mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/user_management_UI
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { ContractsRepository } from '../modules/contracts/contracts.repository';
|
||||
import { Contract } from '../modules/contracts/entities/contract.entity';
|
||||
import {
|
||||
Contract,
|
||||
ContractDocumentSnapshot,
|
||||
} from '../modules/contracts/entities/contract.entity';
|
||||
import { ContractRoute } from '../modules/contracts/entities/contract-route.entity';
|
||||
import {
|
||||
ContractSignature,
|
||||
@@ -11,7 +14,10 @@ import { ContractPricingBreakdown } from '../modules/contracts/contract-pricing.
|
||||
import { ContractTemplatesService } from '../modules/contract-templates/contract-templates.service';
|
||||
import { ContractTemplateResolver } from './contract-template.resolver';
|
||||
import { getTemplateMeta } from './contract-template.registry';
|
||||
import { ContractViewModel } from './contract-view-model.builder';
|
||||
import {
|
||||
ContractDynamicTemplateView,
|
||||
ContractViewModel,
|
||||
} from './contract-view-model.builder';
|
||||
|
||||
/**
|
||||
* Signature row for the contract PDF. Mirrors the booking builder's
|
||||
@@ -90,22 +96,36 @@ export class ContractDocumentViewModelBuilder {
|
||||
contract.contractTemplateKey ?? this.templateResolver.resolve(this.toResolverInput(contract));
|
||||
let template = getTemplateMeta(templateKey);
|
||||
|
||||
// Prefer the admin-editable DB template matching the contract's
|
||||
// direction/freight pair; fall back to the code-defined generic layout
|
||||
// when none is active.
|
||||
const dynamicSource = await this.contractTemplates.findActiveForContract(
|
||||
contract.tradeDirection,
|
||||
contract.freightType,
|
||||
);
|
||||
const dynamicTemplate = dynamicSource
|
||||
? {
|
||||
code: dynamicSource.code,
|
||||
name: dynamicSource.name,
|
||||
documentTitle: dynamicSource.documentTitle,
|
||||
whereasClauses: dynamicSource.whereasClauses ?? [],
|
||||
articles: dynamicSource.articles ?? [],
|
||||
}
|
||||
: undefined;
|
||||
// The document articles come, in order of preference, from:
|
||||
// 1. this contract's frozen snapshot (staff accepted / edited it) — the
|
||||
// shared six templates are never consulted for these contracts;
|
||||
// 2. the admin-editable DB template matching the direction/freight pair;
|
||||
// 3. the code-defined generic layout (handled below when none of the above).
|
||||
const snapshot = contract.documentSnapshot as ContractDocumentSnapshot | null;
|
||||
let dynamicTemplate: ContractDynamicTemplateView | undefined;
|
||||
if (snapshot && (snapshot.articles?.length ?? 0) > 0) {
|
||||
dynamicTemplate = {
|
||||
code: snapshot.code ?? 'CONTRACT',
|
||||
name: snapshot.name ?? template.title,
|
||||
documentTitle: snapshot.documentTitle ?? '',
|
||||
whereasClauses: snapshot.whereasClauses ?? [],
|
||||
articles: snapshot.articles,
|
||||
};
|
||||
} else {
|
||||
const dynamicSource = await this.contractTemplates.findActiveForContract(
|
||||
contract.tradeDirection,
|
||||
contract.freightType,
|
||||
);
|
||||
dynamicTemplate = dynamicSource
|
||||
? {
|
||||
code: dynamicSource.code,
|
||||
name: dynamicSource.name,
|
||||
documentTitle: dynamicSource.documentTitle,
|
||||
whereasClauses: dynamicSource.whereasClauses ?? [],
|
||||
articles: dynamicSource.articles ?? [],
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
if (dynamicTemplate) {
|
||||
template = {
|
||||
...template,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Drop the unused reopen-delay knob from the global rules.
|
||||
*
|
||||
* The window engine never honoured `reopen_delay_minutes`: a not-yet-full train
|
||||
* reopens as soon as its payment phase settles, so the real gap between a cycle
|
||||
* closing and reopening is doc review + payment — nothing else. The per-schedule
|
||||
* `rule_reopen_delay_minutes` snapshot stays: it freezes that derived gap at
|
||||
* creation so the batch board keeps projecting the cycles the customer was shown.
|
||||
*/
|
||||
export class DropReopenDelayMinutes2190000000000 implements MigrationInterface {
|
||||
name = "DropReopenDelayMinutes2190000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
DROP COLUMN IF EXISTS reopen_delay_minutes;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
ADD COLUMN IF NOT EXISTS reopen_delay_minutes integer NOT NULL DEFAULT 90;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Every built train owns a fixed pair of run numbers, typed at build time:
|
||||
* an EXPORT number (odd, e.g. 8001) and an IMPORT number (even, e.g. 8002).
|
||||
* Scheduling copies the route-direction-matched number onto the schedule at
|
||||
* creation; legacy trains with a null pair keep dispatch-time pool assignment.
|
||||
*
|
||||
* NOTE: the shared dev DB has no applied migration history, so this is also
|
||||
* hand-applied there. IF NOT EXISTS keeps that idempotent.
|
||||
*/
|
||||
export class TrainNumberPair2200000000000 implements MigrationInterface {
|
||||
name = 'TrainNumberPair2200000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.trains
|
||||
ADD COLUMN IF NOT EXISTS import_train_number varchar(20),
|
||||
ADD COLUMN IF NOT EXISTS export_train_number varchar(20);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_import_train_number"
|
||||
ON freight.trains (import_train_number)
|
||||
WHERE import_train_number IS NOT NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_export_train_number"
|
||||
ON freight.trains (export_train_number)
|
||||
WHERE export_train_number IS NOT NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_export_train_number";`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_import_train_number";`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.trains
|
||||
DROP COLUMN IF EXISTS export_train_number,
|
||||
DROP COLUMN IF EXISTS import_train_number;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Prepaid customs clearance service fee (Path B):
|
||||
* - contract_rate_snapshots.is_clearance — flags the frozen CUSTOMS_CLEARANCE
|
||||
* fee line so it is billed via its own clearance invoice and excluded from
|
||||
* shipment booking totals;
|
||||
* - contracts.clearance_fee_paid_at — when the ONE_TIME contract-level fee
|
||||
* settled (gate: AWAITING_CLEARANCE_PAYMENT → AWAITING_CLEARANCE_DOCUMENTS);
|
||||
* - bookings.clearance_fee_paid_at — when a GENERAL shipment-request instance's
|
||||
* fee settled (gate: AWAITING_CLEARANCE_PAYMENT → AWAITING_DOCUMENTS).
|
||||
* All nullable/defaulted — existing rows are untouched and keep today's flow.
|
||||
*/
|
||||
export class AddClearanceFeePayment2260000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_rate_snapshots
|
||||
ADD COLUMN IF NOT EXISTS is_clearance BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contracts
|
||||
ADD COLUMN IF NOT EXISTS clearance_fee_paid_at TIMESTAMPTZ;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS clearance_fee_paid_at TIMESTAMPTZ;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS clearance_fee_paid_at;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contracts
|
||||
DROP COLUMN IF EXISTS clearance_fee_paid_at;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_rate_snapshots
|
||||
DROP COLUMN IF EXISTS is_clearance;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Adds freight.booking_container.return_quantity — how many units of a
|
||||
* container line ship with the empty-container-return service (≤ quantity).
|
||||
* Mirrors hazardous_quantity / reefer_quantity: captured per line at booking
|
||||
* creation when the contract enables WITH_RETURN (container freight only) and
|
||||
* drives the booking-level equipment_return flag that fires the WITH_RETURN
|
||||
* pricing surcharge.
|
||||
*/
|
||||
export class AddContainerReturnQuantity2270000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_container
|
||||
ADD COLUMN IF NOT EXISTS return_quantity SMALLINT NOT NULL DEFAULT 0;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_container
|
||||
DROP COLUMN IF EXISTS return_quantity;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Wagons are now soft-deleted (deleted_at) instead of hard-deleted. The plain
|
||||
* UNIQUE on wagon_number would keep a retired wagon's number reserved forever
|
||||
* and block ever re-registering that number. Swap it for a PARTIAL unique index
|
||||
* that only constrains live rows (deleted_at IS NULL); soft-deleted wagons no
|
||||
* longer occupy their number.
|
||||
*
|
||||
* NOTE: the shared dev DB has no applied migration history, so this is also
|
||||
* hand-applied there. The DO blocks + IF EXISTS/IF NOT EXISTS keep it
|
||||
* idempotent whether the original uniqueness is the auto-named column
|
||||
* constraint (wagons_wagon_number_key) or a TypeORM-named UQ_* constraint/index.
|
||||
*/
|
||||
export class WagonNumberPartialUnique2280000000000 implements MigrationInterface {
|
||||
name = 'WagonNumberPartialUnique2280000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Drop any UNIQUE constraint on freight.wagons(wagon_number), whatever it is
|
||||
// named (dropping the constraint also drops its backing index).
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE con_name text;
|
||||
BEGIN
|
||||
FOR con_name IN
|
||||
SELECT conname
|
||||
FROM pg_constraint
|
||||
WHERE conrelid = 'freight.wagons'::regclass
|
||||
AND contype = 'u'
|
||||
AND pg_get_constraintdef(oid) ILIKE '%(wagon_number)%'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE freight.wagons DROP CONSTRAINT IF EXISTS %I', con_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Drop any standalone (non-partial) unique index on wagon_number too.
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE idx_name text;
|
||||
BEGIN
|
||||
FOR idx_name IN
|
||||
SELECT c.relname
|
||||
FROM pg_index i
|
||||
JOIN pg_class c ON c.oid = i.indexrelid
|
||||
WHERE i.indrelid = 'freight.wagons'::regclass
|
||||
AND i.indisunique
|
||||
AND i.indpred IS NULL
|
||||
AND c.relname <> 'UQ_wagons_wagon_number_active'
|
||||
AND pg_get_indexdef(i.indexrelid) ILIKE '%(wagon_number)%'
|
||||
LOOP
|
||||
EXECUTE format('DROP INDEX IF EXISTS freight.%I', idx_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Live wagon numbers stay unique; soft-deleted rows are exempt.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_wagons_wagon_number_active"
|
||||
ON freight.wagons (wagon_number)
|
||||
WHERE deleted_at IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// No-op: re-adding a plain UNIQUE would fail whenever two soft-deleted
|
||||
// wagons share a number, and the partial index is strictly safer. Left in
|
||||
// place intentionally.
|
||||
}
|
||||
}
|
||||
@@ -602,6 +602,19 @@ export class BillingService {
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
throw new BadRequestException("Invoice is already fully paid.");
|
||||
}
|
||||
// M27: a Draft invoice is not yet issued and an Expired invoice's pay
|
||||
// window has closed — neither is payable. Without these guards a payment
|
||||
// could settle an unissued draft or a lapsed invoice.
|
||||
if (invoice.status === Freight.InvoiceStatus.Draft) {
|
||||
throw new BadRequestException(
|
||||
"Cannot pay a draft invoice — it must be issued first.",
|
||||
);
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Expired) {
|
||||
throw new BadRequestException(
|
||||
"Cannot pay an expired invoice — its payment window has closed.",
|
||||
);
|
||||
}
|
||||
if (round2(input.amount) > Number(invoice.balanceAmount)) {
|
||||
throw new BadRequestException(
|
||||
`Payment of ${round2(input.amount)} exceeds the outstanding balance of ${Number(invoice.balanceAmount)}.`,
|
||||
@@ -891,6 +904,20 @@ export class BillingService {
|
||||
status: Freight.InvoiceStatus,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
// M27: this is the blunt "issue a draft" override — it stamps `issuedAt` but
|
||||
// does NOT touch paidAmount/balanceAmount. Its only legitimate use is the
|
||||
// Draft → Pending/Issued issue transition. It must NEVER mark an invoice
|
||||
// Paid/Refunded/Cancelled/Expired (or PartiallyPaid/Overdue): those carry
|
||||
// balance implications and must go through the dedicated settlement methods
|
||||
// (recordPayment / markInvoiceAsRefunded / cancelInvoice / expirePayable).
|
||||
if (
|
||||
status !== Freight.InvoiceStatus.Pending &&
|
||||
status !== Freight.InvoiceStatus.Issued
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`updateStatus only issues an invoice (→ PENDING/ISSUED); use the dedicated settlement methods to set ${status}.`,
|
||||
);
|
||||
}
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: {
|
||||
|
||||
@@ -103,8 +103,35 @@ export class PaymentController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML-escape a value interpolated into the public checkout pages. These
|
||||
* pages are served unauthenticated and the interpolated values (provider
|
||||
* error messages, status strings, intent ids, redirect URLs) can carry
|
||||
* attacker-influenced input — unescaped they are a reflected-XSS sink.
|
||||
*/
|
||||
private escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
private buildRedirectHtml(url: string): string {
|
||||
const escaped = url.replace(/\"/g, """);
|
||||
// Only http(s) URLs may be used as a redirect target — a javascript:
|
||||
// URL would execute in the victim's browser from the <a>/location.href.
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return this.buildErrorHtml("Invalid payment redirect URL");
|
||||
}
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
return this.buildErrorHtml("Invalid payment redirect URL");
|
||||
}
|
||||
const escaped = this.escapeHtml(url);
|
||||
const jsEscaped = JSON.stringify(url);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -126,12 +153,14 @@ export class PaymentController {
|
||||
<p>Redirecting to payment provider…</p>
|
||||
<p><a href="${escaped}">Click here if you are not redirected</a></p>
|
||||
</div>
|
||||
<script>window.location.href = "${escaped}";</script>
|
||||
<script>window.location.href = ${jsEscaped};</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildStatusHtml(status: string, intentId: string): string {
|
||||
private buildStatusHtml(rawStatus: string, rawIntentId: string): string {
|
||||
const status = this.escapeHtml(rawStatus);
|
||||
const intentId = this.escapeHtml(rawIntentId);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -153,7 +182,8 @@ export class PaymentController {
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildErrorHtml(message: string): string {
|
||||
private buildErrorHtml(rawMessage: string): string {
|
||||
const message = this.escapeHtml(rawMessage);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
||||
@@ -119,6 +119,26 @@ export class BookingInvoiceService {
|
||||
return this.billing.updateStatus(invoiceId, status, manager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire the booking's currently-open prepaid invoice when the booking is
|
||||
* cancelled or rejected — the counterpart to the pay-window-expiry path
|
||||
* (which also calls {@link BillingService.expirePayable}). Stops a terminated
|
||||
* booking from leaving a payable invoice open. No-op when the booking has no
|
||||
* open invoice (never invoiced, already paid/cancelled/expired). Pass a
|
||||
* caller `manager` to enlist in its transaction.
|
||||
*/
|
||||
expireOpenInvoices(
|
||||
bookingId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
return this.billing.expirePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
"PREPAID",
|
||||
manager,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance a booking once its prepaid invoice settles — the domain side-effect
|
||||
* of payment, relocated out of the payment service: the booking becomes PAID
|
||||
@@ -138,7 +158,32 @@ export class BookingInvoiceService {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// if (booking.paymentStatus === "PAID") return;
|
||||
|
||||
// Idempotency + state-machine guard (restored). The prepaid-invoice paid
|
||||
// event can be delivered more than once (retries / re-emit), and a booking
|
||||
// may have moved on or been terminated between invoicing and settlement.
|
||||
// Only advance one that is still awaiting payment: no-op when already PAID,
|
||||
// and refuse to advance a booking in a terminal/advanced status
|
||||
// (CANCELLED/REJECTED/EXPIRED or already past the payment gate) so we never
|
||||
// rewrite its status or re-run allocation.
|
||||
if (booking.paymentStatus === "PAID" || booking.status === "PAID") {
|
||||
return;
|
||||
}
|
||||
const TERMINAL_OR_ADVANCED_STATUSES: string[] = [
|
||||
"CANCELLED",
|
||||
"REJECTED",
|
||||
"EXPIRED",
|
||||
"IN_TRANSIT",
|
||||
"ARRIVED",
|
||||
"COMPLETED",
|
||||
"CONTRACT_CLOSED",
|
||||
];
|
||||
if (TERMINAL_OR_ADVANCED_STATUSES.includes(booking.status)) {
|
||||
this.logger.warn(
|
||||
`Skipping advance of booking ${bookingId} on payment: status ${booking.status} is terminal/advanced.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (mg) => {
|
||||
await mg.update(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import {
|
||||
AppliedCargoModifier,
|
||||
@@ -128,11 +129,18 @@ export class BookingPricingService {
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
|
||||
// H15: a booking created under a contract prices from that contract's FROZEN
|
||||
// rate snapshots (the agreed rates), not the live rate of the day. Loaded
|
||||
// once and threaded through the line builders; each rate code that has a
|
||||
// snapshot uses it, and any code without one falls back to the live rate.
|
||||
// Non-contract bookings resolve to null and keep the live-rate path.
|
||||
const frozenRates = await this.loadFrozenContractRates(booking);
|
||||
|
||||
const lineItems: PriceLineItemDto[] = [];
|
||||
let total = 0;
|
||||
|
||||
const { lineItems: baseLines, usedRates: baseRates } =
|
||||
await this.computeBaseRailLinesWithRates(booking, evalInput);
|
||||
await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
|
||||
for (const line of baseLines) {
|
||||
lineItems.push(line);
|
||||
total += line.amount;
|
||||
@@ -141,7 +149,7 @@ export class BookingPricingService {
|
||||
// First / last mile trucking — billed per the rate's unit (km / container /
|
||||
// ton / flat), only for legs the booking actually carries.
|
||||
const { lineItems: mileLines, usedRates: mileRates } =
|
||||
await this.computeFirstLastMileLines(booking, evalInput);
|
||||
await this.computeFirstLastMileLines(booking, evalInput, frozenRates);
|
||||
for (const line of mileLines) {
|
||||
lineItems.push(line);
|
||||
total += line.amount;
|
||||
@@ -153,15 +161,14 @@ export class BookingPricingService {
|
||||
|
||||
for (const mod of ruleResult.appliedModifiers) {
|
||||
const usdAmount = mod.calculatedAmount;
|
||||
const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
|
||||
const rate = rateById.get(mod.rateId);
|
||||
const unit = rate?.rateUnit ?? 'FLAT';
|
||||
const unitUsd = rate ? Number(rate.rateValue) : usdAmount;
|
||||
const unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||||
// Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an
|
||||
// explicit trigger (e.g. overweight tons) wins when present; otherwise
|
||||
// derive from total ÷ unit price.
|
||||
// derive from total ÷ unit price (the live unit price — a count, not a
|
||||
// currency amount, so it is snapshot-independent).
|
||||
const quantity =
|
||||
unit === 'FLAT' || unit === 'PER_INVOICE'
|
||||
? 1
|
||||
@@ -171,6 +178,26 @@ export class BookingPricingService {
|
||||
? Math.max(1, Math.round(usdAmount / unitUsd))
|
||||
: 1;
|
||||
|
||||
// H15: bill the frozen contract surcharge rate (already in the booking
|
||||
// currency) when this code has a snapshot; else keep the live amount.
|
||||
const frozen = this.frozenRateByCode(
|
||||
frozenRates,
|
||||
mod.surchargeCode,
|
||||
paymentCurrency,
|
||||
);
|
||||
const unitAmount = frozen
|
||||
? Number(frozen.unitPrice)
|
||||
: isEtbBooking
|
||||
? Math.round(unitUsd * usdToEtb)
|
||||
: unitUsd;
|
||||
const convertedAmount = frozen
|
||||
? isEtbBooking
|
||||
? Math.round(unitAmount * quantity)
|
||||
: unitAmount * quantity
|
||||
: isEtbBooking
|
||||
? Math.round(usdAmount * usdToEtb)
|
||||
: usdAmount;
|
||||
|
||||
const item: PriceLineItemDto = {
|
||||
code: mod.surchargeCode,
|
||||
description: surchargeLabel(mod.surchargeCode),
|
||||
@@ -328,6 +355,11 @@ export class BookingPricingService {
|
||||
// reefer quantity) applies the REEFER surcharge even for non-reefer
|
||||
// container types. ORed with per-container reefer in the engine.
|
||||
isReefer: booking.isReefer === true || (booking.isReefer as unknown) === 'true',
|
||||
// Empty-container return service (container freight only) — bills the
|
||||
// WITH_RETURN surcharge per container, like hazard/reefer.
|
||||
withReturn:
|
||||
booking.freightType === 'CONTAINER' &&
|
||||
booking.equipmentReturn === 'WITH_RETURN',
|
||||
isGovernment: booking.isGovernment,
|
||||
allowConsolidation,
|
||||
shippingLineId: booking.shippingLineId,
|
||||
@@ -419,6 +451,7 @@ export class BookingPricingService {
|
||||
private async computeBaseRailLinesWithRates(
|
||||
booking: Booking,
|
||||
evalInput: BookingEvaluationInput,
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null = null,
|
||||
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
@@ -448,15 +481,35 @@ export class BookingPricingService {
|
||||
if (!rate) continue;
|
||||
|
||||
usedRatesMap.set(rate.id, rate);
|
||||
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
const unitUsd = Number(rate.rateValue);
|
||||
// H15: frozen contract rate for this container size, when present — its
|
||||
// unitPrice is already in the booking currency (no USD→currency convert).
|
||||
const frozen = await this.frozenRateForContainer(
|
||||
frozenRates,
|
||||
container.containerTypeId,
|
||||
paymentCurrency,
|
||||
);
|
||||
let amount: number;
|
||||
let unitAmount: number;
|
||||
if (frozen) {
|
||||
unitAmount = Number(frozen.unitPrice);
|
||||
amount = this.amountForUnit(
|
||||
rate.rateUnit,
|
||||
unitAmount,
|
||||
container.quantity,
|
||||
wagonCount,
|
||||
);
|
||||
} else {
|
||||
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||||
}
|
||||
const label = await this.containerTypeLabel(container.containerTypeId);
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: `${label} rail freight`,
|
||||
amount,
|
||||
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
|
||||
unitAmount,
|
||||
unit: rate.rateUnit,
|
||||
quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount),
|
||||
currency: paymentCurrency,
|
||||
@@ -472,14 +525,31 @@ export class BookingPricingService {
|
||||
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const quantity =
|
||||
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
|
||||
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
const unitUsd = Number(fallback.rateValue);
|
||||
// H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
|
||||
const frozen = isBulk
|
||||
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency)
|
||||
: null;
|
||||
let amount: number;
|
||||
let unitAmount: number;
|
||||
if (frozen) {
|
||||
unitAmount = Number(frozen.unitPrice);
|
||||
amount = this.amountForUnit(
|
||||
fallback.rateUnit,
|
||||
unitAmount,
|
||||
quantity,
|
||||
wagonCount,
|
||||
);
|
||||
} else {
|
||||
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
|
||||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||||
}
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: isBulk ? 'Bulk rail freight' : 'Container rail freight',
|
||||
amount,
|
||||
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
|
||||
unitAmount,
|
||||
unit: fallback.rateUnit,
|
||||
quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
|
||||
currency: paymentCurrency,
|
||||
@@ -503,6 +573,7 @@ export class BookingPricingService {
|
||||
private async computeFirstLastMileLines(
|
||||
booking: Booking,
|
||||
evalInput: BookingEvaluationInput,
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null = null,
|
||||
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
|
||||
const legs: Array<{ rateType: 'FIRST_MILE' | 'LAST_MILE'; label: string; active: boolean }> = [
|
||||
{
|
||||
@@ -560,18 +631,34 @@ export class BookingPricingService {
|
||||
break;
|
||||
}
|
||||
|
||||
const usdAmount = value * quantity;
|
||||
// H15: frozen mile rate (already in booking currency) when the contract
|
||||
// has one; else the live USD rate converted as before.
|
||||
const frozen = this.frozenRateByCode(
|
||||
frozenRates,
|
||||
leg.rateType,
|
||||
paymentCurrency,
|
||||
);
|
||||
let amount: number;
|
||||
let unitAmount: number;
|
||||
if (frozen) {
|
||||
unitAmount = Number(frozen.unitPrice);
|
||||
amount = isEtbBooking
|
||||
? Math.round(unitAmount * quantity)
|
||||
: unitAmount * quantity;
|
||||
} else {
|
||||
const usdAmount = value * quantity;
|
||||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? Math.round(value * usdToEtb) : value;
|
||||
}
|
||||
// Skip legs that resolve to nothing (zero rate, or zero km / count / tons).
|
||||
if (!(usdAmount > 0)) continue;
|
||||
if (!(amount > 0)) continue;
|
||||
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
const unitUsd = value;
|
||||
usedRatesMap.set(rate.id, rate);
|
||||
lines.push({
|
||||
code: leg.rateType,
|
||||
description: leg.label,
|
||||
amount,
|
||||
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
|
||||
unitAmount,
|
||||
unit: rate.rateUnit,
|
||||
quantity,
|
||||
currency: paymentCurrency,
|
||||
@@ -644,21 +731,93 @@ export class BookingPricingService {
|
||||
}
|
||||
|
||||
private amountForRate(rate: Rate, quantity: number, wagonCount: number): number {
|
||||
const value = Number(rate.rateValue);
|
||||
switch (rate.rateUnit) {
|
||||
return this.amountForUnit(
|
||||
rate.rateUnit,
|
||||
Number(rate.rateValue),
|
||||
quantity,
|
||||
wagonCount,
|
||||
);
|
||||
}
|
||||
|
||||
/** Apply a unit value by rate unit — shared by live and frozen-snapshot lines. */
|
||||
private amountForUnit(
|
||||
rateUnit: string,
|
||||
unitValue: number,
|
||||
quantity: number,
|
||||
wagonCount: number,
|
||||
): number {
|
||||
switch (rateUnit) {
|
||||
case 'PER_CONTAINER':
|
||||
return value * quantity;
|
||||
return unitValue * quantity;
|
||||
case 'PER_WAGON':
|
||||
return value * wagonCount;
|
||||
return unitValue * wagonCount;
|
||||
case 'PER_TON':
|
||||
return value * quantity;
|
||||
return unitValue * quantity;
|
||||
case 'FLAT':
|
||||
return value;
|
||||
return unitValue;
|
||||
default:
|
||||
return value * quantity;
|
||||
return unitValue * quantity;
|
||||
}
|
||||
}
|
||||
|
||||
// ── H15: frozen contract rate snapshots ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load a contract's frozen rate snapshots into a by-rate-code lookup, or null
|
||||
* for a non-contract booking (or a contract with no snapshots). The pricing
|
||||
* line builders prefer a matching snapshot's unit price over the live rate.
|
||||
*/
|
||||
private async loadFrozenContractRates(
|
||||
booking: Booking,
|
||||
): Promise<Map<string, ContractRateSnapshot> | null> {
|
||||
if (!booking.contractId) return null;
|
||||
const snapshots = await this.bookingsRepository.findContractRateSnapshots(
|
||||
booking.contractId,
|
||||
);
|
||||
if (!snapshots.length) return null;
|
||||
const byCode = new Map<string, ContractRateSnapshot>();
|
||||
for (const snap of snapshots) byCode.set(snap.rateCode, snap);
|
||||
return byCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The frozen snapshot for a rate code, or null when there is none, its price
|
||||
* is negative, or it is in a different currency than the booking (in which
|
||||
* case the live-rate path is safer than a mis-converted frozen price).
|
||||
*/
|
||||
private frozenRateByCode(
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||
code: string,
|
||||
bookingCurrency: string,
|
||||
): ContractRateSnapshot | null {
|
||||
const snap = frozenRates?.get(code);
|
||||
if (!snap) return null;
|
||||
if (snap.currency !== bookingCurrency) return null;
|
||||
if (!(Number(snap.unitPrice) >= 0)) return null;
|
||||
return snap;
|
||||
}
|
||||
|
||||
/**
|
||||
* The frozen base-rail snapshot for a container line, matched by the
|
||||
* container's size (CONTAINER_20FT / CONTAINER_40FT — the codes
|
||||
* ContractPricingService freezes). Null when there is no snapshot.
|
||||
*/
|
||||
private async frozenRateForContainer(
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||
containerTypeId: string,
|
||||
bookingCurrency: string,
|
||||
): Promise<ContractRateSnapshot | null> {
|
||||
if (!frozenRates) return null;
|
||||
let sizeFt: number | null = null;
|
||||
try {
|
||||
sizeFt = Number((await this.containerTypesService.findById(containerTypeId)).sizeFt) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!sizeFt) return null;
|
||||
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency);
|
||||
}
|
||||
|
||||
private lineItemsSignature(items: PriceLineItemDto[]): string {
|
||||
return JSON.stringify(
|
||||
[...items]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
@@ -533,6 +534,10 @@ export class BookingTransitionService {
|
||||
"REJECTION",
|
||||
);
|
||||
|
||||
// Stop the open-invoice leak: a cancelled booking must not leave a payable
|
||||
// invoice open. Mirror the pay-window-expiry path (billing.expirePayable).
|
||||
await this.invoiceService.expireOpenInvoices(bookingId);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: "CANCELLED",
|
||||
} as never);
|
||||
@@ -561,6 +566,10 @@ export class BookingTransitionService {
|
||||
"REJECTION",
|
||||
);
|
||||
|
||||
// Stop the open-invoice leak: a rejected booking must not leave a payable
|
||||
// invoice open. Mirror the pay-window-expiry path (billing.expirePayable).
|
||||
await this.invoiceService.expireOpenInvoices(bookingId);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: "REJECTED",
|
||||
} as never);
|
||||
@@ -714,6 +723,11 @@ export class BookingTransitionService {
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (booking.status === "AWAITING_CLEARANCE_PAYMENT") {
|
||||
throw new ConflictException(
|
||||
"The customs clearance service fee for this shipment has not been paid yet — pay it from the portal to unlock document upload.",
|
||||
);
|
||||
}
|
||||
assertBookingStatus(booking, [
|
||||
"AWAITING_DOCUMENTS",
|
||||
"DOCUMENTS_UNDER_REVIEW",
|
||||
@@ -1003,18 +1017,26 @@ export class BookingTransitionService {
|
||||
}
|
||||
|
||||
// The binding shipment day must have at least one OPEN departure on the
|
||||
// route — only schedule-backed days are selectable. The batch engine
|
||||
// assigns the specific train within that (route, day) pool later.
|
||||
const hasDeparture = await this.bookingsService.hasOpenDepartureOnDay(
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
eatDay(date),
|
||||
);
|
||||
// route — only schedule-backed days are selectable — AND some departure
|
||||
// that day must be able to physically carry this cargo type (wagon-TYPE
|
||||
// gate; quantity never blocks — oversized bookings get a partial split
|
||||
// offer). The batch engine assigns the specific train within that
|
||||
// (route, day) pool later.
|
||||
const { hasDeparture, hasCompatible } =
|
||||
await this.bookingsService.checkDayCompatibilityForBooking(
|
||||
booking,
|
||||
eatDay(date),
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
"No departures available on the selected day for this route",
|
||||
);
|
||||
}
|
||||
if (!hasCompatible) {
|
||||
throw new BadRequestException(
|
||||
"No wagon on the selected day can carry this cargo type — please choose another day",
|
||||
);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
|
||||
@@ -348,6 +348,28 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id/available-days')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)',
|
||||
})
|
||||
async availableDays(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
|
||||
) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
booking,
|
||||
);
|
||||
}
|
||||
return this.bookingsService.availableDaysForBooking(id);
|
||||
}
|
||||
|
||||
@Get(':id/mile-summary')
|
||||
@ApiOperation({
|
||||
summary: 'First/last-mile operational summary for a booking (customer-safe)',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQuer
|
||||
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
import { ContractRoute } from '../contracts/entities/contract-route.entity';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
@@ -200,6 +201,19 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
return Number(route?.km ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Frozen contract unit-rate snapshots for a contract (H15). A booking created
|
||||
* under a contract prices from these agreed, frozen rates rather than the live
|
||||
* rate of the day; the pricing service matches them by rate code.
|
||||
*/
|
||||
findContractRateSnapshots(
|
||||
contractId: string,
|
||||
): Promise<ContractRateSnapshot[]> {
|
||||
return this.dataSource
|
||||
.getRepository(ContractRateSnapshot)
|
||||
.find({ where: { contractId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
||||
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
|
||||
@@ -217,10 +231,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
quantity: number;
|
||||
containersPerWagon: number;
|
||||
},
|
||||
manager?: EntityManager,
|
||||
): Promise<Booking | null> {
|
||||
const { containerTypeId, quantity, containersPerWagon: perWagon } = slot;
|
||||
|
||||
const qb = this.repository
|
||||
const repo = manager ? manager.getRepository(Booking) : this.repository;
|
||||
const qb = repo
|
||||
.createQueryBuilder('b')
|
||||
.innerJoinAndSelect('b.bookingContainers', 'bc')
|
||||
.innerJoin('bc.containerType', 'ct')
|
||||
@@ -257,7 +273,18 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
);
|
||||
}
|
||||
|
||||
return qb.orderBy('b.createdAt', 'ASC').getOne();
|
||||
qb.orderBy('b.createdAt', 'ASC');
|
||||
|
||||
// H9: under the caller's transaction, take a write lock on the matched
|
||||
// partner booking row (FOR UPDATE OF b — booking rows only, not the joined
|
||||
// reference tables) so a concurrent consolidation cannot claim the same
|
||||
// partner between this find and the pair write. Only when a transaction
|
||||
// manager is supplied — a pessimistic lock requires an open transaction.
|
||||
if (manager) {
|
||||
qb.setLock('pessimistic_write', undefined, ['b']);
|
||||
}
|
||||
|
||||
return qb.getOne();
|
||||
}
|
||||
|
||||
/** Try each partial-wagon line until a complementary partner booking is found. */
|
||||
@@ -268,9 +295,14 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
quantity: number;
|
||||
containersPerWagon: number;
|
||||
}>,
|
||||
manager?: EntityManager,
|
||||
): Promise<Booking | null> {
|
||||
for (const slot of slots) {
|
||||
const partner = await this.findComplementaryConsolidationPartner(booking, slot);
|
||||
const partner = await this.findComplementaryConsolidationPartner(
|
||||
booking,
|
||||
slot,
|
||||
manager,
|
||||
);
|
||||
if (partner) return partner;
|
||||
}
|
||||
return null;
|
||||
@@ -308,6 +340,63 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
} as never);
|
||||
}
|
||||
|
||||
/**
|
||||
* Race-safe pairing (H9): the transactional counterpart of
|
||||
* {@link pairConsolidation}. Must run inside the caller's transaction
|
||||
* (`manager`), which should already hold the partner-row write lock taken by
|
||||
* {@link findComplementaryConsolidationPartner}. Re-reads both rows and
|
||||
* re-asserts `consolidationPartnerId IS NULL` on each before writing; returns
|
||||
* `false` (no write) when either booking was already paired by a concurrent
|
||||
* flow, so the caller can fall back to parking.
|
||||
*/
|
||||
async pairConsolidationIfUnpaired(
|
||||
bookingId: string,
|
||||
partnerId: string,
|
||||
manager: EntityManager,
|
||||
): Promise<boolean> {
|
||||
const repo = manager.getRepository(Booking);
|
||||
// Sequential (one connection per transaction) — never Promise.all here.
|
||||
const booking = await repo.findOne({
|
||||
where: { id: bookingId },
|
||||
select: {
|
||||
id: true,
|
||||
consolidationPartnerId: true,
|
||||
consolidationResumeStatus: true,
|
||||
},
|
||||
});
|
||||
const partner = await repo.findOne({
|
||||
where: { id: partnerId },
|
||||
select: {
|
||||
id: true,
|
||||
consolidationPartnerId: true,
|
||||
consolidationResumeStatus: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Re-assert both are still unpaired before writing (the partner row is held
|
||||
// under the finder's write lock, so its state is stable here).
|
||||
if (
|
||||
!booking ||
|
||||
!partner ||
|
||||
booking.consolidationPartnerId != null ||
|
||||
partner.consolidationPartnerId != null
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await repo.update(bookingId, {
|
||||
consolidationPartnerId: partnerId,
|
||||
status: booking.consolidationResumeStatus ?? 'SUBMITTED',
|
||||
consolidationResumeStatus: null,
|
||||
} as never);
|
||||
await repo.update(partnerId, {
|
||||
consolidationPartnerId: bookingId,
|
||||
status: partner.consolidationResumeStatus ?? 'SUBMITTED',
|
||||
consolidationResumeStatus: null,
|
||||
} as never);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Park a booking that needs consolidation but has no partner yet. The optional
|
||||
* resumeStatus is where the booking returns once it pairs — pass it for a
|
||||
|
||||
@@ -508,13 +508,28 @@ export class BookingsService {
|
||||
return { booking, messages };
|
||||
}
|
||||
|
||||
const partner = await this.bookingsRepository.findConsolidationPartner(
|
||||
booking,
|
||||
slots,
|
||||
);
|
||||
// H9: find + pair must be atomic. Run both inside one transaction where the
|
||||
// finder holds a write lock on the candidate partner row and pairing
|
||||
// re-asserts both rows are still unpaired before writing — otherwise two
|
||||
// concurrent bookings can claim the same partner (or pair an
|
||||
// already-paired booking). `didPair` is false when a concurrent flow won
|
||||
// the partner, in which case we fall through to parking below.
|
||||
const partner = await this.dataSource.transaction(async (manager) => {
|
||||
const candidate = await this.bookingsRepository.findConsolidationPartner(
|
||||
booking,
|
||||
slots,
|
||||
manager,
|
||||
);
|
||||
if (!candidate) return null;
|
||||
const didPair = await this.bookingsRepository.pairConsolidationIfUnpaired(
|
||||
booking.id,
|
||||
candidate.id,
|
||||
manager,
|
||||
);
|
||||
return didPair ? candidate : null;
|
||||
});
|
||||
|
||||
if (partner) {
|
||||
await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
|
||||
const paired = await this.findById(booking.id);
|
||||
messages.push(
|
||||
this.consolidationService.describePaired(partner.reference, slots),
|
||||
@@ -653,22 +668,37 @@ export class BookingsService {
|
||||
} else if (dto.scheduledDate) {
|
||||
// A real (binding) scheduledDate was supplied (e.g. staff pinning a day
|
||||
// directly). Require that the route has at least one OPEN departure on
|
||||
// that EAT day. The booking wizard does NOT send scheduledDate at creation
|
||||
// — it captures a non-binding estimatedShipmentDate instead, and the
|
||||
// binding day is chosen later at the operation-request step. General
|
||||
// contracts also skip this (each drawdown order validates its own day).
|
||||
// that EAT day AND that some departure that day can physically carry the
|
||||
// cargo (wagon-TYPE gate — quantity never blocks; oversized bookings get
|
||||
// a partial split offer later). The booking wizard does NOT send
|
||||
// scheduledDate at creation — it captures a non-binding
|
||||
// estimatedShipmentDate instead, and the binding day is chosen later at
|
||||
// the operation-request step. General contracts also skip this (each
|
||||
// drawdown order validates its own day).
|
||||
const day = eatDay(new Date(dto.scheduledDate));
|
||||
const hasDeparture =
|
||||
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
||||
const { hasDeparture, hasCompatible } =
|
||||
await this.trainSchedulingService.checkDayCargoCompatibility(
|
||||
dto.originYardId,
|
||||
dto.destinationYardId,
|
||||
day,
|
||||
{
|
||||
freightType: dto.freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId: dto.cargoTypeId,
|
||||
containerTypeIds: (dto.containers ?? [])
|
||||
.map((c) => c.containerTypeId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
},
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
'No departures available on the selected day for this route',
|
||||
);
|
||||
}
|
||||
if (!hasCompatible) {
|
||||
throw new BadRequestException(
|
||||
'No wagon on the selected day can carry this cargo type — please choose another day',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const containers = dto.containers ?? [];
|
||||
@@ -1149,6 +1179,52 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Cargo identity of a booking for the wagon-TYPE compatibility gate. */
|
||||
private cargoIdentityOf(booking: Booking): {
|
||||
freightType: 'CONTAINER' | 'BULK';
|
||||
cargoTypeId?: string | null;
|
||||
containerTypeIds?: string[];
|
||||
} {
|
||||
return {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId: booking.cargoTypeId ?? null,
|
||||
containerTypeIds: (booking.bookingContainers ?? [])
|
||||
.map((line) => line.containerTypeId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Day gate for a specific booking: OPEN departure exists AND some departure
|
||||
* that day can physically carry the booking's cargo/container type.
|
||||
* Quantity never blocks — oversized bookings get a partial split offer.
|
||||
*/
|
||||
async checkDayCompatibilityForBooking(
|
||||
booking: Booking,
|
||||
day: string,
|
||||
): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> {
|
||||
return this.trainSchedulingService.checkDayCargoCompatibility(
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
day,
|
||||
this.cargoIdentityOf(booking),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Days the customer may pick for THIS booking (operation-request step):
|
||||
* cargo-aware — only days whose departures can carry the booking's cargo
|
||||
* type. Returns days only, no capacity counts.
|
||||
*/
|
||||
async availableDaysForBooking(bookingId: string): Promise<{ days: string[] }> {
|
||||
const booking = await this.findById(bookingId);
|
||||
return this.trainSchedulingService.getAvailableDaysForCargo({
|
||||
originYardId: booking.originYardId,
|
||||
destinationYardId: booking.destinationYardId,
|
||||
...this.cargoIdentityOf(booking),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched version of the findById flag: marks each page item whose booking
|
||||
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
|
||||
|
||||
@@ -41,6 +41,10 @@ export class BookingContainer extends BaseEntity {
|
||||
@Column({ name: 'reefer_quantity', type: 'smallint', default: 0 })
|
||||
reeferQuantity!: number;
|
||||
|
||||
/** How many units of this line ship with empty-container return (≤ quantity). */
|
||||
@Column({ name: 'return_quantity', type: 'smallint', default: 0 })
|
||||
returnQuantity!: number;
|
||||
|
||||
@Column({ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||
vgmPerUnitTons!: number;
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ export const BOOKING_STATUSES = [
|
||||
'CONTRACT_ACTIVE',
|
||||
'CONTRACT_CLOSED',
|
||||
// Post counter-sign document-clearance gate (GL workflow).
|
||||
'AWAITING_CLEARANCE_PAYMENT', // clearance fee invoiced, unpaid — docs locked
|
||||
'AWAITING_DOCUMENTS',
|
||||
'DOCUMENTS_UNDER_REVIEW',
|
||||
'CLEARANCE_READY',
|
||||
@@ -86,6 +87,7 @@ export const SCHEDULING_STATUSES = [
|
||||
SchedulingStatus.Eligible,
|
||||
SchedulingStatus.Scheduled,
|
||||
SchedulingStatus.Dispatched,
|
||||
SchedulingStatus.WaitingForWagon,
|
||||
] as const;
|
||||
|
||||
export type BookingSchedulingStatus = (typeof SCHEDULING_STATUSES)[number];
|
||||
@@ -520,6 +522,10 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true })
|
||||
clearanceCurrentPhase?: string | null;
|
||||
|
||||
/** When the prepaid customs clearance service fee settled (GENERAL + customs). */
|
||||
@Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
|
||||
clearanceFeePaidAt?: Date | null;
|
||||
|
||||
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
|
||||
dutyRequired?: boolean | null;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
|
||||
import { FindOptionsOrder, FindOptionsWhere, ILike, Not, Repository } from 'typeorm';
|
||||
import { CreateCargoDto } from './dto/create-cargo.dto';
|
||||
import { UpdateCargoDto } from './dto/update-cargo.dto';
|
||||
import { LoadCargoDto } from './dto/load-cargo.dto';
|
||||
@@ -32,6 +32,7 @@ export class CargoesService {
|
||||
if (!container) {
|
||||
throw new NotFoundException(`Container ${dto.containerId} not found`);
|
||||
}
|
||||
await this.assertContainerCapacity(container, dto.weight);
|
||||
|
||||
if (dto.cargoTypeId) {
|
||||
const cargoType = await this.cargoTypeRepo.findOne({
|
||||
@@ -121,6 +122,10 @@ export class CargoesService {
|
||||
throw new ConflictException('Cargo already loaded or delivered');
|
||||
}
|
||||
|
||||
if (cargo.container) {
|
||||
await this.assertContainerCapacity(cargo.container, dto.weight, cargo.id);
|
||||
}
|
||||
|
||||
cargo.status = 'LOADED';
|
||||
cargo.loadedAt = new Date();
|
||||
cargo.quantity = dto.quantity;
|
||||
@@ -137,13 +142,31 @@ export class CargoesService {
|
||||
}
|
||||
|
||||
async unloadCargo(id: string): Promise<Cargo> {
|
||||
const cargo = await this.findById(id);
|
||||
const cargo = await this.cargoRepo.findOne({
|
||||
where: { id },
|
||||
relations: { container: true },
|
||||
});
|
||||
if (!cargo) throw new NotFoundException('Cargo not found');
|
||||
if (cargo.status !== 'LOADED') {
|
||||
throw new ConflictException('Cargo is not loaded');
|
||||
}
|
||||
cargo.status = 'UNLOADED';
|
||||
cargo.unloadedAt = new Date();
|
||||
return this.cargoRepo.save(cargo);
|
||||
const saved = await this.cargoRepo.save(cargo);
|
||||
|
||||
// loadCargo flips the container to LOADED; on unload, free it back to
|
||||
// AVAILABLE once no other LOADED cargo still references the container.
|
||||
if (cargo.containerId != null && cargo.container) {
|
||||
const remaining = await this.cargoRepo.count({
|
||||
where: { containerId: cargo.containerId, status: 'LOADED', id: Not(cargo.id) },
|
||||
});
|
||||
if (remaining === 0) {
|
||||
cargo.container.status = 'AVAILABLE';
|
||||
await this.containerRepo.save(cargo.container);
|
||||
}
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
async deliverCargo(id: string, dto?: DeliverCargoDto): Promise<Cargo> {
|
||||
@@ -161,10 +184,13 @@ export class CargoesService {
|
||||
if (dto?.receiverName) cargo.receiverName = dto.receiverName;
|
||||
if (dto?.deliveryRemarks) cargo.deliveryRemarks = dto.deliveryRemarks;
|
||||
|
||||
// Exclude the cargo being delivered — it is still LOADED in the DB until the
|
||||
// save below, so counting it would keep `remaining` > 0 and never free the
|
||||
// container.
|
||||
const remaining =
|
||||
cargo.containerId != null
|
||||
? await this.cargoRepo.count({
|
||||
where: { containerId: cargo.containerId, status: 'LOADED' },
|
||||
where: { containerId: cargo.containerId, status: 'LOADED', id: Not(cargo.id) },
|
||||
})
|
||||
: 0;
|
||||
if (remaining === 0 && cargo.container) {
|
||||
@@ -174,4 +200,34 @@ export class CargoesService {
|
||||
|
||||
return this.cargoRepo.save(cargo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject when placing `newWeightKg` on the container would exceed its max gross
|
||||
* weight. All values are kilograms: cargoes.weight is kg (entity), and the
|
||||
* container's tare_weight / max_gross_weight are kg (entity). Capacity check is
|
||||
* tare + already-LOADED cargo + new cargo <= max gross weight.
|
||||
*/
|
||||
private async assertContainerCapacity(
|
||||
container: Container,
|
||||
newWeightKg: number,
|
||||
excludeCargoId?: string,
|
||||
): Promise<void> {
|
||||
const qb = this.cargoRepo
|
||||
.createQueryBuilder('c')
|
||||
.select('COALESCE(SUM(c.weight), 0)', 'sum')
|
||||
.where('c.containerId = :containerId', { containerId: container.id })
|
||||
.andWhere('c.status = :status', { status: 'LOADED' });
|
||||
if (excludeCargoId) qb.andWhere('c.id != :excludeCargoId', { excludeCargoId });
|
||||
const raw = await qb.getRawOne<{ sum: string }>();
|
||||
|
||||
const loadedKg = Number(raw?.sum ?? 0);
|
||||
const tareKg = Number(container.tareWeight);
|
||||
const maxGrossKg = Number(container.maxGrossWeight);
|
||||
if (tareKg + loadedKg + newWeightKg > maxGrossKg) {
|
||||
throw new BadRequestException(
|
||||
`Cargo weight exceeds container capacity: tare ${tareKg}kg + loaded ${loadedKg}kg + ` +
|
||||
`new ${newWeightKg}kg > max gross ${maxGrossKg}kg`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// apps/edr-freight-api/src/modules/container-management/containers.service.ts
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
|
||||
import { DataSource, FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
|
||||
import { CreateContainerDto } from './dto/create-container.dto';
|
||||
import { UpdateContainerDto } from './dto/update-container.dto';
|
||||
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
|
||||
@@ -18,6 +18,7 @@ export class ContainersService {
|
||||
private readonly wagonRepo: Repository<Wagon>, // ✅ use raw repository
|
||||
@InjectRepository(ContainerType)
|
||||
private readonly containerTypeRepo: Repository<ContainerType>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateContainerDto): Promise<Container> {
|
||||
@@ -115,24 +116,42 @@ export class ContainersService {
|
||||
if (container.status === 'LOADED') {
|
||||
throw new ConflictException('Cannot reassign a loaded container');
|
||||
}
|
||||
// Reject a container that is already placed on a wagon — it must be
|
||||
// unassigned first, otherwise it would silently jump to another wagon.
|
||||
if (container.wagonId) {
|
||||
throw new ConflictException(
|
||||
`Container ${containerId} is already assigned to wagon ${container.wagonId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } });
|
||||
if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`);
|
||||
|
||||
let position: number | null = dto.position ?? null;
|
||||
if (position === null) {
|
||||
const maxPos = await this.containerRepo
|
||||
.createQueryBuilder('c')
|
||||
.select('MAX(c.position)', 'max')
|
||||
.where('c.wagonId = :wagonId', { wagonId: wagon.id })
|
||||
.getRawOne();
|
||||
position = (maxPos?.max ?? 0) + 1;
|
||||
}
|
||||
// The MAX(position)+1 allocation is check-then-act: two concurrent assigns can
|
||||
// read the same MAX and collide on the same position. Do the read + save inside
|
||||
// one transaction to narrow the race window.
|
||||
// TODO: add a unique (wagon_id, position) DB index so the database itself
|
||||
// rejects a colliding position even under concurrency.
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const containerRepo = manager.getRepository(Container);
|
||||
|
||||
container.wagonId = wagon.id;
|
||||
container.position = position;
|
||||
container.status = 'AVAILABLE';
|
||||
return this.containerRepo.save(container);
|
||||
let position: number | null = dto.position ?? null;
|
||||
if (position === null) {
|
||||
const maxPos = await containerRepo
|
||||
.createQueryBuilder('c')
|
||||
.select('MAX(c.position)', 'max')
|
||||
.where('c.wagonId = :wagonId', { wagonId: wagon.id })
|
||||
.getRawOne<{ max: number | null }>();
|
||||
position = (maxPos?.max ?? 0) + 1;
|
||||
}
|
||||
|
||||
container.wagonId = wagon.id;
|
||||
container.position = position;
|
||||
// Placing a container on a wagon does not make it AVAILABLE. The status enum
|
||||
// (AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED) has no ASSIGNED/ON_WAGON
|
||||
// state, so leave the existing status unchanged rather than forcing AVAILABLE.
|
||||
return containerRepo.save(container);
|
||||
});
|
||||
}
|
||||
|
||||
async unassignFromWagon(containerId: string): Promise<Container> {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { Injectable, Logger, UnprocessableEntityException } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Freight } from '@edr/types';
|
||||
|
||||
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { ContractPricingBreakdown } from './contract-pricing.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
|
||||
/** Invoice `type` for the contract-level fee (Path B ONE_TIME, after counter-sign). */
|
||||
export const CLEARANCE_CONTRACT_INVOICE_TYPE = 'CLEARANCE_CONTRACT';
|
||||
/** Invoice `type` for the per-shipment fee (Path B GENERAL, at shipment request). */
|
||||
export const CLEARANCE_BOOKING_INVOICE_TYPE = 'CLEARANCE_BOOKING';
|
||||
|
||||
/**
|
||||
* The prepaid customs clearance service fee (Path B) — the GL service charge,
|
||||
* separate from both freight (booking invoice) and duty/tax (paid offline).
|
||||
* Issued as its own `clearance`-source invoice and paid BEFORE the clearance
|
||||
* document step opens and before GL touches the file:
|
||||
* - ONE_TIME: once per contract, at staff counter-sign
|
||||
* (AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_CLEARANCE_DOCUMENTS);
|
||||
* - GENERAL: once per shipment request, on the initiated booking instance
|
||||
* (booking AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_DOCUMENTS).
|
||||
* The fee amount is the frozen CUSTOMS_CLEARANCE contract rate snapshot, so
|
||||
* customers pay what their contract shows, not the live rate of the day.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ClearanceFeeService {
|
||||
private readonly logger = new Logger(ClearanceFeeService.name);
|
||||
|
||||
constructor(
|
||||
private readonly billing: BillingService,
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly notifier: ContractNotifierService,
|
||||
) {}
|
||||
|
||||
/** The frozen flat fee for a contract; falls back to the pricing breakdown. */
|
||||
private async feeAmountOrNull(
|
||||
contract: Contract,
|
||||
): Promise<{ amount: number; currency: string } | null> {
|
||||
const snapshots = await this.contractsRepository.findRateSnapshots(contract.id);
|
||||
const snapshot = snapshots.find(
|
||||
(s) => s.isClearance || s.rateCode === 'CUSTOMS_CLEARANCE',
|
||||
);
|
||||
if (snapshot && Number(snapshot.unitPrice) > 0) {
|
||||
return { amount: Number(snapshot.unitPrice), currency: snapshot.currency };
|
||||
}
|
||||
const breakdown = contract.pricingBreakdown as ContractPricingBreakdown | null;
|
||||
const line = breakdown?.lineItems?.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||||
if (line && Number(line.unitPrice) > 0) {
|
||||
return { amount: Number(line.unitPrice), currency: breakdown!.currency };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async feeAmount(
|
||||
contract: Contract,
|
||||
): Promise<{ amount: number; currency: string }> {
|
||||
const fee = await this.feeAmountOrNull(contract);
|
||||
if (!fee) {
|
||||
throw new UnprocessableEntityException(
|
||||
`Contract ${contract.reference} has no frozen customs clearance fee — regenerate its price with a live CUSTOMS_CLEARANCE rate.`,
|
||||
);
|
||||
}
|
||||
return fee;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the payment gate applies. Skipped for government/unlinked
|
||||
* contracts (no company to bill — invoices require one, same rule the
|
||||
* booking invoice applies) and for legacy customs contracts frozen before
|
||||
* the fee existed (no CUSTOMS_CLEARANCE snapshot to bill from) — both keep
|
||||
* the pre-fee flow instead of dead-ending.
|
||||
*/
|
||||
async gateApplies(contract: Contract): Promise<boolean> {
|
||||
// Customs disabled → the prepay gate genuinely does not apply.
|
||||
if (!contract.customsClearingEnabled) return false;
|
||||
// No company to bill (government / unlinked) → the gate cannot raise an
|
||||
// invoice, so it stays out of the flow (same rule the booking invoice uses).
|
||||
if (!contract.companyId) return false;
|
||||
// M26: customs IS enabled and billable. A missing frozen fee line must NOT
|
||||
// silently waive the gate — that ships clearance for free. Hard-fail exactly
|
||||
// as price generation does when no CUSTOMS_CLEARANCE rate is configured, so a
|
||||
// missing fee blocks counter-sign / shipment instead of bypassing payment.
|
||||
if ((await this.feeAmountOrNull(contract)) === null) {
|
||||
throw new UnprocessableEntityException(
|
||||
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Issue (idempotently) the ONE_TIME contract-level fee invoice. */
|
||||
async issueForContract(contract: Contract): Promise<Invoice> {
|
||||
const existing = await this.billing.findPayable(
|
||||
Freight.InvoiceSource.Clearance,
|
||||
contract.id,
|
||||
CLEARANCE_CONTRACT_INVOICE_TYPE,
|
||||
);
|
||||
if (existing) return existing;
|
||||
|
||||
const { amount, currency } = await this.feeAmount(contract);
|
||||
const invoice = await this.billing.generateInvoice({
|
||||
source: Freight.InvoiceSource.Clearance,
|
||||
sourceId: contract.id,
|
||||
type: CLEARANCE_CONTRACT_INVOICE_TYPE,
|
||||
companyId: contract.companyId!,
|
||||
companyProfileId: contract.companyProfileId!,
|
||||
currency,
|
||||
lines: [
|
||||
{
|
||||
chargeType: 'CUSTOMS_CLEARANCE',
|
||||
description: `Customs clearance service fee — contract ${contract.reference}`,
|
||||
quantity: 1,
|
||||
unitRate: amount,
|
||||
amount,
|
||||
currency,
|
||||
},
|
||||
],
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
});
|
||||
this.notifier.clearanceFeeDue(contract, amount, currency);
|
||||
return invoice;
|
||||
}
|
||||
|
||||
/** Issue (idempotently) the GENERAL per-shipment fee invoice on the booking. */
|
||||
async issueForBooking(booking: Booking, contract: Contract): Promise<Invoice> {
|
||||
const existing = await this.billing.findPayable(
|
||||
Freight.InvoiceSource.Clearance,
|
||||
booking.id,
|
||||
CLEARANCE_BOOKING_INVOICE_TYPE,
|
||||
);
|
||||
if (existing) return existing;
|
||||
|
||||
const { amount, currency } = await this.feeAmount(contract);
|
||||
const invoice = await this.billing.generateInvoice({
|
||||
source: Freight.InvoiceSource.Clearance,
|
||||
sourceId: booking.id,
|
||||
type: CLEARANCE_BOOKING_INVOICE_TYPE,
|
||||
companyId: booking.companyId ?? contract.companyId!,
|
||||
companyProfileId: booking.companyProfileId ?? contract.companyProfileId!,
|
||||
currency,
|
||||
lines: [
|
||||
{
|
||||
chargeType: 'CUSTOMS_CLEARANCE',
|
||||
description: `Customs clearance service fee — shipment ${booking.reference}`,
|
||||
quantity: 1,
|
||||
unitRate: amount,
|
||||
amount,
|
||||
currency,
|
||||
},
|
||||
],
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
});
|
||||
this.notifier.clearanceFeeDue(contract, amount, currency, booking.reference);
|
||||
return invoice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settlement branch point for `clearance`-source invoices: unlock the
|
||||
* document-upload step the fee was gating. Idempotent — a replayed event on
|
||||
* an already-advanced contract/booking is a no-op.
|
||||
*/
|
||||
@OnEvent('clearance.invoice.paid')
|
||||
async onClearanceInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
this.logger.log(
|
||||
`clearance.invoice.paid (${payload.type}) for ${payload.sourceId} from ${payload.invoiceId}`,
|
||||
);
|
||||
switch (payload.type) {
|
||||
case CLEARANCE_CONTRACT_INVOICE_TYPE:
|
||||
await this.advanceContract(payload.sourceId);
|
||||
break;
|
||||
case CLEARANCE_BOOKING_INVOICE_TYPE:
|
||||
await this.advanceBooking(payload.sourceId);
|
||||
break;
|
||||
default:
|
||||
this.logger.warn(
|
||||
`Unhandled clearance invoice type "${payload.type}" paid (${payload.invoiceId})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async advanceContract(contractId: string): Promise<void> {
|
||||
const contract = await this.contractsRepository.findById(contractId);
|
||||
if (!contract) {
|
||||
this.logger.warn(`Cannot advance unknown contract ${contractId} on clearance fee payment.`);
|
||||
return;
|
||||
}
|
||||
if (contract.status !== 'AWAITING_CLEARANCE_PAYMENT') return;
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
clearanceStatus: 'AWAITING_DOCUMENTS',
|
||||
clearanceFeePaidAt: new Date(),
|
||||
} as never);
|
||||
const updated = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||
if (updated) this.notifier.clearanceFeePaid(updated);
|
||||
}
|
||||
|
||||
private async advanceBooking(bookingId: string): Promise<void> {
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
if (!booking) {
|
||||
this.logger.warn(`Cannot advance unknown booking ${bookingId} on clearance fee payment.`);
|
||||
return;
|
||||
}
|
||||
if (booking.status !== 'AWAITING_CLEARANCE_PAYMENT') return;
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'AWAITING_DOCUMENTS',
|
||||
clearanceFeePaidAt: new Date(),
|
||||
} as never);
|
||||
if (booking.contractId) {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(
|
||||
booking.contractId,
|
||||
);
|
||||
if (contract) this.notifier.clearanceFeePaid(contract, booking.reference);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
||||
{} as never, // milestoneService
|
||||
{} as never, // workflowService
|
||||
{} as never, // invoiceService
|
||||
{} as never, // clearanceFeeService
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
|
||||
@@ -57,6 +57,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
milestoneService as never,
|
||||
{} as never, // workflowService
|
||||
invoiceService as never,
|
||||
{} as never, // clearanceFeeService
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
|
||||
@@ -35,6 +35,7 @@ import { hasFreightPermission } from '../../common/freight-permission.util';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractRoute } from './entities/contract-route.entity';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ClearanceFeeService } from './clearance-fee.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||
@@ -78,6 +79,7 @@ export class ContractBookingService {
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly clearanceFeeService: ClearanceFeeService,
|
||||
private readonly dataSource: DataSource,
|
||||
@Inject(forwardRef(() => TrainSchedulingService))
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
@@ -261,7 +263,7 @@ export class ContractBookingService {
|
||||
contractType: 'NEW',
|
||||
customsClearingEnabled: contract.customsClearingEnabled,
|
||||
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||
equipmentReturn: dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN',
|
||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||
originYardId: route?.originYardId ?? null,
|
||||
destinationYardId: route?.destinationYardId ?? null,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
@@ -492,6 +494,11 @@ export class ContractBookingService {
|
||||
|
||||
const route = await this.resolveRoute(contract, opts.contractRouteId);
|
||||
|
||||
// Prepay gate: each shipment request owes its own flat clearance service
|
||||
// fee before the document step opens (the paid event advances the booking
|
||||
// to AWAITING_DOCUMENTS). Government/unlinked contracts skip the gate.
|
||||
const feeGate = await this.clearanceFeeService.gateApplies(contract);
|
||||
|
||||
const booking = await insertWithGeneratedReference(
|
||||
() => this.generateReference(),
|
||||
(reference) =>
|
||||
@@ -501,7 +508,7 @@ export class ContractBookingService {
|
||||
companyProfileId: contract.companyProfileId ?? null,
|
||||
isGovernment: contract.isGovernment,
|
||||
governmentInstitution: contract.governmentInstitution ?? null,
|
||||
status: 'AWAITING_DOCUMENTS',
|
||||
status: feeGate ? 'AWAITING_CLEARANCE_PAYMENT' : 'AWAITING_DOCUMENTS',
|
||||
bookingType: 'ONE_TIME',
|
||||
contractId: contract.id,
|
||||
contractRouteId: route?.id ?? null,
|
||||
@@ -540,6 +547,10 @@ export class ContractBookingService {
|
||||
contract.tradeDirection,
|
||||
);
|
||||
|
||||
if (feeGate) {
|
||||
await this.clearanceFeeService.issueForBooking(booking, contract);
|
||||
}
|
||||
|
||||
return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
|
||||
}
|
||||
|
||||
@@ -654,7 +665,7 @@ export class ContractBookingService {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
...(dto.equipmentReturn ? { equipmentReturn: dto.equipmentReturn } : {}),
|
||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||
} as never);
|
||||
|
||||
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
@@ -1405,6 +1416,47 @@ export class ContractBookingService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the booking's equipment return from the per-line return quantities
|
||||
* (container freight). The CONTRACT gates the service — like hazardous:
|
||||
* - contract WITH_RETURN → per-line returnQuantity (≤ quantity) decides; any
|
||||
* line > 0 makes the booking WITH_RETURN (fires the pricing surcharge).
|
||||
* - contract WITHOUT_RETURN/unset → returnQuantity is rejected and the legacy
|
||||
* booking-level override (dto.equipmentReturn ?? contract default) applies.
|
||||
* Bulk freight keeps the legacy behaviour untouched.
|
||||
*/
|
||||
private resolveShipmentEquipmentReturn(
|
||||
contract: Contract,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
): string {
|
||||
const legacy =
|
||||
dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN';
|
||||
if (contract.freightType !== 'CONTAINER') return legacy;
|
||||
|
||||
const lines = dto.containers ?? [];
|
||||
for (const line of lines) {
|
||||
const qty = Number(line.returnQuantity ?? 0);
|
||||
if (qty === 0) continue;
|
||||
if (contract.equipmentReturn !== 'WITH_RETURN') {
|
||||
throw new BadRequestException(
|
||||
'This contract was not created with the empty-container return ' +
|
||||
'service — return quantities are not allowed on its bookings.',
|
||||
);
|
||||
}
|
||||
if (qty > line.quantity) {
|
||||
throw new BadRequestException(
|
||||
`Return quantity ${qty} exceeds the ${line.containerSize} line quantity ${line.quantity}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (contract.equipmentReturn === 'WITH_RETURN') {
|
||||
const anyReturn = lines.some((l) => Number(l.returnQuantity ?? 0) > 0);
|
||||
return anyReturn ? 'WITH_RETURN' : 'WITHOUT_RETURN';
|
||||
}
|
||||
return legacy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map each contract-scope container size to a concrete container type and
|
||||
* persist the booking_container line + its per-unit container numbers. Weight
|
||||
@@ -1455,6 +1507,10 @@ export class ContractBookingService {
|
||||
quantity: line.quantity,
|
||||
hazardousQuantity: line.hazardousQuantity ?? 0,
|
||||
reeferQuantity: line.reeferQuantity ?? 0,
|
||||
returnQuantity:
|
||||
contract.equipmentReturn === 'WITH_RETURN'
|
||||
? (line.returnQuantity ?? 0)
|
||||
: 0,
|
||||
vgmPerUnitTons: vgmPerUnit,
|
||||
totalVgmTons: totalVgm,
|
||||
wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)),
|
||||
@@ -1575,6 +1631,7 @@ export class ContractBookingService {
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
isHazardous: contract.isHazardous,
|
||||
isReefer: contract.isReefer,
|
||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||
isGovernment: contract.isGovernment,
|
||||
shippingLineId: null,
|
||||
contractRouteId: route?.id ?? null,
|
||||
@@ -1588,6 +1645,10 @@ export class ContractBookingService {
|
||||
quantity: line.quantity,
|
||||
hazardousQuantity: line.hazardousQuantity ?? 0,
|
||||
reeferQuantity: line.reeferQuantity ?? 0,
|
||||
returnQuantity:
|
||||
contract.equipmentReturn === 'WITH_RETURN'
|
||||
? (line.returnQuantity ?? 0)
|
||||
: 0,
|
||||
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
|
||||
totalVgmTons,
|
||||
wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)),
|
||||
|
||||
@@ -489,6 +489,11 @@ export class ContractClearanceService {
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (contract.status === 'AWAITING_CLEARANCE_PAYMENT') {
|
||||
throw new ConflictException(
|
||||
'The customs clearance service fee has not been paid yet — pay it from the portal to unlock document upload.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
|
||||
contract.status !== 'CLEARANCE_UNDER_REVIEW'
|
||||
|
||||
@@ -158,6 +158,26 @@ export class ContractNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Clearance service fee invoiced — customer must pay before document upload. */
|
||||
clearanceFeeDue(c: Contract, amount: number, currency: string, shipmentRef?: string): void {
|
||||
const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
|
||||
const msg =
|
||||
`A customs clearance service fee of ${amount} ${currency} is due for ${scope}. ` +
|
||||
`Please pay from the portal to unlock the clearance document upload.`;
|
||||
void this.notifyContact(c, msg, 'CLEARANCE FEE DUE');
|
||||
this.inApp(c, 'Clearance fee due', msg);
|
||||
}
|
||||
|
||||
/** Clearance service fee settled — document upload is now open. */
|
||||
clearanceFeePaid(c: Contract, shipmentRef?: string): void {
|
||||
const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
|
||||
const msg =
|
||||
`Your customs clearance service fee for ${scope} has been received. ` +
|
||||
`You can now upload the clearance documents from the portal.`;
|
||||
void this.notifyContact(c, msg, 'CLEARANCE FEE PAID');
|
||||
this.inApp(c, 'Clearance fee paid', msg);
|
||||
}
|
||||
|
||||
// ── Clearance milestones needing customer action ──────────────────────────
|
||||
|
||||
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, UnprocessableEntityException } from '@nestjs/common';
|
||||
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
@@ -15,6 +15,11 @@ export interface ContractUnitRateLineItem {
|
||||
containerSize?: string | null;
|
||||
conditionalOn?: string | null;
|
||||
cargoTypeCode?: string | null;
|
||||
/**
|
||||
* Customs clearance service fee — billed separately in advance (before the
|
||||
* clearance document step), never part of shipment booking totals.
|
||||
*/
|
||||
isClearance?: boolean;
|
||||
}
|
||||
|
||||
/** The contract `pricing_breakdown` shape (doc §9.1). */
|
||||
@@ -183,6 +188,50 @@ export class ContractPricingService {
|
||||
});
|
||||
}
|
||||
}
|
||||
// Empty-container return service — container contracts only, toggled on the
|
||||
// contract like hazard/reefer. Billed at booking per WITH_RETURN container.
|
||||
if (
|
||||
contract.freightType === 'CONTAINER' &&
|
||||
contract.equipmentReturn === 'WITH_RETURN'
|
||||
) {
|
||||
const withReturn = liveRates.find(
|
||||
(r) => r.rateType === 'RETURN_SURCHARGE' && r.currency === 'USD',
|
||||
);
|
||||
if (withReturn && Number(withReturn.rateValue) > 0) {
|
||||
lineItems.push({
|
||||
code: 'RETURN_SURCHARGE',
|
||||
label: 'Empty container return',
|
||||
unit: toContractUnit(withReturn.rateUnit),
|
||||
unitPrice: convert(Number(withReturn.rateValue)),
|
||||
conditionalOn: 'with_return',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Customs clearance service fee (Path B) — a FLAT prepaid fee, shown on the
|
||||
// contract and billed via its own clearance invoice: after counter-sign for
|
||||
// ONE_TIME, per shipment request for GENERAL. Excluded from booking totals.
|
||||
// A customs contract may not proceed without a configured live rate.
|
||||
if (contract.customsClearingEnabled) {
|
||||
const clearance = liveRates.find(
|
||||
(r) => r.rateType === 'CUSTOMS_CLEARANCE' && r.currency === 'USD',
|
||||
);
|
||||
if (!clearance || Number(clearance.rateValue) <= 0) {
|
||||
throw new UnprocessableEntityException(
|
||||
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
|
||||
);
|
||||
}
|
||||
lineItems.push({
|
||||
code: 'CUSTOMS_CLEARANCE',
|
||||
label:
|
||||
contract.contractKind === 'GENERAL'
|
||||
? 'Customs clearance service fee (per shipment request, prepaid)'
|
||||
: 'Customs clearance service fee (prepaid)',
|
||||
unit: toContractUnit(clearance.rateUnit),
|
||||
unitPrice: convert(Number(clearance.rateValue)),
|
||||
isClearance: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
displayMode: 'UNIT_RATES',
|
||||
@@ -229,6 +278,7 @@ export class ContractPricingService {
|
||||
containerSize: line.containerSize ?? null,
|
||||
isSurcharge: !!line.conditionalOn,
|
||||
conditionalOn: line.conditionalOn ?? null,
|
||||
isClearance: !!line.isClearance,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Readable } from 'stream';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
@@ -21,16 +22,36 @@ import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { OtpService } from '../otp/otp.service';
|
||||
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ClearanceFeeService } from './clearance-fee.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractsService } from './contracts.service';
|
||||
import { contractClearanceSettingCode } from './contract-clearance.util';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import {
|
||||
Contract,
|
||||
ContractDocumentArticle,
|
||||
ContractDocumentSnapshot,
|
||||
ContractDocumentSnapshotInput,
|
||||
} from './entities/contract.entity';
|
||||
import { ContractSignerRole } from './entities/contract-signature.entity';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
|
||||
/** The editable contract-document draft returned for the accept/edit dialog. */
|
||||
export interface ContractDocumentDraft {
|
||||
documentTitle: string | null;
|
||||
whereasClauses: string[];
|
||||
articles: ContractDocumentArticle[];
|
||||
code: string | null;
|
||||
name: string | null;
|
||||
/** True once the document may no longer be edited/regenerated. */
|
||||
locked: boolean;
|
||||
generatedAt: Date | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown-settings code holding the admin-configured contract validity options
|
||||
* (each option's `value` is a day count). The staff accept dialog reads the same
|
||||
@@ -39,6 +60,17 @@ import { SignContractDto } from './dto/sign-contract.dto';
|
||||
*/
|
||||
const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods';
|
||||
|
||||
/**
|
||||
* Mask a phone for display — keep the last 4 digits, star the rest
|
||||
* (`+251986680099` → `•••••••0099`). Used to tell the customer WHERE the signing
|
||||
* code went without echoing the company's full registered number back to the UI.
|
||||
*/
|
||||
function maskPhone(phone: string): string {
|
||||
const trimmed = phone.trim();
|
||||
if (trimmed.length <= 4) return trimmed;
|
||||
return `${'•'.repeat(trimmed.length - 4)}${trimmed.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** Status-machine guard mirroring booking-status.util. */
|
||||
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
||||
if (!allowed.includes(contract.status)) {
|
||||
@@ -68,6 +100,8 @@ export class ContractTransitionService {
|
||||
private readonly minioService: MinioService,
|
||||
private readonly otpService: OtpService,
|
||||
private readonly notifier: ContractNotifierService,
|
||||
private readonly contractTemplates: ContractTemplatesService,
|
||||
private readonly clearanceFeeService: ClearanceFeeService,
|
||||
) {}
|
||||
|
||||
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||
@@ -110,6 +144,7 @@ export class ContractTransitionService {
|
||||
contractId: string,
|
||||
actorId: string,
|
||||
validityDays: number,
|
||||
documentSnapshot?: ContractDocumentSnapshotInput | null,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['SUBMITTED']);
|
||||
@@ -128,6 +163,12 @@ export class ContractTransitionService {
|
||||
|
||||
await this.instantiateApprovalSteps(contract);
|
||||
|
||||
// Freeze the contract document for THIS contract only. Staff may have edited
|
||||
// the articles in the accept dialog; otherwise the live template is captured
|
||||
// as-is so later template edits never change an in-flight contract. The
|
||||
// shared six templates are never written here.
|
||||
const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot);
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'PENDING_APPROVAL',
|
||||
approvedByStaffId: actorId,
|
||||
@@ -135,12 +176,148 @@ export class ContractTransitionService {
|
||||
contractValidityDays: validityDays,
|
||||
contractValidFrom: validFrom,
|
||||
contractValidUntil: validUntil,
|
||||
documentSnapshot: snapshot,
|
||||
} as never);
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
this.notifier.accepted(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ── Per-contract document snapshot (US: edit articles for one contract) ─────
|
||||
|
||||
/**
|
||||
* The editable document draft for the accept/edit dialog: the frozen snapshot
|
||||
* if one exists, else the live active template resolved for this contract's
|
||||
* direction/freight pair. `locked` flips true once the document may no longer
|
||||
* be edited (an approver has acted, or the contract has left the pre-approval
|
||||
* window).
|
||||
*/
|
||||
async getContractDocumentDraft(
|
||||
contractId: string,
|
||||
): Promise<ContractDocumentDraft> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
const snapshot =
|
||||
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
|
||||
(await this.resolveDocumentSnapshot(contract));
|
||||
return {
|
||||
documentTitle: snapshot?.documentTitle ?? null,
|
||||
whereasClauses: snapshot?.whereasClauses ?? [],
|
||||
articles: snapshot?.articles ?? [],
|
||||
code: snapshot?.code ?? null,
|
||||
name: snapshot?.name ?? null,
|
||||
locked: !this.documentIsEditable(contract),
|
||||
generatedAt: contract.contractGeneratedAt ?? null,
|
||||
status: contract.status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace this contract's document articles from the editor. Per-contract
|
||||
* only — it writes the contract's own snapshot and never the shared templates.
|
||||
* Allowed while the document is still editable (PENDING_APPROVAL, no approver
|
||||
* has acted).
|
||||
*/
|
||||
async updateContractDocument(
|
||||
contractId: string,
|
||||
input: ContractDocumentSnapshotInput,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['PENDING_APPROVAL']);
|
||||
this.assertDocumentEditable(contract);
|
||||
|
||||
const current =
|
||||
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
|
||||
(await this.resolveDocumentSnapshot(contract));
|
||||
const merged: ContractDocumentSnapshotInput = {
|
||||
code: current?.code ?? null,
|
||||
name: input.name ?? current?.name ?? null,
|
||||
documentTitle: input.documentTitle ?? current?.documentTitle ?? null,
|
||||
whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [],
|
||||
articles: input.articles ?? current?.articles ?? [],
|
||||
};
|
||||
await this.contractsRepository.update(contractId, {
|
||||
documentSnapshot: this.normalizeSnapshot(merged),
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-contract document snapshot. Prefer the staff's edited articles
|
||||
* from the dialog; otherwise freeze the active template matching the
|
||||
* contract's direction/freight. Returns null when no active template exists
|
||||
* (the renderer then falls back to the built-in generic layout at render time).
|
||||
*/
|
||||
private async resolveDocumentSnapshot(
|
||||
contract: Contract,
|
||||
provided?: ContractDocumentSnapshotInput | null,
|
||||
): Promise<ContractDocumentSnapshot | null> {
|
||||
if (provided && (provided.articles?.length ?? 0) > 0) {
|
||||
return this.normalizeSnapshot(provided);
|
||||
}
|
||||
const active = await this.contractTemplates.findActiveForContract(
|
||||
contract.tradeDirection,
|
||||
contract.freightType,
|
||||
);
|
||||
if (!active) return null;
|
||||
return {
|
||||
code: active.code,
|
||||
name: active.name,
|
||||
documentTitle: active.documentTitle,
|
||||
whereasClauses: active.whereasClauses ?? [],
|
||||
articles: this.normalizeArticles(active.articles ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeSnapshot(
|
||||
input: ContractDocumentSnapshotInput,
|
||||
): ContractDocumentSnapshot {
|
||||
return {
|
||||
code: input.code ?? null,
|
||||
name: input.name ?? null,
|
||||
documentTitle: input.documentTitle ?? null,
|
||||
whereasClauses: Array.isArray(input.whereasClauses)
|
||||
? input.whereasClauses
|
||||
.map((c) => String(c))
|
||||
.filter((c) => c.trim().length > 0)
|
||||
: [],
|
||||
articles: this.normalizeArticles(input.articles ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
/** Re-key ids and renumber order sequentially, dropping empty-title rows. */
|
||||
private normalizeArticles(
|
||||
articles: Array<{ id?: string; title?: string; body?: string; order?: number }>,
|
||||
): ContractDocumentArticle[] {
|
||||
return articles
|
||||
.filter((a) => (a.title ?? '').trim().length > 0 || (a.body ?? '').trim().length > 0)
|
||||
.map((a, index) => ({
|
||||
id: a.id ?? randomUUID(),
|
||||
title: (a.title ?? '').trim(),
|
||||
body: a.body ?? '',
|
||||
order: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-contract document may be edited/regenerated while the contract is at
|
||||
* the accept stage (SUBMITTED) or in approval with NO approver having acted
|
||||
* yet. The first approval action freezes it.
|
||||
*/
|
||||
private documentIsEditable(contract: Contract): boolean {
|
||||
if (contract.status === 'SUBMITTED') return true;
|
||||
if (contract.status !== 'PENDING_APPROVAL') return false;
|
||||
return !(contract.approvalSteps ?? []).some((s) => s.status !== 'PENDING');
|
||||
}
|
||||
|
||||
private assertDocumentEditable(contract: Contract): void {
|
||||
if (!this.documentIsEditable(contract)) {
|
||||
throw new ConflictException(
|
||||
'The contract document is locked — an approver has already acted or the ' +
|
||||
'contract has advanced. It can no longer be edited or regenerated.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the chosen validity (days) is one of the admin-configured options in
|
||||
* the `contract_validity_periods` dropdown setting. If the setting is missing
|
||||
@@ -303,6 +480,15 @@ export class ContractTransitionService {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
|
||||
|
||||
// Approvers review the generated contract document, so it must exist before
|
||||
// the first approval can be recorded. Staff generate it (from the frozen,
|
||||
// optionally-edited snapshot) at the accept stage.
|
||||
if (contract.status === 'PENDING_APPROVAL' && !contract.contractGeneratedAt) {
|
||||
throw new BadRequestException(
|
||||
'Generate the contract document before it can be approved.',
|
||||
);
|
||||
}
|
||||
|
||||
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
|
||||
if (!step || step.status !== 'PENDING') {
|
||||
throw new BadRequestException('Approval step not found or already actioned');
|
||||
@@ -350,15 +536,14 @@ export class ContractTransitionService {
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
if (allDone) {
|
||||
this.notifier.approved(updated);
|
||||
// Final approval step also generates the contract document from the
|
||||
// template matching the contract's direction/freight pair. Best-effort:
|
||||
// a rendering hiccup must not roll back the approval — the document can
|
||||
// still be generated manually or lazily on view/download.
|
||||
// Every step approved → CONTRACT_READY. The document was already generated
|
||||
// (and reviewed) at the accept stage, so we reuse it rather than
|
||||
// re-rendering. Best-effort: a hiccup must not roll back the approval.
|
||||
try {
|
||||
return await this.generateContract(contractId);
|
||||
return await this.finalizeApprovedContract(contractId);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Auto contract generation after final approval failed for ${updated.reference}: ${err}`,
|
||||
`Finalizing contract after final approval failed for ${updated.reference}: ${err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -366,30 +551,66 @@ export class ContractTransitionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the contract PDF from the Contract aggregate, store it via FilesService,
|
||||
* stamp the template key, and move to CONTRACT_READY. PDF rendering (Puppeteer/
|
||||
* Chromium) is best-effort and must NOT block the contract from becoming ready —
|
||||
* the document is (re)rendered lazily on view/download once Chromium is available.
|
||||
* Staff (re)generate the contract PDF. Two stages:
|
||||
* - PENDING_APPROVAL: render from the frozen (optionally staff-edited)
|
||||
* snapshot so approvers review the real document. Status is UNCHANGED, and
|
||||
* it is blocked once an approver has acted (the document is then locked).
|
||||
* - APPROVED / APPROVED_PENDING_SIGNATURE (fallback): render and advance to
|
||||
* CONTRACT_READY.
|
||||
* PDF rendering (Puppeteer/Chromium) is best-effort and never blocks the
|
||||
* transition — the document re-renders lazily on view/download.
|
||||
*/
|
||||
async generateContract(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
|
||||
if (contract.status === 'PENDING_APPROVAL') {
|
||||
this.assertDocumentEditable(contract);
|
||||
await this.renderContractDocument(contract);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']);
|
||||
await this.renderContractDocument(contract);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CONTRACT_READY',
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
const { view } = await this.documentViewModelBuilder.build(contractId);
|
||||
|
||||
/**
|
||||
* Render the contract PDF from the Contract aggregate (snapshot-driven), store
|
||||
* it via FilesService, and stamp the template key + generated timestamp. Never
|
||||
* changes status. Rendering is best-effort — a Chromium hiccup defers the file
|
||||
* (it re-renders on view/download) but the timestamp is still stamped.
|
||||
*/
|
||||
private async renderContractDocument(contract: Contract): Promise<void> {
|
||||
const { view } = await this.documentViewModelBuilder.build(contract.id);
|
||||
try {
|
||||
await this.upsertContractPdf(contractId, contract.reference, view);
|
||||
await this.upsertContractPdf(contract.id, contract.reference, view);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CONTRACT_READY',
|
||||
await this.contractsRepository.update(contract.id, {
|
||||
contractTemplateKey: view.templateKey,
|
||||
contractGeneratedAt: new Date(),
|
||||
} as never);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every approval step landed → CONTRACT_READY. The document was already
|
||||
* generated (and reviewed) at the accept stage, so reuse it; render now only
|
||||
* if it was somehow never generated. Never re-renders over an existing file.
|
||||
*/
|
||||
private async finalizeApprovedContract(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (!contract.contractGeneratedAt) {
|
||||
await this.renderContractDocument(contract);
|
||||
}
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CONTRACT_READY',
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
@@ -574,6 +795,36 @@ export class ContractTransitionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the sudo-mode signing OTP to the CONTRACT COMPANY's registered phone —
|
||||
* the same number {@link sign} verifies against. The client never picks the
|
||||
* number (that is the H12(b) trust property): it only asks us to send, and we
|
||||
* resolve the phone from the contract. Returns a masked hint so the UI can
|
||||
* say where the code went without exposing the full number.
|
||||
*/
|
||||
async sendSigningOtp(
|
||||
contractId: string,
|
||||
options: { signerUserId?: string },
|
||||
): Promise<{ sentTo: string }> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
// Same ownership gate as signing — only the owning company's customer may
|
||||
// trigger a code for this contract.
|
||||
await this.contractsService.assertCustomerCanAccessContract(
|
||||
options.signerUserId,
|
||||
contract,
|
||||
);
|
||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||
|
||||
const companyPhone = contract.company?.phone?.trim();
|
||||
if (!companyPhone) {
|
||||
throw new BadRequestException(
|
||||
'The contract company has no registered phone on file to send the signing OTP to',
|
||||
);
|
||||
}
|
||||
await this.otpService.sendOtp({ phone: companyPhone });
|
||||
return { sentTo: maskPhone(companyPhone) };
|
||||
}
|
||||
|
||||
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
|
||||
async sign(
|
||||
contractId: string,
|
||||
@@ -583,17 +834,34 @@ export class ContractTransitionService {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
|
||||
if (dto.role === 'CUSTOMER') {
|
||||
// H12(a): only the owning company's customer may sign — assert ownership
|
||||
// before anything else (hidden as NotFound otherwise). A signing customer
|
||||
// has no permission key, so this is the gate that binds the sign to the
|
||||
// contract's company.
|
||||
await this.contractsService.assertCustomerCanAccessContract(
|
||||
options.signerUserId,
|
||||
contract,
|
||||
);
|
||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||
const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER');
|
||||
if (existing) {
|
||||
throw new BadRequestException('Customer has already signed this contract');
|
||||
}
|
||||
// Sudo-mode gate: a fresh, single-use OTP (SMS'd to the customer's phone)
|
||||
// must be verified before the signature is applied.
|
||||
if (!dto.otpPhone || !dto.otp) {
|
||||
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
||||
// signature is applied. H12(b): verify against the CONTRACT COMPANY's
|
||||
// registered phone — never the caller-supplied dto.otpPhone, which an
|
||||
// attacker could point at their own phone to sign someone else's
|
||||
// contract. The OTP is issued to the company's registered number.
|
||||
const companyPhone = contract.company?.phone?.trim();
|
||||
if (!companyPhone) {
|
||||
throw new BadRequestException(
|
||||
'The contract company has no registered phone on file to verify the signing OTP against',
|
||||
);
|
||||
}
|
||||
if (!dto.otp) {
|
||||
throw new BadRequestException('OTP verification is required to sign the contract');
|
||||
}
|
||||
await this.otpService.verifyOtpForAction({ phone: dto.otpPhone }, dto.otp);
|
||||
await this.otpService.verifyOtpForAction({ phone: companyPhone }, dto.otp);
|
||||
await this.applySignature(contract, dto, options);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SIGNED_CUSTOMER',
|
||||
@@ -658,8 +926,17 @@ export class ContractTransitionService {
|
||||
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
|
||||
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
|
||||
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
|
||||
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||
// Path B prepay gate: the customs clearance service fee is invoiced here
|
||||
// and must settle before the document step opens (the paid event advances
|
||||
// to AWAITING_CLEARANCE_DOCUMENTS). Path A (self-clearance) has no GL fee.
|
||||
if (await this.clearanceFeeService.gateApplies(contract)) {
|
||||
await this.clearanceFeeService.issueForContract(contract);
|
||||
updates.status = 'AWAITING_CLEARANCE_PAYMENT';
|
||||
updates.clearanceStatus = 'AWAITING_PAYMENT';
|
||||
} else {
|
||||
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||
}
|
||||
updates.clearanceCycleNumber = cycleNumber;
|
||||
} else {
|
||||
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
@@ -57,6 +58,7 @@ import { UpdateContractDto } from './dto/update-contract.dto';
|
||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||
import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
|
||||
import { AcceptContractDto } from './dto/accept-contract.dto';
|
||||
import { UpdateContractDocumentDto } from './dto/contract-document.dto';
|
||||
import {
|
||||
ApproveStepDto,
|
||||
RejectContractDto,
|
||||
@@ -340,9 +342,33 @@ export class ContractsController {
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
dto.validityDays,
|
||||
dto.documentSnapshot,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id/document/draft')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
|
||||
})
|
||||
getContractDocumentDraft(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.getContractDocumentDraft(id);
|
||||
}
|
||||
|
||||
@Put(':id/document/articles')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
|
||||
})
|
||||
updateContractDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateContractDocumentDto,
|
||||
) {
|
||||
return this.transitionService.updateContractDocument(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/staff/request-changes')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.requestChanges)
|
||||
@ApiOperation({ summary: 'Staff return contract for customer updates' })
|
||||
@@ -473,6 +499,19 @@ export class ContractsController {
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
@Post(':id/contract/send-signing-otp')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)",
|
||||
})
|
||||
sendSigningOtp(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.sendSigningOtp(id, { signerUserId: user?.id });
|
||||
}
|
||||
|
||||
@Post(':id/contract/sign')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
||||
@@ -498,12 +537,18 @@ export class ContractsController {
|
||||
|
||||
@Post(':id/renew')
|
||||
@ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' })
|
||||
renew(
|
||||
async renew(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() _dto: RenewContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.renew(id, user?.id ?? user?.sub);
|
||||
// H12(c): a customer may only renew a contract their company owns. Staff
|
||||
// with bookings.view bypass, mirroring getContractView/downloadContractDocument.
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
return this.transitionService.renew(id, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
// ── Pre-booking clearance (Path B, doc §15.2.1) ────────────────────────────
|
||||
@@ -518,10 +563,17 @@ export class ContractsController {
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)' })
|
||||
uploadClearanceDocuments(
|
||||
async uploadClearanceDocuments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
// H12(c): only the owning company's customer may upload clearance docs.
|
||||
// Staff with bookings.view bypass, mirroring the other contract handlers.
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
return this.clearanceService.uploadDocuments(id, files ?? []);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ContractsController } from './contracts.controller';
|
||||
import { ContractsService } from './contracts.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ClearanceFeeService } from './clearance-fee.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
import { ContractClearanceService } from './contract-clearance.service';
|
||||
@@ -103,6 +104,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
ContractsService,
|
||||
ContractsRepository,
|
||||
ContractPricingService,
|
||||
ClearanceFeeService,
|
||||
ContractNotifierService,
|
||||
ContractTransitionService,
|
||||
ContractClearanceService,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsInt, Max, Min } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Max, Min, ValidateNested } from 'class-validator';
|
||||
|
||||
import { UpdateContractDocumentDto } from './contract-document.dto';
|
||||
|
||||
export class AcceptContractDto {
|
||||
@ApiProperty({
|
||||
@@ -14,4 +17,16 @@ export class AcceptContractDto {
|
||||
@Min(1)
|
||||
@Max(3650)
|
||||
validityDays!: number;
|
||||
|
||||
/**
|
||||
* Optional per-contract document override edited by staff in the accept
|
||||
* dialog. When present its articles are frozen onto THIS contract; when
|
||||
* omitted the live template is snapshotted as-is. Never edits the shared
|
||||
* six templates.
|
||||
*/
|
||||
@ApiPropertyOptional({ type: UpdateContractDocumentDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => UpdateContractDocumentDto)
|
||||
documentSnapshot?: UpdateContractDocumentDto;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -77,6 +77,18 @@ export class CreateBookingContainerLineDto {
|
||||
@Transform(({ value }) => Number(value))
|
||||
reeferQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 0,
|
||||
description:
|
||||
'How many units of this line ship with empty-container return (≤ quantity). ' +
|
||||
'Only allowed when the contract was created WITH_RETURN (container freight).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
returnQuantity?: number;
|
||||
|
||||
@ApiProperty({ type: [CreateContainerUnitDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
|
||||
@@ -22,7 +22,10 @@ import { CONTRACT_KINDS } from '../entities/contract.entity';
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
|
||||
const EQUIPMENT_RETURNS = ['with_return', 'without_return'] as const;
|
||||
// Canonical UPPERCASE — everything downstream (booking gating, pricing
|
||||
// surcharge, GL/portal booking forms) compares contract.equipmentReturn
|
||||
// against 'WITH_RETURN'/'WITHOUT_RETURN'. Lowercase input is normalized.
|
||||
const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const;
|
||||
|
||||
export {
|
||||
CONTRACT_KINDS,
|
||||
@@ -161,6 +164,9 @@ export class CreateContractDto {
|
||||
|
||||
@ApiPropertyOptional({ enum: EQUIPMENT_RETURNS })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? value.toUpperCase() : value,
|
||||
)
|
||||
@IsIn([...EQUIPMENT_RETURNS])
|
||||
equipmentReturn?: string;
|
||||
|
||||
|
||||
@@ -44,4 +44,11 @@ export class ContractRateSnapshot extends BaseEntity {
|
||||
/** is_hazardous | is_reefer when this is a conditional surcharge. */
|
||||
@Column({ name: 'conditional_on', type: 'varchar', length: 32, nullable: true })
|
||||
conditionalOn?: string | null;
|
||||
|
||||
/**
|
||||
* Customs clearance service fee line — billed up front via a clearance
|
||||
* invoice, excluded from shipment booking totals.
|
||||
*/
|
||||
@Column({ name: 'is_clearance', type: 'boolean', default: false })
|
||||
isClearance!: boolean;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export const CONTRACT_STATUSES = [
|
||||
'SIGNED_CUSTOMER',
|
||||
'FULLY_EXECUTED',
|
||||
'CONTRACT_ACTIVE',
|
||||
'AWAITING_CLEARANCE_PAYMENT', // Path B — clearance fee invoiced, unpaid
|
||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
'CLEARANCE_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
@@ -42,11 +43,49 @@ export const CONTRACT_STATUSES = [
|
||||
|
||||
export type ContractStatus = (typeof CONTRACT_STATUSES)[number];
|
||||
|
||||
/** One article on a per-contract document snapshot (mirrors the template shape). */
|
||||
export interface ContractDocumentArticle {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-contract copy of the resolved contract-document template, frozen when
|
||||
* staff accept the contract for approval. Staff may edit these articles for a
|
||||
* single contract in the accept/edit dialog — editing NEVER writes back to the
|
||||
* shared six {@link ContractTemplate} rows. The PDF is rendered from this
|
||||
* snapshot when present; a null snapshot renders from the live template.
|
||||
*/
|
||||
export interface ContractDocumentSnapshot {
|
||||
code?: string | null;
|
||||
name?: string | null;
|
||||
documentTitle?: string | null;
|
||||
whereasClauses: string[];
|
||||
articles: ContractDocumentArticle[];
|
||||
}
|
||||
|
||||
/** Loose inbound shape (article ids/order optional) — normalized before store. */
|
||||
export interface ContractDocumentSnapshotInput {
|
||||
code?: string | null;
|
||||
name?: string | null;
|
||||
documentTitle?: string | null;
|
||||
whereasClauses?: string[];
|
||||
articles?: Array<{
|
||||
id?: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
order?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const CONTRACT_KINDS = ['ONE_TIME', 'GENERAL'] as const;
|
||||
export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
|
||||
|
||||
export const CONTRACT_CLEARANCE_STATUSES = [
|
||||
'NOT_APPLICABLE',
|
||||
'AWAITING_PAYMENT', // Path B — clearance service fee must be paid first
|
||||
'AWAITING_DOCUMENTS',
|
||||
'DOCUMENTS_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking
|
||||
@@ -178,6 +217,10 @@ export class Contract extends BaseEntity {
|
||||
@Column({ name: 'clearance_cycle_number', type: 'int', default: 0 })
|
||||
clearanceCycleNumber!: number;
|
||||
|
||||
/** When the prepaid customs clearance service fee settled (Path B ONE_TIME). */
|
||||
@Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
|
||||
clearanceFeePaidAt?: Date | null;
|
||||
|
||||
@Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
|
||||
pricingBreakdown?: Record<string, unknown> | null;
|
||||
|
||||
@@ -193,6 +236,14 @@ export class Contract extends BaseEntity {
|
||||
@Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
|
||||
contractGeneratedAt?: Date | null;
|
||||
|
||||
/**
|
||||
* Per-contract frozen copy of the document template (articles + WHEREAS),
|
||||
* captured at staff accept. Editing it affects only this contract, never the
|
||||
* shared six templates. Null → the PDF renders from the live template.
|
||||
*/
|
||||
@Column({ name: 'document_snapshot', type: 'jsonb', nullable: true })
|
||||
documentSnapshot?: ContractDocumentSnapshot | null;
|
||||
|
||||
@Column({ name: 'contract_summary', type: 'text', nullable: true })
|
||||
contractSummary?: string | null;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
@@ -72,7 +73,30 @@ export class DriversController {
|
||||
@Post(':id/documents')
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.update)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
// Bound the upload: 10MB/file, max 20 files, images + PDF only. Without limits
|
||||
// AnyFilesInterceptor buffers arbitrarily large / arbitrary-type payloads.
|
||||
@UseInterceptors(
|
||||
AnyFilesInterceptor({
|
||||
limits: { fileSize: 10 * 1024 * 1024, files: 20 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'application/pdf',
|
||||
];
|
||||
if (allowed.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(
|
||||
new BadRequestException(`Unsupported file type: ${file.mimetype}`),
|
||||
false,
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
@ApiOperation({ summary: 'Upload driver documents (code driver_docs)' })
|
||||
uploadDocuments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
|
||||
@@ -6,22 +6,24 @@ import {
|
||||
Query,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
|
||||
import { FilesService } from "./files.service";
|
||||
|
||||
@ApiTags("files")
|
||||
@ApiBearerAuth()
|
||||
@Controller("files")
|
||||
export class FilesController {
|
||||
constructor(private readonly filesService: FilesService) {}
|
||||
|
||||
@Get(":fileId")
|
||||
// Public so the browser can load the bytes directly via <img>/<iframe>/<a> —
|
||||
// those requests can't carry the Bearer token the axios client injects, so a
|
||||
// guarded route 401s. File UUIDs are unguessable; same tradeoff as webhooks.
|
||||
@Public()
|
||||
// Authenticated: no @Public, so the global JwtGuard applies. Unguessable file
|
||||
// UUIDs are obscurity, not authorization — raw byte streams must require auth.
|
||||
// Browser inline previews (<img>/<iframe>/<a>) that can't carry the Bearer
|
||||
// token should use a short-lived signed URL instead (FilesService.signUrl).
|
||||
// TODO: enforce ownership-by-resource here next (scope the file to the
|
||||
// caller's booking/company before streaming).
|
||||
@ApiOperation({
|
||||
summary: "Stream a file by ID",
|
||||
description:
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Readable } from "stream";
|
||||
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
@@ -28,6 +32,31 @@ function sanitizeObjectName(name: string): string {
|
||||
|
||||
@Injectable()
|
||||
export class FilesService {
|
||||
// Defense-in-depth for ANY caller of upload() (not just the driver-docs
|
||||
// route). This is deliberately BROADER than the driver controller's strict
|
||||
// images+pdf Multer filter, because the same method also stores generated
|
||||
// PDFs, PNG signatures, and customer/customs booking documents (scans, office
|
||||
// docs). It rejects the actual attack surface (executables/scripts/HTML) while
|
||||
// permitting every business-document type these flows legitimately upload.
|
||||
// No file-upload-settings row governs raw byte size, so the cap is a sane,
|
||||
// generous default that won't reject large scanned documents.
|
||||
private static readonly MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
||||
private static readonly ALLOWED_UPLOAD_MIME = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"image/heic",
|
||||
"image/tiff",
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"text/csv",
|
||||
"text/plain",
|
||||
]);
|
||||
|
||||
constructor(
|
||||
private readonly filesRepository: FilesRepository,
|
||||
private readonly minioService: MinioService,
|
||||
@@ -35,6 +64,15 @@ export class FilesService {
|
||||
|
||||
async upload(input: CreateFileInput): Promise<FileRecord> {
|
||||
const { resourceId, resource, code, file } = input;
|
||||
|
||||
if (!FilesService.ALLOWED_UPLOAD_MIME.has(file.mimetype)) {
|
||||
throw new BadRequestException(`Unsupported file type: ${file.mimetype}`);
|
||||
}
|
||||
if (file.size > FilesService.MAX_UPLOAD_BYTES) {
|
||||
throw new BadRequestException(
|
||||
`File exceeds the ${FilesService.MAX_UPLOAD_BYTES / (1024 * 1024)}MB upload limit`,
|
||||
);
|
||||
}
|
||||
// Keep the object key URL-safe so it survives the round-trip through the
|
||||
// stored URL (spaces/unicode in the original name would otherwise be
|
||||
// percent-encoded in the URL and no longer match the MinIO key). The
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsUUID, IsNumber, IsDateString, IsString, IsOptional, IsEnum } from 'class-validator';
|
||||
import { IsUUID, IsNumber, IsPositive, IsDateString, IsString, IsOptional, IsEnum } from 'class-validator';
|
||||
import { PaymentMethod } from '../entities/fuel-purchase.entity';
|
||||
|
||||
export class CreateFuelPurchaseDto {
|
||||
@@ -9,9 +9,11 @@ export class CreateFuelPurchaseDto {
|
||||
purchaseDate!: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
liters!: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
costPerLiter!: number;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConflictException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { FuelRepository } from './fuel.repository';
|
||||
@@ -15,6 +15,18 @@ export class FuelService {
|
||||
) {}
|
||||
|
||||
async recordFuelPurchase(dto: CreateFuelPurchaseDto): Promise<FuelPurchase> {
|
||||
// Reject a re-submitted receipt for the same vehicle (double-entry guard).
|
||||
if (dto.receiptNumber) {
|
||||
const duplicate = await this.purchaseRepository.findOne({
|
||||
where: { vehicleId: dto.vehicleId, receiptNumber: dto.receiptNumber },
|
||||
});
|
||||
if (duplicate) {
|
||||
throw new ConflictException(
|
||||
`A fuel purchase with receipt number ${dto.receiptNumber} already exists for this vehicle`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const totalCost = dto.liters * dto.costPerLiter;
|
||||
|
||||
const purchase = this.purchaseRepository.create({
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import {
|
||||
AssignCustomsRiskDto,
|
||||
CreateDjiboutiIncidentDto,
|
||||
@@ -15,6 +17,9 @@ import { ImportOperationsService } from './import-operations.service';
|
||||
@ApiTags('import-operations')
|
||||
@ApiBearerAuth()
|
||||
@Controller('import-operations')
|
||||
// Post-booking customs / import-operations actions are GL/Ops work, mirroring the
|
||||
// contracts controller's GL operational endpoints (risk, duty, milestones).
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
export class ImportOperationsController {
|
||||
constructor(private readonly service: ImportOperationsService) {}
|
||||
|
||||
|
||||
@@ -8,18 +8,26 @@ import {
|
||||
Param,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { ApiBearerAuth, ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { IncidentsService } from './incidents.service';
|
||||
import { CreateIncidentDto } from './dto/create-incident.dto';
|
||||
import { UpdateIncidentDto } from './dto/update-incident.dto';
|
||||
import { IncidentStatus, IncidentType } from './entities/incident.entity';
|
||||
|
||||
@ApiTags('Accident & Incident Management')
|
||||
@ApiBearerAuth()
|
||||
@Controller('incidents')
|
||||
// No incidents-specific permission exists in the registry, so this reuses the
|
||||
// (real) drivers.* fleet-road keys — incident records are driver-safety data
|
||||
// (driver stats / incident history). TODO: add a dedicated incidents:* key.
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.view)
|
||||
export class IncidentsController {
|
||||
constructor(private readonly incidentsService: IncidentsService) {}
|
||||
|
||||
@Post()
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.create)
|
||||
@ApiOperation({ summary: 'Report an incident' })
|
||||
async create(@Body() dto: CreateIncidentDto) {
|
||||
return this.incidentsService.create(dto);
|
||||
@@ -55,12 +63,14 @@ export class IncidentsController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.update)
|
||||
@ApiOperation({ summary: 'Update an incident' })
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateIncidentDto) {
|
||||
return this.incidentsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.delete)
|
||||
@ApiOperation({ summary: 'Delete an incident' })
|
||||
async remove(@Param('id') id: string) {
|
||||
await this.incidentsService.remove(id);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { GenerateFromScheduleDto } from './dto/generate-from-schedule.dto';
|
||||
import { InterchangeDocumentQueryDto } from './dto/interchange-document-query.dto';
|
||||
import {
|
||||
@@ -12,6 +14,8 @@ import { InterchangeDocumentsService } from './interchange-documents.service';
|
||||
@ApiTags('interchange-documents')
|
||||
@ApiBearerAuth()
|
||||
@Controller('interchange-documents')
|
||||
// Class-level view guard; each write route adds its own manage permission below.
|
||||
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.view)
|
||||
export class InterchangeDocumentsController {
|
||||
constructor(private readonly service: InterchangeDocumentsService) {}
|
||||
|
||||
@@ -28,12 +32,14 @@ export class InterchangeDocumentsController {
|
||||
}
|
||||
|
||||
@Post('generate-from-schedule')
|
||||
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.generate)
|
||||
@ApiOperation({ summary: 'Generate interchange document from a train schedule handover' })
|
||||
generateFromSchedule(@Body() dto: GenerateFromScheduleDto) {
|
||||
return this.service.generateFromSchedule(dto);
|
||||
}
|
||||
|
||||
@Patch(':id/acknowledge')
|
||||
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.acknowledge)
|
||||
@ApiOperation({ summary: 'Acknowledge an interchange document' })
|
||||
acknowledge(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -43,12 +49,14 @@ export class InterchangeDocumentsController {
|
||||
}
|
||||
|
||||
@Patch(':id/dispute')
|
||||
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.dispute)
|
||||
@ApiOperation({ summary: 'Dispute an interchange document' })
|
||||
dispute(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DisputeInterchangeDocumentDto) {
|
||||
return this.service.dispute(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/cancel')
|
||||
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.cancel)
|
||||
@ApiOperation({ summary: 'Cancel a draft/generated interchange document' })
|
||||
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.cancel(id);
|
||||
|
||||
@@ -184,8 +184,13 @@ export class InterchangeDocumentsService {
|
||||
dto: AcknowledgeInterchangeDocumentDto,
|
||||
): Promise<InterchangeDocument> {
|
||||
const document = await this.findOne(id);
|
||||
if (document.status === 'CANCELLED') {
|
||||
throw new BadRequestException('Cancelled interchange document cannot be acknowledged');
|
||||
// Only a freshly GENERATED document can be acknowledged. Rejecting DISPUTED
|
||||
// (as well as CANCELLED / already-ACKNOWLEDGED) stops an acknowledge from
|
||||
// silently overriding a raised dispute.
|
||||
if (document.status !== 'GENERATED') {
|
||||
throw new BadRequestException(
|
||||
`Interchange document in ${document.status} status cannot be acknowledged (must be GENERATED)`,
|
||||
);
|
||||
}
|
||||
await this.dataSource.getRepository(InterchangeDocument).update(id, {
|
||||
status: 'ACKNOWLEDGED',
|
||||
@@ -197,7 +202,14 @@ export class InterchangeDocumentsService {
|
||||
}
|
||||
|
||||
async dispute(id: string, dto: DisputeInterchangeDocumentDto): Promise<InterchangeDocument> {
|
||||
await this.findOne(id);
|
||||
const document = await this.findOne(id);
|
||||
// A dispute can only be raised on a live handover — a GENERATED or already
|
||||
// ACKNOWLEDGED document. CANCELLED and already-DISPUTED are terminal here.
|
||||
if (!['GENERATED', 'ACKNOWLEDGED'].includes(document.status)) {
|
||||
throw new BadRequestException(
|
||||
`Interchange document in ${document.status} status cannot be disputed (must be GENERATED or ACKNOWLEDGED)`,
|
||||
);
|
||||
}
|
||||
await this.dataSource.getRepository(InterchangeDocument).update(id, {
|
||||
status: 'DISPUTED',
|
||||
remarks: dto.remarks,
|
||||
@@ -273,7 +285,10 @@ export class InterchangeDocumentsService {
|
||||
NULL::uuid AS "cargoId",
|
||||
a.booking_cargo_type AS "cargoType",
|
||||
a.cargo_free_text AS "cargoDescription",
|
||||
COALESCE(bc.total_vgm_tons, c.max_gross_weight, a.cargo_total_weight_vgm) AS "weight",
|
||||
-- Item weight is normalized to TONS. total_vgm_tons and
|
||||
-- cargo_total_weight_vgm are already tons; containers.max_gross_weight
|
||||
-- is kilograms, so convert it (kg -> tons).
|
||||
COALESCE(bc.total_vgm_tons, c.max_gross_weight / 1000.0 /* kg->tons */, a.cargo_total_weight_vgm) AS "weight",
|
||||
COALESCE(bc.quantity, 1) AS "quantity",
|
||||
COALESCE(bc.quantity, 1) AS "packageCount",
|
||||
a.wagon_number AS "wagonNumber",
|
||||
@@ -322,7 +337,9 @@ export class InterchangeDocumentsService {
|
||||
cg.id AS "cargoId",
|
||||
COALESCE(cgt.cargo_type_name, a.booking_cargo_type) AS "cargoType",
|
||||
COALESCE(cg.description, a.cargo_free_text) AS "cargoDescription",
|
||||
COALESCE(cg.weight, a.cargo_total_weight_vgm) AS "weight",
|
||||
-- Normalized to TONS: cargoes.weight is kilograms (convert), while
|
||||
-- cargo_total_weight_vgm is already tons.
|
||||
COALESCE(cg.weight / 1000.0 /* kg->tons */, a.cargo_total_weight_vgm) AS "weight",
|
||||
cg.quantity AS "quantity",
|
||||
cg.quantity AS "packageCount",
|
||||
a.wagon_number AS "wagonNumber",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||
@@ -9,11 +10,23 @@ import {
|
||||
type LocomotiveStatus,
|
||||
type LocomotiveType,
|
||||
} from './entities/locomotive.entity';
|
||||
import { TrainLocomotive } from '../trains/entities/train-locomotive.entity';
|
||||
import { LocomotivesRepository } from './locomotives.repository';
|
||||
|
||||
@Injectable()
|
||||
export class LocomotivesService {
|
||||
constructor(private readonly locomotivesRepository: LocomotivesRepository) {}
|
||||
constructor(
|
||||
private readonly locomotivesRepository: LocomotivesRepository,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/** The built-train link (if any) coupling this locomotive to a fleet train. */
|
||||
private findTrainLink(locomotiveId: string): Promise<TrainLocomotive | null> {
|
||||
return this.dataSource.getRepository(TrainLocomotive).findOne({
|
||||
where: { locomotiveId },
|
||||
relations: { train: true },
|
||||
});
|
||||
}
|
||||
|
||||
findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
|
||||
return this.locomotivesRepository.findAll({
|
||||
@@ -94,6 +107,22 @@ export class LocomotivesService {
|
||||
}
|
||||
}
|
||||
|
||||
// A locomotive coupled to a built train follows the train: its yard and
|
||||
// status are owned by the train-builder flow, not this generic PATCH.
|
||||
const link = await this.findTrainLink(id);
|
||||
if (link) {
|
||||
if (dto.currentYardId !== undefined && dto.currentYardId !== link.train?.currentYardId) {
|
||||
throw new ConflictException(
|
||||
`Locomotive ${locomotive.code} is coupled to train ${link.train?.code}; move the train (train-builder yard change) instead`,
|
||||
);
|
||||
}
|
||||
if (dto.status !== undefined && dto.status !== locomotive.status) {
|
||||
throw new ConflictException(
|
||||
`Locomotive ${locomotive.code} is coupled to train ${link.train?.code}; detach it before changing its status`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.locomotivesRepository.update(id, {
|
||||
...dto,
|
||||
locomotiveType:
|
||||
@@ -119,7 +148,16 @@ export class LocomotivesService {
|
||||
}
|
||||
|
||||
async decommission(id: string): Promise<Locomotive> {
|
||||
await this.findById(id);
|
||||
const locomotive = await this.findById(id);
|
||||
|
||||
// Can't retire a locomotive that is still coupled to a built train — detach
|
||||
// it in the train-builder first so the train never loses a live loco.
|
||||
const link = await this.findTrainLink(id);
|
||||
if (link) {
|
||||
throw new ConflictException(
|
||||
`Locomotive ${locomotive.code} is coupled to train ${link.train?.code}; detach it before taking it out of service`,
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await this.locomotivesRepository.update(id, {
|
||||
status: 'OUT_OF_SERVICE',
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { MaintenanceRepository } from './maintenance.repository';
|
||||
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
|
||||
import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity';
|
||||
import { MaintenanceCost } from './entities/maintenance-cost.entity';
|
||||
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity';
|
||||
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
|
||||
|
||||
@Injectable()
|
||||
@@ -14,15 +15,41 @@ export class MaintenanceService {
|
||||
private readonly scheduleRepository: Repository<MaintenanceSchedule>,
|
||||
@InjectRepository(MaintenanceCost)
|
||||
private readonly costRepository: Repository<MaintenanceCost>,
|
||||
// Vehicle isn't registered in this module's TypeOrmModule.forFeature, so we
|
||||
// reach it through the global DataSource rather than @InjectRepository.
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Reflect a maintenance schedule's lifecycle on the target vehicle. A vehicle
|
||||
* under maintenance is taken out of service (MAINTENANCE + BUSY); once the
|
||||
* maintenance completes or is cancelled it returns to service (ACTIVE + FREE).
|
||||
* Only the vehicle's status/availability columns are written here. The
|
||||
* assignment-side reject (first-mile/last-mile refusing MAINTENANCE vehicles)
|
||||
* lives in those excluded mile modules, not here.
|
||||
*/
|
||||
private async setVehicleMaintenanceState(
|
||||
vehicleId: string,
|
||||
underMaintenance: boolean,
|
||||
): Promise<void> {
|
||||
await this.dataSource.getRepository(Vehicle).update(vehicleId, {
|
||||
status: underMaintenance ? VehicleStatus.MAINTENANCE : VehicleStatus.ACTIVE,
|
||||
availability: underMaintenance
|
||||
? VehicleAvailability.BUSY
|
||||
: VehicleAvailability.FREE,
|
||||
});
|
||||
}
|
||||
|
||||
async scheduleMaintenanceAsync(dto: CreateMaintenanceScheduleDto): Promise<MaintenanceSchedule> {
|
||||
const schedule = this.scheduleRepository.create({
|
||||
...dto,
|
||||
scheduledDate: new Date(dto.scheduledDate),
|
||||
nextDueDate: dto.nextDueDate ? new Date(dto.nextDueDate) : undefined,
|
||||
});
|
||||
return this.scheduleRepository.save(schedule);
|
||||
const saved = await this.scheduleRepository.save(schedule);
|
||||
// Scheduling maintenance takes the vehicle out of the available pool.
|
||||
await this.setVehicleMaintenanceState(saved.vehicleId, true);
|
||||
return saved;
|
||||
}
|
||||
|
||||
async recordMaintenanceCost(dto: CreateMaintenanceCostDto): Promise<MaintenanceCost> {
|
||||
@@ -42,6 +69,21 @@ export class MaintenanceService {
|
||||
completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined,
|
||||
});
|
||||
const updated = await this.scheduleRepository.findOneBy({ id });
|
||||
|
||||
// Keep the vehicle's status/availability in step with the schedule status.
|
||||
if (updated && dto.status) {
|
||||
if (
|
||||
dto.status === MaintenanceStatus.COMPLETED ||
|
||||
dto.status === MaintenanceStatus.CANCELLED
|
||||
) {
|
||||
// Maintenance finished/aborted → vehicle back in service.
|
||||
await this.setVehicleMaintenanceState(updated.vehicleId, false);
|
||||
} else if (dto.status === MaintenanceStatus.IN_PROGRESS) {
|
||||
// Maintenance started → keep the vehicle out of service.
|
||||
await this.setVehicleMaintenanceState(updated.vehicleId, true);
|
||||
}
|
||||
}
|
||||
|
||||
return updated!;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from "@nestjs/common";
|
||||
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
|
||||
import { NotificationStrategy } from "./strategies/notification.strategy";
|
||||
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
|
||||
@@ -27,8 +32,19 @@ export class NotificationsService {
|
||||
if (!strategy) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
// A strategy returning false (or throwing) is a real delivery failure — do
|
||||
// not swallow it. Surface it so callers observe the failure (existing
|
||||
// callers wrap directSend in try/catch for best-effort notifications).
|
||||
const sent = await strategy.send(recipient, message);
|
||||
this.logger.log(`is sent - ${sent}`);
|
||||
if (!sent) {
|
||||
this.logger.error(
|
||||
`Notification via ${method} to ${recipient} failed to send`,
|
||||
);
|
||||
throw new ServiceUnavailableException(
|
||||
`Failed to send ${method} notification`,
|
||||
);
|
||||
}
|
||||
this.logger.log(`Notification via ${method} to ${recipient} sent`);
|
||||
}
|
||||
|
||||
async notifyDriverVehicleAssignment(params: {
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { NotificationStrategy } from "./notification.strategy";
|
||||
import { EmailClientService } from "../email-client.service";
|
||||
|
||||
@Injectable()
|
||||
export class EmailNotificationStrategy implements NotificationStrategy {
|
||||
private readonly logger = new Logger(EmailNotificationStrategy.name);
|
||||
constructor() { }
|
||||
constructor(private readonly emailClient: EmailClientService) { }
|
||||
async send(recipient: string, message: string): Promise<boolean> {
|
||||
this.logger.log(`${recipient}, ${message}`)
|
||||
return false;
|
||||
try {
|
||||
// Route through the shared email client (RabbitMQ hand-off). `queued`
|
||||
// reflects whether the message was accepted for delivery; a false or
|
||||
// a thrown result is a real failure the caller must observe.
|
||||
const { queued } = await this.emailClient.sendEmail({
|
||||
to: recipient,
|
||||
subject: "EDR Freight notification",
|
||||
text: message,
|
||||
});
|
||||
return queued;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to send email to ${recipient}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
err instanceof Error ? err.stack : undefined,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ function toTarget(phone?: string, email?: string): OtpTarget {
|
||||
throw new BadRequestException("phone or email is required");
|
||||
}
|
||||
|
||||
// TODO: these public routes need per-target + per-IP rate limiting (a NestJS
|
||||
// ThrottlerGuard / @Throttle on /otp/send and /otp/verify). No Throttler is
|
||||
// wired into the app yet; add @nestjs/throttler and apply it here.
|
||||
@Controller("otp")
|
||||
@Public()
|
||||
export class OtpController {
|
||||
|
||||
@@ -1,18 +1,61 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { OtpService } from './otp.service';
|
||||
import { OtpService, normalizeOtpTarget } from './otp.service';
|
||||
|
||||
describe('OtpService', () => {
|
||||
let service: OtpService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [OtpService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<OtpService>(OtpService);
|
||||
describe('normalizeOtpTarget', () => {
|
||||
it('canonicalises Ethiopian forms to one E.164 key', () => {
|
||||
const forms = ['+251986680099', '251986680099', '0986680099', '+251 98 668 0099'];
|
||||
const keys = forms.map((phone) => normalizeOtpTarget({ phone }).phone);
|
||||
expect(new Set(keys)).toEqual(new Set(['+251986680099']));
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
it('maps local 07… mobile to +2517…', () => {
|
||||
expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678');
|
||||
});
|
||||
|
||||
it('passes email targets through untouched', () => {
|
||||
expect(normalizeOtpTarget({ email: 'a@b.com' })).toEqual({ email: 'a@b.com' });
|
||||
});
|
||||
|
||||
it('keeps an already-normalised number stable (idempotent)', () => {
|
||||
const once = normalizeOtpTarget({ phone: '0986680099' }).phone!;
|
||||
expect(normalizeOtpTarget({ phone: once }).phone).toBe(once);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OtpService — send/verify agree across phone formats', () => {
|
||||
// In-memory fake keyed by the exact phone string the service stores under, so
|
||||
// the test proves normalisation makes send and verify collide on one key.
|
||||
function makeService() {
|
||||
const rows = new Map<string, { phone?: string; email?: string; otp: string; updatedAt: Date }>();
|
||||
const repo = {
|
||||
findByTarget: jest.fn(async (t: { phone?: string; email?: string }) =>
|
||||
rows.get(t.email ?? t.phone!) ?? null,
|
||||
),
|
||||
updateOtp: jest.fn(async (existing: { otp: string }, otp: string) => {
|
||||
existing.otp = otp;
|
||||
}),
|
||||
createOtp: jest.fn(async (t: { phone?: string; email?: string }, otp: string) => {
|
||||
rows.set(t.phone ?? t.email!, { ...t, otp, updatedAt: new Date(0) });
|
||||
}),
|
||||
deleteOtp: jest.fn(async (row: { phone?: string; email?: string }) => {
|
||||
rows.delete(row.phone ?? row.email!);
|
||||
}),
|
||||
};
|
||||
const sms = { sendSms: jest.fn().mockResolvedValue(undefined) };
|
||||
const email = { sendEmail: jest.fn().mockResolvedValue(undefined) };
|
||||
const service = new OtpService(repo as never, sms as never, email as never);
|
||||
return { service, rows };
|
||||
}
|
||||
|
||||
it('verifies a code sent to +251… when verify is called with 09…', async () => {
|
||||
const { service, rows } = makeService();
|
||||
await service.sendOtp({ phone: '+251986680099' });
|
||||
const stored = [...rows.values()][0]!.otp;
|
||||
|
||||
// Fresh TTL: stamp updatedAt to now so the action verifier does not expire it.
|
||||
[...rows.values()][0]!.updatedAt = new Date();
|
||||
|
||||
await expect(
|
||||
service.verifyOtpForAction({ phone: '0986680099' }, stored),
|
||||
).resolves.toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// otp.service.ts
|
||||
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import { randomInt } from "node:crypto";
|
||||
|
||||
import { OtpRepository } from "./otp.repository";
|
||||
|
||||
@@ -11,6 +12,28 @@ import { EmailClientService } from "../notifications/email-client.service";
|
||||
// reaches here.
|
||||
export type OtpTarget = { phone?: string; email?: string };
|
||||
|
||||
/**
|
||||
* Canonicalise a phone to E.164 so the code stored on send and the one looked
|
||||
* up on verify collide regardless of how the number was typed. Without this,
|
||||
* `+251986680099`, `251986680099` and `0986680099` are three different keys and
|
||||
* a code sent to one is invisible to the others — the send/verify halves must
|
||||
* agree on the exact string. Ethiopian local `09…`/`07…` (10 digits) maps to
|
||||
* `+2519…`/`+2517…`; a bare `251…` gains its `+`; anything already `+…` is kept.
|
||||
* Email targets pass through untouched.
|
||||
*/
|
||||
export function normalizeOtpTarget(target: OtpTarget): OtpTarget {
|
||||
if (target.email || !target.phone) return target;
|
||||
const raw = target.phone.trim();
|
||||
const digits = raw.replace(/[^\d+]/g, '');
|
||||
if (digits.startsWith('+')) return { phone: digits };
|
||||
const bare = digits.replace(/^0+/, '');
|
||||
if (/^251\d{9}$/.test(digits)) return { phone: `+${digits}` };
|
||||
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return { phone: `+251${bare}` };
|
||||
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
|
||||
// it looks like a full international number, else leave as typed.
|
||||
return { phone: digits.length >= 11 ? `+${digits}` : raw };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OtpService {
|
||||
logger = new Logger(OtpService.name);
|
||||
@@ -25,14 +48,19 @@ export class OtpService {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
generateOtp(): string {
|
||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||
// Cryptographically secure 6-digit code (100000–999999). Math.random() is a
|
||||
// non-CSPRNG and must never be used to mint a security token.
|
||||
return randomInt(100000, 1000000).toString();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Send OTP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async sendOtp(target: OtpTarget) {
|
||||
async sendOtp(rawTarget: OtpTarget) {
|
||||
// Store under the canonical E.164 key so verify (which normalises the same
|
||||
// way) always finds this row regardless of how either side typed the number.
|
||||
const target = normalizeOtpTarget(rawTarget);
|
||||
try {
|
||||
// The verification code is generated server-side — never supplied by the
|
||||
// caller — so the OTP stays a secret known only to the server and the
|
||||
@@ -50,8 +78,13 @@ export class OtpService {
|
||||
await this.otpRepository.createOtp(target, otp);
|
||||
}
|
||||
|
||||
// A freshly issued code gets a fresh guess budget.
|
||||
this.actionAttempts.delete(this.targetKey(target));
|
||||
// NOTE: do NOT reset the brute-force attempt counter on send. Clearing it
|
||||
// here let an attacker wipe the per-target guess budget just by calling
|
||||
// /otp/send between guesses. The counter is cleared only when the code is
|
||||
// consumed/expired during verification.
|
||||
// TODO: add per-target + per-IP rate limiting on the public /otp/send and
|
||||
// /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists
|
||||
// in the codebase yet.
|
||||
|
||||
if (target.email) {
|
||||
// send email (queued to RabbitMQ via the shared Email service)
|
||||
@@ -91,9 +124,13 @@ export class OtpService {
|
||||
// Verify OTP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async verifyOtp(target: OtpTarget, otp: string) {
|
||||
async verifyOtp(rawTarget: OtpTarget, otp: string) {
|
||||
// Same canonicalisation as sendOtp so a code stored under +2519… is found
|
||||
// when verify is called with 09… (or any equivalent form).
|
||||
const target = normalizeOtpTarget(rawTarget);
|
||||
// find the channel's row
|
||||
const otpData = await this.otpRepository.findByTarget(target);
|
||||
const key = this.targetKey(target);
|
||||
|
||||
// not found
|
||||
if (!otpData) {
|
||||
@@ -102,13 +139,35 @@ export class OtpService {
|
||||
);
|
||||
}
|
||||
|
||||
// invalid otp
|
||||
// TTL: reuse the same age window as the hardened action verifier — an old
|
||||
// code can't be verified.
|
||||
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
|
||||
if (ageMs > this.ACTION_OTP_TTL_MS) {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
throw new BadRequestException(
|
||||
"Verification code has expired. Request a new one.",
|
||||
);
|
||||
}
|
||||
|
||||
// invalid otp — per-target attempt cap so a 6-digit code can't be
|
||||
// brute-forced within its TTL; the code is burned once the budget is spent.
|
||||
if (otpData.otp !== otp) {
|
||||
const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
|
||||
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
throw new BadRequestException(
|
||||
"Too many incorrect attempts. Request a new code.",
|
||||
);
|
||||
}
|
||||
this.actionAttempts.set(key, attempts);
|
||||
throw new BadRequestException("Invalid OTP");
|
||||
}
|
||||
|
||||
// mark verified
|
||||
await this.otpRepository.markVerified(otpData);
|
||||
// single-use: consume the code on success so it can't be replayed.
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -142,10 +201,11 @@ export class OtpService {
|
||||
}
|
||||
|
||||
async verifyOtpForAction(
|
||||
target: OtpTarget,
|
||||
rawTarget: OtpTarget,
|
||||
otp: string,
|
||||
ttlMs: number = this.ACTION_OTP_TTL_MS,
|
||||
) {
|
||||
const target = normalizeOtpTarget(rawTarget);
|
||||
const otpData = await this.otpRepository.findByTarget(target);
|
||||
const key = this.targetKey(target);
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { TrainScheduleStatus } from '@edr/types';
|
||||
import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { CreateRouteDto } from './dto/create-route.dto';
|
||||
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||
import { UpdateRouteDto } from './dto/update-route.dto';
|
||||
@@ -112,6 +119,30 @@ export class RoutesService {
|
||||
? await this.validateMilestones(dto.milestones)
|
||||
: null;
|
||||
|
||||
// Milestones or endpoints are about to be rewritten — reject if any
|
||||
// non-terminal schedule still references this route, otherwise its stop list
|
||||
// and distances would silently shift under a live plan. Status-only /
|
||||
// label-only edits (no milestones supplied) are always allowed.
|
||||
if (milestoneInput) {
|
||||
const activeSchedules = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.count({
|
||||
where: {
|
||||
routeId: id,
|
||||
status: In([
|
||||
TrainScheduleStatus.Draft,
|
||||
TrainScheduleStatus.Scheduled,
|
||||
TrainScheduleStatus.Dispatched,
|
||||
]),
|
||||
},
|
||||
});
|
||||
if (activeSchedules > 0) {
|
||||
throw new ConflictException(
|
||||
'This route is used by active train schedules and its stops cannot be changed. Create a new route instead.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(Route).update(id, {
|
||||
originYardId: milestoneInput?.originYardId ?? existing.originYardId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
@@ -24,6 +25,23 @@ export class PriorityConfigsController {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
// Static route — must stay above `:id` (Express matches in declaration order).
|
||||
@Get('next-range')
|
||||
@RuleEngineView('priority-configs')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Where the next contiguous range for a type (and currency) must start, plus the type's ceiling",
|
||||
})
|
||||
nextRange(
|
||||
@Query('type') type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',
|
||||
@Query('currency') currency?: string,
|
||||
) {
|
||||
if (!['WAGON', 'CURRENCY', 'CUSTOMS'].includes(type)) {
|
||||
throw new BadRequestException('type must be WAGON, CURRENCY, or CUSTOMS');
|
||||
}
|
||||
return this.service.nextRange(type, currency ?? null);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('priority-configs')
|
||||
@ApiOperation({ summary: 'Get a priority config by ID' })
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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 { isSuperAdmin } from '../../../common/freight-permission.util';
|
||||
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,
|
||||
) {
|
||||
// Super admins have full backoffice authority — they may approve a change
|
||||
// they submitted; everyone else is held to separation of duties.
|
||||
return this.service.approve(id, user?.id, dto.decisionNote, isSuperAdmin(user));
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { isSuperAdmin } from '../../../common/freight-permission.util';
|
||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import {
|
||||
@@ -70,9 +72,11 @@ export class RatesController {
|
||||
@ApiOperation({ summary: 'CEO approves a rate' })
|
||||
approve(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.approve(id, resolveAuthUserId(user));
|
||||
// Super admins have full backoffice authority — they may approve a rate
|
||||
// they proposed; everyone else is held to separation of duties.
|
||||
return this.service.approve(id, resolveAuthUserId(user), isSuperAdmin(user));
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -37,6 +37,8 @@ export function deriveRateType(input: {
|
||||
return 'DEMURRAGE';
|
||||
case 'PIL_EXTRA_FEE':
|
||||
return 'PIL_EXTRA_FEE';
|
||||
case 'CUSTOMS_CLEARANCE':
|
||||
return 'CUSTOMS_CLEARANCE';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,14 @@ export function allowedRateUnits(input: {
|
||||
return ['PER_CONTAINER', 'PER_TON'];
|
||||
case 'DEMURRAGE':
|
||||
return ['PER_CONTAINER', 'PER_TON'];
|
||||
case 'WITH_RETURN':
|
||||
// Container-only empty-return service — bills per returned container.
|
||||
return ['PER_CONTAINER', 'FLAT'];
|
||||
case 'CANCELLATION':
|
||||
return ['FLAT', 'PER_INVOICE'];
|
||||
case 'CUSTOMS_CLEARANCE':
|
||||
// Flat per clearance (ONE_TIME contract) / per shipment request (GENERAL).
|
||||
return ['FLAT'];
|
||||
case 'CONSOLIDATION':
|
||||
return ['PER_CONTAINER', 'FLAT'];
|
||||
case 'SHIPPING_LINE':
|
||||
|
||||
@@ -20,7 +20,9 @@ export const RATE_TYPES = [
|
||||
'OVERWEIGHT_PER_TON',
|
||||
'HAZARD_SURCHARGE',
|
||||
'REEFER_SURCHARGE',
|
||||
'RETURN_SURCHARGE',
|
||||
'PIL_EXTRA_FEE',
|
||||
'CUSTOMS_CLEARANCE',
|
||||
] as const;
|
||||
|
||||
export type RateType = typeof RATE_TYPES[number];
|
||||
@@ -70,11 +72,17 @@ export const RATE_TRIGGERS = [
|
||||
'HAZARDOUS',
|
||||
'OVERWEIGHT',
|
||||
'REEFER',
|
||||
// Empty-container return service (container freight only) — fires when the
|
||||
// booking ships WITH_RETURN, billed like hazard/reefer (usually PER_CONTAINER).
|
||||
'WITH_RETURN',
|
||||
'SHIPPING_LINE',
|
||||
'CONSOLIDATION',
|
||||
'CANCELLATION',
|
||||
'DEMURRAGE',
|
||||
'PIL_EXTRA_FEE',
|
||||
// Customs clearance service fee — billed up front via a clearance invoice,
|
||||
// never auto-applied to booking pricing (matchesTrigger returns false).
|
||||
'CUSTOMS_CLEARANCE',
|
||||
] as const;
|
||||
export type RateTrigger = typeof RATE_TRIGGERS[number];
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ApprovalRulesController } from './controllers/approval-rules.controller
|
||||
import { CargoTypesController } from './controllers/cargo-types.controller';
|
||||
import { ContainerTypesController } from './controllers/container-types.controller';
|
||||
import { PriorityConfigsController } from './controllers/priority-configs.controller';
|
||||
import { PriorityRuleChangeRequestsController } from './controllers/priority-rule-change-requests.controller';
|
||||
import { RatesController } from './controllers/rates.controller';
|
||||
import { ServiceTypesController } from './controllers/service-types.controller';
|
||||
import { ShippingLinesController } from './controllers/shipping-lines.controller';
|
||||
@@ -15,6 +16,7 @@ import { ApprovalRule } from './entities/approval-rule.entity';
|
||||
import { CargoType } from './entities/cargo-type.entity';
|
||||
import { ContainerType } from './entities/container-type.entity';
|
||||
import { PriorityConfig } from './entities/priority-config.entity';
|
||||
import { PriorityRuleChangeRequest } from './entities/priority-rule-change-request.entity';
|
||||
import { Rate } from './entities/rate.entity';
|
||||
import { ServiceType } from './entities/service-type.entity';
|
||||
import { ShippingLine } from './entities/shipping-line.entity';
|
||||
@@ -46,6 +48,7 @@ import { DisplayOrderService } from './services/display-order.service';
|
||||
import { CargoTypesService } from './services/cargo-types.service';
|
||||
import { ContainerTypesService } from './services/container-types.service';
|
||||
import { PriorityConfigsService } from './services/priority-configs.service';
|
||||
import { PriorityRuleChangeRequestsService } from './services/priority-rule-change-requests.service';
|
||||
import { RatesService } from './services/rates.service';
|
||||
import { ServiceTypesService } from './services/service-types.service';
|
||||
import { ShippingLinesService } from './services/shipping-lines.service';
|
||||
@@ -54,6 +57,8 @@ import { YardsService } from './services/yards.service';
|
||||
|
||||
import { RuleEngineService } from './rule-engine.service';
|
||||
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
|
||||
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
@@ -66,6 +71,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
CargoType,
|
||||
ContainerType,
|
||||
PriorityConfig,
|
||||
PriorityRuleChangeRequest,
|
||||
ServiceType,
|
||||
WeightLimitRule,
|
||||
Yard,
|
||||
@@ -77,11 +83,14 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
BookingApprovalStep,
|
||||
BookingRateSnapshot,
|
||||
]),
|
||||
// Team notifications for the priority-rule approval workflow.
|
||||
NotificationInboxModule,
|
||||
],
|
||||
controllers: [
|
||||
CargoTypesController,
|
||||
ContainerTypesController,
|
||||
PriorityConfigsController,
|
||||
PriorityRuleChangeRequestsController,
|
||||
ServiceTypesController,
|
||||
WeightLimitRulesController,
|
||||
YardsController,
|
||||
@@ -111,6 +120,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
CargoTypesService,
|
||||
ContainerTypesService,
|
||||
PriorityConfigsService,
|
||||
PriorityRuleChangeRequestsService,
|
||||
ServiceTypesService,
|
||||
WeightLimitRulesService,
|
||||
YardsService,
|
||||
|
||||
@@ -53,6 +53,11 @@ export interface BookingEvaluationInput {
|
||||
isHazardous: boolean;
|
||||
/** Booking-level reefer flag; ORed with per-container reefer. */
|
||||
isReefer?: boolean;
|
||||
/**
|
||||
* Booking ships with empty-container return (equipment_return = WITH_RETURN,
|
||||
* container freight only). Fires the WITH_RETURN surcharge like hazard/reefer.
|
||||
*/
|
||||
withReturn?: boolean;
|
||||
isGovernment?: boolean;
|
||||
allowConsolidation?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
@@ -228,6 +233,7 @@ export class RuleEngineService {
|
||||
const triggered = this.matchesTrigger(rate.trigger, {
|
||||
isHazardous: input.isHazardous,
|
||||
hasReefer,
|
||||
withReturn: input.withReturn ?? false,
|
||||
hasOverweight,
|
||||
shippingLineMapped,
|
||||
allowConsolidation: input.allowConsolidation ?? false,
|
||||
@@ -456,6 +462,7 @@ export class RuleEngineService {
|
||||
state: {
|
||||
isHazardous: boolean;
|
||||
hasReefer: boolean;
|
||||
withReturn: boolean;
|
||||
hasOverweight: boolean;
|
||||
shippingLineMapped: boolean;
|
||||
allowConsolidation: boolean;
|
||||
@@ -469,6 +476,8 @@ export class RuleEngineService {
|
||||
return truthy(state.isHazardous);
|
||||
case 'REEFER':
|
||||
return truthy(state.hasReefer);
|
||||
case 'WITH_RETURN':
|
||||
return truthy(state.withReturn);
|
||||
case 'OVERWEIGHT':
|
||||
return truthy(state.hasOverweight);
|
||||
case 'SHIPPING_LINE':
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { PriorityConfig } from '../entities/priority-config.entity';
|
||||
import { PriorityConfigsService } from './priority-configs.service';
|
||||
|
||||
/**
|
||||
* Contiguous-range rules for priority configs: per type (per currency for
|
||||
* CURRENCY), ranges run 1..cap with no gaps and no overlaps; the next range
|
||||
* must start at the lowest uncovered wagon count. Caps: WAGON 50,
|
||||
* CURRENCY 35, CUSTOMS 15.
|
||||
*/
|
||||
describe('PriorityConfigsService range validation', () => {
|
||||
const rule = (
|
||||
type: PriorityConfig['type'],
|
||||
min: number,
|
||||
max: number,
|
||||
currency: string | null = null,
|
||||
id = `${type}-${min}-${max}-${currency ?? 'none'}`,
|
||||
): PriorityConfig =>
|
||||
({
|
||||
id,
|
||||
type,
|
||||
label: `${min}-${max}`,
|
||||
currency,
|
||||
minWagonCount: min,
|
||||
maxWagonCount: max,
|
||||
}) as PriorityConfig;
|
||||
|
||||
const serviceWith = (rules: PriorityConfig[]): PriorityConfigsService => {
|
||||
const repository = {
|
||||
findAll: jest.fn(async ({ where }: { where: { type: string } }) =>
|
||||
rules.filter((r) => r.type === where.type),
|
||||
),
|
||||
findById: jest.fn(async (id: string) =>
|
||||
rules.find((r) => r.id === id) ?? null,
|
||||
),
|
||||
};
|
||||
return new PriorityConfigsService(
|
||||
repository as never,
|
||||
undefined as never, // DisplayOrderService — unused by range validation
|
||||
);
|
||||
};
|
||||
|
||||
const attempt = (
|
||||
svc: PriorityConfigsService,
|
||||
input: Partial<Parameters<PriorityConfigsService['assertNoRangeCollision']>[0]>,
|
||||
) =>
|
||||
svc.assertNoRangeCollision({
|
||||
type: 'WAGON',
|
||||
minWagonCount: 1,
|
||||
maxWagonCount: 5,
|
||||
...input,
|
||||
});
|
||||
|
||||
it('accepts the first WAGON range starting at 1', async () => {
|
||||
await expect(
|
||||
attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 5 }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a first range that does not start at 1', async () => {
|
||||
await expect(
|
||||
attempt(serviceWith([]), { minWagonCount: 3, maxWagonCount: 5 }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects an exact duplicate (1–5 vs 1–5)', async () => {
|
||||
await expect(
|
||||
attempt(serviceWith([rule('WAGON', 1, 5)]), {
|
||||
minWagonCount: 1,
|
||||
maxWagonCount: 5,
|
||||
}),
|
||||
).rejects.toThrow(/must start at 6/);
|
||||
});
|
||||
|
||||
it('rejects a partial overlap (4–7 after 1–5)', async () => {
|
||||
await expect(
|
||||
attempt(serviceWith([rule('WAGON', 1, 5)]), {
|
||||
minWagonCount: 4,
|
||||
maxWagonCount: 7,
|
||||
}),
|
||||
).rejects.toThrow(/must start at 6/);
|
||||
});
|
||||
|
||||
it('rejects a gap (8–9 after 1–5) — next range must start at 6', async () => {
|
||||
await expect(
|
||||
attempt(serviceWith([rule('WAGON', 1, 5)]), {
|
||||
minWagonCount: 8,
|
||||
maxWagonCount: 9,
|
||||
}),
|
||||
).rejects.toThrow(/must start at 6/);
|
||||
});
|
||||
|
||||
it('accepts the contiguous continuation (6–10 after 1–5)', async () => {
|
||||
await expect(
|
||||
attempt(serviceWith([rule('WAGON', 1, 5)]), {
|
||||
minWagonCount: 6,
|
||||
maxWagonCount: 10,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('after deleting a middle rule, the next range must fill the lowest gap', async () => {
|
||||
// Chain was 1–5, 6–10, 11–20; 6–10 deleted → next must start at 6.
|
||||
const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]);
|
||||
await expect(
|
||||
attempt(svc, { minWagonCount: 21, maxWagonCount: 25 }),
|
||||
).rejects.toThrow(/must start at 6/);
|
||||
await expect(
|
||||
attempt(svc, { minWagonCount: 6, maxWagonCount: 10 }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a gap-fill that overruns into the next rule (6–15 into 11–20)', async () => {
|
||||
const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]);
|
||||
await expect(
|
||||
attempt(svc, { minWagonCount: 6, maxWagonCount: 15 }),
|
||||
).rejects.toThrow(/overlaps existing rule/);
|
||||
});
|
||||
|
||||
it('enforces the per-type ceilings (WAGON 50, CURRENCY 35, CUSTOMS 15)', async () => {
|
||||
await expect(
|
||||
attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 51 }),
|
||||
).rejects.toThrow(/may not exceed 50/);
|
||||
await expect(
|
||||
attempt(serviceWith([]), {
|
||||
type: 'CURRENCY',
|
||||
currency: 'USD',
|
||||
minWagonCount: 1,
|
||||
maxWagonCount: 36,
|
||||
}),
|
||||
).rejects.toThrow(/may not exceed 35/);
|
||||
await expect(
|
||||
attempt(serviceWith([]), {
|
||||
type: 'CUSTOMS',
|
||||
minWagonCount: 1,
|
||||
maxWagonCount: 16,
|
||||
}),
|
||||
).rejects.toThrow(/may not exceed 15/);
|
||||
});
|
||||
|
||||
it('rejects any new rule once the chain covers the full range', async () => {
|
||||
await expect(
|
||||
attempt(serviceWith([rule('WAGON', 1, 50)]), {
|
||||
minWagonCount: 51,
|
||||
maxWagonCount: 51,
|
||||
}),
|
||||
).rejects.toThrow(/may not exceed 50/);
|
||||
await expect(
|
||||
attempt(serviceWith([rule('CUSTOMS', 1, 15)]), {
|
||||
type: 'CUSTOMS',
|
||||
minWagonCount: 1,
|
||||
maxWagonCount: 1,
|
||||
}),
|
||||
).rejects.toThrow(/already cover the full 1–15 range/);
|
||||
});
|
||||
|
||||
it('tracks CURRENCY chains per currency — USD and ETB are independent', async () => {
|
||||
const svc = serviceWith([rule('CURRENCY', 1, 5, 'USD')]);
|
||||
// ETB has no rules yet → starts at 1.
|
||||
await expect(
|
||||
attempt(svc, {
|
||||
type: 'CURRENCY',
|
||||
currency: 'ETB',
|
||||
minWagonCount: 1,
|
||||
maxWagonCount: 5,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
// USD must continue at 6.
|
||||
await expect(
|
||||
attempt(svc, {
|
||||
type: 'CURRENCY',
|
||||
currency: 'USD',
|
||||
minWagonCount: 1,
|
||||
maxWagonCount: 5,
|
||||
}),
|
||||
).rejects.toThrow(/must start at 6/);
|
||||
});
|
||||
|
||||
it('excludes the rule being edited from its own contiguity check', async () => {
|
||||
const existing = rule('WAGON', 6, 10, null, 'editing-me');
|
||||
const svc = serviceWith([rule('WAGON', 1, 5), existing]);
|
||||
// Re-saving 6–10 (e.g. changing points) keeps min 6 — allowed.
|
||||
await expect(
|
||||
attempt(svc, {
|
||||
minWagonCount: 6,
|
||||
maxWagonCount: 12,
|
||||
excludeId: 'editing-me',
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('lets an upper rule keep its start while a lower gap exists', async () => {
|
||||
// Chain 1–5, [gap 6–10], 11–20: editing 11–20 keeps min 11 — a lower gap
|
||||
// must not block editing an upper rule's points or max.
|
||||
const upper = rule('WAGON', 11, 20, null, 'upper');
|
||||
const svc = serviceWith([rule('WAGON', 1, 5), upper]);
|
||||
await expect(
|
||||
attempt(svc, {
|
||||
minWagonCount: 11,
|
||||
maxWagonCount: 25,
|
||||
excludeId: 'upper',
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
// But it cannot RELOCATE to an arbitrary start — only keep 11 or fill 6.
|
||||
await expect(
|
||||
attempt(svc, {
|
||||
minWagonCount: 30,
|
||||
maxWagonCount: 35,
|
||||
excludeId: 'upper',
|
||||
}),
|
||||
).rejects.toThrow(/must start at 6/);
|
||||
});
|
||||
|
||||
it('reports the next-range prefill for the form', async () => {
|
||||
const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]);
|
||||
await expect(svc.nextRange('WAGON')).resolves.toEqual({
|
||||
nextMin: 6,
|
||||
maxCap: 50,
|
||||
});
|
||||
await expect(
|
||||
serviceWith([rule('CUSTOMS', 1, 15)]).nextRange('CUSTOMS'),
|
||||
).resolves.toEqual({ nextMin: null, maxCap: 15 });
|
||||
await expect(serviceWith([]).nextRange('CURRENCY', 'USD')).resolves.toEqual({
|
||||
nextMin: 1,
|
||||
maxCap: 35,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,31 @@ import {
|
||||
} from '../interfaces/priority-configs.repository.interface';
|
||||
import { DisplayOrderService } from './display-order.service';
|
||||
|
||||
/** Hard ceiling of each type's wagon-count chain (1..cap, contiguous). */
|
||||
export const RANGE_CAPS: Record<'WAGON' | 'CURRENCY' | 'CUSTOMS', number> = {
|
||||
WAGON: 50,
|
||||
CURRENCY: 35,
|
||||
CUSTOMS: 15,
|
||||
};
|
||||
|
||||
/**
|
||||
* Lowest wagon count ≥ 1 not covered by any of `rules` — where the next range
|
||||
* must start. Null when the chain is already complete up to the type's cap.
|
||||
*/
|
||||
function nextRangeStart(
|
||||
rules: Pick<PriorityConfig, 'type' | 'minWagonCount' | 'maxWagonCount'>[],
|
||||
): number | null {
|
||||
const cap = rules.length ? RANGE_CAPS[rules[0].type] : null;
|
||||
const sorted = [...rules].sort((a, b) => a.minWagonCount - b.minWagonCount);
|
||||
let next = 1;
|
||||
for (const r of sorted) {
|
||||
if (r.minWagonCount > next) break; // gap before this rule — fill it
|
||||
next = Math.max(next, r.maxWagonCount + 1);
|
||||
}
|
||||
if (cap != null && next > cap) return null;
|
||||
return next;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PriorityConfigsService {
|
||||
constructor(
|
||||
@@ -31,6 +56,12 @@ export class PriorityConfigsService {
|
||||
|
||||
async create(dto: CreatePriorityConfigDto): Promise<PriorityConfig> {
|
||||
this.validateCurrencyField(dto.type, dto.currency);
|
||||
await this.assertNoRangeCollision({
|
||||
type: dto.type,
|
||||
currency: dto.currency ?? null,
|
||||
minWagonCount: dto.minWagonCount,
|
||||
maxWagonCount: dto.maxWagonCount,
|
||||
});
|
||||
|
||||
const displayOrder = await this.displayOrder.resolveCreateOrder(PriorityConfig, 'displayOrder', {});
|
||||
|
||||
@@ -52,6 +83,13 @@ export class PriorityConfigsService {
|
||||
const type = dto.type ?? existing.type;
|
||||
const currency = dto.currency !== undefined ? dto.currency : existing.currency;
|
||||
this.validateCurrencyField(type, currency);
|
||||
await this.assertNoRangeCollision({
|
||||
type,
|
||||
currency: currency ?? null,
|
||||
minWagonCount: dto.minWagonCount ?? existing.minWagonCount,
|
||||
maxWagonCount: dto.maxWagonCount ?? existing.maxWagonCount,
|
||||
excludeId: id,
|
||||
});
|
||||
|
||||
const { ...patch } = dto;
|
||||
const updated = await this.repository.update(id, patch);
|
||||
@@ -59,6 +97,100 @@ export class PriorityConfigsService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Range rules per type (and, for CURRENCY rules, per currency):
|
||||
* - ranges never overlap — a booking matches at most one rule per type;
|
||||
* - ranges are contiguous from 1: a new range must START at the lowest
|
||||
* wagon count not yet covered (after 1–5 the next is 6–…; deleting a
|
||||
* middle rule opens a gap and the next create must fill it first);
|
||||
* - each type has a hard ceiling: WAGON 50, CURRENCY 35, CUSTOMS 15.
|
||||
* 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 cap = RANGE_CAPS[input.type];
|
||||
if (input.maxWagonCount > cap) {
|
||||
throw new BadRequestException(
|
||||
`${input.type} ranges may not exceed ${cap} — ` +
|
||||
`${input.minWagonCount}–${input.maxWagonCount} goes past the ceiling.`,
|
||||
);
|
||||
}
|
||||
|
||||
const siblings = (
|
||||
await this.repository.findAll({ where: { type: input.type } })
|
||||
).filter(
|
||||
(s) =>
|
||||
s.id !== input.excludeId &&
|
||||
(input.type !== 'CURRENCY' ||
|
||||
(s.currency ?? null) === (input.currency ?? null)),
|
||||
);
|
||||
|
||||
const expectedStart = nextRangeStart(siblings);
|
||||
// An edited rule may always KEEP its current start (so a gap lower in the
|
||||
// chain never blocks editing an upper rule's points/max) — or move down to
|
||||
// fill that lowest gap.
|
||||
const currentStart = input.excludeId
|
||||
? (await this.repository.findById(input.excludeId))?.minWagonCount ?? null
|
||||
: null;
|
||||
if (expectedStart == null && currentStart == null) {
|
||||
throw new BadRequestException(
|
||||
`${input.type} rules already cover the full 1–${cap} range — ` +
|
||||
'delete or shrink an existing rule first.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
input.minWagonCount !== expectedStart &&
|
||||
input.minWagonCount !== currentStart
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`The next ${input.type} range must start at ${expectedStart} ` +
|
||||
`(ranges are contiguous — no gaps, no overlaps). ` +
|
||||
`You entered ${input.minWagonCount}–${input.maxWagonCount}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const clash = siblings.find(
|
||||
(s) =>
|
||||
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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the next range for a type/currency must start, and the type's
|
||||
* ceiling — feeds the create form so the min field is auto-filled and
|
||||
* locked. `nextMin` is null when the chain already covers 1..cap.
|
||||
*/
|
||||
async nextRange(
|
||||
type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',
|
||||
currency?: string | null,
|
||||
): Promise<{ nextMin: number | null; maxCap: number }> {
|
||||
const siblings = (
|
||||
await this.repository.findAll({ where: { type } })
|
||||
).filter(
|
||||
(s) =>
|
||||
type !== 'CURRENCY' || (s.currency ?? null) === (currency ?? null),
|
||||
);
|
||||
return { nextMin: nextRangeStart(siblings), maxCap: RANGE_CAPS[type] };
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.repository.softDelete(id);
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
} from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
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,
|
||||
canSelfApprove = false,
|
||||
): Promise<PriorityRuleChangeRequest> {
|
||||
const request = await this.findPending(id);
|
||||
|
||||
// Separation of duties: the requester cannot approve their own change —
|
||||
// except super admins, who have full backoffice authority.
|
||||
// TODO: split approval into a distinct approver permission rather than
|
||||
// relying on this id check.
|
||||
if (!canSelfApprove && userId && userId === request.requestedByUserId) {
|
||||
throw new ForbiddenException(
|
||||
'You cannot approve a change request you submitted',
|
||||
);
|
||||
}
|
||||
|
||||
// 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}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
@@ -194,11 +195,18 @@ export class RatesService {
|
||||
}
|
||||
|
||||
/** CEO approves a rate — moves to LIVE. */
|
||||
async approve(id: string, approverUserId: string): Promise<Rate> {
|
||||
async approve(id: string, approverUserId: string, canSelfApprove = false): Promise<Rate> {
|
||||
const rate = await this.findById(id);
|
||||
if (rate.status !== 'PENDING_APPROVAL') {
|
||||
throw new BadRequestException('Only PENDING_APPROVAL rates can be approved');
|
||||
}
|
||||
// Separation of duties: the proposer cannot approve their own rate — except
|
||||
// super admins, who have full backoffice authority (propose + approve).
|
||||
// TODO: split approval into a distinct CEO/approver permission — a normal
|
||||
// proposer who also holds the approve permission is still the wrong signer.
|
||||
if (!canSelfApprove && approverUserId === rate.proposedByStaffId) {
|
||||
throw new ForbiddenException('You cannot approve a rate you proposed');
|
||||
}
|
||||
const updated = await this.repository.update(id, {
|
||||
status: 'LIVE',
|
||||
approvedByCeoId: approverUserId,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsDateString } from 'class-validator';
|
||||
|
||||
import { PreviewRescheduleDto } from './preview-reschedule.dto';
|
||||
|
||||
/**
|
||||
* Body for the maintenance-reschedule endpoint. This must be a real class (not
|
||||
* the previous `PreviewRescheduleDto & { newDepartureDate: string }`
|
||||
* intersection): an intersection type carries no class-validator metadata, so
|
||||
* Nest's ValidationPipe silently skipped validation of the whole payload.
|
||||
*/
|
||||
export class MaintenanceRescheduleDto extends PreviewRescheduleDto {
|
||||
@ApiProperty({ example: '2026-06-22T08:00:00.000Z' })
|
||||
@IsDateString()
|
||||
newDepartureDate!: string;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
resolveAuthUserId,
|
||||
} from '../../common/resolve-auth-user-id';
|
||||
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
|
||||
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
||||
import { SchedulingRescheduleService } from './scheduling-reschedule.service';
|
||||
|
||||
@ApiTags('train-scheduling')
|
||||
@@ -53,7 +54,7 @@ export class SchedulingMaintenanceController {
|
||||
@ApiOperation({ summary: 'Reschedule train for maintenance (new departure + rebalance)' })
|
||||
maintenance(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: PreviewRescheduleDto & { newDepartureDate: string },
|
||||
@Body() dto: MaintenanceRescheduleDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.schedulingRescheduleService.maintenanceReschedule(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { EntityManager, Repository } from 'typeorm';
|
||||
|
||||
import { SchedulingEvent, type RescheduleTrigger } from './entities/scheduling-event.entity';
|
||||
|
||||
@@ -12,14 +12,18 @@ export class SchedulingRescheduleRepository {
|
||||
) {}
|
||||
|
||||
/** Persist an audit record for a completed reschedule. */
|
||||
async createEvent(data: {
|
||||
trainScheduleId: string;
|
||||
trigger: RescheduleTrigger;
|
||||
actorUserId?: string;
|
||||
reason?: string;
|
||||
planSnapshot: Record<string, unknown>;
|
||||
displacedBookingIds: string[];
|
||||
}): Promise<SchedulingEvent> {
|
||||
return this.repository.save(this.repository.create(data));
|
||||
async createEvent(
|
||||
data: {
|
||||
trainScheduleId: string;
|
||||
trigger: RescheduleTrigger;
|
||||
actorUserId?: string;
|
||||
reason?: string;
|
||||
planSnapshot: Record<string, unknown>;
|
||||
displacedBookingIds: string[];
|
||||
},
|
||||
manager?: EntityManager,
|
||||
): Promise<SchedulingEvent> {
|
||||
const repo = manager ? manager.getRepository(SchedulingEvent) : this.repository;
|
||||
return repo.save(repo.create(data));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,10 @@ describe('SchedulingRescheduleService', () => {
|
||||
let bookingsRepository: Record<string, jest.Mock>;
|
||||
let trainSchedulingService: Record<string, jest.Mock>;
|
||||
let schedulingRescheduleRepository: Record<string, jest.Mock>;
|
||||
// Sentinel EntityManager the mocked dataSource.transaction hands to the
|
||||
// callback; executeReschedule threads it into updateStatus/createEvent.
|
||||
const txManager = {} as never;
|
||||
let dataSource: { transaction: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
trainSchedulesRepository = {
|
||||
@@ -72,6 +76,9 @@ describe('SchedulingRescheduleService', () => {
|
||||
schedulingRescheduleRepository = {
|
||||
createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }),
|
||||
};
|
||||
dataSource = {
|
||||
transaction: jest.fn(async (cb: (m: never) => unknown) => cb(txManager)),
|
||||
};
|
||||
|
||||
service = new SchedulingRescheduleService(
|
||||
trainSchedulesRepository as never,
|
||||
@@ -83,6 +90,7 @@ describe('SchedulingRescheduleService', () => {
|
||||
removedFromTrain: jest.fn(),
|
||||
maintenanceMoved: jest.fn(),
|
||||
} as never, // notifier
|
||||
dataSource as never,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -212,7 +220,7 @@ describe('SchedulingRescheduleService', () => {
|
||||
incomingBookingIds: ['c1'],
|
||||
trigger: 'TRAIN_MAINTENANCE',
|
||||
reason: 'Locomotive service',
|
||||
newDepartureDate: '2026-06-22T10:00:00.000Z',
|
||||
newDepartureDate: '2099-06-22T10:00:00.000Z',
|
||||
},
|
||||
'staff-1',
|
||||
);
|
||||
@@ -220,7 +228,8 @@ describe('SchedulingRescheduleService', () => {
|
||||
expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith(
|
||||
'sched-1',
|
||||
'DRAFT',
|
||||
{ scheduledDepartureDate: new Date('2026-06-22T10:00:00.000Z') },
|
||||
{ scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z') },
|
||||
txManager,
|
||||
);
|
||||
expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -228,6 +237,7 @@ describe('SchedulingRescheduleService', () => {
|
||||
actorUserId: 'staff-1',
|
||||
reason: 'Locomotive service',
|
||||
}),
|
||||
txManager,
|
||||
);
|
||||
expect(result.plan.trigger).toBe('TRAIN_MAINTENANCE');
|
||||
expect(result.plan.finalBookingIds).toEqual(['c1']);
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { SchedulingStatus, TrainScheduleStatus } from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
@@ -12,6 +14,7 @@ import { TrainSchedulesRepository } from '../train-schedules/train-schedules.rep
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { BookingNotifierService } from '../train-scheduling/booking-notifier.service';
|
||||
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
|
||||
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
||||
import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
|
||||
|
||||
export interface RescheduleBookingSummary {
|
||||
@@ -40,6 +43,8 @@ export class SchedulingRescheduleService {
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository,
|
||||
private readonly notifier: BookingNotifierService,
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/** Preview who is retained, displaced, and readmitted on a schedule. */
|
||||
@@ -148,21 +153,38 @@ export class SchedulingRescheduleService {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
|
||||
if (dto.newDepartureDate && schedule) {
|
||||
await this.trainSchedulesRepository.updateStatus(
|
||||
scheduleId,
|
||||
schedule.status as TrainScheduleStatus,
|
||||
{ scheduledDepartureDate: new Date(dto.newDepartureDate) },
|
||||
);
|
||||
// M7: validate the requested departure BEFORE mutating anything, so a past
|
||||
// or malformed date is rejected up front rather than after (un)assign side
|
||||
// effects have already run. The actual write happens late (below), so a
|
||||
// failing (un)assign step never leaves the train visibly moved.
|
||||
let newDeparture: Date | null = null;
|
||||
if (dto.newDepartureDate) {
|
||||
newDeparture = new Date(dto.newDepartureDate);
|
||||
const now = new Date();
|
||||
if (Number.isNaN(newDeparture.getTime()) || newDeparture <= now) {
|
||||
throw new BadRequestException('New departure date must be in the future');
|
||||
}
|
||||
}
|
||||
|
||||
// H18: run the cross-service (un)assign steps FIRST. They own their own
|
||||
// transactions and are the steps most likely to fail, so doing them before
|
||||
// the date change + audit write means a failure aborts before anything of
|
||||
// ours is committed.
|
||||
for (const bookingId of dto.displacedBookingIds) {
|
||||
try {
|
||||
await this.trainSchedulingService.unassignBooking(scheduleId, bookingId);
|
||||
} catch {
|
||||
// M11: the unassign failed but this booking is being removed from the
|
||||
// train — also clear its schedule pointer, otherwise it stays linked
|
||||
// (stale trainScheduleId) and risks being double-booked. NOTE: the
|
||||
// TrainScheduleBooking link row / wagon allocations may still persist
|
||||
// (deleting those lives in TrainSchedulingService, not an injected repo
|
||||
// we own), so this is a best-effort detach; a human should finish the
|
||||
// link/allocation cleanup.
|
||||
await this.bookingsRepository.updateSchedulingFields(bookingId, {
|
||||
schedulingStatus: SchedulingStatus.Eligible,
|
||||
wagonsRequired: null,
|
||||
trainScheduleId: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -170,7 +192,7 @@ export class SchedulingRescheduleService {
|
||||
// A train can be rescheduled even with no bookings (e.g. moved for
|
||||
// maintenance). assignBookingsToSchedule requires at least one booking, so
|
||||
// only call it when something is actually being (re)assigned — the new
|
||||
// departure date above is the meaningful change for an empty train. The
|
||||
// departure date below is the meaningful change for an empty train. The
|
||||
// empty-train branch returns the same schedule-detail shape as the assign
|
||||
// path so callers get a consistent response.
|
||||
const assignResult = dto.finalBookingIds.length
|
||||
@@ -186,25 +208,50 @@ export class SchedulingRescheduleService {
|
||||
deferredBookings: [] as unknown[],
|
||||
};
|
||||
|
||||
await this.schedulingRescheduleRepository.createEvent({
|
||||
trainScheduleId: scheduleId,
|
||||
trigger: dto.trigger,
|
||||
actorUserId,
|
||||
reason: dto.reason,
|
||||
planSnapshot: plan as unknown as Record<string, unknown>,
|
||||
displacedBookingIds: dto.displacedBookingIds,
|
||||
// H18: apply the date change and write the audit record LAST, together, in a
|
||||
// single transaction over repositories we own (both updateStatus and
|
||||
// createEvent accept our manager, so the two writes commit or roll back as
|
||||
// one). RESIDUAL RISK: the cross-service (un)assign calls above are NOT
|
||||
// covered by this transaction — they run their own and cannot be threaded
|
||||
// through this manager without editing TrainSchedulingService. A failure
|
||||
// between those steps and this block can still leave partial state; a human
|
||||
// must finish the full cross-service transaction threading.
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
if (newDeparture) {
|
||||
// M7: raw write of scheduledDepartureDate. We deliberately do NOT
|
||||
// delegate to TrainSchedulingService.updateScheduleDate, which only
|
||||
// permits a date change while windowPhase === 'PRE_WINDOW' and would
|
||||
// reject reschedules of already-open (SCHEDULED) trains. Consequence:
|
||||
// the booking-window fields are NOT re-derived for the new date here.
|
||||
await this.trainSchedulesRepository.updateStatus(
|
||||
scheduleId,
|
||||
schedule.status as TrainScheduleStatus,
|
||||
{ scheduledDepartureDate: newDeparture },
|
||||
manager,
|
||||
);
|
||||
}
|
||||
|
||||
await this.schedulingRescheduleRepository.createEvent(
|
||||
{
|
||||
trainScheduleId: scheduleId,
|
||||
trigger: dto.trigger,
|
||||
actorUserId,
|
||||
reason: dto.reason,
|
||||
planSnapshot: plan as unknown as Record<string, unknown>,
|
||||
displacedBookingIds: dto.displacedBookingIds,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
// Notify affected customers (SMS + email). Best-effort — a notification
|
||||
// failure must never fail the reschedule, so each send is fire-and-forget
|
||||
// inside the notifier. Government pre-empt already notifies via the batch
|
||||
// displaced() path, so skip removed-from-train notices for that trigger.
|
||||
// Use the new departure date when the reschedule moved it (the in-memory
|
||||
// `schedule` still holds the pre-update date).
|
||||
const effectiveDeparture = dto.newDepartureDate
|
||||
? new Date(dto.newDepartureDate)
|
||||
: schedule.scheduledDepartureDate;
|
||||
await this.notifyRescheduleOutcome(dto, effectiveDeparture);
|
||||
// M12: only announce a new departure when the date actually moved —
|
||||
// `newDeparture` is null when the date was unchanged, so retained customers
|
||||
// are not falsely told the train was rescheduled.
|
||||
await this.notifyRescheduleOutcome(dto, newDeparture);
|
||||
|
||||
return { plan, schedule: assignResult };
|
||||
}
|
||||
@@ -256,17 +303,26 @@ export class SchedulingRescheduleService {
|
||||
/** Maintenance shortcut: new departure + rebalance. */
|
||||
async maintenanceReschedule(
|
||||
scheduleId: string,
|
||||
dto: PreviewRescheduleDto & { newDepartureDate: string },
|
||||
dto: MaintenanceRescheduleDto,
|
||||
actorUserId?: string,
|
||||
) {
|
||||
const currentIds = (
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId)
|
||||
)?.scheduleBookings?.map((l) => l.bookingId) ?? [];
|
||||
|
||||
// M13: merge the bookings already on the train with any caller-supplied
|
||||
// incoming ids and feed the SAME set to both preview and execute. The old
|
||||
// code dropped the caller's ids whenever the train was non-empty (preview)
|
||||
// and then executed against a different (raw) set, so the previewed plan and
|
||||
// the executed plan could diverge.
|
||||
const mergedIncomingIds = Array.from(
|
||||
new Set([...currentIds, ...(dto.incomingBookingIds ?? [])]),
|
||||
);
|
||||
|
||||
const preview = await this.previewReschedule(scheduleId, {
|
||||
...dto,
|
||||
trigger: 'TRAIN_MAINTENANCE',
|
||||
incomingBookingIds: currentIds.length ? currentIds : dto.incomingBookingIds,
|
||||
incomingBookingIds: mergedIncomingIds,
|
||||
});
|
||||
|
||||
return this.executeReschedule(
|
||||
@@ -274,7 +330,7 @@ export class SchedulingRescheduleService {
|
||||
{
|
||||
...dto,
|
||||
trigger: 'TRAIN_MAINTENANCE',
|
||||
incomingBookingIds: dto.incomingBookingIds,
|
||||
incomingBookingIds: mergedIncomingIds,
|
||||
finalBookingIds: preview.finalBookingIds,
|
||||
displacedBookingIds: preview.displaced.map((b) => b.id),
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ export interface SchedulingPriorityBooking {
|
||||
isGovernment?: boolean;
|
||||
priorityScore?: number | null;
|
||||
// One-time bookings always carry a date; general contracts (never scheduled)
|
||||
// may be null — treated as epoch 0 so they sort last.
|
||||
// may be null — treated as the far future (MAX_SAFE_INTEGER) so they sort last.
|
||||
scheduledDate?: Date | string | null;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,11 @@ export function compareSchedulingPriority(
|
||||
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
|
||||
const aTime = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0;
|
||||
const bTime = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0;
|
||||
const aTime = a.scheduledDate
|
||||
? new Date(a.scheduledDate).getTime()
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const bTime = b.scheduledDate
|
||||
? new Date(b.scheduledDate).getTime()
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
return aTime - bTime;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { TrackingService } from "./tracking.service";
|
||||
|
||||
@ApiTags("tracking")
|
||||
@ApiBearerAuth()
|
||||
@Controller("tracking")
|
||||
@BookingStaff(FREIGHT_PERMS.tracking.view)
|
||||
export class TrackingController {
|
||||
constructor(private readonly trackingService: TrackingService) {}
|
||||
|
||||
@Get(":consignmentId")
|
||||
@ApiOperation({ summary: "Get the tracking timeline for a consignment" })
|
||||
// TODO: scope to the caller's company — a staffer with tracking:view can
|
||||
// currently read any consignment's timeline. Add company-ownership filtering
|
||||
// once the ownership helper is wired into this module.
|
||||
findByConsignment(
|
||||
@Param("consignmentId", ParseUUIDPipe) consignmentId: string,
|
||||
) {
|
||||
|
||||
@@ -109,17 +109,26 @@ describe('computeImportWindowTimes — first-window open respects office hours',
|
||||
});
|
||||
|
||||
it('caps the close at departure', () => {
|
||||
// Opens now (05 Jul 12:00 EAT); a 24h duration would close 06 Jul 12:00 EAT,
|
||||
// past the 06 Jul 08:00 departure → clamped to departure.
|
||||
// Round-the-clock desk (no desk-close cap in play). Opens now (05 Jul 12:00
|
||||
// EAT); a 24h duration would close 06 Jul 12:00 EAT, past the 06 Jul 08:00
|
||||
// departure → clamped to departure.
|
||||
const now = new Date('2026-07-05T09:00:00.000Z');
|
||||
const { windowClosesAt } = computeImportWindowTimes(
|
||||
departure,
|
||||
{ ...bounded, windowDurationHours: 24 },
|
||||
{ ...bounded, windowOpenHour: 8, windowCloseHour: 8, windowDurationHours: 24 },
|
||||
now,
|
||||
);
|
||||
expect(windowClosesAt.toISOString()).toBe(departure.toISOString());
|
||||
});
|
||||
|
||||
it('desk close hour cuts the window short (duration never outlives the desk)', () => {
|
||||
// Opens now (05 Jul 12:00 EAT); the 15h duration would run to 03:00 next
|
||||
// day, but the desk shuts 17:00 EAT (14:00 UTC) → the window closes with it.
|
||||
const now = new Date('2026-07-05T09:00:00.000Z');
|
||||
const { windowClosesAt } = computeImportWindowTimes(departure, bounded, now);
|
||||
expect(windowClosesAt.toISOString()).toBe('2026-07-05T14:00:00.000Z');
|
||||
});
|
||||
|
||||
describe('overnight desk (open > close, wraps past midnight)', () => {
|
||||
// Desk open 08:00, closes 05:00 next morning — open across midnight.
|
||||
const overnight = { ...bounded, windowOpenHour: 8, windowCloseHour: 5 };
|
||||
@@ -189,13 +198,13 @@ describe('computeImportWindowTimes — overnight desk (open > close, wraps midni
|
||||
|
||||
describe('batch-window board windows (config-driven booking cycles)', () => {
|
||||
// Default rules: open 08:00 EAT, desk shuts 17:00, 3 days before departure,
|
||||
// 3h long, reopen 90m later.
|
||||
// 3h long, reopen gap (doc review + payment) 90m.
|
||||
const cfg: BoardWindowConfig = {
|
||||
importWindowLeadDays: 3,
|
||||
windowOpenHour: 8,
|
||||
windowCloseHour: 17,
|
||||
windowDurationHours: 3,
|
||||
reopenDelayMinutes: 90,
|
||||
reopenGapMinutes: 90,
|
||||
exportBookingLeadHours: 24,
|
||||
};
|
||||
|
||||
@@ -211,7 +220,7 @@ describe('batch-window board windows (config-driven booking cycles)', () => {
|
||||
expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z');
|
||||
});
|
||||
|
||||
it('import: reopens reopenDelayMinutes after close while inside office hours', () => {
|
||||
it('import: reopens after the doc-review + payment gap while inside office hours', () => {
|
||||
const departure = new Date('2026-06-08T11:00:00.000Z');
|
||||
const windows = listConfigBookingWindows('IMPORT', departure, cfg);
|
||||
// cycle 1: 08:00–11:00; reopen +90m → cycle 2 opens 12:30 EAT, same day
|
||||
@@ -250,6 +259,17 @@ describe('batch-window board windows (config-driven booking cycles)', () => {
|
||||
expect(new Set(windows.map((w) => w.date)).size).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('import: desk close hour cuts a cycle short (duration past 17:00 clamps)', () => {
|
||||
const longCfg: BoardWindowConfig = { ...cfg, windowDurationHours: 10 };
|
||||
const departure = new Date('2026-06-08T11:00:00.000Z');
|
||||
const windows = listConfigBookingWindows('IMPORT', departure, longCfg);
|
||||
// Cycle 1 opens 08:00 EAT; 10h would close 18:00 — desk shuts 17:00 (14:00 UTC).
|
||||
expect(windows[0].start.toISOString()).toBe('2026-06-05T05:00:00.000Z');
|
||||
expect(windows[0].end.toISOString()).toBe('2026-06-05T14:00:00.000Z');
|
||||
// Reopen 90m after the clamped close lands past 17:00 → next morning 08:00 EAT.
|
||||
expect(windows[1].start.toISOString()).toBe('2026-06-06T05:00:00.000Z');
|
||||
});
|
||||
|
||||
it('export: single FCFS window exportBookingLeadHours before departure', () => {
|
||||
const departure = new Date('2026-06-08T11:00:00.000Z');
|
||||
const windows = listConfigBookingWindows('EXPORT', departure, cfg);
|
||||
|
||||
@@ -223,6 +223,49 @@ export function nextCycleOpensAt(
|
||||
return opensAt.getTime() < departure.getTime() ? opensAt : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The desk-close instant of the office window containing `opensAt`; null for a
|
||||
* round-the-clock desk. Same-day desk (open < close): closeHour on `opensAt`'s
|
||||
* EAT day. Overnight desk (open > close): closeHour on the NEXT EAT day when
|
||||
* `opensAt` sits in the evening half, closeHour the same day when it sits in the
|
||||
* after-midnight half.
|
||||
*/
|
||||
export function officeCloseAfter(opensAt: Date, hours: OfficeHours): Date | null {
|
||||
if (isRoundTheClock(hours)) return null;
|
||||
const { hour, minute } = eatParts(opensAt);
|
||||
const openMinutes = hour * 60 + minute;
|
||||
if (
|
||||
hours.windowOpenHour > hours.windowCloseHour &&
|
||||
openMinutes >= hours.windowOpenHour * 60
|
||||
) {
|
||||
return eatDayToUtc(shiftEatDay(eatDay(opensAt), 1), hours.windowCloseHour);
|
||||
}
|
||||
return eatDayToUtc(eatDay(opensAt), hours.windowCloseHour);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap a window close at the desk-close hour that follows its open: the office
|
||||
* hours end a running window early rather than letting the duration outlive the
|
||||
* desk (open 16:00, 3h duration, desk 8–17 → closes 17:00, not 19:00). A
|
||||
* round-the-clock desk never caps; a desk-close at/before the open (degenerate
|
||||
* config) is ignored so the window is never clamped to zero length here.
|
||||
*/
|
||||
export function clampCloseToOfficeHours(
|
||||
opensAt: Date,
|
||||
closesAt: Date,
|
||||
hours: OfficeHours,
|
||||
): Date {
|
||||
const deskClose = officeCloseAfter(opensAt, hours);
|
||||
if (
|
||||
deskClose != null &&
|
||||
deskClose.getTime() > opensAt.getTime() &&
|
||||
closesAt.getTime() > deskClose.getTime()
|
||||
) {
|
||||
return deskClose;
|
||||
}
|
||||
return closesAt;
|
||||
}
|
||||
|
||||
export interface InitialWindowTimes {
|
||||
windowOpensAt: Date;
|
||||
windowClosesAt: Date;
|
||||
@@ -243,7 +286,8 @@ export interface InitialWindowTimes {
|
||||
* • `now` before openHour that EAT day → opens at openHour that morning
|
||||
* • `now` at/after closeHour → desk shut; opens openHour next morning
|
||||
*
|
||||
* `windowDurationHours` extends from that open, capped at departure.
|
||||
* `windowDurationHours` extends from that open, capped at the desk close hour
|
||||
* and at departure.
|
||||
*/
|
||||
export function computeImportWindowTimes(
|
||||
departure: Date,
|
||||
@@ -276,6 +320,10 @@ export function computeImportWindowTimes(
|
||||
}
|
||||
|
||||
let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000);
|
||||
closesAt = clampCloseToOfficeHours(opensAt, closesAt, {
|
||||
windowOpenHour: cfg.windowOpenHour,
|
||||
windowCloseHour: cfg.windowCloseHour,
|
||||
});
|
||||
if (closesAt.getTime() > departure.getTime()) {
|
||||
closesAt = departure;
|
||||
}
|
||||
@@ -381,10 +429,11 @@ export function listBatchWindowsForBookings(
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Board-display windows: the REAL booking-window cycles derived from the
|
||||
// train_scheduling_global_rules config (window open hour, lead days, duration,
|
||||
// reopen delay) — NOT a fixed clock grid. Import shows each booking-window cycle
|
||||
// (opens at windowOpenHour EAT, lasts windowDurationHours, reopens after
|
||||
// reopenDelayMinutes until departure). Export shows the single FCFS lead window.
|
||||
// schedule's frozen window rule (open/close hour, lead days, duration, reopen
|
||||
// gap = doc review + payment) — NOT a fixed clock grid. Import shows each
|
||||
// booking-window cycle (opens at windowOpenHour EAT, lasts windowDurationHours
|
||||
// capped at the desk close, reopens after the gap until departure). Export shows
|
||||
// the single FCFS lead window.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A board window carries an EAT calendar date in addition to the slot times. */
|
||||
@@ -402,8 +451,11 @@ export interface BoardWindowConfig {
|
||||
/** EAT hour the daily booking desk shuts; equals windowOpenHour for a 24h desk. */
|
||||
windowCloseHour: number;
|
||||
windowDurationHours: number;
|
||||
/** Gap between a cycle's close and its reopen (doc review + payment minutes). */
|
||||
reopenDelayMinutes: number;
|
||||
/**
|
||||
* Gap between a cycle's close and its reopen — always doc review + payment
|
||||
* minutes (the schedule's frozen snapshot, or the live sum for legacy rows).
|
||||
*/
|
||||
reopenGapMinutes: number;
|
||||
exportBookingLeadHours: number;
|
||||
}
|
||||
|
||||
@@ -435,10 +487,11 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
|
||||
* The real booking-window cycles for a schedule, straight from config.
|
||||
*
|
||||
* IMPORT: first window opens at `windowOpenHour` EAT on `departure − importWindowLeadDays`
|
||||
* for `windowDurationHours`; if the train isn't full it reopens `reopenDelayMinutes`
|
||||
* after each close, on the same booking day, until departure. This mirrors
|
||||
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
|
||||
* exact windows the engine runs.
|
||||
* for `windowDurationHours` (cut short by the desk close hour); if the train isn't
|
||||
* full it reopens `reopenGapMinutes` (doc review + payment) after each close,
|
||||
* honouring office hours, until departure. This mirrors `computeImportWindowTimes`
|
||||
* + `concludeCycle`'s reopen math so the board shows the exact windows the engine
|
||||
* runs.
|
||||
* EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure,
|
||||
* with the open shifted to the next desk opening when it lands outside office hours
|
||||
* (same math as `computeExportWindowTimes`).
|
||||
@@ -464,7 +517,7 @@ export function listConfigBookingWindows(
|
||||
const durationMs = cfg.windowDurationHours * 3_600_000;
|
||||
// Post-close gap before the next cycle opens (doc review + payment), subject
|
||||
// to office hours below.
|
||||
const reopenMs = cfg.reopenDelayMinutes * 60_000;
|
||||
const reopenMs = cfg.reopenGapMinutes * 60_000;
|
||||
const officeHours: OfficeHours = {
|
||||
windowOpenHour: cfg.windowOpenHour,
|
||||
windowCloseHour: cfg.windowCloseHour,
|
||||
@@ -484,6 +537,7 @@ export function listConfigBookingWindows(
|
||||
for (let cycle = 0; cycle < maxCycles; cycle += 1) {
|
||||
if (opensAt.getTime() >= departure.getTime()) break;
|
||||
let closesAt = new Date(opensAt.getTime() + durationMs);
|
||||
closesAt = clampCloseToOfficeHours(opensAt, closesAt, officeHours);
|
||||
if (closesAt.getTime() > departure.getTime()) closesAt = departure;
|
||||
windows.push(boardWindowFromInterval(opensAt, closesAt));
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
};
|
||||
let trainSchedulingService: {
|
||||
tryAutoWagonAllocation: jest.Mock;
|
||||
previewPaidBookingWagonShortage: jest.Mock;
|
||||
getBookableSchedules: jest.Mock;
|
||||
getWindowConfig: jest.Mock;
|
||||
};
|
||||
@@ -87,6 +88,8 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
issues: [],
|
||||
violations: [],
|
||||
}),
|
||||
// No shortage by default — paid bookings link as before.
|
||||
previewPaidBookingWagonShortage: jest.fn().mockResolvedValue(null),
|
||||
getBookableSchedules: jest.fn().mockResolvedValue([]),
|
||||
getWindowConfig: jest.fn().mockResolvedValue({
|
||||
importWindowLeadDays: 3,
|
||||
@@ -96,7 +99,6 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
windowDurationHours: 3,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
reopenDelayMinutes: 90,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -169,6 +171,38 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('ensurePaidBookingAllocated holds a wagon-short booking out of the train', async () => {
|
||||
trainSchedulingService.previewPaidBookingWagonShortage.mockResolvedValue({
|
||||
wagonTypeCodes: 'NW6',
|
||||
wagonsNeeded: 1,
|
||||
wagonsAvailable: 0,
|
||||
wagonsShort: 1,
|
||||
});
|
||||
|
||||
await service.ensurePaidBookingAllocated(bookingId);
|
||||
|
||||
// Not linked, no wagon run — held PAID + unlinked, flagged for manual placement.
|
||||
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
|
||||
expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled();
|
||||
expect(dataSource.getRepository().update).toHaveBeenCalledWith(
|
||||
bookingId,
|
||||
expect.objectContaining({ schedulingStatus: 'WAITING_FOR_WAGON' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reconcilePaidUnlinked leaves WAITING_FOR_WAGON bookings held', async () => {
|
||||
bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([
|
||||
{ ...paidBooking, schedulingStatus: 'WAITING_FOR_WAGON' },
|
||||
]);
|
||||
|
||||
await service.reconcilePaidUnlinked(scheduleId);
|
||||
|
||||
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
|
||||
expect(
|
||||
trainSchedulingService.previewPaidBookingWagonShortage,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => {
|
||||
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0);
|
||||
const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined);
|
||||
|
||||
@@ -497,6 +497,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const linked =
|
||||
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
|
||||
if (!linked) {
|
||||
if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return;
|
||||
await this.allocate(booking.trainScheduleId, booking, "paid");
|
||||
this.logger.log(
|
||||
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
|
||||
@@ -768,7 +769,54 @@ export class BookingBatchService implements OnModuleInit {
|
||||
bookings: Booking[],
|
||||
scheduleId: string,
|
||||
): Promise<void> {
|
||||
for (const b of bookings) await this.reserve(b, scheduleId);
|
||||
// H8: the capacity check (pickExportSchedule → budget.fits) and the
|
||||
// reservation writes below are not atomic on their own — two concurrent
|
||||
// export accepts can each see the same train as fitting and both reserve,
|
||||
// overshooting the train's capacity. Serialize reservations against this
|
||||
// schedule: take a pessimistic_write lock on the TrainSchedule row
|
||||
// (SELECT … FOR UPDATE), then RE-VERIFY budget.fits for these bookings'
|
||||
// combined need from freshly-committed state INSIDE the lock before the
|
||||
// reserve writes run. A loser (another accept took the space first) gets a
|
||||
// ConflictException — the staff accept fails and reverts, exactly as an
|
||||
// up-front full train does. Covered: the fits-vs-reserve overshoot on the
|
||||
// export FCFS path; the lock is held for the duration of the reserve writes.
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const locked = await manager.findOne(TrainSchedule, {
|
||||
where: { id: scheduleId },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
if (!locked) {
|
||||
throw new ConflictException(
|
||||
"Export train is no longer available for reservation",
|
||||
);
|
||||
}
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !locomotive) {
|
||||
throw new ConflictException(
|
||||
"Export train is no longer available for reservation",
|
||||
);
|
||||
}
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
const leg = budget.legOf(
|
||||
bookings[0].originYardId,
|
||||
bookings[0].destinationYardId,
|
||||
);
|
||||
const need =
|
||||
bookings.length >= 2
|
||||
? this.combinedNeed(bookings[0], bookings[1], wagonDims)
|
||||
: this.needFor(bookings[0], wagonDims);
|
||||
if (!leg || !budget.fits(need, leg)) {
|
||||
throw new ConflictException(
|
||||
"Train is full — no export capacity left for this day",
|
||||
);
|
||||
}
|
||||
|
||||
for (const b of bookings) await this.reserve(b, scheduleId);
|
||||
});
|
||||
this.armSettle(scheduleId);
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
@@ -783,6 +831,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const unlinked =
|
||||
await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId);
|
||||
for (const booking of unlinked) {
|
||||
// Held on purpose (paid, no wagon free) — the cron must not undo it.
|
||||
if (booking.schedulingStatus === "WAITING_FOR_WAGON") continue;
|
||||
if (await this.holdIfWagonShort(scheduleId, booking)) continue;
|
||||
await this.allocate(scheduleId, booking, "paid");
|
||||
this.logger.log(
|
||||
`Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
|
||||
@@ -1050,7 +1101,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
s.ruleWindowDurationHours,
|
||||
liveCfg.windowDurationHours,
|
||||
),
|
||||
reopenDelayMinutes: num(s.ruleReopenDelayMinutes, liveCfg.reopenDelayMinutes),
|
||||
// Frozen doc-review + payment sum; legacy rows fall back to the live sum.
|
||||
reopenGapMinutes: num(
|
||||
s.ruleReopenDelayMinutes,
|
||||
liveCfg.docReviewMinutes + liveCfg.paymentWindowMinutes,
|
||||
),
|
||||
importWindowLeadDays: num(
|
||||
s.ruleImportWindowLeadDays,
|
||||
liveCfg.importWindowLeadDays,
|
||||
@@ -1894,7 +1949,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
done.add(booking.id);
|
||||
if (isPaid(booking)) {
|
||||
await this.allocate(scheduleId, booking, "paid");
|
||||
if (!(await this.holdIfWagonShort(scheduleId, booking))) {
|
||||
await this.allocate(scheduleId, booking, "paid");
|
||||
}
|
||||
anySettled = true;
|
||||
} else if (isExpired(booking)) {
|
||||
await this.expire(booking);
|
||||
@@ -1920,6 +1977,29 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Conclude-time retry: promote whatever still fits from the route-day waiting
|
||||
* list, opening fresh pay windows. Returns how many commercial units got
|
||||
* reserved — corridor-wide, since the fill is day-level and may reserve onto a
|
||||
* sibling train; the caller must check `hasLiveReservations` for its OWN
|
||||
* schedule before deciding to stay in PAYMENT.
|
||||
*/
|
||||
async fillFromWaitingList(scheduleId: string): Promise<number> {
|
||||
return this.withScheduleLock(scheduleId, async () => {
|
||||
let promoted = 0;
|
||||
for (let round = 0; round < 10; round += 1) {
|
||||
const reservedThisRound = await this.topUpFill(scheduleId);
|
||||
if (reservedThisRound <= 0) break;
|
||||
promoted += reservedThisRound;
|
||||
await this.extendPaymentPhaseForTopUp(scheduleId);
|
||||
}
|
||||
if (promoted > 0) {
|
||||
this.notifyBoardChanged(scheduleId, "conclude_waiting_list_fill");
|
||||
}
|
||||
return promoted;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle, then keep promoting the waiting list until the train can take no more.
|
||||
* Returns whether anything settled.
|
||||
@@ -2050,7 +2130,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { paymentStatus: "PAID" });
|
||||
await this.allocate(booking.trainScheduleId, booking, "paid");
|
||||
if (!(await this.holdIfWagonShort(booking.trainScheduleId, booking))) {
|
||||
await this.allocate(booking.trainScheduleId, booking, "paid");
|
||||
}
|
||||
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
booking.trainScheduleId,
|
||||
@@ -2112,7 +2194,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await manager.getRepository(Booking).update(bookingId, {
|
||||
trainScheduleId: newScheduleId,
|
||||
status: restoredStatus,
|
||||
schedulingStatus: "ELIGIBLE",
|
||||
// A paid booking still hunting for a wagon keeps its flag through the
|
||||
// move — it only clears when wagons are actually assigned.
|
||||
schedulingStatus:
|
||||
booking.schedulingStatus === "WAITING_FOR_WAGON"
|
||||
? "WAITING_FOR_WAGON"
|
||||
: "ELIGIBLE",
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
@@ -2254,6 +2341,50 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */
|
||||
/**
|
||||
* Fleet preflight shared by every single-booking paid-allocation path: when
|
||||
* no wagon of the booking's required type is free, hold it OUT of the train
|
||||
* instead of linking — it stays PAID + unlinked in the (route, day) pool,
|
||||
* flagged WAITING_FOR_WAGON, and staff place it on any same-day schedule from
|
||||
* the workspace "Paid · unassigned" panel once a wagon frees up. Returns true
|
||||
* when the booking was held. Consolidated pairs are exempt (the shared wagon
|
||||
* is both-or-neither and settles atomically in settleReserved).
|
||||
*/
|
||||
private async holdIfWagonShort(
|
||||
scheduleId: string,
|
||||
booking: Booking,
|
||||
): Promise<boolean> {
|
||||
if (booking.consolidationPartnerId) return false;
|
||||
const shortage =
|
||||
await this.trainSchedulingService.previewPaidBookingWagonShortage(
|
||||
scheduleId,
|
||||
booking.id,
|
||||
);
|
||||
if (!shortage) return false;
|
||||
|
||||
await this.dataSource.getRepository(Booking).update(booking.id, {
|
||||
status: "PAID",
|
||||
paymentStatus: "PAID",
|
||||
schedulingStatus: "WAITING_FOR_WAGON",
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
// Payment landed — record it even though nothing boards yet. The wagon
|
||||
// milestone stays pending until staff assign one.
|
||||
void this.completeTrackingMilestones(booking.id, [
|
||||
"FREIGHT_PAYMENT_PENDING",
|
||||
"FREIGHT_PAYMENT_SETTLED",
|
||||
]);
|
||||
this.logger.warn(
|
||||
`PAID booking ${booking.reference ?? booking.id} is WAITING FOR WAGON: ` +
|
||||
`needs ${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` +
|
||||
`${shortage.wagonsAvailable} available (short ${shortage.wagonsShort}). ` +
|
||||
`Held in the day pool for manual placement.`,
|
||||
);
|
||||
this.notifyBoardChanged(scheduleId, "booking_waiting_wagon");
|
||||
return true;
|
||||
}
|
||||
|
||||
private async allocate(
|
||||
scheduleId: string,
|
||||
booking: Booking,
|
||||
@@ -2358,7 +2489,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
`[BATCH] expire skipped for ${booking.reference} — payment already ` +
|
||||
`landed; allocating on schedule ${paidScheduleId} instead`,
|
||||
);
|
||||
await this.allocate(paidScheduleId, fresh, "paid");
|
||||
if (!(await this.holdIfWagonShort(paidScheduleId, fresh))) {
|
||||
await this.allocate(paidScheduleId, fresh, "paid");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,6 @@ export interface BookingWindowConfig {
|
||||
/** Max staff document-review time after the window closes. */
|
||||
docReviewMinutes: number;
|
||||
paymentWindowMinutes: number;
|
||||
/** Delay after window close before reopening when the train is not full. */
|
||||
reopenDelayMinutes: number;
|
||||
}
|
||||
|
||||
/** Window phase lifecycle for the one-booking-day import cycle. NULL on legacy/DOMESTIC schedules. */
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('BookingWindowService — window state machine', () => {
|
||||
hasLiveReservations: jest.Mock;
|
||||
refreshWindowStatus: jest.Mock;
|
||||
expireLeftoverDayPool: jest.Mock;
|
||||
fillFromWaitingList: jest.Mock;
|
||||
};
|
||||
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
|
||||
@@ -33,7 +34,6 @@ describe('BookingWindowService — window state machine', () => {
|
||||
windowDurationHours: 1,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
reopenDelayMinutes: 0,
|
||||
};
|
||||
|
||||
const baseSchedule = (over: Partial<TrainSchedule>): TrainSchedule =>
|
||||
@@ -75,6 +75,8 @@ describe('BookingWindowService — window state machine', () => {
|
||||
hasLiveReservations: jest.fn().mockResolvedValue(false),
|
||||
refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
|
||||
expireLeftoverDayPool: jest.fn().mockResolvedValue(0),
|
||||
// No waiting booking fits by default, so conclude proceeds to reopen/DONE.
|
||||
fillFromWaitingList: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
trainSchedulesRepository = {
|
||||
findById: jest.fn().mockResolvedValue(null),
|
||||
@@ -123,6 +125,8 @@ describe('BookingWindowService — window state machine', () => {
|
||||
});
|
||||
|
||||
it('DOC_REVIEW → PAYMENT expires un-accepted, then runs the batch', async () => {
|
||||
// The batch reserved someone (live reservations exist) → real PAYMENT phase.
|
||||
batch.hasLiveReservations.mockResolvedValue(true);
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'DOC_REVIEW',
|
||||
docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'),
|
||||
@@ -140,6 +144,7 @@ describe('BookingWindowService — window state machine', () => {
|
||||
});
|
||||
|
||||
it('DOC_REVIEW → PAYMENT also fires when staff finished review early (docReviewCompletedAt)', async () => {
|
||||
batch.hasLiveReservations.mockResolvedValue(true);
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'DOC_REVIEW',
|
||||
docReviewEndsAt: new Date('2026-07-01T05:00:00.000Z'), // far future
|
||||
@@ -150,6 +155,21 @@ describe('BookingWindowService — window state machine', () => {
|
||||
expect(s.windowPhase).toBe('PAYMENT');
|
||||
});
|
||||
|
||||
it('DOC_REVIEW → batch reserves nothing → skips the empty PAYMENT phase and reopens', async () => {
|
||||
// Default hasLiveReservations=false: the batch reserved nobody. Waiting a
|
||||
// full payment window with the desk shut would serve no one — the cycle
|
||||
// concludes immediately (24h desk + far departure → straight to PRE_WINDOW).
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'DOC_REVIEW',
|
||||
docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'),
|
||||
});
|
||||
const advanced = await advanceImport(s, new Date('2026-07-01T01:30:01.000Z'));
|
||||
expect(advanced).toBe(true);
|
||||
expect(batch.processRouteDay).toHaveBeenCalledTimes(1);
|
||||
expect(s.windowPhase).toBe('PRE_WINDOW');
|
||||
expect(s.windowOpensAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('PAYMENT → conclude at paymentPhaseEndsAt settles due reservations', async () => {
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'PAYMENT',
|
||||
@@ -205,6 +225,22 @@ describe('BookingWindowService — window state machine', () => {
|
||||
expect(trainSchedulingService.finalizeSchedule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('conclude: waiting booking still fits → fresh pay window, back to PAYMENT, no reopen', async () => {
|
||||
batch.isScheduleFull.mockResolvedValue(false);
|
||||
batch.fillFromWaitingList.mockResolvedValue(2);
|
||||
batch.hasLiveReservations.mockResolvedValue(true);
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'PAYMENT',
|
||||
scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'),
|
||||
});
|
||||
const now = new Date('2026-07-01T02:30:05.000Z');
|
||||
await concludeCycle(s, now);
|
||||
expect(batch.fillFromWaitingList).toHaveBeenCalledWith(scheduleId);
|
||||
expect(s.windowPhase).toBe('PAYMENT');
|
||||
// Fresh pay window from `now`, not a reopen.
|
||||
expect(s.paymentPhaseEndsAt).toEqual(new Date(now.getTime() + 60 * 60_000));
|
||||
});
|
||||
|
||||
it('conclude: NOT full but NO cycle fits before departure → DONE', async () => {
|
||||
batch.isScheduleFull.mockResolvedValue(false);
|
||||
const s = baseSchedule({
|
||||
|
||||
@@ -17,7 +17,12 @@ import { BookingBatchService } from './booking-batch.service';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
|
||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||
import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util';
|
||||
import {
|
||||
clampCloseToOfficeHours,
|
||||
eatDay,
|
||||
nextCycleOpensAt,
|
||||
type OfficeHours,
|
||||
} from './batch-window.util';
|
||||
import { type BookingWindowConfig } from './booking-window.config';
|
||||
|
||||
/**
|
||||
@@ -297,6 +302,17 @@ export class BookingWindowService implements OnModuleInit {
|
||||
// (or allocating government) — skipped automatically for everyone who fits
|
||||
// is handled inside the fill (all fit → all reserved → all notified).
|
||||
await this.bookingBatchService.processRouteDay(routeDay);
|
||||
// Batch reserved nobody (empty pool, or it allocated without pay windows):
|
||||
// a PAYMENT phase with nobody to pay is a dead hour with the window shut.
|
||||
// Conclude straight away — full → DONE, otherwise reopen per office hours.
|
||||
if (!(await this.bookingBatchService.hasLiveReservations(schedule.id))) {
|
||||
this.logger.log(
|
||||
`[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch reserved nothing; ` +
|
||||
`skipping the empty payment phase and concluding the cycle`,
|
||||
);
|
||||
await this.concludeCycle(schedule, cfg, now);
|
||||
return true;
|
||||
}
|
||||
this.logger.log(
|
||||
`[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch ran; payment phase ` +
|
||||
`until ${paymentPhaseEndsAt.toISOString()}`,
|
||||
@@ -353,7 +369,10 @@ export class BookingWindowService implements OnModuleInit {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** After settle: full → finalize + DONE; space left → reopen same day or close for the day. */
|
||||
/**
|
||||
* After settle: full → finalize + DONE; waiting bookings still fit → fresh pay
|
||||
* window, back to PAYMENT; otherwise reopen (office hours decide when) or DONE.
|
||||
*/
|
||||
private async concludeCycle(
|
||||
schedule: TrainSchedule,
|
||||
cfg: BookingWindowConfig,
|
||||
@@ -386,6 +405,31 @@ export class BookingWindowService implements OnModuleInit {
|
||||
if (fresh) schedule.bookingWindowStatus = fresh.bookingWindowStatus;
|
||||
}
|
||||
|
||||
// The window reopens only once the waiting list is exhausted: a booking can
|
||||
// still reach the pool mid-payment (late doc accept, consolidation partner),
|
||||
// so retry the batch before reopening. Anything that fits gets a fresh pay
|
||||
// window and the cycle stays in PAYMENT; check live reservations on THIS
|
||||
// schedule because the day-level fill may have reserved onto a sibling.
|
||||
// Waiting bookings that fit no train stay pooled and the window reopens.
|
||||
const promoted = await this.bookingBatchService.fillFromWaitingList(schedule.id);
|
||||
if (
|
||||
promoted > 0 &&
|
||||
(await this.bookingBatchService.hasLiveReservations(schedule.id))
|
||||
) {
|
||||
let paymentPhaseEndsAt = new Date(
|
||||
now.getTime() + cfg.paymentWindowMinutes * 60_000,
|
||||
);
|
||||
if (paymentPhaseEndsAt > schedule.scheduledDepartureDate) {
|
||||
paymentPhaseEndsAt = schedule.scheduledDepartureDate;
|
||||
}
|
||||
await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt });
|
||||
this.logger.log(
|
||||
`[WINDOW] ${schedule.id} conclude → waiting list still had bookings that ` +
|
||||
`fit — back in PAYMENT until ${paymentPhaseEndsAt.toISOString()}, no reopen yet`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Doc review + payment have already run, so the desk is ready to reopen NOW —
|
||||
// office hours decide whether that is this afternoon or tomorrow morning. Past
|
||||
// the last cycle before departure, nextCycleOpensAt returns null and we finish.
|
||||
@@ -413,6 +457,9 @@ export class BookingWindowService implements OnModuleInit {
|
||||
let nextClosesAt = new Date(
|
||||
nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000,
|
||||
);
|
||||
// Office hours end a running window early: never let the duration outlive
|
||||
// the desk close (open 16:00, 3h, desk 8–17 → closes 17:00).
|
||||
nextClosesAt = clampCloseToOfficeHours(nextOpensAt, nextClosesAt, officeHours);
|
||||
if (nextClosesAt > schedule.scheduledDepartureDate) {
|
||||
nextClosesAt = schedule.scheduledDepartureDate;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,28 @@ export class AvailableDaysForCargoQueryDto {
|
||||
@IsString()
|
||||
cargoTypeCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Bulk cargo type id (preferred over code).' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cargoTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Container type ids as a JSON string array — enables the exact wagon-type compatibility gate (falls back to containerSize matching when absent).',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value == null || value === '') return undefined;
|
||||
if (typeof value !== 'string') return value;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
@IsArray()
|
||||
containerTypeIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Total bulk weight in tons.' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||
|
||||
@@ -95,11 +95,4 @@ export class UpdateTrainSchedulingGlobalRulesDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
paymentWindowMinutes?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 90 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
reopenDelayMinutes?: number;
|
||||
}
|
||||
|
||||
@@ -79,8 +79,4 @@ export class TrainSchedulingGlobalRules extends BaseEntity {
|
||||
|
||||
@Column({ name: 'payment_window_minutes', type: 'int', default: 60 })
|
||||
paymentWindowMinutes!: number;
|
||||
|
||||
/** Delay after window close before the window reopens when the train is not yet full. */
|
||||
@Column({ name: 'reopen_delay_minutes', type: 'int', default: 90 })
|
||||
reopenDelayMinutes!: number;
|
||||
}
|
||||
|
||||
@@ -114,6 +114,35 @@ describe('fleet-plan.util', () => {
|
||||
expect(warnings.some((w) => w.includes('deferred'))).toBe(true);
|
||||
});
|
||||
|
||||
it('names the booking and its per-type shortfall when the deferral carries a shortage', () => {
|
||||
const warnings = summarizeFleetWarnings(
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 'b1',
|
||||
reference: 'BKG-1',
|
||||
reason: 'No available NW6 wagon at the yard',
|
||||
shortage: {
|
||||
wagonTypeCodes: 'NW6',
|
||||
wagonsNeeded: 2,
|
||||
wagonsAvailable: 1,
|
||||
wagonsShort: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
warnings.some(
|
||||
(w) =>
|
||||
w.includes('BKG-1') &&
|
||||
w.includes('2 × NW6') &&
|
||||
w.includes('only 1 available') &&
|
||||
w.includes('short 1'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('counts wagons required per booking from container lines', () => {
|
||||
const booking = makeBooking('b1', {
|
||||
bookingContainers: [
|
||||
|
||||
@@ -17,10 +17,21 @@ export type FleetAvailabilityRow = {
|
||||
shortfall: number;
|
||||
};
|
||||
|
||||
/** Per-booking wagon shortage: how many wagons of which type this booking still lacks. */
|
||||
export type BookingWagonShortage = {
|
||||
/** Candidate wagon-type codes usable by the booking, joined ("NW6" or "NW6/CW3"). */
|
||||
wagonTypeCodes: string;
|
||||
wagonsNeeded: number;
|
||||
wagonsAvailable: number;
|
||||
wagonsShort: number;
|
||||
};
|
||||
|
||||
export type DeferredBookingRow = {
|
||||
id: string;
|
||||
reference: string;
|
||||
reason: string;
|
||||
/** Set when the deferral is a fleet-stock shortage (absent for config issues). */
|
||||
shortage?: BookingWagonShortage | null;
|
||||
};
|
||||
|
||||
export function sortBookingsForScheduling(bookings: Booking[]): Booking[] {
|
||||
@@ -156,6 +167,16 @@ export function summarizeFleetWarnings(
|
||||
);
|
||||
}
|
||||
|
||||
// Name the bookings the shortage actually hits, with their own per-type counts,
|
||||
// so staff know WHAT is held out — not just that the pool is short overall.
|
||||
for (const row of deferred) {
|
||||
if (!row.shortage) continue;
|
||||
warnings.push(
|
||||
`Booking ${row.reference} held out: needs ${row.shortage.wagonsNeeded} × ${row.shortage.wagonTypeCodes}, ` +
|
||||
`only ${row.shortage.wagonsAvailable} available (short ${row.shortage.wagonsShort})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (deferred.length) {
|
||||
warnings.push(
|
||||
`${deferred.length} booking(s) deferred to next train due to insufficient fleet wagons`,
|
||||
|
||||
@@ -234,9 +234,11 @@ export class TrainSchedulingController {
|
||||
originYardId: query.originYardId,
|
||||
destinationYardId: query.destinationYardId,
|
||||
freightType: query.freightType,
|
||||
cargoTypeId: query.cargoTypeId,
|
||||
cargoTypeCode: query.cargoTypeCode,
|
||||
totalWeightTons: query.totalWeightTons,
|
||||
containers: query.containers,
|
||||
containerTypeIds: query.containerTypeIds,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { planWagonsWithStock } from './wagon-plan-flex.util';
|
||||
|
||||
const nw6: WagonType = {
|
||||
id: 'wt-nw6',
|
||||
code: 'NW6',
|
||||
name: 'Flat Wagon',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
supportedLoadTypes: ['CONTAINER'],
|
||||
isActive: true,
|
||||
supportsContainer: true,
|
||||
} as WagonType;
|
||||
|
||||
const cw3: WagonType = {
|
||||
id: 'wt-cw3',
|
||||
code: 'CW3',
|
||||
name: 'Covered Wagon',
|
||||
capacityTons: 60,
|
||||
lengthMeters: 14,
|
||||
supportedLoadTypes: ['BULK'],
|
||||
isActive: true,
|
||||
supportsContainer: false,
|
||||
} as WagonType;
|
||||
|
||||
const containerBooking = (id: string, quantity: number, wagonsRequired: number): Booking =>
|
||||
({
|
||||
id,
|
||||
reference: id,
|
||||
freightType: 'CONTAINER',
|
||||
cargoTotalWeightVgm: quantity * 25,
|
||||
bookingContainers: [
|
||||
{
|
||||
id: `${id}-line-0`,
|
||||
containerTypeId: 'ct-1',
|
||||
quantity,
|
||||
wagonsRequired,
|
||||
vgmPerUnitTons: 25,
|
||||
},
|
||||
],
|
||||
}) as Booking;
|
||||
|
||||
describe('planWagonsWithStock — shortage detail', () => {
|
||||
it('defers with a structured per-type shortage when container stock runs out', () => {
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [containerBooking('BKG-1', 2, 1)],
|
||||
allowed: {
|
||||
byContainerTypeId: new Map([['ct-1', [nw6]]]),
|
||||
byCargoTypeId: new Map(),
|
||||
},
|
||||
stock: {
|
||||
mode: 'YARD',
|
||||
remainingByTypeId: new Map([[nw6.id, 0]]),
|
||||
codesByTypeId: new Map([[nw6.id, nw6.code]]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.fitting).toHaveLength(0);
|
||||
expect(result.deferred).toHaveLength(1);
|
||||
const row = result.deferred[0]!;
|
||||
expect(row.reference).toBe('BKG-1');
|
||||
expect(row.reason).toContain('No available NW6 wagon at the yard');
|
||||
expect(row.reason).toContain('short 1');
|
||||
expect(row.shortage).toEqual({
|
||||
wagonTypeCodes: 'NW6',
|
||||
wagonsNeeded: 1,
|
||||
wagonsAvailable: 0,
|
||||
wagonsShort: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts the stock the deferred booking actually saw, not its rolled-back usage', () => {
|
||||
// Two wagons needed (2 × 40ft), one in stock: booking rolls back entirely,
|
||||
// the shortage reports 1 available / 1 short.
|
||||
const fortyFooter = containerBooking('BKG-2', 2, 2);
|
||||
fortyFooter.bookingContainers![0]!.containerType = {
|
||||
code: '40GP',
|
||||
sizeFt: 40,
|
||||
wagonsPerUnit: 1,
|
||||
} as never;
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [fortyFooter],
|
||||
allowed: {
|
||||
byContainerTypeId: new Map([['ct-1', [nw6]]]),
|
||||
byCargoTypeId: new Map(),
|
||||
},
|
||||
stock: {
|
||||
mode: 'YARD',
|
||||
remainingByTypeId: new Map([[nw6.id, 1]]),
|
||||
codesByTypeId: new Map([[nw6.id, nw6.code]]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.deferred).toHaveLength(1);
|
||||
expect(result.deferred[0]?.shortage).toEqual({
|
||||
wagonTypeCodes: 'NW6',
|
||||
wagonsNeeded: 2,
|
||||
wagonsAvailable: 1,
|
||||
wagonsShort: 1,
|
||||
});
|
||||
// The rolled-back wagon is plannable again for later bookings.
|
||||
expect(result.plan).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('leaves shortage unset for configuration problems', () => {
|
||||
const bulkBooking = {
|
||||
id: 'BKG-3',
|
||||
reference: 'BKG-3',
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: 40,
|
||||
cargoTypeId: 'cargo-1',
|
||||
cargoType: { id: 'cargo-1', cargoTypeName: 'Fertilizer' },
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [bulkBooking],
|
||||
allowed: {
|
||||
byContainerTypeId: new Map(),
|
||||
byCargoTypeId: new Map(), // no wagon types configured → config issue
|
||||
},
|
||||
stock: {
|
||||
mode: 'YARD',
|
||||
remainingByTypeId: new Map([[cw3.id, 5]]),
|
||||
codesByTypeId: new Map([[cw3.id, cw3.code]]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.configIssues).toHaveLength(1);
|
||||
expect(result.deferred[0]?.shortage).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,14 @@ import { AllocationLoadType } from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { sortBookingsForScheduling, type DeferredBookingRow } from './fleet-plan.util';
|
||||
import {
|
||||
sortBookingsForScheduling,
|
||||
type BookingWagonShortage,
|
||||
type DeferredBookingRow,
|
||||
} from './fleet-plan.util';
|
||||
import {
|
||||
MAX_TEU_SLOTS_PER_WAGON,
|
||||
containerWagonsForLines,
|
||||
expandBookingContainerUnits,
|
||||
roundTons,
|
||||
tareTonsOf,
|
||||
@@ -55,7 +60,12 @@ type OpenSlot = {
|
||||
freeCapacityTons: number;
|
||||
};
|
||||
|
||||
type PlacementProblem = { kind: 'config' | 'stock'; message: string };
|
||||
type PlacementProblem = {
|
||||
kind: 'config' | 'stock';
|
||||
message: string;
|
||||
/** Wagon types the failing placement could have used (stock problems only). */
|
||||
candidates?: WagonType[];
|
||||
};
|
||||
|
||||
const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanSlot => ({
|
||||
sequenceNo: 0, // stamped at the end
|
||||
@@ -69,6 +79,38 @@ const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanS
|
||||
slotLoadType: kind,
|
||||
});
|
||||
|
||||
/**
|
||||
* Booking-level shortage against the wagon types the failing placement could
|
||||
* use: wagons the whole booking needs vs stock left for those types. Container
|
||||
* counts are TEU-packed per booking; bulk divides by the largest candidate.
|
||||
*/
|
||||
const shortageFor = (
|
||||
booking: Booking,
|
||||
candidates: WagonType[],
|
||||
remaining: Map<string, number>,
|
||||
): BookingWagonShortage => {
|
||||
const wagonsNeeded =
|
||||
booking.freightType === 'BULK'
|
||||
? Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
Number(booking.cargoTotalWeightVgm ?? 0) /
|
||||
Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))),
|
||||
),
|
||||
)
|
||||
: Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
|
||||
const wagonsAvailable = candidates.reduce(
|
||||
(sum, wt) => sum + (remaining.get(wt.id) ?? 0),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
wagonTypeCodes: [...new Set(candidates.map((wt) => wt.code))].join('/'),
|
||||
wagonsNeeded,
|
||||
wagonsAvailable,
|
||||
wagonsShort: Math.max(1, wagonsNeeded - wagonsAvailable),
|
||||
};
|
||||
};
|
||||
|
||||
const addAllocation = (
|
||||
slot: WagonPlanSlot,
|
||||
bookingId: string,
|
||||
@@ -120,7 +162,9 @@ export function planWagonsWithStock(params: {
|
||||
cargoTypeId: string | null,
|
||||
): OpenSlot | PlacementProblem => {
|
||||
const inStock = candidates.filter((wt) => (remaining.get(wt.id) ?? 0) > 0);
|
||||
if (!inStock.length) return { kind: 'stock', message: noStockMessage(candidates) };
|
||||
if (!inStock.length) {
|
||||
return { kind: 'stock', message: noStockMessage(candidates), candidates };
|
||||
}
|
||||
// Bulk favors the largest wagon (fewest wagons for the tonnage); containers
|
||||
// favor the deepest stock so the consist drains evenly. Ties keep config order.
|
||||
const chosen = [...inStock].sort((a, b) =>
|
||||
@@ -271,7 +315,21 @@ export function planWagonsWithStock(params: {
|
||||
});
|
||||
|
||||
if (problem.kind === 'config') configIssues.add(problem.message);
|
||||
deferred.push({ id: booking.id, reference: booking.reference, reason: problem.message });
|
||||
// remaining is rolled back here, so the shortage counts the stock this
|
||||
// booking actually saw — not what its own partial placement consumed.
|
||||
const shortage =
|
||||
problem.kind === 'stock' && problem.candidates?.length
|
||||
? shortageFor(booking, problem.candidates, remaining)
|
||||
: null;
|
||||
deferred.push({
|
||||
id: booking.id,
|
||||
reference: booking.reference,
|
||||
reason: shortage
|
||||
? `${problem.message} — needs ${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` +
|
||||
`${shortage.wagonsAvailable} available (short ${shortage.wagonsShort})`
|
||||
: problem.message,
|
||||
shortage,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,14 +5,26 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class BuildTrainDto {
|
||||
@ApiProperty({ example: '81001', description: 'Operator-assigned train code (unique)' })
|
||||
@ApiProperty({ example: '8001', description: 'EXPORT run number (odd, unique across trains)' })
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
code!: string;
|
||||
@MaxLength(20)
|
||||
@Matches(/^\d*[13579]$/, {
|
||||
message: 'Export train number must be numeric and odd (e.g. 8001)',
|
||||
})
|
||||
exportTrainNumber!: string;
|
||||
|
||||
@ApiProperty({ example: '8002', description: 'IMPORT run number (even, unique across trains)' })
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
@Matches(/^\d*[02468]$/, {
|
||||
message: 'Import train number must be numeric and even (e.g. 8002)',
|
||||
})
|
||||
importTrainNumber!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'Yard the train is built in' })
|
||||
@IsUUID()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Edit a built train's display identity: its name and its fixed import/export
|
||||
* run numbers. Composition (yard, locomotives, wagons) has its own endpoints.
|
||||
* Omitted fields keep their current value; an empty trainName clears the name.
|
||||
*/
|
||||
export class UpdateTrainDetailsDto {
|
||||
@ApiPropertyOptional({ description: 'Display name; empty string clears it' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
trainName?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Fixed IMPORT (even) run number' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
importTrainNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Fixed EXPORT (odd) run number' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
exportTrainNumber?: string;
|
||||
}
|
||||
@@ -32,12 +32,20 @@ export class Train extends BaseEntity {
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
|
||||
/** Fixed IMPORT (even) run number typed at build time; unique via partial index. */
|
||||
@Column({ name: 'import_train_number', type: 'varchar', length: 20, nullable: true })
|
||||
importTrainNumber!: string | null;
|
||||
|
||||
/** Fixed EXPORT (odd) run number typed at build time; unique via partial index. */
|
||||
@Column({ name: 'export_train_number', type: 'varchar', length: 20, nullable: true })
|
||||
exportTrainNumber!: string | null;
|
||||
|
||||
// --- new required fields ---
|
||||
@Column({ name: 'train_number', type: 'varchar', length: 20, unique: true, nullable: true })
|
||||
trainNumber?: string;
|
||||
|
||||
@Column({ name: 'train_name', type: 'varchar', length: 100, nullable: true })
|
||||
trainName?: string;
|
||||
trainName?: string | null;
|
||||
|
||||
@Column({ name: 'route_id', type: 'uuid', nullable: true })
|
||||
routeId?: string;
|
||||
|
||||
@@ -19,6 +19,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
||||
import { BuildTrainDto } from './dto/build-train.dto';
|
||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
|
||||
import { TrainBuilderService } from './train-builder.service';
|
||||
@@ -59,6 +60,18 @@ export class TrainBuilderController {
|
||||
return this.trainBuilderService.setLocomotives(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/details')
|
||||
@FleetManage()
|
||||
@ApiOperation({
|
||||
summary: "Edit the train's name and fixed import/export run numbers",
|
||||
})
|
||||
updateDetails(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateTrainDetailsDto,
|
||||
) {
|
||||
return this.trainBuilderService.updateDetails(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/yard')
|
||||
@FleetManage()
|
||||
@ApiOperation({
|
||||
@@ -85,6 +98,16 @@ export class TrainBuilderController {
|
||||
return this.trainBuilderService.removeWagon(id, wagonId);
|
||||
}
|
||||
|
||||
@Post(':id/wagons/:wagonId/maintenance')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' })
|
||||
sendWagonToMaintenance(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||
) {
|
||||
return this.trainBuilderService.sendWagonToMaintenance(id, wagonId);
|
||||
}
|
||||
|
||||
@Post(':id/reorder-wagons')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, EntityManager, ILike, In } from 'typeorm';
|
||||
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
@@ -17,6 +18,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
||||
import { BuildTrainDto } from './dto/build-train.dto';
|
||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||
import { TrainLocomotive } from './entities/train-locomotive.entity';
|
||||
import { Train } from './entities/train.entity';
|
||||
@@ -27,6 +29,15 @@ import {
|
||||
|
||||
const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100;
|
||||
|
||||
/** The one active (DRAFT/SCHEDULED/DISPATCHED) schedule surfaced per built train. */
|
||||
export interface ActiveScheduleRef {
|
||||
id: string;
|
||||
status: string;
|
||||
reference: string | null;
|
||||
direction: string | null;
|
||||
trainNumber: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Train Builder — assembles persistent fleet trains (code + 2+ locomotives +
|
||||
* ordered wagons, all in one yard) that scheduling can later reference as a
|
||||
@@ -50,10 +61,25 @@ export class TrainBuilderService {
|
||||
}
|
||||
|
||||
const trainId = await this.dataSource.transaction(async (manager) => {
|
||||
const code = dto.code.trim();
|
||||
const existing = await manager.getRepository(Train).findOne({ where: { code } });
|
||||
if (existing) {
|
||||
throw new ConflictException(`Train code ${code} is already in use`);
|
||||
const code = await this.generateTrainCode(manager);
|
||||
|
||||
// Friendly 409 before the partial unique indexes (the race-proof backstop):
|
||||
// the typed pair may not collide with any train's pair or legacy number.
|
||||
const importTrainNumber = dto.importTrainNumber.trim();
|
||||
const exportTrainNumber = dto.exportTrainNumber.trim();
|
||||
const numberClash: { code: string }[] = await manager.query(
|
||||
`SELECT code FROM freight.trains
|
||||
WHERE deleted_at IS NULL
|
||||
AND (import_train_number IN ($1, $2)
|
||||
OR export_train_number IN ($1, $2)
|
||||
OR train_number IN ($1, $2))
|
||||
LIMIT 1`,
|
||||
[importTrainNumber, exportTrainNumber],
|
||||
);
|
||||
if (numberClash.length) {
|
||||
throw new ConflictException(
|
||||
`Train number ${importTrainNumber}/${exportTrainNumber} is already used by train ${numberClash[0].code}`,
|
||||
);
|
||||
}
|
||||
|
||||
const yard = await manager.getRepository(Yard).findOne({ where: { id: dto.currentYardId } });
|
||||
@@ -76,6 +102,8 @@ export class TrainBuilderService {
|
||||
status: Freight.TrainStatus.Available,
|
||||
trainName: dto.trainName?.trim() || undefined,
|
||||
notes: dto.notes?.trim() || undefined,
|
||||
importTrainNumber,
|
||||
exportTrainNumber,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -117,12 +145,42 @@ export class TrainBuilderService {
|
||||
take,
|
||||
});
|
||||
|
||||
const activeByTrain = await this.loadActiveScheduleByTrain(trains.map((t) => t.id));
|
||||
|
||||
return {
|
||||
items: trains.map((train) => this.mapSummary(train)),
|
||||
items: trains.map((train) => this.mapSummary(train, activeByTrain.get(train.id) ?? null)),
|
||||
meta: buildPaginationMeta(total, page, pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One ACTIVE schedule per train for the page (prefer the DISPATCHED run,
|
||||
* else the earliest upcoming departure) — feeds the list's direction tint
|
||||
* and in-use train number.
|
||||
*/
|
||||
private async loadActiveScheduleByTrain(
|
||||
trainIds: string[],
|
||||
): Promise<Map<string, ActiveScheduleRef>> {
|
||||
if (!trainIds.length) return new Map();
|
||||
const rows: (ActiveScheduleRef & { trainId: string })[] = await this.dataSource.query(
|
||||
`SELECT DISTINCT ON (tset.train_id)
|
||||
tset.train_id AS "trainId",
|
||||
ts.id,
|
||||
ts.status,
|
||||
ts.reference,
|
||||
ts.direction,
|
||||
ts.train_number AS "trainNumber"
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
WHERE tset.train_id = ANY($1)
|
||||
AND ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
||||
ORDER BY tset.train_id, (ts.status = 'DISPATCHED') DESC, ts.scheduled_departure_date ASC`,
|
||||
[trainIds],
|
||||
);
|
||||
return new Map(rows.map(({ trainId, ...schedule }) => [trainId, schedule]));
|
||||
}
|
||||
|
||||
/** Full consist: yard, ordered locomotives + wagons, totals vs. haul limits. */
|
||||
async getComposition(id: string) {
|
||||
const train = await this.dataSource.getRepository(Train).findOne({
|
||||
@@ -139,17 +197,17 @@ export class TrainBuilderService {
|
||||
});
|
||||
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
||||
|
||||
const schedules: { id: string; status: string; reference: string | null }[] =
|
||||
await this.dataSource.query(
|
||||
`SELECT ts.id, ts.status, ts.reference
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
WHERE tset.train_id = $1
|
||||
AND ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
||||
ORDER BY ts.scheduled_departure_date ASC`,
|
||||
[id],
|
||||
);
|
||||
const schedules: ActiveScheduleRef[] = await this.dataSource.query(
|
||||
`SELECT ts.id, ts.status, ts.reference, ts.direction,
|
||||
ts.train_number AS "trainNumber"
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
WHERE tset.train_id = $1
|
||||
AND ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
||||
ORDER BY ts.scheduled_departure_date ASC`,
|
||||
[id],
|
||||
);
|
||||
|
||||
const locomotives = (train.locomotives ?? [])
|
||||
.filter((link) => link.locomotive)
|
||||
@@ -212,6 +270,8 @@ export class TrainBuilderService {
|
||||
code: train.code,
|
||||
trainName: train.trainName ?? null,
|
||||
status: train.status,
|
||||
importTrainNumber: train.importTrainNumber ?? null,
|
||||
exportTrainNumber: train.exportTrainNumber ?? null,
|
||||
notes: train.notes ?? null,
|
||||
createdAt: train.createdAt,
|
||||
currentYard: train.currentYard
|
||||
@@ -273,6 +333,53 @@ export class TrainBuilderService {
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a built train's display identity: name and fixed import/export run
|
||||
* numbers. Mirrors the build-time number rules — the pair may not collide
|
||||
* with any other train's pair or legacy number (friendly 409 ahead of the
|
||||
* partial unique indexes). Blocked while the train is out on a dispatched
|
||||
* run, like every other composition edit.
|
||||
*/
|
||||
async updateDetails(id: string, dto: UpdateTrainDetailsDto) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
|
||||
const patch: QueryDeepPartialEntity<Train> = {};
|
||||
if (dto.trainName !== undefined) {
|
||||
patch.trainName = dto.trainName.trim() || null;
|
||||
}
|
||||
const importTrainNumber = dto.importTrainNumber?.trim();
|
||||
const exportTrainNumber = dto.exportTrainNumber?.trim();
|
||||
if (importTrainNumber) patch.importTrainNumber = importTrainNumber;
|
||||
if (exportTrainNumber) patch.exportTrainNumber = exportTrainNumber;
|
||||
|
||||
if (importTrainNumber || exportTrainNumber) {
|
||||
const nextImport = importTrainNumber ?? train.importTrainNumber ?? '';
|
||||
const nextExport = exportTrainNumber ?? train.exportTrainNumber ?? '';
|
||||
const numberClash: { code: string }[] = await manager.query(
|
||||
`SELECT code FROM freight.trains
|
||||
WHERE deleted_at IS NULL
|
||||
AND id != $3
|
||||
AND (import_train_number IN ($1, $2)
|
||||
OR export_train_number IN ($1, $2)
|
||||
OR train_number IN ($1, $2))
|
||||
LIMIT 1`,
|
||||
[nextImport, nextExport, train.id],
|
||||
);
|
||||
if (numberClash.length) {
|
||||
throw new ConflictException(
|
||||
`Train number ${nextImport}/${nextExport} is already used by train ${numberClash[0].code}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length) {
|
||||
await manager.getRepository(Train).update(train.id, patch);
|
||||
}
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relocate the train to another yard. The consist moves as one unit: every
|
||||
* coupled locomotive and wagon follows to the new yard (so their current
|
||||
@@ -341,7 +448,7 @@ export class TrainBuilderService {
|
||||
if (!wagon || wagon.trainId !== train.id) {
|
||||
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
||||
}
|
||||
if (wagon.currentTrainScheduleId) {
|
||||
if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
|
||||
);
|
||||
@@ -356,6 +463,56 @@ export class TrainBuilderService {
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach one wagon AND flag it for maintenance: it leaves the consist and
|
||||
* moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until
|
||||
* it clears maintenance. The freed sequence gap is closed.
|
||||
*/
|
||||
async sendWagonToMaintenance(id: string, wagonId: string) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
if (!wagon || wagon.trainId !== train.id) {
|
||||
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
||||
}
|
||||
if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
|
||||
);
|
||||
}
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
trainId: null,
|
||||
sequenceNumber: null,
|
||||
status: WagonStatus.Maintenance,
|
||||
});
|
||||
await this.resequenceWagons(manager, train.id);
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule occupancy lives on TrainSetWagon slots (per-schedule snapshot),
|
||||
* not on the Wagon entity — a wagon is busy when any live (DRAFT/SCHEDULED/
|
||||
* DISPATCHED) schedule has it pinned to one of its slots.
|
||||
*/
|
||||
private async isWagonPinnedToLiveSchedule(
|
||||
manager: EntityManager,
|
||||
wagonId: string,
|
||||
): Promise<boolean> {
|
||||
const rows: { exists: boolean }[] = await manager.query(
|
||||
`SELECT TRUE AS exists
|
||||
FROM freight.train_set_wagons tsw
|
||||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||||
WHERE tsw.physical_wagon_id = $1
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
||||
AND ts.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[wagonId],
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */
|
||||
async reorderWagons(id: string, dto: ReorderTrainWagonsDto) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -407,7 +564,30 @@ export class TrainBuilderService {
|
||||
|
||||
// ---------------------------------------------------------------- internals
|
||||
|
||||
private mapSummary(train: Train) {
|
||||
/**
|
||||
* System-assigned train code `TR-NNNNN`. Draws the next number from the
|
||||
* highest existing `TR-` code and probes past any manual collision so the
|
||||
* unique constraint never rejects the build.
|
||||
*/
|
||||
private async generateTrainCode(manager: EntityManager): Promise<string> {
|
||||
const [row]: { max_seq: string | null }[] = await manager.query(
|
||||
`SELECT MAX(CAST(SUBSTRING(code FROM '^TR-([0-9]+)$') AS INTEGER)) AS max_seq
|
||||
FROM freight.trains
|
||||
WHERE code ~ '^TR-[0-9]+$'`,
|
||||
);
|
||||
let seq = Number(row?.max_seq ?? 0) + 1;
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
const code = `TR-${String(seq).padStart(5, '0')}`;
|
||||
const exists = await manager
|
||||
.getRepository(Train)
|
||||
.findOne({ where: { code }, withDeleted: true });
|
||||
if (!exists) return code;
|
||||
seq += 1;
|
||||
}
|
||||
throw new ConflictException('Could not allocate a unique train code');
|
||||
}
|
||||
|
||||
private mapSummary(train: Train, activeSchedule: ActiveScheduleRef | null) {
|
||||
const locomotives = [...(train.locomotives ?? [])]
|
||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||
.map((link) => link.locomotive)
|
||||
@@ -421,6 +601,9 @@ export class TrainBuilderService {
|
||||
code: train.code,
|
||||
trainName: train.trainName ?? null,
|
||||
status: train.status,
|
||||
importTrainNumber: train.importTrainNumber ?? null,
|
||||
exportTrainNumber: train.exportTrainNumber ?? null,
|
||||
activeSchedule,
|
||||
createdAt: train.createdAt,
|
||||
currentYard: train.currentYard
|
||||
? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label }
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { WagonStatus } from '@edr/types';
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
|
||||
import { DataSource, FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
|
||||
import { CreateTrainDto } from './dto/create-train.dto';
|
||||
import { UpdateTrainDto } from './dto/update-train.dto';
|
||||
import { TrainLocomotive } from './entities/train-locomotive.entity';
|
||||
import { Train } from './entities/train.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
|
||||
@Injectable()
|
||||
export class TrainsService {
|
||||
constructor(
|
||||
@InjectRepository(Train)
|
||||
private readonly trainRepo: Repository<Train>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
create(dto: CreateTrainDto): Promise<Train> {
|
||||
@@ -54,8 +58,40 @@ export class TrainsService {
|
||||
return this.trainRepo.save(train);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a built train. Blocked while it still has a live (DRAFT/SCHEDULED/
|
||||
* DISPATCHED) schedule; otherwise its wagons are freed (back to AVAILABLE)
|
||||
* and its locomotive links dropped so nothing is stranded, then the train is
|
||||
* soft-deleted. Mirrors TrainBuilderService.disband but prefers softRemove.
|
||||
*/
|
||||
async remove(id: string): Promise<void> {
|
||||
const train = await this.findById(id);
|
||||
await this.trainRepo.remove(train);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await manager.getRepository(Train).findOne({ where: { id } });
|
||||
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
||||
|
||||
const active: { count: string }[] = await manager.query(
|
||||
`SELECT COUNT(*)::text AS count
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
WHERE tset.train_id = $1
|
||||
AND ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')`,
|
||||
[id],
|
||||
);
|
||||
if (Number(active[0]?.count ?? 0) > 0) {
|
||||
throw new ConflictException(
|
||||
'Train has active schedules; cancel them before deleting the train',
|
||||
);
|
||||
}
|
||||
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
.update(
|
||||
{ trainId: train.id },
|
||||
{ trainId: null, sequenceNumber: null, status: WagonStatus.Available },
|
||||
);
|
||||
await manager.getRepository(TrainLocomotive).delete({ trainId: train.id });
|
||||
await manager.getRepository(Train).softRemove(train);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user