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

This commit is contained in:
Abubeker Yasin
2026-07-15 21:33:17 +03:00
140 changed files with 6841 additions and 1433 deletions

View File

@@ -172,7 +172,7 @@ jobs:
run: |
set -euo pipefail
IMAGE_TAG="${COMPOSE_PROJECT_NAME}-${{ matrix.service }}:${GITHUB_SHA::8}"
docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}"
docker compose --project-name "${COMPOSE_PROJECT_NAME}" build "${{ matrix.service }}"
# Tag with git SHA for rollback capability
CONTAINER_NAME=$(docker compose --project-name "${COMPOSE_PROJECT_NAME}" config --services | grep "${{ matrix.service }}" | head -1)
docker tag "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}" "${IMAGE_TAG}" 2>/dev/null || true

View File

@@ -1,14 +1,12 @@
# syntax=docker/dockerfile:1
# Build from monorepo root: docker build -f apps/edr-freight-api/Dockerfile .
#
# The base image (Node + Alpine Chromium/Puppeteer + pnpm) is built and pushed
# separately — see Dockerfile.base. Override the pinned tag at build time with
# --build-arg BASE_IMAGE=registry.license.aafda.gov.et/edr-public/freight-api-base:<tag>
ARG BASE_IMAGE=registry.license.aafda.gov.et/edr-public/freight-api-base:node24-alpine
FROM node:24.15.0-alpine AS base
RUN apk add --no-cache libc6-compat
# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit
# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds.
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
FROM ${BASE_IMAGE} AS base
FROM base AS pruner
COPY . .
@@ -31,8 +29,8 @@ COPY --from=builder /app/ .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
FROM node:24.15.0-alpine AS runner
RUN apk add --no-cache libc6-compat
FROM base AS runner
ENV NODE_ENV=production
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs \

View File

@@ -0,0 +1,41 @@
# syntax=docker/dockerfile:1
# Base image for edr-freight-api — Node + Alpine Chromium/Puppeteer + pnpm.
# Built and pushed separately so app builds pull it from Harbor instead of
# reinstalling the ~system Chromium toolchain on every build.
#
# Build + push (from monorepo root):
# docker build -f apps/edr-freight-api/Dockerfile.base \
# -t registry.license.aafda.gov.et/edr/freight-api-base:node24-alpine .
# docker push registry.license.aafda.gov.et/edr/freight-api-base:node24-alpine
#
# Bump the tag whenever Node, Chromium, or the apk set below changes, then
# update BASE_IMAGE in Dockerfile to match.
FROM node:24.15.0-alpine
# Puppeteer ships a glibc Chrome that cannot run on Alpine; skip the ~150MB
# download at install time. This stage installs Alpine's system Chromium.
ENV PUPPETEER_SKIP_DOWNLOAD=true
# Chromium + fonts for Puppeteer PDF rendering (contract/invoice/receipt docs).
# Without these, Puppeteer fails to launch and the code degrades to an
# unformatted plain-text PDF fallback. Use Alpine's system Chromium (musl-built);
# the glibc Chrome that `puppeteer install` downloads cannot run on Alpine.
RUN apk add --no-cache \
libc6-compat \
chromium \
nss \
freetype \
harfbuzz \
ca-certificates \
ttf-freefont \
font-noto-cjk
ENV NODE_ENV=production
# Point Puppeteer at the system Chromium and skip its bundled download.
ENV PUPPETEER_SKIP_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit
# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds.
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app

View File

@@ -1,7 +1,10 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ContractsRepository } from '../modules/contracts/contracts.repository';
import { Contract } from '../modules/contracts/entities/contract.entity';
import {
Contract,
ContractDocumentSnapshot,
} from '../modules/contracts/entities/contract.entity';
import { ContractRoute } from '../modules/contracts/entities/contract-route.entity';
import {
ContractSignature,
@@ -11,7 +14,10 @@ import { ContractPricingBreakdown } from '../modules/contracts/contract-pricing.
import { ContractTemplatesService } from '../modules/contract-templates/contract-templates.service';
import { ContractTemplateResolver } from './contract-template.resolver';
import { getTemplateMeta } from './contract-template.registry';
import { ContractViewModel } from './contract-view-model.builder';
import {
ContractDynamicTemplateView,
ContractViewModel,
} from './contract-view-model.builder';
/**
* Signature row for the contract PDF. Mirrors the booking builder's
@@ -90,22 +96,36 @@ export class ContractDocumentViewModelBuilder {
contract.contractTemplateKey ?? this.templateResolver.resolve(this.toResolverInput(contract));
let template = getTemplateMeta(templateKey);
// Prefer the admin-editable DB template matching the contract's
// direction/freight pair; fall back to the code-defined generic layout
// when none is active.
const dynamicSource = await this.contractTemplates.findActiveForContract(
contract.tradeDirection,
contract.freightType,
);
const dynamicTemplate = dynamicSource
? {
code: dynamicSource.code,
name: dynamicSource.name,
documentTitle: dynamicSource.documentTitle,
whereasClauses: dynamicSource.whereasClauses ?? [],
articles: dynamicSource.articles ?? [],
}
: undefined;
// The document articles come, in order of preference, from:
// 1. this contract's frozen snapshot (staff accepted / edited it) — the
// shared six templates are never consulted for these contracts;
// 2. the admin-editable DB template matching the direction/freight pair;
// 3. the code-defined generic layout (handled below when none of the above).
const snapshot = contract.documentSnapshot as ContractDocumentSnapshot | null;
let dynamicTemplate: ContractDynamicTemplateView | undefined;
if (snapshot && (snapshot.articles?.length ?? 0) > 0) {
dynamicTemplate = {
code: snapshot.code ?? 'CONTRACT',
name: snapshot.name ?? template.title,
documentTitle: snapshot.documentTitle ?? '',
whereasClauses: snapshot.whereasClauses ?? [],
articles: snapshot.articles,
};
} else {
const dynamicSource = await this.contractTemplates.findActiveForContract(
contract.tradeDirection,
contract.freightType,
);
dynamicTemplate = dynamicSource
? {
code: dynamicSource.code,
name: dynamicSource.name,
documentTitle: dynamicSource.documentTitle,
whereasClauses: dynamicSource.whereasClauses ?? [],
articles: dynamicSource.articles ?? [],
}
: undefined;
}
if (dynamicTemplate) {
template = {
...template,

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Drop the unused reopen-delay knob from the global rules.
*
* The window engine never honoured `reopen_delay_minutes`: a not-yet-full train
* reopens as soon as its payment phase settles, so the real gap between a cycle
* closing and reopening is doc review + payment — nothing else. The per-schedule
* `rule_reopen_delay_minutes` snapshot stays: it freezes that derived gap at
* creation so the batch board keeps projecting the cycles the customer was shown.
*/
export class DropReopenDelayMinutes2190000000000 implements MigrationInterface {
name = "DropReopenDelayMinutes2190000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
DROP COLUMN IF EXISTS reopen_delay_minutes;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
ADD COLUMN IF NOT EXISTS reopen_delay_minutes integer NOT NULL DEFAULT 90;
`);
}
}

View File

@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Every built train owns a fixed pair of run numbers, typed at build time:
* an EXPORT number (odd, e.g. 8001) and an IMPORT number (even, e.g. 8002).
* Scheduling copies the route-direction-matched number onto the schedule at
* creation; legacy trains with a null pair keep dispatch-time pool assignment.
*
* NOTE: the shared dev DB has no applied migration history, so this is also
* hand-applied there. IF NOT EXISTS keeps that idempotent.
*/
export class TrainNumberPair2200000000000 implements MigrationInterface {
name = 'TrainNumberPair2200000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.trains
ADD COLUMN IF NOT EXISTS import_train_number varchar(20),
ADD COLUMN IF NOT EXISTS export_train_number varchar(20);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_import_train_number"
ON freight.trains (import_train_number)
WHERE import_train_number IS NOT NULL;
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_export_train_number"
ON freight.trains (export_train_number)
WHERE export_train_number IS NOT NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_export_train_number";`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_import_train_number";`);
await queryRunner.query(`
ALTER TABLE freight.trains
DROP COLUMN IF EXISTS export_train_number,
DROP COLUMN IF EXISTS import_train_number;
`);
}
}

View File

@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Schedule-scoped wagon pins.
*
* Wagon occupancy now lives ONLY on each schedule's own train_set_wagons slots
* (the per-schedule snapshot): pinning/releasing a wagon no longer mutates the
* Wagon entity, so the same physical wagon can serve many schedules (the July 17
* and July 20 runs of one train both use its 50 wagons). The Wagon columns
* `current_train_schedule_id` / `train_set_wagon_id` keep only their physical
* meaning — "out on this DISPATCHED train right now" (stamped at dispatch,
* cleared at arrive/unload/cancel).
*
* This migration erases the legacy pin-time stamps left by the old flow: any
* wagon pointing at a schedule that is not currently DISPATCHED (or that no
* longer exists) gets its pointers cleared, and — when the old flow had parked
* it in ASSIGNED — its status returns to the pool semantics (ASSIGNED only
* while coupled to a built train, otherwise AVAILABLE).
*/
export class ScheduleScopedWagonPins2210000000000 implements MigrationInterface {
name = "ScheduleScopedWagonPins2210000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.wagons w
SET current_train_schedule_id = NULL,
train_set_wagon_id = NULL,
status = CASE
WHEN w.status = 'ASSIGNED' AND w.train_id IS NULL THEN 'AVAILABLE'
ELSE w.status
END
WHERE w.deleted_at IS NULL
AND w.current_train_schedule_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM freight.train_schedules ts
WHERE ts.id = w.current_train_schedule_id
AND ts.deleted_at IS NULL
AND ts.status = 'DISPATCHED'
);
`);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// Pin-time stamps cannot be reconstructed (the data was the bug); the
// slots on train_set_wagons still hold every live pin, so down is a no-op.
}
}

View File

@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds freight.contracts.document_snapshot — a per-contract frozen copy of the
* contract-document template (articles + WHEREAS recitals) captured at staff
* accept. Staff can edit these articles for a single contract before generating
* its PDF; the edit never touches the shared six freight.contract_templates
* rows. Null on existing contracts → the PDF keeps rendering from the live
* template, so this is backward compatible.
*/
export class AddContractDocumentSnapshot2220000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.contracts
ADD COLUMN IF NOT EXISTS document_snapshot JSONB;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.contracts
DROP COLUMN IF EXISTS document_snapshot;
`);
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Wagon status RETIRED is renamed DETAINED (wagons pulled from circulation).
* The column is a plain varchar, so this is a data-only rename. Vehicles keep
* their own RETIRED status — only freight.wagons rows are touched.
*/
export class RenameWagonStatusRetiredToDetained2230000000000
implements MigrationInterface
{
name = 'RenameWagonStatusRetiredToDetained2230000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.wagons SET status = 'DETAINED' WHERE status = 'RETIRED'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.wagons SET status = 'RETIRED' WHERE status = 'DETAINED'
`);
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Every new wagon-transfer request must state WHY the wagons are needed; the
* reason is shown on the OCC request queue. Nullable in the DB — legacy rows
* predate the requirement; the DTO enforces it for new requests.
*/
export class AddTransferRequestReason2240000000000 implements MigrationInterface {
name = 'AddTransferRequestReason2240000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_transfer_requests
ADD COLUMN IF NOT EXISTS reason text NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_transfer_requests
DROP COLUMN IF EXISTS reason
`);
}
}

View File

