gMerge branch 'dev' of github.com:Tria-plc/edr-platform into dev

This commit is contained in:
natib21
2026-07-18 09:04:52 +00:00
2531 changed files with 292294 additions and 172026 deletions

View File

@@ -39,6 +39,7 @@ import { TrackingModule } from "./modules/tracking/tracking.module";
import { BillingModule } from "./modules/billing/billing.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
import { NotificationInboxModule } from "./modules/notification-inbox/notification-inbox.module";
import { SupportChatModule } from "./modules/support-chat/support-chat.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
@@ -53,22 +54,24 @@ import {
} from "./seed/edr-freight.seed";
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
// Disabled seeds — imports commented out with their provider/injection/run below.
// import { DemoUsersSeeder } from "./seed/demo-users.seeder";
// import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
import { PaymentModule } from "./modules/payment/payment.module";
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
// import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder";
// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
// import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
// import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
// import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
// import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
// import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
// import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
// import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
//New Trains, Wagons, Container and Cargo management modules
@@ -93,6 +96,7 @@ import { FirstMileModule } from "./modules/first-mile/first-mile.module";
import { LastMileModule } from "./modules/last-mile/last-mile.module";
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
import { AiModule } from "./modules/ai/ai.module";
import { LoggerMiddleware } from "./logger.middleware";
@Module({
@@ -157,6 +161,7 @@ import { LoggerMiddleware } from "./logger.middleware";
BillingModule,
NotificationsModule,
NotificationInboxModule,
SupportChatModule,
FileUploadSettingsModule,
DropdownSettingsModule,
ContractTemplatesModule,
@@ -188,25 +193,28 @@ import { LoggerMiddleware } from "./logger.middleware";
ImportOperationsModule,
VerifaydaModule,
FleetHistoryModule,
AiModule,
],
providers: [
EdrOrgSeeder,
FreightPositionsSeeder,
DemoUsersSeeder,
FreightStaffUsersSeeder,
PricingDataSeeder,
FileUploadSettingsSeeder,
YardFacilitiesSeeder,
FreightPermissionKeyMigrationSeeder,
DemoFreightDataSeeder,
GovCompaniesSeeder,
IndodeFacilitySeeder,
Batch14TestDataSeeder,
Batch5TestDataSeeder,
Batch7TestDataSeeder,
Batch8TestDataSeeder,
WarehouseDemoSeeder,
ExportDjiboutiInterchangeDemoSeeder,
MarshallingDemoTrainsSeeder,
// Disabled seeds — providers commented out (imports/injection/run too):
// DemoUsersSeeder,
// FreightStaffUsersSeeder,
// PricingDataSeeder,
// DemoFreightDataSeeder,
// GovCompaniesSeeder,
// IndodeFacilitySeeder,
// Batch14TestDataSeeder,
// Batch5TestDataSeeder,
// Batch7TestDataSeeder,
// Batch8TestDataSeeder,
// WarehouseDemoSeeder,
// ExportDjiboutiInterchangeDemoSeeder,
// MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder,
PaidImportExportMileDemoSeeder,
],
@@ -216,51 +224,71 @@ export class AppModule implements OnApplicationBootstrap {
private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly freightPositionsSeeder: FreightPositionsSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder,
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
private readonly pricingDataSeeder: PricingDataSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
private readonly batch5TestDataSeeder: Batch5TestDataSeeder,
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
private readonly yardFacilitiesSeeder: YardFacilitiesSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
private readonly govCompaniesSeeder: GovCompaniesSeeder,
// Disabled seeds — injections commented out (imports/provider/run too):
// private readonly demoUsersSeeder: DemoUsersSeeder,
// private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
// private readonly pricingDataSeeder: PricingDataSeeder,
// private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
// private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
// private readonly batch5TestDataSeeder: Batch5TestDataSeeder,
// private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
// private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
// private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
// private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
// private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
// private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
// private readonly govCompaniesSeeder: GovCompaniesSeeder,
) { }
async onApplicationBootstrap() {
// ── Enabled: permissions + file-upload settings (+ dropdown settings) only ──
// Everything else below is intentionally disabled. Seeders stay registered
// as providers and injected; only their .run() calls are commented out, so
// re-enabling any of them is a one-line uncomment.
// Permissions foundation — keep enabled:
// freightPermissionKeyMigration → renames legacy permission keys
// seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions
// edrOrgSeeder → seeds org/unit + the Permission catalog
// freightPositionsSeeder → seeds Position + PositionPermission rows
// (depends on edrOrgSeeder, must run after)
await this.freightPermissionKeyMigrationSeeder.run();
await this.seeder.run();
await this.edrOrgSeeder.run();
await this.freightPositionsSeeder.run();
await this.demoUsersSeeder.run();
await this.freightStaffUsersSeeder.run();
await this.pricingDataSeeder.run();
// File upload settings — keep enabled.
await this.fileUploadSettingsSeeder.run();
await this.indodeFacilitySeeder.run();
await this.batch14TestDataSeeder.run();
await this.batch5TestDataSeeder.run();
await this.batch7TestDataSeeder.run();
await this.batch8TestDataSeeder.run();
await this.warehouseDemoSeeder.run();
await this.exportDjiboutiInterchangeDemoSeeder.run();
await this.marshallingDemoTrainsSeeder.run();
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
// Each block self-guards on an empty-table check, so this is safe every boot.
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
// FileUploadSettingsSeeder) are intentionally disabled — they stay
// registered as providers but are not run. Re-inject + call .run() to enable.
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
// rules are disabled inside the seeder). Kept running for the staff users.
await this.demoFreightDataSeeder.run();
// Government entities (with importer/exporter profiles) that government
// bookings bill to. Idempotent — keyed by fixed IDs.
await this.govCompaniesSeeder.run();
// Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama,
// Dire Dawa). Idempotent; creates no yards.
await this.yardFacilitiesSeeder.run();
// Dropdown settings are not seeded on boot; run them with
// `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts).
// ── Disabled: demo / test / reference data seeds ──
// Uncomment a line to re-enable that seed.
// await this.demoUsersSeeder.run();
// await this.freightStaffUsersSeeder.run();
// await this.pricingDataSeeder.run();
// await this.indodeFacilitySeeder.run();
// await this.batch14TestDataSeeder.run();
// await this.batch5TestDataSeeder.run();
// await this.batch7TestDataSeeder.run();
// await this.batch8TestDataSeeder.run();
// await this.warehouseDemoSeeder.run();
// await this.exportDjiboutiInterchangeDemoSeeder.run();
// await this.marshallingDemoTrainsSeeder.run();
// demoFreightDataSeeder seeds ONLY the 4 staff users (wagons + approval
// rules are already disabled inside the seeder).
// await this.demoFreightDataSeeder.run();
// Government entities (importer/exporter profiles) that government bookings
// bill to. Idempotent — keyed by fixed IDs.
// await this.govCompaniesSeeder.run();
}
configure(consumer: MiddlewareConsumer) {

View File

@@ -26,6 +26,18 @@ export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view);
export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage);
/** Requester creates a wagon-transfer request (count-only, no wagon picks). */
export const WagonTransferRequest = () =>
BookingStaff(FREIGHT_PERMS.wagons.transferRequest);
/** OCC fulfils a wagon-transfer request — picks the wagons and executes the move. */
export const WagonTransferFulfill = () =>
BookingStaff(FREIGHT_PERMS.wagons.transferFulfill);
/** Admin: read every staffer's wagon-transfer history (not just one's own). */
export const WagonTransferHistoryAll = () =>
BookingStaff(FREIGHT_PERMS.wagons.transferHistoryAll);
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);

View File

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

View File

@@ -0,0 +1,13 @@
/**
* Goods Received Note number: `GRN-<DIRECTION>-<YYYYMMDD>-<REF8>`.
*
* Shared so a GRN raised at a load/unload facility is indistinguishable from one
* raised in a warehouse — the two live in different tables
* (facility_handling_events vs warehouse_inventory), and a second generator would
* eventually let their formats drift apart.
*/
export function generateGrnNumber(direction: string, referenceId: string, date: Date): string {
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
}

View File

@@ -4,6 +4,7 @@ import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { FreightPermissionGuard } from './freight-permission.guard';
import {
FREIGHT_PERMS,
type RuleEngineApprovableSlug,
type RuleEngineResourceSlug,
} from '../seed/freight-permissions.registry';
@@ -16,3 +17,13 @@ export const RuleEngineManage = (slug: RuleEngineResourceSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])),
);
/**
* Deciding a filed change — a step above `manage`, which only lets a staff
* member propose one. Super admins pass any freight permission check, so
* approvals work before the permission is granted to a director role.
*/
export const RuleEngineApprove = (slug: RuleEngineApprovableSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.approve(slug)])),
);

View File

@@ -0,0 +1,28 @@
/**
* SQL CTE resolving the bookings riding a train schedule, as `sched_bookings
* (schedule_id, booking_id)`. Use as: `WITH ${SCHEDULE_BOOKINGS_CTE} SELECT ...`.
*
* A booking reaches a train through WAGON ALLOCATION
* (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations),
* which is what the allocation UI writes. `train_schedule_bookings` is only ever
* written by the demo seeders, so both sources are unioned: real allocations work
* and the seeded scenarios keep working.
*
* Shared so the warehouse loading queue and the train dispatch guard agree on
* exactly which bookings are on a train — if they drift, a train can be
* dispatched leaving cargo the warehouse still thinks it should load.
*/
export const SCHEDULE_BOOKINGS_CTE = `
sched_bookings AS (
SELECT ts.id AS schedule_id, wba.booking_id
FROM freight.train_schedules ts
JOIN freight.train_set_wagons tsw
ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL
JOIN freight.wagon_booking_allocations wba
ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL
WHERE ts.deleted_at IS NULL
UNION
SELECT tsb.train_schedule_id, tsb.booking_id
FROM freight.train_schedule_bookings tsb
WHERE tsb.deleted_at IS NULL
)`;

View File

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

View File

