Merge pull request #710 from Tria-plc/dev

prod deployment
This commit is contained in:
mulish77
2026-07-15 19:09:12 +03:00
committed by GitHub
79 changed files with 4007 additions and 489 deletions

View File

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

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

View File

@@ -348,6 +348,28 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); 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') @Get(':id/mile-summary')
@ApiOperation({ @ApiOperation({
summary: 'First/last-mile operational summary for a booking (customer-safe)', summary: 'First/last-mile operational summary for a booking (customer-safe)',

View File

@@ -653,22 +653,37 @@ export class BookingsService {
} else if (dto.scheduledDate) { } else if (dto.scheduledDate) {
// A real (binding) scheduledDate was supplied (e.g. staff pinning a day // A real (binding) scheduledDate was supplied (e.g. staff pinning a day
// directly). Require that the route has at least one OPEN departure on // directly). Require that the route has at least one OPEN departure on
// that EAT day. The booking wizard does NOT send scheduledDate at creation // that EAT day AND that some departure that day can physically carry the
// — it captures a non-binding estimatedShipmentDate instead, and the // cargo (wagon-TYPE gate — quantity never blocks; oversized bookings get
// binding day is chosen later at the operation-request step. General // a partial split offer later). The booking wizard does NOT send
// contracts also skip this (each drawdown order validates its own day). // 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 day = eatDay(new Date(dto.scheduledDate));
const hasDeparture = const { hasDeparture, hasCompatible } =
await this.trainSchedulingService.existsOpenScheduleOnRouteDay( await this.trainSchedulingService.checkDayCargoCompatibility(
dto.originYardId, dto.originYardId,
dto.destinationYardId, dto.destinationYardId,
day, 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) { if (!hasDeparture) {
throw new BadRequestException( throw new BadRequestException(
'No departures available on the selected day for this route', '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 ?? []; 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 * Batched version of the findById flag: marks each page item whose booking
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal * has a generated-but-unsigned SELF_HAUL handover, so list rows (portal

View File

@@ -4,6 +4,7 @@ import {
Injectable, Injectable,
Logger, Logger,
} from '@nestjs/common'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { insertWithGeneratedReference } from '@edr/api-common'; import { insertWithGeneratedReference } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; 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 { FilesService } from '../files/files.service';
import { SignaturesService } from '../signatures/signatures.service'; import { SignaturesService } from '../signatures/signatures.service';
import { OtpService } from '../otp/otp.service'; import { OtpService } from '../otp/otp.service';
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
import { ContractPricingService } from './contract-pricing.service'; import { ContractPricingService } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service'; import { ContractNotifierService } from './contract-notifier.service';
import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository'; import { ContractsRepository } from './contracts.repository';
import { ContractsService } from './contracts.service'; import { ContractsService } from './contracts.service';
import { contractClearanceSettingCode } from './contract-clearance.util'; 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 { ContractSignerRole } from './entities/contract-signature.entity';
import { SignContractDto } from './dto/sign-contract.dto'; 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 * 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 * (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 minioService: MinioService,
private readonly otpService: OtpService, private readonly otpService: OtpService,
private readonly notifier: ContractNotifierService, private readonly notifier: ContractNotifierService,
private readonly contractTemplates: ContractTemplatesService,
) {} ) {}
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
@@ -110,6 +131,7 @@ export class ContractTransitionService {
contractId: string, contractId: string,
actorId: string, actorId: string,
validityDays: number, validityDays: number,
documentSnapshot?: ContractDocumentSnapshotInput | null,
): Promise<Contract> { ): Promise<Contract> {
const contract = await this.contractsService.findById(contractId); const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['SUBMITTED']); assertContractStatus(contract, ['SUBMITTED']);
@@ -128,6 +150,12 @@ export class ContractTransitionService {
await this.instantiateApprovalSteps(contract); 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, { await this.contractsRepository.update(contractId, {
status: 'PENDING_APPROVAL', status: 'PENDING_APPROVAL',
approvedByStaffId: actorId, approvedByStaffId: actorId,
@@ -135,12 +163,148 @@ export class ContractTransitionService {
contractValidityDays: validityDays, contractValidityDays: validityDays,
contractValidFrom: validFrom, contractValidFrom: validFrom,
contractValidUntil: validUntil, contractValidUntil: validUntil,
documentSnapshot: snapshot,
} as never); } as never);
const updated = await this.contractsService.findById(contractId); const updated = await this.contractsService.findById(contractId);
this.notifier.accepted(updated); this.notifier.accepted(updated);
return 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 * Ensure the chosen validity (days) is one of the admin-configured options in
* the `contract_validity_periods` dropdown setting. If the setting is missing * 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); const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); 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); const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
if (!step || step.status !== 'PENDING') { if (!step || step.status !== 'PENDING') {
throw new BadRequestException('Approval step not found or already actioned'); throw new BadRequestException('Approval step not found or already actioned');
@@ -350,15 +523,14 @@ export class ContractTransitionService {
const updated = await this.contractsService.findById(contractId); const updated = await this.contractsService.findById(contractId);
if (allDone) { if (allDone) {
this.notifier.approved(updated); this.notifier.approved(updated);
// Final approval step also generates the contract document from the // Every step approved → CONTRACT_READY. The document was already generated
// template matching the contract's direction/freight pair. Best-effort: // (and reviewed) at the accept stage, so we reuse it rather than
// a rendering hiccup must not roll back the approval — the document can // re-rendering. Best-effort: a hiccup must not roll back the approval.
// still be generated manually or lazily on view/download.
try { try {
return await this.generateContract(contractId); return await this.finalizeApprovedContract(contractId);
} catch (err) { } catch (err) {
this.logger.warn( 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, * Staff (re)generate the contract PDF. Two stages:
* stamp the template key, and move to CONTRACT_READY. PDF rendering (Puppeteer/ * - PENDING_APPROVAL: render from the frozen (optionally staff-edited)
* Chromium) is best-effort and must NOT block the contract from becoming ready — * snapshot so approvers review the real document. Status is UNCHANGED, and
* the document is (re)rendered lazily on view/download once Chromium is available. * 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> { async generateContract(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId); 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']); 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 { try {
await this.upsertContractPdf(contractId, contract.reference, view); await this.upsertContractPdf(contract.id, contract.reference, view);
} catch (err) { } catch (err) {
this.logger.warn( this.logger.warn(
`Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`, `Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
); );
} }
await this.contractsRepository.update(contract.id, {
await this.contractsRepository.update(contractId, {
status: 'CONTRACT_READY',
contractTemplateKey: view.templateKey, contractTemplateKey: view.templateKey,
contractGeneratedAt: new Date(), contractGeneratedAt: new Date(),
} as never); } 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); return this.contractsService.findById(contractId);
} }

View File

@@ -8,6 +8,7 @@ import {
ParseUUIDPipe, ParseUUIDPipe,
Patch, Patch,
Post, Post,
Put,
Query, Query,
Res, Res,
UnauthorizedException, UnauthorizedException,
@@ -57,6 +58,7 @@ import { UpdateContractDto } from './dto/update-contract.dto';
import { FilterContractDto } from './dto/filter-contract.dto'; import { FilterContractDto } from './dto/filter-contract.dto';
import { ContractListSummaryDto } from './dto/contract-list-summary.dto'; import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
import { AcceptContractDto } from './dto/accept-contract.dto'; import { AcceptContractDto } from './dto/accept-contract.dto';
import { UpdateContractDocumentDto } from './dto/contract-document.dto';
import { import {
ApproveStepDto, ApproveStepDto,
RejectContractDto, RejectContractDto,
@@ -340,9 +342,33 @@ export class ContractsController {
id, id,
resolveAuthUserId(user), resolveAuthUserId(user),
dto.validityDays, 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') @Post(':id/staff/request-changes')
@BookingStaff(FREIGHT_PERMS.contracts.requestChanges) @BookingStaff(FREIGHT_PERMS.contracts.requestChanges)
@ApiOperation({ summary: 'Staff return contract for customer updates' }) @ApiOperation({ summary: 'Staff return contract for customer updates' })

View File

@@ -1,5 +1,8 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, Max, Min } from 'class-validator'; import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min, ValidateNested } from 'class-validator';
import { UpdateContractDocumentDto } from './contract-document.dto';
export class AcceptContractDto { export class AcceptContractDto {
@ApiProperty({ @ApiProperty({
@@ -14,4 +17,16 @@ export class AcceptContractDto {
@Min(1) @Min(1)
@Max(3650) @Max(3650)
validityDays!: number; 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]; 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 const CONTRACT_KINDS = ['ONE_TIME', 'GENERAL'] as const;
export type ContractKindValue = (typeof CONTRACT_KINDS)[number]; 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 }) @Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
contractGeneratedAt?: Date | null; 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 }) @Column({ name: 'contract_summary', type: 'text', nullable: true })
contractSummary?: string | null; 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 { CargoTypesController } from './controllers/cargo-types.controller';
import { ContainerTypesController } from './controllers/container-types.controller'; import { ContainerTypesController } from './controllers/container-types.controller';
import { PriorityConfigsController } from './controllers/priority-configs.controller'; import { PriorityConfigsController } from './controllers/priority-configs.controller';
import { PriorityRuleChangeRequestsController } from './controllers/priority-rule-change-requests.controller';
import { RatesController } from './controllers/rates.controller'; import { RatesController } from './controllers/rates.controller';
import { ServiceTypesController } from './controllers/service-types.controller'; import { ServiceTypesController } from './controllers/service-types.controller';
import { ShippingLinesController } from './controllers/shipping-lines.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 { CargoType } from './entities/cargo-type.entity';
import { ContainerType } from './entities/container-type.entity'; import { ContainerType } from './entities/container-type.entity';
import { PriorityConfig } from './entities/priority-config.entity'; import { PriorityConfig } from './entities/priority-config.entity';
import { PriorityRuleChangeRequest } from './entities/priority-rule-change-request.entity';
import { Rate } from './entities/rate.entity'; import { Rate } from './entities/rate.entity';
import { ServiceType } from './entities/service-type.entity'; import { ServiceType } from './entities/service-type.entity';
import { ShippingLine } from './entities/shipping-line.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 { CargoTypesService } from './services/cargo-types.service';
import { ContainerTypesService } from './services/container-types.service'; import { ContainerTypesService } from './services/container-types.service';
import { PriorityConfigsService } from './services/priority-configs.service'; import { PriorityConfigsService } from './services/priority-configs.service';
import { PriorityRuleChangeRequestsService } from './services/priority-rule-change-requests.service';
import { RatesService } from './services/rates.service'; import { RatesService } from './services/rates.service';
import { ServiceTypesService } from './services/service-types.service'; import { ServiceTypesService } from './services/service-types.service';
import { ShippingLinesService } from './services/shipping-lines.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 { RuleEngineService } from './rule-engine.service';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity'; import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity'; import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity';
@@ -66,6 +71,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
CargoType, CargoType,
ContainerType, ContainerType,
PriorityConfig, PriorityConfig,
PriorityRuleChangeRequest,
ServiceType, ServiceType,
WeightLimitRule, WeightLimitRule,
Yard, Yard,
@@ -77,11 +83,14 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
BookingApprovalStep, BookingApprovalStep,
BookingRateSnapshot, BookingRateSnapshot,
]), ]),
// Team notifications for the priority-rule approval workflow.
NotificationInboxModule,
], ],
controllers: [ controllers: [
CargoTypesController, CargoTypesController,
ContainerTypesController, ContainerTypesController,
PriorityConfigsController, PriorityConfigsController,
PriorityRuleChangeRequestsController,
ServiceTypesController, ServiceTypesController,
WeightLimitRulesController, WeightLimitRulesController,
YardsController, YardsController,
@@ -111,6 +120,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
CargoTypesService, CargoTypesService,
ContainerTypesService, ContainerTypesService,
PriorityConfigsService, PriorityConfigsService,
PriorityRuleChangeRequestsService,
ServiceTypesService, ServiceTypesService,
WeightLimitRulesService, WeightLimitRulesService,
YardsService, YardsService,

View File

@@ -31,6 +31,12 @@ export class PriorityConfigsService {
async create(dto: CreatePriorityConfigDto): Promise<PriorityConfig> { async create(dto: CreatePriorityConfigDto): Promise<PriorityConfig> {
this.validateCurrencyField(dto.type, dto.currency); 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', {}); const displayOrder = await this.displayOrder.resolveCreateOrder(PriorityConfig, 'displayOrder', {});
@@ -52,6 +58,13 @@ export class PriorityConfigsService {
const type = dto.type ?? existing.type; const type = dto.type ?? existing.type;
const currency = dto.currency !== undefined ? dto.currency : existing.currency; const currency = dto.currency !== undefined ? dto.currency : existing.currency;
this.validateCurrencyField(type, 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 { ...patch } = dto;
const updated = await this.repository.update(id, patch); const updated = await this.repository.update(id, patch);
@@ -59,6 +72,43 @@ export class PriorityConfigsService {
return updated; 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> { async remove(id: string): Promise<void> {
await this.findById(id); await this.findById(id);
await this.repository.softDelete(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

@@ -41,6 +41,28 @@ export class AvailableDaysForCargoQueryDto {
@IsString() @IsString()
cargoTypeCode?: string; 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.' }) @ApiPropertyOptional({ description: 'Total bulk weight in tons.' })
@IsOptional() @IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))

View File

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

View File

@@ -1391,8 +1391,6 @@ export class TrainSchedulingService {
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
const trainSetId = schedule.trainSetId; const trainSetId = schedule.trainSetId;
await this.releasePinnedWagonsForTrainSet(manager, trainSetId);
const deletedAllocationIds = const deletedAllocationIds =
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(trainSetId, manager); await this.wagonBookingAllocationsRepository.deleteByTrainSetId(trainSetId, manager);
@@ -1528,7 +1526,6 @@ export class TrainSchedulingService {
(sb) => sb.bookingId !== bookingId, (sb) => sb.bookingId !== bookingId,
); );
if (remainingBookings.length === 0) { if (remainingBookings.length === 0) {
await this.releasePinnedWagonsForTrainSet(manager, schedule.trainSetId);
await this.wagonBookingAllocationsRepository.deleteByTrainSetId( await this.wagonBookingAllocationsRepository.deleteByTrainSetId(
schedule.trainSetId, schedule.trainSetId,
manager, manager,
@@ -1768,7 +1765,18 @@ export class TrainSchedulingService {
throw new BadRequestException('Cannot pin wagons on a dispatched or cancelled schedule'); 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) => { await this.dataSource.transaction(async (manager) => {
for (const assignment of dto.assignments) { for (const assignment of dto.assignments) {
@@ -1784,29 +1792,61 @@ export class TrainSchedulingService {
if (!physicalWagon) { if (!physicalWagon) {
throw new NotFoundException(`Wagon ${assignment.physicalWagonId} not found`); throw new NotFoundException(`Wagon ${assignment.physicalWagonId} not found`);
} }
if ( const occupyingSlotId = slotIdByPhysicalId.get(assignment.physicalWagonId);
physicalWagon.status !== WagonStatus.Available && if (occupyingSlotId && occupyingSlotId !== assignment.trainSetWagonId) {
physicalWagon.currentTrainScheduleId !== scheduleId const occupyingSlot = slotById.get(occupyingSlotId);
) {
throw new ConflictException( 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) { if (builtTrainId) {
throw new ConflictException( // Train-bound schedule: only the built train's own consist may be
`Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`, // 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, { await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, {
physicalWagonId: assignment.physicalWagonId, physicalWagonId: assignment.physicalWagonId,
status: 'RESERVED', status: 'RESERVED',
}); });
await manager.getRepository(Wagon).update(assignment.physicalWagonId, { for (const [physicalId, slotId] of slotIdByPhysicalId) {
trainSetWagonId: assignment.trainSetWagonId, if (slotId === assignment.trainSetWagonId) {
currentTrainScheduleId: scheduleId, slotIdByPhysicalId.delete(physicalId);
status: WagonStatus.Assigned, break;
}); }
}
slotIdByPhysicalId.set(assignment.physicalWagonId, assignment.trainSetWagonId);
} }
}); });
@@ -1860,6 +1900,22 @@ export class TrainSchedulingService {
// at a time — block dispatch while any set locomotive is out on a dispatched train. // 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); const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId); 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(); const now = new Date();
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
@@ -3735,10 +3791,11 @@ export class TrainSchedulingService {
originYardId: string, originYardId: string,
targetScheduleId?: string, targetScheduleId?: string,
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> { ): 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(Wagon).find(),
this.dataSource.getRepository(WagonType).find(), this.dataSource.getRepository(WagonType).find(),
this.builtTrainIdOfSchedule(targetScheduleId), this.builtTrainIdOfSchedule(targetScheduleId),
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
]); ]);
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code])); const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
const counts = new Map<string, { code: string; available: number }>(); const counts = new Map<string, { code: string; available: number }>();
@@ -3750,10 +3807,20 @@ export class TrainSchedulingService {
if (builtTrainId) { if (builtTrainId) {
if (wagon.trainId !== builtTrainId) continue; if (wagon.trainId !== builtTrainId) continue;
} else { } else {
const pinnedOnTarget = targetScheduleId // Schedule-scoped availability: pins held by OTHER schedules never
? wagon.currentTrainScheduleId === targetScheduleId // consume a wagon here — the same physical wagon may serve the July 17
: false; // and the July 20 run. A wagon is unusable only when it is coupled to a
if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue; // 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; if (wagon.currentYardId !== originYardId) continue;
} }
@@ -3812,22 +3879,59 @@ export class TrainSchedulingService {
}; };
} }
private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) { /**
const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } }); * A wagon in a blocked physical state can never be planned or pinned.
const physicalIds = slots * ASSIGNED no longer blocks: it only means the wagon is coupled to a built
.map((slot) => slot.physicalWagonId) * train or stamped by a live run — schedule-level occupancy is tracked on
.filter((id): id is string => Boolean(id)); * the schedule's own TrainSetWagon slots, never on the Wagon entity.
if (!physicalIds.length) return; */
const wagons = await manager.getRepository(Wagon).find({ where: { id: In(physicalIds) } }); private isWagonPhysicallyUsable(wagon: Wagon): boolean {
for (const wagon of wagons) { return (
await manager.getRepository(Wagon).update(wagon.id, { wagon.status === WagonStatus.Available || wagon.status === WagonStatus.Assigned
// 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, * 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( private async autoPinWagonsForSchedule(
@@ -3839,6 +3943,10 @@ export class TrainSchedulingService {
const wagons = await manager.getRepository(Wagon).find(); const wagons = await manager.getRepository(Wagon).find();
const wagonTypes = await manager.getRepository(WagonType).find(); const wagonTypes = await manager.getRepository(WagonType).find();
const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId, manager); 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 typeCodeById = new Map(wagonTypes.map((wt) => [wt.id, wt.code]));
const planSlots = [...slots] const planSlots = [...slots]
@@ -3857,6 +3965,7 @@ export class TrainSchedulingService {
scheduleId, scheduleId,
originYardId, originYardId,
builtTrainId, builtTrainId,
pinnedToScheduleIds,
); );
if (unpinnable.length) { if (unpinnable.length) {
throw new BadRequestException({ throw new BadRequestException({
@@ -3874,18 +3983,17 @@ export class TrainSchedulingService {
originYardId, originYardId,
assignedPhysicalIds, assignedPhysicalIds,
builtTrainId, builtTrainId,
pinnedToScheduleIds,
); );
if (!physical) continue; 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!, { await manager.getRepository(TrainSetWagon).update(slot.trainSetWagonId!, {
physicalWagonId: physical.id, physicalWagonId: physical.id,
status: 'RESERVED', status: 'RESERVED',
}); });
await manager.getRepository(Wagon).update(physical.id, {
trainSetWagonId: slot.trainSetWagonId,
currentTrainScheduleId: scheduleId,
status: WagonStatus.Assigned,
});
assignedPhysicalIds.add(physical.id); assignedPhysicalIds.add(physical.id);
} }
} }
@@ -3898,9 +4006,10 @@ export class TrainSchedulingService {
): Promise<string[]> { ): Promise<string[]> {
if (!wagonPlan.length) return []; if (!wagonPlan.length) return [];
const [wagons, builtTrainId] = await Promise.all([ const [wagons, builtTrainId, pinnedToScheduleIds] = await Promise.all([
this.dataSource.getRepository(Wagon).find(), this.dataSource.getRepository(Wagon).find(),
this.builtTrainIdOfSchedule(targetScheduleId), this.builtTrainIdOfSchedule(targetScheduleId),
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
]); ]);
return this.findUnpinnableWagonSlots( return this.findUnpinnableWagonSlots(
wagonPlan.map((slot) => ({ wagonPlan.map((slot) => ({
@@ -3913,6 +4022,7 @@ export class TrainSchedulingService {
targetScheduleId, targetScheduleId,
originYardId, originYardId,
builtTrainId, builtTrainId,
pinnedToScheduleIds,
); );
} }
@@ -3927,6 +4037,7 @@ export class TrainSchedulingService {
scheduleId: string | undefined, scheduleId: string | undefined,
originYardId: string, originYardId: string,
builtTrainId: string | null = null, builtTrainId: string | null = null,
pinnedToScheduleIds: Set<string> = new Set(),
): string[] { ): string[] {
const violations: string[] = []; const violations: string[] = [];
const assignedPhysicalIds = new Set<string>(); const assignedPhysicalIds = new Set<string>();
@@ -3939,6 +4050,7 @@ export class TrainSchedulingService {
originYardId, originYardId,
assignedPhysicalIds, assignedPhysicalIds,
builtTrainId, builtTrainId,
pinnedToScheduleIds,
); );
if (!physical) { if (!physical) {
violations.push( violations.push(
@@ -3964,14 +4076,22 @@ export class TrainSchedulingService {
originYardId: string, originYardId: string,
assignedPhysicalIds: Set<string>, assignedPhysicalIds: Set<string>,
builtTrainId: string | null = null, builtTrainId: string | null = null,
pinnedToScheduleIds: Set<string> = new Set(),
): Wagon | undefined { ): Wagon | undefined {
const usable = (wagon: Wagon): boolean => { const usable = (wagon: Wagon): boolean => {
if (wagon.wagonTypeId !== slot.wagonTypeId) return false; if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
if (assignedPhysicalIds.has(wagon.id)) return false; if (assignedPhysicalIds.has(wagon.id)) return false;
const pinnedOnSchedule = scheduleId // Loose pool never lends a wagon coupled to a built train's consist.
? wagon.currentTrainScheduleId === scheduleId if (wagon.trainId) return false;
: false; // Out on a dispatched train right now — physically gone.
return wagon.status === WagonStatus.Available || pinnedOnSchedule; 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 — // 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 // wherever they currently sit (they travel with the train), never a loose
@@ -4746,6 +4866,7 @@ export class TrainSchedulingService {
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string), .map((slot) => slot.physicalWagonId as string),
); );
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules();
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0)); const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
@@ -4802,8 +4923,8 @@ export class TrainSchedulingService {
wagons: wagons.map((wagon) => ({ wagons: wagons.map((wagon) => ({
...mapWagon(wagon), ...mapWagon(wagon),
loaded: loadedWagonIds.has(wagon.id), loaded: loadedWagonIds.has(wagon.id),
// Free = not pinned to any run; only free wagons can be trimmed. // Free = not pinned to any live run's slot; only free wagons can be trimmed.
removable: wagon.currentTrainScheduleId == null && !loadedWagonIds.has(wagon.id), removable: !pinnedToLiveIds.has(wagon.id) && !loadedWagonIds.has(wagon.id),
})), })),
addableWagons: addableWagons.map(mapWagon), addableWagons: addableWagons.map(mapWagon),
adjustments: adjustments.map((log) => ({ adjustments: adjustments.map((log) => ({
@@ -4882,13 +5003,14 @@ export class TrainSchedulingService {
const consistById = new Map(consist.map((w) => [w.id, w])); const consistById = new Map(consist.map((w) => [w.id, w]));
// --- validate removals: must be coupled and free (no cargo, no pin) --- // --- validate removals: must be coupled and free (no cargo, no pin) ---
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(manager);
const removed: Wagon[] = []; const removed: Wagon[] = [];
for (const wagonId of removeWagonIds) { for (const wagonId of removeWagonIds) {
const wagon = consistById.get(wagonId); const wagon = consistById.get(wagonId);
if (!wagon) { if (!wagon) {
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`); 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( throw new ConflictException(
`Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`, `Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`,
); );
@@ -5369,10 +5491,10 @@ export class TrainSchedulingService {
/** /**
* Cargo-aware day pool: the EAT days a customer may pick for this cargo. A day * 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 * is selectable when ≥1 OPEN schedule on the route that day still has remaining
* train capacity (not fully allocated). Wagon availability is deliberately NOT * train capacity (not fully allocated) AND its wagon stock can physically carry
* checked here: whether a matching wagon currently sits in the right yard is an * the selected cargo/container type (wagon-TYPE gate). Quantity is deliberately
* operational question staff resolve when they approve or reject the booking, * NOT gated — a booking bigger than the free capacity is accepted and the batch
* not something the customer can act on while choosing a date. Same * engine offers a partial split later. No counts are exposed: same
* `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY, * `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY,
* not a train. * not a train.
*/ */
@@ -5380,9 +5502,11 @@ export class TrainSchedulingService {
originYardId?: string; originYardId?: string;
destinationYardId?: string; destinationYardId?: string;
freightType: 'CONTAINER' | 'BULK'; freightType: 'CONTAINER' | 'BULK';
cargoTypeId?: string | null;
cargoTypeCode?: string | null; cargoTypeCode?: string | null;
totalWeightTons?: number; totalWeightTons?: number;
containers?: Array<{ containerSize: string; quantity: number }>; containers?: Array<{ containerSize: string; quantity: number }>;
containerTypeIds?: string[];
}): Promise<{ days: string[] }> { }): Promise<{ days: string[] }> {
const schedules = await this.getBookableScheduleEntities( const schedules = await this.getBookableScheduleEntities(
input.originYardId, input.originYardId,
@@ -5390,17 +5514,233 @@ export class TrainSchedulingService {
); );
if (schedules.length === 0) return { days: [] }; 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>(); const days = new Set<string>();
for (const s of schedules) { for (const s of compatible) {
const hasCapacity =
Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0;
if (!hasCapacity) continue;
if (s.scheduledDepartureDate) if (s.scheduledDepartureDate)
days.add(eatDay(new Date(s.scheduledDepartureDate))); days.add(eatDay(new Date(s.scheduledDepartureDate)));
} }
return { days: [...days].sort() }; 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, * Ordered stop yards of a schedule's route: origin → milestones → destination,
* de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule * de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule
@@ -5553,6 +5893,7 @@ export class TrainSchedulingService {
status: schedule.status, status: schedule.status,
freightType: this.resolveScheduleFreightType(schedule), freightType: this.resolveScheduleFreightType(schedule),
trainNumber: schedule.trainNumber ?? null, trainNumber: schedule.trainNumber ?? null,
maxWagons: schedule.maxWagons ?? null,
direction: schedule.direction ?? null, direction: schedule.direction ?? null,
requiresLoadingConfirmation, requiresLoadingConfirmation,
loadingConfirmed, loadingConfirmed,

View File

@@ -399,7 +399,7 @@ export class TrainBuilderService {
if (!wagon || wagon.trainId !== train.id) { if (!wagon || wagon.trainId !== train.id) {
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
} }
if (wagon.currentTrainScheduleId) { if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
throw new ConflictException( throw new ConflictException(
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
); );
@@ -426,7 +426,7 @@ export class TrainBuilderService {
if (!wagon || wagon.trainId !== train.id) { if (!wagon || wagon.trainId !== train.id) {
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
} }
if (wagon.currentTrainScheduleId) { if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
throw new ConflictException( throw new ConflictException(
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
); );
@@ -441,6 +441,29 @@ export class TrainBuilderService {
return this.getComposition(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. */ /** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */
async reorderWagons(id: string, dto: ReorderTrainWagonsDto) { async reorderWagons(id: string, dto: ReorderTrainWagonsDto) {
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {

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 { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; import {
IsInt,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
} from 'class-validator';
/** /**
* A count-only wagon-transfer request. The requester picks source yard, wagon * 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 * 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 { export class CreateTransferRequestDto {
@IsUUID() @IsUUID()
@@ -21,6 +31,12 @@ export class CreateTransferRequestDto {
@Max(1000) @Max(1000)
quantity!: number; 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' }) @ApiPropertyOptional({ description: 'Optional note for the fulfilling staff' })
@IsOptional() @IsOptional()
@IsString() @IsString()

View File

@@ -59,4 +59,11 @@ export class WagonTransferRequest extends BaseEntity {
@Column({ name: 'note', type: 'text', nullable: true }) @Column({ name: 'note', type: 'text', nullable: true })
note?: string | null; 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.ImportReady,
WagonStatus.ExportReady, WagonStatus.ExportReady,
WagonStatus.Maintenance, WagonStatus.Maintenance,
WagonStatus.Retired, WagonStatus.Detained,
] as const; ] as const;
export type WagonStatusType = (typeof WAGON_STATUSES)[number]; export type WagonStatusType = (typeof WAGON_STATUSES)[number];

View File

@@ -19,6 +19,7 @@ import {
WagonTransferHistoryAll, WagonTransferHistoryAll,
WagonTransferRequest, WagonTransferRequest,
} from '../../common/booking-guards'; } from '../../common/booking-guards';
import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto';
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto'; import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto'; import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
import { WagonTransferRequestsService } from './wagon-transfer-requests.service'; import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
@@ -51,6 +52,22 @@ export class WagonTransferRequestsController {
return this.service.listRequests(status); 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 // NOTE: the two `history` routes MUST stay above `@Get(':id')` — Express
// matches in declaration order, so `/history` would otherwise be captured by // matches in declaration order, so `/history` would otherwise be captured by
// the `:id` param route (and rejected by ParseUUIDPipe). // 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 { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
@@ -48,7 +48,12 @@ export class WagonTransferRequestsService {
private readonly wagonsService: WagonsService, 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( async createRequest(
dto: CreateTransferRequestDto, dto: CreateTransferRequestDto,
userId?: string | null, userId?: string | null,
@@ -58,6 +63,14 @@ export class WagonTransferRequestsService {
'Source and destination yard must be different', '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({ const request = this.requestRepo.create({
fromYardId: dto.fromYardId, fromYardId: dto.fromYardId,
toYardId: dto.toYardId, toYardId: dto.toYardId,
@@ -65,12 +78,24 @@ export class WagonTransferRequestsService {
quantity: dto.quantity, quantity: dto.quantity,
status: WagonTransferRequestStatus.Pending, status: WagonTransferRequestStatus.Pending,
requestedByUserId: userId ?? null, requestedByUserId: userId ?? null,
reason: dto.reason,
note: dto.note ?? null, note: dto.note ?? null,
}); });
const saved = await this.requestRepo.save(request); const saved = await this.requestRepo.save(request);
return this.findById(saved.id); 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). */ /** Requests, newest first, optionally filtered by status (OCC queue = PENDING). */
async listRequests( async listRequests(
status?: WagonTransferRequestStatus, status?: WagonTransferRequestStatus,
@@ -136,6 +161,14 @@ export class WagonTransferRequestsService {
.join(', ')}`, .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, // Reuse the audited bulk-transfer path (writes wagon_movements ledger rows,
// each stamped with this request's id so history can link them back). // each stamped with this request's id so history can link them back).
@@ -152,6 +185,71 @@ export class WagonTransferRequestsService {
return this.findById(id); 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 * Per-user transfer history: the requests a user filed OR fulfilled, plus the
* individual wagons they physically moved (linked back to their request when * 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" `SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId"
FROM freight.wagons FROM freight.wagons
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
AND UPPER(status) NOT IN ('RETIRED', 'MAINTENANCE') AND UPPER(status) NOT IN ('DETAINED', 'MAINTENANCE')
ORDER BY wagon_number ASC`, ORDER BY wagon_number ASC`,
); );
} }

View File

@@ -330,6 +330,80 @@ export class WarehouseFeeService {
return best; 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' { private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' {
return currency === 'ETB' ? '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 { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Request, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express'; 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 { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BulkReceiveDto } from './dto/bulk-receive.dto'; import { BulkReceiveDto } from './dto/bulk-receive.dto';
@@ -73,6 +76,35 @@ export class WarehouseInventoryController {
return this.inventoryService.zoneOccupancy(yardId); 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') @Post('auto-unload-arrived')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload) @BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' }) @ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
@@ -98,7 +130,8 @@ export class WarehouseInventoryController {
@Post('receive-bulk') @Post('receive-bulk')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive) @BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' }) @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); return this.inventoryService.bulkReceive(dto);
} }
@@ -144,15 +177,16 @@ export class WarehouseInventoryController {
loadItemsOntoTrain( loadItemsOntoTrain(
@Param('scheduleId', ParseUUIDPipe) scheduleId: string, @Param('scheduleId', ParseUUIDPipe) scheduleId: string,
@Body() dto: { inventoryIds: string[]; performedBy?: 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') @Post('bulk-dispatch-export')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch) @BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' }) @ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) { bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }, @CurrentUser() user: TCurrentUser) {
return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], dto.performedBy); return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], actorLabel(user) ?? dto.performedBy);
} }
@Post('bulk-mark-inspected') @Post('bulk-mark-inspected')
@@ -175,8 +209,12 @@ export class WarehouseInventoryController {
@Post(':id/gate-clearance') @Post(':id/gate-clearance')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.gatePass) @BookingStaff(FREIGHT_PERMS.warehouseInventory.gatePass)
@ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' }) @ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' })
gateClearance(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { gateClearance(
return this.inventoryService.gateClearance(id, performedBy); @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') @Get('import/arrive-queue')
@@ -201,10 +239,10 @@ export class WarehouseInventoryController {
warehouseId?: string; warehouseId?: string;
performedBy?: string; performedBy?: string;
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[]; assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
}) { }, @CurrentUser() user: TCurrentUser) {
return this.inventoryService.autoUnloadArrivedBookings( return this.inventoryService.autoUnloadArrivedBookings(
dto.scheduleId, dto.scheduleId,
dto.performedBy, actorLabel(user) ?? dto.performedBy,
dto.warehouseId, dto.warehouseId,
dto.assignments, dto.assignments,
); );
@@ -246,8 +284,8 @@ export class WarehouseInventoryController {
@Post('export/auto-unload-at-djibouti') @Post('export/auto-unload-at-djibouti')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload) @BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' }) @ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' })
autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }) { autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }, @CurrentUser() user: TCurrentUser) {
return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, dto.performedBy); return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, actorLabel(user) ?? dto.performedBy);
} }
@Get('import/pickup-ready-queue') @Get('import/pickup-ready-queue')
@@ -274,14 +312,16 @@ export class WarehouseInventoryController {
@Post('receive') @Post('receive')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive) @BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({ summary: 'Receive inventory at a warehouse location' }) @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); return this.inventoryService.receive(dto);
} }
@Post('reserve') @Post('reserve')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move) @BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' }) @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); return this.inventoryService.reserve(dto);
} }
@@ -316,15 +356,19 @@ export class WarehouseInventoryController {
@Post(':id/store') @Post(':id/store')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move) @BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' }) @ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) { store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto, @CurrentUser() user: TCurrentUser) {
return this.inventoryService.store(id, dto.performedBy, dto); return this.inventoryService.store(id, actorLabel(user) ?? dto.performedBy, dto);
} }
@Post(':id/ready-for-loading') @Post(':id/ready-for-loading')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move) @BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' }) @ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' })
readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { readyForLoading(
return this.inventoryService.readyForLoading(id, performedBy); @Param('id', ParseUUIDPipe) id: string,
@Body('performedBy') performedBy: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.inventoryService.readyForLoading(id, actorLabel(user) ?? performedBy);
} }
@Post(':id/load') @Post(':id/load')
@@ -337,8 +381,12 @@ export class WarehouseInventoryController {
@Post(':id/ready-for-pickup') @Post(':id/ready-for-pickup')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move) @BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' }) @ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' })
readyForPickup(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { readyForPickup(
return this.inventoryService.readyForPickup(id, performedBy); @Param('id', ParseUUIDPipe) id: string,
@Body('performedBy') performedBy: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.inventoryService.readyForPickup(id, actorLabel(user) ?? performedBy);
} }
@Post(':id/release') @Post(':id/release')
@@ -400,10 +448,11 @@ export class WarehouseInventoryController {
@Param('bookingId', ParseUUIDPipe) bookingId: string, @Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: ApproveDeliveryDto, @Body() dto: ApproveDeliveryDto,
@Request() req: { user?: { id?: string; sub?: string } }, @Request() req: { user?: { id?: string; sub?: string } },
@CurrentUser() user: TCurrentUser,
) { ) {
return this.inventoryService.approveDeliveryForBooking( return this.inventoryService.approveDeliveryForBooking(
bookingId, bookingId,
req.user?.id ?? req.user?.sub, user?.id ?? req.user?.id ?? req.user?.sub,
dto.signerName, dto.signerName,
); );
} }
@@ -465,14 +514,19 @@ export class WarehouseInventoryController {
@Post(':id/deliver') @Post(':id/deliver')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver) @BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver)
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) @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); return this.inventoryService.deliver(id, dto);
} }
@Patch(':id/dispatch') @Patch(':id/dispatch')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch) @BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
@ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' }) @ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' })
dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { dispatch(
return this.inventoryService.dispatch(id, performedBy); @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<{ async opsStats(): Promise<{
receivedToday: number; receivedToday: number;
receivedYesterday: number;
pendingInspection: number; pendingInspection: number;
trucksOnSite: number; trucksOnSite: number;
itemsAging: number; itemsAging: number;
}> { }> {
const [row]: Array<{ const [row]: Array<{
receivedToday: number; receivedToday: number;
receivedYesterday: number;
pendingInspection: number; pendingInspection: number;
trucksOnSite: number; trucksOnSite: number;
itemsAging: number; itemsAging: number;
@@ -419,6 +421,8 @@ export class WarehouseInventoryService {
`SELECT `SELECT
(SELECT count(*)::int FROM freight.warehouse_inventory (SELECT count(*)::int FROM freight.warehouse_inventory
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE) AS "receivedToday", 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 (SELECT count(*)::int FROM freight.warehouse_inventory
WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection", WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection",
(SELECT count(*)::int FROM freight.customer_truck_assignments (SELECT count(*)::int FROM freight.customer_truck_assignments
@@ -430,12 +434,208 @@ export class WarehouseInventoryService {
); );
return { return {
receivedToday: row?.receivedToday ?? 0, receivedToday: row?.receivedToday ?? 0,
receivedYesterday: row?.receivedYesterday ?? 0,
pendingInspection: row?.pendingInspection ?? 0, pendingInspection: row?.pendingInspection ?? 0,
trucksOnSite: row?.trucksOnSite ?? 0, trucksOnSite: row?.trucksOnSite ?? 0,
itemsAging: row?.itemsAging ?? 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 * Live occupancy per zone: rated capacity vs the weight/items currently held
* (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard * (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 { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express'; 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 { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto'; import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
@@ -17,7 +20,8 @@ export class WarehouseInvoiceController {
@Post('warehouse-inventory/:id/generate-fee-invoice') @Post('warehouse-inventory/:id/generate-fee-invoice')
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate) @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate)
@ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' }) @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); return this.invoiceService.generateForInventory(id, dto);
} }

View File

@@ -96,6 +96,13 @@ export class WarehouseRulesController {
return this.feeService.accrualDashboard(billingCurrency); 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') @Post('warehouse-fees/accrual/:inventoryId/acknowledge')
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update) @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
@ApiOperation({ summary: 'Acknowledge / snooze an item fee-accrual alert' }) @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 { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import { Button, Modal, Stack, Text, Textarea } from "@mantine/core";
Anchor,
Button,
Modal,
Select,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { import {
Check, Check,
FilePen,
FileSignature, FileSignature,
MessageSquareWarning, MessageSquareWarning,
RefreshCw,
ShieldCheck, ShieldCheck,
Sparkles, Sparkles,
XCircle, XCircle,
@@ -23,6 +17,7 @@ import type { Freight } from "@edr/types";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { ContractDocumentEditorModal } from "@/components/contracts/ContractDocumentEditorModal";
import type { useContractMutations } from "@/hooks/contracts/useContracts"; import type { useContractMutations } from "@/hooks/contracts/useContracts";
/** Dropdown-settings code holding the admin-configured contract validity days. */ /** Dropdown-settings code holding the admin-configured contract validity days. */
@@ -54,8 +49,8 @@ export function ContractActionsToolbar({
const navigate = useNavigate(); const navigate = useNavigate();
const { status } = contract; const { status } = contract;
const [acceptOpen, setAcceptOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
const [validityDays, setValidityDays] = useState<string | null>(null); const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
const [changesOpen, setChangesOpen] = useState(false); const [changesOpen, setChangesOpen] = useState(false);
const [changesNote, setChangesNote] = useState(""); const [changesNote, setChangesNote] = useState("");
const [rejectOpen, setRejectOpen] = useState(false); const [rejectOpen, setRejectOpen] = useState(false);
@@ -76,12 +71,6 @@ export function ContractActionsToolbar({
.map((o) => ({ value: String(o.value), label: o.label })), .map((o) => ({ value: String(o.value), label: o.label })),
[validitySetting], [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)) { if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) {
return null; return null;
@@ -98,10 +87,16 @@ export function ContractActionsToolbar({
} }
const canAccept = status === "SUBMITTED"; const canAccept = status === "SUBMITTED";
// Generation only becomes available once EVERY approval step is complete and // While the contract is PENDING_APPROVAL and NO approver has acted yet, staff
// the contract reaches APPROVED. While any step is still pending the contract // can edit this contract's articles and (re)generate its PDF. The first
// stays in PENDING_APPROVAL, so this button does not appear after only the // approval action locks the document.
// first (line-staff) approval — the director step must land first. 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 = const needsManualGenerate =
status === "APPROVED" && !contract.contractGeneratedAt; status === "APPROVED" && !contract.contractGeneratedAt;
// Signing now happens on the contract VIEW page (staff must open and read the // Signing now happens on the contract VIEW page (staff must open and read the
@@ -131,7 +126,10 @@ export function ContractActionsToolbar({
fullWidth fullWidth
color="edr-green" color="edr-green"
leftSection={<Check size={16} />} leftSection={<Check size={16} />}
onClick={() => setAcceptOpen(true)} onClick={() => {
setEditorMode("accept");
setEditorOpen(true);
}}
> >
Accept for approval Accept for approval
</Button> </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 && ( {needsManualGenerate && (
<Button <Button
fullWidth fullWidth
@@ -198,6 +233,7 @@ export function ContractActionsToolbar({
the customer creates the booking in the portal. */} the customer creates the booking in the portal. */}
{!canAccept && {!canAccept &&
!canEditGenerate &&
!needsManualGenerate && !needsManualGenerate &&
!canViewContract && !canViewContract &&
!canReviewClearance && ( !canReviewClearance && (
@@ -208,62 +244,28 @@ export function ContractActionsToolbar({
)} )}
</Stack> </Stack>
{/* Accept — sets the contract validity window */} {/* Accept / edit — review + optionally edit this contract's articles */}
<Modal <ContractDocumentEditorModal
opened={acceptOpen} opened={editorOpen}
onClose={() => setAcceptOpen(false)} onClose={() => setEditorOpen(false)}
title="Accept contract for approval" contractId={contract.id}
centered mode={editorMode}
> validityOptions={validityOptions}
<Stack gap="md"> validityLoading={validityLoading}
<Text size="sm" c="dimmed"> accepting={mutations.staffAccept.isPending}
Pick the contract validity window, then start the approval chain. saving={mutations.updateDocument.isPending}
</Text> onAccept={(days, snapshot) =>
{validityOptions.length > 0 ? ( mutations.staffAccept.mutate(
<Select { validityDays: days, documentSnapshot: snapshot },
label="Validity" { onSuccess: () => setEditorOpen(false) },
placeholder="Select a validity period" )
data={validityOptions} }
value={validityDays} onSaveEdit={(snapshot) =>
onChange={setValidityDays} mutations.updateDocument.mutate(snapshot, {
allowDeselect={false} onSuccess: () => setEditorOpen(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>
{/* Request changes */} {/* Request changes */}
<Modal <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

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

View File

@@ -17,6 +17,11 @@ export interface KpiItem {
* into semantic tints. * into semantic tints.
*/ */
color?: string; 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 { export interface KpiStripProps {
@@ -67,16 +72,30 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
{loading ? ( {loading ? (
<Skeleton height={26} width={72} radius="sm" my={2} /> <Skeleton height={26} width={72} radius="sm" my={2} />
) : ( ) : (
<Text <div className="flex items-baseline gap-2">
fw={800} <Text
fz={24} fw={800}
lh={1.05} fz={24}
c="edr-text" lh={1.05}
style={{ letterSpacing: "-0.02em" }} c="edr-text"
truncate style={{ letterSpacing: "-0.02em" }}
> truncate
{item.value} >
</Text> {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> <Text size="xs" fw={600} c="edr-muted" truncate>
{item.label} {item.label}

View File

@@ -168,6 +168,11 @@ function HistoryPanel({ opened }: { opened: boolean }) {
</Badge> </Badge>
</Group> </Group>
</Group> </Group>
{r.reason ? (
<Text size="xs" c="dimmed" mt={4}>
Reason: {r.reason}
</Text>
) : null}
</Card> </Card>
))} ))}
</Stack> </Stack>
@@ -233,6 +238,8 @@ const WagonTransferRequestsModal = ({
const [tab, setTab] = useState<string | null>("queue"); const [tab, setTab] = useState<string | null>("queue");
const [active, setActive] = useState<WagonTransferRequest | null>(null); const [active, setActive] = useState<WagonTransferRequest | null>(null);
const [picked, setPicked] = useState<Set<string>>(new Set()); 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({ const { data: requests = [], isLoading } = useQuery({
...api.wagonTransferRequests.list.queryOptions({ input: { status: PENDING } }), ...api.wagonTransferRequests.list.queryOptions({ input: { status: PENDING } }),
@@ -256,6 +263,9 @@ const WagonTransferRequestsModal = ({
}); });
const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions()); const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
const bulkFulfill = useMutation(
api.wagonTransferRequests.bulkFulfill.mutationOptions(),
);
const cancel = useMutation(api.wagonTransferRequests.cancel.mutationOptions()); const cancel = useMutation(api.wagonTransferRequests.cancel.mutationOptions());
const showError = (err: unknown, fallback: string) => { 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( const sortedWagons = useMemo(
() => [...wagons].sort((a, b) => a.wagonNumber.localeCompare(b.wagonNumber)), () => [...wagons].sort((a, b) => a.wagonNumber.localeCompare(b.wagonNumber)),
[wagons], [wagons],
@@ -371,17 +411,65 @@ const WagonTransferRequestsModal = ({
</Card> </Card>
) : ( ) : (
<Stack gap="sm"> <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) => ( {requests.map((r) => (
<Card key={r.id} withBorder radius="md" padding="md"> <Card key={r.id} withBorder radius="md" padding="md">
<Group justify="space-between" wrap="nowrap" align="flex-start"> <Group justify="space-between" wrap="nowrap" align="flex-start">
<Stack gap={6} style={{ minWidth: 0 }}> <Group gap="sm" wrap="nowrap" align="flex-start" style={{ minWidth: 0 }}>
<RequestSummary r={r} /> <Checkbox
{r.note ? ( checked={selected.has(r.id)}
<Text size="xs" c="dimmed"> onChange={() => toggleSelected(r.id)}
{r.note} color="edr-green"
</Text> mt={2}
) : null} />
</Stack> <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"> <Group gap={8} wrap="nowrap">
<Button <Button
size="compact-sm" size="compact-sm"

View File

@@ -15,6 +15,7 @@ import {
Slider, Slider,
Stack, Stack,
Text, Text,
Textarea,
ThemeIcon, ThemeIcon,
} from "@mantine/core"; } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
@@ -125,6 +126,7 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
const [transferYardId, setTransferYardId] = useState<string | null>(null); const [transferYardId, setTransferYardId] = useState<string | null>(null);
const [transferQty, setTransferQty] = useState(0); const [transferQty, setTransferQty] = useState(0);
const [transferReason, setTransferReason] = useState("");
const [toAssignedQty, setToAssignedQty] = useState(0); const [toAssignedQty, setToAssignedQty] = useState(0);
const [toAvailableQty, setToAvailableQty] = useState(0); const [toAvailableQty, setToAvailableQty] = useState(0);
@@ -207,6 +209,7 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
useEffect(() => { useEffect(() => {
setTransferYardId(null); setTransferYardId(null);
setTransferQty(0); setTransferQty(0);
setTransferReason("");
setToAssignedQty(0); setToAssignedQty(0);
setToAvailableQty(0); setToAvailableQty(0);
}, [yardId, typeId]); }, [yardId, typeId]);
@@ -219,8 +222,9 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
} }
}, [opened]); }, [opened]);
// Keep quantities within bounds as counts shift after each action. // Keep quantities within bounds as counts shift after each action. Transfers
useEffect(() => setTransferQty((q) => Math.min(q, total)), [total]); // 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(() => setToAssignedQty((q) => Math.min(q, availableCount)), [availableCount]);
useEffect(() => setToAvailableQty((q) => Math.min(q, assignedCount)), [assignedCount]); 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 // Request-only: the requester specifies count + destination; OCC later picks
// the physical wagons and executes the move. No wagons are moved here. // the physical wagons and executes the move. No wagons are moved here.
const handleRequest = async () => { const handleRequest = async () => {
if (!yardId || !typeId || !transferYardId || transferQty < 1) return; if (
!yardId ||
!typeId ||
!transferYardId ||
transferQty < 1 ||
!transferReason.trim()
)
return;
try { try {
await createRequest.mutateAsync({ await createRequest.mutateAsync({
fromYardId: yardId, fromYardId: yardId,
toYardId: transferYardId, toYardId: transferYardId,
wagonTypeId: typeId, wagonTypeId: typeId,
quantity: transferQty, quantity: transferQty,
reason: transferReason.trim(),
}); });
toast({ toast({
title: `Requested ${transferQty} ${typeInfo.code(typeId)} wagon(s) · ${yardName( title: `Requested ${transferQty} ${typeInfo.code(typeId)} wagon(s) · ${yardName(
@@ -249,6 +261,7 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
}); });
setTransferQty(0); setTransferQty(0);
setTransferYardId(null); setTransferYardId(null);
setTransferReason("");
} catch (err) { } catch (err) {
showError(err, "Request failed"); showError(err, "Request failed");
} }
@@ -407,10 +420,19 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
</Text> </Text>
<Stack gap="md"> <Stack gap="md">
<div> <div>
<Text size="sm" fw={500} mb={4}> <Group justify="space-between" mb={4}>
How many wagons <Text size="sm" fw={500}>
</Text> How many wagons
<QuantityField value={transferQty} onChange={setTransferQty} max={total} /> </Text>
<Badge color="teal" variant="light">
{availableCount} available
</Badge>
</Group>
<QuantityField
value={transferQty}
onChange={setTransferQty}
max={availableCount}
/>
</div> </div>
<Select <Select
label="Destination yard" label="Destination yard"
@@ -421,6 +443,16 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
searchable searchable
radius="md" 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 ? ( {transferYardId && transferQty > 0 ? (
<Card bg="var(--mantine-color-gray-0)" radius="md" padding="sm" withBorder> <Card bg="var(--mantine-color-gray-0)" radius="md" padding="sm" withBorder>
<Group gap={8} wrap="nowrap"> <Group gap={8} wrap="nowrap">
@@ -446,7 +478,12 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
leftSection={<ArrowRightLeft size={16} />} leftSection={<ArrowRightLeft size={16} />}
onClick={handleRequest} onClick={handleRequest}
loading={createRequest.isPending} loading={createRequest.isPending}
disabled={busy || !transferYardId || transferQty < 1} disabled={
busy ||
!transferYardId ||
transferQty < 1 ||
!transferReason.trim()
}
color="edr-green" color="edr-green"
> >
Request {transferQty > 0 ? `${transferQty} ` : ""}wagon 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 { useState } from 'react';
import { Card, Group, SegmentedControl, SimpleGrid, Text, ThemeIcon } from '@mantine/core'; import { Card, Group, SegmentedControl, Stack, Text, ThemeIcon } from '@mantine/core';
import { BarChart3, CalendarRange, PieChart as PieChartIcon } from 'lucide-react'; import { BarChart3, CalendarRange } from 'lucide-react';
import { import {
Bar, Bar,
BarChart, BarChart,
CartesianGrid, CartesianGrid,
Cell, Cell,
Legend, Legend,
Pie,
PieChart,
ResponsiveContainer, ResponsiveContainer,
Tooltip, Tooltip,
XAxis, XAxis,
YAxis, YAxis,
} from 'recharts'; } from 'recharts';
import { useQuery } from '@tanstack/react-query'; import { useWarehouseThroughput } from '@/hooks/useWarehouses';
import type { WarehouseDashboard } from '@/types/warehouse';
import { api } from '@/services/api';
import type { WarehouseDashboard, WarehouseInventoryItem } from '@/types/warehouse';
interface WarehouseDashboardChartsProps { interface WarehouseDashboardChartsProps {
data?: WarehouseDashboard; data?: WarehouseDashboard;
@@ -38,11 +34,25 @@ const STATUS_SERIES = [
type Granularity = 'week' | 'month' | 'year'; 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) { export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps) {
const [granularity, setGranularity] = useState<Granularity>('month'); const [granularity, setGranularity] = useState<Granularity>('month');
const { data: inventory } = useQuery( // Server-side time series (replaces downloading the whole inventory to bucket).
api.warehouses.listInventory.queryOptions({ input: {} }), 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) => ({ const statusData = STATUS_SERIES.map((s) => ({
name: s.label, name: s.label,
@@ -51,16 +61,10 @@ export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps
})); }));
const hasStatus = statusData.some((d) => d.value > 0); 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 ( return (
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md"> <Stack gap="md">
{/* Time-filtered throughput */} {/* 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 justify="space-between" mb="md" wrap="wrap">
<Group gap="sm"> <Group gap="sm">
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: ORANGE, color: '#fff' }}> <ThemeIcon size="lg" radius="md" style={{ backgroundColor: ORANGE, color: '#fff' }}>
@@ -133,103 +137,10 @@ export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps
<EmptyChart /> <EmptyChart />
)} )}
</Card> </Card>
</Stack>
{/* 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>
); );
} }
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() { function EmptyChart() {
return ( return (
<Group justify="center" align="center" h={280}> <Group justify="center" align="center" h={280}>

View File

@@ -19,6 +19,10 @@ export function WarehouseOpsKpiStrip() {
value: data?.receivedToday ?? 0, value: data?.receivedToday ?? 0,
icon: PackageCheck, icon: PackageCheck,
color: "edr-green", 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", label: "Pending inspection",

View File

@@ -32,3 +32,6 @@ export { FeePreviewModal } from './FeePreviewModal';
export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap'; export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap';
export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip'; export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip';
export { AccrualDashboard } from './AccrualDashboard'; 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)}`; 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) => { export const formatDate = (value: string | null | undefined) => {
if (!value) return '—'; if (!value) return '—';
const date = new Date(value); const date = new Date(value);

View File

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

View File

@@ -176,6 +176,9 @@ export const URL_CONSTANTS = {
CONTRACT_VIEW: (id: string) => `/contracts/${id}/contract/view`, CONTRACT_VIEW: (id: string) => `/contracts/${id}/contract/view`,
CONTRACT_DOCUMENT: (id: string) => `/contracts/${id}/contract/document`, CONTRACT_DOCUMENT: (id: string) => `/contracts/${id}/contract/document`,
CONTRACT_SIGN: (id: string) => `/contracts/${id}/contract/sign`, 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_QUEUE: "/contracts/clearance/queue",
CLEARANCE: (id: string) => `/contracts/${id}/clearance`, CLEARANCE: (id: string) => `/contracts/${id}/clearance`,
CLEARANCE_REVIEW: (id: string) => `/contracts/${id}/clearance/review`, CLEARANCE_REVIEW: (id: string) => `/contracts/${id}/clearance/review`,
@@ -487,6 +490,11 @@ export const URL_CONSTANTS = {
RESERVE: "/warehouse-inventory/reserve", RESERVE: "/warehouse-inventory/reserve",
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue", ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
OPS_STATS: "/warehouse-inventory/ops-stats", 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) => ZONE_OCCUPANCY: (yardId?: string) =>
yardId yardId
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}` ? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`
@@ -559,6 +567,7 @@ export const URL_CONSTANTS = {
FEE_PREVIEW: (inventoryId: string) => FEE_PREVIEW: (inventoryId: string) =>
`/warehouse-inventory/${inventoryId}/fee-preview`, `/warehouse-inventory/${inventoryId}/fee-preview`,
ACCRUAL_DASHBOARD: "/warehouse-fees/accrual-dashboard", ACCRUAL_DASHBOARD: "/warehouse-fees/accrual-dashboard",
ON_TIME_DISPATCH: "/warehouse-fees/on-time-dispatch",
ACCRUAL_ACK: (inventoryId: string) => ACCRUAL_ACK: (inventoryId: string) =>
`/warehouse-fees/accrual/${inventoryId}/acknowledge`, `/warehouse-fees/accrual/${inventoryId}/acknowledge`,
}, },

View File

@@ -125,12 +125,27 @@ export function useContractMutations(contractId: string) {
}; };
const staffAccept = useMutation({ const staffAccept = useMutation({
mutationFn: (validityDays: number) => mutationFn: (payload: {
contractsService.staffAccept(contractId, validityDays), validityDays: number;
documentSnapshot?: Freight.IContractDocumentSnapshot;
}) =>
contractsService.staffAccept(
contractId,
payload.validityDays,
payload.documentSnapshot,
),
onSuccess: (data) => onSuccess(data, "Contract accepted for approval"), onSuccess: (data) => onSuccess(data, "Contract accepted for approval"),
onError: () => toast.error("Failed to accept contract"), 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({ const requestChanges = useMutation({
mutationFn: (note: string) => mutationFn: (note: string) =>
contractsService.requestChanges(contractId, note), contractsService.requestChanges(contractId, note),
@@ -144,11 +159,6 @@ export function useContractMutations(contractId: string) {
onError: () => toast.error("Failed to reject contract"), 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({ const approveStep = useMutation({
mutationFn: ({ mutationFn: ({
stepId, stepId,
@@ -158,25 +168,15 @@ export function useContractMutations(contractId: string) {
requiredRole: string; requiredRole: string;
}) => }) =>
contractsService.approveStep({ id: contractId, stepId, requiredRole }), contractsService.approveStep({ id: contractId, stepId, requiredRole }),
onSuccess: async (data) => { onSuccess: (data) => {
// If this was the LAST approval, auto-generate the contract so it goes // The document is generated at the accept stage and reviewed during
// straight to CONTRACT_READY without a manual step. // approval, so the final approval moves the contract straight to
const alreadyGenerated = Boolean( // CONTRACT_READY on the server — no client-side generate call here.
(data as Freight.IContract).contractGeneratedAt, const message =
); data.status === "CONTRACT_READY"
if (READY_TO_GENERATE.includes(data.status) && !alreadyGenerated) { ? "Final approval complete — contract ready to sign"
toast.success("Final approval complete — generating contract…"); : "Approval step completed";
try { onSuccess(data, message);
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");
}, },
onError: () => toast.error("Failed to approve step"), onError: () => toast.error("Failed to approve step"),
}); });
@@ -239,6 +239,7 @@ export function useContractMutations(contractId: string) {
const isPending = const isPending =
staffAccept.isPending || staffAccept.isPending ||
updateDocument.isPending ||
requestChanges.isPending || requestChanges.isPending ||
reject.isPending || reject.isPending ||
approveStep.isPending || approveStep.isPending ||
@@ -249,6 +250,7 @@ export function useContractMutations(contractId: string) {
return { return {
staffAccept, staffAccept,
updateDocument,
requestChanges, requestChanges,
reject, reject,
approveStep, approveStep,

View File

@@ -3,7 +3,11 @@ import toast from "react-hot-toast";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { api } from "@/services/api"; 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 { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources";
import type { import type {
RuleEngineRecord, RuleEngineRecord,
@@ -239,6 +243,68 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
return { create, update, remove }; 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 = () => { export const useRateWorkflow = () => {
const qc = useQueryClient(); const qc = useQueryClient();

View File

@@ -141,6 +141,7 @@ export function useZoneOccupancy(yardId?: string) {
return useQuery({ return useQuery({
queryKey: ['warehouse-zones', 'occupancy', yardId ?? 'all'], queryKey: ['warehouse-zones', 'occupancy', yardId ?? 'all'],
queryFn: () => warehouseService.zoneOccupancy(yardId).then((r) => r.data), queryFn: () => warehouseService.zoneOccupancy(yardId).then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
}); });
} }
@@ -149,6 +150,55 @@ export function useWarehouseOpsStats() {
return useQuery({ return useQuery({
queryKey: ['warehouse-inventory', 'ops-stats'], queryKey: ['warehouse-inventory', 'ops-stats'],
queryFn: () => warehouseService.opsStats().then((r) => r.data), 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({ return useQuery({
queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'], queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'],
queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data), queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
}); });
} }
@@ -405,6 +456,7 @@ export function useWarehouseDashboard() {
return useQuery({ return useQuery({
queryKey: ['warehouses', 'dashboard'], queryKey: ['warehouses', 'dashboard'],
queryFn: () => warehouseService.dashboard().then((r) => r.data), 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: 'EXPORT_READY', label: 'Export ready' },
{ value: 'ASSIGNED', label: 'Assigned' }, { value: 'ASSIGNED', label: 'Assigned' },
{ value: 'MAINTENANCE', label: 'Maintenance' }, { value: 'MAINTENANCE', label: 'Maintenance' },
{ value: 'RETIRED', label: 'Retired' }, { value: 'DETAINED', label: 'Detained' },
], ],
}, },
{ key: 'notes', label: 'Notes' }, { key: 'notes', label: 'Notes' },

View File

@@ -113,7 +113,7 @@ const WAGON_STATUS_OPTIONS = [
{ label: "Available", value: Freight.WagonStatus.Available }, { label: "Available", value: Freight.WagonStatus.Available },
{ label: "Assigned", value: Freight.WagonStatus.Assigned }, { label: "Assigned", value: Freight.WagonStatus.Assigned },
{ label: "Maintenance", value: Freight.WagonStatus.Maintenance }, { 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 { PageContainer, PageHeader } from "@/components/page";
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog"; import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid"; import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog"; import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls"; import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls";
@@ -34,6 +35,7 @@ import {
useContainerTypeOptions, useContainerTypeOptions,
useLiveRateOptions, useLiveRateOptions,
useWagonTypeOptions, useWagonTypeOptions,
usePriorityRuleWorkflow,
useRateWorkflow, useRateWorkflow,
useRuleEngineList, useRuleEngineList,
useRuleEngineMutations, useRuleEngineMutations,
@@ -142,6 +144,13 @@ const RuleEngineResourcePage = () => {
chainOpen && config?.slug === "approval-rules", 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 editingId = editing?.id ? String(editing.id) : undefined;
const usesContainerTypeField = Boolean( const usesContainerTypeField = Boolean(
config?.formFields.some((f) => f.name === "containerTypeId"), config?.formFields.some((f) => f.name === "containerTypeId"),
@@ -360,9 +369,37 @@ const RuleEngineResourcePage = () => {
currency: "USD", currency: "USD",
trigger: isSurcharge ? values.trigger : "ALWAYS", 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. // Label is required by the backend but hidden in the UI for now.
payload = { ...values, label: String(Date.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") { } else if (config.slug === "weight-limit-rules") {
// Empty max capacity means "no ceiling" — send null explicitly so an // Empty max capacity means "no ceiling" — send null explicitly so an
// edit can clear a previously-set ceiling (omitting the key keeps it). // 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}> <Card p={0}>
<Stack gap={0}> <Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%"> <Box px="md" pt="md" pb="sm" w="100%">
@@ -519,7 +565,9 @@ const RuleEngineResourcePage = () => {
} }
fields={formFields} fields={formFields}
initialRecord={editing} initialRecord={editing}
isSubmitting={create.isPending || update.isPending} isSubmitting={
create.isPending || update.isPending || priorityWorkflow.submit.isPending
}
selectOptionsLoading={ selectOptionsLoading={
(config.slug === "cargo-types" && cargoParentOptionsLoading) || (config.slug === "cargo-types" && cargoParentOptionsLoading) ||
(usesContainerTypeField && containerTypeOptionsLoading) || (usesContainerTypeField && containerTypeOptionsLoading) ||
@@ -557,8 +605,9 @@ const RuleEngineResourcePage = () => {
> >
<Stack gap="md"> <Stack gap="md">
<Text size="sm"> <Text size="sm">
This will soft-delete the selected {config.label.toLowerCase()}{" "} {isPriorityRules
record. ? "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> </Text>
<Group justify="flex-end" gap="sm"> <Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setDeleteTarget(null)}> <Button variant="default" onClick={() => setDeleteTarget(null)}>
@@ -566,15 +615,25 @@ const RuleEngineResourcePage = () => {
</Button> </Button>
<Button <Button
color="red" color="red"
loading={remove.isPending} loading={remove.isPending || priorityWorkflow.submit.isPending}
onClick={() => { onClick={() => {
if (!deleteTarget) return; if (!deleteTarget) return;
if (isPriorityRules) {
priorityWorkflow.submit.mutate(
{
action: "DELETE",
priorityConfigId: String(deleteTarget.id),
},
{ onSuccess: () => setDeleteTarget(null) },
);
return;
}
remove.mutate(deleteTarget.id, { remove.mutate(deleteTarget.id, {
onSuccess: () => setDeleteTarget(null), onSuccess: () => setDeleteTarget(null),
}); });
}} }}
> >
Delete {isPriorityRules ? "Request delete" : "Delete"}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -68,6 +68,7 @@ import {
ScheduleWarningsAlert, ScheduleWarningsAlert,
} from "@/components/trainScheduling/ScheduleWarningsAlert"; } from "@/components/trainScheduling/ScheduleWarningsAlert";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram"; import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { TrainConsistView } from "@/components/trainScheduling/compositionEditor";
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid"; import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep"; import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
import { openPdfBlob } from "@/components/warehouses/pdf"; import { openPdfBlob } from "@/components/warehouses/pdf";
@@ -252,12 +253,6 @@ export default function TrainScheduleV2DetailPage() {
() => (isExportDisplay ? [...displayWagonPlan].reverse() : displayWagonPlan), () => (isExportDisplay ? [...displayWagonPlan].reverse() : displayWagonPlan),
[displayWagonPlan, isExportDisplay], [displayWagonPlan, isExportDisplay],
); );
const diagramWagons = useMemo(() => {
const source = schedule?.trainSet?.wagons?.length
? schedule.trainSet.wagons
: displayWagonPlan;
return isExportDisplay ? [...source].reverse() : source;
}, [schedule?.trainSet?.wagons, displayWagonPlan, isExportDisplay]);
const runPreview = useCallback( const runPreview = useCallback(
async (options?: { silent?: boolean; advanceStep?: boolean }) => { async (options?: { silent?: boolean; advanceStep?: boolean }) => {
@@ -774,22 +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 ( return (
<Stack gap="md"> <Stack gap="md">
<TrainCompositionDiagram {schedule.trainSet ? (
locomotive={schedule.trainSet?.locomotive} <TrainConsistView
locomotives={locomotives} scheduleDetail={schedule}
wagons={diagramWagons} scheduleId={scheduleId ?? ""}
freightType={freightType} maxWagons={schedule.maxWagons ?? 53}
trainNumber={schedule.trainNumber ?? schedule.train?.code ?? null} />
totalLengthMeters={schedule.trainSet?.totalLengthMeters} ) : (
/> <TrainCompositionDiagram
{isExportDisplay && diagramWagons.length ? ( locomotive={null}
<Text size="xs" c="dimmed"> locomotives={locomotives}
Shown rear-first (export direction) positions keep their original numbers. wagons={[]}
</Text> freightType={freightType}
) : null} trainNumber={schedule.trainNumber ?? schedule.train?.code ?? null}
totalLengthMeters={null}
/>
)}
<Paper <Paper
p="lg" p="lg"
radius="lg" radius="lg"

View File

@@ -1,5 +1,5 @@
import { useNavigate } from 'react-router-dom'; 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 { import {
ClipboardCheck, ClipboardCheck,
ClipboardList, ClipboardList,
@@ -16,10 +16,26 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page'; 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 { useWarehouseDashboard } from '@/hooks/useWarehouses';
import type { WarehouseDashboard } from '@/types/warehouse'; 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 { interface Metric {
key: keyof WarehouseDashboard; key: keyof WarehouseDashboard;
label: string; label: string;
@@ -56,6 +72,26 @@ export default function WarehouseDashboardPage() {
<PageHeader <PageHeader
title="Warehouse Dashboard" title="Warehouse Dashboard"
subtitle="Live overview of warehouse capacity and inventory lifecycle." 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 ? ( {isLoading ? (
@@ -67,7 +103,16 @@ export default function WarehouseDashboardPage() {
<Text c="red">Failed to load warehouse dashboard.</Text> <Text c="red">Failed to load warehouse dashboard.</Text>
</Center> </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"> <SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
{METRICS.map((metric) => ( {METRICS.map((metric) => (
<Card <Card
@@ -98,8 +143,30 @@ export default function WarehouseDashboardPage() {
))} ))}
</SimpleGrid> </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> </PageContainer>
); );

View File

@@ -202,6 +202,7 @@ import {
type WagonMovementRecord, type WagonMovementRecord,
type WagonTransferRequest, type WagonTransferRequest,
type CreateTransferRequestPayload, type CreateTransferRequestPayload,
type BulkFulfillResult,
type TransferHistory, type TransferHistory,
} from "./wagon.service"; } from "./wagon.service";
import { warehouseService } from "./warehouse.service"; import { warehouseService } from "./warehouse.service";
@@ -1721,6 +1722,15 @@ export const api = {
() => [["wagonTransferRequests"], ["wagons"]], () => [["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>( cancel: endpoint<{ id: string }, WagonTransferRequest>(
"wagonTransferRequests", "wagonTransferRequests",
"cancel", "cancel",

View File

@@ -170,8 +170,35 @@ export const contractsService = {
}, },
// ── Staff review ── // ── Staff review ──
staffAccept: (id: string, validityDays: number) => staffAccept: (
postContract<Freight.IContract>(C.STAFF_ACCEPT(id), { validityDays }), 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) => requestChanges: (id: string, note: string) =>
postContract<Freight.IContract>(C.STAFF_REQUEST_CHANGES(id), { note }), postContract<Freight.IContract>(C.STAFF_REQUEST_CHANGES(id), { note }),

View File

@@ -24,6 +24,30 @@ export interface RuleEngineReorderPayload {
requiresDirectorApproval?: boolean; 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> = { const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES, "cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES, "container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
@@ -242,6 +266,46 @@ export const ruleEngineService = {
return normalizeEntity<T>(response.data); 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 ( getApprovalChain: async (
requiresDirectorApproval = true, requiresDirectorApproval = true,
): Promise<RuleEngineRecord[]> => { ): Promise<RuleEngineRecord[]> => {

View File

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

View File

@@ -6,6 +6,11 @@ import { URL_CONSTANTS } from '@/constants/URLS';
import type { import type {
ZoneOccupancy, ZoneOccupancy,
WarehouseOpsStats, WarehouseOpsStats,
WarehouseThroughputPoint,
WarehouseDwellStats,
WarehouseCycleStats,
WarehouseOnTimeStats,
WarehouseGateStats,
AccrualDashboardRow, AccrualDashboardRow,
AllocationCriteria, AllocationCriteria,
AllocationPreviewResult, AllocationPreviewResult,
@@ -394,6 +399,16 @@ export const warehouseService = {
), ),
opsStats: () => opsStats: () =>
apiClient.get<WarehouseOpsStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.OPS_STATS), apiClient.get<WarehouseOpsStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.OPS_STATS),
throughput: (granularity: 'week' | 'month' | 'year') =>
apiClient.get<WarehouseThroughputPoint[]>(
URL_CONSTANTS.WAREHOUSE_INVENTORY.THROUGHPUT(granularity),
),
dwellStats: () =>
apiClient.get<WarehouseDwellStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DWELL_STATS),
cycleStats: () =>
apiClient.get<WarehouseCycleStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.CYCLE_STATS),
gateStats: () =>
apiClient.get<WarehouseGateStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.GATE_STATS),
autoUnloadArrived: () => autoUnloadArrived: () =>
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED), apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
autoLoadReady: () => autoLoadReady: () =>
@@ -450,6 +465,8 @@ export const warehouseService = {
apiClient.get<AccrualDashboardRow[]>(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, { apiClient.get<AccrualDashboardRow[]>(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, {
params: cleanParams({ billingCurrency }), params: cleanParams({ billingCurrency }),
}), }),
onTimeDispatch: () =>
apiClient.get<WarehouseOnTimeStats>(URL_CONSTANTS.WAREHOUSE_RULES.ON_TIME_DISPATCH),
acknowledgeAccrual: (inventoryId: string, body: { snoozeDays?: number; note?: string } = {}) => acknowledgeAccrual: (inventoryId: string, body: { snoozeDays?: number; note?: string } = {}) =>
apiClient.post(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_ACK(inventoryId), body), apiClient.post(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_ACK(inventoryId), body),
unacknowledgeAccrual: (inventoryId: string) => unacknowledgeAccrual: (inventoryId: string) =>

View File

@@ -528,6 +528,8 @@ export interface TrainScheduleDetail {
deferredBookings?: DeferredBookingRow[]; deferredBookings?: DeferredBookingRow[];
freightType?: FreightType | null; freightType?: FreightType | null;
trainNumber?: string | null; trainNumber?: string | null;
/** Wagon cap for this departure (built-train consist size or configured limit). */
maxWagons?: number | null;
/** Built train (Train Builder) behind this departure, when scheduled by train. */ /** Built train (Train Builder) behind this departure, when scheduled by train. */
train?: { train?: {
id: string; id: string;

View File

@@ -1115,11 +1115,47 @@ export interface ZoneOccupancy {
/** At-a-glance warehouse ops counters for the KPI strip. */ /** At-a-glance warehouse ops counters for the KPI strip. */
export interface WarehouseOpsStats { export interface WarehouseOpsStats {
receivedToday: number; receivedToday: number;
receivedYesterday: number;
pendingInspection: number; pendingInspection: number;
trucksOnSite: number; trucksOnSite: number;
itemsAging: number; itemsAging: number;
} }
/** One bucket of the received-vs-dispatched throughput time series. */
export interface WarehouseThroughputPoint {
periodStart: string;
received: number;
dispatched: number;
}
/** Dwell time of in-warehouse items: average days + aging-bucket counts. */
export interface WarehouseDwellStats {
avgDwellDays: number;
inWarehouseCount: number;
buckets: Array<{ key: string; label: string; count: number }>;
}
/** Average stage cycle times over recently dispatched items. */
export interface WarehouseCycleStats {
sampleSize: number;
avgDockToDispatchDays: number;
stages: Array<{ key: string; label: string; avgDays: number }>;
}
/** On-time dispatch rate (items that left before storage free-days expired). */
export interface WarehouseOnTimeStats {
sampleSize: number;
onTimeCount: number;
onTimePct: number | null;
}
/** Gate / dock throughput: cleared today, turnaround, and hourly clearances. */
export interface WarehouseGateStats {
clearedToday: number;
avgTurnaroundHours: number | null;
byHour: Array<{ hour: string; count: number }>;
}
export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING'; export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
/** One item's live fee accrual for the accrual dashboard. */ /** One item's live fee accrual for the accrual dashboard. */

View File

@@ -279,6 +279,60 @@ export default function NewBookingPage() {
const originYard = form.watch("originYard"); const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard"); const destinationYard = form.watch("destinationYard");
const operationType = form.watch("operationType"); const operationType = form.watch("operationType");
const watchedCargoKind = form.watch("cargoType");
const watchedContainers = form.watch("containers");
const watchedCargoTypePath = form.watch("cargoTypePath");
const watchedScheduledDate = form.watch("scheduledDate");
const isGeneralContractBooking =
form.watch("bookingType") === "general_contract";
// Wagon-TYPE availability gate: which days have a departure whose train can
// physically carry the selected cargo/container type. Quantity is NOT part
// of this gate — an oversized booking is accepted and gets a partial split
// offer later. Only selectable days reach the UI; no capacity counts.
const gateContainerTypeIds = useMemo(() => {
if (watchedCargoKind !== "container") return [];
const groups = referenceData?.containers ?? [];
const ids = new Set<string>();
for (const line of watchedContainers ?? []) {
if (!line?.containerType) continue;
for (const group of groups) {
const ct = group.types.find((t) => t.name === line.containerType);
if (ct) ids.add(ct.id);
}
}
return [...ids];
}, [watchedCargoKind, watchedContainers, referenceData]);
const gateCargoTypeId =
watchedCargoKind === "bulk" ? watchedCargoTypePath?.[1] : undefined;
const gateReady =
!isGeneralContractBooking &&
!!originYard &&
!!destinationYard &&
(watchedCargoKind === "bulk"
? !!gateCargoTypeId
: gateContainerTypeIds.length > 0);
const availableDaysQuery = useQuery(
api.bookings.getAvailableDaysForCargo.queryOptions({
input: {
originYardId: originYard,
destinationYardId: destinationYard,
freightType: watchedCargoKind === "bulk" ? "BULK" : "CONTAINER",
cargoTypeId: gateCargoTypeId || undefined,
containerTypeIds: gateContainerTypeIds,
},
enabled: gateReady,
}),
);
const availableBookingDays = gateReady ? availableDaysQuery.data : undefined;
// Block submit only on a POSITIVE answer that the picked day has no wagon
// for this cargo — a loading/failed availability lookup never bricks the
// wizard (the backend re-checks at the binding step anyway).
const noWagonForSelectedDay = Boolean(
availableBookingDays &&
watchedScheduledDate &&
!availableBookingDays.includes(watchedScheduledDate),
);
// The estimated shipment date lives in the Route step now; for general // The estimated shipment date lives in the Route step now; for general
// contracts that date field is simply hidden there (the date is chosen per // contracts that date field is simply hidden there (the date is chosen per
@@ -678,6 +732,8 @@ export default function NewBookingPage() {
form={form} form={form}
referenceData={referenceData} referenceData={referenceData}
isLoading={refDataLoading} isLoading={refDataLoading}
availableDays={availableBookingDays}
noWagonForSelectedDay={noWagonForSelectedDay}
/> />
)} )}
{step === 6 && <StepDocuments form={form} />} {step === 6 && <StepDocuments form={form} />}
@@ -698,6 +754,7 @@ export default function NewBookingPage() {
persistAndPriceMutation.isPending && persistAndPriceMutation.isPending &&
persistAndPriceMutation.variables?.mode === "submit" persistAndPriceMutation.variables?.mode === "submit"
} }
noWagonForSelectedDay={noWagonForSelectedDay}
/> />
)} )}
</Box> </Box>
@@ -746,6 +803,7 @@ export default function NewBookingPage() {
leftSection={<Send size={16} />} leftSection={<Send size={16} />}
onClick={handleSubmitBooking} onClick={handleSubmitBooking}
loading={isPricing} loading={isPricing}
disabled={noWagonForSelectedDay}
> >
Submit Submit
</Button> </Button>

View File

@@ -220,12 +220,14 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
Choose your shipment day Choose your shipment day
</Text> </Text>
<Text fz="12px" c="dimmed" mb="sm"> <Text fz="12px" c="dimmed" mb="sm">
Only days with a scheduled departure on your route can be selected. Only days with a scheduled departure that can carry your cargo type
The operations team assigns the specific train for that day. can be selected. The operations team assigns the specific train for
that day.
</Text> </Text>
<OperationDatePicker <OperationDatePicker
originYardId={booking.originYard?.id} originYardId={booking.originYard?.id}
destinationYardId={booking.destinationYard?.id} destinationYardId={booking.destinationYard?.id}
bookingId={booking.id}
value={scheduledDate} value={scheduledDate}
onChange={setScheduledDate} onChange={setScheduledDate}
/> />

View File

@@ -6,6 +6,8 @@ import { api } from "@/services/api";
interface OperationDatePickerProps { interface OperationDatePickerProps {
originYardId?: string; originYardId?: string;
destinationYardId?: string; destinationYardId?: string;
/** When set, days are cargo-aware: only days whose train can carry THIS booking's cargo type. */
bookingId?: string;
value: string; value: string;
onChange: (date: string) => void; onChange: (date: string) => void;
} }
@@ -13,20 +15,31 @@ interface OperationDatePickerProps {
/** /**
* Route-based day picker for the operation-request step: a thin query wrapper * Route-based day picker for the operation-request step: a thin query wrapper
* around the shared presentational `OperationDatePicker` from `@edr/ui-common`. * around the shared presentational `OperationDatePicker` from `@edr/ui-common`.
* Only days with an OPEN scheduled departure on the route are selectable. * Only days with an OPEN scheduled departure on the route are selectable; with
* a `bookingId` the server additionally drops days whose trains have no wagon
* type that can carry the booking's cargo (no capacity counts are shown).
*/ */
export function OperationDatePicker({ export function OperationDatePicker({
originYardId, originYardId,
destinationYardId, destinationYardId,
bookingId,
value, value,
onChange, onChange,
}: OperationDatePickerProps) { }: OperationDatePickerProps) {
const { data: availableDays, isLoading } = useQuery( const routeDays = useQuery(
api.bookings.getAvailableDays.queryOptions({ api.bookings.getAvailableDays.queryOptions({
input: { originYardId, destinationYardId }, input: { originYardId, destinationYardId },
enabled: !!originYardId && !!destinationYardId, enabled: !bookingId && !!originYardId && !!destinationYardId,
}), }),
); );
const bookingDays = useQuery(
api.bookings.getAvailableDaysForBooking.queryOptions({
input: { bookingId: bookingId ?? "" },
enabled: !!bookingId,
}),
);
const availableDays = bookingId ? bookingDays.data : routeDays.data;
const isLoading = bookingId ? bookingDays.isLoading : routeDays.isLoading;
return ( return (
<DatePicker <DatePicker

View File

@@ -1,5 +1,5 @@
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { Box, Skeleton, Stack } from "@mantine/core"; import { Box, Skeleton, Stack, Text } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates"; import { DatePickerInput } from "@mantine/dates";
import { CalendarDays, MapPin, Route as RouteIcon } from "lucide-react"; import { CalendarDays, MapPin, Route as RouteIcon } from "lucide-react";
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
@@ -21,10 +21,19 @@ export function Step4Route({
form, form,
referenceData, referenceData,
isLoading, isLoading,
availableDays,
noWagonForSelectedDay,
}: { }: {
form: BookingForm; form: BookingForm;
referenceData?: Freight.BookingReferenceData; referenceData?: Freight.BookingReferenceData;
isLoading?: boolean; isLoading?: boolean;
/**
* Days with a departure whose train can carry the selected cargo type
* (wagon-TYPE gate). `undefined` = unknown (loading/failed) — no gating.
* Never carries capacity counts.
*/
availableDays?: string[];
noWagonForSelectedDay?: boolean;
}) { }) {
const originYard = form.watch("originYard"); const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard"); const destinationYard = form.watch("destinationYard");
@@ -194,6 +203,16 @@ export function Step4Route({
minDate={todayISODate} minDate={todayISODate}
leftSection={<CalendarDays size={16} />} leftSection={<CalendarDays size={16} />}
error={fieldState.error?.message} error={fieldState.error?.message}
// Days whose trains cannot carry the selected cargo type are
// not selectable (wagon-TYPE gate; quantity never blocks).
excludeDate={(date) => {
if (!availableDays) return false;
const day =
typeof date === "string"
? date.slice(0, 10)
: new Date(date).toISOString().slice(0, 10);
return !availableDays.includes(day);
}}
// Mantine v9 DatePickerInput uses string (YYYY-MM-DD) values, // Mantine v9 DatePickerInput uses string (YYYY-MM-DD) values,
// matching the form's `scheduledDate` string directly. // matching the form's `scheduledDate` string directly.
value={field.value || null} value={field.value || null}
@@ -204,6 +223,19 @@ export function Step4Route({
/> />
)} )}
/> />
{noWagonForSelectedDay && (
<Text size="xs" c="red.7" mt={6}>
No wagon on this day&apos;s train can carry your cargo type
please pick another available day.
</Text>
)}
{availableDays && availableDays.length === 0 && (
<Text size="xs" c="red.7" mt={6}>
No upcoming departure can carry this cargo type on the chosen
route right now. Try a different cargo/container type or check
back later.
</Text>
)}
</Box> </Box>
)} )}
</div> </div>

View File

@@ -137,6 +137,7 @@ export function Step8Review({
onSubmit, onSubmit,
saveDraftPending = false, saveDraftPending = false,
submitPending = false, submitPending = false,
noWagonForSelectedDay = false,
}: { }: {
form: BookingForm; form: BookingForm;
setStep: (step: number) => void; setStep: (step: number) => void;
@@ -147,6 +148,8 @@ export function Step8Review({
onSubmit?: () => void; onSubmit?: () => void;
saveDraftPending?: boolean; saveDraftPending?: boolean;
submitPending?: boolean; submitPending?: boolean;
/** Wagon-TYPE gate: the picked day has no train that can carry this cargo. */
noWagonForSelectedDay?: boolean;
}) { }) {
const values = form.watch(); const values = form.watch();
const serviceType = referenceData?.service.find( const serviceType = referenceData?.service.find(
@@ -551,6 +554,19 @@ export function Step8Review({
</p> </p>
</AlertBox> </AlertBox>
</Box> </Box>
) : noWagonForSelectedDay ? (
<Box mb="md">
<AlertBox tone="error">
<p className="font-semibold">
No wagon available for the selected day
</p>
<p className="mt-1 text-xs">
No train departing that day has a wagon type that can carry
your cargo. Go back to the route step and pick one of the
available days.
</p>
</AlertBox>
</Box>
) : ( ) : (
<Text size="sm" c="dimmed" mb="md"> <Text size="sm" c="dimmed" mb="md">
Ready to submit. You'll review the unit rates before final Ready to submit. You'll review the unit rates before final
@@ -567,7 +583,7 @@ export function Step8Review({
leftSection={<Send size={16} />} leftSection={<Send size={16} />}
onClick={onSubmit} onClick={onSubmit}
loading={submitPending} loading={submitPending}
disabled={submitPending || hasOdd20ft} disabled={submitPending || hasOdd20ft || noWagonForSelectedDay}
> >
Submit Submit
</Button> </Button>

View File

@@ -423,6 +423,12 @@ export const api = {
bookingsService.getAvailableDaysForCargo(input), bookingsService.getAvailableDaysForCargo(input),
), ),
getAvailableDaysForBooking: endpoint<{ bookingId: string }, string[]>(
"train-scheduling",
"availableDaysForBooking",
({ bookingId }) => bookingsService.getAvailableDaysForBooking(bookingId),
),
getMyBookingWindows: endpoint<void, MyBookingWindow[]>( getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
"train-scheduling", "train-scheduling",
"myBookingWindows", "myBookingWindows",

View File

@@ -414,25 +414,38 @@ export const bookingsService = {
return (data.data as Freight.AvailableDaysResponse).days; return (data.data as Freight.AvailableDaysResponse).days;
}, },
// Cargo-aware day pool: only days where a train has remaining capacity AND // Cargo-aware day pool: only days where a train has remaining capacity AND a
// enough matching-type wagons for this cargo. `containers` is serialized as a // wagon TYPE that can carry this cargo. `containers`/`containerTypeIds` are
// JSON string param (the server parses it). // serialized as JSON string params (the server parses them). Days only — no
// capacity counts are ever returned.
getAvailableDaysForCargo: async ( getAvailableDaysForCargo: async (
query: Freight.AvailableDaysForCargoQuery, query: Freight.AvailableDaysForCargoQuery,
): Promise<string[]> => { ): Promise<string[]> => {
const { containers, ...rest } = query; const { containers, containerTypeIds, ...rest } = query;
const { data } = await client.get( const { data } = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS_FOR_CARGO, URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS_FOR_CARGO,
{ {
params: { params: {
...rest, ...rest,
...(containers ? { containers: JSON.stringify(containers) } : {}), ...(containers ? { containers: JSON.stringify(containers) } : {}),
...(containerTypeIds?.length
? { containerTypeIds: JSON.stringify(containerTypeIds) }
: {}),
}, },
}, },
); );
return (data.data as Freight.AvailableDaysResponse).days; return (data.data as Freight.AvailableDaysResponse).days;
}, },
// Days bookable for an EXISTING booking (operation-request step): the server
// derives the cargo from the booking and applies the wagon-type gate.
getAvailableDaysForBooking: async (bookingId: string): Promise<string[]> => {
const { data } = await client.get(
`/api/bookings/${bookingId}/available-days`,
);
return (data.data as Freight.AvailableDaysResponse).days;
},
/** /**
* Upcoming/open booking windows on the signed-in customer's active-contract * Upcoming/open booking windows on the signed-in customer's active-contract
* lanes (import booking-day windows + export 24h pre-departure windows). * lanes (import booking-day windows + export 24h pre-departure windows).

View File

@@ -280,7 +280,15 @@ export class SeatsService {
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`); throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
} }
const blocked = seats.filter(s => s.status === 'BLOCKED' || s.status === 'BOOKED'); // Only the raw BLOCKED status (seat pulled out of service — a genuine
// cross-schedule flag) is trusted here. BOOKED is intentionally NOT checked
// against this raw column: the same physical Seat row is reused across every
// recurring date a coach runs, and Seat.status only resets to AVAILABLE via a
// trip-completion event that isn't guaranteed to fire, so a stale BOOKED value
// here would wrongly block a seat that's actually free for this schedule/leg.
// The schedule- and leg-scoped SeatHold/JourneySegment checks below are the
// authoritative source for whether a seat is actually taken.
const blocked = seats.filter(s => s.status === 'BLOCKED');
if (blocked.length > 0) if (blocked.length > 0)
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`); throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`);

View File

@@ -1065,23 +1065,36 @@ export default function ResultsPage() {
} }
if (isOneWayNoOutbound) { if (isOneWayNoOutbound) {
const hasAlternatives = alternativeOutbound.length > 0;
return ( return (
<div className="booking-page"> <div className="booking-page">
{renderClassModal()} {renderClassModal()}
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-8"> <div className="card max-w-lg mx-auto text-center py-10 px-6 mb-8">
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300"> <div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-5">
<Calendar className="w-4 h-4 flex-shrink-0" /> <Calendar className="w-8 h-8 text-red-500 dark:text-red-400" />
<span>No trains available on <span className="font-semibold">{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d, yyyy") : "your selected date"}</span>.</span>
</div> </div>
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0"> <h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
Change date No trains available
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
There are no trains scheduled on{" "}
<span className="font-semibold text-gray-900 dark:text-gray-100">
{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d, yyyy") : "your selected date"}
</span>
. Try a different date to see available trains.
</p>
<button
onClick={() => router.push(buildSearchUrl())}
className="btn-primary inline-flex items-center gap-2"
>
<Calendar className="w-4 h-4" />
Change Date
</button> </button>
</div> </div>
{/* Alternative Travel Options — commented out for the time being;
only the "No trains available" banner above is shown.
{hasAlternatives && ( {hasAlternatives && (
<div> <div>
<div className="mb-4"> <div className="mb-4">
@@ -1100,6 +1113,7 @@ export default function ResultsPage() {
</div> </div>
</div> </div>
)} )}
*/}
</div> </div>
</div> </div>
</div> </div>
@@ -1233,30 +1247,32 @@ export default function ResultsPage() {
renderScheduleCard(schedule, true), renderScheduleCard(schedule, true),
)} )}
</div> </div>
{outboundSchedules.length === 0 && {outboundSchedules.length === 0 && (
alternativeOutbound.length > 0 && ( <div className="mt-6">
<div className="mt-6"> <div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-6"> <div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300"> <Calendar className="w-4 h-4 flex-shrink-0" />
<Calendar className="w-4 h-4 flex-shrink-0" /> <span>No trains on <span className="font-semibold">{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d") : "your selected date"}</span>.</span>
<span>No trains on <span className="font-semibold">{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d") : "your selected date"}</span>.</span>
</div>
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
Change dates
</button>
</div>
<div className="mb-3">
<h3 className="text-base font-bold text-gray-900 dark:text-white">
Alternative Outbound Options
</h3>
</div>
<div className="space-y-4">
{alternativeOutbound.map((schedule: Schedule) =>
renderScheduleCard(schedule, true, true),
)}
</div> </div>
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
Change dates
</button>
</div> </div>
)} {/* Alternative Outbound Options — commented out for the time being;
only the "No trains" banner above is shown.
<div className="mb-3">
<h3 className="text-base font-bold text-gray-900 dark:text-white">
Alternative Outbound Options
</h3>
</div>
<div className="space-y-4">
{alternativeOutbound.map((schedule: Schedule) =>
renderScheduleCard(schedule, true, true),
)}
</div>
*/}
</div>
)}
</div> </div>
) : ( ) : (
<div> <div>
@@ -1317,30 +1333,32 @@ export default function ResultsPage() {
renderScheduleCard(schedule, false), renderScheduleCard(schedule, false),
)} )}
</div> </div>
{inboundSchedules.length === 0 && {inboundSchedules.length === 0 && (
alternativeInbound.length > 0 && ( <div className="mt-6">
<div className="mt-6"> <div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-6"> <div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300"> <Calendar className="w-4 h-4 flex-shrink-0" />
<Calendar className="w-4 h-4 flex-shrink-0" /> <span>No trains on <span className="font-semibold">{searchData.returnDate ? format(new Date(`${searchData.returnDate}T00:00:00`), "EEEE, MMMM d") : "your selected return date"}</span>.</span>
<span>No trains on <span className="font-semibold">{searchData.returnDate ? format(new Date(`${searchData.returnDate}T00:00:00`), "EEEE, MMMM d") : "your selected return date"}</span>.</span>
</div>
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
Change dates
</button>
</div>
<div className="mb-3">
<h3 className="text-base font-bold text-gray-900 dark:text-white">
Alternative Return Options
</h3>
</div>
<div className="space-y-4">
{alternativeInbound.map((schedule: Schedule) =>
renderScheduleCard(schedule, false, true),
)}
</div> </div>
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
Change dates
</button>
</div> </div>
)} {/* Alternative Return Options — commented out for the time being;
only the "No trains" banner above is shown.
<div className="mb-3">
<h3 className="text-base font-bold text-gray-900 dark:text-white">
Alternative Return Options
</h3>
</div>
<div className="space-y-4">
{alternativeInbound.map((schedule: Schedule) =>
renderScheduleCard(schedule, false, true),
)}
</div>
*/}
</div>
)}
</div> </div>
) )
) : ( ) : (

View File

@@ -182,6 +182,42 @@ export interface IContractSignature {
signedAt: string; signedAt: string;
} }
// ── Per-contract document snapshot (staff-editable articles for one contract) ─
export interface IContractDocumentArticle {
id: string;
title: string;
body: string;
order: number;
}
/**
* A per-contract frozen copy of the resolved document template. Staff may edit
* its articles for a single contract in the accept/edit dialog — this never
* writes back to the shared six contract templates. Null → the PDF renders from
* the live template.
*/
export interface IContractDocumentSnapshot {
code?: string | null;
name?: string | null;
documentTitle?: string | null;
whereasClauses: string[];
articles: IContractDocumentArticle[];
}
/** Editable document draft returned for the accept/edit editor. */
export interface IContractDocumentDraft {
documentTitle: string | null;
whereasClauses: string[];
articles: IContractDocumentArticle[];
code: string | null;
name: string | null;
/** True once the document may no longer be edited/regenerated. */
locked: boolean;
generatedAt: string | null;
status: ContractStatus;
}
export type ContractApprovalStepStatus = export type ContractApprovalStepStatus =
| "PENDING" | "PENDING"
| "APPROVED" | "APPROVED"
@@ -584,6 +620,8 @@ export interface IContract extends BaseEntity {
contractType?: string | null; contractType?: string | null;
contractTemplateKey?: string | null; contractTemplateKey?: string | null;
contractGeneratedAt?: string | null; contractGeneratedAt?: string | null;
/** Per-contract frozen document (articles + WHEREAS) captured at staff accept. */
documentSnapshot?: IContractDocumentSnapshot | null;
contractSummary?: string | null; contractSummary?: string | null;
versionNumber: number; versionNumber: number;
financialTerms?: string | null; financialTerms?: string | null;

View File

@@ -231,7 +231,8 @@ export enum WagonStatus {
ImportReady = "IMPORT_READY", ImportReady = "IMPORT_READY",
ExportReady = "EXPORT_READY", ExportReady = "EXPORT_READY",
Maintenance = "MAINTENANCE", Maintenance = "MAINTENANCE",
Retired = "RETIRED", /** Formerly RETIRED — wagons pulled from circulation. */
Detained = "DETAINED",
} }
export enum WagonReadiness { export enum WagonReadiness {
@@ -359,6 +360,8 @@ export interface IWagonTransferRequest extends BaseEntity {
requestedByUserId?: string | null; requestedByUserId?: string | null;
fulfilledByUserId?: string | null; fulfilledByUserId?: string | null;
fulfilledAt?: string | null; fulfilledAt?: string | null;
/** Why the wagons are needed — required for new requests, shown on the OCC queue. */
reason?: string | null;
note?: string | null; note?: string | null;
} }
@@ -923,10 +926,14 @@ export interface AvailableDaysForCargoQuery {
freightType: "CONTAINER" | "BULK"; freightType: "CONTAINER" | "BULK";
/** Bulk cargo type code (e.g. "COFFEE"); ignored for container freight. */ /** Bulk cargo type code (e.g. "COFFEE"); ignored for container freight. */
cargoTypeCode?: string; cargoTypeCode?: string;
/** Bulk cargo type id — preferred over code for the wagon-type gate. */
cargoTypeId?: string;
/** Total bulk weight in tons. */ /** Total bulk weight in tons. */
totalWeightTons?: number; totalWeightTons?: number;
/** Container lines (size + quantity) for container freight. */ /** Container lines (size + quantity) for container freight. */
containers?: { containerSize: string; quantity: number }[]; containers?: { containerSize: string; quantity: number }[];
/** Container type ids — enables the exact wagon-type compatibility gate. */
containerTypeIds?: string[];
} }
/** /**