@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Approval workflow for priority-rule changes: every create/update/delete of a
* priority config is filed here as a PENDING change request; an approver
* applies or rejects it. `payload` carries the proposed field values (null for
* DELETE), `priority_config_id` the target row (null for CREATE).
*/
export class CreatePriorityRuleChangeRequests2250000000000
implements MigrationInterface
{
name = 'CreatePriorityRuleChangeRequests2250000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.priority_rule_change_requests (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
action varchar(10) NOT NULL,
priority_config_id uuid NULL REFERENCES freight.priority_configs (id),
payload jsonb NULL,
status varchar(10) NOT NULL DEFAULT 'PENDING',
requested_by_user_id uuid NULL,
decided_by_user_id uuid NULL,
decided_at timestamptz NULL,
decision_note text NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_prcr_status
ON freight.priority_rule_change_requests (status)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS freight.priority_rule_change_requests`,
);
}
}

View File

@@ -1003,18 +1003,26 @@ export class BookingTransitionService {
}
// The binding shipment day must have at least one OPEN departure on the
// route — only schedule-backed days are selectable. The batch engine
// assigns the specific train within that (route, day) pool later.
const hasDeparture = await this.bookingsService.hasOpenDepartureOnDay(
booking.originYardId,
booking.destinationYardId,
eatDay(date),
);
// route — only schedule-backed days are selectable — AND some departure
// that day must be able to physically carry this cargo type (wagon-TYPE
// gate; quantity never blocks — oversized bookings get a partial split
// offer). The batch engine assigns the specific train within that
// (route, day) pool later.
const { hasDeparture, hasCompatible } =
await this.bookingsService.checkDayCompatibilityForBooking(
booking,
eatDay(date),
);
if (!hasDeparture) {
throw new BadRequestException(
"No departures available on the selected day for this route",
);
}
if (!hasCompatible) {
throw new BadRequestException(
"No wagon on the selected day can carry this cargo type — please choose another day",
);
}
await this.bookingsRepository.update(bookingId, {
status: "OPERATION_REQUEST_PENDING",

View File

@@ -348,6 +348,28 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/available-days')
@ApiOperation({
summary:
'Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)',
})
async availableDays(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.bookingsService.availableDaysForBooking(id);
}
@Get(':id/mile-summary')
@ApiOperation({
summary: 'First/last-mile operational summary for a booking (customer-safe)',

View File

@@ -653,22 +653,37 @@ export class BookingsService {
} else if (dto.scheduledDate) {
// A real (binding) scheduledDate was supplied (e.g. staff pinning a day
// directly). Require that the route has at least one OPEN departure on
// that EAT day. The booking wizard does NOT send scheduledDate at creation
// — it captures a non-binding estimatedShipmentDate instead, and the
// binding day is chosen later at the operation-request step. General
// contracts also skip this (each drawdown order validates its own day).
// that EAT day AND that some departure that day can physically carry the
// cargo (wagon-TYPE gate — quantity never blocks; oversized bookings get
// a partial split offer later). The booking wizard does NOT send
// scheduledDate at creation — it captures a non-binding
// estimatedShipmentDate instead, and the binding day is chosen later at
// the operation-request step. General contracts also skip this (each
// drawdown order validates its own day).
const day = eatDay(new Date(dto.scheduledDate));
const hasDeparture =
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
const { hasDeparture, hasCompatible } =
await this.trainSchedulingService.checkDayCargoCompatibility(
dto.originYardId,
dto.destinationYardId,
day,
{
freightType: dto.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: dto.cargoTypeId,
containerTypeIds: (dto.containers ?? [])
.map((c) => c.containerTypeId)
.filter((id): id is string => Boolean(id)),
},
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
if (!hasCompatible) {
throw new BadRequestException(
'No wagon on the selected day can carry this cargo type — please choose another day',
);
}
}
const containers = dto.containers ?? [];
@@ -1149,6 +1164,52 @@ export class BookingsService {
);
}
/** Cargo identity of a booking for the wagon-TYPE compatibility gate. */
private cargoIdentityOf(booking: Booking): {
freightType: 'CONTAINER' | 'BULK';
cargoTypeId?: string | null;
containerTypeIds?: string[];
} {
return {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId ?? null,
containerTypeIds: (booking.bookingContainers ?? [])
.map((line) => line.containerTypeId)
.filter((id): id is string => Boolean(id)),
};
}
/**
* Day gate for a specific booking: OPEN departure exists AND some departure
* that day can physically carry the booking's cargo/container type.
* Quantity never blocks — oversized bookings get a partial split offer.
*/
async checkDayCompatibilityForBooking(
booking: Booking,
day: string,
): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> {
return this.trainSchedulingService.checkDayCargoCompatibility(
booking.originYardId,
booking.destinationYardId,
day,
this.cargoIdentityOf(booking),
);
}
/**
* Days the customer may pick for THIS booking (operation-request step):
* cargo-aware — only days whose departures can carry the booking's cargo
* type. Returns days only, no capacity counts.
*/
async availableDaysForBooking(bookingId: string): Promise<{ days: string[] }> {
const booking = await this.findById(bookingId);
return this.trainSchedulingService.getAvailableDaysForCargo({
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
...this.cargoIdentityOf(booking),
});
}
/**
* Batched version of the findById flag: marks each page item whose booking
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal

View File

@@ -86,6 +86,7 @@ export const SCHEDULING_STATUSES = [
SchedulingStatus.Eligible,
SchedulingStatus.Scheduled,
SchedulingStatus.Dispatched,
SchedulingStatus.WaitingForWagon,
] as const;
export type BookingSchedulingStatus = (typeof SCHEDULING_STATUSES)[number];

View File

@@ -4,6 +4,7 @@ import {
Injectable,
Logger,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { Readable } from 'stream';
import { insertWithGeneratedReference } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
@@ -21,16 +22,35 @@ import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.
import { FilesService } from '../files/files.service';
import { SignaturesService } from '../signatures/signatures.service';
import { OtpService } from '../otp/otp.service';
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
import { ContractPricingService } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository';
import { ContractsService } from './contracts.service';
import { contractClearanceSettingCode } from './contract-clearance.util';
import { Contract } from './entities/contract.entity';
import {
Contract,
ContractDocumentArticle,
ContractDocumentSnapshot,
ContractDocumentSnapshotInput,
} from './entities/contract.entity';
import { ContractSignerRole } from './entities/contract-signature.entity';
import { SignContractDto } from './dto/sign-contract.dto';
/** The editable contract-document draft returned for the accept/edit dialog. */
export interface ContractDocumentDraft {
documentTitle: string | null;
whereasClauses: string[];
articles: ContractDocumentArticle[];
code: string | null;
name: string | null;
/** True once the document may no longer be edited/regenerated. */
locked: boolean;
generatedAt: Date | null;
status: string;
}
/**
* Dropdown-settings code holding the admin-configured contract validity options
* (each option's `value` is a day count). The staff accept dialog reads the same
@@ -68,6 +88,7 @@ export class ContractTransitionService {
private readonly minioService: MinioService,
private readonly otpService: OtpService,
private readonly notifier: ContractNotifierService,
private readonly contractTemplates: ContractTemplatesService,
) {}
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
@@ -110,6 +131,7 @@ export class ContractTransitionService {
contractId: string,
actorId: string,
validityDays: number,
documentSnapshot?: ContractDocumentSnapshotInput | null,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['SUBMITTED']);
@@ -128,6 +150,12 @@ export class ContractTransitionService {
await this.instantiateApprovalSteps(contract);
// Freeze the contract document for THIS contract only. Staff may have edited
// the articles in the accept dialog; otherwise the live template is captured
// as-is so later template edits never change an in-flight contract. The
// shared six templates are never written here.
const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot);
await this.contractsRepository.update(contractId, {
status: 'PENDING_APPROVAL',
approvedByStaffId: actorId,
@@ -135,12 +163,148 @@ export class ContractTransitionService {
contractValidityDays: validityDays,
contractValidFrom: validFrom,
contractValidUntil: validUntil,
documentSnapshot: snapshot,
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.accepted(updated);
return updated;
}
// ── Per-contract document snapshot (US: edit articles for one contract) ─────
/**
* The editable document draft for the accept/edit dialog: the frozen snapshot
* if one exists, else the live active template resolved for this contract's
* direction/freight pair. `locked` flips true once the document may no longer
* be edited (an approver has acted, or the contract has left the pre-approval
* window).
*/
async getContractDocumentDraft(
contractId: string,
): Promise<ContractDocumentDraft> {
const contract = await this.contractsService.findById(contractId);
const snapshot =
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
(await this.resolveDocumentSnapshot(contract));
return {
documentTitle: snapshot?.documentTitle ?? null,
whereasClauses: snapshot?.whereasClauses ?? [],
articles: snapshot?.articles ?? [],
code: snapshot?.code ?? null,
name: snapshot?.name ?? null,
locked: !this.documentIsEditable(contract),
generatedAt: contract.contractGeneratedAt ?? null,
status: contract.status,
};
}
/**
* Replace this contract's document articles from the editor. Per-contract
* only — it writes the contract's own snapshot and never the shared templates.
* Allowed while the document is still editable (PENDING_APPROVAL, no approver
* has acted).
*/
async updateContractDocument(
contractId: string,
input: ContractDocumentSnapshotInput,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['PENDING_APPROVAL']);
this.assertDocumentEditable(contract);
const current =
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
(await this.resolveDocumentSnapshot(contract));
const merged: ContractDocumentSnapshotInput = {
code: current?.code ?? null,
name: input.name ?? current?.name ?? null,
documentTitle: input.documentTitle ?? current?.documentTitle ?? null,
whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [],
articles: input.articles ?? current?.articles ?? [],
};
await this.contractsRepository.update(contractId, {
documentSnapshot: this.normalizeSnapshot(merged),
} as never);
return this.contractsService.findById(contractId);
}
/**
* Build the per-contract document snapshot. Prefer the staff's edited articles
* from the dialog; otherwise freeze the active template matching the
* contract's direction/freight. Returns null when no active template exists
* (the renderer then falls back to the built-in generic layout at render time).
*/
private async resolveDocumentSnapshot(
contract: Contract,
provided?: ContractDocumentSnapshotInput | null,
): Promise<ContractDocumentSnapshot | null> {
if (provided && (provided.articles?.length ?? 0) > 0) {
return this.normalizeSnapshot(provided);
}
const active = await this.contractTemplates.findActiveForContract(
contract.tradeDirection,
contract.freightType,
);
if (!active) return null;
return {
code: active.code,
name: active.name,
documentTitle: active.documentTitle,
whereasClauses: active.whereasClauses ?? [],
articles: this.normalizeArticles(active.articles ?? []),
};
}
private normalizeSnapshot(
input: ContractDocumentSnapshotInput,
): ContractDocumentSnapshot {
return {
code: input.code ?? null,
name: input.name ?? null,
documentTitle: input.documentTitle ?? null,
whereasClauses: Array.isArray(input.whereasClauses)
? input.whereasClauses
.map((c) => String(c))
.filter((c) => c.trim().length > 0)
: [],
articles: this.normalizeArticles(input.articles ?? []),
};
}
/** Re-key ids and renumber order sequentially, dropping empty-title rows. */
private normalizeArticles(
articles: Array<{ id?: string; title?: string; body?: string; order?: number }>,
): ContractDocumentArticle[] {
return articles
.filter((a) => (a.title ?? '').trim().length > 0 || (a.body ?? '').trim().length > 0)
.map((a, index) => ({
id: a.id ?? randomUUID(),
title: (a.title ?? '').trim(),
body: a.body ?? '',
order: index + 1,
}));
}
/**
* The per-contract document may be edited/regenerated while the contract is at
* the accept stage (SUBMITTED) or in approval with NO approver having acted
* yet. The first approval action freezes it.
*/
private documentIsEditable(contract: Contract): boolean {
if (contract.status === 'SUBMITTED') return true;
if (contract.status !== 'PENDING_APPROVAL') return false;
return !(contract.approvalSteps ?? []).some((s) => s.status !== 'PENDING');
}
private assertDocumentEditable(contract: Contract): void {
if (!this.documentIsEditable(contract)) {
throw new ConflictException(
'The contract document is locked — an approver has already acted or the ' +
'contract has advanced. It can no longer be edited or regenerated.',
);
}
}
/**
* Ensure the chosen validity (days) is one of the admin-configured options in
* the `contract_validity_periods` dropdown setting. If the setting is missing
@@ -303,6 +467,15 @@ export class ContractTransitionService {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
// Approvers review the generated contract document, so it must exist before
// the first approval can be recorded. Staff generate it (from the frozen,
// optionally-edited snapshot) at the accept stage.
if (contract.status === 'PENDING_APPROVAL' && !contract.contractGeneratedAt) {
throw new BadRequestException(
'Generate the contract document before it can be approved.',
);
}
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
if (!step || step.status !== 'PENDING') {
throw new BadRequestException('Approval step not found or already actioned');
@@ -350,15 +523,14 @@ export class ContractTransitionService {
const updated = await this.contractsService.findById(contractId);
if (allDone) {
this.notifier.approved(updated);
// Final approval step also generates the contract document from the
// template matching the contract's direction/freight pair. Best-effort:
// a rendering hiccup must not roll back the approval — the document can
// still be generated manually or lazily on view/download.
// Every step approved → CONTRACT_READY. The document was already generated
// (and reviewed) at the accept stage, so we reuse it rather than
// re-rendering. Best-effort: a hiccup must not roll back the approval.
try {
return await this.generateContract(contractId);
return await this.finalizeApprovedContract(contractId);
} catch (err) {
this.logger.warn(
`Auto contract generation after final approval failed for ${updated.reference}: ${err}`,
`Finalizing contract after final approval failed for ${updated.reference}: ${err}`,
);
}
}
@@ -366,30 +538,66 @@ export class ContractTransitionService {
}
/**
* Render the contract PDF from the Contract aggregate, store it via FilesService,
* stamp the template key, and move to CONTRACT_READY. PDF rendering (Puppeteer/
* Chromium) is best-effort and must NOT block the contract from becoming ready —
* the document is (re)rendered lazily on view/download once Chromium is available.
* Staff (re)generate the contract PDF. Two stages:
* - PENDING_APPROVAL: render from the frozen (optionally staff-edited)
* snapshot so approvers review the real document. Status is UNCHANGED, and
* it is blocked once an approver has acted (the document is then locked).
* - APPROVED / APPROVED_PENDING_SIGNATURE (fallback): render and advance to
* CONTRACT_READY.
* PDF rendering (Puppeteer/Chromium) is best-effort and never blocks the
* transition — the document re-renders lazily on view/download.
*/
async generateContract(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (contract.status === 'PENDING_APPROVAL') {
this.assertDocumentEditable(contract);
await this.renderContractDocument(contract);
return this.contractsService.findById(contractId);
}
assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']);
await this.renderContractDocument(contract);
await this.contractsRepository.update(contractId, {
status: 'CONTRACT_READY',
} as never);
return this.contractsService.findById(contractId);
}
const { view } = await this.documentViewModelBuilder.build(contractId);
/**
* Render the contract PDF from the Contract aggregate (snapshot-driven), store
* it via FilesService, and stamp the template key + generated timestamp. Never
* changes status. Rendering is best-effort — a Chromium hiccup defers the file
* (it re-renders on view/download) but the timestamp is still stamped.
*/
private async renderContractDocument(contract: Contract): Promise<void> {
const { view } = await this.documentViewModelBuilder.build(contract.id);
try {
await this.upsertContractPdf(contractId, contract.reference, view);
await this.upsertContractPdf(contract.id, contract.reference, view);
} catch (err) {
this.logger.warn(
`Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
);
}
await this.contractsRepository.update(contractId, {
status: 'CONTRACT_READY',
await this.contractsRepository.update(contract.id, {
contractTemplateKey: view.templateKey,
contractGeneratedAt: new Date(),
} as never);
}
/**
* Every approval step landed → CONTRACT_READY. The document was already
* generated (and reviewed) at the accept stage, so reuse it; render now only
* if it was somehow never generated. Never re-renders over an existing file.
*/
private async finalizeApprovedContract(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (!contract.contractGeneratedAt) {
await this.renderContractDocument(contract);
}
await this.contractsRepository.update(contractId, {
status: 'CONTRACT_READY',
} as never);
return this.contractsService.findById(contractId);
}

View File

@@ -8,6 +8,7 @@ import {
ParseUUIDPipe,
Patch,
Post,
Put,
Query,
Res,
UnauthorizedException,
@@ -57,6 +58,7 @@ import { UpdateContractDto } from './dto/update-contract.dto';
import { FilterContractDto } from './dto/filter-contract.dto';
import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
import { AcceptContractDto } from './dto/accept-contract.dto';
import { UpdateContractDocumentDto } from './dto/contract-document.dto';
import {
ApproveStepDto,
RejectContractDto,
@@ -340,9 +342,33 @@ export class ContractsController {
id,
resolveAuthUserId(user),
dto.validityDays,
dto.documentSnapshot,
);
}
@Get(':id/document/draft')
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
@ApiOperation({
summary:
'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
})
getContractDocumentDraft(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.getContractDocumentDraft(id);
}
@Put(':id/document/articles')
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
@ApiOperation({
summary:
'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
})
updateContractDocument(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateContractDocumentDto,
) {
return this.transitionService.updateContractDocument(id, dto);
}
@Post(':id/staff/request-changes')
@BookingStaff(FREIGHT_PERMS.contracts.requestChanges)
@ApiOperation({ summary: 'Staff return contract for customer updates' })

View File

@@ -1,5 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsInt, Max, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min, ValidateNested } from 'class-validator';
import { UpdateContractDocumentDto } from './contract-document.dto';
export class AcceptContractDto {
@ApiProperty({
@@ -14,4 +17,16 @@ export class AcceptContractDto {
@Min(1)
@Max(3650)
validityDays!: number;
/**
* Optional per-contract document override edited by staff in the accept
* dialog. When present its articles are frozen onto THIS contract; when
* omitted the live template is snapshotted as-is. Never edits the shared
* six templates.
*/
@ApiPropertyOptional({ type: UpdateContractDocumentDto })
@IsOptional()
@ValidateNested()
@Type(() => UpdateContractDocumentDto)
documentSnapshot?: UpdateContractDocumentDto;
}

View File

@@ -0,0 +1,64 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsArray,
IsInt,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
/** One article of a per-contract document override sent from the editor. */
export class ContractDocumentArticleDto {
@ApiPropertyOptional({ description: 'Stable id; omitted for a new article.' })
@IsOptional()
@IsString()
id?: string;
@ApiProperty()
@IsString()
title!: string;
@ApiProperty({ description: 'Plain multiline body; each line becomes a clause.' })
@IsString()
body!: string;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
order?: number;
}
/**
* The per-contract document override sent from the accept/edit editor. It edits
* ONLY this contract's frozen snapshot — it is never written back to the shared
* six {@link ContractTemplate} rows.
*/
export class UpdateContractDocumentDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
code?: string | null;
@ApiPropertyOptional()
@IsOptional()
@IsString()
name?: string | null;
@ApiPropertyOptional()
@IsOptional()
@IsString()
documentTitle?: string | null;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
whereasClauses?: string[];
@ApiProperty({ type: [ContractDocumentArticleDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => ContractDocumentArticleDto)
articles!: ContractDocumentArticleDto[];
}

View File

@@ -42,6 +42,43 @@ export const CONTRACT_STATUSES = [
export type ContractStatus = (typeof CONTRACT_STATUSES)[number];
/** One article on a per-contract document snapshot (mirrors the template shape). */
export interface ContractDocumentArticle {
id: string;
title: string;
body: string;
order: number;
}
/**
* A per-contract copy of the resolved contract-document template, frozen when
* staff accept the contract for approval. Staff may edit these articles for a
* single contract in the accept/edit dialog — editing NEVER writes back to the
* shared six {@link ContractTemplate} rows. The PDF is rendered from this
* snapshot when present; a null snapshot renders from the live template.
*/
export interface ContractDocumentSnapshot {
code?: string | null;
name?: string | null;
documentTitle?: string | null;
whereasClauses: string[];
articles: ContractDocumentArticle[];
}
/** Loose inbound shape (article ids/order optional) — normalized before store. */
export interface ContractDocumentSnapshotInput {
code?: string | null;
name?: string | null;
documentTitle?: string | null;
whereasClauses?: string[];
articles?: Array<{
id?: string;
title?: string;
body?: string;
order?: number;
}>;
}
export const CONTRACT_KINDS = ['ONE_TIME', 'GENERAL'] as const;
export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
@@ -193,6 +230,14 @@ export class Contract extends BaseEntity {
@Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
contractGeneratedAt?: Date | null;
/**
* Per-contract frozen copy of the document template (articles + WHEREAS),
* captured at staff accept. Editing it affects only this contract, never the
* shared six templates. Null → the PDF renders from the live template.
*/
@Column({ name: 'document_snapshot', type: 'jsonb', nullable: true })
documentSnapshot?: ContractDocumentSnapshot | null;
@Column({ name: 'contract_summary', type: 'text', nullable: true })
contractSummary?: string | null;

View File

@@ -0,0 +1,72 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import {
DecidePriorityRuleChangeDto,
SubmitPriorityRuleChangeDto,
} from '../dto/priority-rule-change-request.dto';
import { PriorityRuleChangeStatus } from '../entities/priority-rule-change-request.entity';
import { PriorityRuleChangeRequestsService } from '../services/priority-rule-change-requests.service';
/**
* Approval workflow for priority-rule changes. Anyone with the manage
* permission SUBMITS a change; an approver (same permission — the team decides
* who reviews) approves or rejects it. The team is notified at each step.
*/
@ApiTags('priority-rule-change-requests')
@Controller('priority-rule-change-requests')
@ApiBearerAuth()
export class PriorityRuleChangeRequestsController {
constructor(private readonly service: PriorityRuleChangeRequestsService) {}
@Post()
@RuleEngineManage('priority-configs')
@ApiOperation({ summary: 'Submit a priority-rule change for approval' })
submit(
@Body() dto: SubmitPriorityRuleChangeDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.submit(dto, user?.id);
}
@Get()
@RuleEngineView('priority-configs')
@ApiQuery({ name: 'status', required: false, enum: ['PENDING', 'APPROVED', 'REJECTED'] })
@ApiOperation({ summary: 'List priority-rule change requests' })
list(@Query('status') status?: PriorityRuleChangeStatus) {
return this.service.list(status);
}
@Post(':id/approve')
@RuleEngineManage('priority-configs')
@ApiOperation({ summary: 'Approve and apply a pending change' })
approve(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DecidePriorityRuleChangeDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.approve(id, user?.id, dto.decisionNote);
}
@Post(':id/reject')
@RuleEngineManage('priority-configs')
@ApiOperation({ summary: 'Reject a pending change' })
reject(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DecidePriorityRuleChangeDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.reject(id, user?.id, dto.decisionNote);
}
}

View File

@@ -0,0 +1,49 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsIn,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from 'class-validator';
import { CreatePriorityConfigDto } from './create-priority-config.dto';
import { UpdatePriorityConfigDto } from './update-priority-config.dto';
/**
* File a priority-rule change for approval. CREATE carries a full `create`
* payload; UPDATE carries the target id + an `update` patch; DELETE carries
* only the target id.
*/
export class SubmitPriorityRuleChangeDto {
@ApiProperty({ enum: ['CREATE', 'UPDATE', 'DELETE'] })
@IsIn(['CREATE', 'UPDATE', 'DELETE'])
action!: 'CREATE' | 'UPDATE' | 'DELETE';
@ApiPropertyOptional({ description: 'Target rule id (UPDATE / DELETE)' })
@IsOptional()
@IsUUID()
priorityConfigId?: string;
@ApiPropertyOptional({ description: 'Proposed new rule (CREATE)' })
@IsOptional()
@ValidateNested()
@Type(() => CreatePriorityConfigDto)
create?: CreatePriorityConfigDto;
@ApiPropertyOptional({ description: 'Proposed field changes (UPDATE)' })
@IsOptional()
@ValidateNested()
@Type(() => UpdatePriorityConfigDto)
update?: UpdatePriorityConfigDto;
}
export class DecidePriorityRuleChangeDto {
@ApiPropertyOptional({ description: 'Optional note shown to the requester' })
@IsOptional()
@IsString()
@MaxLength(1000)
decisionNote?: string;
}

View File

@@ -0,0 +1,46 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { PriorityConfig } from './priority-config.entity';
export type PriorityRuleChangeAction = 'CREATE' | 'UPDATE' | 'DELETE';
export type PriorityRuleChangeStatus = 'PENDING' | 'APPROVED' | 'REJECTED';
/**
* One proposed change to a priority rule, awaiting approval. Every
* create/update/delete of a priority config is filed here first; an approver
* applies (which runs the real mutation, including range-collision checks) or
* rejects it. `payload` holds the proposed field values (null for DELETE);
* `priorityConfigId` the target rule (null for CREATE).
*/
@Entity({ schema: 'freight', name: 'priority_rule_change_requests' })
@Index(['status'])
export class PriorityRuleChangeRequest extends BaseEntity {
@Column({ name: 'action', type: 'varchar', length: 10 })
action!: PriorityRuleChangeAction;
@Column({ name: 'priority_config_id', type: 'uuid', nullable: true })
priorityConfigId?: string | null;
@ManyToOne(() => PriorityConfig, { nullable: true })
@JoinColumn({ name: 'priority_config_id' })
priorityConfig?: PriorityConfig | null;
@Column({ name: 'payload', type: 'jsonb', nullable: true })
payload?: Record<string, unknown> | null;
@Column({ name: 'status', type: 'varchar', length: 10, default: 'PENDING' })
status!: PriorityRuleChangeStatus;
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
requestedByUserId?: string | null;
@Column({ name: 'decided_by_user_id', type: 'uuid', nullable: true })
decidedByUserId?: string | null;
@Column({ name: 'decided_at', type: 'timestamptz', nullable: true })
decidedAt?: Date | null;
@Column({ name: 'decision_note', type: 'text', nullable: true })
decisionNote?: string | null;
}

View File

@@ -5,6 +5,7 @@ import { ApprovalRulesController } from './controllers/approval-rules.controller
import { CargoTypesController } from './controllers/cargo-types.controller';
import { ContainerTypesController } from './controllers/container-types.controller';
import { PriorityConfigsController } from './controllers/priority-configs.controller';
import { PriorityRuleChangeRequestsController } from './controllers/priority-rule-change-requests.controller';
import { RatesController } from './controllers/rates.controller';
import { ServiceTypesController } from './controllers/service-types.controller';
import { ShippingLinesController } from './controllers/shipping-lines.controller';
@@ -15,6 +16,7 @@ import { ApprovalRule } from './entities/approval-rule.entity';
import { CargoType } from './entities/cargo-type.entity';
import { ContainerType } from './entities/container-type.entity';
import { PriorityConfig } from './entities/priority-config.entity';
import { PriorityRuleChangeRequest } from './entities/priority-rule-change-request.entity';
import { Rate } from './entities/rate.entity';
import { ServiceType } from './entities/service-type.entity';
import { ShippingLine } from './entities/shipping-line.entity';
@@ -46,6 +48,7 @@ import { DisplayOrderService } from './services/display-order.service';
import { CargoTypesService } from './services/cargo-types.service';
import { ContainerTypesService } from './services/container-types.service';
import { PriorityConfigsService } from './services/priority-configs.service';
import { PriorityRuleChangeRequestsService } from './services/priority-rule-change-requests.service';
import { RatesService } from './services/rates.service';
import { ServiceTypesService } from './services/service-types.service';
import { ShippingLinesService } from './services/shipping-lines.service';
@@ -54,6 +57,8 @@ import { YardsService } from './services/yards.service';
import { RuleEngineService } from './rule-engine.service';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
@@ -66,6 +71,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
CargoType,
ContainerType,
PriorityConfig,
PriorityRuleChangeRequest,
ServiceType,
WeightLimitRule,
Yard,
@@ -77,11 +83,14 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
BookingApprovalStep,
BookingRateSnapshot,
]),
// Team notifications for the priority-rule approval workflow.
NotificationInboxModule,
],
controllers: [
CargoTypesController,
ContainerTypesController,
PriorityConfigsController,
PriorityRuleChangeRequestsController,
ServiceTypesController,
WeightLimitRulesController,
YardsController,
@@ -111,6 +120,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
CargoTypesService,
ContainerTypesService,
PriorityConfigsService,
PriorityRuleChangeRequestsService,
ServiceTypesService,
WeightLimitRulesService,
YardsService,

View File

@@ -31,6 +31,12 @@ export class PriorityConfigsService {
async create(dto: CreatePriorityConfigDto): Promise<PriorityConfig> {
this.validateCurrencyField(dto.type, dto.currency);
await this.assertNoRangeCollision({
type: dto.type,
currency: dto.currency ?? null,
minWagonCount: dto.minWagonCount,
maxWagonCount: dto.maxWagonCount,
});
const displayOrder = await this.displayOrder.resolveCreateOrder(PriorityConfig, 'displayOrder', {});
@@ -52,6 +58,13 @@ export class PriorityConfigsService {
const type = dto.type ?? existing.type;
const currency = dto.currency !== undefined ? dto.currency : existing.currency;
this.validateCurrencyField(type, currency);
await this.assertNoRangeCollision({
type,
currency: currency ?? null,
minWagonCount: dto.minWagonCount ?? existing.minWagonCount,
maxWagonCount: dto.maxWagonCount ?? existing.maxWagonCount,
excludeId: id,
});
const { ...patch } = dto;
const updated = await this.repository.update(id, patch);
@@ -59,6 +72,43 @@ export class PriorityConfigsService {
return updated;
}
/**
* No two rules of the same type (and, for CURRENCY rules, the same currency)
* may cover overlapping wagon-count ranges — a booking must match at most one
* rule per type. Rejects an exact duplicate (15 vs 15) and any partial
* overlap (15 vs 47). Ranges are inclusive on both ends.
*/
async assertNoRangeCollision(input: {
type: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
currency?: string | null;
minWagonCount: number;
maxWagonCount: number;
excludeId?: string;
}): Promise<void> {
if (input.minWagonCount > input.maxWagonCount) {
throw new BadRequestException(
'Min wagon count cannot be greater than max wagon count',
);
}
const siblings = await this.repository.findAll({
where: { type: input.type },
});
const clash = siblings.find(
(s) =>
s.id !== input.excludeId &&
(input.type !== 'CURRENCY' || (s.currency ?? null) === (input.currency ?? null)) &&
input.minWagonCount <= s.maxWagonCount &&
input.maxWagonCount >= s.minWagonCount,
);
if (clash) {
throw new BadRequestException(
`Wagon range ${input.minWagonCount}${input.maxWagonCount} overlaps existing rule ` +
`"${clash.label}" (${clash.minWagonCount}${clash.maxWagonCount}). ` +
'Adjust the range so rules do not collide.',
);
}
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);

View File

@@ -0,0 +1,226 @@
import {
NotificationAudience,
NotificationType,
} from '@edr/types';
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { NotificationInboxService } from '../../notification-inbox/notification-inbox.service';
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
import { SubmitPriorityRuleChangeDto } from '../dto/priority-rule-change-request.dto';
import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto';
import {
PriorityRuleChangeRequest,
PriorityRuleChangeStatus,
} from '../entities/priority-rule-change-request.entity';
import { PriorityConfigsService } from './priority-configs.service';
/** Backoffice rule-engine page — where both queue and rules live. */
const RULES_LINK = '/dashboard/rules/priority-configs';
/**
* Approval workflow for priority-rule changes. Nobody mutates priority configs
* directly any more: a change is SUBMITTED here (validated up front so the
* requester gets immediate feedback on range collisions), the team is
* notified, and an approver later applies or rejects it. Applying re-runs the
* full validation — the winning state is whatever is true at approval time.
*/
@Injectable()
export class PriorityRuleChangeRequestsService {
private readonly logger = new Logger(PriorityRuleChangeRequestsService.name);
constructor(
@InjectRepository(PriorityRuleChangeRequest)
private readonly repo: Repository<PriorityRuleChangeRequest>,
private readonly configs: PriorityConfigsService,
private readonly inbox: NotificationInboxService,
) {}
async submit(
dto: SubmitPriorityRuleChangeDto,
userId?: string | null,
): Promise<PriorityRuleChangeRequest> {
const payload = await this.validateSubmission(dto);
const request = await this.repo.save(
this.repo.create({
action: dto.action,
priorityConfigId: dto.priorityConfigId ?? null,
payload,
status: 'PENDING',
requestedByUserId: userId ?? null,
}),
);
this.notifyTeam(
'Priority rule change submitted',
`A ${dto.action.toLowerCase()} of a priority rule was submitted and awaits approval.`,
request,
);
return request;
}
async list(status?: PriorityRuleChangeStatus): Promise<PriorityRuleChangeRequest[]> {
return this.repo.find({
where: status ? { status } : {},
relations: { priorityConfig: true },
order: { createdAt: 'DESC' },
});
}
async approve(
id: string,
userId?: string | null,
decisionNote?: string,
): Promise<PriorityRuleChangeRequest> {
const request = await this.findPending(id);
// Apply the change through the normal service so currency + range-collision
// validation runs against the CURRENT rules; a stale request that now
// collides fails here and stays PENDING for the approver to see the error.
if (request.action === 'CREATE') {
await this.configs.create(request.payload as unknown as CreatePriorityConfigDto);
} else if (request.action === 'UPDATE') {
await this.configs.update(
this.requireTarget(request),
request.payload as unknown as UpdatePriorityConfigDto,
);
} else {
await this.configs.remove(this.requireTarget(request));
}
request.status = 'APPROVED';
request.decidedByUserId = userId ?? null;
request.decidedAt = new Date();
request.decisionNote = decisionNote ?? null;
const saved = await this.repo.save(request);
this.notifyTeam(
'Priority rule change approved',
`The ${request.action.toLowerCase()} priority-rule change was approved and applied.` +
(decisionNote ? ` Note: ${decisionNote}` : ''),
saved,
);
return saved;
}
async reject(
id: string,
userId?: string | null,
decisionNote?: string,
): Promise<PriorityRuleChangeRequest> {
const request = await this.findPending(id);
request.status = 'REJECTED';
request.decidedByUserId = userId ?? null;
request.decidedAt = new Date();
request.decisionNote = decisionNote ?? null;
const saved = await this.repo.save(request);
this.notifyTeam(
'Priority rule change rejected',
`The ${request.action.toLowerCase()} priority-rule change was rejected.` +
(decisionNote ? ` Note: ${decisionNote}` : ''),
saved,
);
return saved;
}
/**
* Validate a submission the way applying it would, so bad requests are
* refused at the door — most importantly the wagon-range collision rule.
* Returns the payload to persist.
*/
private async validateSubmission(
dto: SubmitPriorityRuleChangeDto,
): Promise<Record<string, unknown> | null> {
if (dto.action === 'CREATE') {
if (!dto.create) {
throw new BadRequestException('CREATE requires the proposed rule in `create`');
}
await this.configs.assertNoRangeCollision({
type: dto.create.type,
currency: dto.create.currency ?? null,
minWagonCount: dto.create.minWagonCount,
maxWagonCount: dto.create.maxWagonCount,
});
return { ...dto.create };
}
if (!dto.priorityConfigId) {
throw new BadRequestException(`${dto.action} requires priorityConfigId`);
}
const existing = await this.configs.findById(dto.priorityConfigId);
if (dto.action === 'DELETE') return null;
if (!dto.update || Object.keys(dto.update).length === 0) {
throw new BadRequestException('UPDATE requires the field changes in `update`');
}
await this.configs.assertNoRangeCollision({
type: dto.update.type ?? existing.type,
currency:
dto.update.currency !== undefined ? dto.update.currency : existing.currency,
minWagonCount: dto.update.minWagonCount ?? existing.minWagonCount,
maxWagonCount: dto.update.maxWagonCount ?? existing.maxWagonCount,
excludeId: existing.id,
});
return { ...dto.update };
}
private async findPending(id: string): Promise<PriorityRuleChangeRequest> {
const request = await this.repo.findOne({
where: { id },
relations: { priorityConfig: true },
});
if (!request) throw new NotFoundException(`Change request ${id} not found`);
if (request.status !== 'PENDING') {
throw new ConflictException(
`Change request is already ${request.status.toLowerCase()}`,
);
}
return request;
}
private requireTarget(request: PriorityRuleChangeRequest): string {
if (!request.priorityConfigId) {
throw new BadRequestException(
`${request.action} change request has no target rule`,
);
}
return request.priorityConfigId;
}
/**
* In-app notification to the whole backoffice team (submission AND decision
* both notify the team; the requester is staff, so they are included).
* Fire-and-forget — a notification failure never blocks the workflow.
*/
private notifyTeam(
title: string,
body: string,
request: PriorityRuleChangeRequest,
): void {
void this.inbox
.notify({
recipients: { allBackoffice: true },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED,
title,
body,
link: RULES_LINK,
data: { priorityRuleChangeRequestId: request.id, action: request.action },
})
.catch((err) =>
this.logger.warn(
`Priority-rule notification failed: ${(err as Error).message}`,
),
);
}
}

View File

@@ -109,17 +109,26 @@ describe('computeImportWindowTimes — first-window open respects office hours',
});
it('caps the close at departure', () => {
// Opens now (05 Jul 12:00 EAT); a 24h duration would close 06 Jul 12:00 EAT,
// past the 06 Jul 08:00 departure → clamped to departure.
// Round-the-clock desk (no desk-close cap in play). Opens now (05 Jul 12:00
// EAT); a 24h duration would close 06 Jul 12:00 EAT, past the 06 Jul 08:00
// departure → clamped to departure.
const now = new Date('2026-07-05T09:00:00.000Z');
const { windowClosesAt } = computeImportWindowTimes(
departure,
{ ...bounded, windowDurationHours: 24 },
{ ...bounded, windowOpenHour: 8, windowCloseHour: 8, windowDurationHours: 24 },
now,
);
expect(windowClosesAt.toISOString()).toBe(departure.toISOString());
});
it('desk close hour cuts the window short (duration never outlives the desk)', () => {
// Opens now (05 Jul 12:00 EAT); the 15h duration would run to 03:00 next
// day, but the desk shuts 17:00 EAT (14:00 UTC) → the window closes with it.
const now = new Date('2026-07-05T09:00:00.000Z');
const { windowClosesAt } = computeImportWindowTimes(departure, bounded, now);
expect(windowClosesAt.toISOString()).toBe('2026-07-05T14:00:00.000Z');
});
describe('overnight desk (open > close, wraps past midnight)', () => {
// Desk open 08:00, closes 05:00 next morning — open across midnight.
const overnight = { ...bounded, windowOpenHour: 8, windowCloseHour: 5 };
@@ -189,13 +198,13 @@ describe('computeImportWindowTimes — overnight desk (open > close, wraps midni
describe('batch-window board windows (config-driven booking cycles)', () => {
// Default rules: open 08:00 EAT, desk shuts 17:00, 3 days before departure,
// 3h long, reopen 90m later.
// 3h long, reopen gap (doc review + payment) 90m.
const cfg: BoardWindowConfig = {
importWindowLeadDays: 3,
windowOpenHour: 8,
windowCloseHour: 17,
windowDurationHours: 3,
reopenDelayMinutes: 90,
reopenGapMinutes: 90,
exportBookingLeadHours: 24,
};
@@ -211,7 +220,7 @@ describe('batch-window board windows (config-driven booking cycles)', () => {
expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z');
});
it('import: reopens reopenDelayMinutes after close while inside office hours', () => {
it('import: reopens after the doc-review + payment gap while inside office hours', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listConfigBookingWindows('IMPORT', departure, cfg);
// cycle 1: 08:0011:00; reopen +90m → cycle 2 opens 12:30 EAT, same day
@@ -250,6 +259,17 @@ describe('batch-window board windows (config-driven booking cycles)', () => {
expect(new Set(windows.map((w) => w.date)).size).toBeGreaterThanOrEqual(3);
});
it('import: desk close hour cuts a cycle short (duration past 17:00 clamps)', () => {
const longCfg: BoardWindowConfig = { ...cfg, windowDurationHours: 10 };
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listConfigBookingWindows('IMPORT', departure, longCfg);
// Cycle 1 opens 08:00 EAT; 10h would close 18:00 — desk shuts 17:00 (14:00 UTC).
expect(windows[0].start.toISOString()).toBe('2026-06-05T05:00:00.000Z');
expect(windows[0].end.toISOString()).toBe('2026-06-05T14:00:00.000Z');
// Reopen 90m after the clamped close lands past 17:00 → next morning 08:00 EAT.
expect(windows[1].start.toISOString()).toBe('2026-06-06T05:00:00.000Z');
});
it('export: single FCFS window exportBookingLeadHours before departure', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listConfigBookingWindows('EXPORT', departure, cfg);

View File

@@ -223,6 +223,49 @@ export function nextCycleOpensAt(
return opensAt.getTime() < departure.getTime() ? opensAt : null;
}
/**
* The desk-close instant of the office window containing `opensAt`; null for a
* round-the-clock desk. Same-day desk (open < close): closeHour on `opensAt`'s
* EAT day. Overnight desk (open > close): closeHour on the NEXT EAT day when
* `opensAt` sits in the evening half, closeHour the same day when it sits in the
* after-midnight half.
*/
export function officeCloseAfter(opensAt: Date, hours: OfficeHours): Date | null {
if (isRoundTheClock(hours)) return null;
const { hour, minute } = eatParts(opensAt);
const openMinutes = hour * 60 + minute;
if (
hours.windowOpenHour > hours.windowCloseHour &&
openMinutes >= hours.windowOpenHour * 60
) {
return eatDayToUtc(shiftEatDay(eatDay(opensAt), 1), hours.windowCloseHour);
}
return eatDayToUtc(eatDay(opensAt), hours.windowCloseHour);
}
/**
* Cap a window close at the desk-close hour that follows its open: the office
* hours end a running window early rather than letting the duration outlive the
* desk (open 16:00, 3h duration, desk 817 → closes 17:00, not 19:00). A
* round-the-clock desk never caps; a desk-close at/before the open (degenerate
* config) is ignored so the window is never clamped to zero length here.
*/
export function clampCloseToOfficeHours(
opensAt: Date,
closesAt: Date,
hours: OfficeHours,
): Date {
const deskClose = officeCloseAfter(opensAt, hours);
if (
deskClose != null &&
deskClose.getTime() > opensAt.getTime() &&
closesAt.getTime() > deskClose.getTime()
) {
return deskClose;
}
return closesAt;
}
export interface InitialWindowTimes {
windowOpensAt: Date;
windowClosesAt: Date;
@@ -243,7 +286,8 @@ export interface InitialWindowTimes {
* • `now` before openHour that EAT day → opens at openHour that morning
* • `now` at/after closeHour → desk shut; opens openHour next morning
*
* `windowDurationHours` extends from that open, capped at departure.
* `windowDurationHours` extends from that open, capped at the desk close hour
* and at departure.
*/
export function computeImportWindowTimes(
departure: Date,
@@ -276,6 +320,10 @@ export function computeImportWindowTimes(
}
let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000);
closesAt = clampCloseToOfficeHours(opensAt, closesAt, {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
});
if (closesAt.getTime() > departure.getTime()) {
closesAt = departure;
}
@@ -381,10 +429,11 @@ export function listBatchWindowsForBookings(
// ---------------------------------------------------------------------------
// Board-display windows: the REAL booking-window cycles derived from the
// train_scheduling_global_rules config (window open hour, lead days, duration,
// reopen delay) — NOT a fixed clock grid. Import shows each booking-window cycle
// (opens at windowOpenHour EAT, lasts windowDurationHours, reopens after
// reopenDelayMinutes until departure). Export shows the single FCFS lead window.
// schedule's frozen window rule (open/close hour, lead days, duration, reopen
// gap = doc review + payment) — NOT a fixed clock grid. Import shows each
// booking-window cycle (opens at windowOpenHour EAT, lasts windowDurationHours
// capped at the desk close, reopens after the gap until departure). Export shows
// the single FCFS lead window.
// ---------------------------------------------------------------------------
/** A board window carries an EAT calendar date in addition to the slot times. */
@@ -402,8 +451,11 @@ export interface BoardWindowConfig {
/** EAT hour the daily booking desk shuts; equals windowOpenHour for a 24h desk. */
windowCloseHour: number;
windowDurationHours: number;
/** Gap between a cycle's close and its reopen (doc review + payment minutes). */
reopenDelayMinutes: number;
/**
* Gap between a cycle's close and its reopen — always doc review + payment
* minutes (the schedule's frozen snapshot, or the live sum for legacy rows).
*/
reopenGapMinutes: number;
exportBookingLeadHours: number;
}
@@ -435,10 +487,11 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
* The real booking-window cycles for a schedule, straight from config.
*
* IMPORT: first window opens at `windowOpenHour` EAT on `departure importWindowLeadDays`
* for `windowDurationHours`; if the train isn't full it reopens `reopenDelayMinutes`
* after each close, on the same booking day, until departure. This mirrors
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
* exact windows the engine runs.
* for `windowDurationHours` (cut short by the desk close hour); if the train isn't
* full it reopens `reopenGapMinutes` (doc review + payment) after each close,
* honouring office hours, until departure. This mirrors `computeImportWindowTimes`
* + `concludeCycle`'s reopen math so the board shows the exact windows the engine
* runs.
* EXPORT: a single FCFS window from `departure exportBookingLeadHours` to departure,
* with the open shifted to the next desk opening when it lands outside office hours
* (same math as `computeExportWindowTimes`).
@@ -464,7 +517,7 @@ export function listConfigBookingWindows(
const durationMs = cfg.windowDurationHours * 3_600_000;
// Post-close gap before the next cycle opens (doc review + payment), subject
// to office hours below.
const reopenMs = cfg.reopenDelayMinutes * 60_000;
const reopenMs = cfg.reopenGapMinutes * 60_000;
const officeHours: OfficeHours = {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
@@ -484,6 +537,7 @@ export function listConfigBookingWindows(
for (let cycle = 0; cycle < maxCycles; cycle += 1) {
if (opensAt.getTime() >= departure.getTime()) break;
let closesAt = new Date(opensAt.getTime() + durationMs);
closesAt = clampCloseToOfficeHours(opensAt, closesAt, officeHours);
if (closesAt.getTime() > departure.getTime()) closesAt = departure;
windows.push(boardWindowFromInterval(opensAt, closesAt));

View File

@@ -37,6 +37,7 @@ describe('BookingBatchService — PAID reconcile', () => {
};
let trainSchedulingService: {
tryAutoWagonAllocation: jest.Mock;
previewPaidBookingWagonShortage: jest.Mock;
getBookableSchedules: jest.Mock;
getWindowConfig: jest.Mock;
};
@@ -87,6 +88,8 @@ describe('BookingBatchService — PAID reconcile', () => {
issues: [],
violations: [],
}),
// No shortage by default — paid bookings link as before.
previewPaidBookingWagonShortage: jest.fn().mockResolvedValue(null),
getBookableSchedules: jest.fn().mockResolvedValue([]),
getWindowConfig: jest.fn().mockResolvedValue({
importWindowLeadDays: 3,
@@ -96,7 +99,6 @@ describe('BookingBatchService — PAID reconcile', () => {
windowDurationHours: 3,
docReviewMinutes: 30,
paymentWindowMinutes: 60,
reopenDelayMinutes: 90,
}),
};
@@ -169,6 +171,38 @@ describe('BookingBatchService — PAID reconcile', () => {
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledTimes(2);
});
it('ensurePaidBookingAllocated holds a wagon-short booking out of the train', async () => {
trainSchedulingService.previewPaidBookingWagonShortage.mockResolvedValue({
wagonTypeCodes: 'NW6',
wagonsNeeded: 1,
wagonsAvailable: 0,
wagonsShort: 1,
});
await service.ensurePaidBookingAllocated(bookingId);
// Not linked, no wagon run — held PAID + unlinked, flagged for manual placement.
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled();
expect(dataSource.getRepository().update).toHaveBeenCalledWith(
bookingId,
expect.objectContaining({ schedulingStatus: 'WAITING_FOR_WAGON' }),
);
});
it('reconcilePaidUnlinked leaves WAITING_FOR_WAGON bookings held', async () => {
bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([
{ ...paidBooking, schedulingStatus: 'WAITING_FOR_WAGON' },
]);
await service.reconcilePaidUnlinked(scheduleId);
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
expect(
trainSchedulingService.previewPaidBookingWagonShortage,
).not.toHaveBeenCalled();
});
it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => {
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0);
const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined);

View File

@@ -497,6 +497,7 @@ export class BookingBatchService implements OnModuleInit {
const linked =
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
if (!linked) {
if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return;
await this.allocate(booking.trainScheduleId, booking, "paid");
this.logger.log(
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
@@ -783,6 +784,9 @@ export class BookingBatchService implements OnModuleInit {
const unlinked =
await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId);
for (const booking of unlinked) {
// Held on purpose (paid, no wagon free) — the cron must not undo it.
if (booking.schedulingStatus === "WAITING_FOR_WAGON") continue;
if (await this.holdIfWagonShort(scheduleId, booking)) continue;
await this.allocate(scheduleId, booking, "paid");
this.logger.log(
`Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
@@ -1050,7 +1054,11 @@ export class BookingBatchService implements OnModuleInit {
s.ruleWindowDurationHours,
liveCfg.windowDurationHours,
),
reopenDelayMinutes: num(s.ruleReopenDelayMinutes, liveCfg.reopenDelayMinutes),
// Frozen doc-review + payment sum; legacy rows fall back to the live sum.
reopenGapMinutes: num(
s.ruleReopenDelayMinutes,
liveCfg.docReviewMinutes + liveCfg.paymentWindowMinutes,
),
importWindowLeadDays: num(
s.ruleImportWindowLeadDays,
liveCfg.importWindowLeadDays,
@@ -1894,7 +1902,9 @@ export class BookingBatchService implements OnModuleInit {
done.add(booking.id);
if (isPaid(booking)) {
await this.allocate(scheduleId, booking, "paid");
if (!(await this.holdIfWagonShort(scheduleId, booking))) {
await this.allocate(scheduleId, booking, "paid");
}
anySettled = true;
} else if (isExpired(booking)) {
await this.expire(booking);
@@ -1920,6 +1930,29 @@ export class BookingBatchService implements OnModuleInit {
);
}
/**
* Conclude-time retry: promote whatever still fits from the route-day waiting
* list, opening fresh pay windows. Returns how many commercial units got
* reserved — corridor-wide, since the fill is day-level and may reserve onto a
* sibling train; the caller must check `hasLiveReservations` for its OWN
* schedule before deciding to stay in PAYMENT.
*/
async fillFromWaitingList(scheduleId: string): Promise<number> {
return this.withScheduleLock(scheduleId, async () => {
let promoted = 0;
for (let round = 0; round < 10; round += 1) {
const reservedThisRound = await this.topUpFill(scheduleId);
if (reservedThisRound <= 0) break;
promoted += reservedThisRound;
await this.extendPaymentPhaseForTopUp(scheduleId);
}
if (promoted > 0) {
this.notifyBoardChanged(scheduleId, "conclude_waiting_list_fill");
}
return promoted;
});
}
/**
* Settle, then keep promoting the waiting list until the train can take no more.
* Returns whether anything settled.
@@ -2050,7 +2083,9 @@ export class BookingBatchService implements OnModuleInit {
await this.dataSource
.getRepository(Booking)
.update(bookingId, { paymentStatus: "PAID" });
await this.allocate(booking.trainScheduleId, booking, "paid");
if (!(await this.holdIfWagonShort(booking.trainScheduleId, booking))) {
await this.allocate(booking.trainScheduleId, booking, "paid");
}
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
booking.trainScheduleId,
@@ -2112,7 +2147,12 @@ export class BookingBatchService implements OnModuleInit {
await manager.getRepository(Booking).update(bookingId, {
trainScheduleId: newScheduleId,
status: restoredStatus,
schedulingStatus: "ELIGIBLE",
// A paid booking still hunting for a wagon keeps its flag through the
// move — it only clears when wagons are actually assigned.
schedulingStatus:
booking.schedulingStatus === "WAITING_FOR_WAGON"
? "WAITING_FOR_WAGON"
: "ELIGIBLE",
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
@@ -2254,6 +2294,50 @@ export class BookingBatchService implements OnModuleInit {
}
/** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */
/**
* Fleet preflight shared by every single-booking paid-allocation path: when
* no wagon of the booking's required type is free, hold it OUT of the train
* instead of linking — it stays PAID + unlinked in the (route, day) pool,
* flagged WAITING_FOR_WAGON, and staff place it on any same-day schedule from
* the workspace "Paid · unassigned" panel once a wagon frees up. Returns true
* when the booking was held. Consolidated pairs are exempt (the shared wagon
* is both-or-neither and settles atomically in settleReserved).
*/
private async holdIfWagonShort(
scheduleId: string,
booking: Booking,
): Promise<boolean> {
if (booking.consolidationPartnerId) return false;
const shortage =
await this.trainSchedulingService.previewPaidBookingWagonShortage(
scheduleId,
booking.id,
);
if (!shortage) return false;
await this.dataSource.getRepository(Booking).update(booking.id, {
status: "PAID",
paymentStatus: "PAID",
schedulingStatus: "WAITING_FOR_WAGON",
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
// Payment landed — record it even though nothing boards yet. The wagon
// milestone stays pending until staff assign one.
void this.completeTrackingMilestones(booking.id, [
"FREIGHT_PAYMENT_PENDING",
"FREIGHT_PAYMENT_SETTLED",
]);
this.logger.warn(
`PAID booking ${booking.reference ?? booking.id} is WAITING FOR WAGON: ` +
`needs ${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` +
`${shortage.wagonsAvailable} available (short ${shortage.wagonsShort}). ` +
`Held in the day pool for manual placement.`,
);
this.notifyBoardChanged(scheduleId, "booking_waiting_wagon");
return true;
}
private async allocate(
scheduleId: string,
booking: Booking,
@@ -2358,7 +2442,9 @@ export class BookingBatchService implements OnModuleInit {
`[BATCH] expire skipped for ${booking.reference} — payment already ` +
`landed; allocating on schedule ${paidScheduleId} instead`,
);
await this.allocate(paidScheduleId, fresh, "paid");
if (!(await this.holdIfWagonShort(paidScheduleId, fresh))) {
await this.allocate(paidScheduleId, fresh, "paid");
}
return;
}
}

View File

@@ -19,8 +19,6 @@ export interface BookingWindowConfig {
/** Max staff document-review time after the window closes. */
docReviewMinutes: number;
paymentWindowMinutes: number;
/** Delay after window close before reopening when the train is not full. */
reopenDelayMinutes: number;
}
/** Window phase lifecycle for the one-booking-day import cycle. NULL on legacy/DOMESTIC schedules. */

View File

@@ -20,6 +20,7 @@ describe('BookingWindowService — window state machine', () => {
hasLiveReservations: jest.Mock;
refreshWindowStatus: jest.Mock;
expireLeftoverDayPool: jest.Mock;
fillFromWaitingList: jest.Mock;
};
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
@@ -33,7 +34,6 @@ describe('BookingWindowService — window state machine', () => {
windowDurationHours: 1,
docReviewMinutes: 30,
paymentWindowMinutes: 60,
reopenDelayMinutes: 0,
};
const baseSchedule = (over: Partial<TrainSchedule>): TrainSchedule =>
@@ -75,6 +75,8 @@ describe('BookingWindowService — window state machine', () => {
hasLiveReservations: jest.fn().mockResolvedValue(false),
refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
expireLeftoverDayPool: jest.fn().mockResolvedValue(0),
// No waiting booking fits by default, so conclude proceeds to reopen/DONE.
fillFromWaitingList: jest.fn().mockResolvedValue(0),
};
trainSchedulesRepository = {
findById: jest.fn().mockResolvedValue(null),
@@ -123,6 +125,8 @@ describe('BookingWindowService — window state machine', () => {
});
it('DOC_REVIEW → PAYMENT expires un-accepted, then runs the batch', async () => {
// The batch reserved someone (live reservations exist) → real PAYMENT phase.
batch.hasLiveReservations.mockResolvedValue(true);
const s = baseSchedule({
windowPhase: 'DOC_REVIEW',
docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'),
@@ -140,6 +144,7 @@ describe('BookingWindowService — window state machine', () => {
});
it('DOC_REVIEW → PAYMENT also fires when staff finished review early (docReviewCompletedAt)', async () => {
batch.hasLiveReservations.mockResolvedValue(true);
const s = baseSchedule({
windowPhase: 'DOC_REVIEW',
docReviewEndsAt: new Date('2026-07-01T05:00:00.000Z'), // far future
@@ -150,6 +155,21 @@ describe('BookingWindowService — window state machine', () => {
expect(s.windowPhase).toBe('PAYMENT');
});
it('DOC_REVIEW → batch reserves nothing → skips the empty PAYMENT phase and reopens', async () => {
// Default hasLiveReservations=false: the batch reserved nobody. Waiting a
// full payment window with the desk shut would serve no one — the cycle
// concludes immediately (24h desk + far departure → straight to PRE_WINDOW).
const s = baseSchedule({
windowPhase: 'DOC_REVIEW',
docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'),
});
const advanced = await advanceImport(s, new Date('2026-07-01T01:30:01.000Z'));
expect(advanced).toBe(true);
expect(batch.processRouteDay).toHaveBeenCalledTimes(1);
expect(s.windowPhase).toBe('PRE_WINDOW');
expect(s.windowOpensAt).not.toBeNull();
});
it('PAYMENT → conclude at paymentPhaseEndsAt settles due reservations', async () => {
const s = baseSchedule({
windowPhase: 'PAYMENT',
@@ -205,6 +225,22 @@ describe('BookingWindowService — window state machine', () => {
expect(trainSchedulingService.finalizeSchedule).not.toHaveBeenCalled();
});
it('conclude: waiting booking still fits → fresh pay window, back to PAYMENT, no reopen', async () => {
batch.isScheduleFull.mockResolvedValue(false);
batch.fillFromWaitingList.mockResolvedValue(2);
batch.hasLiveReservations.mockResolvedValue(true);
const s = baseSchedule({
windowPhase: 'PAYMENT',
scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'),
});
const now = new Date('2026-07-01T02:30:05.000Z');
await concludeCycle(s, now);
expect(batch.fillFromWaitingList).toHaveBeenCalledWith(scheduleId);
expect(s.windowPhase).toBe('PAYMENT');
// Fresh pay window from `now`, not a reopen.
expect(s.paymentPhaseEndsAt).toEqual(new Date(now.getTime() + 60 * 60_000));
});
it('conclude: NOT full but NO cycle fits before departure → DONE', async () => {
batch.isScheduleFull.mockResolvedValue(false);
const s = baseSchedule({

View File

@@ -17,7 +17,12 @@ import { BookingBatchService } from './booking-batch.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
import { BATCH_TIMEZONE } from './booking-batch.constants';
import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util';
import {
clampCloseToOfficeHours,
eatDay,
nextCycleOpensAt,
type OfficeHours,
} from './batch-window.util';
import { type BookingWindowConfig } from './booking-window.config';
/**
@@ -297,6 +302,17 @@ export class BookingWindowService implements OnModuleInit {
// (or allocating government) — skipped automatically for everyone who fits
// is handled inside the fill (all fit → all reserved → all notified).
await this.bookingBatchService.processRouteDay(routeDay);
// Batch reserved nobody (empty pool, or it allocated without pay windows):
// a PAYMENT phase with nobody to pay is a dead hour with the window shut.
// Conclude straight away — full → DONE, otherwise reopen per office hours.
if (!(await this.bookingBatchService.hasLiveReservations(schedule.id))) {
this.logger.log(
`[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch reserved nothing; ` +
`skipping the empty payment phase and concluding the cycle`,
);
await this.concludeCycle(schedule, cfg, now);
return true;
}
this.logger.log(
`[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch ran; payment phase ` +
`until ${paymentPhaseEndsAt.toISOString()}`,
@@ -353,7 +369,10 @@ export class BookingWindowService implements OnModuleInit {
return false;
}
/** After settle: full → finalize + DONE; space left → reopen same day or close for the day. */
/**
* After settle: full → finalize + DONE; waiting bookings still fit → fresh pay
* window, back to PAYMENT; otherwise reopen (office hours decide when) or DONE.
*/
private async concludeCycle(
schedule: TrainSchedule,
cfg: BookingWindowConfig,
@@ -386,6 +405,31 @@ export class BookingWindowService implements OnModuleInit {
if (fresh) schedule.bookingWindowStatus = fresh.bookingWindowStatus;
}
// The window reopens only once the waiting list is exhausted: a booking can
// still reach the pool mid-payment (late doc accept, consolidation partner),
// so retry the batch before reopening. Anything that fits gets a fresh pay
// window and the cycle stays in PAYMENT; check live reservations on THIS
// schedule because the day-level fill may have reserved onto a sibling.
// Waiting bookings that fit no train stay pooled and the window reopens.
const promoted = await this.bookingBatchService.fillFromWaitingList(schedule.id);
if (
promoted > 0 &&
(await this.bookingBatchService.hasLiveReservations(schedule.id))
) {
let paymentPhaseEndsAt = new Date(
now.getTime() + cfg.paymentWindowMinutes * 60_000,
);
if (paymentPhaseEndsAt > schedule.scheduledDepartureDate) {
paymentPhaseEndsAt = schedule.scheduledDepartureDate;
}
await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt });
this.logger.log(
`[WINDOW] ${schedule.id} conclude → waiting list still had bookings that ` +
`fit — back in PAYMENT until ${paymentPhaseEndsAt.toISOString()}, no reopen yet`,
);
return;
}
// Doc review + payment have already run, so the desk is ready to reopen NOW —
// office hours decide whether that is this afternoon or tomorrow morning. Past
// the last cycle before departure, nextCycleOpensAt returns null and we finish.
@@ -413,6 +457,9 @@ export class BookingWindowService implements OnModuleInit {
let nextClosesAt = new Date(
nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000,
);
// Office hours end a running window early: never let the duration outlive
// the desk close (open 16:00, 3h, desk 817 → closes 17:00).
nextClosesAt = clampCloseToOfficeHours(nextOpensAt, nextClosesAt, officeHours);
if (nextClosesAt > schedule.scheduledDepartureDate) {
nextClosesAt = schedule.scheduledDepartureDate;
}

View File

@@ -41,6 +41,28 @@ export class AvailableDaysForCargoQueryDto {
@IsString()
cargoTypeCode?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Bulk cargo type id (preferred over code).' })
@IsOptional()
@IsUUID()
cargoTypeId?: string;
@ApiPropertyOptional({
description:
'Container type ids as a JSON string array — enables the exact wagon-type compatibility gate (falls back to containerSize matching when absent).',
})
@IsOptional()
@Transform(({ value }) => {
if (value == null || value === '') return undefined;
if (typeof value !== 'string') return value;
try {
return JSON.parse(value);
} catch {
return undefined;
}
})
@IsArray()
containerTypeIds?: string[];
@ApiPropertyOptional({ description: 'Total bulk weight in tons.' })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))

View File

@@ -95,11 +95,4 @@ export class UpdateTrainSchedulingGlobalRulesDto {
@IsInt()
@Min(1)
paymentWindowMinutes?: number;
@ApiPropertyOptional({ example: 90 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
reopenDelayMinutes?: number;
}

View File

@@ -79,8 +79,4 @@ export class TrainSchedulingGlobalRules extends BaseEntity {
@Column({ name: 'payment_window_minutes', type: 'int', default: 60 })
paymentWindowMinutes!: number;
/** Delay after window close before the window reopens when the train is not yet full. */
@Column({ name: 'reopen_delay_minutes', type: 'int', default: 90 })
reopenDelayMinutes!: number;
}

View File

@@ -114,6 +114,35 @@ describe('fleet-plan.util', () => {
expect(warnings.some((w) => w.includes('deferred'))).toBe(true);
});
it('names the booking and its per-type shortfall when the deferral carries a shortage', () => {
const warnings = summarizeFleetWarnings(
[],
[
{
id: 'b1',
reference: 'BKG-1',
reason: 'No available NW6 wagon at the yard',
shortage: {
wagonTypeCodes: 'NW6',
wagonsNeeded: 2,
wagonsAvailable: 1,
wagonsShort: 1,
},
},
],
);
expect(
warnings.some(
(w) =>
w.includes('BKG-1') &&
w.includes('2 × NW6') &&
w.includes('only 1 available') &&
w.includes('short 1'),
),
).toBe(true);
});
it('counts wagons required per booking from container lines', () => {
const booking = makeBooking('b1', {
bookingContainers: [

View File

@@ -17,10 +17,21 @@ export type FleetAvailabilityRow = {
shortfall: number;
};
/** Per-booking wagon shortage: how many wagons of which type this booking still lacks. */
export type BookingWagonShortage = {
/** Candidate wagon-type codes usable by the booking, joined ("NW6" or "NW6/CW3"). */
wagonTypeCodes: string;
wagonsNeeded: number;
wagonsAvailable: number;
wagonsShort: number;
};
export type DeferredBookingRow = {
id: string;
reference: string;
reason: string;
/** Set when the deferral is a fleet-stock shortage (absent for config issues). */
shortage?: BookingWagonShortage | null;
};
export function sortBookingsForScheduling(bookings: Booking[]): Booking[] {
@@ -156,6 +167,16 @@ export function summarizeFleetWarnings(
);
}
// Name the bookings the shortage actually hits, with their own per-type counts,
// so staff know WHAT is held out — not just that the pool is short overall.
for (const row of deferred) {
if (!row.shortage) continue;
warnings.push(
`Booking ${row.reference} held out: needs ${row.shortage.wagonsNeeded} × ${row.shortage.wagonTypeCodes}, ` +
`only ${row.shortage.wagonsAvailable} available (short ${row.shortage.wagonsShort})`,
);
}
if (deferred.length) {
warnings.push(
`${deferred.length} booking(s) deferred to next train due to insufficient fleet wagons`,

View File

@@ -234,9 +234,11 @@ export class TrainSchedulingController {
originYardId: query.originYardId,
destinationYardId: query.destinationYardId,
freightType: query.freightType,
cargoTypeId: query.cargoTypeId,
cargoTypeCode: query.cargoTypeCode,
totalWeightTons: query.totalWeightTons,
containers: query.containers,
containerTypeIds: query.containerTypeIds,
});
}

View File

@@ -99,6 +99,7 @@ import {
summarizeFleetWarnings,
totalAssignedWeight,
wagonsRequiredForBooking,
type BookingWagonShortage,
type DeferredBookingRow,
type FleetAvailabilityRow,
} from './fleet-plan.util';
@@ -214,8 +215,6 @@ export function effectiveWindowConfig(
: liveCfg.windowDurationHours,
docReviewMinutes: liveCfg.docReviewMinutes,
paymentWindowMinutes: liveCfg.paymentWindowMinutes,
reopenDelayMinutes:
schedule.ruleReopenDelayMinutes ?? liveCfg.reopenDelayMinutes,
};
}
@@ -251,6 +250,8 @@ export interface CompositionUnassignedBookingRow {
yardWagonsAvailable: number;
canAssign: boolean;
blockReason: string | null;
/** Structured fleet shortage when the block is missing wagons (null otherwise). */
shortage: BookingWagonShortage | null;
}
export interface UnassignedBookingsResponse {
@@ -435,7 +436,12 @@ export class TrainSchedulingService {
.where('s.originStationId = :originStationId', { originStationId })
.andWhere('s.destinationStationId = :destinationStationId', { destinationStationId })
.andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
.andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart });
.andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart })
// A cancelled train is not a sibling: cancel retires its window as DONE,
// and a newborn anchoring to it would inherit that dead window verbatim.
.andWhere('s.status != :cancelledStatus', {
cancelledStatus: TrainScheduleStatusEnum.Cancelled,
});
if (excludeScheduleId) {
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
}
@@ -471,7 +477,12 @@ export class TrainSchedulingService {
departure,
);
if (siblings.length === 0) return null;
const withWindow = siblings.filter((s) => s.windowOpensAt != null);
// A DONE window is retired (the day's last cycle already ran) — anchoring
// to it would hand the newborn a dead window no tick ever advances. With no
// live or pending sibling left, fall back to fresh times (return null).
const withWindow = siblings.filter(
(s) => s.windowOpensAt != null && s.windowPhase !== 'DONE',
);
if (withWindow.length === 0) return null;
// A group whose window is live (some sibling has moved past PRE_WINDOW but is
@@ -603,7 +614,6 @@ export class TrainSchedulingService {
if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours;
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
if (dto.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes;
// The booking desk supports three shapes: a same-day range
// (closeHour > openHour), a 24-hour desk (openHour === closeHour), and an
@@ -692,7 +702,6 @@ export class TrainSchedulingService {
// override changes them, so the derived snapshot delay stays consistent.
docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes,
paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes,
reopenDelayMinutes: liveCfg.reopenDelayMinutes,
};
// Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid
@@ -919,7 +928,6 @@ export class TrainSchedulingService {
windowDurationHours: num(row?.windowDurationHours, 3),
docReviewMinutes: num(row?.docReviewMinutes, 30),
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
reopenDelayMinutes: num(row?.reopenDelayMinutes, 90),
};
}
@@ -1069,6 +1077,20 @@ export class TrainSchedulingService {
// getSchedulableRoute already rejected DOMESTIC (intercity).
const direction = this.resolveRouteDirection(route);
// Direction-matched fixed number from the built train's typed pair.
// Legacy locomotive-picked schedules keep dispatch-time pool assignment
// (assignTrainNumber is idempotent, so both paths compose).
const pairTrainNumber = builtTrain
? (direction === 'IMPORT'
? builtTrain.importTrainNumber
: builtTrain.exportTrainNumber) ?? null
: null;
if (builtTrain && !pairTrainNumber) {
scheduleWarnings.push(
`Train ${builtTrain.code} has no ${direction === 'IMPORT' ? 'import' : 'export'} train number; a pool number will be assigned at dispatch`,
);
}
const trainSet = await this.buildEmptyTrainSet(
manager,
lockedLocomotives,
@@ -1164,6 +1186,7 @@ export class TrainSchedulingService {
scheduledDepartureDate: departure,
status: TrainScheduleStatusEnum.Draft,
direction,
trainNumber: pairTrainNumber ?? undefined,
maxWagons,
...windowFields,
}),
@@ -1368,8 +1391,6 @@ export class TrainSchedulingService {
await this.dataSource.transaction(async (manager) => {
const trainSetId = schedule.trainSetId;
await this.releasePinnedWagonsForTrainSet(manager, trainSetId);
const deletedAllocationIds =
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(trainSetId, manager);
@@ -1505,7 +1526,6 @@ export class TrainSchedulingService {
(sb) => sb.bookingId !== bookingId,
);
if (remainingBookings.length === 0) {
await this.releasePinnedWagonsForTrainSet(manager, schedule.trainSetId);
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(
schedule.trainSetId,
manager,
@@ -1745,7 +1765,18 @@ export class TrainSchedulingService {
throw new BadRequestException('Cannot pin wagons on a dispatched or cancelled schedule');
}
const slotIds = new Set((schedule.trainSet?.wagons ?? []).map((w) => w.id));
const slots = schedule.trainSet?.wagons ?? [];
const slotIds = new Set(slots.map((w) => w.id));
const slotById = new Map(slots.map((w) => [w.id, w]));
const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId);
// Occupancy is judged against THIS schedule's own slots only — a wagon
// pinned on another schedule (e.g. the same train's July 17 run) stays
// pinnable here.
const slotIdByPhysicalId = new Map(
slots
.filter((w) => w.physicalWagonId)
.map((w) => [w.physicalWagonId as string, w.id]),
);
await this.dataSource.transaction(async (manager) => {
for (const assignment of dto.assignments) {
@@ -1761,29 +1792,61 @@ export class TrainSchedulingService {
if (!physicalWagon) {
throw new NotFoundException(`Wagon ${assignment.physicalWagonId} not found`);
}
if (
physicalWagon.status !== WagonStatus.Available &&
physicalWagon.currentTrainScheduleId !== scheduleId
) {
const occupyingSlotId = slotIdByPhysicalId.get(assignment.physicalWagonId);
if (occupyingSlotId && occupyingSlotId !== assignment.trainSetWagonId) {
const occupyingSlot = slotById.get(occupyingSlotId);
throw new ConflictException(
`Wagon ${physicalWagon.wagonNumber} is not available`,
`Wagon ${physicalWagon.wagonNumber} is already pinned to slot #${occupyingSlot?.sequenceNo ?? '?'} of this schedule`,
);
}
if (physicalWagon.currentYardId !== schedule.originStationId) {
throw new ConflictException(
`Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`,
);
if (builtTrainId) {
// Train-bound schedule: only the built train's own consist may be
// pinned — wherever the wagons currently sit, they travel with the
// train, so no yard/status gate applies.
if (physicalWagon.trainId !== builtTrainId) {
throw new ConflictException(
`Wagon ${physicalWagon.wagonNumber} is not part of this schedule's train`,
);
}
} else {
if (physicalWagon.trainId) {
throw new ConflictException(
`Wagon ${physicalWagon.wagonNumber} is coupled to a built train and cannot be pinned as a loose wagon`,
);
}
if (!this.isWagonPhysicallyUsable(physicalWagon)) {
throw new ConflictException(
`Wagon ${physicalWagon.wagonNumber} is not available (${physicalWagon.status})`,
);
}
if (
physicalWagon.currentTrainScheduleId &&
physicalWagon.currentTrainScheduleId !== scheduleId
) {
throw new ConflictException(
`Wagon ${physicalWagon.wagonNumber} is out on a dispatched train`,
);
}
if (physicalWagon.currentYardId !== schedule.originStationId) {
throw new ConflictException(
`Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`,
);
}
}
// The pin lives ONLY on the schedule's slot — the Wagon entity keeps
// its status untouched so other schedules can still use the wagon.
await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, {
physicalWagonId: assignment.physicalWagonId,
status: 'RESERVED',
});
await manager.getRepository(Wagon).update(assignment.physicalWagonId, {
trainSetWagonId: assignment.trainSetWagonId,
currentTrainScheduleId: scheduleId,
status: WagonStatus.Assigned,
});
for (const [physicalId, slotId] of slotIdByPhysicalId) {
if (slotId === assignment.trainSetWagonId) {
slotIdByPhysicalId.delete(physicalId);
break;
}
}
slotIdByPhysicalId.set(assignment.physicalWagonId, assignment.trainSetWagonId);
}
});
@@ -1837,6 +1900,22 @@ export class TrainSchedulingService {
// at a time — block dispatch while any set locomotive is out on a dispatched train.
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId);
// Same rule for wagons: many schedules may pin the same wagon, but it can
// only be OUT on one dispatched train at a time.
const pinnedPhysicalIds = (schedule.trainSet?.wagons ?? [])
.map((slot) => slot.physicalWagonId)
.filter((id): id is string => Boolean(id));
if (pinnedPhysicalIds.length) {
const rolling = await this.dataSource.getRepository(Wagon).find({
where: { id: In(pinnedPhysicalIds), currentTrainScheduleId: Not(IsNull()) },
});
const busy = rolling.filter((w) => w.currentTrainScheduleId !== scheduleId);
if (busy.length) {
throw new ConflictException(
`Cannot dispatch: wagon(s) ${busy.map((w) => w.wagonNumber).join(', ')} are still out on another dispatched train`,
);
}
}
const now = new Date();
await this.dataSource.transaction(async (manager) => {
@@ -2674,7 +2753,23 @@ export class TrainSchedulingService {
manager: EntityManager,
schedule: TrainSchedule,
): Promise<string> {
if (schedule.trainNumber) return schedule.trainNumber;
if (schedule.trainNumber) {
// Creation-assigned pair number: two live runs may never share a number,
// so block dispatch while another DISPATCHED schedule still carries it.
const clash = await manager
.getRepository(TrainSchedule)
.createQueryBuilder('s')
.where('s.status = :status', { status: TrainScheduleStatusEnum.Dispatched })
.andWhere('s.train_number = :trainNumber', { trainNumber: schedule.trainNumber })
.andWhere('s.id != :id', { id: schedule.id })
.getOne();
if (clash) {
throw new ConflictException(
`Train number ${schedule.trainNumber} is already out on ${clash.reference ?? clash.id}; it must arrive before this train dispatches`,
);
}
return schedule.trainNumber;
}
// Count container vs bulk wagons from the planned allocations.
let containerWagons = 0;
@@ -2695,17 +2790,38 @@ export class TrainSchedulingService {
// Lock the set of currently-active numbered schedules so two concurrent
// dispatches serialize and can't both claim the same lowest-free number.
// DRAFT/SCHEDULED are included because pair numbers are now assigned at
// creation and must be invisible to pool picks.
const activeNumbered = await manager
.getRepository(TrainSchedule)
.createQueryBuilder('schedule')
.setLock('pessimistic_write')
.where('schedule.status = :status', { status: TrainScheduleStatusEnum.Dispatched })
.where('schedule.status IN (:...statuses)', {
statuses: [
TrainScheduleStatusEnum.Draft,
TrainScheduleStatusEnum.Scheduled,
TrainScheduleStatusEnum.Dispatched,
],
})
.andWhere('schedule.train_number IS NOT NULL')
.getMany();
const usedNumbers = activeNumbered
.map((s) => s.trainNumber)
.filter((n): n is string => Boolean(n));
// Every typed train pair is reserved for its train — the pool may never
// hand one out, even when that train has no active schedule right now.
const pairRows: { n: string }[] = await manager.query(
`SELECT import_train_number AS n FROM freight.trains
WHERE deleted_at IS NULL AND import_train_number IS NOT NULL
UNION
SELECT export_train_number FROM freight.trains
WHERE deleted_at IS NULL AND export_train_number IS NOT NULL`,
);
const usedNumbers = [
...activeNumbered
.map((s) => s.trainNumber)
.filter((n): n is string => Boolean(n)),
...pairRows.map((row) => row.n),
];
const number = pickLowestFreeNumber(pool.numbers, usedNumbers);
if (!number) {
@@ -3675,10 +3791,11 @@ export class TrainSchedulingService {
originYardId: string,
targetScheduleId?: string,
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
const [wagons, wagonTypes, builtTrainId] = await Promise.all([
const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds] = await Promise.all([
this.dataSource.getRepository(Wagon).find(),
this.dataSource.getRepository(WagonType).find(),
this.builtTrainIdOfSchedule(targetScheduleId),
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
]);
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
const counts = new Map<string, { code: string; available: number }>();
@@ -3690,10 +3807,20 @@ export class TrainSchedulingService {
if (builtTrainId) {
if (wagon.trainId !== builtTrainId) continue;
} else {
const pinnedOnTarget = targetScheduleId
? wagon.currentTrainScheduleId === targetScheduleId
: false;
if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue;
// Schedule-scoped availability: pins held by OTHER schedules never
// consume a wagon here — the same physical wagon may serve the July 17
// and the July 20 run. A wagon is unusable only when it is coupled to a
// built train's consist, physically blocked, or out on a dispatched
// train right now.
const pinnedOnTarget = pinnedToTargetIds.has(wagon.id);
if (wagon.trainId) continue;
if (!this.isWagonPhysicallyUsable(wagon) && !pinnedOnTarget) continue;
if (
wagon.currentTrainScheduleId &&
wagon.currentTrainScheduleId !== targetScheduleId
) {
continue;
}
if (wagon.currentYardId !== originYardId) continue;
}
@@ -3752,22 +3879,59 @@ export class TrainSchedulingService {
};
}
private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) {
const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } });
const physicalIds = slots
.map((slot) => slot.physicalWagonId)
.filter((id): id is string => Boolean(id));
if (!physicalIds.length) return;
const wagons = await manager.getRepository(Wagon).find({ where: { id: In(physicalIds) } });
for (const wagon of wagons) {
await manager.getRepository(Wagon).update(wagon.id, {
// Built-train wagons stay coupled to their train (ASSIGNED); loose
// wagons return to the open AVAILABLE pool.
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
trainSetWagonId: null,
currentTrainScheduleId: null,
});
}
/**
* A wagon in a blocked physical state can never be planned or pinned.
* ASSIGNED no longer blocks: it only means the wagon is coupled to a built
* train or stamped by a live run — schedule-level occupancy is tracked on
* the schedule's own TrainSetWagon slots, never on the Wagon entity.
*/
private isWagonPhysicallyUsable(wagon: Wagon): boolean {
return (
wagon.status === WagonStatus.Available || wagon.status === WagonStatus.Assigned
);
}
/**
* Physical wagons already pinned to THIS schedule's slots. Availability is
* schedule-scoped: only a duplicate pin within the same schedule conflicts;
* pins held by other schedules of the same train are irrelevant.
*/
private async pinnedPhysicalWagonIdsForSchedule(
scheduleId: string | undefined,
manager?: EntityManager,
): Promise<Set<string>> {
if (!scheduleId) return new Set();
const runner = manager ?? this.dataSource;
const rows: { physical_wagon_id: string }[] = await runner.query(
`SELECT tsw.physical_wagon_id
FROM freight.train_set_wagons tsw
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
WHERE ts.id = $1
AND ts.deleted_at IS NULL
AND tsw.deleted_at IS NULL
AND tsw.physical_wagon_id IS NOT NULL`,
[scheduleId],
);
return new Set(rows.map((row) => row.physical_wagon_id));
}
/**
* Physical wagons pinned to any slot of a live (DRAFT/SCHEDULED/DISPATCHED)
* schedule. Used to guard consist trims — the Wagon entity itself carries no
* schedule-occupancy state anymore.
*/
private async wagonIdsPinnedToLiveSchedules(manager?: EntityManager): Promise<Set<string>> {
const runner = manager ?? this.dataSource;
const rows: { physical_wagon_id: string }[] = await runner.query(
`SELECT DISTINCT tsw.physical_wagon_id
FROM freight.train_set_wagons tsw
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
WHERE ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
AND ts.deleted_at IS NULL
AND tsw.deleted_at IS NULL
AND tsw.physical_wagon_id IS NOT NULL`,
);
return new Set(rows.map((row) => row.physical_wagon_id));
}
private async autoPinWagonsForSchedule(
@@ -3779,6 +3943,10 @@ export class TrainSchedulingService {
const wagons = await manager.getRepository(Wagon).find();
const wagonTypes = await manager.getRepository(WagonType).find();
const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId, manager);
const pinnedToScheduleIds = await this.pinnedPhysicalWagonIdsForSchedule(
scheduleId,
manager,
);
const typeCodeById = new Map(wagonTypes.map((wt) => [wt.id, wt.code]));
const planSlots = [...slots]
@@ -3797,6 +3965,7 @@ export class TrainSchedulingService {
scheduleId,
originYardId,
builtTrainId,
pinnedToScheduleIds,
);
if (unpinnable.length) {
throw new BadRequestException({
@@ -3814,18 +3983,17 @@ export class TrainSchedulingService {
originYardId,
assignedPhysicalIds,
builtTrainId,
pinnedToScheduleIds,
);
if (!physical) continue;
// Pin lives ONLY on the schedule's own slot — the Wagon entity is never
// touched here, so the same physical wagon stays free for every other
// schedule (it gets stamped at dispatch, when it physically leaves).
await manager.getRepository(TrainSetWagon).update(slot.trainSetWagonId!, {
physicalWagonId: physical.id,
status: 'RESERVED',
});
await manager.getRepository(Wagon).update(physical.id, {
trainSetWagonId: slot.trainSetWagonId,
currentTrainScheduleId: scheduleId,
status: WagonStatus.Assigned,
});
assignedPhysicalIds.add(physical.id);
}
}
@@ -3838,9 +4006,10 @@ export class TrainSchedulingService {
): Promise<string[]> {
if (!wagonPlan.length) return [];
const [wagons, builtTrainId] = await Promise.all([
const [wagons, builtTrainId, pinnedToScheduleIds] = await Promise.all([
this.dataSource.getRepository(Wagon).find(),
this.builtTrainIdOfSchedule(targetScheduleId),
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
]);
return this.findUnpinnableWagonSlots(
wagonPlan.map((slot) => ({
@@ -3853,6 +4022,7 @@ export class TrainSchedulingService {
targetScheduleId,
originYardId,
builtTrainId,
pinnedToScheduleIds,
);
}
@@ -3867,6 +4037,7 @@ export class TrainSchedulingService {
scheduleId: string | undefined,
originYardId: string,
builtTrainId: string | null = null,
pinnedToScheduleIds: Set<string> = new Set(),
): string[] {
const violations: string[] = [];
const assignedPhysicalIds = new Set<string>();
@@ -3879,6 +4050,7 @@ export class TrainSchedulingService {
originYardId,
assignedPhysicalIds,
builtTrainId,
pinnedToScheduleIds,
);
if (!physical) {
violations.push(
@@ -3904,14 +4076,22 @@ export class TrainSchedulingService {
originYardId: string,
assignedPhysicalIds: Set<string>,
builtTrainId: string | null = null,
pinnedToScheduleIds: Set<string> = new Set(),
): Wagon | undefined {
const usable = (wagon: Wagon): boolean => {
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
if (assignedPhysicalIds.has(wagon.id)) return false;
const pinnedOnSchedule = scheduleId
? wagon.currentTrainScheduleId === scheduleId
: false;
return wagon.status === WagonStatus.Available || pinnedOnSchedule;
// Loose pool never lends a wagon coupled to a built train's consist.
if (wagon.trainId) return false;
// Out on a dispatched train right now — physically gone.
if (
wagon.currentTrainScheduleId &&
wagon.currentTrainScheduleId !== scheduleId
) {
return false;
}
const pinnedOnSchedule = pinnedToScheduleIds.has(wagon.id);
return this.isWagonPhysicallyUsable(wagon) || pinnedOnSchedule;
};
// Train-bound schedule: ONLY the built train's own wagons may be pinned —
// wherever they currently sit (they travel with the train), never a loose
@@ -4614,6 +4794,8 @@ export class TrainSchedulingService {
code: train.code,
trainName: train.trainName ?? null,
status: train.status,
importTrainNumber: train.importTrainNumber ?? null,
exportTrainNumber: train.exportTrainNumber ?? null,
currentYardId: train.currentYardId ?? null,
currentYard: train.currentYard
? {
@@ -4684,6 +4866,7 @@ export class TrainSchedulingService {
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string),
);
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules();
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
@@ -4740,8 +4923,8 @@ export class TrainSchedulingService {
wagons: wagons.map((wagon) => ({
...mapWagon(wagon),
loaded: loadedWagonIds.has(wagon.id),
// Free = not pinned to any run; only free wagons can be trimmed.
removable: wagon.currentTrainScheduleId == null && !loadedWagonIds.has(wagon.id),
// Free = not pinned to any live run's slot; only free wagons can be trimmed.
removable: !pinnedToLiveIds.has(wagon.id) && !loadedWagonIds.has(wagon.id),
})),
addableWagons: addableWagons.map(mapWagon),
adjustments: adjustments.map((log) => ({
@@ -4820,13 +5003,14 @@ export class TrainSchedulingService {
const consistById = new Map(consist.map((w) => [w.id, w]));
// --- validate removals: must be coupled and free (no cargo, no pin) ---
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(manager);
const removed: Wagon[] = [];
for (const wagonId of removeWagonIds) {
const wagon = consistById.get(wagonId);
if (!wagon) {
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
}
if (loadedWagonIds.has(wagon.id) || wagon.currentTrainScheduleId != null) {
if (loadedWagonIds.has(wagon.id) || pinnedToLiveIds.has(wagon.id)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`,
);
@@ -5307,10 +5491,10 @@ export class TrainSchedulingService {
/**
* Cargo-aware day pool: the EAT days a customer may pick for this cargo. A day
* is selectable when ≥1 OPEN schedule on the route that day still has remaining
* train capacity (not fully allocated). Wagon availability is deliberately NOT
* checked here: whether a matching wagon currently sits in the right yard is an
* operational question staff resolve when they approve or reject the booking,
* not something the customer can act on while choosing a date. Same
* train capacity (not fully allocated) AND its wagon stock can physically carry
* the selected cargo/container type (wagon-TYPE gate). Quantity is deliberately
* NOT gated — a booking bigger than the free capacity is accepted and the batch
* engine offers a partial split later. No counts are exposed: same
* `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY,
* not a train.
*/
@@ -5318,9 +5502,11 @@ export class TrainSchedulingService {
originYardId?: string;
destinationYardId?: string;
freightType: 'CONTAINER' | 'BULK';
cargoTypeId?: string | null;
cargoTypeCode?: string | null;
totalWeightTons?: number;
containers?: Array<{ containerSize: string; quantity: number }>;
containerTypeIds?: string[];
}): Promise<{ days: string[] }> {
const schedules = await this.getBookableScheduleEntities(
input.originYardId,
@@ -5328,17 +5514,233 @@ export class TrainSchedulingService {
);
if (schedules.length === 0) return { days: [] };
const withCapacity = schedules.filter(
(s) => Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0,
);
const compatible = await this.filterCargoCompatibleSchedules(withCapacity, input);
const days = new Set<string>();
for (const s of schedules) {
const hasCapacity =
Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0;
if (!hasCapacity) continue;
for (const s of compatible) {
if (s.scheduledDepartureDate)
days.add(eatDay(new Date(s.scheduledDepartureDate)));
}
return { days: [...days].sort() };
}
/**
* Wagon-TYPE compatibility gate (customer booking): keep only the schedules
* whose wagon stock can physically carry the selected cargo — every container
* line (or the bulk cargo type) must map to at least one wagon type the
* schedule's stock actually has. Stock = the built train's own consist, or the
* origin yard's loose pool for schedules assembled from loose locomotives.
* QUANTITY is deliberately ignored: an over-sized booking is allowed and gets
* a partial split offer from the batch engine later.
*/
private async filterCargoCompatibleSchedules(
schedules: TrainSchedule[],
cargo: {
freightType: 'CONTAINER' | 'BULK';
cargoTypeId?: string | null;
cargoTypeCode?: string | null;
containers?: Array<{ containerSize: string; quantity: number }>;
containerTypeIds?: string[];
},
): Promise<TrainSchedule[]> {
if (!schedules.length) return schedules;
const required = await this.requiredWagonTypeSets(cargo);
// No cargo identity supplied — nothing to gate on (legacy callers).
if (required === null) return schedules;
const stockByScheduleId = await this.scheduleWagonTypeStock(schedules);
return schedules.filter((s) => {
const stock = stockByScheduleId.get(s.id) ?? new Set<string>();
return required.every((set) => {
for (const typeId of set) if (stock.has(typeId)) return true;
return false;
});
});
}
/**
* One Set of allowed wagon-type ids per required cargo dimension: per
* container line's type (or per container size when only sizes are known),
* or a single set for the bulk cargo type. `null` = no cargo identity given,
* skip gating. An EMPTY set means "nothing can carry this" (no wagon types
* configured) — the gate then blocks every schedule, mirroring the hard
* config violation scheduling raises for the same state.
*/
private async requiredWagonTypeSets(cargo: {
freightType: 'CONTAINER' | 'BULK';
cargoTypeId?: string | null;
cargoTypeCode?: string | null;
containers?: Array<{ containerSize: string; quantity: number }>;
containerTypeIds?: string[];
}): Promise<Set<string>[] | null> {
if (cargo.freightType === 'CONTAINER') {
const typeIds = [...new Set((cargo.containerTypeIds ?? []).filter(Boolean))];
if (typeIds.length) {
const rows: { container_type_id: string; wagon_type_id: string | null }[] =
await this.dataSource.query(
`SELECT ct.id AS container_type_id, wt.id AS wagon_type_id
FROM freight.container_types ct
LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id
LEFT JOIN freight.wagon_types wt
ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
WHERE ct.id = ANY($1::uuid[]) AND ct.deleted_at IS NULL`,
[typeIds],
);
const byType = new Map<string, Set<string>>(typeIds.map((id) => [id, new Set()]));
for (const row of rows) {
if (row.wagon_type_id) byType.get(row.container_type_id)?.add(row.wagon_type_id);
}
return [...byType.values()];
}
// Legacy callers only know sizes ("20ft"/"40ft"): a size is carriable when
// ANY active container type of that size has a matching wagon type.
const sizes = [
...new Set(
(cargo.containers ?? [])
.map((line) => parseInt(String(line.containerSize), 10))
.filter((n) => Number.isFinite(n) && n > 0),
),
];
if (!sizes.length) return null;
const rows: { size_ft: number; wagon_type_id: string | null }[] =
await this.dataSource.query(
`SELECT ct.size_ft, wt.id AS wagon_type_id
FROM freight.container_types ct
LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id
LEFT JOIN freight.wagon_types wt
ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
WHERE ct.size_ft = ANY($1::int[]) AND ct.deleted_at IS NULL
AND (ct.is_active IS DISTINCT FROM false)`,
[sizes],
);
const bySize = new Map<number, Set<string>>(sizes.map((s) => [s, new Set()]));
for (const row of rows) {
if (row.wagon_type_id) bySize.get(Number(row.size_ft))?.add(row.wagon_type_id);
}
return [...bySize.values()];
}
if (!cargo.cargoTypeId && !cargo.cargoTypeCode) return null;
const rows: { wagon_type_id: string | null }[] = await this.dataSource.query(
`SELECT wt.id AS wagon_type_id
FROM freight.cargo_types c
LEFT JOIN freight.cargo_type_wagon_types ctwt ON ctwt.cargo_type_id = c.id
LEFT JOIN freight.wagon_types wt
ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
WHERE c.deleted_at IS NULL
AND (($1::uuid IS NOT NULL AND c.id = $1::uuid) OR ($1::uuid IS NULL AND c.code = $2))`,
[cargo.cargoTypeId ?? null, cargo.cargoTypeCode ?? null],
);
const set = new Set<string>();
for (const row of rows) if (row.wagon_type_id) set.add(row.wagon_type_id);
return [set];
}
/**
* Wagon-type ids each schedule's stock can offer: the built train's own
* consist for train-bound schedules, the origin yard's loose usable pool
* otherwise. Batched — two queries for the whole schedule list.
*/
private async scheduleWagonTypeStock(
schedules: TrainSchedule[],
): Promise<Map<string, Set<string>>> {
const builtTrainIds = [
...new Set(
schedules
.map((s) => s.trainSet?.trainId)
.filter((id): id is string => Boolean(id)),
),
];
const looseOriginYardIds = [
...new Set(
schedules
.filter((s) => !s.trainSet?.trainId)
.map((s) => s.originStationId)
.filter(Boolean),
),
];
const [trainRows, yardRows] = await Promise.all([
builtTrainIds.length
? (this.dataSource.query(
`SELECT train_id, wagon_type_id
FROM freight.wagons
WHERE train_id = ANY($1::uuid[]) AND deleted_at IS NULL
GROUP BY train_id, wagon_type_id`,
[builtTrainIds],
) as Promise<{ train_id: string; wagon_type_id: string }[]>)
: Promise.resolve([] as { train_id: string; wagon_type_id: string }[]),
looseOriginYardIds.length
? (this.dataSource.query(
`SELECT current_yard_id, wagon_type_id
FROM freight.wagons
WHERE train_id IS NULL AND deleted_at IS NULL
AND status IN ('AVAILABLE', 'ASSIGNED')
AND current_yard_id = ANY($1::uuid[])
GROUP BY current_yard_id, wagon_type_id`,
[looseOriginYardIds],
) as Promise<{ current_yard_id: string; wagon_type_id: string }[]>)
: Promise.resolve([] as { current_yard_id: string; wagon_type_id: string }[]),
]);
const byTrain = new Map<string, Set<string>>();
for (const row of trainRows) {
const set = byTrain.get(row.train_id) ?? new Set<string>();
set.add(row.wagon_type_id);
byTrain.set(row.train_id, set);
}
const byYard = new Map<string, Set<string>>();
for (const row of yardRows) {
const set = byYard.get(row.current_yard_id) ?? new Set<string>();
set.add(row.wagon_type_id);
byYard.set(row.current_yard_id, set);
}
const result = new Map<string, Set<string>>();
for (const s of schedules) {
const trainId = s.trainSet?.trainId;
result.set(
s.id,
trainId
? byTrain.get(trainId) ?? new Set()
: byYard.get(s.originStationId) ?? new Set(),
);
}
return result;
}
/**
* Booking-time gate for a chosen day: does the route have an OPEN departure
* that day at all, and can any of that day's departures physically carry the
* cargo (wagon-TYPE only — quantity never blocks, oversized bookings get a
* partial split offer instead).
*/
async checkDayCargoCompatibility(
originYardId: string,
destinationYardId: string,
day: string,
cargo: {
freightType: 'CONTAINER' | 'BULK';
cargoTypeId?: string | null;
containerTypeIds?: string[];
},
): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> {
const schedules = await this.getBookableScheduleEntities(
originYardId,
destinationYardId,
);
const onDay = schedules.filter(
(s) =>
s.scheduledDepartureDate && eatDay(new Date(s.scheduledDepartureDate)) === day,
);
if (!onDay.length) return { hasDeparture: false, hasCompatible: false };
const compatible = await this.filterCargoCompatibleSchedules(onDay, cargo);
return { hasDeparture: true, hasCompatible: compatible.length > 0 };
}
/**
* Ordered stop yards of a schedule's route: origin → milestones → destination,
* de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule
@@ -5491,6 +5893,7 @@ export class TrainSchedulingService {
status: schedule.status,
freightType: this.resolveScheduleFreightType(schedule),
trainNumber: schedule.trainNumber ?? null,
maxWagons: schedule.maxWagons ?? null,
direction: schedule.direction ?? null,
requiresLoadingConfirmation,
loadingConfirmed,
@@ -5512,7 +5915,7 @@ export class TrainSchedulingService {
// Per-schedule booking-window rule snapshot — powers the "Booking window
// settings" editor on the ops board (prefill + save one schedule's
// override). docReview/payment are not snapshotted per schedule (only their
// sum, as reopenDelayMinutes), so the editor prefills them from live config.
// sum, as the frozen reopen gap), so the editor prefills them from live config.
windowRule: {
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
@@ -5520,7 +5923,6 @@ export class TrainSchedulingService {
schedule.ruleWindowDurationHours != null
? Number(schedule.ruleWindowDurationHours)
: null,
reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null,
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
docReviewMinutes: windowCfg.docReviewMinutes,
@@ -6148,6 +6550,7 @@ export class TrainSchedulingService {
yardWagonsAvailable: number;
canAssign: boolean;
blockReason: string | null;
shortage: BookingWagonShortage | null;
}> {
if (!schedule.trainSet?.locomotive) {
return {
@@ -6156,6 +6559,7 @@ export class TrainSchedulingService {
yardWagonsAvailable: 0,
canAssign: false,
blockReason: 'Schedule has no locomotive',
shortage: null,
};
}
@@ -6176,6 +6580,7 @@ export class TrainSchedulingService {
yardWagonsAvailable: 0,
canAssign: false,
blockReason: 'No suitable wagon type found',
shortage: null,
};
}
@@ -6220,6 +6625,7 @@ export class TrainSchedulingService {
yardWagonsAvailable,
canAssign: false,
blockReason: err instanceof Error ? err.message : 'Validation failed',
shortage: null,
};
}
@@ -6230,6 +6636,7 @@ export class TrainSchedulingService {
yardWagonsAvailable,
canAssign: false,
blockReason: validation.violations[0] ?? 'Booking validation failed',
shortage: null,
};
}
@@ -6249,6 +6656,16 @@ export class TrainSchedulingService {
deferred?.reason ??
yardShortfall ??
`Need ${wagonsRequired} ${requiredWagonTypeCode} wagon(s) at origin yard`,
shortage:
deferred?.shortage ??
(yardShortfall
? {
wagonTypeCodes: requiredWagonTypeCode,
wagonsNeeded: wagonsRequired,
wagonsAvailable: yardWagonsAvailable,
wagonsShort: Math.max(1, wagonsRequired - yardWagonsAvailable),
}
: null),
};
}
@@ -6267,6 +6684,7 @@ export class TrainSchedulingService {
yardWagonsAvailable,
canAssign: false,
blockReason: missing.issue,
shortage: null,
};
}
}
@@ -6277,9 +6695,49 @@ export class TrainSchedulingService {
yardWagonsAvailable,
canAssign: true,
blockReason: null,
shortage: null,
};
}
/**
* Fleet-shortage preflight for a PAID booking targeting a schedule: the
* structured per-type shortage this booking would hit if placed on top of the
* schedule's current wagon assignments, or null when it fits (or is blocked
* by something other than missing wagons — those keep the legacy link-then-
* fix-manually path).
*/
async previewPaidBookingWagonShortage(
scheduleId: string,
bookingId: string,
): Promise<BookingWagonShortage | null> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule?.trainSet?.locomotive) return null;
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) return null;
const [booking] = await this.bookingsRepository.findByIdsForScheduling([bookingId]);
if (!booking) return null;
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
const fleetCounts = await this.countFleetAvailability(
schedule.originStationId,
scheduleId,
);
const fleetByTypeId = new Map(
fleetCounts.map((row) => [
row.wagonTypeId,
{ code: row.wagonTypeCode, available: row.available },
]),
);
const assignability = await this.previewUnassignedBookingAssignability(
schedule,
wagonAssignedIds,
booking,
fleetByTypeId,
);
return assignability.shortage;
}
/** Paid (or government) bookings that may be loaded onto wagons — excludes expired / awaiting payment. */
private isReadyToLoadBooking(booking: {
status: string;

View File

@@ -0,0 +1,133 @@
import { Booking } from '../bookings/entities/booking.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { planWagonsWithStock } from './wagon-plan-flex.util';
const nw6: WagonType = {
id: 'wt-nw6',
code: 'NW6',
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,
} as WagonType;
const cw3: WagonType = {
id: 'wt-cw3',
code: 'CW3',
name: 'Covered Wagon',
capacityTons: 60,
lengthMeters: 14,
supportedLoadTypes: ['BULK'],
isActive: true,
supportsContainer: false,
} as WagonType;
const containerBooking = (id: string, quantity: number, wagonsRequired: number): Booking =>
({
id,
reference: id,
freightType: 'CONTAINER',
cargoTotalWeightVgm: quantity * 25,
bookingContainers: [
{
id: `${id}-line-0`,
containerTypeId: 'ct-1',
quantity,
wagonsRequired,
vgmPerUnitTons: 25,
},
],
}) as Booking;
describe('planWagonsWithStock — shortage detail', () => {
it('defers with a structured per-type shortage when container stock runs out', () => {
const result = planWagonsWithStock({
bookings: [containerBooking('BKG-1', 2, 1)],
allowed: {
byContainerTypeId: new Map([['ct-1', [nw6]]]),
byCargoTypeId: new Map(),
},
stock: {
mode: 'YARD',
remainingByTypeId: new Map([[nw6.id, 0]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
},
});
expect(result.fitting).toHaveLength(0);
expect(result.deferred).toHaveLength(1);
const row = result.deferred[0]!;
expect(row.reference).toBe('BKG-1');
expect(row.reason).toContain('No available NW6 wagon at the yard');
expect(row.reason).toContain('short 1');
expect(row.shortage).toEqual({
wagonTypeCodes: 'NW6',
wagonsNeeded: 1,
wagonsAvailable: 0,
wagonsShort: 1,
});
});
it('counts the stock the deferred booking actually saw, not its rolled-back usage', () => {
// Two wagons needed (2 × 40ft), one in stock: booking rolls back entirely,
// the shortage reports 1 available / 1 short.
const fortyFooter = containerBooking('BKG-2', 2, 2);
fortyFooter.bookingContainers![0]!.containerType = {
code: '40GP',
sizeFt: 40,
wagonsPerUnit: 1,
} as never;
const result = planWagonsWithStock({
bookings: [fortyFooter],
allowed: {
byContainerTypeId: new Map([['ct-1', [nw6]]]),
byCargoTypeId: new Map(),
},
stock: {
mode: 'YARD',
remainingByTypeId: new Map([[nw6.id, 1]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
},
});
expect(result.deferred).toHaveLength(1);
expect(result.deferred[0]?.shortage).toEqual({
wagonTypeCodes: 'NW6',
wagonsNeeded: 2,
wagonsAvailable: 1,
wagonsShort: 1,
});
// The rolled-back wagon is plannable again for later bookings.
expect(result.plan).toHaveLength(0);
});
it('leaves shortage unset for configuration problems', () => {
const bulkBooking = {
id: 'BKG-3',
reference: 'BKG-3',
freightType: 'BULK',
cargoTotalWeightVgm: 40,
cargoTypeId: 'cargo-1',
cargoType: { id: 'cargo-1', cargoTypeName: 'Fertilizer' },
bookingContainers: [],
} as unknown as Booking;
const result = planWagonsWithStock({
bookings: [bulkBooking],
allowed: {
byContainerTypeId: new Map(),
byCargoTypeId: new Map(), // no wagon types configured → config issue
},
stock: {
mode: 'YARD',
remainingByTypeId: new Map([[cw3.id, 5]]),
codesByTypeId: new Map([[cw3.id, cw3.code]]),
},
});
expect(result.configIssues).toHaveLength(1);
expect(result.deferred[0]?.shortage).toBeNull();
});
});

View File

@@ -2,9 +2,14 @@ import { AllocationLoadType } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { sortBookingsForScheduling, type DeferredBookingRow } from './fleet-plan.util';
import {
sortBookingsForScheduling,
type BookingWagonShortage,
type DeferredBookingRow,
} from './fleet-plan.util';
import {
MAX_TEU_SLOTS_PER_WAGON,
containerWagonsForLines,
expandBookingContainerUnits,
roundTons,
tareTonsOf,
@@ -55,7 +60,12 @@ type OpenSlot = {
freeCapacityTons: number;
};
type PlacementProblem = { kind: 'config' | 'stock'; message: string };
type PlacementProblem = {
kind: 'config' | 'stock';
message: string;
/** Wagon types the failing placement could have used (stock problems only). */
candidates?: WagonType[];
};
const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanSlot => ({
sequenceNo: 0, // stamped at the end
@@ -69,6 +79,38 @@ const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanS
slotLoadType: kind,
});
/**
* Booking-level shortage against the wagon types the failing placement could
* use: wagons the whole booking needs vs stock left for those types. Container
* counts are TEU-packed per booking; bulk divides by the largest candidate.
*/
const shortageFor = (
booking: Booking,
candidates: WagonType[],
remaining: Map<string, number>,
): BookingWagonShortage => {
const wagonsNeeded =
booking.freightType === 'BULK'
? Math.max(
1,
Math.ceil(
Number(booking.cargoTotalWeightVgm ?? 0) /
Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))),
),
)
: Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
const wagonsAvailable = candidates.reduce(
(sum, wt) => sum + (remaining.get(wt.id) ?? 0),
0,
);
return {
wagonTypeCodes: [...new Set(candidates.map((wt) => wt.code))].join('/'),
wagonsNeeded,
wagonsAvailable,
wagonsShort: Math.max(1, wagonsNeeded - wagonsAvailable),
};
};
const addAllocation = (
slot: WagonPlanSlot,
bookingId: string,
@@ -120,7 +162,9 @@ export function planWagonsWithStock(params: {
cargoTypeId: string | null,
): OpenSlot | PlacementProblem => {
const inStock = candidates.filter((wt) => (remaining.get(wt.id) ?? 0) > 0);
if (!inStock.length) return { kind: 'stock', message: noStockMessage(candidates) };
if (!inStock.length) {
return { kind: 'stock', message: noStockMessage(candidates), candidates };
}
// Bulk favors the largest wagon (fewest wagons for the tonnage); containers
// favor the deepest stock so the consist drains evenly. Ties keep config order.
const chosen = [...inStock].sort((a, b) =>
@@ -271,7 +315,21 @@ export function planWagonsWithStock(params: {
});
if (problem.kind === 'config') configIssues.add(problem.message);
deferred.push({ id: booking.id, reference: booking.reference, reason: problem.message });
// remaining is rolled back here, so the shortage counts the stock this
// booking actually saw — not what its own partial placement consumed.
const shortage =
problem.kind === 'stock' && problem.candidates?.length
? shortageFor(booking, problem.candidates, remaining)
: null;
deferred.push({
id: booking.id,
reference: booking.reference,
reason: shortage
? `${problem.message} — needs ${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` +
`${shortage.wagonsAvailable} available (short ${shortage.wagonsShort})`
: problem.message,
shortage,
});
}
return {

View File

@@ -5,14 +5,26 @@ import {
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
} from 'class-validator';
export class BuildTrainDto {
@ApiProperty({ example: '81001', description: 'Operator-assigned train code (unique)' })
@ApiProperty({ example: '8001', description: 'EXPORT run number (odd, unique across trains)' })
@IsString()
@MaxLength(32)
code!: string;
@MaxLength(20)
@Matches(/^\d*[13579]$/, {
message: 'Export train number must be numeric and odd (e.g. 8001)',
})
exportTrainNumber!: string;
@ApiProperty({ example: '8002', description: 'IMPORT run number (even, unique across trains)' })
@IsString()
@MaxLength(20)
@Matches(/^\d*[02468]$/, {
message: 'Import train number must be numeric and even (e.g. 8002)',
})
importTrainNumber!: string;
@ApiProperty({ format: 'uuid', description: 'Yard the train is built in' })
@IsUUID()

View File

@@ -32,6 +32,14 @@ export class Train extends BaseEntity {
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
/** Fixed IMPORT (even) run number typed at build time; unique via partial index. */
@Column({ name: 'import_train_number', type: 'varchar', length: 20, nullable: true })
importTrainNumber!: string | null;
/** Fixed EXPORT (odd) run number typed at build time; unique via partial index. */
@Column({ name: 'export_train_number', type: 'varchar', length: 20, nullable: true })
exportTrainNumber!: string | null;
// --- new required fields ---
@Column({ name: 'train_number', type: 'varchar', length: 20, unique: true, nullable: true })
trainNumber?: string;

View File

@@ -85,6 +85,16 @@ export class TrainBuilderController {
return this.trainBuilderService.removeWagon(id, wagonId);
}
@Post(':id/wagons/:wagonId/maintenance')
@FleetManage()
@ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' })
sendWagonToMaintenance(
@Param('id', ParseUUIDPipe) id: string,
@Param('wagonId', ParseUUIDPipe) wagonId: string,
) {
return this.trainBuilderService.sendWagonToMaintenance(id, wagonId);
}
@Post(':id/reorder-wagons')
@FleetManage()
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })

View File

@@ -27,6 +27,15 @@ import {
const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100;
/** The one active (DRAFT/SCHEDULED/DISPATCHED) schedule surfaced per built train. */
export interface ActiveScheduleRef {
id: string;
status: string;
reference: string | null;
direction: string | null;
trainNumber: string | null;
}
/**
* Train Builder — assembles persistent fleet trains (code + 2+ locomotives +
* ordered wagons, all in one yard) that scheduling can later reference as a
@@ -50,10 +59,25 @@ export class TrainBuilderService {
}
const trainId = await this.dataSource.transaction(async (manager) => {
const code = dto.code.trim();
const existing = await manager.getRepository(Train).findOne({ where: { code } });
if (existing) {
throw new ConflictException(`Train code ${code} is already in use`);
const code = await this.generateTrainCode(manager);
// Friendly 409 before the partial unique indexes (the race-proof backstop):
// the typed pair may not collide with any train's pair or legacy number.
const importTrainNumber = dto.importTrainNumber.trim();
const exportTrainNumber = dto.exportTrainNumber.trim();
const numberClash: { code: string }[] = await manager.query(
`SELECT code FROM freight.trains
WHERE deleted_at IS NULL
AND (import_train_number IN ($1, $2)
OR export_train_number IN ($1, $2)
OR train_number IN ($1, $2))
LIMIT 1`,
[importTrainNumber, exportTrainNumber],
);
if (numberClash.length) {
throw new ConflictException(
`Train number ${importTrainNumber}/${exportTrainNumber} is already used by train ${numberClash[0].code}`,
);
}
const yard = await manager.getRepository(Yard).findOne({ where: { id: dto.currentYardId } });
@@ -76,6 +100,8 @@ export class TrainBuilderService {
status: Freight.TrainStatus.Available,
trainName: dto.trainName?.trim() || undefined,
notes: dto.notes?.trim() || undefined,
importTrainNumber,
exportTrainNumber,
}),
);
@@ -117,12 +143,42 @@ export class TrainBuilderService {
take,
});
const activeByTrain = await this.loadActiveScheduleByTrain(trains.map((t) => t.id));
return {
items: trains.map((train) => this.mapSummary(train)),
items: trains.map((train) => this.mapSummary(train, activeByTrain.get(train.id) ?? null)),
meta: buildPaginationMeta(total, page, pageSize),
};
}
/**
* One ACTIVE schedule per train for the page (prefer the DISPATCHED run,
* else the earliest upcoming departure) — feeds the list's direction tint
* and in-use train number.
*/
private async loadActiveScheduleByTrain(
trainIds: string[],
): Promise<Map<string, ActiveScheduleRef>> {
if (!trainIds.length) return new Map();
const rows: (ActiveScheduleRef & { trainId: string })[] = await this.dataSource.query(
`SELECT DISTINCT ON (tset.train_id)
tset.train_id AS "trainId",
ts.id,
ts.status,
ts.reference,
ts.direction,
ts.train_number AS "trainNumber"
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE tset.train_id = ANY($1)
AND ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
ORDER BY tset.train_id, (ts.status = 'DISPATCHED') DESC, ts.scheduled_departure_date ASC`,
[trainIds],
);
return new Map(rows.map(({ trainId, ...schedule }) => [trainId, schedule]));
}
/** Full consist: yard, ordered locomotives + wagons, totals vs. haul limits. */
async getComposition(id: string) {
const train = await this.dataSource.getRepository(Train).findOne({
@@ -139,17 +195,17 @@ export class TrainBuilderService {
});
if (!train) throw new NotFoundException(`Train ${id} not found`);
const schedules: { id: string; status: string; reference: string | null }[] =
await this.dataSource.query(
`SELECT ts.id, ts.status, ts.reference
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE tset.train_id = $1
AND ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
ORDER BY ts.scheduled_departure_date ASC`,
[id],
);
const schedules: ActiveScheduleRef[] = await this.dataSource.query(
`SELECT ts.id, ts.status, ts.reference, ts.direction,
ts.train_number AS "trainNumber"
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE tset.train_id = $1
AND ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
ORDER BY ts.scheduled_departure_date ASC`,
[id],
);
const locomotives = (train.locomotives ?? [])
.filter((link) => link.locomotive)
@@ -212,6 +268,8 @@ export class TrainBuilderService {
code: train.code,
trainName: train.trainName ?? null,
status: train.status,
importTrainNumber: train.importTrainNumber ?? null,
exportTrainNumber: train.exportTrainNumber ?? null,
notes: train.notes ?? null,
createdAt: train.createdAt,
currentYard: train.currentYard
@@ -341,7 +399,7 @@ export class TrainBuilderService {
if (!wagon || wagon.trainId !== train.id) {
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
}
if (wagon.currentTrainScheduleId) {
if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
);
@@ -356,6 +414,56 @@ export class TrainBuilderService {
return this.getComposition(id);
}
/**
* Detach one wagon AND flag it for maintenance: it leaves the consist and
* moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until
* it clears maintenance. The freed sequence gap is closed.
*/
async sendWagonToMaintenance(id: string, wagonId: string) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
if (!wagon || wagon.trainId !== train.id) {
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
}
if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
);
}
await manager.getRepository(Wagon).update(wagon.id, {
trainId: null,
sequenceNumber: null,
status: WagonStatus.Maintenance,
});
await this.resequenceWagons(manager, train.id);
});
return this.getComposition(id);
}
/**
* Schedule occupancy lives on TrainSetWagon slots (per-schedule snapshot),
* not on the Wagon entity — a wagon is busy when any live (DRAFT/SCHEDULED/
* DISPATCHED) schedule has it pinned to one of its slots.
*/
private async isWagonPinnedToLiveSchedule(
manager: EntityManager,
wagonId: string,
): Promise<boolean> {
const rows: { exists: boolean }[] = await manager.query(
`SELECT TRUE AS exists
FROM freight.train_set_wagons tsw
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
WHERE tsw.physical_wagon_id = $1
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
AND ts.deleted_at IS NULL
AND tsw.deleted_at IS NULL
LIMIT 1`,
[wagonId],
);
return rows.length > 0;
}
/** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */
async reorderWagons(id: string, dto: ReorderTrainWagonsDto) {
await this.dataSource.transaction(async (manager) => {
@@ -407,7 +515,30 @@ export class TrainBuilderService {
// ---------------------------------------------------------------- internals
private mapSummary(train: Train) {
/**
* System-assigned train code `TR-NNNNN`. Draws the next number from the
* highest existing `TR-` code and probes past any manual collision so the
* unique constraint never rejects the build.
*/
private async generateTrainCode(manager: EntityManager): Promise<string> {
const [row]: { max_seq: string | null }[] = await manager.query(
`SELECT MAX(CAST(SUBSTRING(code FROM '^TR-([0-9]+)$') AS INTEGER)) AS max_seq
FROM freight.trains
WHERE code ~ '^TR-[0-9]+$'`,
);
let seq = Number(row?.max_seq ?? 0) + 1;
for (let attempt = 0; attempt < 50; attempt += 1) {
const code = `TR-${String(seq).padStart(5, '0')}`;
const exists = await manager
.getRepository(Train)
.findOne({ where: { code }, withDeleted: true });
if (!exists) return code;
seq += 1;
}
throw new ConflictException('Could not allocate a unique train code');
}
private mapSummary(train: Train, activeSchedule: ActiveScheduleRef | null) {
const locomotives = [...(train.locomotives ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((link) => link.locomotive)
@@ -421,6 +552,9 @@ export class TrainBuilderService {
code: train.code,
trainName: train.trainName ?? null,
status: train.status,
importTrainNumber: train.importTrainNumber ?? null,
exportTrainNumber: train.exportTrainNumber ?? null,
activeSchedule,
createdAt: train.createdAt,
currentYard: train.currentYard
? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label }

View File

@@ -0,0 +1,13 @@
import { ArrayMaxSize, ArrayMinSize, IsArray, IsUUID } from 'class-validator';
/**
* OCC bulk accept-and-execute: the subset of PENDING request ids to execute
* now. Requests not listed (or that cannot be executed) stay PENDING.
*/
export class BulkFulfillTransferRequestsDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(200)
@IsUUID('all', { each: true })
requestIds!: string[];
}

View File

@@ -1,10 +1,20 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsInt,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
} from 'class-validator';
/**
* A count-only wagon-transfer request. The requester picks source yard, wagon
* type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks
* those at fulfilment.
* those at fulfilment. The quantity may not exceed the AVAILABLE wagons of that
* type currently in the source yard, and a reason is mandatory.
*/
export class CreateTransferRequestDto {
@IsUUID()
@@ -21,6 +31,12 @@ export class CreateTransferRequestDto {
@Max(1000)
quantity!: number;
@ApiProperty({ description: 'Why the wagons are needed — shown on the OCC queue' })
@IsString()
@IsNotEmpty()
@MaxLength(2000)
reason!: string;
@ApiPropertyOptional({ description: 'Optional note for the fulfilling staff' })
@IsOptional()
@IsString()

View File

@@ -59,4 +59,11 @@ export class WagonTransferRequest extends BaseEntity {
@Column({ name: 'note', type: 'text', nullable: true })
note?: string | null;
/**
* Why the wagons are needed — required for every new request and shown on
* the OCC queue. Nullable only for rows that predate the requirement.
*/
@Column({ name: 'reason', type: 'text', nullable: true })
reason?: string | null;
}

View File

@@ -15,7 +15,7 @@ export const WAGON_STATUSES = [
WagonStatus.ImportReady,
WagonStatus.ExportReady,
WagonStatus.Maintenance,
WagonStatus.Retired,
WagonStatus.Detained,
] as const;
export type WagonStatusType = (typeof WAGON_STATUSES)[number];

View File

@@ -19,6 +19,7 @@ import {
WagonTransferHistoryAll,
WagonTransferRequest,
} from '../../common/booking-guards';
import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto';
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
@@ -51,6 +52,22 @@ export class WagonTransferRequestsController {
return this.service.listRequests(status);
}
// NOTE: static routes (`history`, `bulk-fulfill`) MUST stay above `@Get(':id')`
// — Express matches in declaration order, so they would otherwise be captured
// by the `:id` param route (and rejected by ParseUUIDPipe).
@Post('bulk-fulfill')
@WagonTransferFulfill()
@ApiOperation({
summary:
'OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING)',
})
bulkFulfill(
@Body() dto: BulkFulfillTransferRequestsDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.bulkFulfill(dto.requestIds, user?.id);
}
// NOTE: the two `history` routes MUST stay above `@Get(':id')` — Express
// matches in declaration order, so `/history` would otherwise be captured by
// the `:id` param route (and rejected by ParseUUIDPipe).

View File

@@ -1,4 +1,4 @@
import { WagonTransferRequestStatus } from '@edr/types';
import { WagonStatus, WagonTransferRequestStatus } from '@edr/types';
import {
BadRequestException,
ConflictException,
@@ -48,7 +48,12 @@ export class WagonTransferRequestsService {
private readonly wagonsService: WagonsService,
) {}
/** Record a PENDING request. Count-only — no wagons are picked here. */
/**
* Record a PENDING request. Count-only — no wagons are picked here, but the
* count is capped at the AVAILABLE wagons of that type currently sitting in
* the source yard: staff may only ask for wagons that are actually there to
* give. A reason is mandatory and is shown on the OCC queue.
*/
async createRequest(
dto: CreateTransferRequestDto,
userId?: string | null,
@@ -58,6 +63,14 @@ export class WagonTransferRequestsService {
'Source and destination yard must be different',
);
}
const available = await this.countAvailable(dto.fromYardId, dto.wagonTypeId);
if (available < dto.quantity) {
throw new BadRequestException(
available === 0
? 'No available wagons of this type in the source yard'
: `Only ${available} available wagon(s) of this type in the source yard — request at most ${available}`,
);
}
const request = this.requestRepo.create({
fromYardId: dto.fromYardId,
toYardId: dto.toYardId,
@@ -65,12 +78,24 @@ export class WagonTransferRequestsService {
quantity: dto.quantity,
status: WagonTransferRequestStatus.Pending,
requestedByUserId: userId ?? null,
reason: dto.reason,
note: dto.note ?? null,
});
const saved = await this.requestRepo.save(request);
return this.findById(saved.id);
}
/** AVAILABLE wagons of `wagonTypeId` currently in `yardId`. */
private countAvailable(yardId: string, wagonTypeId: string): Promise<number> {
return this.wagonRepo.count({
where: {
currentYardId: yardId,
wagonTypeId,
status: WagonStatus.Available,
},
});
}
/** Requests, newest first, optionally filtered by status (OCC queue = PENDING). */
async listRequests(
status?: WagonTransferRequestStatus,
@@ -136,6 +161,14 @@ export class WagonTransferRequestsService {
.join(', ')}`,
);
}
const notAvailable = wagons.filter((w) => w.status !== WagonStatus.Available);
if (notAvailable.length) {
throw new BadRequestException(
`These wagons are not available: ${notAvailable
.map((w) => w.wagonNumber)
.join(', ')}`,
);
}
// Reuse the audited bulk-transfer path (writes wagon_movements ledger rows,
// each stamped with this request's id so history can link them back).
@@ -152,6 +185,71 @@ export class WagonTransferRequestsService {
return this.findById(id);
}
/**
* OCC accepts AND executes a subset of pending requests in one action. For
* each selected request the system auto-picks the required number of
* AVAILABLE wagons of the requested type from the source yard (lowest wagon
* number first) and runs the audited transfer. A request that cannot be
* executed — already decided, or not enough available wagons left after the
* ones processed before it — is SKIPPED and simply stays PENDING, visible to
* both teams; nothing is rolled back for the others.
*/
async bulkFulfill(
requestIds: string[],
userId?: string | null,
): Promise<{
fulfilled: WagonTransferRequest[];
skipped: Array<{ id: string; reason: string }>;
}> {
const fulfilled: WagonTransferRequest[] = [];
const skipped: Array<{ id: string; reason: string }> = [];
// Sequential on purpose: each executed transfer moves wagons out of the
// source yard, and the next request's auto-pick must see that new state.
for (const id of [...new Set(requestIds)]) {
const request = await this.requestRepo.findOne({ where: { id } });
if (!request) {
skipped.push({ id, reason: 'Request not found' });
continue;
}
if (request.status !== WagonTransferRequestStatus.Pending) {
skipped.push({
id,
reason: `Already ${request.status.toLowerCase()}`,
});
continue;
}
const wagons = await this.wagonRepo.find({
where: {
currentYardId: request.fromYardId,
wagonTypeId: request.wagonTypeId,
status: WagonStatus.Available,
},
order: { wagonNumber: 'ASC' },
take: request.quantity,
});
if (wagons.length < request.quantity) {
skipped.push({
id,
reason: `Only ${wagons.length} of ${request.quantity} wagon(s) available in the source yard — left pending`,
});
continue;
}
await this.wagonsService.bulkTransfer(
{ wagonIds: wagons.map((w) => w.id), toYardId: request.toYardId },
userId,
{ transferRequestId: request.id },
);
request.status = WagonTransferRequestStatus.Fulfilled;
request.fulfilledByUserId = userId ?? null;
request.fulfilledAt = new Date();
await this.requestRepo.save(request);
fulfilled.push(await this.findById(id));
}
return { fulfilled, skipped };
}
/**
* Per-user transfer history: the requests a user filed OR fulfilled, plus the
* individual wagons they physically moved (linked back to their request when

View File

@@ -0,0 +1,13 @@
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
/**
* Human-readable actor label for audit stamps (`performed_by` / `moved_by`).
* Prefers a display name, then username/email, so the activity log shows a
* person rather than a UUID. Returns undefined when there is no authenticated
* user (internal/cron calls), letting callers fall back to their prior value.
*/
export function actorLabel(user?: TCurrentUser | null): string | undefined {
if (!user) return undefined;
const name = user.name?.en?.trim() || user.name?.am?.trim();
return name || user.username || user.email || user.id || undefined;
}

View File

@@ -144,7 +144,7 @@ export class SchedulingReadFacade {
`SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId"
FROM freight.wagons
WHERE deleted_at IS NULL
AND UPPER(status) NOT IN ('RETIRED', 'MAINTENANCE')
AND UPPER(status) NOT IN ('DETAINED', 'MAINTENANCE')
ORDER BY wagon_number ASC`,
);
}

View File

@@ -330,6 +330,80 @@ export class WarehouseFeeService {
return best;
}
/**
* On-time dispatch rate: the share of items dispatched in the last N days that
* LEFT before their storage free-days expired — CEIL((dispatchedarrived)/day)
* <= freeDays, with freeDays resolved by the same rule matching the fee engine
* uses (bestRule over active STORAGE_FEE rules). onTimePct is null when there
* is nothing to measure (e.g. no dispatched items / no storage rules).
*/
async onTimeDispatchStats(
windowDays = 90,
): Promise<{ sampleSize: number; onTimeCount: number; onTimePct: number | null }> {
const storageRules = (
await this.feeRuleRepository.findAll({ where: { isActive: true } })
).filter((r) => r.ruleType === 'STORAGE_FEE');
// Batched attribute pull mirroring loadItem's scope joins (multi-row) — only
// the fields bestRule/matchScore reads, plus the two clock timestamps.
const rows: Array<
ItemAttributes & { arrivedAt: string; dispatchedAt: string }
> = await this.dataSource.query(
`SELECT inv.arrived_at AS "arrivedAt",
inv.dispatched_at AS "dispatchedAt",
inv.warehouse_id AS "warehouseId",
inv.yard_id AS "yardId",
inv.zone_id AS "zoneId",
w.facility_id AS "facilityId",
b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection",
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
NULL AS "vehicleType"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
LEFT JOIN freight.cargo_types booking_cgt ON booking_cgt.id = b.cargo_type_id
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
LEFT JOIN LATERAL (
SELECT bc.container_type_id
FROM freight.booking_container bc
WHERE bc.booking_id = inv.booking_id
AND bc.deleted_at IS NULL
AND bc.container_type_id IS NOT NULL
ORDER BY bc.created_at ASC
LIMIT 1
) booking_container_type ON true
LEFT JOIN freight.container_types booking_ctt ON booking_ctt.id = booking_container_type.container_type_id
WHERE inv.deleted_at IS NULL
AND inv.arrived_at IS NOT NULL
AND inv.dispatched_at IS NOT NULL
AND inv.dispatched_at > now() - ($1 || ' days')::interval`,
[windowDays],
);
let onTimeCount = 0;
for (const row of rows) {
const freeDays = this.bestRule(storageRules, row)?.freeDays ?? 0;
const elapsed = Math.max(
0,
Math.ceil(
(new Date(row.dispatchedAt).getTime() - new Date(row.arrivedAt).getTime()) / MS_PER_DAY,
),
);
if (elapsed <= freeDays) onTimeCount += 1;
}
const sampleSize = rows.length;
return {
sampleSize,
onTimeCount,
onTimePct: sampleSize ? Math.round((onTimeCount / sampleSize) * 100) : null,
};
}
private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' {
return currency === 'ETB' ? 'ETB' : 'USD';
}

View File

@@ -1,7 +1,10 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Request, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { actorLabel } from './current-actor.util';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BulkReceiveDto } from './dto/bulk-receive.dto';
@@ -73,6 +76,35 @@ export class WarehouseInventoryController {
return this.inventoryService.zoneOccupancy(yardId);
}
@Get('throughput')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Received-vs-dispatched throughput time series (week/month/year)' })
throughput(@Query('granularity') granularity?: string) {
const g = granularity === 'week' || granularity === 'year' ? granularity : 'month';
return this.inventoryService.throughput(g);
}
@Get('dwell-stats')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Dwell time of in-warehouse items: average + aging buckets' })
dwellStats() {
return this.inventoryService.dwellStats();
}
@Get('cycle-stats')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Average stage cycle times over recently dispatched items' })
cycleStats() {
return this.inventoryService.cycleStats();
}
@Get('gate-stats')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Gate/dock throughput: cleared today, turnaround, hourly clearances' })
gateStats() {
return this.inventoryService.gateStats();
}
@Post('auto-unload-arrived')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
@@ -98,7 +130,8 @@ export class WarehouseInventoryController {
@Post('receive-bulk')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
receiveBulk(@Body() dto: BulkReceiveDto) {
receiveBulk(@Body() dto: BulkReceiveDto, @CurrentUser() user: TCurrentUser) {
dto.performedBy = actorLabel(user) ?? dto.performedBy;
return this.inventoryService.bulkReceive(dto);
}
@@ -144,15 +177,16 @@ export class WarehouseInventoryController {
loadItemsOntoTrain(
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
@Body() dto: { inventoryIds: string[]; performedBy?: string },
@CurrentUser() user: TCurrentUser,
) {
return this.inventoryService.loadItemsOntoTrain(scheduleId, dto.inventoryIds ?? [], dto.performedBy);
return this.inventoryService.loadItemsOntoTrain(scheduleId, dto.inventoryIds ?? [], actorLabel(user) ?? dto.performedBy);
}
@Post('bulk-dispatch-export')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) {
return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], dto.performedBy);
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }, @CurrentUser() user: TCurrentUser) {
return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], actorLabel(user) ?? dto.performedBy);
}
@Post('bulk-mark-inspected')
@@ -175,8 +209,12 @@ export class WarehouseInventoryController {
@Post(':id/gate-clearance')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.gatePass)
@ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' })
gateClearance(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.gateClearance(id, performedBy);
gateClearance(
@Param('id', ParseUUIDPipe) id: string,
@Body('performedBy') performedBy: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.inventoryService.gateClearance(id, actorLabel(user) ?? performedBy);
}
@Get('import/arrive-queue')
@@ -201,10 +239,10 @@ export class WarehouseInventoryController {
warehouseId?: string;
performedBy?: string;
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
}) {
}, @CurrentUser() user: TCurrentUser) {
return this.inventoryService.autoUnloadArrivedBookings(
dto.scheduleId,
dto.performedBy,
actorLabel(user) ?? dto.performedBy,
dto.warehouseId,
dto.assignments,
);
@@ -246,8 +284,8 @@ export class WarehouseInventoryController {
@Post('export/auto-unload-at-djibouti')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' })
autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }) {
return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, dto.performedBy);
autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }, @CurrentUser() user: TCurrentUser) {
return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, actorLabel(user) ?? dto.performedBy);
}
@Get('import/pickup-ready-queue')
@@ -274,14 +312,16 @@ export class WarehouseInventoryController {
@Post('receive')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({ summary: 'Receive inventory at a warehouse location' })
receive(@Body() dto: ReceiveWarehouseInventoryDto) {
receive(@Body() dto: ReceiveWarehouseInventoryDto, @CurrentUser() user: TCurrentUser) {
dto.performedBy = actorLabel(user) ?? dto.performedBy;
return this.inventoryService.receive(dto);
}
@Post('reserve')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' })
reserve(@Body() dto: ReserveInventoryDto) {
reserve(@Body() dto: ReserveInventoryDto, @CurrentUser() user: TCurrentUser) {
dto.performedBy = actorLabel(user) ?? dto.performedBy;
return this.inventoryService.reserve(dto);
}
@@ -316,15 +356,19 @@ export class WarehouseInventoryController {
@Post(':id/store')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) {
return this.inventoryService.store(id, dto.performedBy, dto);
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto, @CurrentUser() user: TCurrentUser) {
return this.inventoryService.store(id, actorLabel(user) ?? dto.performedBy, dto);
}
@Post(':id/ready-for-loading')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' })
readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.readyForLoading(id, performedBy);
readyForLoading(
@Param('id', ParseUUIDPipe) id: string,
@Body('performedBy') performedBy: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.inventoryService.readyForLoading(id, actorLabel(user) ?? performedBy);
}
@Post(':id/load')
@@ -337,8 +381,12 @@ export class WarehouseInventoryController {
@Post(':id/ready-for-pickup')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' })
readyForPickup(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.readyForPickup(id, performedBy);
readyForPickup(
@Param('id', ParseUUIDPipe) id: string,
@Body('performedBy') performedBy: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.inventoryService.readyForPickup(id, actorLabel(user) ?? performedBy);
}
@Post(':id/release')
@@ -400,10 +448,11 @@ export class WarehouseInventoryController {
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: ApproveDeliveryDto,
@Request() req: { user?: { id?: string; sub?: string } },
@CurrentUser() user: TCurrentUser,
) {
return this.inventoryService.approveDeliveryForBooking(
bookingId,
req.user?.id ?? req.user?.sub,
user?.id ?? req.user?.id ?? req.user?.sub,
dto.signerName,
);
}
@@ -465,14 +514,19 @@ export class WarehouseInventoryController {
@Post(':id/deliver')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver)
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto, @CurrentUser() user: TCurrentUser) {
dto.performedBy = actorLabel(user) ?? dto.performedBy;
return this.inventoryService.deliver(id, dto);
}
@Patch(':id/dispatch')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
@ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' })
dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.dispatch(id, performedBy);
dispatch(
@Param('id', ParseUUIDPipe) id: string,
@Body('performedBy') performedBy: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.inventoryService.dispatch(id, actorLabel(user) ?? performedBy);
}
}

View File

@@ -406,12 +406,14 @@ export class WarehouseInventoryService {
*/
async opsStats(): Promise<{
receivedToday: number;
receivedYesterday: number;
pendingInspection: number;
trucksOnSite: number;
itemsAging: number;
}> {
const [row]: Array<{
receivedToday: number;
receivedYesterday: number;
pendingInspection: number;
trucksOnSite: number;
itemsAging: number;
@@ -419,6 +421,8 @@ export class WarehouseInventoryService {
`SELECT
(SELECT count(*)::int FROM freight.warehouse_inventory
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE) AS "receivedToday",
(SELECT count(*)::int FROM freight.warehouse_inventory
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE - 1) AS "receivedYesterday",
(SELECT count(*)::int FROM freight.warehouse_inventory
WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection",
(SELECT count(*)::int FROM freight.customer_truck_assignments
@@ -430,12 +434,208 @@ export class WarehouseInventoryService {
);
return {
receivedToday: row?.receivedToday ?? 0,
receivedYesterday: row?.receivedYesterday ?? 0,
pendingInspection: row?.pendingInspection ?? 0,
trucksOnSite: row?.trucksOnSite ?? 0,
itemsAging: row?.itemsAging ?? 0,
};
}
/** In-warehouse statuses used by the dwell / aging metrics. */
private readonly IN_WAREHOUSE_STATUSES = [
'RECEIVED',
'UNLOADED',
'STORED',
'RESERVED',
'READY_FOR_LOADING',
'READY_FOR_PICKUP',
];
/**
* Dwell time of items still in the warehouse: average days held plus a count
* per aging bucket (03 / 47 / 814 / 15+). Clock starts at arrival (falling
* back to created_at). Powers the dwell / aging histogram.
*/
async dwellStats(): Promise<{
avgDwellDays: number;
inWarehouseCount: number;
buckets: Array<{ key: string; label: string; count: number }>;
}> {
const [row]: Array<{
avgDwellDays: number | null;
inWarehouseCount: number;
b0: number;
b1: number;
b2: number;
b3: number;
}> = await this.dataSource.query(
`WITH held AS (
SELECT EXTRACT(EPOCH FROM (now() - COALESCE(arrived_at, created_at))) / 86400.0 AS age_days
FROM freight.warehouse_inventory
WHERE deleted_at IS NULL
AND status = ANY($1)
)
SELECT COALESCE(round(avg(age_days)::numeric, 1), 0)::float8 AS "avgDwellDays",
count(*)::int AS "inWarehouseCount",
count(*) FILTER (WHERE age_days < 4)::int AS b0,
count(*) FILTER (WHERE age_days >= 4 AND age_days < 8)::int AS b1,
count(*) FILTER (WHERE age_days >= 8 AND age_days < 15)::int AS b2,
count(*) FILTER (WHERE age_days >= 15)::int AS b3
FROM held`,
[this.IN_WAREHOUSE_STATUSES],
);
return {
avgDwellDays: row?.avgDwellDays ?? 0,
inWarehouseCount: row?.inWarehouseCount ?? 0,
buckets: [
{ key: '0-3', label: '03 days', count: row?.b0 ?? 0 },
{ key: '4-7', label: '47 days', count: row?.b1 ?? 0 },
{ key: '8-14', label: '814 days', count: row?.b2 ?? 0 },
{ key: '15+', label: '15+ days', count: row?.b3 ?? 0 },
],
};
}
/**
* Average stage cycle times over items dispatched in the last 90 days:
* arrived→ready, ready→loaded, loaded→dispatched, and the total
* arrived→dispatched (dock-to-dispatch). Days, to one decimal.
*/
async cycleStats(): Promise<{
sampleSize: number;
avgDockToDispatchDays: number;
stages: Array<{ key: string; label: string; avgDays: number }>;
}> {
const gapDays = (from: string, to: string) =>
`round((avg(EXTRACT(EPOCH FROM (${to} - ${from})) / 86400.0) FILTER (WHERE ${from} IS NOT NULL AND ${to} IS NOT NULL))::numeric, 1)::float8`;
const [row]: Array<{
sampleSize: number;
total: number | null;
arrivedReady: number | null;
readyLoaded: number | null;
loadedDispatched: number | null;
}> = await this.dataSource.query(
`SELECT count(*)::int AS "sampleSize",
${gapDays('arrived_at', 'dispatched_at')} AS "total",
${gapDays('arrived_at', 'ready_for_loading_at')} AS "arrivedReady",
${gapDays('ready_for_loading_at', 'loaded_at')} AS "readyLoaded",
${gapDays('loaded_at', 'dispatched_at')} AS "loadedDispatched"
FROM freight.warehouse_inventory
WHERE deleted_at IS NULL
AND arrived_at IS NOT NULL
AND dispatched_at IS NOT NULL
AND dispatched_at > now() - interval '90 days'`,
);
return {
sampleSize: row?.sampleSize ?? 0,
avgDockToDispatchDays: row?.total ?? 0,
stages: [
{ key: 'arrived-ready', label: 'Arrived → Ready', avgDays: row?.arrivedReady ?? 0 },
{ key: 'ready-loaded', label: 'Ready → Loaded', avgDays: row?.readyLoaded ?? 0 },
{ key: 'loaded-dispatched', label: 'Loaded → Dispatched', avgDays: row?.loadedDispatched ?? 0 },
],
};
}
/**
* Gate / dock throughput: items cleared through the gate today, the average
* arrival→gate-clearance turnaround (hours, last 30 days), and gate clearances
* bucketed per hour over the last 24 hours. Powers the gate throughput card.
*/
async gateStats(): Promise<{
clearedToday: number;
avgTurnaroundHours: number | null;
byHour: Array<{ hour: string; count: number }>;
}> {
const [scalar]: Array<{ clearedToday: number; avgTurnaroundHours: number | null }> =
await this.dataSource.query(
`SELECT
count(*) FILTER (WHERE gate_cleared_at::date = CURRENT_DATE)::int AS "clearedToday",
round(
avg(EXTRACT(EPOCH FROM (gate_cleared_at - arrived_at)) / 3600.0)
FILTER (
WHERE gate_cleared_at IS NOT NULL AND arrived_at IS NOT NULL
AND gate_cleared_at > now() - interval '30 days'
)::numeric,
1
)::float8 AS "avgTurnaroundHours"
FROM freight.warehouse_inventory
WHERE deleted_at IS NULL`,
);
const byHour: Array<{ hour: string; count: number }> = await this.dataSource.query(
`WITH hours AS (
SELECT gs AS h
FROM generate_series(
date_trunc('hour', now()) - interval '23 hours',
date_trunc('hour', now()),
interval '1 hour'
) gs
)
SELECT to_char(hours.h, 'HH24:00') AS hour,
COALESCE(g.cnt, 0)::int AS count
FROM hours
LEFT JOIN (
SELECT date_trunc('hour', gate_cleared_at) AS ph, count(*) AS cnt
FROM freight.warehouse_inventory
WHERE deleted_at IS NULL AND gate_cleared_at IS NOT NULL
GROUP BY 1
) g ON g.ph = hours.h
ORDER BY hours.h`,
);
return {
clearedToday: scalar?.clearedToday ?? 0,
avgTurnaroundHours: scalar?.avgTurnaroundHours ?? null,
byHour,
};
}
/**
* Received-vs-dispatched throughput as a server-side time series. Buckets by
* date_trunc over the last N periods (8 weeks / 12 months / 5 years) with a
* generate_series so empty periods still return a zero row — replaces the
* client-side approach that downloaded the whole inventory to bucket it.
*/
async throughput(
granularity: 'week' | 'month' | 'year' = 'month',
): Promise<Array<{ periodStart: string; received: number; dispatched: number }>> {
// Whitelist the unit — it is interpolated into date_trunc / interval literals.
const unit: 'week' | 'month' | 'year' = ['week', 'month', 'year'].includes(granularity)
? granularity
: 'month';
const back = unit === 'week' ? 7 : unit === 'month' ? 11 : 4;
const rows: Array<{ periodStart: string; received: number; dispatched: number }> =
await this.dataSource.query(
`WITH periods AS (
SELECT gs AS period_start
FROM generate_series(
date_trunc('${unit}', now()) - ($1 || ' ${unit}')::interval,
date_trunc('${unit}', now()),
'1 ${unit}'::interval
) gs
)
SELECT p.period_start AS "periodStart",
COALESCE(r.cnt, 0)::int AS received,
COALESCE(d.cnt, 0)::int AS dispatched
FROM periods p
LEFT JOIN (
SELECT date_trunc('${unit}', arrived_at) AS ps, count(*) AS cnt
FROM freight.warehouse_inventory
WHERE deleted_at IS NULL AND arrived_at IS NOT NULL
GROUP BY 1
) r ON r.ps = p.period_start
LEFT JOIN (
SELECT date_trunc('${unit}', dispatched_at) AS ps, count(*) AS cnt
FROM freight.warehouse_inventory
WHERE deleted_at IS NULL AND dispatched_at IS NOT NULL
GROUP BY 1
) d ON d.ps = p.period_start
ORDER BY p.period_start`,
[back],
);
return rows;
}
/**
* Live occupancy per zone: rated capacity vs the weight/items currently held
* (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard

View File

@@ -1,7 +1,10 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { actorLabel } from './current-actor.util';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
@@ -17,7 +20,8 @@ export class WarehouseInvoiceController {
@Post('warehouse-inventory/:id/generate-fee-invoice')
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate)
@ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' })
generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto) {
generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto, @CurrentUser() user: TCurrentUser) {
dto.performedBy = actorLabel(user) ?? dto.performedBy;
return this.invoiceService.generateForInventory(id, dto);
}

View File

@@ -96,6 +96,13 @@ export class WarehouseRulesController {
return this.feeService.accrualDashboard(billingCurrency);
}
@Get('warehouse-fees/on-time-dispatch')
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
@ApiOperation({ summary: 'On-time dispatch rate — items that left before storage free-days expired' })
onTimeDispatch() {
return this.feeService.onTimeDispatchStats();
}
@Post('warehouse-fees/accrual/:inventoryId/acknowledge')
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
@ApiOperation({ summary: 'Acknowledge / snooze an item fee-accrual alert' })

View File

@@ -1,19 +1,13 @@
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Anchor,
Button,
Modal,
Select,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { Button, Modal, Stack, Text, Textarea } from "@mantine/core";
import {
Check,
FilePen,
FileSignature,
MessageSquareWarning,
RefreshCw,
ShieldCheck,
Sparkles,
XCircle,
@@ -23,6 +17,7 @@ import type { Freight } from "@edr/types";
import { api } from "@/services/api";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { ContractDocumentEditorModal } from "@/components/contracts/ContractDocumentEditorModal";
import type { useContractMutations } from "@/hooks/contracts/useContracts";
/** Dropdown-settings code holding the admin-configured contract validity days. */
@@ -54,8 +49,8 @@ export function ContractActionsToolbar({
const navigate = useNavigate();
const { status } = contract;
const [acceptOpen, setAcceptOpen] = useState(false);
const [validityDays, setValidityDays] = useState<string | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
const [changesOpen, setChangesOpen] = useState(false);
const [changesNote, setChangesNote] = useState("");
const [rejectOpen, setRejectOpen] = useState(false);
@@ -76,12 +71,6 @@ export function ContractActionsToolbar({
.map((o) => ({ value: String(o.value), label: o.label })),
[validitySetting],
);
// Default the selection to the first configured option when the dialog opens.
useEffect(() => {
if (acceptOpen && !validityDays && validityOptions.length > 0) {
setValidityDays(validityOptions[0].value);
}
}, [acceptOpen, validityDays, validityOptions]);
if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) {
return null;
@@ -98,10 +87,16 @@ export function ContractActionsToolbar({
}
const canAccept = status === "SUBMITTED";
// Generation only becomes available once EVERY approval step is complete and
// the contract reaches APPROVED. While any step is still pending the contract
// stays in PENDING_APPROVAL, so this button does not appear after only the
// first (line-staff) approval — the director step must land first.
// While the contract is PENDING_APPROVAL and NO approver has acted yet, staff
// can edit this contract's articles and (re)generate its PDF. The first
// approval action locks the document.
const docLocked =
status !== "PENDING_APPROVAL" ||
(contract.approvalSteps ?? []).some((s) => s.status !== "PENDING");
const canEditGenerate = status === "PENDING_APPROVAL" && !docLocked;
const documentGenerated = Boolean(contract.contractGeneratedAt);
// Legacy fallback: if a contract ever lands on APPROVED without a document
// (older flow), still offer a manual generate that moves it to CONTRACT_READY.
const needsManualGenerate =
status === "APPROVED" && !contract.contractGeneratedAt;
// Signing now happens on the contract VIEW page (staff must open and read the
@@ -131,7 +126,10 @@ export function ContractActionsToolbar({
fullWidth
color="edr-green"
leftSection={<Check size={16} />}
onClick={() => setAcceptOpen(true)}
onClick={() => {
setEditorMode("accept");
setEditorOpen(true);
}}
>
Accept for approval
</Button>
@@ -156,6 +154,43 @@ export function ContractActionsToolbar({
</>
)}
{canEditGenerate && (
<>
<Text size="xs" c="dimmed">
{documentGenerated
? "Document generated. Approvers can now review it. You can still edit and regenerate until the first approval."
: "Review the contract document, edit its articles if needed, then generate it so approvers can review."}
</Text>
<Button
fullWidth
variant="light"
color="gray"
leftSection={<FilePen size={16} />}
onClick={() => {
setEditorMode("edit");
setEditorOpen(true);
}}
>
Edit contract articles
</Button>
<Button
fullWidth
color="edr-green"
leftSection={
documentGenerated ? (
<RefreshCw size={16} />
) : (
<Sparkles size={16} />
)
}
loading={mutations.generateContract.isPending}
onClick={() => mutations.generateContract.mutate()}
>
{documentGenerated ? "Regenerate contract" : "Generate contract"}
</Button>
</>
)}
{needsManualGenerate && (
<Button
fullWidth
@@ -198,6 +233,7 @@ export function ContractActionsToolbar({
the customer creates the booking in the portal. */}
{!canAccept &&
!canEditGenerate &&
!needsManualGenerate &&
!canViewContract &&
!canReviewClearance && (
@@ -208,62 +244,28 @@ export function ContractActionsToolbar({
)}
</Stack>
{/* Accept — sets the contract validity window */}
<Modal
opened={acceptOpen}
onClose={() => setAcceptOpen(false)}
title="Accept contract for approval"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Pick the contract validity window, then start the approval chain.
</Text>
{validityOptions.length > 0 ? (
<Select
label="Validity"
placeholder="Select a validity period"
data={validityOptions}
value={validityDays}
onChange={setValidityDays}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
/>
) : (
<Text size="sm" c="orange.7">
{validityLoading
? "Loading validity periods…"
: "No validity periods are configured yet. Add them under "}
{!validityLoading && (
<Anchor
href="/dashboard/dropdown-settings"
onClick={(e) => {
e.preventDefault();
navigate("/dashboard/dropdown-settings");
}}
>
Dropdown Settings
</Anchor>
)}
{!validityLoading && "."}
</Text>
)}
<Button
color="edr-green"
loading={mutations.staffAccept.isPending}
disabled={!validityDays}
onClick={() => {
const days = Number(validityDays);
if (!days) return;
mutations.staffAccept.mutate(days, {
onSuccess: () => setAcceptOpen(false),
});
}}
>
Accept
</Button>
</Stack>
</Modal>
{/* Accept / edit — review + optionally edit this contract's articles */}
<ContractDocumentEditorModal
opened={editorOpen}
onClose={() => setEditorOpen(false)}
contractId={contract.id}
mode={editorMode}
validityOptions={validityOptions}
validityLoading={validityLoading}
accepting={mutations.staffAccept.isPending}
saving={mutations.updateDocument.isPending}
onAccept={(days, snapshot) =>
mutations.staffAccept.mutate(
{ validityDays: days, documentSnapshot: snapshot },
{ onSuccess: () => setEditorOpen(false) },
)
}
onSaveEdit={(snapshot) =>
mutations.updateDocument.mutate(snapshot, {
onSuccess: () => setEditorOpen(false),
})
}
/>
{/* Request changes */}
<Modal

View File

@@ -0,0 +1,413 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
Box,
Button,
Divider,
Group,
Loader,
Modal,
Select,
Stack,
Text,
Textarea,
TextInput,
Tooltip,
} from "@mantine/core";
import {
ArrowDown,
ArrowUp,
FileText,
Info,
Lock,
Plus,
Trash2,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { contractsService } from "@/services/contracts.service";
/** New client-side article id (server keeps whatever id we send). */
function newArticleId(): string {
const c = globalThis.crypto;
if (c && typeof c.randomUUID === "function") return c.randomUUID();
return `art-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
}
interface EditableArticle {
id: string;
title: string;
body: string;
}
export interface ContractDocumentEditorModalProps {
opened: boolean;
onClose: () => void;
contractId: string;
/**
* "accept" — shown from the Accept-for-approval action: pick a validity window
* and (optionally) edit the articles, then start the approval chain.
* "edit" — re-edit the frozen articles of an already-accepted contract before
* generating/regenerating its PDF.
*/
mode: "accept" | "edit";
/** Validity options (accept mode only). */
validityOptions?: Array<{ value: string; label: string }>;
validityLoading?: boolean;
accepting?: boolean;
saving?: boolean;
onAccept?: (
validityDays: number,
snapshot: Freight.IContractDocumentSnapshot,
) => void;
onSaveEdit?: (snapshot: Freight.IContractDocumentSnapshot) => void;
}
/**
* Per-contract contract-document editor. Loads the resolved template (or this
* contract's frozen snapshot) and lets staff add/remove/reorder/edit articles
* for THIS contract only — it never writes back to the shared six templates.
*/
export function ContractDocumentEditorModal({
opened,
onClose,
contractId,
mode,
validityOptions = [],
validityLoading = false,
accepting = false,
saving = false,
onAccept,
onSaveEdit,
}: ContractDocumentEditorModalProps) {
const { data: draft, isLoading } = useQuery({
queryKey: ["contracts", contractId, "document-draft"],
queryFn: () => contractsService.getContractDocumentDraft(contractId),
enabled: opened && Boolean(contractId),
// Always refetch the current draft when the dialog opens.
staleTime: 0,
});
const [documentTitle, setDocumentTitle] = useState("");
const [whereasClauses, setWhereasClauses] = useState<string[]>([]);
const [articles, setArticles] = useState<EditableArticle[]>([]);
const [validityDays, setValidityDays] = useState<string | null>(null);
// Seed the editor from the loaded draft whenever the dialog (re)opens.
useEffect(() => {
if (!opened || !draft) return;
setDocumentTitle(draft.documentTitle ?? "");
setWhereasClauses(draft.whereasClauses ?? []);
setArticles(
(draft.articles ?? []).map((a) => ({
id: a.id || newArticleId(),
title: a.title,
body: a.body,
})),
);
}, [opened, draft]);
// Default validity to the first configured option (accept mode).
useEffect(() => {
if (mode === "accept" && !validityDays && validityOptions.length > 0) {
setValidityDays(validityOptions[0].value);
}
}, [mode, validityDays, validityOptions]);
const locked = mode === "edit" && Boolean(draft?.locked);
const moveArticle = (index: number, delta: number) => {
setArticles((prev) => {
const next = [...prev];
const target = index + delta;
if (target < 0 || target >= next.length) return prev;
[next[index], next[target]] = [next[target], next[index]];
return next;
});
};
const updateArticle = (id: string, patch: Partial<EditableArticle>) =>
setArticles((prev) =>
prev.map((a) => (a.id === id ? { ...a, ...patch } : a)),
);
const removeArticle = (id: string) =>
setArticles((prev) => prev.filter((a) => a.id !== id));
const addArticle = () =>
setArticles((prev) => [
...prev,
{ id: newArticleId(), title: "", body: "" },
]);
const buildSnapshot = (): Freight.IContractDocumentSnapshot => ({
code: draft?.code ?? null,
name: draft?.name ?? null,
documentTitle: documentTitle.trim() || null,
whereasClauses: whereasClauses
.map((c) => c.trim())
.filter((c) => c.length > 0),
articles: articles
.filter((a) => a.title.trim().length > 0 || a.body.trim().length > 0)
.map((a, index) => ({
id: a.id,
title: a.title.trim(),
body: a.body,
order: index + 1,
})),
});
const hasArticles = useMemo(
() => articles.some((a) => a.title.trim() || a.body.trim()),
[articles],
);
const submit = () => {
const snapshot = buildSnapshot();
if (mode === "accept") {
const days = Number(validityDays);
if (!days) return;
onAccept?.(days, snapshot);
} else {
onSaveEdit?.(snapshot);
}
};
const submitting = accepting || saving;
const canSubmit =
hasArticles &&
!locked &&
(mode === "edit" || Boolean(validityDays)) &&
!submitting;
return (
<Modal
opened={opened}
onClose={onClose}
size="xl"
centered
radius="md"
title={
<Group gap="xs">
<FileText size={18} />
<Text fw={700}>
{mode === "accept"
? "Review contract document & accept"
: "Edit contract document"}
</Text>
</Group>
}
>
{isLoading ? (
<Group justify="center" py="xl">
<Loader size="sm" color="gray" />
<Text size="sm" c="dimmed">
Loading document
</Text>
</Group>
) : (
<Stack gap="md">
<Alert
variant="light"
color={locked ? "orange" : "blue"}
icon={locked ? <Lock size={16} /> : <Info size={16} />}
>
{locked
? "This document is locked — an approver has already acted, so it can no longer be edited."
: "Edits apply to THIS contract only. The six shared templates are never changed."}
</Alert>
<TextInput
label="Document title"
placeholder="e.g. Bulk Cargo Transportation and Customs Clearance Services"
value={documentTitle}
onChange={(e) => setDocumentTitle(e.currentTarget.value)}
disabled={locked}
/>
<Box>
<Group justify="space-between" mb={6}>
<Text size="sm" fw={600}>
WHEREAS recitals
</Text>
<Button
size="compact-xs"
variant="light"
color="gray"
leftSection={<Plus size={13} />}
disabled={locked}
onClick={() => setWhereasClauses((p) => [...p, ""])}
>
Add recital
</Button>
</Group>
{whereasClauses.length === 0 ? (
<Text size="xs" c="dimmed">
No recitals.
</Text>
) : (
<Stack gap="xs">
{whereasClauses.map((clause, i) => (
<Group key={i} gap="xs" wrap="nowrap" align="flex-start">
<Textarea
style={{ flex: 1 }}
autosize
minRows={1}
value={clause}
disabled={locked}
onChange={(e) =>
setWhereasClauses((prev) =>
prev.map((c, idx) =>
idx === i ? e.currentTarget.value : c,
),
)
}
/>
<ActionIcon
variant="subtle"
color="red"
disabled={locked}
onClick={() =>
setWhereasClauses((prev) =>
prev.filter((_, idx) => idx !== i),
)
}
>
<Trash2 size={15} />
</ActionIcon>
</Group>
))}
</Stack>
)}
</Box>
<Divider label="Articles" labelPosition="left" />
<Stack gap="md">
{articles.map((article, index) => (
<Box
key={article.id}
p="sm"
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 8,
}}
>
<Group justify="space-between" mb="xs" wrap="nowrap">
<Text size="sm" fw={700} c="dimmed">
Article {index + 1}
</Text>
<Group gap={4} wrap="nowrap">
<Tooltip label="Move up" withArrow>
<ActionIcon
variant="subtle"
color="gray"
disabled={locked || index === 0}
onClick={() => moveArticle(index, -1)}
>
<ArrowUp size={15} />
</ActionIcon>
</Tooltip>
<Tooltip label="Move down" withArrow>
<ActionIcon
variant="subtle"
color="gray"
disabled={locked || index === articles.length - 1}
onClick={() => moveArticle(index, 1)}
>
<ArrowDown size={15} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete article" withArrow>
<ActionIcon
variant="subtle"
color="red"
disabled={locked}
onClick={() => removeArticle(article.id)}
>
<Trash2 size={15} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
<Stack gap="xs">
<TextInput
placeholder="Article title"
value={article.title}
disabled={locked}
onChange={(e) =>
updateArticle(article.id, { title: e.currentTarget.value })
}
/>
<Textarea
placeholder="Article body — each line becomes a numbered clause. Use '- ' for bullets. Placeholders like {{client.companyName}} are supported."
autosize
minRows={3}
styles={{ input: { fontFamily: "var(--mantine-font-family-monospace)" } }}
value={article.body}
disabled={locked}
onChange={(e) =>
updateArticle(article.id, { body: e.currentTarget.value })
}
/>
</Stack>
</Box>
))}
<Button
variant="light"
color="edr-green"
leftSection={<Plus size={15} />}
disabled={locked}
onClick={addArticle}
>
Add article
</Button>
</Stack>
<Divider />
{mode === "accept" && (
<>
{validityOptions.length > 0 ? (
<Select
label="Contract validity"
placeholder="Select a validity period"
data={validityOptions}
value={validityDays}
onChange={setValidityDays}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
/>
) : (
<Text size="sm" c="orange.7">
{validityLoading
? "Loading validity periods…"
: "No validity periods are configured yet. Add them under Dropdown Settings."}
</Text>
)}
</>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button
color="edr-green"
loading={submitting}
disabled={!canSubmit}
onClick={submit}
>
{mode === "accept"
? "Accept & start approval"
: "Save document changes"}
</Button>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -17,7 +17,8 @@ import {
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import { CountdownTimer, bookingWindowUiState } from "@edr/ui-common";
import type { BookingWindowUiKind } from "@edr/ui-common";
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
import { api } from "@/services/api";
@@ -81,51 +82,40 @@ function windowLabel(w: WindowRow): string {
}
/**
* The countdown for whichever phase the window is currently in, mirroring the
* customer portal. `expiredText` names the NEXT step so a deadline that lapses
* between refetches announces what comes next rather than the bare "Expired".
* The countdown for the window's UI state, mirroring the customer portal.
* Derived from the SAME state as the badge (`bookingWindowUiState`) so they
* can never contradict — a full train shows no ticking countdown.
* `expiredText` names the NEXT step so a deadline that lapses between
* refetches announces what comes next rather than the bare "Expired".
*/
const COUNTDOWN_TEXT: Partial<
Record<BookingWindowUiKind, { label: string; expiredText: string }>
> = {
PRE_WINDOW: { label: "Opens in", expiredText: "Opening now…" },
OPEN: { label: "Closes in", expiredText: "Review starting…" },
DOC_REVIEW: { label: "Doc review ends in", expiredText: "Payment starting…" },
PAYMENT: { label: "Payment ends in", expiredText: "Closing…" },
};
function phaseCountdown(
w: WindowRow,
): { label: string; deadline: string; expiredText: string } | null {
switch (w.windowPhase) {
case "PRE_WINDOW":
return w.windowOpensAt
? {
label: "Opens in",
deadline: w.windowOpensAt,
expiredText: "Opening now…",
}
: null;
case "OPEN":
return w.windowClosesAt
? {
label: "Closes in",
deadline: w.windowClosesAt,
expiredText: "Review starting…",
}
: null;
case "DOC_REVIEW":
return w.docReviewEndsAt
? {
label: "Doc review ends in",
deadline: w.docReviewEndsAt,
expiredText: "Payment starting…",
}
: null;
case "PAYMENT":
return w.paymentPhaseEndsAt
? {
label: "Payment ends in",
deadline: w.paymentPhaseEndsAt,
expiredText: "Closing…",
}
: null;
default:
return null;
}
const state = bookingWindowUiState(w);
const text = COUNTDOWN_TEXT[state.kind];
if (!state.countdownTo || !text) return null;
return { ...text, deadline: state.countdownTo };
}
/** Badge label + Mantine color per UI state — same state the countdown uses. */
const KIND_BADGE: Record<BookingWindowUiKind, { label: string; color: string }> = {
OPEN: { label: "Open now", color: "edr-green" },
FULL: { label: "Train full", color: "red" },
PRE_WINDOW: { label: "Opens soon", color: "yellow" },
DOC_REVIEW: { label: "Doc review", color: "gray" },
PAYMENT: { label: "Payment", color: "gray" },
CLOSED: { label: "Closed", color: "gray" },
};
/**
* Drop windows the SERVER considers finished — keyed off windowPhase, never the
* client clock. The server query already excludes terminal / departed rows;
@@ -139,7 +129,9 @@ function isPast(w: WindowRow): boolean {
function WindowCard({ w }: { w: WindowRow }) {
const cd = phaseCountdown(w);
const open = w.isOpenNow;
const state = bookingWindowUiState(w);
const badge = KIND_BADGE[state.kind];
const open = state.isBookable;
const isImport = w.direction === "IMPORT";
return (
@@ -177,13 +169,11 @@ function WindowCard({ w }: { w: WindowRow }) {
)}
<Badge
variant={open ? "filled" : "light"}
color={open ? "edr-green" : "gray"}
color={badge.color}
radius="sm"
size="sm"
>
{open
? "Open now"
: (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")}
{badge.label}
</Badge>
</Group>

View File

@@ -40,7 +40,7 @@ export const formatFleetCell = (
if (s === "INACTIVE") return "gray";
if (s === "SUSPENDED" || s === "OUT_OF_SERVICE") return "red";
if (s === "MAINTENANCE" || s === "ON_LEAVE") return "orange";
if (s === "RETIRED") return "gray";
if (s === "RETIRED" || s === "DETAINED") return "gray";
return "gray";
};
const color = getStatusColor(status);

View File

@@ -17,6 +17,11 @@ export interface KpiItem {
* into semantic tints.
*/
color?: string;
/**
* Optional change vs a prior period, rendered as a ▲/▼ chip next to the value
* (green up, red down, muted zero). E.g. today's count minus yesterday's.
*/
delta?: number;
}
export interface KpiStripProps {
@@ -67,16 +72,30 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
{loading ? (
<Skeleton height={26} width={72} radius="sm" my={2} />
) : (
<Text
fw={800}
fz={24}
lh={1.05}
c="edr-text"
style={{ letterSpacing: "-0.02em" }}
truncate
>
{item.value}
</Text>
<div className="flex items-baseline gap-2">
<Text
fw={800}
fz={24}
lh={1.05}
c="edr-text"
style={{ letterSpacing: "-0.02em" }}
truncate
>
{item.value}
</Text>
{item.delta != null && item.delta !== 0 ? (
<Text
component="span"
fz="xs"
fw={700}
c={item.delta > 0 ? "edr-green" : "red"}
style={{ whiteSpace: "nowrap" }}
>
{item.delta > 0 ? "▲" : "▼"}
{Math.abs(item.delta)}
</Text>
) : null}
</div>
)}
<Text size="xs" fw={600} c="edr-muted" truncate>
{item.label}

View File

@@ -50,7 +50,15 @@ export default function AvailableWagonsPanel({
const typeOptions = useMemo(() => {
const byId = new Map<string, string>();
for (const wagon of wagonsQuery.data ?? []) {
if (wagon.wagonType) byId.set(wagon.wagonType.id, wagon.wagonType.name);
if (wagon.wagonType) {
// e.g. "Flat wagon (NW5)" — name with its type code.
byId.set(
wagon.wagonType.id,
wagon.wagonType.code
? `${wagon.wagonType.name} (${wagon.wagonType.code})`
: wagon.wagonType.name,
);
}
}
return [
{ value: "ALL", label: "All types" },
@@ -64,6 +72,22 @@ export default function AvailableWagonsPanel({
);
};
const allSelected =
wagons.length > 0 && wagons.every((w) => selected.includes(w.id));
const someSelected = wagons.some((w) => selected.includes(w.id));
const toggleAll = (checked: boolean) => {
setSelected((prev) => {
if (checked) {
const ids = new Set(prev);
wagons.forEach((w) => ids.add(w.id));
return [...ids];
}
const visible = new Set(wagons.map((w) => w.id));
return prev.filter((id) => !visible.has(id));
});
};
const handleAssign = () => {
if (!selected.length) return;
onAssign(selected);
@@ -88,6 +112,16 @@ export default function AvailableWagonsPanel({
/>
</Group>
{wagons.length ? (
<Checkbox
size="sm"
label={`Select all (${wagons.length})`}
checked={allSelected}
indeterminate={!allSelected && someSelected}
onChange={(e) => toggleAll(e.currentTarget.checked)}
/>
) : null}
<ScrollArea.Autosize mah={380} type="auto">
<Stack gap={6}>
{wagonsQuery.isLoading ? (

View File

@@ -26,14 +26,19 @@ const parseError = (error: unknown, fallback: string) => {
return fallback;
};
// Run-number parity carries the trade direction: odd = export, even = import.
const isOddNumber = (value: string) => /^\d*[13579]$/.test(value.trim());
const isEvenNumber = (value: string) => /^\d*[02468]$/.test(value.trim());
/**
* Step one of the Train Builder: give the train its operator code, pick the
* yard it is being assembled in, and couple at least two locomotives from that
* yard. Wagons are attached afterwards on the composition page.
* Step one of the Train Builder: pick the yard it is being assembled in and
* couple at least two locomotives from that yard. The train code is assigned by
* the system. Wagons are attached afterwards on the composition page.
*/
export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrainModalProps) {
const { toast } = useToast();
const [code, setCode] = useState("");
const [exportTrainNumber, setExportTrainNumber] = useState("");
const [importTrainNumber, setImportTrainNumber] = useState("");
const [trainName, setTrainName] = useState("");
const [yardId, setYardId] = useState("");
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
@@ -56,7 +61,8 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
useEffect(() => {
if (!opened) {
setCode("");
setExportTrainNumber("");
setImportTrainNumber("");
setTrainName("");
setYardId("");
setLocomotiveIds([]);
@@ -65,16 +71,24 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
}, [opened]);
const handleBuild = async () => {
if (!code.trim() || !yardId || locomotiveIds.length < 2) {
if (!yardId || locomotiveIds.length < 2) {
toast({
title: "Enter a train code, pick a yard, and couple at least two locomotives",
title: "Pick a yard and couple at least two locomotives",
variant: "destructive",
});
return;
}
if (!isOddNumber(exportTrainNumber) || !isEvenNumber(importTrainNumber)) {
toast({
title: "Enter both run numbers — export must be odd (e.g. 8001), import even (e.g. 8002)",
variant: "destructive",
});
return;
}
try {
const composition = await build.mutateAsync({
code: code.trim(),
exportTrainNumber: exportTrainNumber.trim(),
importTrainNumber: importTrainNumber.trim(),
currentYardId: yardId,
locomotiveIds,
...(trainName.trim() ? { trainName: trainName.trim() } : {}),
@@ -108,22 +122,42 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
<Stack gap="md">
<Text size="sm" c="dimmed">
A train is assembled in one yard: two or more locomotives plus wagons
standing in that same yard. Wagons are attached on the next screen.
standing in that same yard. The train code is assigned automatically;
wagons are attached on the next screen.
</Text>
<TextInput
label="Name (optional)"
placeholder="e.g. Fertilizer block"
value={trainName}
onChange={(e) => setTrainName(e.currentTarget.value)}
maxLength={100}
/>
<Group grow>
<TextInput
label="Train code"
placeholder="e.g. 81001"
value={code}
onChange={(e) => setCode(e.currentTarget.value)}
maxLength={32}
label="Export train number"
description="Odd — Ethiopia → Djibouti runs"
placeholder="e.g. 8001"
value={exportTrainNumber}
onChange={(e) => setExportTrainNumber(e.currentTarget.value)}
maxLength={20}
error={
exportTrainNumber && !isOddNumber(exportTrainNumber)
? "Must be numeric and odd"
: undefined
}
/>
<TextInput
label="Name (optional)"
placeholder="e.g. Fertilizer block"
value={trainName}
onChange={(e) => setTrainName(e.currentTarget.value)}
maxLength={100}
label="Import train number"
description="Even — Djibouti → Ethiopia runs"
placeholder="e.g. 8002"
value={importTrainNumber}
onChange={(e) => setImportTrainNumber(e.currentTarget.value)}
maxLength={20}
error={
importTrainNumber && !isEvenNumber(importTrainNumber)
? "Must be numeric and even"
: undefined
}
/>
</Group>
<Select

View File

@@ -7,7 +7,7 @@ import {
type DropResult,
} from "@hello-pangea/dnd";
import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import { GripVertical, Trash2 } from "lucide-react";
import { GripVertical, Trash2, Wrench } from "lucide-react";
import { type ReactNode } from "react";
import { createPortal } from "react-dom";
@@ -36,6 +36,7 @@ export default function ConsistWagonList({
editable,
onReorder,
onRemove,
onMaintenance,
busy = false,
}: ConsistWagonListProps) {
const onDragEnd = (result: DropResult) => {
@@ -78,6 +79,7 @@ export default function ConsistWagonList({
editable={editable}
busy={busy}
onRemove={onRemove}
onMaintenance={onMaintenance}
/>
)}
</Draggable>
@@ -95,6 +97,8 @@ export interface ConsistWagonListProps {
editable: boolean;
onReorder: (wagonIds: string[]) => void;
onRemove: (wagonId: string) => void;
/** Detach the wagon and move it to MAINTENANCE status. */
onMaintenance: (wagonId: string) => void;
busy?: boolean;
}
@@ -106,6 +110,7 @@ function WagonRow({
editable,
busy,
onRemove,
onMaintenance,
}: {
wagon: TrainCompositionWagon;
index: number;
@@ -114,6 +119,7 @@ function WagonRow({
editable: boolean;
busy: boolean;
onRemove: (wagonId: string) => void;
onMaintenance: (wagonId: string) => void;
}) {
return (
<PortalAwareRow snapshot={snapshot}>
@@ -153,17 +159,30 @@ function WagonRow({
</Text>
</Stack>
{editable ? (
<Tooltip label="Detach wagon" withArrow>
<ActionIcon
variant="subtle"
color="red"
disabled={busy}
onClick={() => onRemove(wagon.id)}
aria-label={`Detach wagon ${wagon.wagonNumber}`}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
<Group gap={4} wrap="nowrap">
<Tooltip label="Send to maintenance (detaches)" withArrow>
<ActionIcon
variant="subtle"
color="orange"
disabled={busy}
onClick={() => onMaintenance(wagon.id)}
aria-label={`Send wagon ${wagon.wagonNumber} to maintenance`}
>
<Wrench size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Detach wagon" withArrow>
<ActionIcon
variant="subtle"
color="red"
disabled={busy}
onClick={() => onRemove(wagon.id)}
aria-label={`Detach wagon ${wagon.wagonNumber}`}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</Group>
) : null}
</Group>
</PortalAwareRow>

View File

@@ -1,3 +1,5 @@
import type { CSSProperties } from "react";
import type { BuiltTrainStatus } from "@/services/trainBuilder.service";
/** Badge color per built-train lifecycle status (Mantine palette keys). */
@@ -23,3 +25,17 @@ export const trainStatusLabel = (status: BuiltTrainStatus | string): string =>
.toLowerCase()
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
/** Badge color per trade direction (Mantine palette keys). */
export const directionColor = (direction?: string | null): string =>
direction === "IMPORT" ? "blue" : direction === "EXPORT" ? "orange" : "gray";
/** Row background tint for a train whose active schedule runs in `direction`. */
export const directionRowStyle = (
direction?: string | null,
): CSSProperties | undefined =>
direction === "IMPORT"
? { backgroundColor: "var(--mantine-color-blue-0)" }
: direction === "EXPORT"
? { backgroundColor: "var(--mantine-color-orange-0)" }
: undefined;

View File

@@ -152,6 +152,9 @@ export function ScheduleWorkspacePanel({
// ── Mutations (reuse the existing endpoints) ───────────────────────────────
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
const assignUnassigned = useMutation(
api.trainScheduling.assignUnassignedBooking.mutationOptions(),
);
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
const setLoading = useMutation(
api.trainScheduling.setLoadingStatus.mutationOptions(),
@@ -166,6 +169,12 @@ export function ScheduleWorkspacePanel({
const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
const [moveTarget, setMoveTarget] = useState<string | null>(null);
// Pool → pick a same-day schedule with free wagons and place the booking there.
const [poolAssign, setPoolAssign] = useState<{ id: string; reference: string } | null>(
null,
);
const [poolTarget, setPoolTarget] = useState<string | null>(null);
const { data: targets } = useQuery(
api.trainScheduling.bookableSchedules.queryOptions({
input: {
@@ -190,6 +199,22 @@ export function ScheduleWorkspacePanel({
[targets, schedule.id],
);
// Every schedule departing on THIS train's day (EAT) — a paid booking waiting
// for a wagon may board any of them, so staff pick whichever has wagons free.
const eatDayOf = (iso: string) =>
new Date(iso).toLocaleDateString("en-CA", { timeZone: "Africa/Addis_Ababa" });
const sameDayOptions = useMemo(() => {
const day = eatDayOf(schedule.scheduledDepartureDate);
return (targets ?? [])
.filter((s) => eatDayOf(s.scheduleDate) === day)
.map((s) => ({
value: s.id,
label: `${s.id === schedule.id ? "This train · " : ""}${
s.routeName ?? `${s.origin}${s.destination}`
} · ${s.remainingWagons}/${s.maxWagons} wagons free`,
}));
}, [targets, schedule.id, schedule.scheduledDepartureDate]);
// ── Capacity meter (by cargo weight vs locomotive pull) ────────────────────
const used = usedWeight(schedule);
const capacity = pullCapacity(schedule);
@@ -288,6 +313,36 @@ export function ScheduleWorkspacePanel({
);
};
// Point the pool booking at the chosen same-day train, then put it on wagons.
// If the wagon step fails (that train is short too) the booking stays paid &
// unassigned in the pool — nothing is lost, staff just pick another train.
const doPoolAssign = () => {
if (!poolAssign || !poolTarget) return;
const { id: bookingId, reference } = poolAssign;
moveSchedule
.mutateAsync({ bookingId, trainScheduleId: poolTarget })
.then(() => assignUnassigned.mutateAsync({ id: poolTarget, bookingId }))
.then(() => {
toast({
title: `${reference} assigned`,
description: "Booking placed on the selected train with wagons pinned.",
});
setPoolAssign(null);
onChanged();
void poolQuery.refetch();
})
.catch((error) =>
toast({
title: `Could not assign ${reference}`,
description: apiErrorMessage(
error,
"The selected train has no free wagon of the required type.",
),
variant: "destructive",
}),
);
};
const doMove = () => {
if (!moveBookingId || !moveTarget) return;
moveSchedule
@@ -465,20 +520,41 @@ export function ScheduleWorkspacePanel({
customer={b.customer}
weightTons={b.weightTons}
status={b.status}
waitingForWagon={b.schedulingStatus === "WAITING_FOR_WAGON"}
right={
canManage ? (
<Tooltip label="Force-add to this train" withArrow>
<Button
size="compact-sm"
color="edr-green"
radius="md"
rightSection={<ArrowRight size={14} />}
loading={assign.isPending}
onClick={() => forceAdd(b.id, b.reference, b.weightTons)}
<Group gap={6} wrap="nowrap" justify="flex-end">
<Tooltip label="Force-add to this train" withArrow>
<Button
size="compact-sm"
color="edr-green"
radius="md"
rightSection={<ArrowRight size={14} />}
loading={assign.isPending}
onClick={() => forceAdd(b.id, b.reference, b.weightTons)}
>
Add
</Button>
</Tooltip>
<Tooltip
label="Pick any train departing this day that has wagons free"
withArrow
>
Add
</Button>
</Tooltip>
<Button
size="compact-sm"
variant="light"
color="edr-green"
radius="md"
leftSection={<ArrowLeftRight size={13} />}
onClick={() => {
setPoolAssign({ id: b.id, reference: b.reference });
setPoolTarget(null);
}}
>
Add to
</Button>
</Tooltip>
</Group>
) : null
}
/>
@@ -588,6 +664,52 @@ export function ScheduleWorkspacePanel({
</Group>
</Stack>
{/* Pool → same-day train assignment modal */}
<Modal
opened={Boolean(poolAssign)}
onClose={() => setPoolAssign(null)}
title={
<Group gap={8}>
<Train size={18} />
<Text fw={700}>
Assign {poolAssign?.reference ?? "booking"} to a train on this day
</Text>
</Group>
}
centered
radius="lg"
>
<Stack gap="md">
<Text size="xs" c="dimmed">
All open trains departing on this schedule&apos;s day. Pick one with
free wagons the booking is placed and its wagons pinned in one step.
</Text>
<Select
label="Target train (same day)"
placeholder="Select a departure"
data={sameDayOptions}
value={poolTarget}
onChange={setPoolTarget}
searchable
nothingFoundMessage="No open schedules depart on this day"
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setPoolAssign(null)}>
Cancel
</Button>
<Button
color="edr-green"
disabled={!poolTarget}
loading={moveSchedule.isPending || assignUnassigned.isPending}
leftSection={<CheckCircle2 size={16} />}
onClick={doPoolAssign}
>
Assign to train
</Button>
</Group>
</Stack>
</Modal>
{/* Reassign modal */}
<Modal
opened={Boolean(moveBookingId)}
@@ -710,6 +832,7 @@ function BookingCard({
weightTons,
status,
loadingStatus,
waitingForWagon,
right,
}: {
reference: string;
@@ -717,6 +840,8 @@ function BookingCard({
weightTons?: number | null;
status?: string | null;
loadingStatus?: "LOADED" | "UNLOADED";
/** Paid, but no wagon of the required type was free — waiting for one. */
waitingForWagon?: boolean;
right?: React.ReactNode;
}) {
return (
@@ -742,6 +867,16 @@ function BookingCard({
{reference}
</Text>
{status ? <BookingStatusBadge status={status} /> : null}
{waitingForWagon ? (
<Tooltip
label="Paid, but no wagon of the required type was free. Free a wagon or assign it to a same-day train that has one."
withArrow
>
<Badge size="sm" radius="sm" variant="light" color="orange">
Waiting for wagon
</Badge>
</Tooltip>
) : null}
{loadingStatus ? (
<Badge
size="sm"

View File

@@ -168,6 +168,11 @@ function HistoryPanel({ opened }: { opened: boolean }) {
</Badge>
</Group>
</Group>
{r.reason ? (
<Text size="xs" c="dimmed" mt={4}>
Reason: {r.reason}
</Text>
) : null}
</Card>
))}
</Stack>
@@ -233,6 +238,8 @@ const WagonTransferRequestsModal = ({
const [tab, setTab] = useState<string | null>("queue");
const [active, setActive] = useState<WagonTransferRequest | null>(null);
const [picked, setPicked] = useState<Set<string>>(new Set());
// Bulk accept-and-execute: the subset of pending requests OCC ticked.
const [selected, setSelected] = useState<Set<string>>(new Set());
const { data: requests = [], isLoading } = useQuery({
...api.wagonTransferRequests.list.queryOptions({ input: { status: PENDING } }),
@@ -256,6 +263,9 @@ const WagonTransferRequestsModal = ({
});
const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
const bulkFulfill = useMutation(
api.wagonTransferRequests.bulkFulfill.mutationOptions(),
);
const cancel = useMutation(api.wagonTransferRequests.cancel.mutationOptions());
const showError = (err: unknown, fallback: string) => {
@@ -310,6 +320,36 @@ const WagonTransferRequestsModal = ({
}
};
const toggleSelected = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
// Execute the ticked subset; whatever cannot run (not enough available
// wagons, already decided) is reported and simply stays PENDING.
const handleBulkFulfill = async () => {
if (selected.size === 0) return;
try {
const res = await bulkFulfill.mutateAsync({ requestIds: [...selected] });
setSelected(new Set());
const skippedNote = res.skipped.length
? ` · ${res.skipped.length} left pending (${res.skipped
.map((s) => s.reason)
.join('; ')})`
: "";
toast({
title: `Executed ${res.fulfilled.length} transfer request(s)`,
description: skippedNote || undefined,
variant: res.fulfilled.length === 0 ? "destructive" : undefined,
});
} catch (err) {
showError(err, "Bulk execute failed");
}
};
const sortedWagons = useMemo(
() => [...wagons].sort((a, b) => a.wagonNumber.localeCompare(b.wagonNumber)),
[wagons],
@@ -371,17 +411,65 @@ const WagonTransferRequestsModal = ({
</Card>
) : (
<Stack gap="sm">
{/* Bulk accept-and-execute action bar: tick a subset, run it, and
everything unticked (or unexecutable) stays PENDING. */}
<Group justify="space-between" wrap="nowrap">
<Checkbox
label={
selected.size > 0
? `${selected.size} of ${requests.length} selected`
: "Select all"
}
checked={selected.size === requests.length && requests.length > 0}
indeterminate={selected.size > 0 && selected.size < requests.length}
onChange={() =>
setSelected(
selected.size === requests.length
? new Set()
: new Set(requests.map((r) => r.id)),
)
}
color="edr-green"
/>
<Button
size="compact-sm"
color="edr-green"
leftSection={<PackageCheck size={14} />}
loading={bulkFulfill.isPending}
disabled={selected.size === 0}
onClick={handleBulkFulfill}
>
Accept & execute {selected.size > 0 ? `(${selected.size})` : ""}
</Button>
</Group>
{requests.map((r) => (
<Card key={r.id} withBorder radius="md" padding="md">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Stack gap={6} style={{ minWidth: 0 }}>
<RequestSummary r={r} />
{r.note ? (
<Text size="xs" c="dimmed">
{r.note}
</Text>
) : null}
</Stack>
<Group gap="sm" wrap="nowrap" align="flex-start" style={{ minWidth: 0 }}>
<Checkbox
checked={selected.has(r.id)}
onChange={() => toggleSelected(r.id)}
color="edr-green"
mt={2}
/>
<Stack gap={6} style={{ minWidth: 0 }}>
<RequestSummary r={r} />
{r.reason ? (
<Text size="xs">
<Text span fw={600}>
Reason:
</Text>{" "}
{r.reason}
</Text>
) : null}
{r.note ? (
<Text size="xs" c="dimmed">
{r.note}
</Text>
) : null}
</Stack>
</Group>
<Group gap={8} wrap="nowrap">
<Button
size="compact-sm"

View File

@@ -15,6 +15,7 @@ import {
Slider,
Stack,
Text,
Textarea,
ThemeIcon,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
@@ -125,6 +126,7 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
const [transferYardId, setTransferYardId] = useState<string | null>(null);
const [transferQty, setTransferQty] = useState(0);
const [transferReason, setTransferReason] = useState("");
const [toAssignedQty, setToAssignedQty] = useState(0);
const [toAvailableQty, setToAvailableQty] = useState(0);
@@ -207,6 +209,7 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
useEffect(() => {
setTransferYardId(null);
setTransferQty(0);
setTransferReason("");
setToAssignedQty(0);
setToAvailableQty(0);
}, [yardId, typeId]);
@@ -219,8 +222,9 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
}
}, [opened]);
// Keep quantities within bounds as counts shift after each action.
useEffect(() => setTransferQty((q) => Math.min(q, total)), [total]);
// Keep quantities within bounds as counts shift after each action. Transfers
// may only ask for AVAILABLE wagons, so the request cap is availableCount.
useEffect(() => setTransferQty((q) => Math.min(q, availableCount)), [availableCount]);
useEffect(() => setToAssignedQty((q) => Math.min(q, availableCount)), [availableCount]);
useEffect(() => setToAvailableQty((q) => Math.min(q, assignedCount)), [assignedCount]);
@@ -233,13 +237,21 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
// Request-only: the requester specifies count + destination; OCC later picks
// the physical wagons and executes the move. No wagons are moved here.
const handleRequest = async () => {
if (!yardId || !typeId || !transferYardId || transferQty < 1) return;
if (
!yardId ||
!typeId ||
!transferYardId ||
transferQty < 1 ||
!transferReason.trim()
)
return;
try {
await createRequest.mutateAsync({
fromYardId: yardId,
toYardId: transferYardId,
wagonTypeId: typeId,
quantity: transferQty,
reason: transferReason.trim(),
});
toast({
title: `Requested ${transferQty} ${typeInfo.code(typeId)} wagon(s) · ${yardName(
@@ -249,6 +261,7 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
});
setTransferQty(0);
setTransferYardId(null);
setTransferReason("");
} catch (err) {
showError(err, "Request failed");
}
@@ -407,10 +420,19 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
</Text>
<Stack gap="md">
<div>
<Text size="sm" fw={500} mb={4}>
How many wagons
</Text>
<QuantityField value={transferQty} onChange={setTransferQty} max={total} />
<Group justify="space-between" mb={4}>
<Text size="sm" fw={500}>
How many wagons
</Text>
<Badge color="teal" variant="light">
{availableCount} available
</Badge>
</Group>
<QuantityField
value={transferQty}
onChange={setTransferQty}
max={availableCount}
/>
</div>
<Select
label="Destination yard"
@@ -421,6 +443,16 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
searchable
radius="md"
/>
<Textarea
label="Reason"
placeholder="Why are these wagons needed?"
value={transferReason}
onChange={(e) => setTransferReason(e.currentTarget.value)}
required
autosize
minRows={2}
radius="md"
/>
{transferYardId && transferQty > 0 ? (
<Card bg="var(--mantine-color-gray-0)" radius="md" padding="sm" withBorder>
<Group gap={8} wrap="nowrap">
@@ -446,7 +478,12 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
leftSection={<ArrowRightLeft size={16} />}
onClick={handleRequest}
loading={createRequest.isPending}
disabled={busy || !transferYardId || transferQty < 1}
disabled={
busy ||
!transferYardId ||
transferQty < 1 ||
!transferReason.trim()
}
color="edr-green"
>
Request {transferQty > 0 ? `${transferQty} ` : ""}wagon

View File

@@ -0,0 +1,111 @@
import { Card, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { Gauge } from 'lucide-react';
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { useOnTimeDispatch, useWarehouseCycleStats } from '@/hooks/useWarehouses';
import { formatDays } from './options';
function onTimeColor(pct: number | null | undefined): string {
if (pct == null) return 'edr-text';
if (pct >= 80) return 'teal';
if (pct >= 50) return 'orange';
return 'red';
}
/**
* Warehouse performance: on-time dispatch rate (items that left before their
* storage free-days expired), average dock-to-dispatch, and the per-stage
* cycle times that make it up.
*/
export function CycleTimeCard() {
const { data: cycle, isLoading: cycleLoading } = useWarehouseCycleStats();
const { data: onTime, isLoading: onTimeLoading } = useOnTimeDispatch();
const isLoading = cycleLoading || onTimeLoading;
const hasStages = (cycle?.sampleSize ?? 0) > 0;
const stages = cycle?.stages ?? [];
return (
<Card withBorder radius="lg" padding="lg" h="100%">
<Group gap="sm" mb="md">
<ThemeIcon size="lg" radius="md" color="teal" variant="light">
<Gauge size={20} />
</ThemeIcon>
<div>
<Text fw={700}>Cycle time &amp; on-time</Text>
<Text size="xs" c="dimmed">
Dispatch performance over the last 90 days
</Text>
</div>
</Group>
{isLoading ? (
<Group justify="center" h={220}>
<Loader />
</Group>
) : (
<Stack gap="md">
<SimpleGrid cols={2} spacing="sm">
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
On-time dispatch
</Text>
<Text fw={800} fz={30} lh={1.1} c={onTimeColor(onTime?.onTimePct)}>
{onTime?.onTimePct == null ? 'N/A' : `${onTime.onTimePct}%`}
</Text>
<Text size="xs" c="dimmed">
{onTime?.onTimePct == null
? 'No storage rule / sample'
: `${onTime.onTimeCount}/${onTime.sampleSize} left before free-days`}
</Text>
</Stack>
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Dock dispatch
</Text>
<Text fw={800} fz={30} lh={1.1}>
{hasStages ? formatDays(cycle?.avgDockToDispatchDays) : '—'}
</Text>
<Text size="xs" c="dimmed">
avg over {cycle?.sampleSize ?? 0} dispatched
</Text>
</Stack>
</SimpleGrid>
{hasStages ? (
<ResponsiveContainer width="100%" height={170}>
<BarChart
data={stages}
layout="vertical"
margin={{ top: 4, right: 16, left: 8, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="var(--mantine-color-gray-2)" />
<XAxis type="number" tick={{ fontSize: 12 }} unit="d" />
<YAxis type="category" dataKey="label" width={130} tick={{ fontSize: 11 }} />
<Tooltip
cursor={{ fill: 'var(--mantine-color-gray-1)' }}
formatter={(value) => [`${value} days`, 'Avg'] as [string, string]}
/>
<Bar dataKey="avgDays" name="Avg days" fill="#12b886" radius={[0, 6, 6, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<Group justify="center" align="center" h={170}>
<Text c="dimmed" size="sm">
Not enough dispatched items yet to chart stage times.
</Text>
</Group>
)}
</Stack>
)}
</Card>
);
}

View File

@@ -0,0 +1,92 @@
import { Card, Group, Loader, Stack, Text, ThemeIcon } from '@mantine/core';
import { Hourglass } from 'lucide-react';
import {
Bar,
BarChart,
CartesianGrid,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { useWarehouseDwellStats } from '@/hooks/useWarehouses';
import { formatDays } from './options';
/** Green → amber → red as items age. Aligned with the zone-occupancy heat scale. */
const BUCKET_COLORS = ['#12b886', '#40c057', '#f08c00', '#fa5252'];
/**
* Dwell time of items still in the warehouse: the average, plus how the current
* stock is spread across aging buckets (03 / 47 / 814 / 15+ days).
*/
export function DwellAgingCard() {
const { data, isLoading } = useWarehouseDwellStats();
const buckets = data?.buckets ?? [];
const hasItems = (data?.inWarehouseCount ?? 0) > 0;
return (
<Card withBorder radius="lg" padding="lg" h="100%">
<Group gap="sm" mb="md">
<ThemeIcon size="lg" radius="md" color="grape" variant="light">
<Hourglass size={20} />
</ThemeIcon>
<div>
<Text fw={700}>Dwell time &amp; aging</Text>
<Text size="xs" c="dimmed">
How long current stock has been held
</Text>
</div>
</Group>
{isLoading ? (
<Group justify="center" h={220}>
<Loader />
</Group>
) : (
<Group align="stretch" gap="lg" wrap="nowrap">
<Stack gap={2} justify="center" style={{ minWidth: 110 }}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Avg dwell
</Text>
<Text fw={800} fz={30} lh={1.1}>
{hasItems ? formatDays(data?.avgDwellDays) : '—'}
</Text>
<Text size="xs" c="dimmed">
{data?.inWarehouseCount ?? 0} item{(data?.inWarehouseCount ?? 0) === 1 ? '' : 's'} in warehouse
</Text>
</Stack>
<div style={{ flex: 1, minWidth: 0 }}>
{hasItems ? (
<ResponsiveContainer width="100%" height={200}>
<BarChart
data={buckets}
layout="vertical"
margin={{ top: 4, right: 16, left: 8, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="var(--mantine-color-gray-2)" />
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 12 }} />
<YAxis type="category" dataKey="label" width={72} tick={{ fontSize: 12 }} />
<Tooltip cursor={{ fill: 'var(--mantine-color-gray-1)' }} />
<Bar dataKey="count" name="Items" radius={[0, 6, 6, 0]}>
{buckets.map((b, i) => (
<Cell key={b.key} fill={BUCKET_COLORS[i] ?? '#868e96'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<Group justify="center" align="center" h={200}>
<Text c="dimmed" size="sm">
No items currently in the warehouse.
</Text>
</Group>
)}
</div>
</Group>
)}
</Card>
);
}

View File

@@ -0,0 +1,90 @@
import { Card, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { DoorOpen } from 'lucide-react';
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { useWarehouseGateStats } from '@/hooks/useWarehouses';
/**
* Gate / dock throughput: items cleared through the gate today, the average
* arrival→gate-clearance turnaround, and clearances per hour over the last 24h.
*/
export function GateThroughputCard() {
const { data, isLoading } = useWarehouseGateStats();
const byHour = data?.byHour ?? [];
const hasActivity = byHour.some((h) => h.count > 0);
return (
<Card withBorder radius="lg" padding="lg" h="100%">
<Group gap="sm" mb="md">
<ThemeIcon size="lg" radius="md" color="indigo" variant="light">
<DoorOpen size={20} />
</ThemeIcon>
<div>
<Text fw={700}>Gate &amp; dock throughput</Text>
<Text size="xs" c="dimmed">
Gate clearances over the last 24 hours
</Text>
</div>
</Group>
{isLoading ? (
<Group justify="center" h={220}>
<Loader />
</Group>
) : (
<Stack gap="md">
<SimpleGrid cols={2} spacing="sm">
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Cleared today
</Text>
<Text fw={800} fz={30} lh={1.1}>
{data?.clearedToday ?? 0}
</Text>
<Text size="xs" c="dimmed">
through the gate
</Text>
</Stack>
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Avg turnaround
</Text>
<Text fw={800} fz={30} lh={1.1}>
{data?.avgTurnaroundHours == null ? '—' : `${data.avgTurnaroundHours} h`}
</Text>
<Text size="xs" c="dimmed">
arrival gate (30d)
</Text>
</Stack>
</SimpleGrid>
{hasActivity ? (
<ResponsiveContainer width="100%" height={170}>
<BarChart data={byHour} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--mantine-color-gray-2)" />
<XAxis dataKey="hour" interval={3} tick={{ fontSize: 11 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
<Tooltip cursor={{ fill: 'var(--mantine-color-gray-1)' }} />
<Bar dataKey="count" name="Cleared" fill="#4c6ef5" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<Group justify="center" align="center" h={170}>
<Text c="dimmed" size="sm">
No gate clearances in the last 24 hours.
</Text>
</Group>
)}
</Stack>
)}
</Card>
);
}

View File

@@ -1,24 +1,20 @@
import { useMemo, useState } from 'react';
import { Card, Group, SegmentedControl, SimpleGrid, Text, ThemeIcon } from '@mantine/core';
import { BarChart3, CalendarRange, PieChart as PieChartIcon } from 'lucide-react';
import { useState } from 'react';
import { Card, Group, SegmentedControl, Stack, Text, ThemeIcon } from '@mantine/core';
import { BarChart3, CalendarRange } from 'lucide-react';
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import type { WarehouseDashboard, WarehouseInventoryItem } from '@/types/warehouse';
import { useWarehouseThroughput } from '@/hooks/useWarehouses';
import type { WarehouseDashboard } from '@/types/warehouse';
interface WarehouseDashboardChartsProps {
data?: WarehouseDashboard;
@@ -38,11 +34,25 @@ const STATUS_SERIES = [
type Granularity = 'week' | 'month' | 'year';
/** Label a period start according to the selected granularity. */
function formatPeriod(iso: string, granularity: Granularity): string {
const d = new Date(iso);
if (granularity === 'year') return String(d.getFullYear());
if (granularity === 'week') return d.toLocaleDateString('en', { day: 'numeric', month: 'short' });
return d.toLocaleDateString('en', { month: 'short' });
}
export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps) {
const [granularity, setGranularity] = useState<Granularity>('month');
const { data: inventory } = useQuery(
api.warehouses.listInventory.queryOptions({ input: {} }),
);
// Server-side time series (replaces downloading the whole inventory to bucket).
const { data: series = [] } = useWarehouseThroughput(granularity);
const trend = series.map((p) => ({
label: formatPeriod(p.periodStart, granularity),
received: p.received,
dispatched: p.dispatched,
}));
const hasTrend = trend.some((b) => b.received > 0 || b.dispatched > 0);
const statusData = STATUS_SERIES.map((s) => ({
name: s.label,
@@ -51,16 +61,10 @@ export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps
}));
const hasStatus = statusData.some((d) => d.value > 0);
const trend = useMemo(
() => buildTrend(inventory ?? [], granularity),
[inventory, granularity],
);
const hasTrend = trend.some((b) => b.received > 0 || b.dispatched > 0);
return (
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<Stack gap="md">
{/* Time-filtered throughput */}
<Card withBorder radius="lg" padding="lg" style={{ gridColumn: '1 / -1' }}>
<Card withBorder radius="lg" padding="lg">
<Group justify="space-between" mb="md" wrap="wrap">
<Group gap="sm">
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: ORANGE, color: '#fff' }}>
@@ -133,103 +137,10 @@ export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps
<EmptyChart />
)}
</Card>
{/* Status distribution donut */}
<Card withBorder radius="lg" padding="lg">
<Group gap="sm" mb="md">
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: ORANGE, color: '#fff' }}>
<PieChartIcon size={20} />
</ThemeIcon>
<div>
<Text fw={700}>Lifecycle Distribution</Text>
<Text size="xs" c="dimmed">
Share of inventory across statuses
</Text>
</div>
</Group>
{hasStatus ? (
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={statusData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={55}
outerRadius={95}
paddingAngle={2}
>
{statusData.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Tooltip />
<Legend verticalAlign="bottom" height={36} iconType="circle" />
</PieChart>
</ResponsiveContainer>
) : (
<EmptyChart />
)}
</Card>
</SimpleGrid>
</Stack>
);
}
interface TrendBucket {
label: string;
received: number;
dispatched: number;
}
/** Bucket inventory by arrived/dispatched timestamps into recent week/month/year periods. */
function buildTrend(items: WarehouseInventoryItem[], granularity: Granularity): TrendBucket[] {
const now = new Date();
const buckets: { label: string; start: Date; end: Date }[] = [];
if (granularity === 'week') {
for (let i = 7; i >= 0; i--) {
const end = new Date(now);
end.setDate(now.getDate() - i * 7);
const start = new Date(end);
start.setDate(end.getDate() - 7);
buckets.push({ label: `W${8 - i}`, start, end });
}
} else if (granularity === 'month') {
for (let i = 11; i >= 0; i--) {
const start = new Date(now.getFullYear(), now.getMonth() - i, 1);
const end = new Date(now.getFullYear(), now.getMonth() - i + 1, 1);
buckets.push({
label: start.toLocaleString('en', { month: 'short' }),
start,
end,
});
}
} else {
for (let i = 4; i >= 0; i--) {
const year = now.getFullYear() - i;
buckets.push({
label: String(year),
start: new Date(year, 0, 1),
end: new Date(year + 1, 0, 1),
});
}
}
const inRange = (iso: string | null | undefined, start: Date, end: Date) => {
if (!iso) return false;
const t = new Date(iso).getTime();
return t >= start.getTime() && t < end.getTime();
};
return buckets.map((b) => ({
label: b.label,
received: items.filter((it) => inRange(it.arrivedAt, b.start, b.end)).length,
dispatched: items.filter((it) => inRange(it.dispatchedAt, b.start, b.end)).length,
}));
}
function EmptyChart() {
return (
<Group justify="center" align="center" h={280}>

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Badge, Button, Card, Divider, Group, Stack, Text } from '@mantine/core';
import { Badge, Button, Card, Divider, Group, Stack, Text, Tooltip } from '@mantine/core';
import { PackagePlus, Train as TrainIcon, Warehouse as WarehouseIcon } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
@@ -126,14 +126,24 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo
</>
)}
<Button
variant="light"
leftSection={<PackagePlus size={16} />}
onClick={() => setModalOpen(true)}
fullWidth
<Tooltip
label="This booking is already received at the warehouse"
disabled={!latest}
withArrow
>
Receive At Warehouse
</Button>
{/* span wrapper so the tooltip still fires on the disabled button */}
<span style={{ display: 'block' }}>
<Button
variant="light"
leftSection={<PackagePlus size={16} />}
onClick={() => setModalOpen(true)}
fullWidth
disabled={Boolean(latest)}
>
{latest ? 'Received At Warehouse' : 'Receive At Warehouse'}
</Button>
</span>
</Tooltip>
</Stack>
<ReceiveInventoryModal

View File

@@ -179,17 +179,17 @@ export function WarehouseInventoryTable({
{selectable && (
<Table.Td>
<Checkbox
aria-label={`Select ${item.bookingId ?? item.id}`}
aria-label={`Select ${item.bookingReference ?? item.booking?.reference ?? item.bookingId ?? item.id}`}
checked={selectedIds?.has(item.id) ?? false}
onChange={() => onToggleSelect?.(item.id)}
/>
</Table.Td>
)}
<Table.Td>
{item.bookingId ? (
<Tooltip label={item.bookingId} withArrow>
{item.bookingReference || item.booking?.reference || item.bookingId ? (
<Tooltip label={item.bookingId ?? ''} withArrow disabled={!item.bookingId}>
<Text size="sm" fw={600}>
{item.bookingId.slice(0, 8)}...
{item.bookingReference ?? item.booking?.reference ?? `${item.bookingId?.slice(0, 8)}...`}
</Text>
</Tooltip>
) : (

View File

@@ -19,6 +19,10 @@ export function WarehouseOpsKpiStrip() {
value: data?.receivedToday ?? 0,
icon: PackageCheck,
color: "edr-green",
// Live signal: change vs yesterday's received count.
delta:
data != null ? data.receivedToday - data.receivedYesterday : undefined,
hint: "vs yesterday",
},
{
label: "Pending inspection",

View File

@@ -32,3 +32,6 @@ export { FeePreviewModal } from './FeePreviewModal';
export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap';
export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip';
export { AccrualDashboard } from './AccrualDashboard';
export { DwellAgingCard } from './DwellAgingCard';
export { CycleTimeCard } from './CycleTimeCard';
export { GateThroughputCard } from './GateThroughputCard';

View File

@@ -35,6 +35,14 @@ export const formatCapacity = (current: number, capacity: number | null | undefi
return `${cur} / ${formatNumber(capacity)}`;
};
/** A day count as a short, human duration: "0.2d" / "3.5 days" / "—". */
export const formatDays = (value: number | null | undefined) => {
if (value === null || value === undefined || Number.isNaN(Number(value))) return '—';
const num = Number(value);
const rounded = Math.round(num * 10) / 10;
return `${rounded} ${rounded === 1 ? 'day' : 'days'}`;
};
export const formatDate = (value: string | null | undefined) => {
if (!value) return '—';
const date = new Date(value);

View File

@@ -166,6 +166,7 @@ export const QUERY_KEYS = {
) => ["rule-engine", "select-options", resource, params ?? {}] as const,
orderList: (resource: RuleEngineResourceSlug | string) =>
["rule-engine", "order-list", resource] as const,
priorityRuleChanges: ["rule-engine", "priority-rule-changes"] as const,
},
OVERVIEW: {

View File

@@ -176,6 +176,9 @@ export const URL_CONSTANTS = {
CONTRACT_VIEW: (id: string) => `/contracts/${id}/contract/view`,
CONTRACT_DOCUMENT: (id: string) => `/contracts/${id}/contract/document`,
CONTRACT_SIGN: (id: string) => `/contracts/${id}/contract/sign`,
CONTRACT_DOCUMENT_DRAFT: (id: string) => `/contracts/${id}/document/draft`,
CONTRACT_DOCUMENT_ARTICLES: (id: string) =>
`/contracts/${id}/document/articles`,
CLEARANCE_QUEUE: "/contracts/clearance/queue",
CLEARANCE: (id: string) => `/contracts/${id}/clearance`,
CLEARANCE_REVIEW: (id: string) => `/contracts/${id}/clearance/review`,
@@ -487,6 +490,11 @@ export const URL_CONSTANTS = {
RESERVE: "/warehouse-inventory/reserve",
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
OPS_STATS: "/warehouse-inventory/ops-stats",
THROUGHPUT: (granularity: 'week' | 'month' | 'year') =>
`/warehouse-inventory/throughput?granularity=${granularity}`,
DWELL_STATS: "/warehouse-inventory/dwell-stats",
CYCLE_STATS: "/warehouse-inventory/cycle-stats",
GATE_STATS: "/warehouse-inventory/gate-stats",
ZONE_OCCUPANCY: (yardId?: string) =>
yardId
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`
@@ -559,6 +567,7 @@ export const URL_CONSTANTS = {
FEE_PREVIEW: (inventoryId: string) =>
`/warehouse-inventory/${inventoryId}/fee-preview`,
ACCRUAL_DASHBOARD: "/warehouse-fees/accrual-dashboard",
ON_TIME_DISPATCH: "/warehouse-fees/on-time-dispatch",
ACCRUAL_ACK: (inventoryId: string) =>
`/warehouse-fees/accrual/${inventoryId}/acknowledge`,
},

View File

@@ -125,12 +125,27 @@ export function useContractMutations(contractId: string) {
};
const staffAccept = useMutation({
mutationFn: (validityDays: number) =>
contractsService.staffAccept(contractId, validityDays),
mutationFn: (payload: {
validityDays: number;
documentSnapshot?: Freight.IContractDocumentSnapshot;
}) =>
contractsService.staffAccept(
contractId,
payload.validityDays,
payload.documentSnapshot,
),
onSuccess: (data) => onSuccess(data, "Contract accepted for approval"),
onError: () => toast.error("Failed to accept contract"),
});
// Edit THIS contract's document articles (per-contract; never the templates).
const updateDocument = useMutation({
mutationFn: (snapshot: Freight.IContractDocumentSnapshot) =>
contractsService.updateContractDocument(contractId, snapshot),
onSuccess: (data) => onSuccess(data, "Contract document updated"),
onError: () => toast.error("Failed to update contract document"),
});
const requestChanges = useMutation({
mutationFn: (note: string) =>
contractsService.requestChanges(contractId, note),
@@ -144,11 +159,6 @@ export function useContractMutations(contractId: string) {
onError: () => toast.error("Failed to reject contract"),
});
// Statuses that mean every approval step is done and the contract is ready to
// be generated. Once the final approval lands we generate the PDF
// automatically — staff no longer click a separate "Generate" button.
const READY_TO_GENERATE = ["APPROVED", "APPROVED_PENDING_SIGNATURE"];
const approveStep = useMutation({
mutationFn: ({
stepId,
@@ -158,25 +168,15 @@ export function useContractMutations(contractId: string) {
requiredRole: string;
}) =>
contractsService.approveStep({ id: contractId, stepId, requiredRole }),
onSuccess: async (data) => {
// If this was the LAST approval, auto-generate the contract so it goes
// straight to CONTRACT_READY without a manual step.
const alreadyGenerated = Boolean(
(data as Freight.IContract).contractGeneratedAt,
);
if (READY_TO_GENERATE.includes(data.status) && !alreadyGenerated) {
toast.success("Final approval complete — generating contract…");
try {
const generated = await contractsService.generateContract(data.id);
onSuccess(generated, "Contract generated and ready to sign");
return;
} catch {
toast.error("Approved, but contract generation failed. Retry below.");
void invalidateContractDetail(qc, data.id);
return;
}
}
onSuccess(data, "Approval step completed");
onSuccess: (data) => {
// The document is generated at the accept stage and reviewed during
// approval, so the final approval moves the contract straight to
// CONTRACT_READY on the server — no client-side generate call here.
const message =
data.status === "CONTRACT_READY"
? "Final approval complete — contract ready to sign"
: "Approval step completed";
onSuccess(data, message);
},
onError: () => toast.error("Failed to approve step"),
});
@@ -239,6 +239,7 @@ export function useContractMutations(contractId: string) {
const isPending =
staffAccept.isPending ||
updateDocument.isPending ||
requestChanges.isPending ||
reject.isPending ||
approveStep.isPending ||
@@ -249,6 +250,7 @@ export function useContractMutations(contractId: string) {
return {
staffAccept,
updateDocument,
requestChanges,
reject,
approveStep,

View File

@@ -3,7 +3,11 @@ import toast from "react-hot-toast";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { api } from "@/services/api";
import { ruleEngineService, type RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
import {
ruleEngineService,
type RuleEngineListParams,
type SubmitPriorityRuleChangePayload,
} from "@/services/ruleEngine/ruleEngine.service";
import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources";
import type {
RuleEngineRecord,
@@ -239,6 +243,68 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
return { create, update, remove };
};
/**
* Priority-rule approval workflow. Every create/update/delete of a priority
* config is SUBMITTED as a change request; an approver applies or rejects it.
* Error toasts surface the backend message so range-collision rejections
* ("15 overlaps existing rule …") reach the user verbatim.
*/
export const usePriorityRuleWorkflow = (enabled: boolean) => {
const qc = useQueryClient();
const backendMessage = (err: unknown, fallback: string) => {
const msg = (err as { response?: { data?: { message?: string | string[] } } })
?.response?.data?.message;
if (Array.isArray(msg)) return msg.join(", ");
return msg || fallback;
};
const pending = useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges,
queryFn: () => ruleEngineService.listPriorityRuleChanges("PENDING"),
enabled,
});
const invalidate = async () => {
await qc.invalidateQueries({
queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges,
});
await invalidateRuleEngineList(qc, "priority-configs");
};
const submit = useMutation({
mutationFn: (payload: SubmitPriorityRuleChangePayload) =>
ruleEngineService.submitPriorityRuleChange(payload),
onSuccess: async () => {
toast.success("Change submitted for approval — the team has been notified");
await invalidate();
},
onError: (err) => toast.error(backendMessage(err, "Failed to submit change")),
});
const approve = useMutation({
mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) =>
ruleEngineService.approvePriorityRuleChange(id, decisionNote),
onSuccess: async () => {
toast.success("Change approved and applied");
await invalidate();
},
onError: (err) => toast.error(backendMessage(err, "Failed to approve change")),
});
const reject = useMutation({
mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) =>
ruleEngineService.rejectPriorityRuleChange(id, decisionNote),
onSuccess: async () => {
toast.success("Change rejected");
await invalidate();
},
onError: (err) => toast.error(backendMessage(err, "Failed to reject change")),
});
return { pending, submit, approve, reject };
};
export const useRateWorkflow = () => {
const qc = useQueryClient();

View File

@@ -141,6 +141,7 @@ export function useZoneOccupancy(yardId?: string) {
return useQuery({
queryKey: ['warehouse-zones', 'occupancy', yardId ?? 'all'],
queryFn: () => warehouseService.zoneOccupancy(yardId).then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
});
}
@@ -149,6 +150,55 @@ export function useWarehouseOpsStats() {
return useQuery({
queryKey: ['warehouse-inventory', 'ops-stats'],
queryFn: () => warehouseService.opsStats().then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
});
}
/** How often the live warehouse dashboard widgets auto-refresh (ms). */
export const DASHBOARD_REFETCH_MS = 60_000;
/** Server-side received-vs-dispatched throughput time series. */
export function useWarehouseThroughput(granularity: 'week' | 'month' | 'year') {
return useQuery({
queryKey: ['warehouse-inventory', 'throughput', granularity],
queryFn: () => warehouseService.throughput(granularity).then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
});
}
/** Dwell time of in-warehouse items (average + aging buckets). */
export function useWarehouseDwellStats() {
return useQuery({
queryKey: ['warehouse-inventory', 'dwell-stats'],
queryFn: () => warehouseService.dwellStats().then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
});
}
/** Average stage cycle times over recently dispatched items. */
export function useWarehouseCycleStats() {
return useQuery({
queryKey: ['warehouse-inventory', 'cycle-stats'],
queryFn: () => warehouseService.cycleStats().then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
});
}
/** Gate / dock throughput (cleared today, turnaround, hourly clearances). */
export function useWarehouseGateStats() {
return useQuery({
queryKey: ['warehouse-inventory', 'gate-stats'],
queryFn: () => warehouseService.gateStats().then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
});
}
/** On-time dispatch rate (left before storage free-days expired). */
export function useOnTimeDispatch() {
return useQuery({
queryKey: ['warehouse-fees', 'on-time-dispatch'],
queryFn: () => warehouseService.onTimeDispatch().then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
});
}
@@ -157,6 +207,7 @@ export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') {
return useQuery({
queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'],
queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
});
}
@@ -405,6 +456,7 @@ export function useWarehouseDashboard() {
return useQuery({
queryKey: ['warehouses', 'dashboard'],
queryFn: () => warehouseService.dashboard().then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
});
}

View File

@@ -965,7 +965,7 @@ export function WagonsCrudPage() {
{ value: 'EXPORT_READY', label: 'Export ready' },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'MAINTENANCE', label: 'Maintenance' },
{ value: 'RETIRED', label: 'Retired' },
{ value: 'DETAINED', label: 'Detained' },
],
},
{ key: 'notes', label: 'Notes' },

View File

@@ -113,7 +113,7 @@ const WAGON_STATUS_OPTIONS = [
{ label: "Available", value: Freight.WagonStatus.Available },
{ label: "Assigned", value: Freight.WagonStatus.Assigned },
{ label: "Maintenance", value: Freight.WagonStatus.Maintenance },
{ label: "Retired", value: Freight.WagonStatus.Retired },
{ label: "Detained", value: Freight.WagonStatus.Detained },
];

View File

@@ -0,0 +1,125 @@
import { Badge, Button, Card, Group, Stack, Text } from "@mantine/core";
import type { UseMutationResult } from "@tanstack/react-query";
import { CheckCircle2, XCircle } from "lucide-react";
import type { PriorityRuleChangeRequest } from "@/services/ruleEngine/ruleEngine.service";
const ACTION_COLOR: Record<PriorityRuleChangeRequest["action"], string> = {
CREATE: "teal",
UPDATE: "blue",
DELETE: "red",
};
const fmtDateTime = (iso: string) =>
new Date(iso).toLocaleString("en-GB", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
/** "WAGON 15 · 30 pts" from a change payload / target rule. */
const ruleSummary = (
r: PriorityRuleChangeRequest,
): string => {
const source = (r.payload ?? r.priorityConfig ?? {}) as Record<string, unknown>;
const base = (r.priorityConfig ?? {}) as Record<string, unknown>;
const pick = (key: string) => source[key] ?? base[key];
const type = pick("type");
const currency = pick("currency");
const min = pick("minWagonCount");
const max = pick("maxWagonCount");
const pts = pick("scorePoints");
const parts = [
type ? String(type) : null,
currency ? String(currency) : null,
min != null && max != null ? `${min}${max} wagons` : null,
pts != null ? `${pts} pts` : null,
].filter(Boolean);
return parts.join(" · ") || "—";
};
type Decide = UseMutationResult<
PriorityRuleChangeRequest,
unknown,
{ id: string; decisionNote?: string }
>;
interface PriorityRuleApprovalsSectionProps {
requests: PriorityRuleChangeRequest[];
canDecide: boolean;
approve: Decide;
reject: Decide;
}
/**
* Pending priority-rule change requests awaiting approval. Rendered above the
* rules table on the priority-configs page; every rule change lands here first
* and only an approval applies it.
*/
const PriorityRuleApprovalsSection = ({
requests,
canDecide,
approve,
reject,
}: PriorityRuleApprovalsSectionProps) => {
if (requests.length === 0) return null;
return (
<Card withBorder radius="md" padding="md" mb="md">
<Group gap={8} mb="sm">
<Text fw={700}>Pending approvals</Text>
<Badge variant="light" color="yellow">
{requests.length}
</Badge>
</Group>
<Stack gap={8}>
{requests.map((r) => (
<Card key={r.id} withBorder radius="md" padding="sm">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Stack gap={4} style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Badge variant="light" color={ACTION_COLOR[r.action]} radius="sm">
{r.action.toLowerCase()}
</Badge>
<Text size="sm" fw={600} truncate>
{ruleSummary(r)}
</Text>
</Group>
<Text size="xs" c="dimmed">
Submitted {fmtDateTime(r.createdAt)}
</Text>
</Stack>
{canDecide ? (
<Group gap={8} wrap="nowrap">
<Button
size="compact-sm"
variant="subtle"
color="red"
leftSection={<XCircle size={14} />}
loading={reject.isPending}
onClick={() => reject.mutate({ id: r.id })}
>
Reject
</Button>
<Button
size="compact-sm"
color="edr-green"
leftSection={<CheckCircle2 size={14} />}
loading={approve.isPending}
onClick={() => approve.mutate({ id: r.id })}
>
Approve & apply
</Button>
</Group>
) : null}
</Group>
</Card>
))}
</Stack>
</Card>
);
};
export default PriorityRuleApprovalsSection;

View File

@@ -18,6 +18,7 @@ import { Navigate, useLocation, useParams } from "react-router-dom";
import { PageContainer, PageHeader } from "@/components/page";
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls";
@@ -34,6 +35,7 @@ import {
useContainerTypeOptions,
useLiveRateOptions,
useWagonTypeOptions,
usePriorityRuleWorkflow,
useRateWorkflow,
useRuleEngineList,
useRuleEngineMutations,
@@ -142,6 +144,13 @@ const RuleEngineResourcePage = () => {
chainOpen && config?.slug === "approval-rules",
);
// Priority rules never mutate directly: changes are filed for approval and a
// pending queue renders above the table.
const isPriorityRules = config?.slug === "priority-configs";
const priorityWorkflow = usePriorityRuleWorkflow(
Boolean(isPriorityRules && canView),
);
const editingId = editing?.id ? String(editing.id) : undefined;
const usesContainerTypeField = Boolean(
config?.formFields.some((f) => f.name === "containerTypeId"),
@@ -360,9 +369,37 @@ const RuleEngineResourcePage = () => {
currency: "USD",
trigger: isSurcharge ? values.trigger : "ALWAYS",
};
} else if (config.slug === "priority-configs") {
} else if (isPriorityRules) {
// Label is required by the backend but hidden in the UI for now.
payload = { ...values, label: String(Date.now()) };
// Approval workflow: file a change request instead of mutating directly.
// On update, keep the target's existing label rather than a fresh stamp.
if (editing?.id) {
priorityWorkflow.submit.mutate(
{
action: "UPDATE",
priorityConfigId: String(editing.id),
update: { ...values, label: String(editing.label ?? Date.now()) },
},
{
onSuccess: () => {
setFormOpen(false);
setEditing(null);
},
},
);
} else {
priorityWorkflow.submit.mutate(
{ action: "CREATE", create: payload },
{
onSuccess: () => {
setFormOpen(false);
setEditing(null);
},
},
);
}
return;
} else if (config.slug === "weight-limit-rules") {
// Empty max capacity means "no ceiling" — send null explicitly so an
// edit can clear a previously-set ceiling (omitting the key keeps it).
@@ -406,6 +443,15 @@ const RuleEngineResourcePage = () => {
}
/>
{isPriorityRules ? (
<PriorityRuleApprovalsSection
requests={priorityWorkflow.pending.data ?? []}
canDecide={canManage}
approve={priorityWorkflow.approve}
reject={priorityWorkflow.reject}
/>
) : null}
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
@@ -519,7 +565,9 @@ const RuleEngineResourcePage = () => {
}
fields={formFields}
initialRecord={editing}
isSubmitting={create.isPending || update.isPending}
isSubmitting={
create.isPending || update.isPending || priorityWorkflow.submit.isPending
}
selectOptionsLoading={
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
(usesContainerTypeField && containerTypeOptionsLoading) ||
@@ -557,8 +605,9 @@ const RuleEngineResourcePage = () => {
>
<Stack gap="md">
<Text size="sm">
This will soft-delete the selected {config.label.toLowerCase()}{" "}
record.
{isPriorityRules
? "This files a delete request for approval — the rule is removed once an approver confirms."
: `This will soft-delete the selected ${config.label.toLowerCase()} record.`}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setDeleteTarget(null)}>
@@ -566,15 +615,25 @@ const RuleEngineResourcePage = () => {
</Button>
<Button
color="red"
loading={remove.isPending}
loading={remove.isPending || priorityWorkflow.submit.isPending}
onClick={() => {
if (!deleteTarget) return;
if (isPriorityRules) {
priorityWorkflow.submit.mutate(
{
action: "DELETE",
priorityConfigId: String(deleteTarget.id),
},
{ onSuccess: () => setDeleteTarget(null) },
);
return;
}
remove.mutate(deleteTarget.id, {
onSuccess: () => setDeleteTarget(null),
});
}}
>
Delete
{isPriorityRules ? "Request delete" : "Delete"}
</Button>
</Group>
</Stack>

View File

@@ -32,7 +32,11 @@ import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus";
import {
directionColor,
trainStatusColor,
trainStatusLabel,
} from "@/components/trainBuilder/trainStatus";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
@@ -72,12 +76,18 @@ export default function TrainBuilderDetailPage() {
);
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
const maintenanceWagon = useMutation(
api.trainBuilder.sendWagonToMaintenance.mutationOptions(),
);
const reorderWagons = useMutation(api.trainBuilder.reorderWagons.mutationOptions());
const disband = useMutation(api.trainBuilder.disband.mutationOptions());
const composition = compositionQuery.data;
const busy =
assignWagons.isPending || removeWagon.isPending || reorderWagons.isPending;
assignWagons.isPending ||
removeWagon.isPending ||
maintenanceWagon.isPending ||
reorderWagons.isPending;
const withToast = async (action: () => Promise<unknown>, failTitle: string) => {
try {
@@ -128,9 +138,17 @@ export default function TrainBuilderDetailPage() {
}
backTo="/dashboard/train-builder"
meta={
<Badge color={trainStatusColor(composition.status)} variant="light">
{trainStatusLabel(composition.status)}
</Badge>
<Group gap="xs">
<Badge color={trainStatusColor(composition.status)} variant="light">
{trainStatusLabel(composition.status)}
</Badge>
<Badge color="blue" variant="light" ff="monospace">
IMP {composition.importTrainNumber ?? "—"}
</Badge>
<Badge color="orange" variant="light" ff="monospace">
EXP {composition.exportTrainNumber ?? "—"}
</Badge>
</Group>
}
action={
<Menu position="bottom-end" withinPortal shadow="md" width={220}>
@@ -272,6 +290,12 @@ export default function TrainBuilderDetailPage() {
"Could not detach wagon",
)
}
onMaintenance={(wagonId) =>
void withToast(
() => maintenanceWagon.mutateAsync({ id: composition.id, wagonId }),
"Could not send wagon to maintenance",
)
}
/>
</Stack>
</Card>
@@ -289,6 +313,16 @@ export default function TrainBuilderDetailPage() {
<Text size="sm" ff="monospace" fw={600}>
{schedule.reference ?? schedule.id.slice(0, 8)}
</Text>
{schedule.trainNumber ? (
<Text size="sm" ff="monospace" fw={700}>
{schedule.trainNumber}
</Text>
) : null}
{schedule.direction ? (
<Badge size="sm" variant="light" color={directionColor(schedule.direction)}>
{schedule.direction}
</Badge>
) : null}
<Badge size="sm" variant="light">
{schedule.status}
</Badge>

View File

@@ -27,7 +27,12 @@ import { useNavigate } from "react-router-dom";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal";
import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus";
import {
directionColor,
directionRowStyle,
trainStatusColor,
trainStatusLabel,
} from "@/components/trainBuilder/trainStatus";
import { api } from "@/services/api";
import type {
BuiltTrainListFilters,
@@ -146,6 +151,32 @@ export default function TrainBuilderListPage() {
</Group>
),
},
{
id: "numbers",
header: "Train No.",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const active = row.original.activeSchedule;
return (
<Stack gap={2}>
{active?.trainNumber ? (
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={700} ff="monospace" lh={1.2}>
{active.trainNumber}
</Text>
<Badge size="xs" variant="light" color={directionColor(active.direction)}>
{active.direction ?? "—"}
</Badge>
</Group>
) : null}
<Text size="xs" c="dimmed" ff="monospace" lh={1.2}>
IMP {row.original.importTrainNumber ?? "—"} · EXP{" "}
{row.original.exportTrainNumber ?? "—"}
</Text>
</Stack>
);
},
},
{
id: "yard",
header: "Yard",
@@ -293,6 +324,7 @@ export default function TrainBuilderListPage() {
data={trains}
status={tableStatus}
onRowClick={(train) => navigate(`/dashboard/train-builder/${train.id}`)}
rowStyle={(train) => directionRowStyle(train.activeSchedule?.direction)}
error={
trainsQuery.isError
? {

View File

@@ -68,6 +68,7 @@ import {
ScheduleWarningsAlert,
} from "@/components/trainScheduling/ScheduleWarningsAlert";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { TrainConsistView } from "@/components/trainScheduling/compositionEditor";
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
import { openPdfBlob } from "@/components/warehouses/pdf";
@@ -244,6 +245,15 @@ export default function TrainScheduleV2DetailPage() {
return [];
}, [previewResult?.wagonPlan, schedule?.trainSet?.wagons]);
// EXPORT schedules render the consist back-to-front (the train turns around
// for the return run) — DISPLAY ONLY: stored sequenceNos, allocations,
// documents, and the adjust-consist / placement flows keep the as-built order.
const isExportDisplay = schedule?.direction === "EXPORT";
const displayWagonPlanOriented = useMemo(
() => (isExportDisplay ? [...displayWagonPlan].reverse() : displayWagonPlan),
[displayWagonPlan, isExportDisplay],
);
const runPreview = useCallback(
async (options?: { silent?: boolean; advanceStep?: boolean }) => {
if (!schedule || !scheduleId) return null;
@@ -683,7 +693,12 @@ export default function TrainScheduleV2DetailPage() {
fleetAvailability={previewResult?.fleetAvailability}
deferredBookings={previewResult?.deferredBookings}
/>
<WagonPlanGrid wagonPlan={displayWagonPlan} freightType={freightType} />
{isExportDisplay && displayWagonPlanOriented.length ? (
<Text size="xs" c="dimmed">
Shown rear-first (export direction) positions keep their original numbers.
</Text>
) : null}
<WagonPlanGrid wagonPlan={displayWagonPlanOriented} freightType={freightType} />
{canEditBookings && (previewResult || displayWagonPlan.length) ? (
<Group>
{!hasContainerStep ? (
@@ -754,21 +769,26 @@ export default function TrainScheduleV2DetailPage() {
);
}
// finalize
// finalize — the train is known here, so draw the full composition the
// same way the batch board's composition tab does (interactive consist).
return (
<Stack gap="md">
<TrainCompositionDiagram
locomotive={schedule.trainSet?.locomotive}
locomotives={locomotives}
wagons={
schedule.trainSet?.wagons?.length
? schedule.trainSet.wagons
: displayWagonPlan
}
freightType={freightType}
trainNumber={schedule.train ? schedule.train.code : schedule.trainNumber}
totalLengthMeters={schedule.trainSet?.totalLengthMeters}
/>
{schedule.trainSet ? (
<TrainConsistView
scheduleDetail={schedule}
scheduleId={scheduleId ?? ""}
maxWagons={schedule.maxWagons ?? 53}
/>
) : (
<TrainCompositionDiagram
locomotive={null}
locomotives={locomotives}
wagons={[]}
freightType={freightType}
trainNumber={schedule.trainNumber ?? schedule.train?.code ?? null}
totalLengthMeters={null}
/>
)}
<Paper
p="lg"
radius="lg"
@@ -885,6 +905,11 @@ export default function TrainScheduleV2DetailPage() {
{schedule.trainNumber}
</Badge>
) : null}
{schedule.train ? (
<Text size="xs" c="dimmed" ff="monospace">
Train {schedule.train.code}
</Text>
) : null}
</Group>
<Box maw={340}>
<RouteCorridor

View File

@@ -327,19 +327,24 @@ export default function TrainScheduleV2ListPage() {
header: "Train",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
// Schedules created from the Train Builder carry the train code;
// legacy rows fall back to their locomotive set.
// Schedules created from the Train Builder show the direction-matched
// run number first (falling back to the train code); legacy rows fall
// back to their locomotive set.
if (row.original.train) {
const subtitle = [row.original.trainNumber ? row.original.train.code : null,
row.original.train.trainName]
.filter(Boolean)
.join(" · ");
return (
<Group gap={6} wrap="nowrap">
<Train size={14} color="var(--mantine-color-gray-5)" />
<Stack gap={0}>
<Text size="sm" fw={600} ff="monospace" lh={1.2}>
{row.original.train.code}
{row.original.trainNumber ?? row.original.train.code}
</Text>
{row.original.train.trainName ? (
{subtitle ? (
<Text size="xs" c="dimmed" lh={1.2}>
{row.original.train.trainName}
{subtitle}
</Text>
) : null}
</Stack>
@@ -758,14 +763,21 @@ export default function TrainScheduleV2ListPage() {
label="Train"
description="A built train (Train Builder) runs this departure with its locomotives and wagons"
placeholder={routeId ? "Select a train" : "Select a route first"}
data={(trainsQuery.data ?? []).map((train) => ({
value: train.id,
label: `${train.code}${train.trainName ? `${train.trainName}` : ""} · ${
train.locomotives.length
} locos · ${train.wagonCount} wagons${train.atOriginYard ? "" : " · not at origin yard"}${
train.futureScheduleCount ? ` · ${train.futureScheduleCount} future run(s)` : ""
}`,
}))}
data={(trainsQuery.data ?? []).map((train) => {
// Route direction picks which of the train's typed pair this run uses.
const runNumber =
selectedRoute?.direction === "IMPORT"
? train.importTrainNumber
: train.exportTrainNumber;
return {
value: train.id,
label: `${train.code}${train.trainName ? `${train.trainName}` : ""}${
runNumber ? ` · runs as ${runNumber}` : ""
} · ${train.locomotives.length} locos · ${train.wagonCount} wagons${
train.atOriginYard ? "" : " · not at origin yard"
}${train.futureScheduleCount ? ` · ${train.futureScheduleCount} future run(s)` : ""}`,
};
})}
value={trainId || null}
onChange={(v) => setTrainId(v ?? "")}
searchable

View File

@@ -62,7 +62,6 @@ export default function TrainSchedulingGlobalRulesPage() {
"windowDurationHours",
"docReviewMinutes",
"paymentWindowMinutes",
"reopenDelayMinutes",
];
const payload: Partial<Record<keyof TrainSchedulingGlobalRules, number>> = {};
for (const key of fields) {
@@ -261,17 +260,6 @@ export default function TrainSchedulingGlobalRulesPage() {
min={1}
disabled={loading}
/>
<DurationField
label="Reopen delay"
description="Delay after window close before reopening when the train is not full (90 min = 11:00 close → 12:30 reopen)"
value={form.reopenDelayMinutes ?? ""}
nativeUnit="minutes"
onChange={(value) =>
setForm((current) => ({ ...current, reopenDelayMinutes: value }))
}
min={1}
disabled={loading}
/>
<Group justify="flex-end">
<Button loading={saving} disabled={loading} onClick={() => void handleSave()}>
Save rules

View File

@@ -1,5 +1,5 @@
import { useNavigate } from 'react-router-dom';
import { Card, Center, Group, Loader, SimpleGrid, Text, ThemeIcon } from '@mantine/core';
import { Badge, Card, Center, Divider, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import {
ClipboardCheck,
ClipboardList,
@@ -16,10 +16,26 @@ import {
} from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page';
import { WarehouseDashboardCharts } from '@/components/warehouses';
import {
AccrualDashboard,
CycleTimeCard,
DwellAgingCard,
GateThroughputCard,
WarehouseDashboardCharts,
WarehouseOpsKpiStrip,
ZoneOccupancyHeatmap,
} from '@/components/warehouses';
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
import type { WarehouseDashboard } from '@/types/warehouse';
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<Text fw={700} fz="sm" tt="uppercase" c="edr-muted" style={{ letterSpacing: 0.4 }}>
{children}
</Text>
);
}
interface Metric {
key: keyof WarehouseDashboard;
label: string;
@@ -56,6 +72,26 @@ export default function WarehouseDashboardPage() {
<PageHeader
title="Warehouse Dashboard"
subtitle="Live overview of warehouse capacity and inventory lifecycle."
action={
<Badge
color="edr-green"
variant="light"
size="lg"
leftSection={
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: 'var(--mantine-color-edr-green-6)',
}}
/>
}
>
Live · updates every 60s
</Badge>
}
/>
{isLoading ? (
@@ -67,7 +103,16 @@ export default function WarehouseDashboardPage() {
<Text c="red">Failed to load warehouse dashboard.</Text>
</Center>
) : (
<>
<Stack gap="xl">
{/* Needs attention — live ops counters (received today, pending
inspection, trucks on-site, items aging > 7 days). */}
<Stack gap="sm">
<SectionTitle>Needs attention</SectionTitle>
<WarehouseOpsKpiStrip />
</Stack>
<Divider />
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
{METRICS.map((metric) => (
<Card
@@ -98,8 +143,30 @@ export default function WarehouseDashboardPage() {
))}
</SimpleGrid>
<WarehouseDashboardCharts data={data} />
</>
<Stack gap="sm">
<SectionTitle>Flow</SectionTitle>
<WarehouseDashboardCharts data={data} />
</Stack>
<Stack gap="sm">
<SectionTitle>Performance</SectionTitle>
<SimpleGrid cols={{ base: 1, lg: 2, xl: 3 }} spacing="md">
<DwellAgingCard />
<CycleTimeCard />
<GateThroughputCard />
</SimpleGrid>
</Stack>
<Stack gap="sm">
<SectionTitle>Zone capacity</SectionTitle>
<ZoneOccupancyHeatmap />
</Stack>
<Stack gap="sm">
<SectionTitle>Demurrage &amp; storage exceptions</SectionTitle>
<AccrualDashboard />
</Stack>
</Stack>
)}
</PageContainer>
);

View File

@@ -202,6 +202,7 @@ import {
type WagonMovementRecord,
type WagonTransferRequest,
type CreateTransferRequestPayload,
type BulkFulfillResult,
type TransferHistory,
} from "./wagon.service";
import { warehouseService } from "./warehouse.service";
@@ -1721,6 +1722,15 @@ export const api = {
() => [["wagonTransferRequests"], ["wagons"]],
),
bulkFulfill: endpoint<{ requestIds: string[] }, BulkFulfillResult>(
"wagonTransferRequests",
"bulkFulfill",
({ requestIds }) =>
wagonTransferRequestService.bulkFulfill(requestIds).then((r) => r.data),
undefined,
() => [["wagonTransferRequests"], ["wagons"]],
),
cancel: endpoint<{ id: string }, WagonTransferRequest>(
"wagonTransferRequests",
"cancel",
@@ -1850,6 +1860,15 @@ export const api = {
() => TRAIN_BUILDER_INVALIDATIONS,
),
sendWagonToMaintenance: endpoint<{ id: string; wagonId: string }, TrainComposition>(
"train-builder",
"sendWagonToMaintenance",
({ id, wagonId }) =>
trainBuilderService.sendWagonToMaintenance(id, wagonId).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
"train-builder",
"reorderWagons",

View File

@@ -170,8 +170,35 @@ export const contractsService = {
},
// ── Staff review ──
staffAccept: (id: string, validityDays: number) =>
postContract<Freight.IContract>(C.STAFF_ACCEPT(id), { validityDays }),
staffAccept: (
id: string,
validityDays: number,
documentSnapshot?: Freight.IContractDocumentSnapshot,
) =>
postContract<Freight.IContract>(C.STAFF_ACCEPT(id), {
validityDays,
documentSnapshot,
}),
/** The editable per-contract document draft (snapshot or live template). */
getContractDocumentDraft: async (
id: string,
): Promise<Freight.IContractDocumentDraft> => {
const response = await client.get(C.CONTRACT_DOCUMENT_DRAFT(id));
return unwrap(response.data) as Freight.IContractDocumentDraft;
},
/** Save this contract's edited document articles (never touches the templates). */
updateContractDocument: async (
id: string,
snapshot: Freight.IContractDocumentSnapshot,
): Promise<Freight.IContract> => {
const response = await client.put(
C.CONTRACT_DOCUMENT_ARTICLES(id),
snapshot,
);
return unwrap(response.data) as Freight.IContract;
},
requestChanges: (id: string, note: string) =>
postContract<Freight.IContract>(C.STAFF_REQUEST_CHANGES(id), { note }),

View File

@@ -24,6 +24,30 @@ export interface RuleEngineReorderPayload {
requiresDirectorApproval?: boolean;
}
/** Priority-rule approval workflow (all priority-config changes go through it). */
const PRIORITY_RULE_CHANGES_BASE = "/priority-rule-change-requests";
export interface PriorityRuleChangeRequest {
id: string;
action: "CREATE" | "UPDATE" | "DELETE";
priorityConfigId: string | null;
priorityConfig?: RuleEngineRecord | null;
payload: Record<string, unknown> | null;
status: "PENDING" | "APPROVED" | "REJECTED";
requestedByUserId: string | null;
decidedByUserId: string | null;
decidedAt: string | null;
decisionNote: string | null;
createdAt: string;
}
export interface SubmitPriorityRuleChangePayload {
action: "CREATE" | "UPDATE" | "DELETE";
priorityConfigId?: string;
create?: Record<string, unknown>;
update?: Record<string, unknown>;
}
const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
@@ -242,6 +266,46 @@ export const ruleEngineService = {
return normalizeEntity<T>(response.data);
},
/** File a priority-rule change (create/update/delete) for approval. */
submitPriorityRuleChange: async (
payload: SubmitPriorityRuleChangePayload,
): Promise<PriorityRuleChangeRequest> => {
const response = await client.post(PRIORITY_RULE_CHANGES_BASE, payload);
return unwrap(response.data) as PriorityRuleChangeRequest;
},
listPriorityRuleChanges: async (
status?: PriorityRuleChangeRequest["status"],
): Promise<PriorityRuleChangeRequest[]> => {
const response = await client.get(PRIORITY_RULE_CHANGES_BASE, {
params: status ? { status } : undefined,
});
const body = unwrap(response.data) as unknown;
return Array.isArray(body) ? (body as PriorityRuleChangeRequest[]) : [];
},
approvePriorityRuleChange: async (
id: string,
decisionNote?: string,
): Promise<PriorityRuleChangeRequest> => {
const response = await client.post(
`${PRIORITY_RULE_CHANGES_BASE}/${id}/approve`,
{ decisionNote },
);
return unwrap(response.data) as PriorityRuleChangeRequest;
},
rejectPriorityRuleChange: async (
id: string,
decisionNote?: string,
): Promise<PriorityRuleChangeRequest> => {
const response = await client.post(
`${PRIORITY_RULE_CHANGES_BASE}/${id}/reject`,
{ decisionNote },
);
return unwrap(response.data) as PriorityRuleChangeRequest;
},
getApprovalChain: async (
requiresDirectorApproval = true,
): Promise<RuleEngineRecord[]> => {

View File

@@ -17,11 +17,27 @@ export interface YardRefLite {
label: string;
}
export type TradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
/** The one active (DRAFT/SCHEDULED/DISPATCHED) schedule surfaced per built train. */
export interface ActiveScheduleRef {
id: string;
status: string;
reference: string | null;
direction: TradeDirection | null;
trainNumber: string | null;
}
export interface BuiltTrainSummary {
id: string;
code: string;
trainName: string | null;
status: BuiltTrainStatus;
/** Fixed IMPORT (even) run number typed at build time. */
importTrainNumber: string | null;
/** Fixed EXPORT (odd) run number typed at build time. */
exportTrainNumber: string | null;
activeSchedule: ActiveScheduleRef | null;
createdAt: string;
currentYard: YardRefLite | null;
locomotives: Array<{ id: string; code: string; name: string | null }>;
@@ -80,13 +96,15 @@ export interface TrainComposition {
code: string;
trainName: string | null;
status: BuiltTrainStatus;
importTrainNumber: string | null;
exportTrainNumber: string | null;
notes: string | null;
createdAt: string;
currentYard: YardRefLite | null;
locomotives: TrainCompositionLocomotive[];
wagons: TrainCompositionWagon[];
totals: TrainCompositionTotals;
activeSchedules: Array<{ id: string; status: string; reference: string | null }>;
activeSchedules: ActiveScheduleRef[];
editable: boolean;
}
@@ -111,7 +129,10 @@ export interface BuiltTrainListResponse {
}
export interface BuildTrainPayload {
code: string;
/** EXPORT run number — odd, unique across trains (e.g. 8001). */
exportTrainNumber: string;
/** IMPORT run number — even, unique across trains (e.g. 8002). */
importTrainNumber: string;
currentYardId: string;
locomotiveIds: string[];
wagonIds?: string[];
@@ -125,6 +146,8 @@ export interface AvailableTrain {
code: string;
trainName: string | null;
status: BuiltTrainStatus;
importTrainNumber: string | null;
exportTrainNumber: string | null;
currentYardId: string | null;
currentYard: YardRefLite | null;
locomotives: Array<{ id: string; code: string; name: string | null }>;
@@ -225,6 +248,9 @@ export const trainBuilderService = {
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
removeWagon: (id: string, wagonId: string) =>
apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`),
/** Detach a wagon and move it to MAINTENANCE status. */
sendWagonToMaintenance: (id: string, wagonId: string) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`),
reorderWagons: (id: string, wagonIds: string[]) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
disband: (id: string) => apiClient.delete<void>(`${BASE}/${id}`),

View File

@@ -108,6 +108,8 @@ export interface WagonTransferRequest {
requestedByUserId: string | null;
fulfilledByUserId: string | null;
fulfilledAt: string | null;
/** Why the wagons are needed — required for new requests, shown on the queue. */
reason?: string | null;
note: string | null;
fromYard?: { id: string; label?: string; code?: string } | null;
toYard?: { id: string; label?: string; code?: string } | null;
@@ -120,9 +122,17 @@ export interface CreateTransferRequestPayload {
toYardId: string;
wagonTypeId: string;
quantity: number;
/** Mandatory: why the wagons are needed. */
reason: string;
note?: string;
}
/** Bulk accept-and-execute result: what ran, what stayed PENDING and why. */
export interface BulkFulfillResult {
fulfilled: WagonTransferRequest[];
skipped: Array<{ id: string; reason: string }>;
}
/** Per-user activity: requests filed/fulfilled + the wagons physically moved. */
export interface TransferHistory {
requests: WagonTransferRequest[];
@@ -146,6 +156,11 @@ export const wagonTransferRequestService = {
apiClient.get<WagonTransferRequest>(`/wagon-transfer-requests/${id}`),
create: (data: CreateTransferRequestPayload) =>
apiClient.post<WagonTransferRequest>('/wagon-transfer-requests', data),
/** OCC: accept-and-execute a subset of pending requests (auto-picked wagons). */
bulkFulfill: (requestIds: string[]) =>
apiClient.post<BulkFulfillResult>('/wagon-transfer-requests/bulk-fulfill', {
requestIds,
}),
/** OCC: execute the transfer with the hand-picked wagons. */
fulfill: (id: string, wagonIds: string[]) =>
apiClient.post<WagonTransferRequest>(

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