@@ -3,12 +3,19 @@ import Handlebars from 'handlebars';
/** One numbered clause of a dynamic article, with optional nested bullets. */
export interface RenderedClause {
text: string;
/** Computed outline number, e.g. "3" or "2.1.4". */
number: string;
/** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */
depth: number;
bullets: string[];
}
/** A dynamic article ready for the Handlebars template. */
export interface RenderedArticle {
number: number;
/** Stable article id from the template (e.g. "pricing") — lets the layout
* inject the live rate schedule table under the pricing article. */
id: string;
title: string;
/** Set (instead of clauses) when the body is a single plain paragraph. */
paragraph?: string;
@@ -16,10 +23,26 @@ export interface RenderedArticle {
}
/**
* Parse a template article body into clauses. Format: one clause per line;
* lines prefixed with "- " become bullets nested under the preceding clause.
* A body that reduces to a single clause without bullets renders as a plain
* paragraph rather than a numbered list of one.
* Leading outline token on a clause line: "1.", "2)", "1.1", "1.1.1." …
* The token's segment count sets the clause depth; its digits are ignored —
* numbering is recomputed sequentially so stale numbers self-heal.
* A single-segment token requires its "."/")" ("10 tons…" is prose, "10. x"
* is clause ten); multi-segment tokens ("1.1") may omit it. A token may also
* end the line — that is an empty clause still being typed in the editor.
*/
const CLAUSE_NUMBER_RE = /^(?:(\d+(?:\.\d+)+)[.)]?|(\d+)[.)])(?:\s+|$)/;
/** Deepest supported sub-clause level (1.1.1.1.1.1). */
const MAX_CLAUSE_DEPTH = 6;
/**
* Parse a template article body into clauses. Format: one clause per line.
* A leading outline number ("2. ", "2.1 ", "2.1.3 ") nests the line as a
* sub-clause at that depth — the typed digits are stripped and renumbered
* sequentially, so editing order never leaves stale numbers in the document.
* Lines prefixed with "- " become bullets nested under the preceding clause.
* A body that reduces to a single un-numbered clause without bullets renders
* as a plain paragraph rather than a numbered list of one.
*/
export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph' | 'clauses'> {
const lines = (body ?? '')
@@ -28,20 +51,44 @@ export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph
.filter((line) => line.length > 0);
const clauses: RenderedClause[] = [];
// counters[i] = current number at depth i+1; truncated when a shallower
// clause arrives so deeper numbering restarts at 1.
const counters: number[] = [];
let sawNumberToken = false;
for (const line of lines) {
if (line.startsWith('- ')) {
const bullet = line.slice(2).trim();
if (clauses.length === 0) {
clauses.push({ text: bullet, bullets: [] });
counters.splice(0, counters.length, 1);
clauses.push({ text: bullet, number: '1', depth: 1, bullets: [] });
} else {
clauses[clauses.length - 1].bullets.push(bullet);
}
} else {
clauses.push({ text: line, bullets: [] });
continue;
}
const match = CLAUSE_NUMBER_RE.exec(line);
const token = match ? (match[1] ?? match[2]) : null;
let depth = token ? Math.min(token.split('.').length, MAX_CLAUSE_DEPTH) : 1;
// A sub-clause can only sit directly under an existing parent — "1.1.1"
// typed as the first line clamps to whatever level is actually open.
depth = Math.min(depth, counters.length + 1);
if (match) sawNumberToken = true;
counters.splice(depth);
while (counters.length < depth) counters.push(0);
counters[depth - 1] += 1;
clauses.push({
text: match ? line.slice(match[0].length).trim() : line,
number: counters.slice(0, depth).join('.'),
depth,
bullets: [],
});
}
if (clauses.length === 1 && clauses[0].bullets.length === 0) {
if (clauses.length === 1 && clauses[0].bullets.length === 0 && !sawNumberToken) {
return { paragraph: clauses[0].text, clauses: [] };
}
return { clauses };

View File

@@ -1,7 +1,10 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ContractsRepository } from '../modules/contracts/contracts.repository';
import { Contract } from '../modules/contracts/entities/contract.entity';
import {
Contract,
ContractDocumentSnapshot,
} from '../modules/contracts/entities/contract.entity';
import { ContractRoute } from '../modules/contracts/entities/contract-route.entity';
import {
ContractSignature,
@@ -11,7 +14,11 @@ 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';
import { RateSchedule } from './contract-rate-schedule.builder';
/**
* Signature row for the contract PDF. Mirrors the booking builder's
@@ -90,22 +97,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,
@@ -115,6 +136,7 @@ export class ContractDocumentViewModelBuilder {
}
const pricing = this.buildPricing(contract);
const rateSchedule = this.buildRateSchedule(pricing);
const signatures = await this.loadSignatures(contractId);
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
@@ -157,6 +179,7 @@ export class ContractDocumentViewModelBuilder {
},
schedule: this.buildSchedule(contract),
pricing: pricing as unknown as ContractViewModel['pricing'],
rateSchedule,
// Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking
// view-model's narrower CUSTOMER|STAFF role union.
signatures: signatures as unknown as ContractViewModel['signatures'],
@@ -210,6 +233,40 @@ export class ContractDocumentViewModelBuilder {
};
}
/**
* A rate schedule for the contract PDF, sourced from the contract's own frozen
* unit rates (its agreed lane prices) rather than the global rate config — a
* signed contract must show the prices it was signed on. Rendered as freight
* lanes labelled with the contract's primary origin → destination route.
*/
private buildRateSchedule(pricing: ContractUnitRateSchedule): RateSchedule {
const route = `${pricing.originLabel}${pricing.destinationLabel}`;
const freightLanes = pricing.unitRates.map((line) => ({
route,
cargo: line.label,
currency: line.currency,
amount: this.formatAmount(line.unitPrice),
unit: line.unit.startsWith('per ') ? line.unit : `per ${line.unit}`,
}));
return {
freightLanes,
additionalServices: [],
surcharges: [],
isEmpty: freightLanes.length === 0,
currencyLabel: pricing.currency,
};
}
private formatAmount(value: number | string): string {
const num = Number(value);
if (!Number.isFinite(num)) return String(value);
return num.toLocaleString('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
}
private buildSchedule(contract: Contract): ContractViewModel['schedule'] {
const firstRoute = this.firstRoute(contract);
const cargoScope = (contract.cargoScope ?? [])[0];

View File

@@ -26,6 +26,40 @@ describe('parseArticleBody', () => {
expect(parsed.paragraph).toBe('This Agreement becomes effective when signed.');
expect(parsed.clauses).toEqual([]);
});
it('nests numbered sub-clauses by their outline token and renumbers sequentially', () => {
const parsed = parseArticleBody(
'1. Scope\n5.1 Rail transport\n1.1.1 Wagon supply\n2. Payment',
);
expect(parsed.clauses.map((c) => [c.number, c.depth, c.text])).toEqual([
['1', 1, 'Scope'],
['1.1', 2, 'Rail transport'],
['1.1.1', 3, 'Wagon supply'],
['2', 1, 'Payment'],
]);
});
it('clamps a sub-clause with no open parent to the next available level', () => {
const parsed = parseArticleBody('1.1.1 Orphan sub-clause\nSecond clause.');
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([
['1', 1],
['2', 1],
]);
});
it('leaves prose that merely starts with a number un-tokenized', () => {
const parsed = parseArticleBody('10 tons is the minimum load.\nPayment in advance.');
expect(parsed.clauses.map((c) => c.text)).toEqual([
'10 tons is the minimum load.',
'Payment in advance.',
]);
});
it('keeps a single explicitly numbered line as a clause, not a paragraph', () => {
const parsed = parseArticleBody('1. Only clause.');
expect(parsed.paragraph).toBeUndefined();
expect(parsed.clauses.map((c) => [c.number, c.text])).toEqual([['1', 'Only clause.']]);
});
});
describe('interpolateTemplateText', () => {
@@ -99,6 +133,17 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
originLabel: 'Nagad',
destinationLabel: 'Galaan Multipurpose Port',
} as unknown as ContractViewModel['pricing'],
rateSchedule: {
freightLanes: [
{ route: 'Nagad → Galaan Multipurpose Port', cargo: 'Wheat', currency: 'USD', amount: '100', unit: 'per wagon' },
],
additionalServices: [
{ route: 'First-mile pickup by truck', cargo: '—', currency: 'USD', amount: '50', unit: 'per wagon' },
],
surcharges: [],
isEmpty: false,
currencyLabel: 'USD',
},
signatures: [],
canSignCustomer: false,
canSignStaff: false,
@@ -117,11 +162,17 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
body: 'Integrated logistics services including:\n- Rail transport to GMP\n- Customs clearance',
order: 1,
},
{
id: 'pricing',
title: 'Contract Price and Payment Terms',
body: 'Rates are set out in the Rate Schedule below.\nPayments 100% in advance.',
order: 2,
},
{
id: 'duration',
title: 'Duration',
body: 'Valid until August 31, {{contractYear}}.',
order: 2,
order: 3,
},
],
},
@@ -141,6 +192,16 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
expect(html).toContain('#1b9e7a');
});
it('renders the live rate schedule lane under the pricing article', () => {
const html = renderer.render(dynamicView());
expect(html).toContain('Rate Schedule');
// Base freight lane pulled from the rate config
expect(html).toContain('Nagad → Galaan Multipurpose Port');
expect(html).toContain('USD 100 per wagon');
// Additional-service group
expect(html).toContain('First-mile pickup by truck');
});
it('keeps the generic layout when no dynamic template is attached', () => {
const view = dynamicView();
delete view.dynamicTemplate;

View File

@@ -0,0 +1,226 @@
import { Injectable } from '@nestjs/common';
import { RatesService } from '../modules/rule-engine/services/rates.service';
import { Rate } from '../modules/rule-engine/entities/rate.entity';
import {
ContractDirection,
ContractFreight,
} from './contract-template.types';
/** One priced line in the contract's rate schedule. */
export interface RateScheduleRow {
/** "Negad → Mojo Dry Port" for base freight, service name otherwise. */
route: string;
/** "40ft GP", "Wheat", or "—" when the rate is not scoped to a type. */
cargo: string;
currency: string;
/** Pre-formatted amount, e.g. "200" (grouped, no trailing zeros). */
amount: string;
/** Human unit, e.g. "per container", "per wagon", "per ton". */
unit: string;
}
/**
* The origin → destination rate schedule shown in a generated contract's
* pricing article. Grouped so the reader sees rail freight lanes first, then
* pickup/delivery legs, then trigger-based surcharges and demurrage.
*/
export interface RateSchedule {
/** Base rail freight lanes matching this contract's direction + freight. */
freightLanes: RateScheduleRow[];
/** First-mile / last-mile truck legs (route-agnostic). */
additionalServices: RateScheduleRow[];
/** Hazard, reefer, overweight, demurrage, customs, etc. */
surcharges: RateScheduleRow[];
/** True when every group is empty — the template falls back to prose. */
isEmpty: boolean;
/** Currencies present across the schedule, e.g. "USD" or "USD, ETB". */
currencyLabel: string;
}
const UNIT_LABELS: Record<string, string> = {
PER_WAGON: 'per wagon',
PER_TON: 'per ton',
PER_CONTAINER: 'per container',
PER_KM: 'per km',
PER_INVOICE: 'per invoice',
FLAT: 'flat',
};
const SERVICE_ROUTE_LABELS: Partial<Record<Rate['appliesTo'], string>> = {
FIRST_MILE: 'First-mile pickup by truck',
LAST_MILE: 'Last-mile delivery by truck',
};
/** Friendly wording for the trigger-based charges shown in the surcharge group. */
const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
HAZARDOUS: 'Hazardous cargo surcharge',
OVERWEIGHT: 'Overweight surcharge',
REEFER: 'Reefer (refrigerated) surcharge',
WITH_RETURN: 'Empty-container return service',
SHIPPING_LINE: 'Shipping line handling',
CONSOLIDATION: 'Container consolidation (extra document)',
LASHING: 'Cargo lashing and securing',
CANCELLATION: 'Booking cancellation fee',
DEMURRAGE: 'Demurrage / wagon detention',
PIL_EXTRA_FEE: 'PIL shipping line extra fee',
CUSTOMS_CLEARANCE: 'Customs clearance service',
};
@Injectable()
export class ContractRateScheduleBuilder {
constructor(private readonly ratesService: RatesService) {}
/**
* Build the rate schedule for a contract of the given direction + freight.
* Base-freight lanes are filtered to the matching trade direction / freight
* kind so an import container contract shows import container lanes only;
* additional services and surcharges are route-agnostic and always shown.
*/
async build(
direction: ContractDirection,
freight: ContractFreight,
): Promise<RateSchedule> {
const rates = await this.ratesService.findLiveRatesDetailed();
const freightLanes: RateScheduleRow[] = [];
const additionalServices: RateScheduleRow[] = [];
const surcharges: RateScheduleRow[] = [];
for (const rate of rates) {
if (this.isBaseFreight(rate)) {
if (this.baseFreightMatches(rate, direction, freight)) {
freightLanes.push(this.laneRow(rate));
}
continue;
}
if (rate.appliesTo === 'FIRST_MILE' || rate.appliesTo === 'LAST_MILE') {
additionalServices.push(this.serviceRow(rate));
continue;
}
// Everything left is a trigger-based charge (surcharge / demurrage / customs).
surcharges.push(this.surchargeRow(rate));
}
const currencyLabel = this.currencyLabel([
...freightLanes,
...additionalServices,
...surcharges,
]);
return {
freightLanes,
additionalServices,
surcharges,
isEmpty:
freightLanes.length === 0 &&
additionalServices.length === 0 &&
surcharges.length === 0,
currencyLabel,
};
}
private isBaseFreight(rate: Rate): boolean {
return (
rate.trigger === 'ALWAYS' &&
(rate.appliesTo === 'BULK' ||
rate.appliesTo === 'CONTAINER' ||
rate.appliesTo === 'INTERCITY')
);
}
private baseFreightMatches(
rate: Rate,
direction: ContractDirection,
freight: ContractFreight,
): boolean {
// Domestic contracts price off intercity rates; the freight kind is carried
// in the derived rateType (INTERCITY_BULK vs INTERCITY_CONTAINER).
if (direction === 'DOM') {
if (rate.appliesTo !== 'INTERCITY') return false;
return freight === 'BULK'
? rate.rateType === 'INTERCITY_BULK'
: rate.rateType === 'INTERCITY_CONTAINER';
}
// Import / export price off BULK or CONTAINER rates matching the direction.
const wantAppliesTo = freight === 'BULK' ? 'BULK' : 'CONTAINER';
if (rate.appliesTo !== wantAppliesTo) return false;
const wantDirection = direction === 'IMP' ? 'IMPORT' : 'EXPORT';
return rate.tradeDirection === wantDirection;
}
private laneRow(rate: Rate): RateScheduleRow {
const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—';
const destination =
rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—';
return {
route: `${origin}${destination}`,
cargo: this.cargoLabel(rate),
currency: rate.currency,
amount: this.formatAmount(rate.rateValue),
unit: this.unitLabel(rate.rateUnit),
};
}
private serviceRow(rate: Rate): RateScheduleRow {
return {
route: SERVICE_ROUTE_LABELS[rate.appliesTo] ?? rate.appliesTo,
cargo: this.cargoLabel(rate),
currency: rate.currency,
amount: this.formatAmount(rate.rateValue),
unit: this.unitLabel(rate.rateUnit),
};
}
private surchargeRow(rate: Rate): RateScheduleRow {
return {
route: TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger),
cargo: this.cargoLabel(rate),
currency: rate.currency,
amount: this.formatAmount(rate.rateValue),
unit: this.unitLabel(rate.rateUnit),
};
}
/** The type a rate is scoped to (container/cargo), or a dash when unscoped. */
private cargoLabel(rate: Rate): string {
return (
rate.containerType?.label ??
rate.containerType?.code ??
rate.cargoType?.cargoTypeName ??
'—'
);
}
private unitLabel(unit: Rate['rateUnit']): string {
return UNIT_LABELS[unit] ?? unit.toLowerCase().replace(/_/g, ' ');
}
/** Group thousands and drop the DB's trailing zeros: "200.0000" → "200". */
private formatAmount(value: number | string): string {
const num = Number(value);
if (!Number.isFinite(num)) return String(value);
return num.toLocaleString('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
}
private currencyLabel(rows: RateScheduleRow[]): string {
const seen: string[] = [];
for (const row of rows) {
if (!seen.includes(row.currency)) seen.push(row.currency);
}
return seen.join(', ') || 'USD';
}
private titleCase(value: string): string {
return value
.toLowerCase()
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
}

View File

@@ -58,6 +58,15 @@ describe('ContractRendererService', () => {
destinationLabel: 'Modjo',
containerLines: [{ label: '40ft', quantity: 2, vgmPerUnitTons: 12 }],
},
rateSchedule: {
freightLanes: [
{ route: 'SGTD → Modjo', cargo: '40ft GP', currency: 'USD', amount: '200', unit: 'per container' },
],
additionalServices: [],
surcharges: [],
isEmpty: false,
currencyLabel: 'USD',
},
signatures: [],
canSignCustomer: true,
canSignStaff: false,

View File

@@ -57,6 +57,7 @@ export class ContractRendererService implements OnModuleInit {
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((article, index) => ({
number: index + 1,
id: article.id,
title: interpolateTemplateText(article.title, view),
...parseArticleBody(interpolateTemplateText(article.body, view)),
}));

View File

@@ -7,6 +7,7 @@ import {
ContractSignerRole,
} from '../modules/bookings/entities/booking-contract-signature.entity';
import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder';
import { ContractRateScheduleBuilder, RateSchedule } from './contract-rate-schedule.builder';
import { ContractTemplateResolver } from './contract-template.resolver';
import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry';
@@ -74,6 +75,12 @@ export interface ContractViewModel {
lastMileDeliveryAddress: string;
};
pricing: PricingSchedule;
/**
* The live origin → destination rate schedule (base freight lanes + services
* + surcharges) matching this contract's direction and freight kind. Drives
* the pricing article's rate table so the contract mirrors the rate config.
*/
rateSchedule: RateSchedule;
signatures: ContractSignatureView[];
canSignCustomer: boolean;
canSignStaff: boolean;
@@ -89,6 +96,7 @@ export class ContractViewModelBuilder {
private readonly bookingsRepository: BookingsRepository,
private readonly templateResolver: ContractTemplateResolver,
private readonly pricingBuilder: ContractPricingScheduleBuilder,
private readonly rateScheduleBuilder: ContractRateScheduleBuilder,
) {}
async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> {
@@ -101,6 +109,10 @@ export class ContractViewModelBuilder {
booking.contractTemplateKey ?? this.templateResolver.resolve(booking);
const template = getTemplateMeta(templateKey);
const pricing = await this.pricingBuilder.build(booking);
const rateSchedule = await this.rateScheduleBuilder.build(
template.direction,
template.freight,
);
const signatures = await this.loadSignatures(bookingId);
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
@@ -143,6 +155,7 @@ export class ContractViewModelBuilder {
},
schedule: this.buildSchedule(booking),
pricing,
rateSchedule,
signatures,
canSignCustomer:
booking.status === 'CONTRACT_READY' && !hasCustomer,

View File

@@ -25,25 +25,9 @@
<p><strong>Equipment return:</strong> {{pricing.equipmentReturn}}</p>
{{/if}}
{{#if pricing.unitRates}}
<h3>Unit Rate Schedule</h3>
<p>
The rates below are the frozen unit prices applicable to this contract. Quantities and the resulting
totals are determined per shipment at booking time; no total contract value is fixed at this stage.
</p>
<table class="schedule">
<thead>
<tr><th>Item</th><th>Unit price</th></tr>
</thead>
<tbody>
{{#each pricing.unitRates}}
<tr>
<td>{{label}}</td>
<td>{{currency}} {{unitPrice}} / {{unit}}</td>
</tr>
{{/each}}
</tbody>
</table>
{{#unless rateSchedule.isEmpty}}
<h3>Rate Schedule</h3>
{{> rate_schedule}}
{{else}}
<h3>Charges</h3>
<table class="schedule">
@@ -76,7 +60,7 @@
</tr>
</tbody>
</table>
{{/if}}
{{/unless}}
<h3>Terms of payment</h3>
<p>
Unless otherwise agreed in writing, the Client shall settle the contract value in

View File

@@ -6,7 +6,8 @@
{{else}}
<ol class="clauses">
{{#each clauses}}
<li>
<li class="clause depth-{{depth}}">
<span class="clause-no">{{number}}.</span>
{{text}}
{{#if bullets.length}}
<ul class="clause-bullets">
@@ -19,5 +20,9 @@
{{/each}}
</ol>
{{/if}}
{{#if (eq id "pricing")}}
<h3>Rate Schedule</h3>
{{> rate_schedule}}
{{/if}}
</section>
{{/each}}

View File

@@ -0,0 +1,55 @@
{{#if rateSchedule.isEmpty}}
<p class="muted-note">
No published rate schedule is currently on file for this corridor. Applicable charges will be quoted
by the Service Provider per shipment in accordance with the prevailing EDR tariff.
</p>
{{else}}
<p>
The charges below are the current published railway tariff for this contract's trade direction and
freight type, expressed as unit prices per origin → destination lane. Quantities and the resulting
totals are determined per shipment at booking time.
</p>
<table class="schedule">
<thead>
<tr>
<th>Route / Service</th>
<th>Cargo / Equipment</th>
<th>Unit price</th>
</tr>
</thead>
<tbody>
{{#if rateSchedule.freightLanes.length}}
<tr><th colspan="3">Railway Freight — Origin → Destination</th></tr>
{{#each rateSchedule.freightLanes}}
<tr>
<td>{{route}}</td>
<td>{{cargo}}</td>
<td>{{currency}} {{amount}} {{unit}}</td>
</tr>
{{/each}}
{{/if}}
{{#if rateSchedule.additionalServices.length}}
<tr><th colspan="3">Additional Services</th></tr>
{{#each rateSchedule.additionalServices}}
<tr>
<td>{{route}}</td>
<td>{{cargo}}</td>
<td>{{currency}} {{amount}} {{unit}}</td>
</tr>
{{/each}}
{{/if}}
{{#if rateSchedule.surcharges.length}}
<tr><th colspan="3">Surcharges, Demurrage &amp; Fees</th></tr>
{{#each rateSchedule.surcharges}}
<tr>
<td>{{route}}</td>
<td>{{cargo}}</td>
<td>{{currency}} {{amount}} {{unit}}</td>
</tr>
{{/each}}
{{/if}}
</tbody>
</table>
{{/if}}

View File

@@ -282,28 +282,27 @@
.article-name { color: #0e5b45; }
.article-paragraph { margin: 4px 0 0; }
ol.clauses {
counter-reset: clause;
list-style: none;
margin: 6px 0 0;
padding-left: 0;
}
ol.clauses > li {
counter-increment: clause;
ol.clauses > li.clause {
margin-bottom: 6px;
padding-left: 24px;
position: relative;
text-align: justify;
}
ol.clauses > li::before {
ol.clauses .clause-no {
color: #0e5b45;
content: counter(clause) ".";
font-family: Arial, sans-serif;
font-size: 9.5pt;
font-weight: 700;
left: 0;
position: absolute;
top: 1px;
margin-right: 6px;
}
/* Sub-clause indentation: each outline level steps in. */
ol.clauses > li.depth-2 { padding-left: 20px; }
ol.clauses > li.depth-3 { padding-left: 40px; }
ol.clauses > li.depth-4 { padding-left: 60px; }
ol.clauses > li.depth-5 { padding-left: 80px; }
ol.clauses > li.depth-6 { padding-left: 100px; }
ul.clause-bullets {
margin: 5px 0 2px;
padding-left: 16px;

View File

@@ -134,26 +134,8 @@
</tbody>
</table>
{{#if pricing.unitRates.length}}
<h3>Agreed Unit Rates</h3>
<p class="muted-note">
The rates below are the frozen unit prices applicable to this contract. Quantities and resulting
totals are determined per shipment at booking time.
</p>
<table class="schedule">
<thead>
<tr><th>Item</th><th>Unit price</th></tr>
</thead>
<tbody>
{{#each pricing.unitRates}}
<tr>
<td>{{label}}</td>
<td>{{currency}} {{unitPrice}} / {{unit}}</td>
</tr>
{{/each}}
</tbody>
</table>
{{/if}}
<h3>Published Rate Schedule</h3>
{{> rate_schedule}}
</section>
{{!-- ────────────────────────── Signatures ───────────────────────────── --}}

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* `company_profiles.status` defaulted to 'active', so any insert that omitted
* the column produced an operational role that was approved without ever being
* reviewed. Every live write path already passes 'pending' explicitly; this
* closes the hole at the schema level.
*
* Deliberately no data backfill. A role approved through setCompanyProfileStatus
* always stamps `reviewed_at`, so `status = 'active' AND reviewed_at IS NULL`
* flags a role that skipped review — but it also matches rows approved before
* `reviewed_at` existed (migration 2000000000001). Auditing that set is a
* judgement call about real customers, not something to automate here.
*/
export class CompanyProfileDefaultPending2100000000000
implements MigrationInterface
{
name = 'CompanyProfileDefaultPending2100000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."company_profiles" ALTER COLUMN "status" SET DEFAULT 'pending'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."company_profiles" ALTER COLUMN "status" SET DEFAULT 'active'`,
);
}
}

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Auto-load onto a selected train: a warehouse_loadings row now records WHICH
* train the item was loaded onto (train_schedule_id), and wagon_id becomes
* nullable because a schedule-level load may not resolve to a single wagon.
*/
export class WarehouseLoadingTrainAssociation2100000000000 implements MigrationInterface {
name = 'WarehouseLoadingTrainAssociation2100000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_loadings
ADD COLUMN IF NOT EXISTS train_schedule_id UUID NULL
`);
await queryRunner.query(`
ALTER TABLE freight.warehouse_loadings
ALTER COLUMN wagon_id DROP NOT NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_train_schedule
ON freight.warehouse_loadings(train_schedule_id)
WHERE train_schedule_id IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_loadings_train_schedule`);
await queryRunner.query(`
ALTER TABLE freight.warehouse_loadings DROP COLUMN IF EXISTS train_schedule_id
`);
// wagon_id stays nullable on revert: restoring NOT NULL would fail on rows
// recorded without a wagon and re-introduce the outage this fixes.
}
}

View File

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

View File

@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Repair migration. `SeparateVehicleAvailability1890000000000` is recorded in
* public.migrations but the `availability` column is absent on some databases
* (recorded-but-not-applied drift). Because the original is already recorded,
* TypeORM will not re-run it, so `vehiclesService.findAll` (a query builder that
* selects every entity column) 500s with `column "availability" does not exist`.
*
* This re-adds the column idempotently and backfills. Safe to run everywhere:
* `IF NOT EXISTS` makes it a no-op where the column already exists.
*/
export class RepairVehicleAvailabilityColumn2110000000000
implements MigrationInterface
{
name = "RepairVehicleAvailabilityColumn2110000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.vehicles
ADD COLUMN IF NOT EXISTS availability varchar DEFAULT 'FREE'
`);
await queryRunner.query(`
UPDATE freight.vehicles SET availability = 'FREE' WHERE availability IS NULL
`);
}
public async down(): Promise<void> {
// No-op: dropping a column other code now depends on would reintroduce the
// drift. The original SeparateVehicleAvailability migration owns the column.
}
}

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* The person who signs off a handover must record their full name (a signature
* is optional, especially for self-haul). Stored per handover record.
*/
export class AddHandoverSignerName2130000000000 implements MigrationInterface {
name = "AddHandoverSignerName2130000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_handovers
ADD COLUMN IF NOT EXISTS signer_name varchar(160)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_handovers
DROP COLUMN IF EXISTS signer_name
`);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Accrual alert acknowledgements: ops can mark an in-warehouse item's fee
* accrual as reviewed (optionally snoozed until a date) so it stops nudging and
* drops down the accrual dashboard. One row per inventory item.
*/
export class CreateAccrualAcks2140000000000 implements MigrationInterface {
name = "CreateAccrualAcks2140000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_accrual_acks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
inventory_id uuid NOT NULL UNIQUE,
acknowledged_by uuid,
acknowledged_at timestamptz NOT NULL DEFAULT now(),
snooze_until timestamptz,
note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_accrual_acks`);
}
}

View File

@@ -0,0 +1,105 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Train Builder: a `Train` becomes a first-class buildable consist — a coded
* train (e.g. 81001) assembled in one yard from 2+ locomotives and ordered
* wagons, then reused by scheduling ("schedule the train" instead of picking
* locomotives per departure).
*
* - `freight.train_locomotives` — link table train ⇄ locomotive with an order
* index (mirrors `train_set_locomotives`).
* - `trains.current_yard_id` — yard the train sits in; wagons/locomotives may
* only be attached from this yard.
* - `train_sets.train_id` — which built train an operational set was formed
* from, so schedules can surface the train code and the lifecycle can sync
* the train's status/yard on dispatch/arrival/cancel.
*
* 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 TrainBuilder2150000000000 implements MigrationInterface {
name = 'TrainBuilder2150000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_locomotives (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
train_id uuid NOT NULL,
locomotive_id uuid NOT NULL,
sequence_no int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT "PK_train_locomotives" PRIMARY KEY (id),
CONSTRAINT "FK_train_locomotives_train" FOREIGN KEY (train_id)
REFERENCES freight.trains (id) ON DELETE CASCADE,
CONSTRAINT "FK_train_locomotives_locomotive" FOREIGN KEY (locomotive_id)
REFERENCES freight.locomotives (id)
);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_train_locomotives_train_loco"
ON freight.train_locomotives (train_id, locomotive_id);
`);
await queryRunner.query(`
ALTER TABLE freight.trains
ADD COLUMN IF NOT EXISTS current_yard_id uuid;
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FK_trains_current_yard'
) THEN
ALTER TABLE freight.trains
ADD CONSTRAINT "FK_trains_current_yard" FOREIGN KEY (current_yard_id)
REFERENCES freight.yards (id) ON DELETE SET NULL;
END IF;
END $$;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_trains_current_yard_id"
ON freight.trains (current_yard_id);
`);
await queryRunner.query(`
ALTER TABLE freight.train_sets
ADD COLUMN IF NOT EXISTS train_id uuid;
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FK_train_sets_train'
) THEN
ALTER TABLE freight.train_sets
ADD CONSTRAINT "FK_train_sets_train" FOREIGN KEY (train_id)
REFERENCES freight.trains (id) ON DELETE SET NULL;
END IF;
END $$;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_train_sets_train_id"
ON freight.train_sets (train_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_train_sets_train_id";`);
await queryRunner.query(`
ALTER TABLE freight.train_sets
DROP CONSTRAINT IF EXISTS "FK_train_sets_train",
DROP COLUMN IF EXISTS train_id;
`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_trains_current_yard_id";`);
await queryRunner.query(`
ALTER TABLE freight.trains
DROP CONSTRAINT IF EXISTS "FK_trains_current_yard",
DROP COLUMN IF EXISTS current_yard_id;
`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_train_locomotives_train_loco";`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_locomotives;`);
}
}

View File

@@ -0,0 +1,119 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* A container type / cargo type can now be carried by SEVERAL wagon types
* (e.g. a 20ft container rides NX70 or NW5). Replaces the single
* `wagon_type_id` FK on both tables with proper link tables; train scheduling
* resolves the wagon type from the list, picking whichever type the schedule's
* built train (or the yard) actually has.
*
* Backfills one link row from each existing `wagon_type_id`, then drops the
* old column — the single-FK field is removed from the API and UI entirely.
*
* 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 MultiWagonTypePerCargoAndContainer2160000000000 implements MigrationInterface {
name = 'MultiWagonTypePerCargoAndContainer2160000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.container_type_wagon_types (
container_type_id uuid NOT NULL,
wagon_type_id uuid NOT NULL,
CONSTRAINT "PK_container_type_wagon_types" PRIMARY KEY (container_type_id, wagon_type_id),
CONSTRAINT "FK_ctwt_container_type" FOREIGN KEY (container_type_id)
REFERENCES freight.container_types (id) ON DELETE CASCADE,
CONSTRAINT "FK_ctwt_wagon_type" FOREIGN KEY (wagon_type_id)
REFERENCES freight.wagon_types (id) ON DELETE RESTRICT
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.cargo_type_wagon_types (
cargo_type_id uuid NOT NULL,
wagon_type_id uuid NOT NULL,
CONSTRAINT "PK_cargo_type_wagon_types" PRIMARY KEY (cargo_type_id, wagon_type_id),
CONSTRAINT "FK_cgwt_cargo_type" FOREIGN KEY (cargo_type_id)
REFERENCES freight.cargo_types (id) ON DELETE CASCADE,
CONSTRAINT "FK_cgwt_wagon_type" FOREIGN KEY (wagon_type_id)
REFERENCES freight.wagon_types (id) ON DELETE RESTRICT
);
`);
// Backfill from the old single FK (column may already be gone on re-run).
await queryRunner.query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'freight' AND table_name = 'container_types'
AND column_name = 'wagon_type_id'
) THEN
INSERT INTO freight.container_type_wagon_types (container_type_id, wagon_type_id)
SELECT ct.id, ct.wagon_type_id
FROM freight.container_types ct
WHERE ct.wagon_type_id IS NOT NULL
ON CONFLICT DO NOTHING;
END IF;
END $$;
`);
await queryRunner.query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'freight' AND table_name = 'cargo_types'
AND column_name = 'wagon_type_id'
) THEN
INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id)
SELECT cg.id, cg.wagon_type_id
FROM freight.cargo_types cg
WHERE cg.wagon_type_id IS NOT NULL
ON CONFLICT DO NOTHING;
END IF;
END $$;
`);
// Old single-FK column is fully retired (API + UI now use the lists).
await queryRunner.query(`
ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagon_type_id;
`);
await queryRunner.query(`
ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS wagon_type_id;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.container_types ADD COLUMN IF NOT EXISTS wagon_type_id uuid
REFERENCES freight.wagon_types (id) ON DELETE RESTRICT;
`);
await queryRunner.query(`
ALTER TABLE freight.cargo_types ADD COLUMN IF NOT EXISTS wagon_type_id uuid
REFERENCES freight.wagon_types (id) ON DELETE RESTRICT;
`);
// Restore the first linked wagon type per row, then drop the link tables.
await queryRunner.query(`
UPDATE freight.container_types ct
SET wagon_type_id = link.wagon_type_id
FROM (
SELECT DISTINCT ON (container_type_id) container_type_id, wagon_type_id
FROM freight.container_type_wagon_types
ORDER BY container_type_id, wagon_type_id
) link
WHERE link.container_type_id = ct.id;
`);
await queryRunner.query(`
UPDATE freight.cargo_types cg
SET wagon_type_id = link.wagon_type_id
FROM (
SELECT DISTINCT ON (cargo_type_id) cargo_type_id, wagon_type_id
FROM freight.cargo_type_wagon_types
ORDER BY cargo_type_id, wagon_type_id
) link
WHERE link.cargo_type_id = cg.id;
`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.container_type_wagon_types;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.cargo_type_wagon_types;`);
}
}

View File

@@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Two-person wagon-transfer request queue. A requester records a count-only
* request (N wagons of a type, from yard → to yard); OCC staff later pick the
* physical wagons and execute the move. Replaces the single-step instant
* bulk-transfer as the customer-facing yard-to-yard relocation path.
*/
export class CreateWagonTransferRequests2170000000000
implements MigrationInterface
{
name = 'CreateWagonTransferRequests2170000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.wagon_transfer_requests (
id uuid NOT NULL DEFAULT gen_random_uuid(),
from_yard_id uuid NOT NULL,
to_yard_id uuid NOT NULL,
wagon_type_id uuid NOT NULL,
quantity integer NOT NULL,
status varchar(20) NOT NULL DEFAULT 'PENDING',
requested_by_user_id uuid NULL,
fulfilled_by_user_id uuid NULL,
fulfilled_at timestamptz NULL,
note text NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL,
CONSTRAINT pk_wagon_transfer_requests PRIMARY KEY (id),
CONSTRAINT fk_wtr_from_yard FOREIGN KEY (from_yard_id) REFERENCES freight.yards (id),
CONSTRAINT fk_wtr_to_yard FOREIGN KEY (to_yard_id) REFERENCES freight.yards (id),
CONSTRAINT fk_wtr_wagon_type FOREIGN KEY (wagon_type_id) REFERENCES freight.wagon_types (id),
CONSTRAINT chk_wtr_quantity CHECK (quantity > 0)
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wtr_status_from_yard
ON freight.wagon_transfer_requests (status, from_yard_id)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight.idx_wtr_status_from_yard`,
);
await queryRunner.query(
`DROP TABLE IF EXISTS freight.wagon_transfer_requests`,
);
}
}

View File

@@ -0,0 +1,50 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Consist adjustments from a schedule: staff can trim free wagons off a built
* train when their tare pushes gross weight over the locomotives' pull limit
* (incl. overage tolerance), or couple extra yard wagons on while weight and
* length headroom remain. Each add/remove is logged here so the schedule keeps
* an auditable history; the built train itself is updated in place.
*
* Plain columns (no FKs) so the history survives wagon/train deletion.
*
* 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 ScheduleWagonAdjustmentLogs2170000000000 implements MigrationInterface {
name = 'ScheduleWagonAdjustmentLogs2170000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.schedule_wagon_adjustment_logs (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
train_schedule_id uuid NOT NULL,
train_id uuid NOT NULL,
action varchar(10) NOT NULL,
wagon_id uuid NOT NULL,
wagon_number varchar(50) NOT NULL,
adjusted_by_user_id uuid,
occurred_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT "PK_schedule_wagon_adjustment_logs" PRIMARY KEY (id)
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_swal_train_schedule_id"
ON freight.schedule_wagon_adjustment_logs (train_schedule_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_swal_train_id"
ON freight.schedule_wagon_adjustment_logs (train_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_swal_train_id";`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_swal_train_schedule_id";`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.schedule_wagon_adjustment_logs;`);
}
}

View File

@@ -0,0 +1,54 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Link each physical wagon move back to the transfer request that drove it, so
* the history can show "Request S→K, 3× NX70 → wagons W101, W102, W103".
* Nullable — legacy moves and non-request manual corrections carry no request.
* Also indexes `moved_by_user_id` for the per-user history queries.
*/
export class LinkWagonMovementToTransferRequest2180000000000
implements MigrationInterface
{
name = 'LinkWagonMovementToTransferRequest2180000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_movements
ADD COLUMN IF NOT EXISTS transfer_request_id uuid NULL
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_wm_transfer_request'
) THEN
ALTER TABLE freight.wagon_movements
ADD CONSTRAINT fk_wm_transfer_request
FOREIGN KEY (transfer_request_id)
REFERENCES freight.wagon_transfer_requests (id) ON DELETE SET NULL;
END IF;
END $$;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wm_transfer_request
ON freight.wagon_movements (transfer_request_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wm_moved_by
ON freight.wagon_movements (moved_by_user_id)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wm_moved_by`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wm_transfer_request`);
await queryRunner.query(`
ALTER TABLE freight.wagon_movements
DROP CONSTRAINT IF EXISTS fk_wm_transfer_request
`);
await queryRunner.query(`
ALTER TABLE freight.wagon_movements
DROP COLUMN IF EXISTS transfer_request_id
`);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
`);
}
}

View File

@@ -0,0 +1,64 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* EDR last-mile is multi-truck: a booking can be served by as many trucks as it
* has containers (bulk hauls until the tonnage is drawn down). Arrival/delivery
* were stamped once per `last_mile` record, so every truck shared one timestamp.
* These per-vehicle columns give each EDR truck its own arrival, leaving and
* weighed load — the same granularity self-haul trucks already have.
*
* Weights are TONNES (matching bookings.cargo_total_weight_vgm and the exit
* weighing UI). Named `*_tons` deliberately: the older
* customer_truck_assignments.gross_weight_kg is named kg but stores tonnes.
* All nullable — legacy rows predate per-truck tracking.
*/
export class AddLastMileTruckArrivalDeparture2260000000000 implements MigrationInterface {
name = 'AddLastMileTruckArrivalDeparture2260000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_vehicle_assignments
ADD COLUMN IF NOT EXISTS arrived_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS departed_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS gross_weight_tons numeric(14, 3) NULL,
ADD COLUMN IF NOT EXISTS net_weight_tons numeric(14, 3) NULL
`);
// A truck carries 1x40ft OR 2x20ft, so an EDR truck needs MORE than the one
// container the legacy scalar `container_number` can hold. Mirrors the
// self-haul customer_truck_containers child table. The scalar stays in place
// (synced to the first container) for backward compatibility.
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.last_mile_vehicle_containers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
assignment_id uuid NOT NULL REFERENCES freight.last_mile_vehicle_assignments(id) ON DELETE CASCADE,
last_mile_id uuid NOT NULL,
container_number varchar(32) NOT 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_last_mile_vehicle_containers_assignment"
ON freight.last_mile_vehicle_containers (assignment_id)
`);
// A container rides exactly one truck per delivery.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_last_mile_vehicle_container"
ON freight.last_mile_vehicle_containers (last_mile_id, container_number)
WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.last_mile_vehicle_containers`);
await queryRunner.query(`
ALTER TABLE freight.last_mile_vehicle_assignments
DROP COLUMN IF EXISTS arrived_at,
DROP COLUMN IF EXISTS departed_at,
DROP COLUMN IF EXISTS gross_weight_tons,
DROP COLUMN IF EXISTS net_weight_tons
`);
}
}

View File

@@ -0,0 +1,109 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Re-seed the EDR wagon fleet onto the official ER numbering.
*
* Supersedes SeedWagonsWithYardAssignment1784000000001, which seeded 500 wagons
* on a `<CODE>-NNNN` scheme and wrote the status as 'Available' — mixed case
* that never matches WagonStatus.Available ('AVAILABLE'), so status filters
* silently returned nothing. This seed uses the enum value.
*
* Every wagon lands unassigned: current_yard_id NULL, status AVAILABLE. Wagon
* specs (capacity/length/tare) stay owned by wagon_types and are not touched —
* the types already exist and only the wagon↔type link is (re)established here.
*/
type FleetRow = {
code: string;
start: number;
end: number;
count: number;
};
/** Official fleet: 1100 wagons, ER0001ER1100, contiguous across 10 types. */
const FLEET: FleetRow[] = [
{ code: 'PW2', start: 1, end: 220, count: 220 },
{ code: 'CW4', start: 221, end: 330, count: 110 },
{ code: 'CW3', start: 331, end: 350, count: 20 },
{ code: 'KW2', start: 351, end: 370, count: 20 },
{ code: 'KW3', start: 371, end: 390, count: 20 },
{ code: 'NW5', start: 391, end: 940, count: 550 },
{ code: 'BW1', start: 941, end: 950, count: 10 },
{ code: 'GW2', start: 951, end: 1060, count: 110 },
{ code: 'NW6', start: 1061, end: 1080, count: 20 },
{ code: 'NW7', start: 1081, end: 1100, count: 20 },
];
const wagonNumber = (sequence: number) => `ER${String(sequence).padStart(4, '0')}`;
export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInterface {
name = 'SeedEdrWagonFleetErNumbering2260000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Full replacement: the ER range is the fleet of record, so any wagon
// outside it is stale seed data. Safe to hard-delete — containers and
// train_set_wagons null their link, wagon_movements cascade.
await queryRunner.query(`DELETE FROM freight.wagons;`);
// Deliberately does NOT create a unique index on wagon_number. It once did,
// to satisfy an ON CONFLICT clause that no longer exists (the DELETE above
// makes collisions impossible). Recreating the plain index here would undo
// WagonNumberPartialUnique2280000000000, which replaces it with a PARTIAL
// unique index so soft-deleted wagons stop reserving their number — this
// seeder is run directly by scripts/seed-edr-wagons.ts, which would
// otherwise resurrect the plain index on an already-migrated database.
for (const row of FLEET) {
if (row.end - row.start + 1 !== row.count) {
throw new Error(`wagon_range_mismatch:${row.code}`);
}
const [typeRecord] = await queryRunner.query(
`SELECT id FROM freight.wagon_types WHERE code = $1 AND deleted_at IS NULL LIMIT 1;`,
[row.code],
);
if (!typeRecord?.id) {
throw new Error(`wagon_type_missing:${row.code}`);
}
// generate_series builds the range server-side — one round trip per type
// instead of 1100 individual INSERTs. No ON CONFLICT clause: every wagon
// was deleted above, so a plain INSERT cannot collide, and the clause would
// otherwise hard-require a unique index this table lacks on some envs.
await queryRunner.query(
`
INSERT INTO freight.wagons (
wagon_number,
wagon_type_id,
status,
current_yard_id,
train_id,
sequence_number,
notes,
train_set_wagon_id,
current_train_schedule_id
)
SELECT
'ER' || LPAD(seq::text, 4, '0'),
$1::uuid,
'AVAILABLE',
NULL,
NULL,
NULL,
NULL,
NULL,
NULL
FROM generate_series($2::int, $3::int) AS seq;
`,
[typeRecord.id, row.start, row.end],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM freight.wagons WHERE wagon_number BETWEEN $1 AND $2;`,
[wagonNumber(FLEET[0].start), wagonNumber(FLEET[FLEET.length - 1].end)],
);
}
}

View File

@@ -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;
`);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-wagon EXPORT/IMPORT run numbers, editable from the wagon form.
*
* Nullable with no default: a wagon is not on a run until an operator says so.
* Mirrors the width of trains.export_train_number / trains.import_train_number
* (varchar 20) so the two stay comparable.
*/
export class AddWagonTrainNumbers2270000000000 implements MigrationInterface {
name = 'AddWagonTrainNumbers2270000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagons
ADD COLUMN IF NOT EXISTS export_train_number varchar(20),
ADD COLUMN IF NOT EXISTS import_train_number varchar(20);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagons
DROP COLUMN IF EXISTS export_train_number,
DROP COLUMN IF EXISTS import_train_number;
`);
}
}

View File

@@ -0,0 +1,206 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Assign EDR export/import run numbers to the wagon fleet.
*
* Runs AFTER SeedEdrWagonFleetErNumbering2260000000000, which recreates every
* wagon with NULL run numbers — so this must stay later in timestamp order.
*
* Source data below is the operator-supplied roster, kept verbatim rather than
* pre-resolved so its quirks stay visible:
* - ER0697 is listed twice under run 8101 (deduped here -> 49, not 50).
* - Four wagons are claimed by two runs each. A wagon holds a single run, so
* FIRST-LISTED WINS, which is why four runs land one short of their listed
* count:
* ER0484 8301 over 8401
* ER0451 8401 over 8701
* ER0887 8701 over 9001
* ER0936 8801 over 8901
*
* Wagons outside this roster (PW2 ER0001-0220 and ER0941-1100) keep NULL runs.
*/
/** Odd EXPORT run (Ethiopia -> Djibouti) -> the wagons rostered to it. */
const RUN_WAGONS: Record<string, string[]> = {
'8001': [
'ER0744', 'ER0734', 'ER0791', 'ER0885', 'ER0410', 'ER0901',
'ER0692', 'ER0784', 'ER0663', 'ER0547', 'ER0635', 'ER0840',
'ER0660', 'ER0541', 'ER0850', 'ER0764', 'ER0786', 'ER0694',
'ER0656', 'ER0432', 'ER0666', 'ER0879', 'ER0724', 'ER0868',
'ER0835', 'ER0650', 'ER0926', 'ER0915', 'ER0858', 'ER0826',
'ER0474', 'ER0539', 'ER0419', 'ER0695', 'ER0462', 'ER0825',
'ER0820', 'ER0790', 'ER0905', 'ER0557', 'ER0712', 'ER0782',
'ER0816', 'ER0447', 'ER0674', 'ER0424', 'ER0544', 'ER0519',
'ER0479', 'ER0440',
],
'8101': [
'ER0458', 'ER0600', 'ER0521', 'ER0559', 'ER0846', 'ER0459',
'ER0863', 'ER0925', 'ER0746', 'ER0821', 'ER0914', 'ER0768',
'ER0676', 'ER0470', 'ER0697', 'ER0697', 'ER0923', 'ER0937',
'ER0431', 'ER0412', 'ER0254', 'ER0555', 'ER0527', 'ER0590',
'ER0480', 'ER0723', 'ER0316', 'ER0800', 'ER0648', 'ER0435',
'ER0844', 'ER0939', 'ER0747', 'ER0654', 'ER0752', 'ER0633',
'ER0725', 'ER0567', 'ER0838', 'ER0920', 'ER0843', 'ER0520',
'ER0646', 'ER0407', 'ER0515', 'ER0760', 'ER0703', 'ER0880',
'ER0422', 'ER0852',
],
'8201': [
'ER0322', 'ER0314', 'ER0274', 'ER0514', 'ER0505', 'ER0618',
'ER0812', 'ER0776', 'ER0698', 'ER0662', 'ER0888', 'ER0625',
'ER0568', 'ER0596', 'ER0918', 'ER0524', 'ER0684', 'ER0231',
'ER0907', 'ER0445', 'ER0839', 'ER0430', 'ER0799', 'ER0464',
'ER0491', 'ER0833', 'ER0855', 'ER0571', 'ER0452', 'ER0733',
'ER0606', 'ER0822', 'ER0845', 'ER0771', 'ER0542', 'ER0588',
'ER0443', 'ER0585', 'ER0624', 'ER0538', 'ER0642', 'ER0928',
'ER0411', 'ER0794', 'ER0564', 'ER0906', 'ER0348', 'ER0236',
'ER0933', 'ER0456',
],
'8301': [
'ER0264', 'ER0691', 'ER0562', 'ER0686', 'ER0881', 'ER0780',
'ER0400', 'ER0420', 'ER0475', 'ER0425', 'ER0396', 'ER0818',
'ER0537', 'ER0917', 'ER0421', 'ER0766', 'ER0728', 'ER0485',
'ER0830', 'ER0804', 'ER0935', 'ER0898', 'ER0577', 'ER0762',
'ER0558', 'ER0612', 'ER0484', 'ER0566', 'ER0876', 'ER0528',
'ER0292', 'ER0630', 'ER0761', 'ER0849', 'ER0578', 'ER0232',
'ER0673', 'ER0870', 'ER0575', 'ER0250', 'ER0599', 'ER0622',
'ER0801', 'ER0806', 'ER0594', 'ER0831', 'ER0513',
],
'8401': [
'ER0616', 'ER0730', 'ER0415', 'ER0522', 'ER0454', 'ER0758',
'ER0715', 'ER0658', 'ER0602', 'ER0649', 'ER0540', 'ER0434',
'ER0678', 'ER0550', 'ER0402', 'ER0636', 'ER0500', 'ER0740',
'ER0664', 'ER0397', 'ER0565', 'ER0704', 'ER0720', 'ER0787',
'ER0884', 'ER0573', 'ER0755', 'ER0392', 'ER0739', 'ER0530',
'ER0437', 'ER0484', 'ER0653', 'ER0502', 'ER0615', 'ER0563',
'ER0641', 'ER0391', 'ER0789', 'ER0451', 'ER0819', 'ER0442',
'ER0798', 'ER0729', 'ER0772', 'ER0940', 'ER0682', 'ER0614',
'ER0561', 'ER0393',
],
'8501': [
'ER0807', 'ER0289', 'ER0587', 'ER0902', 'ER0877', 'ER0748',
'ER0837', 'ER0408', 'ER0307', 'ER0759', 'ER0847', 'ER0433',
'ER0498', 'ER0492', 'ER0735', 'ER0503', 'ER0461', 'ER0508',
'ER0243', 'ER0583', 'ER0924', 'ER0395', 'ER0707', 'ER0572',
'ER0536', 'ER0796', 'ER0929', 'ER0713', 'ER0603', 'ER0814',
'ER0756', 'ER0398', 'ER0853', 'ER0276', 'ER0405', 'ER0418',
'ER0517', 'ER0919', 'ER0781', 'ER0516', 'ER0417', 'ER0702',
'ER0857', 'ER0486', 'ER0637', 'ER0736', 'ER0859', 'ER0483',
'ER0824', 'ER0640', 'ER0714',
],
'8601': [
'ER0455', 'ER0930', 'ER0293', 'ER0294', 'ER0677', 'ER0808',
'ER0785', 'ER0628', 'ER0545', 'ER0551', 'ER0644', 'ER0922',
'ER0670', 'ER0864', 'ER0629', 'ER0306', 'ER0494', 'ER0496',
'ER0679', 'ER0874', 'ER0921', 'ER0910', 'ER0621', 'ER0667',
'ER0262', 'ER0774', 'ER0488', 'ER0300', 'ER0234', 'ER0711',
'ER0605', 'ER0897', 'ER0841', 'ER0778', 'ER0769', 'ER0487',
'ER0556', 'ER0526', 'ER0795', 'ER0268', 'ER0266', 'ER0257',
],
'8701': [
'ER0263', 'ER0661', 'ER0282', 'ER0394', 'ER0423', 'ER0665',
'ER0598', 'ER0909', 'ER0481', 'ER0854', 'ER0471', 'ER0582',
'ER0671', 'ER0466', 'ER0788', 'ER0934', 'ER0683', 'ER0680',
'ER0890', 'ER0531', 'ER0647', 'ER0823', 'ER0608', 'ER0900',
'ER0467', 'ER0607', 'ER0554', 'ER0233', 'ER0911', 'ER0726',
'ER0675', 'ER0291', 'ER0313', 'ER0619', 'ER0775', 'ER0705',
'ER0548', 'ER0891', 'ER0560', 'ER0904', 'ER0429', 'ER0655',
'ER0224', 'ER0700', 'ER0797', 'ER0706', 'ER0533', 'ER0861',
'ER0580', 'ER0449', 'ER0409', 'ER0613', 'ER0645', 'ER0315',
'ER0718', 'ER0553', 'ER0444', 'ER0593', 'ER0499', 'ER0693',
'ER0525', 'ER0451', 'ER0634', 'ER0689', 'ER0878', 'ER0518',
'ER0887',
],
'8801': [
'ER0811', 'ER0652', 'ER0889', 'ER0886', 'ER0936', 'ER0476',
'ER0832', 'ER0626', 'ER0669', 'ER0404', 'ER0546', 'ER0501',
'ER0894', 'ER0460', 'ER0805', 'ER0465', 'ER0717', 'ER0601',
'ER0751', 'ER0777', 'ER0504', 'ER0749', 'ER0827', 'ER0896',
'ER0903', 'ER0591', 'ER0436', 'ER0552', 'ER0716', 'ER0895',
'ER0463', 'ER0809', 'ER0473', 'ER0883', 'ER0569', 'ER0610',
'ER0275', 'ER0333', 'ER0344', 'ER0469',
],
'8901': [
'ER0913', 'ER0310', 'ER0873', 'ER0448', 'ER0763', 'ER0441',
'ER0936', 'ER0767', 'ER0416', 'ER0413', 'ER0589', 'ER0453',
'ER0507', 'ER0287', 'ER0414', 'ER0406', 'ER0584', 'ER0866',
'ER0893', 'ER0627', 'ER0227', 'ER0403', 'ER0428', 'ER0908',
'ER0349', 'ER0221', 'ER0271', 'ER0659', 'ER0765', 'ER0478',
'ER0511', 'ER0506', 'ER0743', 'ER0512', 'ER0916', 'ER0497',
'ER0643', 'ER0638', 'ER0468', 'ER0597',
],
'9001': [
'ER0446', 'ER0802', 'ER0570', 'ER0836', 'ER0576', 'ER0672',
'ER0631', 'ER0490', 'ER0851', 'ER0450', 'ER0872', 'ER0912',
'ER0815', 'ER0882', 'ER0738', 'ER0899', 'ER0620', 'ER0399',
'ER0685', 'ER0477', 'ER0842', 'ER0529', 'ER0617', 'ER0865',
'ER0754', 'ER0737', 'ER0753', 'ER0732', 'ER0623', 'ER0574',
'ER0803', 'ER0651', 'ER0489', 'ER0668', 'ER0741', 'ER0699',
'ER0592', 'ER0225', 'ER0229', 'ER0298', 'ER0270', 'ER0259',
'ER0337', 'ER0770', 'ER0327', 'ER0251', 'ER0285', 'ER0927',
'ER0810', 'ER0681', 'ER0887',
],
};
/**
* Even IMPORT run (Djibouti -> Ethiopia) for each export run. Listed rather
* than computed as export+1 so a run that ever breaks the convention stays
* correct. Run numbers are always 4 digits (8401, never 84001).
*/
const IMPORT_RUN: Record<string, string> = {
'8001': '8002',
'8101': '8102',
'8201': '8202',
'8301': '8302',
'8401': '8402',
'8501': '8502',
'8601': '8602',
'8701': '8702',
'8801': '8802',
'8901': '8902',
'9001': '9002',
};
export class SeedWagonRunNumbers2280000000000 implements MigrationInterface {
name = 'SeedWagonRunNumbers2280000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Idempotent: clear the roster's runs first so a re-run cannot leave a
// wagon on a run it was since moved off of.
await queryRunner.query(`
UPDATE freight.wagons
SET export_train_number = NULL, import_train_number = NULL
WHERE export_train_number IS NOT NULL;
`);
const claimed = new Set<string>();
for (const [exportRun, wagons] of Object.entries(RUN_WAGONS)) {
const importRun = IMPORT_RUN[exportRun];
if (!importRun) throw new Error(`import_run_missing:${exportRun}`);
// First-listed wins — skip any wagon an earlier run already claimed.
const fresh = wagons.filter((w) => !claimed.has(w));
fresh.forEach((w) => claimed.add(w));
if (!fresh.length) continue;
await queryRunner.query(
`
UPDATE freight.wagons
SET export_train_number = $1,
import_train_number = $2,
updated_at = now()
WHERE wagon_number = ANY($3::text[]);
`,
[exportRun, importRun, fresh],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.wagons
SET export_train_number = NULL, import_train_number = NULL
WHERE export_train_number IS NOT NULL;
`);
}
}

View File

@@ -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.
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* container_types.wagons_per_unit is no longer stored: the wagon fraction is
* derived from size_ft everywhere (40ft = 1.00 wagon, 20ft = 0.50 — two per
* wagon; see rule-engine/container-type.util.ts). The stored value duplicated
* that rule and could silently drift from it.
*/
export class DropContainerWagonsPerUnit2290000000000 implements MigrationInterface {
name = 'DropContainerWagonsPerUnit2290000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagons_per_unit;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.container_types
ADD COLUMN IF NOT EXISTS wagons_per_unit numeric(4,2);
`);
// Backfill from the same size rule the code now derives from.
await queryRunner.query(`
UPDATE freight.container_types
SET wagons_per_unit = CASE WHEN size_ft >= 40 THEN 1.00 ELSE 0.50 END;
`);
}
}

View File

@@ -0,0 +1,68 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Stand the whole wagon fleet in Doraleh.
*
* Runs AFTER SeedEdrWagonFleetErNumbering2260000000000, which recreates every
* wagon with a NULL yard — so this must stay later in timestamp order.
*
* A wagon with no yard cannot be coupled to a train (the train builder only
* offers AVAILABLE wagons standing in the train's own yard), which left the
* seeded fleet unusable. Doraleh is the Djibouti-side port yard the import runs
* originate from.
*
* The yard is created when absent: environments disagree about which yards
* exist, so this cannot assume one is there.
*/
const YARD_CODE = 'DORALEH';
export class SeedWagonYardDoraleh2290000000000 implements MigrationInterface {
name = 'SeedWagonYardDoraleh2290000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Ensure the yard exists and is usable. Deliberately does NOT overwrite an
// existing label/country — a deployment that already calls this yard
// something else keeps its own naming.
await queryRunner.query(
`
INSERT INTO freight.yards (code, label, country, is_active, display_order)
VALUES ($1, 'Doraleh', 'Djibouti', true, 12)
ON CONFLICT (code) DO UPDATE SET
is_active = true,
deleted_at = NULL,
updated_at = now();
`,
[YARD_CODE],
);
const [yard] = await queryRunner.query(
`SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL LIMIT 1;`,
[YARD_CODE],
);
if (!yard?.id) {
throw new Error(`yard_missing:${YARD_CODE}`);
}
// Whole fleet — a wagon already coupled to a built train follows the train,
// so leave those where they stand.
await queryRunner.query(
`
UPDATE freight.wagons
SET current_yard_id = $1::uuid,
updated_at = now()
WHERE train_id IS NULL;
`,
[yard.id],
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Back to the state SeedEdrWagonFleetErNumbering leaves them in.
await queryRunner.query(`
UPDATE freight.wagons
SET current_yard_id = NULL
WHERE train_id IS NULL;
`);
}
}

View File

@@ -0,0 +1,85 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Intercity (DOMESTIC) cargo is loaded at its origin yard and unloaded at its
* destination yard, but only some yards have the equipment to do it. EDR's
* load/unload facilities are Indode, Sebeta, Modjo, Adama, Dire Dawa and Negad —
* and the set grows, so it must be data, not a constant.
*
* `yards.has_facility` marks a yard as a load/unload point; `yard_facilities`
* holds what that facility can do. Only a facility with `has_warehouse` (Indode
* today) stores cargo, and therefore accrues storage/demurrage — the rest just
* move it on and off the train.
*
* `facility_handling_events` records each load/unload and carries its GRN.
* warehouse_inventory can't do that job: its warehouse/yard/zone are NOT NULL, so
* a facility with no warehouse could never have a row. `inventory_id` links to the
* storage record when the facility does have a warehouse.
*/
export class YardFacilities2290000000000 implements MigrationInterface {
name = 'YardFacilities2290000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.yards
ADD COLUMN IF NOT EXISTS has_facility boolean NOT NULL DEFAULT false
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.yard_facilities (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
yard_id uuid NOT NULL REFERENCES freight.yards(id) ON DELETE CASCADE,
has_warehouse boolean NOT NULL DEFAULT false,
equipment_notes text NULL,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL
)
`);
// One facility record per yard.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yard_facility_yard"
ON freight.yard_facilities (yard_id) WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.facility_handling_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL REFERENCES freight.bookings(id),
yard_id uuid NOT NULL REFERENCES freight.yards(id),
train_schedule_id uuid NULL REFERENCES freight.train_schedules(id),
event_type varchar(10) NOT NULL,
grn_number varchar(60) NULL,
quantity numeric(14, 3) NULL,
weight_tons numeric(14, 3) NULL,
inventory_id uuid NULL REFERENCES freight.warehouse_inventory(id),
performed_by varchar(120) NULL,
occurred_at timestamptz NOT NULL DEFAULT now(),
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_facility_handling_events_booking"
ON freight.facility_handling_events (booking_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_yard"
ON freight.facility_handling_events (yard_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_grn"
ON freight.facility_handling_events (grn_number) WHERE grn_number IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.facility_handling_events`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_facilities`);
await queryRunner.query(`
ALTER TABLE freight.yards DROP COLUMN IF EXISTS has_facility
`);
}
}

View File

@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Approval workflow for edits to LIVE rates. A LIVE rate is what pricing
* charges, so it is never edited in place: the edit is filed here as PENDING
* and the live row keeps its value until an approver applies it.
*
* `payload` holds the changed fields only; `previous_values` snapshots what
* they were at submit time so the approver sees a real before→after diff.
*/
export class CreateRateChangeRequests2300000000000 implements MigrationInterface {
name = 'CreateRateChangeRequests2300000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.rate_change_requests (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
rate_id uuid NOT NULL REFERENCES freight.rates (id),
payload jsonb NOT NULL,
previous_values jsonb NOT 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_rcr_status
ON freight.rate_change_requests (status)
`);
// At most one pending edit per rate — two racing requests would both pass
// validation and the second would silently overwrite the first on approval.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_rcr_one_pending_per_rate
ON freight.rate_change_requests (rate_id)
WHERE status = 'PENDING' AND deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.rate_change_requests`);
}
}

View File

@@ -0,0 +1,78 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Customer-support chat. A `support_conversations` row is the single ongoing
* thread with a company; `support_messages` are its text messages. There is no
* lifecycle column — a thread is opened by whichever side speaks first and
* stays open. Enum-like columns are varchar (no PG enum churn).
*
* The unique index on `company_id` is load-bearing, not just an optimization:
* the get-or-create path depends on it to settle concurrent first-messages.
* It is partial on `deleted_at IS NULL` so a soft-deleted thread doesn't block
* a fresh one.
*/
export class CreateSupportChat2310000000000 implements MigrationInterface {
name = "CreateSupportChat2310000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.support_conversations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
company_id uuid NOT NULL,
company_name varchar(200),
created_by_user_id uuid,
last_message_at timestamptz,
last_message_preview varchar(280),
last_message_author_role varchar(12),
customer_last_read_at timestamptz,
agent_last_read_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_SUPPORT_CONV_COMPANY"
ON freight.support_conversations (company_id)
WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_SUPPORT_CONV_LASTMSG"
ON freight.support_conversations (last_message_at)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.support_messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id uuid NOT NULL,
author_user_id uuid NOT NULL,
author_role varchar(12) NOT NULL,
author_name varchar(200),
body text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_SUPPORT_MSG_CONV_CREATED"
ON freight.support_messages (conversation_id, created_at)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight."IDX_SUPPORT_MSG_CONV_CREATED"`,
);
await queryRunner.query(`DROP TABLE IF EXISTS freight.support_messages`);
await queryRunner.query(
`DROP INDEX IF EXISTS freight."IDX_SUPPORT_CONV_LASTMSG"`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS freight."IDX_SUPPORT_CONV_COMPANY"`,
);
await queryRunner.query(
`DROP TABLE IF EXISTS freight.support_conversations`,
);
}
}

View File

@@ -0,0 +1,140 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Scope base rail freight to a route (origin yard → destination yard).
*
* Until now a base-freight rate was keyed by direction + container/bulk scope
* only, so "container import" cost the same whether the box was railed to Dire
* Dawa or to Mojo. Rates now carry the yard pair the price is quoted for, which
* is what the business actually sells: `container import, Djibouti → Dire Dawa,
* 500 USD`.
*
* Existing base-freight rates predate the yard pair and cannot be backfilled —
* there is no way to know which route each was meant for. They are retired
* (SUPERSEDED + soft-deleted) rather than deleted, because booking_rate_snapshot
* and rate_change_requests hold FKs to them (RESTRICT) and those rows are price
* history. Retiring drops them out of pricing and the admin UI just the same;
* the yard-scoped replacements must be re-entered.
*
* Surcharges, first-mile and last-mile rates are untouched: they are not
* route-scoped and keep NULL yards.
*/
export class AddRateYardScope2320000000000 implements MigrationInterface {
name = 'AddRateYardScope2320000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// ── 1. Yard columns + FKs ──────────────────────────────────────────────
await queryRunner.query(`
ALTER TABLE freight.rates
ADD COLUMN IF NOT EXISTS origin_yard_id uuid NULL,
ADD COLUMN IF NOT EXISTS destination_yard_id uuid NULL;
`);
await queryRunner.query(`
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_rates_origin_yard_id') THEN
ALTER TABLE freight.rates
ADD CONSTRAINT "FK_rates_origin_yard_id"
FOREIGN KEY (origin_yard_id) REFERENCES freight.yards(id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_rates_destination_yard_id') THEN
ALTER TABLE freight.rates
ADD CONSTRAINT "FK_rates_destination_yard_id"
FOREIGN KEY (destination_yard_id) REFERENCES freight.yards(id);
END IF;
END $$;
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_rates_origin_yard_id" ON freight.rates (origin_yard_id);`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_rates_destination_yard_id" ON freight.rates (destination_yard_id);`,
);
// ── 2. Retire route-less base freight ──────────────────────────────────
// Soft-delete, not DELETE: booking_rate_snapshot.rate_id is ON DELETE
// RESTRICT and those snapshots are what past bookings were charged.
await queryRunner.query(`
UPDATE freight.rates
SET status = 'SUPERSEDED',
deleted_at = now(),
updated_at = now()
WHERE deleted_at IS NULL
AND "trigger" = 'ALWAYS'
AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY');
`);
// ── 3. Route is part of a rate's identity ──────────────────────────────
// Two rates may now share rateType + scope + unit as long as they price
// different legs, so the yard pair joins the uniqueness tuple.
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern"
ON freight.rates (
rate_type,
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(trade_direction, ''),
COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
rate_unit
)
WHERE deleted_at IS NULL AND status <> 'SUPERSEDED';
`);
// ── 4. Base freight must carry a route; nothing else may ───────────────
// Retired rows are exempt — they are the route-less rates step 2 just
// superseded, and they must stay readable for snapshot history.
await queryRunner.query(`
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'CK_rates_yard_scope') THEN
ALTER TABLE freight.rates
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL
OR status = 'SUPERSEDED'
OR CASE
WHEN "trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
);
END IF;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// The retired rates are not un-superseded: which route each belonged to was
// never recorded, so reviving them would restore rates that price the wrong
// legs. Down only reverses the schema.
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
);
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern"
ON freight.rates (
rate_type,
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(trade_direction, ''),
rate_unit
)
WHERE deleted_at IS NULL AND status <> 'SUPERSEDED';
`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_destination_yard_id";`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_origin_yard_id";`);
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "FK_rates_destination_yard_id";`,
);
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "FK_rates_origin_yard_id";`,
);
await queryRunner.query(`
ALTER TABLE freight.rates
DROP COLUMN IF EXISTS destination_yard_id,
DROP COLUMN IF EXISTS origin_yard_id;
`);
}
}

View File

@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add a global "booking close offset" — how long BEFORE departure a schedule's
* booking window shuts — configurable separately for import and export.
*
* When an offset is set, the window's close instant is `departure offset`
* (e.g. departure 17:00 with a 3-hour import offset closes at 14:00; departure
* Jul-10 16:00 with a 1-day export offset closes Jul-9 16:00). It caps the whole
* booking lifecycle: the first window close, every reopen cycle, and the export
* FCFS close all land at/at-or-before this cutoff instead of at departure.
*
* NULL / 0 preserves the previous behaviour exactly (import closes at
* open+duration clamped to departure; export closes at departure), so existing
* installs are unaffected until an offset is entered.
*
* `*_close_offset_minutes` on the global-rules singleton is the live config; the
* matching `rule_*_close_offset_minutes` snapshot on each schedule freezes it at
* creation so the batch board keeps drawing the window the customer was shown
* even after a later global-rules edit. Both are nullable with no backfill —
* absent means "no offset", the safe default.
*/
export class AddBookingCloseOffset2330000000000 implements MigrationInterface {
name = "AddBookingCloseOffset2330000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
ADD COLUMN IF NOT EXISTS import_close_offset_minutes integer,
ADD COLUMN IF NOT EXISTS export_close_offset_minutes integer;
`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS rule_import_close_offset_minutes integer,
ADD COLUMN IF NOT EXISTS rule_export_close_offset_minutes integer;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS rule_import_close_offset_minutes,
DROP COLUMN IF EXISTS rule_export_close_offset_minutes;
`);
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
DROP COLUMN IF EXISTS import_close_offset_minutes,
DROP COLUMN IF EXISTS export_close_offset_minutes;
`);
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add `has_lashing` to cargo types.
*
* When true, every booking of that cargo type incurs the flat LASHING
* surcharge (a rate with trigger = 'LASHING'). Defaults to false so existing
* cargo ships without the fee until the flag is turned on.
*/
export class AddCargoTypeHasLashing2340000000000 implements MigrationInterface {
name = "AddCargoTypeHasLashing2340000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.cargo_types
ADD COLUMN IF NOT EXISTS has_lashing boolean NOT NULL DEFAULT false;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.cargo_types
DROP COLUMN IF EXISTS has_lashing;
`);
}
}

View File

@@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add an opt-in "reverse wagon order" flag to a train schedule.
*
* When true, the built wagon plan is flipped at build time so the physically-last
* wagon sits at position 1. Only the order (sequence_no) changes — composition and
* booking allocations travel with their slot. The flag is frozen on the schedule
* at creation and re-applied every time the wagon plan is rebuilt, so the stored
* train order and the schedule order always match.
*
* Defaults to false; existing schedules keep their as-built order.
*/
export class AddReverseWagonOrder2340000000000 implements MigrationInterface {
name = "AddReverseWagonOrder2340000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS reverse_wagon_order boolean NOT NULL DEFAULT false;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS reverse_wagon_order;
`);
}
}

View File

@@ -0,0 +1,63 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
/**
* Refresh the "pricing" article of each seeded contract template so it points
* at the live Rate Schedule instead of hardcoded price figures (USD 400/wagon,
* USD 919/40ft, …). The original CreateContractTemplates migration seeded the
* old prose with ON CONFLICT DO NOTHING, so those figures are frozen in the DB
* rows and would otherwise contradict the rate-config-driven schedule table now
* rendered under the pricing article.
*
* Only the article whose id = 'pricing' is touched, and only when its body
* still matches the originally-seeded prose — so any admin edit to the pricing
* article is left untouched. Idempotent: re-running is a no-op once refreshed.
*/
export class RefreshContractPricingArticles2350000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
for (const seed of CONTRACT_TEMPLATE_DEFAULTS) {
const pricing = seed.articles.find((article) => article.id === 'pricing');
if (!pricing) continue;
// jsonb_set the title + body of the element whose id = 'pricing', matched
// by array index. Guarded so admin-edited bodies are never overwritten.
await queryRunner.query(
`
UPDATE freight.contract_templates ct
SET articles = (
SELECT jsonb_agg(
CASE
WHEN elem->>'id' = 'pricing'
THEN elem || jsonb_build_object('title', $2::text, 'body', $3::text)
ELSE elem
END
)
FROM jsonb_array_elements(ct.articles) elem
)
WHERE ct.code = $1
AND EXISTS (
SELECT 1 FROM jsonb_array_elements(ct.articles) e
WHERE e->>'id' = 'pricing'
AND e->>'body' LIKE ANY (ARRAY[
'%USD 59.4 per metric ton%',
'%USD 696 (six hundred ninety-six) per wagon%',
'%USD 400 (four hundred) per wagon%',
'%From SGTD to Dire Dawa dry port, the rate is USD 919%',
'%Railway transportation charges from GMP to SGTD: USD 819%',
'%prevailing EDR domestic container tariff, as set out in the commercial schedule%'
])
);
`,
[seed.code, pricing.title, pricing.body],
);
}
}
public async down(): Promise<void> {
// No-op: the refreshed pricing prose is the correct forward state; reverting
// to hardcoded figures would reintroduce the rate-schedule contradiction.
}
}

View File

@@ -0,0 +1,31 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '@edr/api-common';
import { AiBookingRequestDto } from './dto/ai-booking-request.dto';
import { AiBookingResult } from './types/ai-booking-result.type';
import { MockAiService } from './mock-ai.service';
// @Public() — TODO: swap for real guard when this leaves dev/testing.
// Safe while public: extracts + validates text only, never creates or
// dispatches anything.
@Public()
@ApiTags('AI Assistant (mock)')
@Controller('ai')
export class AiController {
constructor(private readonly mockAiService: MockAiService) {}
@Post('booking/extract')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary:
'Mock AI: extract structured booking fields from free-text request',
})
@ApiOkResponse({
description:
'Extracted fields, validation result, and next-step recommendation',
})
extractBooking(@Body() dto: AiBookingRequestDto): AiBookingResult {
return this.mockAiService.extractBooking(dto.text);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AiController } from './ai.controller';
import { MockAiService } from './mock-ai.service';
@Module({
controllers: [AiController],
providers: [MockAiService],
exports: [MockAiService],
})
export class AiModule {}

View File

@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class AiBookingRequestDto {
@ApiProperty({
description: 'Free-text customer booking request to extract fields from',
example:
'Book 2x40ft containers from Djibouti to Indode. Cargo electronics. Customer ABC Logistics.',
minLength: 5,
})
@IsString()
@IsNotEmpty({ message: 'text must not be empty' })
@MinLength(5, { message: 'text must be at least 5 characters' })
text!: string;
}

View File

@@ -0,0 +1,277 @@
import { Injectable } from '@nestjs/common';
import {
AiBookingResult,
AiContainerType,
AiDirection,
AiExtractedBooking,
AiRecommendation,
AiValidationResult,
} from './types/ai-booking-result.type';
/**
* Deterministic keyword/regex "AI" for the booking assistant workflow.
* No external AI calls — this class is the single seam to swap for a real
* provider later (OllamaAiService / ClaudeAiService / OpenAiService): keep
* the `extractBooking(text): AiBookingResult` contract and replace the body.
*/
const KNOWN_LOCATIONS = [
'Djibouti',
'Indode',
'Modjo',
'Adama',
'Dire Dawa',
'Addis Ababa',
] as const;
const INLAND_LOCATIONS = new Set<string>([
'Indode',
'Modjo',
'Adama',
'Dire Dawa',
'Addis Ababa',
]);
// Longest names first so "Dire Dawa" wins before a shorter partial could.
const LOCATION_ALTERNATION = [...KNOWN_LOCATIONS]
.sort((a, b) => b.length - a.length)
.map((name) => name.replace(/\s+/g, '\\s+'))
.join('|');
// Checked in order; first hit wins, so specific cargo words beat the
// generic "refrigerated" fallback.
const CARGO_KEYWORDS: ReadonlyArray<readonly [RegExp, string]> = [
[/\belectronics\b/i, 'electronics'],
[/\bcoffee\b/i, 'coffee'],
[/\bwheat\b/i, 'wheat'],
[/\bfertilizers?\b/i, 'fertilizer'],
[/\bchemicals?\b/i, 'chemical'],
[/\bmachinery\b/i, 'machinery'],
[/\bmedicines?\b/i, 'medicine'],
[/\bsesame\b/i, 'sesame'],
[/\b(?:vehicles?|cars?)\b/i, 'vehicles'],
[/\brefrigerated\b/i, 'refrigerated cargo'],
];
const WORD_NUMBERS: Record<string, number> = {
one: 1,
two: 2,
three: 3,
four: 4,
five: 5,
six: 6,
seven: 7,
eight: 8,
nine: 9,
ten: 10,
};
// A capitalized-word run: "ABC Logistics", "Auto Import PLC", "Ethio Coffee
// Export". Stops at the first lowercase word ("wants", "needs", …).
const NAME_CAPTURE = String.raw`([A-Z][A-Za-z0-9&.'-]*(?:\s+[A-Z][A-Za-z0-9&.'-]*)*)`;
// No `i` flag: the capture relies on case ([A-Z] word starts) to know where
// the company name ends ("Customer ABC Logistics wants…" → "ABC Logistics").
const CUSTOMER_PATTERNS: ReadonlyArray<RegExp> = [
new RegExp(String.raw`\b[Cc]ustomer(?:\s+is)?\s*:?\s+${NAME_CAPTURE}`),
new RegExp(String.raw`\b[Ff]or\s+${NAME_CAPTURE}`),
];
const RECOMMEND_CREATE: AiRecommendation = {
action: 'CREATE_DRAFT_BOOKING',
message:
'Booking data looks complete. User can review and create a draft booking.',
confidence: 0.85,
};
const RECOMMEND_MISSING: AiRecommendation = {
action: 'REQUEST_MISSING_INFORMATION',
message:
'Some required booking information is missing. Ask the customer for the missing fields before creating a draft booking.',
confidence: 0.45,
};
@Injectable()
export class MockAiService {
extractBooking(text: string): AiBookingResult {
const input = text.trim();
const { origin, destination } = this.extractRoute(input);
const extracted: AiExtractedBooking = {
customerName: this.extractCustomerName(input),
origin,
destination,
cargoType: this.extractCargoType(input),
containerType: this.extractContainerType(input),
quantity: this.extractQuantity(input),
direction: this.resolveDirection(origin, destination),
weightKg: this.extractWeightKg(input),
pickupRequired: this.extractFlag(input, 'pickup'),
deliveryRequired: this.extractFlag(input, 'delivery'),
};
const validation = this.validate(extracted);
return {
provider: 'mock',
extracted,
validation,
recommendation: validation.valid ? RECOMMEND_CREATE : RECOMMEND_MISSING,
};
}
private extractCustomerName(text: string): string | null {
for (const pattern of CUSTOMER_PATTERNS) {
const match = text.match(pattern);
if (match?.[1]) {
const name = match[1].replace(/[.,;:!?]+$/, '').trim();
if (name) return name;
}
}
return null;
}
private extractRoute(text: string): {
origin: string | null;
destination: string | null;
} {
const fromMatch = text.match(
new RegExp(String.raw`\bfrom\s+(${LOCATION_ALTERNATION})\b`, 'i'),
);
const toMatch = text.match(
new RegExp(String.raw`\bto\s+(${LOCATION_ALTERNATION})\b`, 'i'),
);
let origin = fromMatch ? this.canonicalLocation(fromMatch[1]) : null;
let destination = toMatch ? this.canonicalLocation(toMatch[1]) : null;
if (!origin || !destination) {
// Fall back to order of appearance ("Djibouti to Indode" without
// "from", or a bare location mention).
const mentions: string[] = [];
const all = text.matchAll(
new RegExp(String.raw`\b(${LOCATION_ALTERNATION})\b`, 'gi'),
);
for (const m of all) {
const canonical = this.canonicalLocation(m[1]);
if (canonical && !mentions.includes(canonical)) mentions.push(canonical);
}
if (!origin && !destination) {
origin = mentions[0] ?? null;
destination = mentions[1] ?? null;
} else if (!origin) {
origin = mentions.find((loc) => loc !== destination) ?? null;
} else {
destination = mentions.find((loc) => loc !== origin) ?? null;
}
}
return { origin, destination };
}
private canonicalLocation(raw: string): string | null {
const normalized = raw.replace(/\s+/g, ' ').toLowerCase();
return (
KNOWN_LOCATIONS.find((loc) => loc.toLowerCase() === normalized) ?? null
);
}
private resolveDirection(
origin: string | null,
destination: string | null,
): AiDirection | null {
if (!origin || !destination) return null;
if (origin === 'Djibouti' && INLAND_LOCATIONS.has(destination)) {
return 'IMPORT';
}
if (INLAND_LOCATIONS.has(origin) && destination === 'Djibouti') {
return 'EXPORT';
}
return null;
}
private extractCargoType(text: string): string | null {
for (const [pattern, cargo] of CARGO_KEYWORDS) {
if (pattern.test(text)) return cargo;
}
return null;
}
private extractContainerType(text: string): AiContainerType | null {
// Lookbehind instead of \b: "2x40ft" has no word boundary before "40",
// but "140ft" must not read as a 40ft container.
if (/(?<!\d)40[\s-]?(?:ft|foot)\b/i.test(text)) return '40FT';
if (/(?<!\d)20[\s-]?(?:ft|foot)\b/i.test(text)) return '20FT';
if (/\bbulk\b/i.test(text)) return 'BULK';
if (/\b(?:vehicles?|cars?)\b/i.test(text)) return 'RO_RO';
return null;
}
private extractQuantity(text: string): number | null {
// "2x40ft", "2 x 40ft", "3x20ft", "1x20ft"
let match = text.match(/(\d+)\s*x\s*\d+\s*-?\s*(?:ft|foot)\b/i);
if (match) return parseInt(match[1], 10);
// "one 40ft container", "two containers"
match = text.match(
new RegExp(
String.raw`\b(${Object.keys(WORD_NUMBERS).join('|')})\s+(?:\d+\s*-?\s*(?:ft|foot)\s+)?containers?\b`,
'i',
),
);
if (match) return WORD_NUMBERS[match[1].toLowerCase()];
// "3 containers", "2 refrigerated containers"
match = text.match(/(\d+)\s+(?:[a-z]+\s+)?containers?\b/i);
if (match) return parseInt(match[1], 10);
// "5 vehicles", "3 cars"
match = text.match(/(\d+)\s+(?:vehicles?|cars?)\b/i);
if (match) return parseInt(match[1], 10);
return null;
}
private extractWeightKg(text: string): number | null {
const tons = text.match(/([\d,]+(?:\.\d+)?)\s*(?:tons?|tonnes?)\b/i);
if (tons) return Math.round(this.parseNumber(tons[1]) * 1000);
const kg = text.match(/([\d,]+(?:\.\d+)?)\s*kgs?\b/i);
if (kg) return Math.round(this.parseNumber(kg[1]));
return null;
}
private parseNumber(raw: string): number {
return parseFloat(raw.replace(/,/g, ''));
}
private extractFlag(
text: string,
kind: 'pickup' | 'delivery',
): boolean | null {
// "no pickup required" must read as false, so the negative wins.
if (new RegExp(String.raw`\bno\s+${kind}\b`, 'i').test(text)) return false;
if (new RegExp(String.raw`\b${kind}\s+required\b`, 'i').test(text)) {
return true;
}
return null;
}
private validate(extracted: AiExtractedBooking): AiValidationResult {
const errors: string[] = [];
if (!extracted.customerName) errors.push('Customer name is missing');
if (!extracted.origin) errors.push('Origin is missing');
if (!extracted.destination) errors.push('Destination is missing');
if (!extracted.cargoType) errors.push('Cargo type is missing');
if (!extracted.containerType) errors.push('Container type is missing');
if (extracted.quantity === null) errors.push('Quantity is missing');
if (!extracted.direction) errors.push('Direction is missing');
return { valid: errors.length === 0, errors };
}
}

View File

@@ -0,0 +1,47 @@
export const AI_CONTAINER_TYPES = ['20FT', '40FT', 'BULK', 'RO_RO'] as const;
export type AiContainerType = (typeof AI_CONTAINER_TYPES)[number];
export const AI_DIRECTIONS = ['IMPORT', 'EXPORT'] as const;
export type AiDirection = (typeof AI_DIRECTIONS)[number];
export const AI_RECOMMENDATION_ACTIONS = [
'CREATE_DRAFT_BOOKING',
'REQUEST_MISSING_INFORMATION',
] as const;
export type AiRecommendationAction = (typeof AI_RECOMMENDATION_ACTIONS)[number];
export interface AiExtractedBooking {
customerName: string | null;
origin: string | null;
destination: string | null;
cargoType: string | null;
containerType: AiContainerType | null;
quantity: number | null;
direction: AiDirection | null;
weightKg: number | null;
pickupRequired: boolean | null;
deliveryRequired: boolean | null;
}
export interface AiValidationResult {
valid: boolean;
errors: string[];
}
export interface AiRecommendation {
action: AiRecommendationAction;
message: string;
confidence: number;
}
/**
* Payload returned by the extract endpoint. The global
* ResponseTransformInterceptor wraps it as
* `{ success: true, data: AiBookingResult, timestamp }` on the wire.
*/
export interface AiBookingResult {
provider: 'mock';
extracted: AiExtractedBooking;
validation: AiValidationResult;
recommendation: AiRecommendation;
}

View File

@@ -0,0 +1,62 @@
import { Body, Controller, Patch, Post, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { AccountService } from "./account.service";
import {
SendContactOtpDto,
UpdateAccountNameDto,
UpdateContactDto,
} from "./dto/account.dto";
/**
* The caller's own account record. Everything here is scoped to the JWT's user
* id — there is no `:id` parameter to tamper with, so these routes need no
* permission key beyond being authenticated.
*/
@ApiTags("auth")
@Controller("me")
@ApiBearerAuth()
@UseGuards(JwtGuard)
export class AccountController {
constructor(private readonly accountService: AccountService) {}
@Post("contact/otp")
@ApiOperation({
summary: "Send a verification code to a new email/phone before changing it",
description:
"The code goes to the NEW value supplied here, proving the caller controls " +
"it. Returns the target masked — an unverified caller never gets it back in full.",
})
sendContactOtp(
@CurrentUser() user: TCurrentUser,
@Body() dto: SendContactOtpDto,
): Promise<{ sentTo: string }> {
return this.accountService.sendContactOtp(user.id, dto);
}
@Patch("contact")
@ApiOperation({
summary: "Change the account's email or phone, gated by a verification code",
description:
"Verifies the code and writes the new value in one call, so the API never " +
"has to take a client's word that verification happened.",
})
updateContact(
@CurrentUser() user: TCurrentUser,
@Body() dto: UpdateContactDto,
): Promise<{ success: true; value: string }> {
return this.accountService.updateContact(user.id, dto);
}
@Patch("name")
@ApiOperation({ summary: "Change the account's display name" })
updateName(
@CurrentUser() user: TCurrentUser,
@Body() dto: UpdateAccountNameDto,
): Promise<{ success: true }> {
return this.accountService.updateName(user.id, dto);
}
}

View File

@@ -0,0 +1,226 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
} from "@nestjs/common";
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
import { DataSource, EntityManager, Repository } from "typeorm";
import { isValidPhoneNumber } from "libphonenumber-js";
import { EUserVerifiedBy } from "@tria-plc/api-common/utils/enums/user.enum";
import type { TCurrentTokenUser } from "@tria-plc/iamapi-common/types/current-user.type";
import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity";
import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
import { OtpService, OtpTarget } from "../otp/otp.service";
import {
ContactChannel,
SendContactOtpDto,
UpdateAccountNameDto,
UpdateContactDto,
} from "./dto/account.dto";
import { maskOtpTarget } from "./mask-target.util";
/** How long a contact-change code stays valid before it must be re-requested. */
const CONTACT_OTP_TTL_MS = 10 * 60 * 1000;
/** Postgres unique-violation SQLSTATE. */
const PG_UNIQUE_VIOLATION = "23505";
/**
* Self-serve management of the caller's own IAM user record.
*
* IAM ships `PATCH /api/auth/update-profile`, but it takes email + username +
* phone + name all at once (every field `@IsNotEmpty`) and performs no
* verification — it will move an account's phone to any number the caller
* types. These routes exist so a contact change is *proven*: the code goes to
* the NEW address and the write only lands once it comes back.
*/
@Injectable()
export class AccountService {
private readonly logger = new Logger(AccountService.name);
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly otpService: OtpService,
) {}
/**
* Send a code to the address the caller wants to move TO. Sending to the new
* value (rather than the one on file) is the whole point — it proves control
* of the destination before anything is written.
*/
async sendContactOtp(
userId: string,
dto: SendContactOtpDto,
): Promise<{ sentTo: string }> {
const value = this.normalize(dto.channel, dto.value);
await this.assertNotTaken(dto.channel, value, userId);
const target = this.targetFor(dto.channel, value);
await this.otpService.sendOtp(target);
return { sentTo: maskOtpTarget(target) };
}
/**
* Verify the code, then write the new contact value. The verify and the write
* are one call: the API never has to trust that a client "already verified"
* — unlike the signup flow, where the OTP is client-orchestrated and
* `POST /api/otp/verify` is a separate public route the client may simply skip.
*/
async updateContact(
userId: string,
dto: UpdateContactDto,
): Promise<{ success: true; value: string }> {
const value = this.normalize(dto.channel, dto.value);
await this.assertNotTaken(dto.channel, value, userId);
await this.otpService.verifyOtpForAction(
this.targetFor(dto.channel, value),
dto.otp,
CONTACT_OTP_TTL_MS,
);
const isEmail = dto.channel === ContactChannel.Email;
const userPatch = isEmail
? { email: value }
: {
phoneNumber: value,
// The number just passed an OTP, which is exactly what IAM's own
// phone-verification flag means. Set it here so the freight app stops
// needing its own parallel "verified phone" bookkeeping.
isPhoneNumberVerified: true,
verifiedBy: EUserVerifiedBy.PHONE_NUMBER,
};
const sessionPatch: Partial<TCurrentTokenUser> = isEmail
? { email: value }
: { phoneNumber: value, isPhoneNumberVerified: true };
try {
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(User).update({ id: userId }, userPatch);
await this.refreshSessions(manager, userId, sessionPatch);
});
} catch (error) {
throw this.asConflict(error, dto.channel);
}
this.logger.log(`Account ${dto.channel} updated for user ${userId}`);
return { success: true, value };
}
/** Rename the account. No OTP — a name change proves nothing and grants nothing. */
async updateName(
userId: string,
dto: UpdateAccountNameDto,
): Promise<{ success: true }> {
const en = dto.name.en?.trim();
const name = { am: dto.name.am.trim(), ...(en ? { en } : {}) };
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(User).update({ id: userId }, { name });
// IAM mirrors the name onto the employee row. Portal customers are
// `individual` users with no employee row at all, so this is a no-op for
// them — hence an unconditional update() rather than a lookup-then-write.
await manager.getRepository(Employee).update({ userId }, { name });
await this.refreshSessions(manager, userId, { name });
});
return { success: true };
}
/**
* `GET /api/auth/me` serves `session.userInfo` — a snapshot IAM writes only
* when a session is created at login. Without patching it here, a saved change
* stays invisible to /me (and to anything reading the token's claims) until the
* user logs out and back in, which reads as "my edit didn't save".
*/
private async refreshSessions(
manager: EntityManager,
userId: string,
patch: Partial<TCurrentTokenUser>,
): Promise<void> {
const repo = manager.getRepository(Session);
const sessions = await repo.find({ where: { userId } });
await Promise.all(
sessions.map((session) =>
repo.update(
{ id: session.id },
{ userInfo: { ...session.userInfo, ...patch } },
),
),
);
}
/** Canonicalise for the channel and reject anything malformed up front. */
private normalize(channel: ContactChannel, value: string): string {
const raw = value.trim();
if (channel === ContactChannel.Email) {
const email = raw.toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw new BadRequestException("A valid email address is required");
}
return email;
}
if (!isValidPhoneNumber(raw)) {
throw new BadRequestException(
"A valid international phone number is required (E.164, e.g. +251911223344)",
);
}
// Store the same canonical form the OTP is keyed by, so the code sent here
// is findable on verify regardless of how the number was typed.
return normalizeE164(raw) as string;
}
private targetFor(channel: ContactChannel, value: string): OtpTarget {
return channel === ContactChannel.Email ? { email: value } : { phone: value };
}
/**
* `iam.users.email` and `.phone_number` are each independently UNIQUE, so a
* collision would otherwise surface as a raw 500 at write time. This is a
* courtesy check, not the guard — it races, so {@link asConflict} still has to
* catch the violation.
*/
private async assertNotTaken(
channel: ContactChannel,
value: string,
userId: string,
): Promise<void> {
const existing = await this.userRepository.findOne({
where:
channel === ContactChannel.Email
? { email: value }
: { phoneNumber: value },
select: { id: true },
});
if (existing && existing.id !== userId) {
throw this.takenError(channel);
}
}
private asConflict(error: unknown, channel: ContactChannel): Error {
const code = (error as { code?: string } | null)?.code;
if (code === PG_UNIQUE_VIOLATION) return this.takenError(channel);
return error as Error;
}
private takenError(channel: ContactChannel): ConflictException {
return new ConflictException(
channel === ContactChannel.Email
? "That email address is already registered to another account"
: "That phone number is already registered to another account",
);
}
}

View File

@@ -0,0 +1,60 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsEnum,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
/** The contact channel being changed on the caller's own account. */
export enum ContactChannel {
Email = "email",
Phone = "phone",
}
export class SendContactOtpDto {
@ApiProperty({ enum: ContactChannel })
@IsEnum(ContactChannel)
channel!: ContactChannel;
@ApiProperty({
description:
"The NEW email or phone to verify. The code is sent here, not to the " +
"address currently on the account — that is what proves the caller " +
"controls the number/inbox they are moving to.",
example: "+251911223344",
})
@IsString()
@IsNotEmpty()
value!: string;
}
export class UpdateContactDto extends SendContactOtpDto {
@ApiProperty({ description: "The 6-digit code sent to the new value" })
@IsString()
@IsNotEmpty()
otp!: string;
}
export class AccountNameDto {
@ApiProperty({ description: "Amharic name", example: "አበበ በቀለ" })
@IsString()
@IsNotEmpty()
am!: string;
@ApiPropertyOptional({ description: "English name", example: "Abebe Bekele" })
@IsOptional()
@IsString()
en?: string;
}
export class UpdateAccountNameDto {
@ApiProperty({ type: AccountNameDto })
@IsObject()
@ValidateNested()
@Type(() => AccountNameDto)
name!: AccountNameDto;
}

View File

@@ -11,6 +11,7 @@ import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user
import { OtpService, OtpTarget } from "../otp/otp.service";
import { ResetChannel } from "./dto/forgot-password.dto";
import { maskOtpTarget } from "./mask-target.util";
/**
* How long the reset ticket minted for `PATCH /api/auth/set-password` stays
@@ -158,12 +159,6 @@ export class ForgotPasswordService {
/** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */
maskTarget(target: OtpTarget): string {
if (target.email) {
const [local, domain] = target.email.split("@");
const head = local.slice(0, 1);
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
}
const phone = target.phone ?? "";
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
return maskOtpTarget(target);
}
}

View File

@@ -1,11 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Employee } from '@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity';
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity';
import { ExternalProfile } from '../companies/entities/external-profile.entity';
import { OtpModule } from '../otp/otp.module';
import { AccountController } from './account.controller';
import { AccountService } from './account.service';
import { CheckAvailabilityController } from './check-availability.controller';
import { CheckAvailabilityService } from './check-availability.service';
import { CustomerResetController } from './customer-reset.controller';
@@ -17,17 +21,25 @@ import { FreightMeService } from './freight-me.service';
@Module({
imports: [
TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]),
TypeOrmModule.forFeature([
User,
UserVerification,
ExternalProfile,
Session,
Employee,
]),
OtpModule,
],
controllers: [
FreightMeController,
AccountController,
CheckAvailabilityController,
ForgotPasswordController,
CustomerResetController,
],
providers: [
FreightMeService,
AccountService,
CheckAvailabilityService,
ForgotPasswordService,
CustomerResetService,

View File

@@ -0,0 +1,16 @@
import { OtpTarget } from "../otp/otp.service";
/**
* Mask an OTP target for echoing back to the caller: `+251911234567` ->
* `+251•••••4567`; `ab@x.com` -> `a•@x.com`. Never return an unmasked target to
* a caller who has not yet proven possession of the channel.
*/
export function maskOtpTarget(target: OtpTarget): string {
if (target.email) {
const [local, domain] = target.email.split("@");
const head = local.slice(0, 1);
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
}
const phone = target.phone ?? "";
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
}

View File

@@ -376,4 +376,97 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
expect(result).toBeNull();
expect(transaction).not.toHaveBeenCalled();
});
it("also retires a DRAFT invoice — a superseded/cancelled source must not leave one behind", async () => {
const { service, defaultManager } = build({
...openInvoice,
status: Freight.InvoiceStatus.Draft,
});
await service.expirePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"prepaid",
);
const { where } = defaultManager.findOne.mock.calls[0][1];
expect(where.status.value).toContain(Freight.InvoiceStatus.Draft);
});
});
describe("BillingService.issuePayable", () => {
const dueAt = new Date("2026-01-02T00:00:00.000Z");
const build = (found: Record<string, unknown> | null) => {
const manager = {
findOne: jest.fn().mockResolvedValue(found),
update: jest.fn().mockResolvedValue(undefined),
};
const service = new BillingService(
{ manager, transaction: jest.fn() } as never,
{} as never,
{} as never,
makeEvents() as never,
{} as never,
{} as never,
{} as never,
);
return { service, manager };
};
const issue = (service: BillingService) =>
service.issuePayable(
Freight.InvoiceSource.Booking,
"booking-1",
dueAt,
"PREPAID",
);
it("issues a DRAFT invoice to PENDING, stamping issuedAt and the pay-window dueAt", async () => {
const { service, manager } = build({
id: "inv-1",
invoiceNumber: "INV-20260101-00001",
status: Freight.InvoiceStatus.Draft,
issuedAt: null,
});
const result = await issue(service);
const patch = manager.update.mock.calls[0][2];
expect(patch.status).toBe(Freight.InvoiceStatus.Pending);
expect(patch.dueAt).toBe(dueAt);
expect(patch.issuedAt).toBeInstanceOf(Date);
expect(result?.status).toBe(Freight.InvoiceStatus.Pending);
});
it("looks up DRAFT invoices — a booking's invoice is minted DRAFT and this is what makes it payable", async () => {
const { service, manager } = build(null);
await issue(service);
const { where } = manager.findOne.mock.calls[0][1];
expect(where.status.value).toContain(Freight.InvoiceStatus.Draft);
});
it("only refreshes dueAt on an already-issued invoice, so a re-reserve never re-issues", async () => {
const issuedAt = new Date("2026-01-01T00:00:00.000Z");
const { service, manager } = build({
id: "inv-1",
invoiceNumber: "INV-20260101-00001",
status: Freight.InvoiceStatus.Pending,
issuedAt,
});
const result = await issue(service);
expect(manager.update.mock.calls[0][2]).toEqual({ dueAt });
expect(result?.issuedAt).toBe(issuedAt);
});
it("is a no-op (returns null, writes nothing) when the source has no draft-or-open invoice", async () => {
const { service, manager } = build(null);
await expect(issue(service)).resolves.toBeNull();
expect(manager.update).not.toHaveBeenCalled();
});
});

View File

@@ -125,7 +125,7 @@ export class BillingService {
private readonly payment: PaymentService,
private readonly companies: CompaniesService,
private readonly invoiceDocuments: InvoiceDocumentService,
) {}
) { }
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -432,7 +432,7 @@ export class BillingService {
input.dueAt ??
new Date(
Date.now() +
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
);
const invoiceNumber = await this.nextInvoiceNumber(mg);
@@ -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)}.`,
@@ -813,8 +826,15 @@ export class BillingService {
* Expire a source's currently-open invoice (its pay window closed before
* settlement), then emit `${source}.invoice.expired`. Resolves the open invoice
* and transitions it to EXPIRED — a terminal, non-payable status (kept out of
* `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice
* (already paid/cancelled/expired).
* `OPEN_STATUSES`). No-op (returns null) when the source has no invoice left to
* retire (already paid/cancelled/expired).
*
* DRAFT invoices are matched too, even though they were never issued: this is
* also the "retire the invoice this source no longer needs" path (a cancelled
* booking, or a full-amount invoice superseded by a partial-offer one). Skipping
* drafts would leave the stale one behind for `findPayable` to hand back — the
* superseding invoice would then never be minted, and a cancelled booking would
* keep a draft that a later `issuePayable` could still make payable.
*
* Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in
* the batch engine) to enlist in its DB transaction.
@@ -837,7 +857,7 @@ export class BillingService {
where: {
source,
sourceId,
status: In(OPEN_STATUSES),
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" },
@@ -854,30 +874,58 @@ export class BillingService {
}
/**
* Sync a source's open invoice `dueAt` to its real pay-window deadline. The
* booking invoice is generated before the pay window opens (at booking
* creation/approval), so its printed due date is refreshed when the batch engine
* sets `paymentDeadline`. No-op when the source has no open invoice.
* Issue a source's invoice and stamp its real pay-window deadline — the single
* transition that makes a source payable.
*
* A source's invoice is minted DRAFT, before any pay window exists (e.g. a
* booking invoice is generated at creation / operation-accept, long before the
* batch engine reserves a slot). DRAFT is deliberately outside `OPEN_STATUSES`,
* so such an invoice is not settleable and the portal renders no pay button.
* The domain calls this at the moment the pay window actually opens (booking →
* `reserve`, which sets SELECTED_FOR_BATCH + `paymentDeadline`), which issues
* the draft (→ PENDING, stamping `issuedAt`) and prints the real `dueAt`.
*
* Idempotent: an already-issued open invoice only has its `dueAt` refreshed, so
* a re-reserve never re-issues. No-op (returns null) when the source has no
* draft-or-open invoice (already paid/cancelled/expired).
*/
async syncPayableDueDate(
async issuePayable(
source: Freight.InvoiceSource,
sourceId: string,
dueAt: Date,
type?: string,
manager?: EntityManager,
): Promise<void> {
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: {
source,
sourceId,
status: In(OPEN_STATUSES),
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" },
});
if (!invoice) return;
await mg.update(Invoice, { id: invoice.id }, { dueAt });
if (!invoice) return null;
const issuing = invoice.status === Freight.InvoiceStatus.Draft;
const patch = {
dueAt,
...(issuing
? {
status: Freight.InvoiceStatus.Pending,
issuedAt: invoice.issuedAt ?? new Date(),
}
: {}),
};
await mg.update(Invoice, { id: invoice.id }, patch);
if (issuing) {
this.logger.log(
`Issued invoice ${invoice.invoiceNumber} (${invoice.id}) for ${source}:${sourceId} — payable until ${dueAt.toISOString()}`,
);
}
return { ...invoice, ...patch } as Invoice;
}
/**
@@ -891,6 +939,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: {
@@ -954,7 +1016,7 @@ export class BillingService {
// in the domain via `${source}.invoice.paid`. Neither billing nor the payment
// service branches on a domain-specific reference type.
referenceType: PaymentReferenceType.SHIPMENT,
orderRef: invoice.invoiceNumber.replace("-", "_"),
orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
amountMinor: Math.round(Number(invoice.balanceAmount)),
currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`,
@@ -964,16 +1026,14 @@ export class BillingService {
returnUrl: opts.returnUrl,
failureUrl: opts.failureUrl,
});
//
//
// Link the intent to the invoice BEFORE any settlement can correlate against it.
await this.dataSource
.getRepository(Invoice)
.update({ id: invoice.id }, { paymentId: result.intentId });
// DEMO: manually fire the gateway `payment.succeeded` callback here, without
// waiting for real gateway settlement. Runs AFTER the paymentId link above so
// `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
// remove — real settlement flips this via the `${source}.invoice.paid` handler.
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
// billing must not simulate it. Kept commented for local demos only.
if (!result.immediateSuccess) {
await this.payment.handlePaymentEvent({
eventType: "payment.succeeded",

View File

@@ -143,6 +143,20 @@ export function htmlToText(html: string): string {
.trim();
}
/**
* Large rotated light-gray copy label (e.g. "Copy 1: Port Operations Copy"),
* drawn FIRST so the page content sits on top of it. 30-degree rotation via a
* text matrix; roughly centered on the page.
*/
export function watermarkOp(text: string, page: { width: number; height: number }): string {
const label = clipText(text, 46);
const size = 34;
const w = textWidth(label, size);
const x = page.width / 2 - (w * 0.866) / 2;
const y = page.height / 2 - (w * 0.5) / 2;
return `q BT 0.93 0.93 0.93 rg /F2 ${size} Tf 0.866 0.5 -0.5 0.866 ${x.toFixed(1)} ${y.toFixed(1)} Tm (${escapePdfText(label)}) Tj ET Q`;
}
/**
* Parse a "summary tiles + one <table> + notice + signature lines" document (the
* marshalling / load-list layout the train-scheduling builders emit) and draw it as a
@@ -150,11 +164,26 @@ export function htmlToText(html: string): string {
* document, not a flat text dump. Switches to landscape when the table is wide.
*/
export function buildTabularFallbackPdf(html: string): Buffer {
// Documents printed in duplicate wrap each copy in <section class="copy">
// (freight order: Port Operations copy + Gate Security copy). Render one
// page per copy, each with its own watermark and tile set — parsing the
// whole HTML at once would merge both copies' tiles and drop the watermarks.
const copies = [...html.matchAll(/<section class="copy">([\s\S]*?)<\/section>/gi)].map((m) => m[1]);
const fragments = copies.length ? copies : [html];
return assemblePdf(fragments.flatMap((fragment) => buildTabularPageOps(fragment)));
}
function buildTabularPageOps(
html: string,
): Array<{ ops: string[]; page: { width: number; height: number } }> {
const pick = (re: RegExp) => html.match(re)?.[1];
const title = htmlToText(pick(/<h1[^>]*>([\s\S]*?)<\/h1>/i) ?? "Document");
const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
const metaRef = htmlToText(pick(/class="meta"[\s\S]*?<strong>([\s\S]*?)<\/strong>/i) ?? "");
const metaLabel =
htmlToText(pick(/class="meta"[^>]*>([\s\S]*?)<strong/i) ?? "").toUpperCase() || "REFERENCE";
const generated = htmlToText(pick(/Generated:\s*([^<]+)/i) ?? "");
const watermark = htmlToText(pick(/class="watermark"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
const tiles: Array<[string, string]> = [];
for (const m of html.matchAll(
@@ -180,24 +209,49 @@ export function buildTabularFallbackPdf(html: string): Buffer {
const M = 32;
const contentW = page.width - M * 2;
const right = page.width - M;
const ops: string[] = [];
const MAX_PAGES = 12;
// Header
ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4));
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray));
ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark));
if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray));
if (metaRef) {
ops.push(textOpRight("TRAIN / SCHEDULE", right, page.height - 42, 7.5, "F2", PdfColor.gray));
ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark));
}
if (generated) {
ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray));
}
ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1));
const pagesOut: Array<{ ops: string[]; page: { width: number; height: number } }> = [];
let ops: string[] = [];
let y = 0;
// Summary tiles
let y = page.height - 100;
const drawFullHeader = () => {
ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4));
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray));
ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark));
if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray));
if (metaRef) {
ops.push(textOpRight(clipText(metaLabel, 26), right, page.height - 42, 7.5, "F2", PdfColor.gray));
ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark));
}
if (generated) {
ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray));
}
ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1));
y = page.height - 100;
};
const drawContinuationHeader = (pageNo: number) => {
ops.push(lineOp(M, page.height - 24, right, page.height - 24, PdfColor.teal, 1.6));
ops.push(
textOp(clipText(`${title} (continued — page ${pageNo})`, landscape ? 100 : 68), M, page.height - 42, 11, "F2", PdfColor.dark),
);
if (metaRef) ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 42, 10, "F2", PdfColor.gray));
y = page.height - 54;
};
const startPage = (first: boolean) => {
ops = [];
if (watermark) ops.push(watermarkOp(watermark, page));
if (first) drawFullHeader();
else drawContinuationHeader(pagesOut.length + 1);
};
const finishPage = () => pagesOut.push({ ops, page });
startPage(true);
// Summary tiles (first page only)
if (tiles.length) {
const cols = landscape ? 6 : 4;
const tileW = contentW / cols;
@@ -213,21 +267,34 @@ export function buildTabularFallbackPdf(html: string): Buffer {
y -= tileH + 12;
}
// Table
// Table, paginated across as many pages as the rows need.
if (headers.length) {
const colW = contentW / headers.length;
const headerH = 16;
const rowH = 14;
const cellChars = Math.max(4, Math.floor(colW / 3.9));
ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
headers.forEach((h, c) =>
ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)),
);
y -= headerH;
const bottomReserve = 46; // keep clear of the page edge on row-only pages
let shown = 0;
for (const row of rows) {
if (y < 96) break;
const drawTableHeader = () => {
ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
headers.forEach((h, c) =>
ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)),
);
y -= headerH;
};
drawTableHeader();
let truncated = 0;
for (const [index, row] of rows.entries()) {
if (y - rowH < bottomReserve) {
if (pagesOut.length + 1 >= MAX_PAGES) {
truncated = rows.length - index;
break;
}
finishPage();
startPage(false);
drawTableHeader();
}
ops.push(rectOp(M, y - rowH, contentW, rowH, "1 1 1", PdfColor.line, 0.4));
headers.forEach((_h, c) => {
if (c > 0) ops.push(lineOp(M + c * colW, y - rowH, M + c * colW, y, PdfColor.line, 0.3));
@@ -235,30 +302,33 @@ export function buildTabularFallbackPdf(html: string): Buffer {
if (cell) ops.push(textOp(clipText(cell, cellChars), M + c * colW + 4, y - 10, 6.8, "F1", PdfColor.dark));
});
y -= rowH;
shown += 1;
}
if (shown < rows.length) {
ops.push(textOp(`... ${rows.length - shown} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
if (truncated > 0) {
ops.push(textOp(`... ${truncated} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
}
}
// Notice (verification clause)
// Notice + signatures live on the final page; give them a fresh page when the
// rows ran too deep for the fixed bottom band.
if (y < 110 && (notice || signatures.length)) {
finishPage();
startPage(false);
}
if (notice) {
ops.push(lineOp(M, 78, M, 54, PdfColor.teal, 2));
wrapText(notice, landscape ? 155 : 104)
.slice(0, 2)
.forEach((ln, i) => ops.push(textOp(ln, M + 8, 72 - i * 11, 7.5, "F1", PdfColor.gray)));
}
// Signatures
const sigW = contentW / signatures.length;
signatures.forEach((s, i) => {
signatures.forEach((sig, i) => {
const x = M + i * sigW;
ops.push(lineOp(x, 40, x + sigW - 18, 40, PdfColor.dark, 0.7));
ops.push(textOp(clipText(s, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
ops.push(textOp(clipText(sig, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
});
finishPage();
return assembleSinglePagePdf(ops, page);
return pagesOut;
}
/** Greedy word-wrap to a maximum character width. */
@@ -320,3 +390,41 @@ export function assembleSinglePagePdf(
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, "latin1");
}
/** Assemble a multi-page PDF; one content stream per page, shared Helvetica fonts. */
export function assemblePdf(
pages: Array<{ ops: string[]; page: { width: number; height: number } }>,
): Buffer {
const kids = pages.map((_, i) => `${5 + i * 2} 0 R`).join(" ");
const objects: string[] = [
"<< /Type /Catalog /Pages 2 0 R >>",
`<< /Type /Pages /Kids [${kids}] /Count ${pages.length} >>`,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
];
for (const [i, p] of pages.entries()) {
const stream = p.ops.join("\n");
objects.push(
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${p.page.width} ${p.page.height}] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ${6 + i * 2} 0 R >>`,
);
objects.push(`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`);
}
let pdf = "%PDF-1.4\n";
const offsets: number[] = [0];
objects.forEach((object, index) => {
offsets.push(Buffer.byteLength(pdf, "latin1"));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) {
pdf += "% fallback padding\n";
}
const xrefOffset = Buffer.byteLength(pdf, "latin1");
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += "0000000000 65535 f \n";
for (const offset of offsets.slice(1)) {
pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, "latin1");
}

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
private buildRedirectHtml(url: string): string {
const escaped = url.replace(/\"/g, "&quot;");
// 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>

View File

@@ -16,6 +16,7 @@ import {
InvoiceLineInput,
} from "../billing/billing.service";
import { Invoice } from "../billing/entities/invoice.entity";
import { CLEARANCE_BOOKING_INVOICE_TYPE } from "../contracts/clearance-fee.service";
import { FirstMileService } from "../first-mile/first-mile.service";
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
@@ -119,6 +120,36 @@ export class BookingInvoiceService {
return this.billing.updateStatus(invoiceId, status, manager);
}
/**
* Expire the booking's currently-open invoices (freight PREPAID and the
* per-shipment clearance fee) 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.
*/
async expireOpenInvoices(
bookingId: string,
manager?: EntityManager,
): Promise<Invoice | null> {
// The per-shipment clearance fee (GENERAL contracts) bills this same booking
// id under its own source/type — retire it alongside the freight invoice, or
// a cancelled shipment keeps a payable clearance invoice open.
await this.billing.expirePayable(
Freight.InvoiceSource.Clearance,
bookingId,
CLEARANCE_BOOKING_INVOICE_TYPE,
manager,
);
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 +169,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(

View File

@@ -1,4 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import {
NotificationAudience,
NotificationType,
@@ -8,6 +10,7 @@ import {
import { Booking } from './entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
/**
* Customer + staff notifications for the booking lifecycle: review, clearance
@@ -27,6 +30,8 @@ export class BookingLifecycleNotifierService {
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
private ref(b: Booking): string {
@@ -40,7 +45,9 @@ export class BookingLifecycleNotifierService {
logLabel: string,
): Promise<void> {
this.logger.log(`${logLabel}${this.ref(b)}`);
const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null;
const phone = b.companyId
? await resolveCompanyNotifyPhone(this.dataSource, b.companyId)
: null;
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
if (phone) {

View File

@@ -4,6 +4,12 @@ import type { Rate } from '../rule-engine/entities/rate.entity';
const MOCK_CBE_RATE = 130;
// Base freight is configured per leg, so every rate and every booking names the
// route it runs. MOJO → DIRE is the corridor these rates are priced for.
const MOJO = 'yard-mojo';
const DIRE = 'yard-dire-dawa';
const LEBU = 'yard-lebu';
describe('BookingPricingService — domestic corridor', () => {
const intercityBulkUsd: Rate = {
id: 'rate-intercity-bulk-usd',
@@ -13,6 +19,8 @@ describe('BookingPricingService — domestic corridor', () => {
rateUnit: 'PER_TON',
status: 'LIVE',
containerTypeId: null,
originYardId: MOJO,
destinationYardId: DIRE,
} as Rate;
const intercityContainerUsd: Rate = {
@@ -23,6 +31,8 @@ describe('BookingPricingService — domestic corridor', () => {
rateUnit: 'PER_CONTAINER',
status: 'LIVE',
containerTypeId: null,
originYardId: MOJO,
destinationYardId: DIRE,
} as Rate;
let service: BookingPricingService;
@@ -56,6 +66,8 @@ describe('BookingPricingService — domestic corridor', () => {
tradeDirection: 'DOMESTIC',
paymentCurrency: 'ETB',
cargoTotalWeightVgm: 120,
originYardId: MOJO,
destinationYardId: DIRE,
bookingContainers: [],
} as unknown as Booking;
@@ -81,6 +93,8 @@ describe('BookingPricingService — domestic corridor', () => {
tradeDirection: 'DOMESTIC',
paymentCurrency: 'USD',
cargoTotalWeightVgm: 120,
originYardId: MOJO,
destinationYardId: DIRE,
bookingContainers: [],
} as unknown as Booking;
@@ -106,6 +120,8 @@ describe('BookingPricingService — domestic corridor', () => {
tradeDirection: 'DOMESTIC',
paymentCurrency: 'ETB',
cargoTotalWeightVgm: 50,
originYardId: MOJO,
destinationYardId: DIRE,
bookingContainers: [],
} as unknown as Booking;
@@ -126,4 +142,59 @@ describe('BookingPricingService — domestic corridor', () => {
const line = result.lineItems.find((l) => l.code === 'INTERCITY_CONTAINER')!;
expect(line.currency).toBe('ETB');
});
// Rates are quoted per leg, so one configured for MOJO → DIRE must not price a
// shipment that runs LEBU → DIRE. Charging the wrong corridor's price because
// nobody configured this one yet is worse than billing no base freight.
it('does not price bulk off a rate configured for a different leg', async () => {
const booking = {
id: 'b-3',
freightType: 'BULK',
tradeDirection: 'DOMESTIC',
paymentCurrency: 'USD',
cargoTotalWeightVgm: 120,
originYardId: LEBU,
destinationYardId: DIRE,
bookingContainers: [],
} as unknown as Booking;
const result = await (
service as unknown as {
computeBaseRailLinesWithRates: (
b: Booking,
input: { containers: [] },
) => Promise<{ lineItems: Array<{ amount: number }> }>;
}
).computeBaseRailLinesWithRates(booking, { containers: [] });
expect(result.lineItems).toHaveLength(0);
});
it('does not price containers off a rate configured for a different leg', async () => {
const booking = {
id: 'b-4',
freightType: 'CONTAINER',
tradeDirection: 'DOMESTIC',
paymentCurrency: 'USD',
cargoTotalWeightVgm: 50,
originYardId: LEBU,
destinationYardId: DIRE,
bookingContainers: [],
} as unknown as Booking;
const result = await (
service as unknown as {
computeBaseRailLinesWithRates: (
b: Booking,
input: {
containers: Array<{ containerTypeId: string; quantity: number }>;
},
) => Promise<{ lineItems: Array<{ amount: number }> }>;
}
).computeBaseRailLinesWithRates(booking, {
containers: [{ containerTypeId: 'ct-20', quantity: 3 }],
});
expect(result.lineItems).toHaveLength(0);
});
});

View File

@@ -3,17 +3,16 @@ 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,
BookingEvaluationInput,
RuleEngineService,
} from '../rule-engine/rule-engine.service';
import { containersPerWagonForSize } from '../rule-engine/container-type.util';
import { BookingsRepository } from './bookings.repository';
import {
containersPerWagon,
wagonRemainder,
} from './consolidation.service';
import { wagonRemainder } from './consolidation.service';
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
@@ -128,11 +127,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 +147,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 +159,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 +176,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),
@@ -281,7 +306,7 @@ export class BookingPricingService {
totalVgmTons: qty * vgm,
isReefer: ct.isReefer,
},
perWagon: containersPerWagon(Number(ct.wagonsPerUnit)),
perWagon: containersPerWagonForSize(ct.sizeFt),
quantity: qty,
};
}),
@@ -328,6 +353,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 +449,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;
@@ -444,19 +475,46 @@ export class BookingPricingService {
const wagonCount = await this.resolveWagonCount(booking);
for (const container of evalInput.containers) {
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD');
const rate = this.pickRate(
liveRates,
rateType,
container.containerTypeId,
'USD',
booking.originYardId,
booking.destinationYardId,
);
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,
@@ -464,22 +522,46 @@ export class BookingPricingService {
}
if (lines.length === 0) {
// Bulk (and any booking with no container lines) still has to price off a
// rate configured for this leg — never one belonging to another route.
const fallback = liveRates.find(
(r) => r.rateType === rateType && r.currency === 'USD' && r.status === 'LIVE',
(r) =>
r.rateType === rateType &&
r.currency === 'USD' &&
r.status === 'LIVE' &&
r.originYardId === booking.originYardId &&
r.destinationYardId === booking.destinationYardId,
);
if (fallback) {
usedRatesMap.set(fallback.id, fallback);
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 +585,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 +643,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,
@@ -626,39 +725,123 @@ export class BookingPricingService {
}
}
/**
* Base freight is quoted per leg, so a rate only applies to a booking running
* the exact origin → destination it was configured for. There is deliberately
* no route-agnostic fallback: charging a Dire Dawa price for a Mojo shipment
* because nobody configured Mojo yet is worse than surfacing no line at all.
* Within the leg, a rate scoped to the container type wins over one that
* covers every type.
*/
private pickRate(
rates: Rate[],
rateType: string,
containerTypeId: string,
currency: string,
originYardId: string,
destinationYardId: string,
): Rate | undefined {
const onLeg = rates.filter(
(r) =>
r.rateType === rateType &&
r.currency === currency &&
r.originYardId === originYardId &&
r.destinationYardId === destinationYardId,
);
return (
rates.find(
(r) =>
r.rateType === rateType &&
r.currency === currency &&
r.containerTypeId === containerTypeId,
) ??
rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId)
onLeg.find((r) => r.containerTypeId === containerTypeId) ??
onLeg.find((r) => !r.containerTypeId)
);
}
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]

View File

@@ -110,7 +110,6 @@ export function groupContainersBySize(
name: ct.label?.trim() ? ct.label : ct.code,
code: ct.code,
is_reefer: ct.isReefer ?? false,
wagons_per_unit: Number(ct.wagonsPerUnit ?? 1),
}),
),
}));

View File

@@ -14,9 +14,10 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
serviceType: { includesCustoms: false }, // no output set → only the input gate
};
// Input set has two required docs.
// Input set has two required docs. Non-customs bookings resolve to the
// ONE_TIME self-clearance document set.
const inputSetting = {
code: 'clearance_import_container_without_customs',
code: 'contract_clearance_selfclear_import_container',
fields: [
{ fileKey: 'commercial_invoice', isRequired: true },
{ fileKey: 'packing_list', isRequired: true },
@@ -198,7 +199,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
*/
describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => {
const inputSetting = {
code: 'clearance_import_container_without_customs',
code: 'contract_clearance_selfclear_import_container',
fields: [
{ fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true },
{ fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true },

View File

@@ -1,4 +1,4 @@
import { BadRequestException } from '@nestjs/common';
import { BadRequestException, ConflictException } from '@nestjs/common';
import { BookingTransitionService } from './booking-transition.service';
/**
@@ -116,3 +116,98 @@ describe('BookingTransitionService — operation review', () => {
);
});
});
/**
* Export over-book gate at the customer's requestOperation step: export never
* splits, so the free-space check runs the moment the customer commits to a
* shipment day. When no single export train that day can carry the whole
* booking, `pickExportSchedule` throws and the request is refused BEFORE the
* booking moves to OPERATION_REQUEST_PENDING. Import bookings are never gated
* here (they are batched + splittable later).
*/
describe('BookingTransitionService — requestOperation export space gate', () => {
function makeService(tradeDirection: 'EXPORT' | 'IMPORT', overbook: boolean) {
const booking = {
id: 'b-1',
reference: 'BKG-1',
status: 'CLEARANCE_READY',
tradeDirection,
originYardId: 'o-1',
destinationYardId: 'd-1',
totalAmount: 1000,
contractId: null,
serviceType: { code: 'RAIL_CONTAINER' },
};
const bookingsRepository = {
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
checkDayCompatibilityForBooking: jest
.fn()
.mockResolvedValue({ hasDeparture: true, hasCompatible: true }),
};
const bookingBatchService = {
// Over-book → the export gate rejects; otherwise it returns a schedule id.
pickExportSchedule: overbook
? jest.fn().mockRejectedValue(new ConflictException('Not enough train space'))
: jest.fn().mockResolvedValue('sched-1'),
};
const notifier = { operationRequestedToStaff: jest.fn() };
const service = new BookingTransitionService(
bookingsRepository as never,
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // filesService
{} as never, // fileUploadSettingsService
bookingBatchService as never,
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
notifier as never,
);
return { service, bookingsRepository, bookingBatchService };
}
it('rejects an over-booked export request and does NOT advance the booking', async () => {
const { service, bookingsRepository, bookingBatchService } = makeService(
'EXPORT',
true,
);
await expect(
service.requestOperation('b-1', '2026-07-20T00:00:00.000Z'),
).rejects.toBeInstanceOf(ConflictException);
expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledTimes(1);
expect(bookingsRepository.update).not.toHaveBeenCalled();
});
it('lets an export request through when a train fits the whole booking', async () => {
const { service, bookingsRepository, bookingBatchService } = makeService(
'EXPORT',
false,
);
await service.requestOperation('b-1', '2026-07-20T00:00:00.000Z');
expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledTimes(1);
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }),
);
});
it('never runs the export gate for an import request', async () => {
const { service, bookingsRepository, bookingBatchService } = makeService(
'IMPORT',
true, // would reject IF called — proves it is not called
);
await service.requestOperation('b-1', '2026-07-20T00:00:00.000Z');
expect(bookingBatchService.pickExportSchedule).not.toHaveBeenCalled();
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }),
);
});
});

View File

@@ -1,5 +1,6 @@
import {
BadRequestException,
ConflictException,
forwardRef,
Inject,
Injectable,
@@ -32,8 +33,6 @@ import { ClearanceMilestoneService } from '../contracts/clearance-milestone.serv
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
import { ContractDocPhase } from '@edr/types';
import { Freight } from "@edr/types";
import { BookingInvoiceService } from "./booking-invoice.service";
@Injectable()
@@ -533,6 +532,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 +564,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 +721,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",
@@ -988,24 +1000,57 @@ export class BookingTransitionService {
"OPERATION_CHANGES_REQUESTED",
]);
// A bare initiated instance (clearance-first flow) carries no cargo or
// price — it must go through the contract completion endpoint, which
// persists cargo, prices, invoices and only then lands here itself.
if (booking.contractId && !(Number(booking.totalAmount) > 0)) {
throw new BadRequestException(
"This booking must be completed (cargo and shipment day) before requesting operation.",
);
}
const date = new Date(scheduledDate);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException("A valid schedule date is required");
}
// 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",
);
}
// Export is FCFS and never splits — a booking must ride one train whole. So
// the free-space check belongs HERE, the moment the customer commits to a
// shipment day, not later at staff operation-accept. Blocking now stops the
// customer booking more wagons than any single export train that day can
// still carry; `exportSpaceReport` throws a 409 whose message carries the
// largest bookable leftover ("reduce to N wagons or pick another day").
// Import/domestic bookings are batched + splittable, so they are NOT gated
// here — they get an advisory count below and the batch engine sizes them.
const scheduledBooking = { ...booking, scheduledDate: date } as Booking;
const isExportTrain =
booking.tradeDirection === "EXPORT" &&
!isRoadService(booking.serviceType);
if (isExportTrain) {
await this.bookingBatchService.pickExportSchedule(scheduledBooking);
}
await this.bookingsRepository.update(bookingId, {
status: "OPERATION_REQUEST_PENDING",
@@ -1016,6 +1061,47 @@ export class BookingTransitionService {
return fresh;
}
/**
* Advisory availability for a shipment day the customer is considering — a
* planning hint for the day picker, computed but never enforced. For EXPORT it
* mirrors the real request-time gate: `fits` is whether a single open train
* that day can carry the WHOLE booking (export never splits), and `freeWagons`
* is the largest single-train leftover. For IMPORT/DOMESTIC `freeWagons` is the
* TOTAL room across the day's trains for the booking's wagon type (the batch
* engine may still split or defer a remainder), and `fits` is whether that
* total covers the booking. `trainsForDay` is false when no departure carries
* the leg — the day is unbookable regardless of space.
*/
async dayAvailabilityForBooking(
bookingId: string,
scheduledDate: string,
): Promise<{ fits: boolean; freeWagons: number; trainsForDay: boolean }> {
const booking = await this.bookingsService.findById(bookingId);
const date = new Date(scheduledDate);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException("A valid schedule date is required");
}
const day = eatDay(date);
const isExportTrain =
booking.tradeDirection === "EXPORT" &&
!isRoadService(booking.serviceType);
if (isExportTrain) {
const scheduledBooking = { ...booking, scheduledDate: date } as Booking;
const report =
await this.bookingBatchService.exportSpaceReport(scheduledBooking);
return {
fits: report.scheduleId != null,
freeWagons: report.bestAvailable?.wagons ?? 0,
trainsForDay: report.trainsForDay && report.corridorMatched,
};
}
const { freeWagons, need, trainsForDay } =
await this.bookingBatchService.dayImportAvailability(booking, day);
return { fits: freeWagons >= need, freeWagons, trainsForDay };
}
/**
* Operations team reviews a pending operation request (capacity, documents,
* route). Two outcomes:
@@ -1081,14 +1167,25 @@ export class BookingTransitionService {
await this.bookingBatchService.pickExportSchedule(booking);
}
// Mint the booking's invoice (DRAFT) so the priced order carries its billing
// record from accept onward. It is deliberately NOT issued here: accepting an
// operation only puts the booking in the batch holding pool — no slot has been
// offered and no pay window exists yet. Issuing at this point made the invoice
// payable straight away (portal invoice list/detail gate on invoice status
// alone), letting a customer pay before being selected for a batch, while the
// booking page correctly still showed it as not payable. The batch engine
// issues it in `reserve` (SELECTED_FOR_BATCH), which is where the pay window
// and the real deadline are created — matching the portal's `canPay` gate.
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
this.logger.log(
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`,
);
await this.invoiceService.updateStatus(
invoice.id,
Freight.InvoiceStatus.Pending,
`Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`,
);
// TODO: road (truck) orders are an incomplete feature — they stop at the
// dead-end ROAD_DISPATCH_PENDING status below (no dispatch transition, no
// per-km pricing wired via roadKmPrice, no pay surface in the portal). They
// skip the train batch, so they never reach `reserve` and their invoice stays
// DRAFT / unpayable. When the road flow is built, issue its invoice
// (billing.issuePayable) at whatever transition opens the road pay window.
if (isRoadService(booking.serviceType)) {
await this.bookingsRepository.update(booking.id, {
status: "ROAD_DISPATCH_PENDING",

View File

@@ -348,6 +348,53 @@ 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/day-availability')
@ApiOperation({
summary:
'Advisory free-wagon count for a shipment day (planning hint, not enforced). ' +
'Export: whole-booking fit + largest single-train leftover. ' +
'Import/domestic: total room across the day for the booking\'s wagon type.',
})
async dayAvailability(
@Param('id', ParseUUIDPipe) id: string,
@Query('date') date: 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.transitionService.dayAvailabilityForBooking(id, date);
}
@Get(':id/mile-summary')
@ApiOperation({
summary: 'First/last-mile operational summary for a booking (customer-safe)',

View File

@@ -47,6 +47,7 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractsModule } from '../contracts/contracts.module';
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder";
import { ContractRateScheduleBuilder } from "../../contracts/contract-rate-schedule.builder";
import { ContractRendererService } from "../../contracts/contract-renderer.service";
import { ContractTemplateResolver } from "../../contracts/contract-template.resolver";
import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder";
@@ -106,6 +107,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
ContractTemplateResolver,
ContractViewModelBuilder,
ContractPricingScheduleBuilder,
ContractRateScheduleBuilder,
ContractRendererService,
ContractPdfService,
CustomerTruckAssignmentsRepository,
@@ -118,6 +120,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingPricingService,
BookingInvoiceService,
BookingLifecycleNotifierService,
BookingTransitionService,
ConsolidationService,
CustomerTruckService,
ContainerReceiptService,

View File

@@ -7,6 +7,7 @@ function mockQueryBuilder() {
const qb = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
@@ -15,6 +16,8 @@ function mockQueryBuilder() {
take: jest.fn().mockReturnThis(),
getMany: jest.fn(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
getCount: jest.fn().mockResolvedValue(0),
getRawAndEntities: jest.fn().mockResolvedValue({ entities: [], raw: [] }),
};
return qb;
}

View File

@@ -4,8 +4,10 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
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';
@@ -148,7 +150,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
for (const item of containers) {
const ct = await typeRepo.findOne({ where: { id: item.containerTypeId } });
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
const wagonsPerUnit = wagonsPerUnitForSize(ct?.sizeFt);
const totalVgm = item.quantity * item.vgmPerUnitTons;
const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit);
// A per-line breakdown can never exceed the line's own quantity.
@@ -178,7 +180,10 @@ export class BookingsRepository extends BaseRepository<Booking> {
async calculateWagonCount(bookingId: string): Promise<number> {
const result = await this.dataSource
.createQueryBuilder()
.select('CEILING(SUM(bc.quantity * ct.wagons_per_unit))', 'total')
.select(
'CEILING(SUM(bc.quantity * CASE WHEN ct.size_ft >= 40 THEN 1 WHEN ct.size_ft > 0 THEN 0.5 ELSE 1 END))',
'total',
)
.from(BookingContainer, 'bc')
.innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id')
.where('bc.booking_id = :bookingId', { bookingId })
@@ -200,6 +205,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 +235,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 +277,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 +299,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 +344,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
@@ -605,6 +698,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
async findAllPaginated(options: BookingListFilterOptions & {
page: number;
pageSize: number;
search?: string;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}): Promise<{
@@ -640,6 +734,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
this.applyListFilters(qb, options);
// Free-text search spans joined columns (company, contract) that only this
// list query joins — so it lives here, not in applyListFilters (shared
// with getListSummaryMetrics, whose query builder has no joins).
if (options.search) {
qb.andWhere(
'(booking.reference ILIKE :search OR company.name ILIKE :search OR contract.reference ILIKE :search)',
{ search: `%${options.search}%` },
);
}
if (options.sortBy === 'isGovernment') {
qb.orderBy('booking.isGovernment', 'DESC')
.addOrderBy('booking.priorityScore', 'DESC')
@@ -804,9 +908,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
});
}
if (options.bookingType) {
qb.andWhere('booking.bookingType = :bookingType', {
bookingType: options.bookingType,
});
// The stored booking_type column is 'ONE_TIME' for every row (contract
// drawdowns included — see contract-booking.service create), so the
// one-time vs general split keys on the denormalized contract_kind:
// GENERAL_CONTRACT tab = bookings under a GENERAL contract, ONE_TIME tab
// = everything else (ONE_TIME contracts and legacy contract-less rows).
if (options.bookingType === 'GENERAL_CONTRACT') {
qb.andWhere("booking.contract_kind = 'GENERAL'");
} else {
qb.andWhere("booking.contract_kind IS DISTINCT FROM 'GENERAL'");
}
}
if (options.createdFrom) {
qb.andWhere('booking.created_at >= :createdFrom', {
@@ -1237,10 +1348,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
destinationYard: true,
// units carry the real per-container numbers entered at booking time —
// the wagon plan shows those instead of generated placeholders.
// containerType.wagonType + cargoType.wagonType drive wagon-type
// resolution during scheduling (FK, not the old load-type string map).
bookingContainers: { containerType: { wagonType: true }, units: true },
cargoType: { wagonType: true },
// containerType.wagonTypes + cargoType.wagonTypes drive wagon-type
// resolution during scheduling (many-to-many lists — the plan mixes
// wagon types within one consist).
bookingContainers: { containerType: { wagonTypes: true }, units: true },
cargoType: { wagonTypes: true },
},
order: { priorityScore: 'DESC', createdAt: 'ASC' },
});
@@ -1251,7 +1363,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
fields: Partial<
Pick<
Booking,
'schedulingStatus' | 'wagonsRequired' | 'scheduledAt' | 'holdStartedAt' | 'holdExpiresAt'
| 'schedulingStatus'
| 'wagonsRequired'
| 'scheduledAt'
| 'holdStartedAt'
| 'holdExpiresAt'
| 'trainScheduleId'
>
>,
manager?: EntityManager,

View File

@@ -18,6 +18,7 @@ import { TrainSchedulingService } from '../train-scheduling/train-scheduling.ser
import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import {
BookingEvaluationInput,
@@ -31,6 +32,7 @@ import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { Contract } from '../contracts/entities/contract.entity';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { VehiclesService } from '../vehicles/vehicles.service';
@@ -437,7 +439,7 @@ export class BookingsService {
vgmPerUnitTons: c.vgmPerUnitTons,
totalVgmTons,
isReefer: ct.isReefer,
wagonsRequired: c.quantity * (Number(ct.wagonsPerUnit) || 1),
wagonsRequired: c.quantity * wagonsPerUnitForSize(ct.sizeFt),
};
}),
);
@@ -507,13 +509,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),
@@ -652,22 +669,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 ?? [];
@@ -1148,6 +1180,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
@@ -1211,6 +1289,13 @@ export class BookingsService {
destinationYardId: filter.destinationYardId,
isGovernment: filter.isGovernment,
consolidationPaired: filter.consolidationPaired,
// DTO carries 'true'/'false' strings (query params); the repo option is a
// real boolean — convert, preserving "not filtered" when absent.
customsClearingEnabled:
filter.customsClearingEnabled === undefined
? undefined
: filter.customsClearingEnabled === 'true',
search: filter.search,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
@@ -1262,6 +1347,7 @@ export class BookingsService {
// Global Logistics only clears customs bookings; non-customs clearance is
// reviewed by Marketing from the booking detail, not this queue.
customsClearingEnabled: true,
search: filter.search,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
@@ -1286,6 +1372,7 @@ export class BookingsService {
// Company-wide: payables span all of the customer's services.
companyId: company.id,
companyProfileId: filter.companyProfileId,
search: filter.search,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
@@ -1473,6 +1560,18 @@ export class BookingsService {
);
}
// Surface the parent contract's reference for drawdown bookings — the
// portal detail header shows it (the entity has no contract relation, so
// the list attaches it via a raw join and the detail attaches it here).
if (booking.contractId) {
const contract = await this.dataSource.getRepository(Contract).findOne({
where: { id: booking.contractId },
select: { reference: true },
});
(booking as Booking & { contractReference?: string | null }).contractReference =
contract?.reference ?? null;
}
// Surface the assigned train's operational status so the portal stepper
// can show the Arrival stage: the booking status stays IN_TRANSIT from
// dispatch until delivery, so arrival is only knowable from the schedule.

View File

@@ -8,8 +8,10 @@ describe('clearance.util — clearanceSettingCode', () => {
expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe(
'clearance_import_container_with_customs',
);
// Non-customs bookings self-clear with the same document set a ONE_TIME
// self-clear contract uses.
expect(clearanceSettingCode('IMPORT', 'CONTAINER', false)).toBe(
'clearance_import_container_without_customs',
'contract_clearance_selfclear_import_container',
);
});
@@ -18,7 +20,7 @@ describe('clearance.util — clearanceSettingCode', () => {
'clearance_export_bulk_with_customs',
);
expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe(
'clearance_export_bulk_without_customs',
'contract_clearance_selfclear_export_bulk',
);
});

View File

@@ -29,8 +29,14 @@ export function clearanceSettingCode(
const op = operationFor(tradeDirection);
if (!op) return null;
const freight = freightFor(freightType);
const customs = includesCustoms ? 'with_customs' : 'without_customs';
return `clearance_${op}_${freight}_${customs}`;
// Non-customs (Path A) bookings self-clear: the customer proves his own
// clearance with the SAME smaller document set a ONE_TIME self-clear
// contract uses (customs declaration, release permit, …) — not the
// GL-oriented booking sets.
if (!includesCustoms) {
return `contract_clearance_selfclear_${op}_${freight}`;
}
return `clearance_${op}_${freight}_with_customs`;
}
/** The GL-output (customs output) setting code, keyed on op + freight. */

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { containersPerWagonForSize } from '../rule-engine/container-type.util';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { Booking } from './entities/booking.entity';
@@ -19,13 +20,6 @@ export interface ConsolidationAttemptResult {
messages: string[];
}
/** Containers that fit on one wagon for a given container type (inverse of wagons_per_unit). */
export function containersPerWagon(wagonsPerUnit: number): number {
const wpu = Number(wagonsPerUnit);
if (!wpu || wpu <= 0) return 1;
return Math.max(1, Math.round(1 / wpu));
}
export function wagonRemainder(quantity: number, perWagon: number): number {
const r = quantity % perWagon;
return r;
@@ -73,7 +67,7 @@ export class ConsolidationService {
const slots: ConsolidationSlot[] = [];
for (const [containerTypeId, quantity] of quantityByType) {
const ct = await this.containerTypesService.findById(containerTypeId);
const perWagon = containersPerWagon(Number(ct.wagonsPerUnit));
const perWagon = containersPerWagonForSize(ct.sizeFt);
const remainder = wagonRemainder(quantity, perWagon);
if (remainder === 0) continue;
slots.push({

View File

@@ -2,18 +2,24 @@ import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager, IsNull } from 'typeorm';
import { NotificationAudience, NotificationType } from '@edr/types';
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
interface BookingGuardRow {
tradeDirection: string | null;
freightType: string | null;
firstMile: string | null;
lastMile: string | null;
paymentStatus: string | null;
@@ -29,9 +35,13 @@ interface BookingGuardRow {
*/
@Injectable()
export class CustomerTruckService {
private readonly logger = new Logger(CustomerTruckService.name);
constructor(
private readonly dataSource: DataSource,
private readonly assignments: CustomerTruckAssignmentsRepository,
private readonly inbox: NotificationInboxService,
private readonly notifications: NotificationsService,
) {}
listTrucks(bookingId: string): Promise<CustomerTruckAssignment[]> {
@@ -41,14 +51,20 @@ export class CustomerTruckService {
async addTruck(bookingId: string, dto: AddCustomerTruckDto): Promise<CustomerTruckAssignment[]> {
const booking = await this.loadBookingGuard(bookingId);
this.assertSelfHaulPaid(booking);
this.assertAssignmentWindow(booking);
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
// Bulk bookings have no containers — the truck hauls loose tonnage and is
// weighed out on departure (gross_weight_kg). Container bookings assign the
// 12 specific containers each truck carries.
const isBulk = booking.freightType === 'BULK';
const requested = isBulk
? []
: (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
// Both import and export specify the containers each truck carries. Capacity
// is size-based: a 40ft container fills the truck (max 1); two 20ft containers
// fit (max 2), no size mixing. #trucks <= #containers follows naturally since
// each container is assigned to exactly one truck.
if (requested.length < 1) {
// Container capacity is size-based: a 40ft container fills the truck (max 1);
// two 20ft containers fit (max 2), no size mixing. #trucks <= #containers
// follows naturally since each container is assigned to exactly one truck.
if (!isBulk && requested.length < 1) {
throw new BadRequestException('Select at least one container for this truck');
}
if (requested.length > 2) {
@@ -312,18 +328,21 @@ export class CustomerTruckService {
if (assignment.departedAt) {
throw new ConflictException('This truck has already left — its load is locked');
}
// Containers can only be loaded after the truck has physically arrived at the
// warehouse (arrival weighing recorded). Assignment alone is just planning.
if (!assignment.arrivedAt) {
throw new BadRequestException(
'Record the truck arrival before loading — containers can only be loaded onto an arrived truck',
);
}
// Loading a truck at the warehouse implies it is physically present, so a
// truck that is still only assigned (not yet marked arrived) is auto-arrived
// here rather than blocking the operator — the real gross is weighed on
// departure anyway.
const needsArrival = !assignment.arrivedAt;
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (!requested.length) {
throw new BadRequestException('Select at least one container to load onto the truck');
}
// Capacity is size-based: a truck carries at most 2 containers, and a 40ft
// container fills the truck (max 1) — mirror the addTruck/updateTruck rule.
if (requested.length > 2) {
throw new BadRequestException('A truck carries at most 2 containers');
}
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
for (const n of requested) {
if (!bookingNumbers.includes(n)) {
@@ -336,6 +355,12 @@ export class CustomerTruckService {
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
}
}
const sizes = await this.containerSizes(bookingId, requested);
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
throw new BadRequestException(
'A 40ft container fills the truck — load only 1 container onto this truck',
);
}
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
await this.dataSource.transaction(async (manager) => {
@@ -355,9 +380,20 @@ export class CustomerTruckService {
);
// Provisional gross (tonnes) from the loaded containers' VGM — overridden
// by the weighed gross on departure. (Column is *_kg but holds tonnes.)
// Auto-stamp arrival if the truck was still only assigned.
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
grossWeightKg: grossTons,
...(needsArrival ? { arrivedAt: new Date() } : {}),
});
if (needsArrival) {
await manager.query(
`UPDATE freight.bookings
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
updated_at = NOW()
WHERE id = $1`,
[bookingId],
);
}
});
return this.listTrucks(bookingId);
}
@@ -395,20 +431,74 @@ export class CustomerTruckService {
});
if (!container) return;
const assignment = await m
.getRepository(CustomerTruckAssignment)
.findOne({ where: { id: container.assignmentId } });
const justArrived = Boolean(assignment) && !assignment?.arrivedAt;
await m
.getRepository(CustomerTruckAssignment)
.update({ id: container.assignmentId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
await this.syncBookingArrival(bookingId, m);
if (justArrived && assignment) {
await this.notifyTruckArrival(bookingId, assignment.plateNumber, m);
}
}
/** Mark every truck on the booking arrived (fallback when no container is known). */
async markAllArrived(bookingId: string, manager?: EntityManager): Promise<void> {
const m = manager ?? this.dataSource.manager;
const justArrived = await m
.getRepository(CustomerTruckAssignment)
.find({ where: { bookingId, arrivedAt: IsNull() } });
await m
.getRepository(CustomerTruckAssignment)
.update({ bookingId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
await this.syncBookingArrival(bookingId, m);
for (const truck of justArrived) {
await this.notifyTruckArrival(bookingId, truck.plateNumber, m);
}
}
/**
* Best-effort truck-arrival notification to the booking's company across every
* channel: in-app (portal inbox) + SMS + email. Never throws — a missing
* provider or contact must not break the arrival flow.
*/
private async notifyTruckArrival(
bookingId: string,
plateNumber: string | null,
m: EntityManager,
): Promise<void> {
try {
const [booking]: Array<{ companyId: string | null; reference: string | null }> =
await m.query(
`SELECT company_id AS "companyId", reference
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!booking?.companyId) return;
const ref = booking.reference ?? bookingId;
const truck = plateNumber ? `Truck ${plateNumber}` : 'A customer truck';
const body = `${truck} has arrived at the terminal for booking ${ref}.`;
await this.inbox.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title: 'Truck arrived',
body,
link: `/bookings/${bookingId}`,
data: { bookingId, plateNumber, action: 'TRUCK_ARRIVED' },
});
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
} catch (err) {
this.logger.warn(
`Truck-arrival notify failed for ${bookingId}: ${(err as Error).message}`,
);
}
}
/**
@@ -430,6 +520,7 @@ export class CustomerTruckService {
private async loadBookingGuard(bookingId: string): Promise<BookingGuardRow> {
const [row]: BookingGuardRow[] = await this.dataSource.query(
`SELECT trade_direction AS "tradeDirection",
freight_type AS "freightType",
first_mile_pickup_address AS "firstMile",
last_mile_delivery_address AS "lastMile",
payment_status AS "paymentStatus",
@@ -463,6 +554,30 @@ export class CustomerTruckService {
}
}
/**
* Assignment window by direction:
* - IMPORT: pickup trucks are assigned only AFTER the train has arrived.
* - EXPORT / DOMESTIC: delivery trucks are assigned only BEFORE the cargo is
* loaded onto the train (booking still PAID / TRUCK_ASSIGNED). Once loaded
* (IN_TRANSIT and beyond) assignment is closed.
*/
private assertAssignmentWindow(booking: BookingGuardRow): void {
const status = booking.status ?? '';
if (booking.tradeDirection === 'IMPORT') {
if (status !== 'ARRIVED') {
throw new BadRequestException(
'Import pickup trucks can only be assigned after the train has arrived',
);
}
return;
}
if (!['PAID', 'TRUCK_ASSIGNED'].includes(status)) {
throw new BadRequestException(
'Export delivery trucks can only be assigned before the cargo is loaded onto the train',
);
}
}
private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber"

View File

@@ -27,9 +27,6 @@ export class BookingReferenceContainerTypeDto {
@ApiProperty()
is_reefer!: boolean;
@ApiProperty({ example: 0.5, description: 'Wagon fraction per container' })
wagons_per_unit!: number;
}
export class BookingReferenceContainerSizeGroupDto {

View File

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

View File

@@ -1,9 +1,11 @@
import { ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator';
import { ArrayMaxSize, ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator';
/** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */
export class LoadCustomerTruckDto {
@IsArray()
@ArrayMinSize(1)
// A truck carries at most 2 containers (two 20ft, or one 40ft).
@ArrayMaxSize(2)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
each: true,

View File

@@ -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;

View File

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

View File

@@ -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`,
);
}
}
}

View File

@@ -34,7 +34,10 @@ import {
ResponseCompanyDto,
ResponseCompanyProfileDto,
} from "./dto/response-company.dto";
import { ProfileLicenseFileView } from "./entities/company-profile.entity";
import {
CompanyDocumentFileView,
ProfileLicenseFileView,
} from "./entities/company-profile.entity";
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto";
@@ -218,7 +221,7 @@ export class CompaniesController {
@Post("company-profile")
@ApiOperation({
summary:
"Create a single operational profile for the current user's company and make it the active mode",
"Create a single operational profile for the current user's company. The role starts pending and does not become the active mode",
})
async createCompanyProfile(
@CurrentUser() user: CurrentIamUser,
@@ -306,6 +309,49 @@ export class CompaniesController {
return this.companiesService.listProfileLicenseFiles(user.id, profileId);
}
@Get("poa-delegation")
@ApiOperation({
summary:
"List the Power of Attorney delegation letter (with review state) for the current user's company",
})
async listPoaDelegation(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyDocumentFileView[]> {
return this.companiesService.listPoaDelegationFiles(user.id);
}
@Post("poa-delegation")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"Upload the Power of Attorney delegation letter, replacing any existing one. " +
"For an approved company the upload is staged for backoffice review; during " +
"onboarding it goes live.",
})
async uploadPoaDelegation(
@CurrentUser() user: CurrentIamUser,
@UploadedFiles() files: Array<Express.Multer.File>,
): Promise<CompanyDocumentFileView[]> {
const file = files?.[0];
if (!file) {
throw new BadRequestException("A delegation letter file is required");
}
return this.companiesService.uploadPoaDelegationLetter(user.id, file);
}
@Delete("poa-delegation/:fileId")
@ApiOperation({
summary:
"Remove the Power of Attorney delegation letter (staged for review on an approved company).",
})
async removePoaDelegation(
@CurrentUser() user: CurrentIamUser,
@Param("fileId", ParseUUIDPipe) fileId: string,
): Promise<CompanyDocumentFileView[]> {
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
}
@Patch("active-mode")
@ApiOperation({
summary: "Switch the current user's active operational mode (importer/exporter)",

View File

@@ -1,9 +1,11 @@
import { Module } from "@nestjs/common";
import { Module, forwardRef } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { HttpModule } from "@nestjs/axios";
import { FilesModule } from "../files/files.module";
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module";
import { MinioModule } from "../minio/minio.module";
import { NotificationsModule } from "../notifications/notifications.module";
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
import { CompaniesController } from "./companies.controller";
import { CompaniesService } from "./companies.service";
import { CompaniesRepository } from "./companies.repository";
@@ -17,6 +19,7 @@ import { Booking } from "../bookings/entities/booking.entity";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
@Module({
imports: [
@@ -31,6 +34,10 @@ import { ETradeService } from "./services/etrade.service";
FilesModule,
FileUploadSettingsModule,
MinioModule,
// Account-status notifications (CompanyNotifierService). The inbox module
// imports this module back for portal recipient targeting, hence forwardRef.
NotificationsModule,
forwardRef(() => NotificationInboxModule),
],
controllers: [CompaniesController],
providers: [
@@ -41,6 +48,7 @@ import { ETradeService } from "./services/etrade.service";
CompanyChangeRequestRepository,
CompanyDashboardRepository,
ETradeService,
CompanyNotifierService,
],
exports: [
CompaniesService,

View File

@@ -8,6 +8,27 @@ import { CompanyStatsResponseDto } from './dto/company-stats-response.dto';
@Injectable()
export class CompaniesRepository extends BaseRepository<Company> {
/**
* A company still being filled in by its owner in the portal wizard: it was
* self-registered (so it has an external profile) and nobody has submitted
* onboarding yet. The row exists from the wizard's first click, carrying a
* placeholder name + TIN, so it must not be offered up for review.
* Staff-created companies have no external profiles and are never drafts.
*/
private static readonly DRAFT_SQL = `(
EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = company.id
AND ep.deleted_at IS NULL
)
AND NOT EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = company.id
AND ep.deleted_at IS NULL
AND ep.onboarding_completed = true
)
)`;
constructor(
@InjectRepository(Company)
repo: Repository<Company>,
@@ -38,11 +59,22 @@ export class CompaniesRepository extends BaseRepository<Company> {
async findPaginated(
query: ListCompaniesQueryDto,
): Promise<{ items: Company[]; total: number }> {
const { page = 1, pageSize = 20, search, type, kind, status } = query;
const {
page = 1,
pageSize = 20,
search,
type,
kind,
status,
onboardingCompleted,
} = query;
const qb = this.repository
.createQueryBuilder('company')
.leftJoinAndSelect('company.companyProfiles', 'companyProfiles')
// External profiles carry onboardingCompleted, which the backoffice list
// uses to flag customers still mid-onboarding (not yet reviewable).
.leftJoinAndSelect('company.profiles', 'profiles')
.where('company.deleted_at IS NULL');
if (type) {
@@ -57,6 +89,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
qb.andWhere('company.status = :status', { status });
}
if (onboardingCompleted !== undefined) {
qb.andWhere(
onboardingCompleted
? `NOT ${CompaniesRepository.DRAFT_SQL}`
: CompaniesRepository.DRAFT_SQL,
);
}
if (search) {
const term = `%${search.trim()}%`;
qb.andWhere(
@@ -83,21 +123,35 @@ export class CompaniesRepository extends BaseRepository<Company> {
}
async getStats(): Promise<CompanyStatsResponseDto> {
const rows: { status: string; count: string }[] = await this.repository
.createQueryBuilder('company')
.select('company.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('company.deleted_at IS NULL')
.groupBy('company.status')
.getRawMany();
// Drafts are counted separately rather than under `pending`: they carry
// status=pending from creation, which would otherwise inflate the review
// queue's KPI with customers who haven't submitted anything yet.
const rows: { status: string; is_draft: boolean; count: string }[] =
await this.repository
.createQueryBuilder('company')
.select('company.status', 'status')
.addSelect(CompaniesRepository.DRAFT_SQL, 'is_draft')
.addSelect('COUNT(*)', 'count')
.where('company.deleted_at IS NULL')
.groupBy('company.status')
.addGroupBy(CompaniesRepository.DRAFT_SQL)
.getRawMany();
const map = new Map(rows.map((r) => [r.status, parseInt(r.count, 10)]));
const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
const map = new Map<string, number>();
let onboarding = 0;
let total = 0;
for (const row of rows) {
const count = parseInt(row.count, 10);
total += count;
if (row.is_draft) onboarding += count;
else map.set(row.status, (map.get(row.status) ?? 0) + count);
}
return {
total,
active: map.get('active') ?? 0,
pending: map.get('pending') ?? 0,
onboarding,
suspended: map.get('suspended') ?? 0,
blacklisted: map.get('blacklisted') ?? 0,
};

View File

@@ -17,6 +17,7 @@ import { FilesService } from "../files/files.service";
import { FileRecord } from "../files/entities/file.entity";
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
import { CreateCompanyDto } from "./dto/create-company.dto";
@@ -37,6 +38,7 @@ import {
import { ExternalProfile } from "./entities/external-profile.entity";
import {
BusinessLicenseFile,
CompanyDocumentFileView,
CompanyProfile,
ProfileLicenseFileView,
ProfileType,
@@ -45,6 +47,7 @@ import {
import {
ChangeRequestStatus,
CompanyChangeRequest,
DocumentChangeIntent,
LicenseChangeIntent,
} from "./entities/company-change-request.entity";
@@ -54,6 +57,27 @@ const LICENSE_CODE = "business_license";
/** Code for a license file staged in an open change request (not yet live). */
const LICENSE_PENDING_CODE = "business_license_pending";
/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */
const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
/** Code for a PoA letter staged in an open change request (not yet live). */
const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
/** FileRecord resource that company-level documents are stored under. */
const COMPANY_RESOURCE = "companies";
/** company.attributes keys that together mean "a PoA was entered". */
const POA_ATTRIBUTES = [
"poaName",
"poaPhone",
"poaEmail",
"poaLocation",
"poaAddress",
] as const;
/** Mandatory once the company operates as a freight forwarder. */
const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
{ key: "poaName", label: "PoA name" },
{ key: "poaEmail", label: "PoA email" },
{ key: "poaPhone", label: "PoA phone" },
];
export interface UserIdentity {
userId: string;
firstName: string;
@@ -73,6 +97,7 @@ export class CompaniesService {
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
private readonly etradeService: ETradeService,
private readonly companyNotifier: CompanyNotifierService,
) { }
/**
@@ -347,6 +372,9 @@ export class CompaniesService {
const company = await this.companiesRepo.findById(id);
if (!company) throw new NotFoundException(`Company ${id} not found`);
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
// External profiles carry the onboarding flag the backoffice gates
// approval decisions on (see ResponseCompanyDto.onboardingCompleted).
company.profiles = await this.profilesRepo.findByCompanyId(id);
return company;
}
@@ -562,9 +590,13 @@ export class CompaniesService {
}
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
await this.findCompanyById(id);
const before = await this.findCompanyById(id);
const updated = await this.companiesRepo.update(id, dto);
if (!updated) throw new NotFoundException(`Company ${id} not found`);
// Suspending or blacklisting locks the customer out, so they must be told.
// This is the only path that writes those statuses.
this.companyNotifier.statusChanged(updated, before.status);
return updated;
}
@@ -765,6 +797,7 @@ export class CompaniesService {
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot);
await this.companiesRepo.update(company.id, companyUpdates);
await this.applyLicenseChanges(request);
await this.applyDocumentChanges(request);
return (
(await this.changeRequestRepo.update(id, {
@@ -817,7 +850,12 @@ export class CompaniesService {
if (existing) {
const prev = existing.documents?.documentFileIds ?? [];
await this.changeRequestRepo.update(existing.id, {
documents: { documentFileIds: [...prev, ...fileIds] },
// Spread the existing documents blob: a bare object would drop any
// licenseChanges/documentChanges already staged on this request.
documents: {
...existing.documents,
documentFileIds: [...prev, ...fileIds],
},
submittedBy: submittedBy ?? existing.submittedBy ?? null,
submittedAt: now,
note: null,
@@ -849,12 +887,17 @@ export class CompaniesService {
);
}
await this.discardLicenseChanges(request);
await this.discardDocumentChanges(request);
return (
(await this.changeRequestRepo.update(id, {
status: ChangeRequestStatus.Rejected,
// Staged license uploads were just discarded; drop their intents so an
// amended resubmit never re-references deleted files.
documents: { ...request.documents, licenseChanges: [] },
// Staged license/document uploads were just discarded; drop their intents
// so an amended resubmit never re-references deleted files.
documents: {
...request.documents,
licenseChanges: [],
documentChanges: [],
},
note,
reviewedBy: reviewerId ?? null,
reviewedAt: new Date(),
@@ -922,6 +965,28 @@ export class CompaniesService {
if (!existing)
throw new NotFoundException(`Company profile ${profileId} not found`);
// A self-registered company is only reviewable once its owner submits the
// onboarding wizard (markOnboardingComplete) — until then its profiles are
// half-filled drafts and approving one would mint a reference against an
// application that doesn't exist yet. Staff-created companies have no
// external profiles and are exempt.
//
// Only the review decision itself is gated (a profile still awaiting one:
// Pending, or Rejected and awaiting re-approval). Profiles already in
// service stay managable so staff can suspend/blacklist them — including to
// undo an approval granted before this guard existed.
const awaitingReview =
existing.status === ProfileStatus.Pending ||
existing.status === ProfileStatus.Rejected;
if (awaitingReview) {
const owners = await this.profilesRepo.findByCompanyId(existing.companyId);
if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) {
throw new BadRequestException(
"This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.",
);
}
}
// A reference number is only minted the first time a profile is approved
// (status → Active). Pending/unapproved profiles carry no reference.
const patch: Partial<CompanyProfile> = { status };
@@ -1017,13 +1082,12 @@ export class CompaniesService {
);
}
const reference = await this.companyProfilesRepo.generateReference(type);
// No reference is minted here: it is issued by setCompanyProfileStatus when
// a reviewer approves the role. Creating it Active would bypass that review.
return this.companyProfilesRepo.create({
companyId,
type,
reference,
status: ProfileStatus.Active,
status: ProfileStatus.Pending,
});
}
@@ -1097,9 +1161,11 @@ export class CompaniesService {
}
/**
* Create a single operational profile for the current user's company and
* make it the active mode in the same call. Powers the header "Switch to
* Exporter/Importer" flow when the target profile doesn't exist yet.
* Create a single operational profile for the current user's company. The new
* role starts Pending, so it deliberately does NOT become the active mode:
* switching onto an unapproved profile would strip the user of `canBook` and
* block them from creating contracts under the role they already had approved.
* Callers switch explicitly via {@link setActiveMode} once the role is Active.
*/
async createCompanyProfileForUser(
userId: string,
@@ -1122,8 +1188,7 @@ export class CompaniesService {
let created = await this.companyProfilesRepo.findByType(companyId, type);
if (!created) {
// New self-service roles start Pending (awaiting backoffice approval) and
// carry no reference until approved. The customer can select this mode but
// can't book under it until it's cleared.
// carry no reference until approved.
created = await this.companyProfilesRepo.create({
companyId,
type,
@@ -1132,8 +1197,6 @@ export class CompaniesService {
});
}
await this.profilesRepo.update(profile.id, { activeProfileType: type });
return created;
}
@@ -1240,6 +1303,29 @@ export class CompaniesService {
);
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
// 4. Power of Attorney. Optional in general, but a freight forwarder acts on
// other companies' behalf so its PoA is mandatory. Either way, a PoA that
// has been entered must be evidenced by the delegation letter.
const poaRequired = (company.companyProfiles ?? []).some(
(p) => p.type === ProfileType.freightForwarder,
);
const poaProvided = POA_ATTRIBUTES.some((k) =>
(company.attributes?.[k] as string | undefined)?.trim(),
);
const missingPoaFields = poaRequired
? REQUIRED_POA_FIELDS.filter(
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
)
: [];
// Only gate on the letter once the document set actually carries the field.
const delegationField = (setting?.fields ?? []).find(
(f) => f.fileKey === POA_DELEGATION_FILE_KEY,
);
const missingDelegation =
Boolean(delegationField) &&
(poaRequired || poaProvided) &&
!uploadedCodes.has(POA_DELEGATION_FILE_KEY);
const outstanding = [
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
@@ -1247,18 +1333,31 @@ export class CompaniesService {
(p) =>
`Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
),
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
...(missingDelegation
? ["Upload the delegation letter for your Power of Attorney"]
: []),
];
// Progress spans every required item the user has to satisfy: company-info
// fields, required documents and one license per operational profile.
// fields, required documents, one license per operational profile, and the
// PoA details/letter whenever those are mandatory.
const requiredDocCount = documents.filter((d) => d.isRequired).length;
const poaItemCount =
(poaRequired ? REQUIRED_POA_FIELDS.length : 0) +
(delegationField && (poaRequired || poaProvided) ? 1 : 0);
const total =
this.REQUIRED_COMPANY_INFO.length +
requiredDocCount +
licenseProfiles.length;
licenseProfiles.length +
poaItemCount;
const completed =
total -
(missingInfo.length + missingDocs.length + missingLicenses.length);
(missingInfo.length +
missingDocs.length +
missingLicenses.length +
missingPoaFields.length +
(missingDelegation ? 1 : 0));
return new OnboardingRequirementsResponseDto({
documentSettingCode,
@@ -1266,6 +1365,13 @@ export class CompaniesService {
companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo },
documents,
licenseProfiles,
poa: {
required: poaRequired,
provided: poaProvided,
delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY),
missingFields: missingPoaFields,
complete: missingPoaFields.length === 0 && !missingDelegation,
},
progress: { completed, total },
isComplete: outstanding.length === 0,
onboardingCompleted: profile.onboardingCompleted,
@@ -1365,10 +1471,12 @@ export class CompaniesService {
// browser (which fails on the internal bucket endpoint).
/**
* Upload business-license file(s) for one of the user's profiles. During
* onboarding (company not yet Active) they go live immediately; for an Active
* company they're staged under the pending code and recorded as `add` intents
* on a pending change request for backoffice review. Returns the updated view.
* Upload business-license file(s) for one of the user's profiles. For a role
* not yet approved (a fresh onboarding profile, or a newly added service on an
* already-active company) they go live immediately and are reviewed together
* with the role itself. Only for an already-approved role are they staged under
* the pending code and recorded as `add` intents on a pending change request —
* a licence swap on a live role is a change; a licence on a new role is not.
*/
async addProfileLicenseFiles(
userId: string,
@@ -1377,7 +1485,7 @@ export class CompaniesService {
): Promise<ProfileLicenseFileView[]> {
const profile = await this.resolveOwnedProfile(userId, profileId);
const company = await this.findCompanyById(profile.companyId);
const gated = company.status === CompanyStatus.Active;
const gated = profile.status === ProfileStatus.Active;
const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
const uploaded = await Promise.all(
@@ -1409,9 +1517,9 @@ export class CompaniesService {
/**
* Remove a license file. A staged (pending) file is withdrawn outright
* (soft-deleted, its `add` intent dropped). A live file on an Active company
* is kept and recorded as a `remove` intent for review; during onboarding it
* is deleted immediately.
* (soft-deleted, its `add` intent dropped). A live file on an already-approved
* role is kept and recorded as a `remove` intent for review; on a role still
* awaiting approval it is deleted immediately.
*/
async removeProfileLicenseFile(
userId: string,
@@ -1427,7 +1535,7 @@ export class CompaniesService {
throw new NotFoundException(`License file ${fileId} not found`);
}
const company = await this.findCompanyById(profile.companyId);
const gated = company.status === CompanyStatus.Active;
const gated = profile.status === ProfileStatus.Active;
if (record.code === LICENSE_PENDING_CODE) {
// Withdraw a not-yet-approved upload: delete it and drop its add intent.
@@ -1449,7 +1557,7 @@ export class CompaniesService {
/**
* Replace a live license file with a freshly uploaded one — recorded as a
* `remove` of the old file plus an `add` of the new, so approval swaps them
* atomically. During onboarding the swap is applied immediately.
* atomically. On a role still awaiting approval the swap is applied immediately.
*/
async replaceProfileLicenseFile(
userId: string,
@@ -1463,7 +1571,7 @@ export class CompaniesService {
throw new NotFoundException(`License file ${fileId} not found`);
}
const company = await this.findCompanyById(profile.companyId);
const gated = company.status === CompanyStatus.Active;
const gated = profile.status === ProfileStatus.Active;
const created = await this.filesService.upload({
resourceId: profileId,
@@ -1671,6 +1779,254 @@ export class CompaniesService {
}
}
// ---------------------------------------------------------------------------
// Power of Attorney delegation letter
//
// A company-level document that follows the same staged-review model as the
// business license: on an approved (Active) company an upload lands under the
// pending code and the live letter is flagged for removal, so the reviewer
// sees both and approval swaps them atomically. During onboarding it goes live.
// ---------------------------------------------------------------------------
/** The company's PoA letter(s), with each file's review status resolved. */
async listPoaDelegationFiles(
userId: string,
): Promise<CompanyDocumentFileView[]> {
const { company } = await this.getCompanyInfoByUserId(userId);
return this.getPoaDelegationView(company.id);
}
/**
* Upload the PoA delegation letter, replacing whatever is already on file.
* On an Active company this stages an `add` for the new file plus a `remove`
* for each live one; a letter still awaiting approval is withdrawn outright
* rather than stacking a second pending upload.
*/
async uploadPoaDelegationLetter(
userId: string,
file: Express.Multer.File,
): Promise<CompanyDocumentFileView[]> {
const { company } = await this.getCompanyInfoByUserId(userId);
const gated = company.status === CompanyStatus.Active;
const records = await this.filesService.findByResource(
company.id,
COMPANY_RESOURCE,
);
const live = records.filter((r) => r.code === POA_DELEGATION_FILE_KEY);
const staged = records.filter(
(r) => r.code === POA_DELEGATION_PENDING_CODE,
);
// Supersede an unreviewed upload instead of queueing another one.
for (const r of staged) {
await this.filesService.remove(r.id);
await this.withdrawDocumentIntent(company.id, r.id);
}
const created = await this.filesService.upload({
resourceId: company.id,
resource: COMPANY_RESOURCE,
code: gated ? POA_DELEGATION_PENDING_CODE : POA_DELEGATION_FILE_KEY,
file,
});
if (gated) {
await this.stageDocumentIntent(
company.id,
[
...live.map((r) => ({
op: "remove" as const,
fileId: r.id,
code: POA_DELEGATION_FILE_KEY,
fileName: r.name,
})),
{
op: "add" as const,
fileId: created.id,
code: POA_DELEGATION_FILE_KEY,
fileName: created.name,
},
],
userId,
);
} else {
// Onboarding: no review, so the old letter is simply replaced.
for (const r of live) await this.filesService.remove(r.id);
}
return this.getPoaDelegationView(company.id);
}
/**
* Remove the PoA letter. A staged upload is withdrawn outright; a live file on
* an Active company is kept and flagged for deletion on approval; during
* onboarding it is deleted immediately.
*/
async removePoaDelegationLetter(
userId: string,
fileId: string,
): Promise<CompanyDocumentFileView[]> {
const { company } = await this.getCompanyInfoByUserId(userId);
const record = await this.filesService.findById(fileId);
if (
record.resource !== COMPANY_RESOURCE ||
record.resourceId !== company.id ||
(record.code !== POA_DELEGATION_FILE_KEY &&
record.code !== POA_DELEGATION_PENDING_CODE)
) {
throw new NotFoundException(`Delegation letter ${fileId} not found`);
}
if (record.code === POA_DELEGATION_PENDING_CODE) {
await this.filesService.remove(fileId);
await this.withdrawDocumentIntent(company.id, fileId);
} else if (company.status === CompanyStatus.Active) {
await this.stageDocumentIntent(
company.id,
[
{
op: "remove",
fileId,
code: POA_DELEGATION_FILE_KEY,
fileName: record.name,
},
],
userId,
);
} else {
await this.filesService.remove(fileId);
}
return this.getPoaDelegationView(company.id);
}
private async getPoaDelegationView(
companyId: string,
): Promise<CompanyDocumentFileView[]> {
const pending =
await this.changeRequestRepo.findPendingByCompanyId(companyId);
const removeIds = new Set(
(pending?.documents?.documentChanges ?? [])
.filter((c) => c.op === "remove")
.map((c) => c.fileId),
);
const records = await this.filesService.findByResource(
companyId,
COMPANY_RESOURCE,
);
return records
.filter(
(r) =>
r.code === POA_DELEGATION_FILE_KEY ||
r.code === POA_DELEGATION_PENDING_CODE,
)
.map((r) => ({
id: r.id,
name: r.name,
size: r.size,
mimeType: r.mimeType,
status:
r.code === POA_DELEGATION_PENDING_CODE
? ("pending_add" as const)
: removeIds.has(r.id)
? ("pending_remove" as const)
: ("live" as const),
}));
}
/** Open or append a pending change request recording document add/remove intents. */
private async stageDocumentIntent(
companyId: string,
changes: DocumentChangeIntent[],
submittedBy?: string,
): Promise<void> {
if (changes.length === 0) return;
const now = new Date();
const existing =
await this.changeRequestRepo.findPendingByCompanyId(companyId);
if (existing) {
const prev = existing.documents?.documentChanges ?? [];
// Re-uploading twice before review would otherwise stage a second `remove`
// for the same live file, and the duplicate would fail on approval.
const seen = new Set(prev.map((c) => `${c.op}:${c.fileId}`));
const fresh = changes.filter((c) => !seen.has(`${c.op}:${c.fileId}`));
if (fresh.length === 0) return;
await this.changeRequestRepo.update(existing.id, {
documents: {
...existing.documents,
documentChanges: [...prev, ...fresh],
},
submittedBy: submittedBy ?? existing.submittedBy ?? null,
submittedAt: now,
note: null,
});
} else {
await this.changeRequestRepo.create({
companyId,
snapshot: {},
documents: { documentChanges: changes },
status: ChangeRequestStatus.Pending,
submittedBy: submittedBy ?? null,
submittedAt: now,
});
}
}
/**
* Drop a staged document intent referencing `fileId`. If that empties the
* request entirely, delete it so the customer's settings page unlocks.
*/
private async withdrawDocumentIntent(
companyId: string,
fileId: string,
): Promise<void> {
const existing =
await this.changeRequestRepo.findPendingByCompanyId(companyId);
if (!existing) return;
const remaining = (existing.documents?.documentChanges ?? []).filter(
(c) => c.fileId !== fileId,
);
const docs = existing.documents ?? {};
const stillHasWork =
remaining.length > 0 ||
(docs.licenseChanges?.length ?? 0) > 0 ||
(docs.documentFileIds?.length ?? 0) > 0 ||
Object.keys(existing.snapshot ?? {}).length > 0;
if (stillHasWork) {
await this.changeRequestRepo.update(existing.id, {
documents: { ...docs, documentChanges: remaining },
});
} else {
await this.changeRequestRepo.softDelete(existing.id);
}
}
/** Apply a request's staged document changes: promote adds, delete removes. */
private async applyDocumentChanges(
request: CompanyChangeRequest,
): Promise<void> {
for (const change of request.documents?.documentChanges ?? []) {
if (change.op === "add") {
await this.filesService.setCode(change.fileId, change.code);
} else {
await this.filesService.remove(change.fileId);
}
}
}
/** Discard a rejected request's staged document uploads (adds only). */
private async discardDocumentChanges(
request: CompanyChangeRequest,
): Promise<void> {
for (const change of request.documents?.documentChanges ?? []) {
if (change.op === "add") {
await this.filesService.remove(change.fileId);
}
}
}
/**
* Resolve which company_profile a new booking belongs to, from the company
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
@@ -1719,13 +2075,17 @@ export class CompaniesService {
}
async fetchETradeData(tin: string) {
const { businessInfo } = await this.etradeService.resolveCompanyData(tin);
const { businessInfo, companyInfo } =
await this.etradeService.resolveCompanyData(tin);
if (!businessInfo) {
throw new BadRequestException(
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
);
}
const registrationData = this.etradeService.extractRegistrationData(businessInfo);
const registrationData = this.etradeService.extractRegistrationData(
businessInfo,
companyInfo,
);
const tinTaken = await this.companiesRepo.existsByTin(tin);
return { ...registrationData, tinTaken };
}

View File

@@ -0,0 +1,91 @@
import { Injectable, Logger } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } from "typeorm";
import {
NotificationAudience,
NotificationPriority,
NotificationType,
} from "@edr/types";
import { Company, CompanyStatus } from "./entities/company.entity";
import { NotificationsService } from "../notifications/notifications.service";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util";
/** Account statuses that lock the customer out and therefore must be told to them. */
const PUNITIVE_STATUSES: readonly CompanyStatus[] = [
CompanyStatus.Suspended,
CompanyStatus.Blacklisted,
];
/**
* Customer notifications for company account-status changes. Mirrors
* {@link ContractNotifierService}: SMS + email direct to the company contact,
* plus a persisted in-app item. Every send is fire-and-forget and never throws —
* a notification failure must not roll back the status change itself.
*/
@Injectable()
export class CompanyNotifierService {
private readonly logger = new Logger(CompanyNotifierService.name);
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
/** Send SMS + email to the company contact; log-only on failure. */
private async notifyContact(company: Company, message: string): Promise<void> {
const phone = await resolveCompanyNotifyPhone(this.dataSource, company.id);
const email = company.email ?? company.generalManagerEmail ?? null;
if (phone) {
try {
await this.notifications.directSend("sms", phone, message);
} catch (err) {
this.logger.warn(`SMS failed for ${company.id}: ${(err as Error).message}`);
}
}
if (email) {
try {
await this.notifications.directSend("email", email, message);
} catch (err) {
this.logger.warn(`Email failed for ${company.id}: ${(err as Error).message}`);
}
}
if (!phone && !email) {
this.logger.warn(`No contact on file for ${company.id} — not notified`);
}
}
/**
* Tell the customer their account was suspended or blacklisted. Called only on
* a real transition into one of those statuses; other status writes are silent.
*/
statusChanged(company: Company, previous: CompanyStatus): void {
const status = company.status;
if (status === previous) return;
if (!PUNITIVE_STATUSES.includes(status)) return;
const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted";
const title = `Account ${label}`;
const body =
`Your company account has been ${label}. ` +
`You will not be able to submit new contracts or bookings. ` +
`Please contact EDR support for assistance.`;
this.logger.log(`ACCOUNT_${label.toUpperCase()}${company.id}`);
void this.notifyContact(company, `${title}. ${body}`);
void this.inbox.notify({
recipients: { companyId: company.id },
audience: NotificationAudience.PORTAL,
type: NotificationType.ACCOUNT_STATUS,
title,
body,
link: "/settings",
data: { companyId: company.id, status },
priority: NotificationPriority.HIGH,
});
}
}

View File

@@ -1,6 +1,7 @@
import {
ChangeRequestStatus,
CompanyChangeRequest,
DocumentChangeIntent,
LicenseChangeIntent,
} from "../entities/company-change-request.entity";
@@ -18,6 +19,8 @@ export class ChangeRequestResponseDto {
documentFileIds: string[];
/** Staged business-license add/remove intents attached to this request. */
licenseChanges: LicenseChangeIntent[];
/** Staged company-document add/remove intents (e.g. the PoA letter). */
documentChanges: DocumentChangeIntent[];
note: string | null;
submittedBy: string | null;
submittedAt: Date | null;
@@ -33,6 +36,7 @@ export class ChangeRequestResponseDto {
this.snapshot = req.snapshot ?? {};
this.documentFileIds = req.documents?.documentFileIds ?? [];
this.licenseChanges = req.documents?.licenseChanges ?? [];
this.documentChanges = req.documents?.documentChanges ?? [];
this.note = req.note ?? null;
this.submittedBy = req.submittedBy ?? null;
this.submittedAt = req.submittedAt ?? null;

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