Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice

This commit is contained in:
natib21
2026-07-07 08:33:25 +00:00
167 changed files with 9826 additions and 1225 deletions

View File

@@ -1,20 +1,33 @@
import type { ScheduleTradeDirection } from '@edr/types';
import { YardCountry, type ScheduleTradeDirection } from '@edr/types';
type YardLike = { country?: string | null };
/** Derive booking/schedule trade direction from origin and destination yard countries. */
/**
* Derive trade direction from origin and destination yard countries.
* Ethiopia → Djibouti = EXPORT, Djibouti → Ethiopia = IMPORT, same country =
* DOMESTIC (shown as "Intercity"; scheduling/contracts reject it for now).
* Comparison is strict against the YardCountry enum values the yards table is
* constrained to; the trim/case fold only shields legacy rows.
*/
export function deriveTradeDirection(
originYard: YardLike,
destinationYard: YardLike,
): ScheduleTradeDirection {
const originCountry = originYard.country?.trim().toLowerCase();
const destinationCountry = destinationYard.country?.trim().toLowerCase();
const origin = normalizeCountry(originYard.country);
const destination = normalizeCountry(destinationYard.country);
if (originCountry === 'djibouti') {
if (origin === YardCountry.DJIBOUTI && destination === YardCountry.ETHIOPIA) {
return 'IMPORT';
}
if (destinationCountry === 'djibouti' && originCountry !== 'djibouti') {
if (origin === YardCountry.ETHIOPIA && destination === YardCountry.DJIBOUTI) {
return 'EXPORT';
}
return 'DOMESTIC';
}
function normalizeCountry(country: string | null | undefined): YardCountry | null {
const folded = country?.trim().toLowerCase();
if (folded === YardCountry.ETHIOPIA.toLowerCase()) return YardCountry.ETHIOPIA;
if (folded === YardCountry.DJIBOUTI.toLowerCase()) return YardCountry.DJIBOUTI;
return null;
}

View File

@@ -105,9 +105,14 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
password: process.env.DB_PASSWORD ?? "",
database: process.env.DB_NAME ?? "edr_freight",
schema: "public",
extra: {
options: `-c search_path=${APPLICATION_SEARCH_PATH}`,
},
// The `-c search_path=...` startup option is rejected by transaction-pooling
// poolers (e.g. PgBouncer: "unsupported startup parameter in options"). When
// behind such a pooler set DB_PGBOUNCER=true and instead make the search_path
// a role default: ALTER ROLE <user> IN DATABASE <db> SET search_path TO
// public,iam,freight,audit;
...(process.env.DB_PGBOUNCER === "true"
? {}
: { extra: { options: `-c search_path=${APPLICATION_SEARCH_PATH}` } }),
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
autoLoadEntities: true,
migrations: [

View File

@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Structured import handover records. Replaces the ad-hoc handover notes so a
* booking can carry one handover (single truck) or several (one per truck when
* multiple trucks are used). Timing differs by mile type:
* - SELF_HAUL: generated on first truck arrival, signed before the truck leaves.
* - EDR_LAST_MILE: generated at delivery (after exit); signed on delivery.
*/
export class AddBookingHandovers1980000000000 implements MigrationInterface {
name = 'AddBookingHandovers1980000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.booking_handovers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
truck_assignment_id uuid REFERENCES freight.customer_truck_assignments(id) ON DELETE SET NULL,
truck_plate varchar(32),
mile_type varchar(20) NOT NULL,
reference varchar(100) NOT NULL,
generated_at timestamptz NOT NULL DEFAULT now(),
signed_at timestamptz,
signed_by_user_id uuid,
delivered_at timestamptz,
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_booking_handovers_booking" ON freight.booking_handovers (booking_id);`,
);
// At most one live handover per (booking, customer truck). EDR trucks (which
// aren't customer_truck_assignments) and per-booking handovers are de-duped
// in the service, since a NULL truck_assignment_id can't be uniquely indexed.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_booking_handovers_booking_truck"
ON freight.booking_handovers (booking_id, truck_assignment_id)
WHERE deleted_at IS NULL AND truck_assignment_id IS NOT NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_handovers;`);
}
}

View File

@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Yard country becomes a two-value enum (Ethiopia | Djibouti) and every route
* freezes its trade direction from the yard countries:
* Ethiopia → Djibouti = EXPORT, Djibouti → Ethiopia = IMPORT,
* same country = DOMESTIC (shown as "Intercity"; disabled for scheduling
* and contracts for now).
*
* Existing yard rows are normalized case-insensitively; anything mentioning
* Djibouti maps there, everything else maps to Ethiopia (the line only serves
* these two countries). A CHECK constraint keeps future writes honest.
*/
export class YardCountryEnumAndRouteDirection1980000000000 implements MigrationInterface {
name = 'YardCountryEnumAndRouteDirection1980000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.yards
SET country = CASE
WHEN lower(trim(country)) LIKE '%djib%' THEN 'Djibouti'
ELSE 'Ethiopia'
END
`);
await queryRunner.query(`
ALTER TABLE freight.yards
DROP CONSTRAINT IF EXISTS chk_yards_country,
ADD CONSTRAINT chk_yards_country CHECK (country IN ('Ethiopia', 'Djibouti'))
`);
await queryRunner.query(`
ALTER TABLE freight.routes
ADD COLUMN IF NOT EXISTS direction varchar(10)
`);
await queryRunner.query(`
UPDATE freight.routes r
SET direction = CASE
WHEN o.country = 'Djibouti' AND d.country = 'Ethiopia' THEN 'IMPORT'
WHEN o.country = 'Ethiopia' AND d.country = 'Djibouti' THEN 'EXPORT'
ELSE 'DOMESTIC'
END
FROM freight.yards o, freight.yards d
WHERE o.id = r.origin_yard_id
AND d.id = r.destination_yard_id
`);
// Orphan origin/destination (deleted yard) — no way to classify; park as
// DOMESTIC, which is blocked everywhere, so nothing can schedule on it.
await queryRunner.query(`
UPDATE freight.routes SET direction = 'DOMESTIC' WHERE direction IS NULL
`);
await queryRunner.query(`
ALTER TABLE freight.routes
ALTER COLUMN direction SET NOT NULL,
DROP CONSTRAINT IF EXISTS chk_routes_direction,
ADD CONSTRAINT chk_routes_direction CHECK (direction IN ('IMPORT', 'EXPORT', 'DOMESTIC'))
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.routes
DROP CONSTRAINT IF EXISTS chk_routes_direction,
DROP COLUMN IF EXISTS direction
`);
await queryRunner.query(`
ALTER TABLE freight.yards DROP CONSTRAINT IF EXISTS chk_yards_country
`);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Double-handling fee support. warehouse_fee_rules.basis: how a
* DOUBLE_HANDLING_FEE rule is charged — PER_CONTAINER | PER_TON | PER_ITEM
* (null for the day-based fee types). The PER_TON / PER_ITEM quantity comes from
* the booking's cargo total (cargo_total_weight_vgm, expressed in the cargo's
* unit of measure), so no new booking column is needed.
*/
export class AddDoubleHandlingBasisAndMachinery1990000000000 implements MigrationInterface {
name = 'AddDoubleHandlingBasisAndMachinery1990000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS basis varchar(20)`,
);
// machinery_units is not used (PER_ITEM reads cargo_total_weight_vgm); drop it
// if a prior version of this migration added it.
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS machinery_units`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS basis`);
}
}

View File

@@ -8,7 +8,11 @@ import { hashPassword } from "@tria-plc/api-common/utils/argon";
import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm";
import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common";
// Subpath imports (not the package root) so ts-jest can resolve them when this
// file lands in a spec's compile graph via the notification recipients chain.
import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity";
import { Organization } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization.entity";
import { UserCredential } from "@tria-plc/iamapi-common/entities/iam/user/user-credential.entity";
import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity";
import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
@@ -40,6 +44,21 @@ export class BackofficeService {
private readonly dataSource: DataSource,
) {}
/**
* IAM user ids of every current employee across all organizations — used by
* the notification recipients resolver's `allBackoffice` selector.
*/
async getAllCurrentEmployeeUserIds(): Promise<string[]> {
const employees = await this.employeeRepository.find({
where: { isCurrent: true },
});
return [
...new Set(
employees.map((e) => e.userId).filter((id): id is string => Boolean(id)),
),
];
}
async createOrganizationUser(
organizationId: string,
dto: CreateOrganizationUserDto,

View File

@@ -1,6 +1,16 @@
import { Injectable } from "@nestjs/common";
import { PdfRenderService } from "./pdf-render.service";
import {
PdfColor,
assembleSinglePagePdf,
lineOp,
rectOp,
sealOp,
textOp,
textOpRight,
wrapText,
} from "./styled-pdf.util";
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
@@ -62,10 +72,136 @@ export class InvoiceDocumentService {
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
return {
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
buffer: await this.pdf.htmlToPdfBuffer(html, { label: `${model.title} ${kindLabel}` }),
buffer: await this.pdf.htmlToPdfBuffer(html, {
label: `${model.title} ${kindLabel}`,
// Chromium-less fallback: draw a genuine styled invoice (header, seal,
// summary grid, line-item table, totals) from the model — not a flat
// plain-text dump — so it still reads as a proper invoice document.
fallback: () => this.buildFallbackPdf(model),
}),
};
}
/**
* Vector-drawn styled invoice/receipt used when headless Chromium is
* unavailable. Mirrors the HTML layout closely enough to pass as the same
* document. Single A4 page; long summaries / line lists are capped to fit.
*/
buildFallbackPdf(model: InvoiceDocumentModel): Buffer {
const currency = (cur?: string | null) =>
(cur ?? model.currency) === "ETB" ? "ETB" : (cur ?? model.currency);
const money = (amount: unknown, cur?: string | null) =>
`${Number(amount ?? 0).toLocaleString()} ${currency(cur)}`;
const date = (value: unknown) =>
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`;
const sealText =
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
const showCategory = Boolean(model.categoryHeader);
const ops: string[] = [];
// ── Header ────────────────────────────────────────────────────────────
ops.push(lineOp(36, 806, 559, 806, PdfColor.teal, 2.4));
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", 36, 790, 8.5, "F2", PdfColor.gray));
const titleSize = heading.length > 34 ? 18 : 22;
ops.push(textOp(heading, 36, 762, titleSize, "F2", PdfColor.dark));
ops.push(textOpRight("DOCUMENT NO.", 559, 792, 7.5, "F2", PdfColor.gray));
ops.push(textOpRight(model.documentNumber, 559, 776, 12, "F2", PdfColor.dark));
ops.push(textOpRight(`Issued ${date(model.issuedAt)}`, 559, 762, 8.5, "F1", PdfColor.gray));
ops.push(
textOpRight(
`Status ${model.status}`,
559,
748,
8.5,
"F1",
model.status === "PAID" ? PdfColor.teal : PdfColor.gray,
),
);
ops.push(lineOp(36, 736, 470, 736, PdfColor.line, 1));
// ── Seal ──────────────────────────────────────────────────────────────
ops.push(sealOp(516, 706, 27, sealText.split(/\s+/), PdfColor.teal));
// ── Summary grid (two columns) ────────────────────────────────────────
let y = 700;
const colX = [36, 300];
const colW = 250;
model.summary.slice(0, 16).forEach((row, i) => {
const x = colX[i % 2];
if (i % 2 === 0 && i > 0) y -= 27;
ops.push(textOp((row.label ?? "").toUpperCase(), x, y, 7, "F1", PdfColor.gray));
ops.push(textOp(this.clip(row.value ?? "-", 44), x, y - 11, 9, "F2", PdfColor.dark));
ops.push(lineOp(x, y - 15, x + colW, y - 15, PdfColor.line, 0.6));
});
y -= 34;
// ── Line-item table ───────────────────────────────────────────────────
const qtyR = 402;
const rateR = 486;
const amtR = 555;
ops.push(rectOp(36, y - 18, 523, 18, PdfColor.shade, PdfColor.line, 0.7));
ops.push(textOp("DESCRIPTION", 40, y - 13, 8, "F2", PdfColor.gray));
if (showCategory) {
ops.push(textOp((model.categoryHeader ?? "").toUpperCase(), 250, y - 13, 8, "F2", PdfColor.gray));
}
ops.push(textOpRight("QTY", qtyR, y - 13, 8, "F2", PdfColor.gray));
ops.push(textOpRight("RATE", rateR, y - 13, 8, "F2", PdfColor.gray));
ops.push(textOpRight("AMOUNT", amtR, y - 13, 8, "F2", PdfColor.gray));
y -= 18;
const descChars = showCategory ? 44 : 66;
for (const item of model.lines) {
if (y < 190) break; // leave room for totals + footer
const descLines = wrapText(item.description ?? "-", descChars).slice(0, 2);
const rowH = Math.max(18, descLines.length * 10 + 8);
ops.push(rectOp(36, y - rowH, 523, rowH, "1 1 1", PdfColor.line, 0.6));
descLines.forEach((line, k) => {
ops.push(textOp(line, 40, y - 12 - k * 10, 8, "F1", PdfColor.dark));
});
if (showCategory) {
ops.push(textOp(this.clip((item.category ?? "").replace(/_/g, " "), 18), 250, y - 12, 8, "F1", PdfColor.dark));
}
ops.push(textOpRight(String(item.quantity ?? 0), qtyR, y - 12, 8, "F1", PdfColor.dark));
ops.push(textOpRight(money(item.unitRate, item.currency), rateR, y - 12, 8, "F1", PdfColor.dark));
ops.push(textOpRight(money(item.amount, item.currency), amtR, y - 12, 8, "F1", PdfColor.dark));
y -= rowH;
}
// ── Totals ────────────────────────────────────────────────────────────
let ty = y - 16;
for (const total of model.totals) {
if (ty < 88) break;
if (total.grand) {
ops.push(lineOp(315, ty + 5, 559, ty + 5, PdfColor.dark, 0.9));
ops.push(textOp(total.label, 320, ty - 9, 11, "F2", PdfColor.dark));
ops.push(textOpRight(money(total.amount), 555, ty - 9, 12, "F2", PdfColor.dark));
ty -= 24;
} else {
ops.push(textOp(total.label, 320, ty - 8, 9.5, "F1", PdfColor.gray));
ops.push(textOpRight(money(total.amount), 555, ty - 8, 10, "F1", PdfColor.dark));
ty -= 17;
}
}
// ── Footer ────────────────────────────────────────────────────────────
ops.push(lineOp(36, 64, 250, 64, PdfColor.dark, 0.8));
ops.push(textOp("Prepared by EDR finance", 36, 52, 7.5, "F1", PdfColor.gray));
ops.push(lineOp(340, 64, 559, 64, PdfColor.dark, 0.8));
ops.push(textOp("Authorized seal / signature", 340, 52, 7.5, "F1", PdfColor.gray));
return assembleSinglePagePdf(ops);
}
/** Truncate to `max` chars with an ellipsis. */
private clip(value: string, max: number): string {
const text = String(value ?? "");
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
}
buildHtml(model: InvoiceDocumentModel): string {
const esc = (value: unknown) =>
String(value ?? "-")

View File

@@ -0,0 +1,322 @@
/**
* Minimal hand-built PDF primitives shared by the Chromium-less document
* fallbacks (invoices, receipts). These draw a genuine vector layout — boxes,
* rules, right-aligned money, a round seal — so a document still looks like a
* real document when headless Chromium is unavailable, instead of degrading to
* a flat plain-text dump. Coordinates are PDF user space (origin bottom-left,
* A4 = 595 x 842 pt). Fonts: F1 = Helvetica, F2 = Helvetica-Bold.
*/
export const MIN_VALID_PDF_BYTES = 2_000;
/** Colours as PDF "r g b" triples in the 0..1 range. */
export const PdfColor = {
teal: "0.06 0.46 0.43",
dark: "0.06 0.09 0.16",
gray: "0.39 0.45 0.55",
line: "0.80 0.84 0.89",
shade: "0.96 0.97 0.98",
tint: "0.94 0.99 0.98",
} as const;
export function escapePdfText(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/\(/g, "\\(")
.replace(/\)/g, "\\)")
.replace(/[^\x20-\x7e]/g, " ");
}
/** Approximate rendered width of Helvetica text (slightly over-estimated so
* right-aligned text never crosses its column edge). */
export function textWidth(text: string, size: number): number {
return text.length * size * 0.52;
}
export function textOp(
text: string,
x: number,
y: number,
size: number,
font: "F1" | "F2" = "F1",
color: string = PdfColor.dark,
): string {
return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`;
}
/** Right-align `text` so it ends at `rightX`. */
export function textOpRight(
text: string,
rightX: number,
y: number,
size: number,
font: "F1" | "F2" = "F1",
color: string = PdfColor.dark,
): string {
return textOp(text, rightX - textWidth(text, size), y, size, font, color);
}
export function lineOp(
x1: number,
y1: number,
x2: number,
y2: number,
color: string = PdfColor.line,
width = 0.8,
): string {
return `q\n${color} RG\n${width} w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
}
export function rectOp(
x: number,
y: number,
width: number,
height: number,
fillColor = "1 1 1",
strokeColor: string = PdfColor.line,
lineWidth = 0.7,
): string {
return `q\n${fillColor} rg\n${strokeColor} RG\n${lineWidth} w\n${x} ${y} ${width} ${height} re\nB\nQ`;
}
function circlePath(cx: number, cy: number, r: number): string {
const k = 0.5522847498;
const c = r * k;
return [
`${cx + r} ${cy} m`,
`${cx + r} ${cy + c} ${cx + c} ${cy + r} ${cx} ${cy + r} c`,
`${cx - c} ${cy + r} ${cx - r} ${cy + c} ${cx - r} ${cy} c`,
`${cx - r} ${cy - c} ${cx - c} ${cy - r} ${cx} ${cy - r} c`,
`${cx + c} ${cy - r} ${cx + r} ${cy - c} ${cx + r} ${cy} c`,
"h",
].join("\n");
}
/** A double-ring round rubber-stamp seal carrying up to three centred lines. */
export function sealOp(
cx: number,
cy: number,
r: number,
lines: string[],
color: string = PdfColor.teal,
): string {
const rows = lines.slice(0, 3);
const ops = [
"q",
`${color} RG`,
`${color} rg`,
"2 w",
circlePath(cx, cy, r),
"S",
"0.7 w",
circlePath(cx, cy, r - 6),
"S",
];
const startY = cy + (rows.length - 1) * 6;
rows.forEach((text, i) => {
const size = i === 0 ? 10 : 7.5;
ops.push(textOpRight(text, cx + textWidth(text, size) / 2, startY - i * 12 - 3, size, "F2", color));
});
ops.push("Q");
return ops.join("\n");
}
/** Hard-truncate to `max` chars (no marker — keeps dense table cells tight). */
export function clipText(value: string, max: number): string {
const t = String(value ?? "");
return t.length > max ? t.slice(0, Math.max(1, max)) : t;
}
/** Strip HTML tags → plain text, decoding the basic entities the doc builders emit. */
export function htmlToText(html: string): string {
return String(html ?? "")
.replace(/<br\s*\/?>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/gi, " ")
.replace(/[^\x20-\x7e]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
/**
* 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
* styled PDF grid. Used as the Chromium-less fallback so the manifest reads as a real
* document, not a flat text dump. Switches to landscape when the table is wide.
*/
export function buildTabularFallbackPdf(html: string): Buffer {
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 generated = htmlToText(pick(/Generated:\s*([^<]+)/i) ?? "");
const tiles: Array<[string, string]> = [];
for (const m of html.matchAll(
/class="tile"[^>]*>\s*<span>([\s\S]*?)<\/span>\s*<strong>([\s\S]*?)<\/strong>/gi,
)) {
tiles.push([htmlToText(m[1]), htmlToText(m[2])]);
}
const thead = pick(/<thead>([\s\S]*?)<\/thead>/i) ?? "";
const headers = [...thead.matchAll(/<th[^>]*>([\s\S]*?)<\/th>/gi)].map((m) => htmlToText(m[1]));
const tbody = pick(/<tbody>([\s\S]*?)<\/tbody>/i) ?? "";
const rows: string[][] = [...tbody.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)].map((tr) =>
[...tr[1].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map((td) => htmlToText(td[1])),
);
const notice = htmlToText(pick(/class="notice"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
const parsedSigs = [...html.matchAll(/class="line"[^>]*>([\s\S]*?)<\/div>/gi)]
.map((m) => htmlToText(m[1]))
.filter(Boolean);
const signatures = parsedSigs.length ? parsedSigs : ["Prepared / date", "Check / date", "Authorization / date"];
const landscape = headers.length > 7;
const page = landscape ? PageSize.landscape : PageSize.portrait;
const M = 32;
const contentW = page.width - M * 2;
const right = page.width - M;
const ops: string[] = [];
// 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));
// Summary tiles
let y = page.height - 100;
if (tiles.length) {
const cols = landscape ? 6 : 4;
const tileW = contentW / cols;
const tileH = 32;
tiles.forEach(([label, value], i) => {
const col = i % cols;
if (col === 0 && i > 0) y -= tileH;
const x = M + col * tileW;
ops.push(rectOp(x, y - tileH + 4, tileW - 4, tileH - 4, PdfColor.shade, PdfColor.line, 0.5));
ops.push(textOp(clipText(label.toUpperCase(), Math.floor((tileW - 12) / 3.6)), x + 6, y - 8, 6.5, "F1", PdfColor.gray));
ops.push(textOp(clipText(value, Math.floor((tileW - 12) / 4.4)), x + 6, y - 20, 9, "F2", PdfColor.dark));
});
y -= tileH + 12;
}
// Table
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;
let shown = 0;
for (const row of rows) {
if (y < 96) break;
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));
const cell = row[c] ?? "";
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));
}
}
// Notice (verification clause)
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) => {
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));
});
return assembleSinglePagePdf(ops, page);
}
/** Greedy word-wrap to a maximum character width. */
export function wrapText(text: string, maxChars: number): string[] {
const out: string[] = [];
for (const raw of String(text ?? "").split("\n")) {
const words = raw.split(/\s+/).filter(Boolean);
let line = "";
for (const word of words) {
const next = line ? `${line} ${word}` : word;
if (next.length > maxChars && line) {
out.push(line);
line = word;
} else {
line = next;
}
}
if (line) out.push(line);
}
return out.length ? out : [""];
}
/** A4 page sizes in PDF points. */
export const PageSize = {
portrait: { width: 595, height: 842 },
landscape: { width: 842, height: 595 },
} as const;
/** Assemble a single-page PDF from content-stream ops (Helvetica fonts). Defaults to A4 portrait. */
export function assembleSinglePagePdf(
ops: string[],
page: { width: number; height: number } = PageSize.portrait,
): Buffer {
const stream = ops.join("\n");
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${page.width} ${page.height}] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>`,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
`<< /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

@@ -0,0 +1,307 @@
import { Injectable, Logger } from '@nestjs/common';
import {
NotificationAudience,
NotificationType,
NotifyInput,
} from '@edr/types';
import { Booking } from './entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
/**
* Customer + staff notifications for the booking lifecycle: review, clearance
* and operation flow. Every customer event fans out over SMS + email (direct)
* and a persisted in-app notification deep-linking to the booking detail page;
* staff events land in the backoffice inbox. All sends are fire-and-forget and
* never throw — a notification failure must not break a booking transition.
*
* NOTE: the batch/payment-window notifications (pay-now, allocated, expired,
* displaced) are handled separately by {@link BookingNotifierService} in
* train-scheduling.
*/
@Injectable()
export class BookingLifecycleNotifierService {
private readonly logger = new Logger(BookingLifecycleNotifierService.name);
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
) {}
private ref(b: Booking): string {
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
}
/** Send SMS + email to the booking's company contact; log-only on failure. */
private async notifyContact(
b: Booking,
message: string,
logLabel: string,
): Promise<void> {
this.logger.log(`${logLabel}${this.ref(b)}`);
const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null;
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
if (phone) {
try {
await this.notifications.directSend('sms', phone, message);
} catch (err) {
this.logger.warn(`SMS failed for ${this.ref(b)}: ${(err as Error).message}`);
}
}
if (email) {
try {
await this.notifications.directSend('email', email, message);
} catch (err) {
this.logger.warn(`Email failed for ${this.ref(b)}: ${(err as Error).message}`);
}
}
if (!phone && !email) {
this.logger.warn(`No contact on file for ${this.ref(b)} — notification not sent`);
}
}
/** Persist + push an in-app item to all portal users of the booking's company. */
private inApp(
b: Booking,
title: string,
body: string,
overrides: Partial<NotifyInput> = {},
): void {
if (!b.companyId) return; // government/unlinked bookings have no portal users
void this.inbox.notify({
recipients: { companyId: b.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title,
body,
link: `/bookings/${b.id}`,
data: { bookingId: b.id, reference: b.reference },
...overrides,
});
}
/** Persist + push an in-app item to every backoffice staff user. */
private inAppStaff(
b: Booking,
title: string,
body: string,
overrides: Partial<NotifyInput> = {},
): void {
void this.inbox.notify({
recipients: { allBackoffice: true },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED,
title,
body,
link: `/dashboard/booking-requests/${b.id}`,
data: { bookingId: b.id, reference: b.reference },
...overrides,
});
}
// ── Customer-facing lifecycle events ───────────────────────────────────────
/** Line staff accepted intake → booking is under approval. */
accepted(b: Booking): void {
const msg =
`Your booking ${b.reference} has been accepted and is now under approval. ` +
`We will notify you once it is approved.`;
void this.notifyContact(b, msg, 'ACCEPTED');
this.inApp(b, 'Booking accepted', msg);
}
/** All approval steps complete → contract generated, ready for customer to sign. */
approved(b: Booking): void {
const msg =
`Your booking ${b.reference} has been approved. ` +
`Please review and sign your contract from the portal.`;
void this.notifyContact(b, msg, 'APPROVED');
this.inApp(b, 'Booking approved', msg);
}
/** Staff rejected the booking (intake or approval step). */
rejected(b: Booking, reason: string): void {
const msg =
`Your booking ${b.reference} was rejected. Reason: ${reason}. ` +
`Please contact us for details.`;
void this.notifyContact(b, msg, 'REJECTED');
this.inApp(b, 'Booking rejected', msg);
}
/** Staff requested changes before approval. */
changesRequested(b: Booking, note: string): void {
const msg =
`Changes were requested on your booking ${b.reference}: ${note}. ` +
`Please update and resubmit from the portal.`;
void this.notifyContact(b, msg, 'CHANGES REQUESTED');
this.inApp(b, 'Booking changes requested', msg);
}
/** A clearance document was queried and needs the customer to re-upload. */
documentQueried(b: Booking, fileKey: string, note: string): void {
const msg =
`A clearance document on booking ${b.reference} needs attention: "${fileKey}". ` +
`${note}. Please re-upload from the portal.`;
void this.notifyContact(b, msg, 'DOCUMENT QUERIED');
this.inApp(b, 'Document queried', msg, {
type: NotificationType.DOCUMENT_ACTION,
});
}
/** Clearance finalized → customer can proceed to request operation. */
clearanceReady(b: Booking): void {
const msg =
`Clearance for booking ${b.reference} is complete. ` +
`You can now proceed to request operation from the portal.`;
void this.notifyContact(b, msg, 'CLEARANCE READY');
this.inApp(b, 'Clearance complete', msg, {
type: NotificationType.CLEARANCE_DECISION,
});
}
/** Operations returned the operation request for changes. */
operationChangesRequested(b: Booking, note: string): void {
const msg =
`Your operation request for booking ${b.reference} needs changes: ${note}. ` +
`Please update and resubmit from the portal.`;
void this.notifyContact(b, msg, 'OPERATION CHANGES REQUESTED');
this.inApp(b, 'Operation request needs changes', msg);
}
/** Operation accepted → invoice ready; await payment / booking window. */
operationAccepted(b: Booking): void {
const msg =
`Your operation request for booking ${b.reference} has been accepted. ` +
`An invoice has been prepared — watch for the payment window to secure your slot.`;
void this.notifyContact(b, msg, 'OPERATION ACCEPTED');
this.inApp(b, 'Operation request accepted', msg);
}
/** Shipment started → in transit. */
inTransit(b: Booking): void {
const msg = `Your shipment for booking ${b.reference} is now in transit.`;
void this.notifyContact(b, msg, 'IN TRANSIT');
this.inApp(b, 'Shipment in transit', msg);
}
/** Shipment delivered → completed. */
completed(b: Booking): void {
const msg = `Your shipment for booking ${b.reference} has been delivered. Thank you.`;
void this.notifyContact(b, msg, 'COMPLETED');
this.inApp(b, 'Shipment delivered', msg);
}
/** Booking cancelled. */
cancelled(b: Booking, reason: string): void {
const msg = `Your booking ${b.reference} has been cancelled. Reason: ${reason}.`;
void this.notifyContact(b, msg, 'CANCELLED');
this.inApp(b, 'Booking cancelled', msg);
}
// ── Clearance milestones needing customer action ──────────────────────────
/** GL advised duty & tax — the customer must pay and upload the slip. */
dutyAdvised(b: Booking, amount: number, currency: string): void {
const msg =
`Duty & tax of ${amount} ${currency} has been advised for booking ${b.reference}. ` +
`Please pay and upload the payment slip from the portal.`;
void this.notifyContact(b, msg, 'DUTY ADVISED');
this.inApp(b, 'Duty & tax advised', msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
/** GL advised the post-arrival additional duty round (import). */
secondDutyAdvised(b: Booking, amount: number, currency: string): void {
const msg =
`Additional duty & tax of ${amount} ${currency} has been advised for booking ${b.reference}. ` +
`Please pay and upload the payment slip from the portal.`;
void this.notifyContact(b, msg, 'SECOND DUTY ADVISED');
this.inApp(b, 'Additional duty & tax advised', msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
/** GL raised the final (post-offload) invoice — customer pays + uploads slip. */
finalInvoiceCreated(b: Booking, amount: number, currency: string): void {
const msg =
`A final invoice of ${amount} ${currency} has been issued for booking ${b.reference}. ` +
`Please pay and upload the payment slip from the portal.`;
void this.notifyContact(b, msg, 'FINAL INVOICE');
this.inApp(b, 'Final invoice issued', msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
/** GL confirmed the final-invoice payment slip. */
finalInvoicePaid(b: Booking): void {
const msg = `Your final invoice payment for booking ${b.reference} has been confirmed. Thank you.`;
void this.notifyContact(b, msg, 'FINAL INVOICE PAID');
this.inApp(b, 'Final invoice paid', msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
// ── Staff-facing (backoffice inbox) ────────────────────────────────────────
/** Customer submitted a booking for review. */
submittedToStaff(b: Booking): void {
this.inAppStaff(
b,
'New booking submitted',
`Booking ${this.ref(b)} was submitted and is awaiting intake review.`,
);
}
/** Customer signed the booking contract. */
customerSignedToStaff(b: Booking): void {
this.inAppStaff(
b,
'Customer signed booking contract',
`The contract for booking ${this.ref(b)} was signed by the customer.`,
);
}
/** Customer requested operation (picked a shipment day). */
operationRequestedToStaff(b: Booking): void {
this.inAppStaff(
b,
'Operation requested',
`Booking ${this.ref(b)} requested operation — review capacity, documents and route.`,
);
}
/** Customer uploaded clearance documents — review is next. */
clearanceDocsUploadedToStaff(b: Booking): void {
this.inAppStaff(
b,
'Clearance documents uploaded',
`Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`,
{
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`,
},
);
}
/** Customer uploaded a duty/tax payment slip — GL verifies it. */
dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void {
const label =
round === 'final'
? 'final invoice'
: round === 'second'
? 'additional duty & tax'
: 'duty & tax';
this.inAppStaff(
b,
'Payment slip uploaded',
`Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`,
{
type: NotificationType.PAYMENT_RECEIVED,
link: `/dashboard/bookings/${b.id}/clearance`,
},
);
}
}

View File

@@ -30,14 +30,32 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
ruleEngineService as never,
{} as never, // pricingService
{} as never, // contractService
{} as never, // invoiceService
{} as never, // filesService
{} as never, // fileUploadSettingsService
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{
accepted: jest.fn(),
approved: jest.fn(),
rejected: jest.fn(),
changesRequested: jest.fn(),
documentQueried: jest.fn(),
clearanceReady: jest.fn(),
operationChangesRequested: jest.fn(),
operationAccepted: jest.fn(),
inTransit: jest.fn(),
completed: jest.fn(),
cancelled: jest.fn(),
submittedToStaff: jest.fn(),
customerSignedToStaff: jest.fn(),
operationRequestedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
);
return { service, bookingsRepository, ruleEngineService };
}

View File

@@ -41,14 +41,32 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{
accepted: jest.fn(),
approved: jest.fn(),
rejected: jest.fn(),
changesRequested: jest.fn(),
documentQueried: jest.fn(),
clearanceReady: jest.fn(),
operationChangesRequested: jest.fn(),
operationAccepted: jest.fn(),
inTransit: jest.fn(),
completed: jest.fn(),
cancelled: jest.fn(),
submittedToStaff: jest.fn(),
customerSignedToStaff: jest.fn(),
operationRequestedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
);
return { service, bookingsRepository };
}
@@ -126,14 +144,32 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
{} as never,
{} as never,
{} as never,
{} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
{} as never,
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{
accepted: jest.fn(),
approved: jest.fn(),
rejected: jest.fn(),
changesRequested: jest.fn(),
documentQueried: jest.fn(),
clearanceReady: jest.fn(),
operationChangesRequested: jest.fn(),
operationAccepted: jest.fn(),
inTransit: jest.fn(),
completed: jest.fn(),
cancelled: jest.fn(),
submittedToStaff: jest.fn(),
customerSignedToStaff: jest.fn(),
operationRequestedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
);
return { service, bookingsRepository };
}
@@ -197,14 +233,32 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
{} as never,
{} as never,
{} as never,
{} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
{} as never,
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{
accepted: jest.fn(),
approved: jest.fn(),
rejected: jest.fn(),
changesRequested: jest.fn(),
documentQueried: jest.fn(),
clearanceReady: jest.fn(),
operationChangesRequested: jest.fn(),
operationAccepted: jest.fn(),
inTransit: jest.fn(),
completed: jest.fn(),
cancelled: jest.fn(),
submittedToStaff: jest.fn(),
customerSignedToStaff: jest.fn(),
operationRequestedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
);
return { service, bookingsRepository, filesService };
}

View File

@@ -3,14 +3,17 @@ import { BookingTransitionService } from './booking-transition.service';
/**
* Operation-request review for general-contract drawdown orders:
* - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool.
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued.
* - ACCEPT a train order → FULLY_EXECUTED with the invoice ensured; import/
* domestic bookings wait for their booking-day window cycle (no immediate
* batch enqueue at accept time).
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, never enters the train batch.
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
*/
describe('BookingTransitionService — operation review', () => {
function makeService(serviceTypeCode: string) {
const booking = {
id: 'b-1',
reference: 'BKG-1',
status: 'OPERATION_REQUEST_PENDING',
originYardId: 'o-1',
destinationYardId: 'd-1',
@@ -26,6 +29,14 @@ describe('BookingTransitionService — operation review', () => {
};
const bookingBatchService = {
enqueueRouteDayProcessing: jest.fn(),
pickExportSchedule: jest.fn(),
acceptExportBooking: jest.fn(),
};
const invoiceService = {
ensureInvoiceForBooking: jest
.fn()
.mockResolvedValue({ id: 'inv-1', invoiceNumber: 'INV-0001' }),
updateStatus: jest.fn().mockResolvedValue(undefined),
};
const service = new BookingTransitionService(
@@ -33,37 +44,60 @@ describe('BookingTransitionService — operation review', () => {
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // invoiceService
{} as never, // filesService
{} as never, // fileUploadSettingsService
bookingBatchService as never,
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{} as never, // workflowService
invoiceService as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{
accepted: jest.fn(),
approved: jest.fn(),
rejected: jest.fn(),
changesRequested: jest.fn(),
documentQueried: jest.fn(),
clearanceReady: jest.fn(),
operationChangesRequested: jest.fn(),
operationAccepted: jest.fn(),
inTransit: jest.fn(),
completed: jest.fn(),
cancelled: jest.fn(),
submittedToStaff: jest.fn(),
customerSignedToStaff: jest.fn(),
operationRequestedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
);
return { service, bookingsRepository, bookingBatchService };
return { service, bookingsRepository, bookingBatchService, invoiceService };
}
it('ACCEPT of a train order → FULLY_EXECUTED and enqueues the batch pool', async () => {
const { service, bookingsRepository, bookingBatchService } =
it('ACCEPT of a train order → FULLY_EXECUTED, invoice ensured, batch waits for window cycle', async () => {
const { service, bookingsRepository, bookingBatchService, invoiceService } =
makeService('RAIL_CONTAINER');
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ status: 'FULLY_EXECUTED' }),
);
expect(bookingBatchService.enqueueRouteDayProcessing).toHaveBeenCalledTimes(1);
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
// Import/domestic train bookings are batched by the window cycle later —
// never enqueued directly at accept time.
expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled();
expect(bookingBatchService.acceptExportBooking).not.toHaveBeenCalled();
});
it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enqueue', async () => {
const { service, bookingsRepository, bookingBatchService } =
it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enter the batch', async () => {
const { service, bookingsRepository, bookingBatchService, invoiceService } =
makeService('ROAD_CONTAINER');
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }),
);
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled();
});

View File

@@ -4,6 +4,7 @@ import {
Inject,
Injectable,
Logger,
Optional,
} from "@nestjs/common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
@@ -15,6 +16,7 @@ import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { FilesService } from '../files/files.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { BookingContractService } from './booking-contract.service';
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
import { BookingPricingService } from './booking-pricing.service';
import { ContainerValidationService } from './container-validation.service';
import { BookingsRepository } from './bookings.repository';
@@ -26,6 +28,7 @@ import { PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity';
import { BookingsService } from './bookings.service';
import { BookingClearanceService } from '../contracts/booking-clearance.service';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
import { ContractDocPhase } from '@edr/types';
@@ -53,7 +56,8 @@ export class BookingTransitionService {
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
private readonly containerValidationService: ContainerValidationService,
private readonly notifier: BookingLifecycleNotifierService,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
) {}
private isPhasedGeneralCustoms(booking: Booking): boolean {
@@ -124,6 +128,9 @@ export class BookingTransitionService {
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
updated!.id,
);
if (finalBooking.status === "SUBMITTED") {
this.notifier.submittedToStaff(finalBooking);
}
return {
bookingId: finalBooking.id,
status: finalBooking.status,
@@ -204,6 +211,9 @@ export class BookingTransitionService {
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
updated!.id,
);
if (finalBooking.status === "SUBMITTED") {
this.notifier.submittedToStaff(finalBooking);
}
return {
bookingId: finalBooking.id,
status: finalBooking.status,
@@ -233,7 +243,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: "CHANGES_REQUESTED",
} as never);
return this.bookingsService.findById(updated!.id);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.changesRequested(fresh, note);
return fresh;
}
/** Auto-create booking approval steps from system rules when none exist yet. */
@@ -284,7 +296,9 @@ export class BookingTransitionService {
contractValidFrom: validFrom,
contractValidUntil: validUntil,
} as never);
return this.bookingsService.findById(updated!.id);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.accepted(fresh);
return fresh;
}
async staffReject(
@@ -305,7 +319,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: "REJECTED",
} as never);
return this.bookingsService.findById(updated!.id);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.rejected(fresh, reason);
return fresh;
}
async approveStep(
@@ -394,7 +410,9 @@ export class BookingTransitionService {
if (allDone) {
const generated = await this.contractService.generateContract(bookingId);
return this.bookingsService.findById(generated.id);
const fresh = await this.bookingsService.findById(generated.id);
this.notifier.approved(fresh);
return fresh;
}
return this.bookingsService.findById(bookingId);
@@ -435,7 +453,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: "REJECTED",
} as never);
return this.bookingsService.findById(updated!.id);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.rejected(fresh, reason);
return fresh;
}
async customerSign(bookingId: string): Promise<Booking> {
@@ -446,7 +466,9 @@ export class BookingTransitionService {
status: "SIGNED_CUSTOMER",
customerSignedAt: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.customerSignedToStaff(fresh);
return fresh;
}
async startTransit(bookingId: string): Promise<Booking> {
@@ -456,7 +478,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: "IN_TRANSIT",
} as never);
return this.bookingsService.findById(updated!.id);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.inTransit(fresh);
return fresh;
}
async complete(bookingId: string): Promise<Booking> {
@@ -467,7 +491,28 @@ export class BookingTransitionService {
status: "COMPLETED",
endDate: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.completed(fresh);
// Customer tracking: close out the tail milestones so a finished shipment
// never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are
// implied by delivery; a storage invoice that was never raised is skipped
// (storage billing does not apply to every shipment). All doc-trigger /
// best-effort — a booking without milestone rows is untouched.
if (this.milestoneService) {
for (const code of ["IMPORT_PROCESS_COMPLETED", "EXIT_NOTE_GENERATED"]) {
try {
await this.milestoneService.completeByDocTrigger({ bookingId }, code);
} catch {
/* tracking must never block completion */
}
}
try {
await this.milestoneService.skipForBooking(bookingId, "STORAGE_INVOICE_RAISED");
} catch {
/* no such milestone row (export / non-customs) — fine */
}
}
return fresh;
}
async cancel(bookingId: string, reason: string): Promise<Booking> {
@@ -491,7 +536,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: "CANCELLED",
} as never);
return this.bookingsService.findById(updated!.id);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.cancelled(fresh, reason);
return fresh;
}
/**
@@ -723,7 +770,9 @@ export class BookingTransitionService {
} as never);
}
return this.bookingsService.findById(bookingId);
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.clearanceDocsUploadedToStaff(fresh);
return fresh;
}
/**
@@ -824,6 +873,9 @@ export class BookingTransitionService {
}
const updated = await this.bookingsService.findById(bookingId);
if (status === "QUERIED") {
this.notifier.documentQueried(updated, fileKey, note ?? '');
}
if (this.isPhasedGeneralCustoms(updated)) {
const allApproved = await this.isClearanceFullyApproved(updated);
if (allApproved) {
@@ -912,7 +964,9 @@ export class BookingTransitionService {
await this.bookingsRepository.update(bookingId, {
status: "CLEARANCE_READY",
} as never);
return this.bookingsService.findById(bookingId);
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.clearanceReady(fresh);
return fresh;
}
/**
@@ -957,7 +1011,9 @@ export class BookingTransitionService {
status: "OPERATION_REQUEST_PENDING",
scheduledDate: date,
} as never);
return this.bookingsService.findById(bookingId);
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.operationRequestedToStaff(fresh);
return fresh;
}
/**
@@ -992,7 +1048,9 @@ export class BookingTransitionService {
await this.bookingsRepository.update(bookingId, {
status: "OPERATION_CHANGES_REQUESTED",
} as never);
return this.bookingsService.findById(bookingId);
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.operationChangesRequested(fresh, options.note);
return fresh;
}
// ACCEPT — enter the batch holding pool.
@@ -1037,7 +1095,9 @@ export class BookingTransitionService {
fullyExecutedAt: now,
lockedAt: booking.lockedAt ?? now,
} as never);
return this.bookingsService.findById(booking.id);
const roadFresh = await this.bookingsService.findById(booking.id);
this.notifier.operationAccepted(roadFresh);
return roadFresh;
}
await this.bookingsRepository.update(booking.id, {
@@ -1072,7 +1132,9 @@ export class BookingTransitionService {
// batch runs after the window closes + staff document review, never at accept
// time. (Legacy pre-migration schedules with no window phase are still served
// by the periodic legacy fill.)
return this.bookingsService.findById(booking.id);
const trainFresh = await this.bookingsService.findById(booking.id);
this.notifier.operationAccepted(trainFresh);
return trainFresh;
}
async enrichBookingResponse(booking: Booking): Promise<

View File

@@ -15,11 +15,13 @@ import {
UnauthorizedException,
UploadedFile,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from '../../common/booking-guards';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { BookingStaff, BookingView } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
import {
@@ -64,6 +66,7 @@ import { ContractViewDto } from './dto/contract-view.dto';
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto';
import { CustomerTruckService } from './customer-truck.service';
import { GenerateGrnDto } from './dto/generate-grn.dto';
import { ContainerReceiptService } from './container-receipt.service';
@@ -194,6 +197,7 @@ export class BookingsController {
}
@Get("by-company/:companyId/customer-view")
@BookingView()
@ApiOperation({
summary: "List bookings for a company (customer-view shape, backoffice)",
})
@@ -204,6 +208,7 @@ export class BookingsController {
}
@Get("list-summary")
@BookingView()
@ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" })
@ApiOkResponse({ type: BookingListSummaryDto })
findListSummary(@Query() filter: FilterBookingDto) {
@@ -225,6 +230,7 @@ export class BookingsController {
}
@Get("queues/:queue")
@BookingView()
@ApiOperation({
summary: "List bookings for a dashboard queue",
description: "Queues: intake, approval, signatures, marketing, finance",
@@ -358,6 +364,33 @@ export class BookingsController {
return this.customerTruckService.removeTruck(id, assignmentId);
}
@Get(':id/customer-trucks/loadable-containers')
@ApiOperation({ summary: 'Booking containers not yet loaded onto a truck' })
async loadableContainers(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.customerTruckService.getLoadableContainers(id);
}
@Post(':id/customer-trucks/:assignmentId/load')
@ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' })
async loadCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
@Body() dto: LoadCustomerTruckDto,
@CurrentUser() user: TCurrentUser,
) {
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
throw new ForbiddenException('Only warehouse staff can load a truck');
}
return this.customerTruckService.loadTruck(id, assignmentId, dto);
}
@Post(':id/customer-trucks/:assignmentId/depart')
@ApiOperation({
summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)',
@@ -928,12 +961,18 @@ export class BookingsController {
}
@Post(":id/contract/sign")
@UseGuards(JwtGuard)
@ApiOperation({ summary: "Apply digital signature (customer or staff)" })
async signContract(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@CurrentUser() user: TCurrentUser,
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
) {
// Staff signature needs the sign permission; customer signs their own booking.
if (dto.role !== "CUSTOMER") {
assertFreightPermission(user, FREIGHT_PERMS.bookings.signStaff);
}
const userId = req.user?.id ?? req.user?.sub;
const booking = await this.contractService.signContract(id, dto, {
signerUserId: userId,

View File

@@ -18,7 +18,10 @@ import { BookingInvoiceService } from './booking-invoice.service';
// import { BookingPaymentService } from './booking-payment.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
import { BookingTransitionService } from './booking-transition.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { BookingsController } from './bookings.controller';
// import { PayController } from './pay.controller';
import { BookingsRepository } from './bookings.repository';
@@ -64,6 +67,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
CustomerTruckContainer,
]),
BillingModule,
NotificationsModule,
NotificationInboxModule,
forwardRef(() => FirstMileModule),
forwardRef(() => TrainSchedulingModule),
forwardRef(() => ContractsModule),
@@ -90,6 +95,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
ContainerValidationService,
BookingReferenceDataService,
BookingPricingService,
BookingLifecycleNotifierService,
BookingTransitionService,
BookingContractService,
BookingInvoiceService,
@@ -107,6 +113,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingsRepository,
BookingPricingService,
BookingInvoiceService,
BookingLifecycleNotifierService,
CustomerTruckService,
ContainerReceiptService,
],

View File

@@ -176,6 +176,44 @@ export class BookingsService {
}
/** Resolve trade direction from yard countries; reject client mismatch. */
/**
* An intercity corridor is valid when both yards are Ethiopian and at least
* one non-retired route passes the origin strictly before the destination in
* its milestone order — that is the corridor an import/export train can
* serve the booking on.
*/
private async assertIntercityCorridorExists(
originYardId: string,
destinationYardId: string,
): Promise<void> {
const yards = await this.dataSource.getRepository(Yard).find({
where: { id: In([originYardId, destinationYardId]) },
});
if (yards.some((y) => y.country !== 'Ethiopia')) {
throw new BadRequestException(
'Intercity bookings only run between Ethiopian yards',
);
}
const rows: Array<{ id: string }> = await this.dataSource.query(
`SELECT r.id
FROM freight.routes r
JOIN freight.route_milestones mo
ON mo.route_id = r.id AND mo.yard_id = $1 AND mo.deleted_at IS NULL
JOIN freight.route_milestones md
ON md.route_id = r.id AND md.yard_id = $2 AND md.deleted_at IS NULL
WHERE mo.sequence_no < md.sequence_no
AND r.status = 'AVAILABLE'
AND r.deleted_at IS NULL
LIMIT 1`,
[originYardId, destinationYardId],
);
if (rows.length === 0) {
throw new BadRequestException(
'No route passes through this origin and destination in order — intercity service is not available on this corridor',
);
}
}
private async resolveTradeDirectionForBooking(
originYardId: string,
destinationYardId: string,
@@ -607,6 +645,23 @@ export class BookingsService {
dto.tradeDirection,
);
// Intercity (DOMESTIC) bookings never get their own train — they ride on a
// passing import/export train, so there is no booking window and no date to
// pin. All we require at creation is that the corridor actually lies on a
// route (origin before destination in some route's milestone order); staff
// accept the booking onto a concrete train at finalize time.
if (tradeDirection === 'DOMESTIC') {
if (dto.scheduledDate || dto.trainScheduleId) {
throw new BadRequestException(
'Intercity bookings cannot pin a date or schedule — staff assign them to a passing train later',
);
}
await this.assertIntercityCorridorExists(
dto.originYardId,
dto.destinationYardId,
);
}
// Stamp the operational profile this booking belongs to (importer/exporter)
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
// for non-government bookings with a resolved company; never blocks creation.
@@ -1333,6 +1388,17 @@ export class BookingsService {
);
}
// 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.
if (booking.trainScheduleId) {
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: booking.trainScheduleId } });
(booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus =
schedule?.status ?? null;
}
return booking;
}

View File

@@ -38,7 +38,7 @@ export class ContainerReceiptService {
SET received_to_port = true,
received_at = COALESCE(bcu.received_at, NOW()),
updated_at = NOW()
FROM freight.booking_containers bc,
FROM freight.booking_container bc,
freight.customer_truck_containers ctc
WHERE bc.id = bcu.booking_container_id
AND bc.booking_id = $1
@@ -60,7 +60,7 @@ export class ContainerReceiptService {
bcu.received_at AS "receivedAt",
bcu.grn_number AS "grnNumber"
FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1
AND bcu.deleted_at IS NULL
@@ -92,7 +92,7 @@ export class ContainerReceiptService {
const pending: ReceivedUnitRow[] = await manager.query(
`SELECT bcu.id, bcu.container_number AS "containerNumber"
FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1
AND bcu.deleted_at IS NULL
@@ -109,7 +109,7 @@ export class ContainerReceiptService {
const [{ batches }]: Array<{ batches: string }> = await manager.query(
`SELECT COUNT(DISTINCT bcu.grn_number) AS batches
FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.grn_number IS NOT NULL AND bcu.deleted_at IS NULL`,
[bookingId],
@@ -129,7 +129,7 @@ export class ContainerReceiptService {
const [{ remaining }]: Array<{ remaining: string }> = await manager.query(
`SELECT COUNT(*) AS remaining
FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL AND bcu.grn_number IS NULL`,
[bookingId],

View File

@@ -198,6 +198,87 @@ export class CustomerTruckService {
return this.listTrucks(bookingId);
}
/** Booking container numbers not yet loaded onto any truck. */
async getLoadableContainers(bookingId: string): Promise<string[]> {
const [all, assigned] = await Promise.all([
this.bookingContainerNumbers(bookingId),
this.assignedContainerNumbers(bookingId),
]);
const taken = new Set(assigned);
return all.filter((n) => !taken.has(n));
}
/**
* Truck_dispatch (load): assign the selected containers to a truck after it has
* arrived, and set a provisional gross weight from their VGM. The truck is
* weighed for real on departure. Locked once the truck has left.
*/
async loadTruck(
bookingId: string,
assignmentId: string,
dto: { containerNumbers: string[] },
): Promise<CustomerTruckAssignment[]> {
await this.loadBookingGuard(bookingId);
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
if (!assignment || assignment.bookingId !== bookingId) {
throw new NotFoundException('Truck assignment not found for this booking');
}
if (assignment.departedAt) {
throw new ConflictException('This truck has already left — its load is locked');
}
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');
}
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
for (const n of requested) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
}
}
const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
for (const n of requested) {
if (elsewhere.includes(n)) {
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
}
}
const grossKg = await this.vgmKgForContainers(bookingId, requested);
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
await manager.getRepository(CustomerTruckContainer).save(
requested.map((containerNumber) =>
manager.getRepository(CustomerTruckContainer).create({
assignmentId,
bookingId,
containerNumber,
}),
),
);
// Provisional gross from the loaded containers' VGM — overridden by the
// weighed gross on departure.
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
grossWeightKg: grossKg,
});
});
return this.listTrucks(bookingId);
}
private async vgmKgForContainers(bookingId: string, numbers: string[]): Promise<number> {
const [row]: Array<{ kg: string }> = await this.dataSource.query(
`SELECT COALESCE(SUM(bcu.vgm_tons), 0) * 1000 AS kg
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1
AND bcu.container_number = ANY($2::varchar[])
AND bcu.deleted_at IS NULL`,
[bookingId, numbers],
);
return Number(row?.kg ?? 0);
}
/**
* Mark the truck carrying `containerNumber` as arrived. Called by the warehouse
* receive flow. When every truck on the booking has arrived, the booking-level
@@ -288,7 +369,7 @@ export class CustomerTruckService {
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber"
FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
[bookingId],

View File

@@ -0,0 +1,13 @@
import { 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)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
each: true,
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})
containerNumbers!: string[];
}

View File

@@ -85,6 +85,13 @@ function makeService(overrides?: {
milestoneService as never,
dropdownSettingsService as never,
glOperationsService as never,
{
dutyAdvised: jest.fn(),
clearanceReady: jest.fn(),
documentQueried: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
} as never, // notifier
);
return {
@@ -115,12 +122,18 @@ describe('BookingClearanceService', () => {
it('records duty advice when duty applies', async () => {
const { service, milestoneService } = makeService();
await service.adviseDuty('b-general', {
dutyRequired: true,
amount: 1500,
currency: 'ETB',
declarationSerial: 'DS-1',
});
await service.adviseDuty(
'b-general',
{
dutyRequired: true,
amount: 1500,
currency: 'ETB',
declarationSerial: 'DS-1',
},
undefined,
// The duty notice attachment is now mandatory when duty applies.
{ fieldname: 'duty_tax_notice' } as Express.Multer.File,
);
expect(milestoneService.adviseDuty).toHaveBeenCalledWith(
'b-general',

View File

@@ -12,6 +12,7 @@ import { FileUploadSettingsService } from '../file-upload-settings/file-upload-s
import { FilesService } from '../files/files.service';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingsService } from '../bookings/bookings.service';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { clearanceCodesForBooking } from '../bookings/clearance.util';
@@ -100,6 +101,7 @@ export class BookingClearanceService {
private readonly milestoneService: ClearanceMilestoneService,
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService,
private readonly notifier: BookingLifecycleNotifierService,
) {}
private async assertPhasedGeneralCustoms(booking: Booking): Promise<void> {
@@ -174,7 +176,28 @@ export class BookingClearanceService {
}
const allApproved = await this.isClearanceFullyApproved(booking);
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
let milestones = await this.workflowService.listMilestonesForBooking(bookingId);
// Self-heal: a booking that has settled its freight payment must have
// FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an
// export FCFS booking (linked to its train at booking time) paid via the
// prepaid invoice can leave the milestone PENDING — the clearance "Payment &
// wagon allocation" step then never ticks. Backfill it here so already-stuck
// rows recover without a migration; idempotent (no-op once COMPLETED).
const paymentSettled = milestones.find(
(m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED',
);
if (
paymentSettled &&
paymentSettled.status === 'PENDING' &&
(booking.paymentStatus === 'PAID' || booking.status === 'PAID')
) {
await this.workflowService.completeMilestoneForBooking(
bookingId,
'FREIGHT_PAYMENT_SETTLED',
);
milestones = await this.workflowService.listMilestonesForBooking(bookingId);
}
const phase = this.workflowService.resolvePhaseForBooking(booking, milestones);
const nextAction = this.workflowService.computeNextActionForBooking(booking, milestones);
const boundary = await this.workflowService.isBoundaryCompleteForBooking(
@@ -414,6 +437,7 @@ export class BookingClearanceService {
},
userId,
);
this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB');
}
return this.bookingsService.findById(bookingId);
@@ -441,6 +465,7 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
this.notifier.dutySlipUploadedToStaff(booking, 'first');
return this.bookingsService.findById(bookingId);
}

View File

@@ -10,6 +10,7 @@ import type { Freight } from '@edr/types';
import { BookingRequestRepository } from './booking-request.repository';
import { ContractsService } from './contracts.service';
import { ContractBookingService } from './contract-booking.service';
import { ContractNotifierService } from './contract-notifier.service';
import { BookingRequest } from './entities/booking-request.entity';
import { Contract } from './entities/contract.entity';
import { CreateBookingRequestDto } from './dto/create-booking-request.dto';
@@ -26,6 +27,7 @@ export class BookingRequestService {
private readonly repo: BookingRequestRepository,
private readonly contractsService: ContractsService,
private readonly contractBookingService: ContractBookingService,
private readonly notifier: ContractNotifierService,
) {}
/** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */
@@ -107,7 +109,7 @@ export class BookingRequestService {
};
const reference = await this.generateReference();
return this.repo.create({
const request = await this.repo.create({
reference,
contractId,
requestedByUserId: userId ?? null,
@@ -117,6 +119,8 @@ export class BookingRequestService {
requestedLines,
notes: dto.notes ?? null,
} as never);
this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference);
return request;
}
listForContract(contractId: string): Promise<BookingRequest[]> {

View File

@@ -6,6 +6,7 @@ import {
CustomsRiskLevel,
MilestoneMetadata,
} from './entities/clearance-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { Contract } from './entities/contract.entity';
import {
HANDOFF_MILESTONES,
@@ -85,10 +86,36 @@ export class ClearanceMilestoneService {
}
async listForBooking(bookingId: string): Promise<ClearanceMilestone[]> {
return this.repo.find({
const rows = await this.repo.find({
where: { bookingId },
order: { sortOrder: 'ASC' },
});
// Self-heal: a booking that has settled its freight payment must have
// FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an
// export FCFS booking (linked to its train at booking time) paid via the
// prepaid invoice can leave the milestone PENDING — the clearance "Payment &
// wagon allocation" step then never ticks. getClearanceView backfills it, but
// the stepper reads its gating milestones straight from here, so heal here too.
// Idempotent (no-op once COMPLETED); recovers already-stuck rows with no migration.
const paymentSettled = rows.find(
(m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED',
);
if (paymentSettled && paymentSettled.status === 'PENDING') {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: bookingId },
select: { id: true, status: true, paymentStatus: true },
});
if (booking?.paymentStatus === 'PAID' || booking?.status === 'PAID') {
await this.completeForBooking(bookingId, 'FREIGHT_PAYMENT_SETTLED');
return this.repo.find({
where: { bookingId },
order: { sortOrder: 'ASC' },
});
}
}
return rows;
}
/**

View File

@@ -48,6 +48,7 @@ function makeService(milestones: ClearanceMilestone[]) {
contractsRepository as never,
milestoneService as never,
bookingsRepository as never,
{ clearanceReady: jest.fn() } as never, // notifier
);
return { service, milestoneService, contractsRepository, bookingsRepository };
}

View File

@@ -9,6 +9,7 @@ import { Contract } from './entities/contract.entity';
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { Booking } from '../bookings/entities/booking.entity';
import type { ClearanceMetaState } from './clearance-workflow.types';
import { metaFromBooking } from './clearance-workflow.types';
@@ -34,6 +35,7 @@ export class ClearanceWorkflowService {
private readonly contractsRepository: ContractsRepository,
private readonly milestoneService: ClearanceMilestoneService,
private readonly bookingsRepository: BookingsRepository,
private readonly notifier: BookingLifecycleNotifierService,
) {}
boundaryMilestone(tradeDirection: string): string {
@@ -264,6 +266,14 @@ export class ClearanceWorkflowService {
status: 'CLEARANCE_READY',
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
// Tell the customer clearance is done and operation can be requested. Load
// failure only skips the notice — the status change above already committed.
try {
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
if (booking) this.notifier.clearanceReady(booking);
} catch {
/* notification is best-effort */
}
}
resolvePhase(

View File

@@ -119,12 +119,27 @@ export class ContractBookingService {
const generalCustoms =
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
// there is no window and no date — staff accept them onto a train at
// finalize time, so both the window gate and scheduledDate are skipped.
const isIntercity = contract.tradeDirection === 'DOMESTIC';
if (isIntercity && dto.scheduledDate) {
throw new BadRequestException(
'Intercity bookings do not pick a date — staff assign them to a passing train',
);
}
// Every other direction keeps the binding shipment day (the DTO field went
// optional only for intercity).
if (!isIntercity && !dto.scheduledDate) {
throw new BadRequestException('A binding shipment day is required');
}
// Booking-window gate (config-driven): an operations booking may only be
// created while the route's booking window is open — import: the day's window
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
// export: within exportBookingLeadHours of departure. Customs Path B bookings
// enter clearance first and are scheduled later, so they are not gated here.
if (!generalCustoms) {
if (!generalCustoms && !isIntercity) {
await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,

View File

@@ -16,6 +16,7 @@ import { BookingsService } from '../bookings/bookings.service';
import { contractClearanceCodes } from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractNotifierService } from './contract-notifier.service';
import { GlOperationsService } from './gl-operations.service';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { Contract } from './entities/contract.entity';
@@ -118,6 +119,7 @@ export class ContractClearanceService {
private readonly milestoneService: ClearanceMilestoneService,
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService,
private readonly notifier: ContractNotifierService,
) {}
private isPhasedCustoms(contract: Contract): boolean {
@@ -543,7 +545,9 @@ export class ContractClearanceService {
await this.workflowService.onDocumentReviewReopened(contractId);
}
return this.contractsService.findById(contractId);
const updated = await this.contractsService.findById(contractId);
this.notifier.clearanceDocsUploadedToStaff(updated);
return updated;
}
private async assertRequiredInputsPresent(
@@ -674,6 +678,7 @@ export class ContractClearanceService {
status: 'AWAITING_CLEARANCE_DOCUMENTS',
clearanceStatus: 'AWAITING_DOCUMENTS',
} as never);
this.notifier.clearanceDocumentQueried(contract, fileKey, note ?? '');
if (cycle) {
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
}
@@ -1009,6 +1014,7 @@ export class ContractClearanceService {
},
userId,
);
this.notifier.dutyAdvised(contract, dto.amount, dto.currency ?? 'ETB');
}
return this.contractsService.findById(contractId);
@@ -1045,7 +1051,9 @@ export class ContractClearanceService {
});
}
return this.contractsService.findById(contractId);
const updated = await this.contractsService.findById(contractId);
this.notifier.dutySlipUploadedToStaff(updated);
return updated;
}
async uploadTransitPermit(
@@ -1118,6 +1126,7 @@ export class ContractClearanceService {
await this.workflowService.markReadyForBooking(contractId);
}
this.notifier.preClearanceFinalized(contract);
return this.contractsService.findById(contractId);
}

View File

@@ -0,0 +1,245 @@
import { Injectable, Logger } from '@nestjs/common';
import {
NotificationAudience,
NotificationType,
NotifyInput,
} from '@edr/types';
import { Contract } from './entities/contract.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
/**
* Customer + staff notifications for the contract lifecycle. Every customer
* event fans out over three channels: SMS + email (direct, via
* {@link NotificationsService}) and a persisted in-app notification (via
* {@link NotificationInboxService}) that deep-links to the contract detail page.
* Staff events go to the backoffice inbox. All sends are fire-and-forget and
* never throw — a notification failure must not break a contract transition.
*/
@Injectable()
export class ContractNotifierService {
private readonly logger = new Logger(ContractNotifierService.name);
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
) {}
private ref(c: Contract): string {
return `${c.reference}${c.isGovernment ? ' (gov)' : ''}`;
}
/** Send SMS + email to the contract's company contact; log-only on failure. */
private async notifyContact(
c: Contract,
message: string,
logLabel: string,
): Promise<void> {
this.logger.log(`${logLabel}${this.ref(c)}`);
const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null;
const email = c.company?.email ?? c.company?.generalManagerEmail ?? null;
if (phone) {
try {
await this.notifications.directSend('sms', phone, message);
} catch (err) {
this.logger.warn(`SMS failed for ${this.ref(c)}: ${(err as Error).message}`);
}
}
if (email) {
try {
await this.notifications.directSend('email', email, message);
} catch (err) {
this.logger.warn(`Email failed for ${this.ref(c)}: ${(err as Error).message}`);
}
}
if (!phone && !email) {
this.logger.warn(`No contact on file for ${this.ref(c)} — notification not sent`);
}
}
/** Persist + push an in-app item to all portal users of the contract's company. */
private inApp(
c: Contract,
title: string,
body: string,
overrides: Partial<NotifyInput> = {},
): void {
if (!c.companyId) return; // government/unlinked contracts have no portal users
void this.inbox.notify({
recipients: { companyId: c.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.CONTRACT_STATUS,
title,
body,
link: `/contracts/${c.id}`,
data: { contractId: c.id, reference: c.reference },
...overrides,
});
}
/** Persist + push an in-app item to every backoffice staff user. */
private inAppStaff(
c: Contract,
title: string,
body: string,
overrides: Partial<NotifyInput> = {},
): void {
void this.inbox.notify({
recipients: { allBackoffice: true },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED,
title,
body,
link: `/dashboard/contract-requests/${c.id}`,
data: { contractId: c.id, reference: c.reference },
...overrides,
});
}
// ── Customer-facing lifecycle events ───────────────────────────────────────
/** Line staff accepted intake → contract is under approval. */
accepted(c: Contract): void {
const msg =
`Your contract ${c.reference} has been accepted and is now under approval. ` +
`We will notify you once it is approved.`;
void this.notifyContact(c, msg, 'ACCEPTED');
this.inApp(c, 'Contract accepted', msg);
}
/** All approval steps complete → contract approved. */
approved(c: Contract): void {
const msg =
`Your contract ${c.reference} has been approved. ` +
`The final document will be prepared for signing.`;
void this.notifyContact(c, msg, 'APPROVED');
this.inApp(c, 'Contract approved', msg);
}
/** Fully executed (all parties signed) → contract active, customer can book. */
signedActive(c: Contract): void {
const msg =
`Your contract ${c.reference} has been signed and is now active. ` +
`You can start booking shipments from the portal.`;
void this.notifyContact(c, msg, 'SIGNED / ACTIVE');
this.inApp(c, 'Contract active', msg);
}
/** Staff rejected the contract. */
rejected(c: Contract, reason: string): void {
const msg =
`Your contract ${c.reference} was rejected. Reason: ${reason}. ` +
`Please contact us for details.`;
void this.notifyContact(c, msg, 'REJECTED');
this.inApp(c, 'Contract rejected', msg);
}
/** Staff requested changes before approval. */
changesRequested(c: Contract, note: string): void {
const msg =
`Changes were requested on your contract ${c.reference}: ${note}. ` +
`Please update and resubmit from the portal.`;
void this.notifyContact(c, msg, 'CHANGES REQUESTED');
this.inApp(c, 'Contract changes requested', msg);
}
// ── Clearance milestones needing customer action ──────────────────────────
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
dutyAdvised(c: Contract, amount: number, currency: string): void {
const msg =
`Duty & tax of ${amount} ${currency} has been advised for contract ${c.reference}. ` +
`Please pay and upload the payment slip from the portal.`;
void this.notifyContact(c, msg, 'DUTY ADVISED');
this.inApp(c, 'Duty & tax advised', msg, {
type: NotificationType.INVOICE_ISSUED,
link: `/contracts/${c.id}/clearance`,
});
}
/** A clearance document was queried — customer must re-upload it. */
clearanceDocumentQueried(c: Contract, fileKey: string, note: string): void {
const msg =
`A clearance document on contract ${c.reference} needs attention: "${fileKey}". ` +
`${note}. Please re-upload from the portal.`;
void this.notifyContact(c, msg, 'CLEARANCE DOC QUERIED');
this.inApp(c, 'Clearance document queried', msg, {
type: NotificationType.DOCUMENT_ACTION,
link: `/contracts/${c.id}/clearance`,
});
}
/** Import pre-clearance finalized — the process moves to GL Djibouti collection. */
preClearanceFinalized(c: Contract): void {
const msg =
`Pre-clearance for contract ${c.reference} is complete. ` +
`Your shipment is proceeding to document collection in Djibouti.`;
void this.notifyContact(c, msg, 'PRE-CLEARANCE FINALIZED');
this.inApp(c, 'Pre-clearance complete', msg, {
type: NotificationType.CLEARANCE_DECISION,
link: `/contracts/${c.id}/clearance`,
});
}
// ── Staff-facing (backoffice inbox) ────────────────────────────────────────
/** Customer submitted a contract for review. */
submittedToStaff(c: Contract): void {
this.inAppStaff(
c,
'New contract submitted',
`Contract ${this.ref(c)} was submitted and is awaiting intake review.`,
);
}
/** Customer signed the contract — staff counter-sign is next. */
customerSignedToStaff(c: Contract): void {
this.inAppStaff(
c,
'Customer signed contract',
`Contract ${this.ref(c)} was signed by the customer and awaits the EDR counter-signature.`,
{ link: `/dashboard/contract-requests/${c.id}/view` },
);
}
/** Customer uploaded clearance documents — GL review is next. */
clearanceDocsUploadedToStaff(c: Contract): void {
this.inAppStaff(
c,
'Clearance documents uploaded',
`Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`,
{
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/contracts/clearance/${c.id}`,
},
);
}
/** Customer uploaded the duty/tax payment slip — GL verifies it. */
dutySlipUploadedToStaff(c: Contract): void {
this.inAppStaff(
c,
'Duty slip uploaded',
`Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`,
{
type: NotificationType.PAYMENT_RECEIVED,
link: `/dashboard/contracts/clearance/${c.id}`,
},
);
}
/** Customer filed a shipment request under a GENERAL customs contract. */
shipmentRequestedToStaff(c: Contract, requestId: string, requestRef: string): void {
this.inAppStaff(
c,
'New shipment request',
`Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`,
{
link: `/dashboard/shipment-requests/${requestId}`,
data: { contractId: c.id, requestId, reference: requestRef },
},
);
}
}

View File

@@ -22,6 +22,7 @@ import { FilesService } from '../files/files.service';
import { SignaturesService } from '../signatures/signatures.service';
import { OtpService } from '../otp/otp.service';
import { ContractPricingService } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository';
import { ContractsService } from './contracts.service';
@@ -66,6 +67,7 @@ export class ContractTransitionService {
private readonly pdfService: ContractPdfService,
private readonly minioService: MinioService,
private readonly otpService: OtpService,
private readonly notifier: ContractNotifierService,
) {}
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
@@ -79,7 +81,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'SUBMITTED',
} as never);
return this.contractsService.findById(contractId);
const updated = await this.contractsService.findById(contractId);
this.notifier.submittedToStaff(updated);
return updated;
}
/** Confirm a price change before submit (mirrors booking confirm-submit). */
@@ -93,7 +97,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'SUBMITTED',
} as never);
return this.contractsService.findById(contractId);
const updated = await this.contractsService.findById(contractId);
this.notifier.submittedToStaff(updated);
return updated;
}
/**
@@ -130,7 +136,9 @@ export class ContractTransitionService {
contractValidFrom: validFrom,
contractValidUntil: validUntil,
} as never);
return this.contractsService.findById(contractId);
const updated = await this.contractsService.findById(contractId);
this.notifier.accepted(updated);
return updated;
}
/**
@@ -218,7 +226,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'CHANGES_REQUESTED',
} as never);
return this.contractsService.findById(contractId);
const updated = await this.contractsService.findById(contractId);
this.notifier.changesRequested(updated, note);
return updated;
}
async reject(contractId: string, reason: string, actorId: string): Promise<Contract> {
@@ -235,7 +245,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'REJECTED',
} as never);
return this.contractsService.findById(contractId);
const updated = await this.contractsService.findById(contractId);
this.notifier.rejected(updated, reason);
return updated;
}
/** Approve one approval step in sequence; → APPROVED when all complete. */
@@ -297,7 +309,11 @@ export class ContractTransitionService {
if (Object.keys(updates).length > 0) {
await this.contractsRepository.update(contractId, updates as never);
}
return this.contractsService.findById(contractId);
const updated = await this.contractsService.findById(contractId);
if (allDone) {
this.notifier.approved(updated);
}
return updated;
}
/**
@@ -535,7 +551,9 @@ export class ContractTransitionService {
customerSignedAt: new Date(),
} as never);
await this.regenerateContractPdf(contractId, contract.reference);
return this.contractsService.findById(contractId);
const updated = await this.contractsService.findById(contractId);
this.notifier.customerSignedToStaff(updated);
return updated;
}
return this.counterSign(contractId, dto, options);
@@ -605,7 +623,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, updates as never);
await this.regenerateContractPdf(contractId, contract.reference);
return this.contractsService.findById(contractId);
const updated = await this.contractsService.findById(contractId);
this.notifier.signedActive(updated);
return updated;
}
/** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */

View File

@@ -13,10 +13,12 @@ import {
UnauthorizedException,
UploadedFiles,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import {
@@ -242,6 +244,7 @@ export class ContractsController {
}
@Get('list-summary')
@BookingStaff([FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.contracts.view])
@ApiOperation({ summary: 'Contract list metrics and status counts (backoffice)' })
@ApiOkResponse({ type: ContractListSummaryDto })
findListSummary(@Query() filter: FilterContractDto) {
@@ -449,14 +452,25 @@ export class ContractsController {
}
@Post(':id/contract/sign')
@UseGuards(JwtGuard)
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
signContract(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
// Each staff signing role maps to the permission that step already requires;
// customers sign their own contract with no permission key.
const signRolePermission: Record<string, string> = {
STAFF: FREIGHT_PERMS.contracts.signStaff,
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
CEO: FREIGHT_PERMS.contracts.approveCeo,
};
if (dto.role !== 'CUSTOMER') {
assertFreightPermission(user, signRolePermission[dto.role]);
}
return this.transitionService.sign(id, dto, {
signerUserId: user?.id ?? user?.sub,
signerUserId: user?.id,
});
}

View File

@@ -12,6 +12,8 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { OtpModule } from '../otp/otp.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { BookingsModule } from '../bookings/bookings.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
@@ -19,6 +21,7 @@ import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service';
import { ContractsRepository } from './contracts.repository';
import { ContractPricingService } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ContractTransitionService } from './contract-transition.service';
import { ContractClearanceService } from './contract-clearance.service';
import { BookingClearanceService } from './booking-clearance.service';
@@ -75,6 +78,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
MinioModule,
SignaturesModule,
OtpModule,
NotificationsModule,
NotificationInboxModule,
CompaniesModule,
// BookingsModule provides BookingsRepository/BookingPricingService used by the
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
@@ -94,6 +99,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ContractsService,
ContractsRepository,
ContractPricingService,
ContractNotifierService,
ContractTransitionService,
ContractClearanceService,
ClearanceWorkflowService,

View File

@@ -8,10 +8,14 @@ import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { insertWithGeneratedReference } from '@edr/api-common';
import { YardCountry } from '@edr/types';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { CompaniesService } from '../companies/companies.service';
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyStatus } from '../companies/entities/company.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { ContractsRepository } from './contracts.repository';
@@ -110,6 +114,49 @@ export class ContractsService {
}
}
/**
* Every route must match the contract's declared trade direction as derived
* from the yard countries (IMPORT = DJ→ET, EXPORT = ET→DJ, DOMESTIC =
* intercity). Intercity is Ethiopian-domestic only: both yards must be in
* Ethiopia — a Djibouti-internal pair is rejected. Direction mismatches
* (e.g. an export lane on an import contract) are rejected for every kind.
*/
private async assertRoutesMatchDirection(
tradeDirection: string,
routes: CreateContractDto['routes'],
): Promise<void> {
const yardIds = [
...new Set(routes.flatMap((r) => [r.originYardId, r.destinationYardId])),
];
const yards = await this.dataSource
.getRepository(Yard)
.find({ where: yardIds.map((id) => ({ id })) });
const yardById = new Map(yards.map((y) => [y.id, y]));
for (const route of routes) {
const origin = yardById.get(route.originYardId);
const destination = yardById.get(route.destinationYardId);
if (!origin || !destination) {
throw new BadRequestException('Route references a yard that does not exist');
}
const derived = deriveTradeDirection(origin, destination);
if (derived !== tradeDirection) {
throw new BadRequestException(
`Route ${origin.label}${destination.label} is ${derived === 'DOMESTIC' ? 'an intercity' : `an ${derived.toLowerCase()}`} lane and does not match the contract's ${tradeDirection === 'DOMESTIC' ? 'intercity' : tradeDirection.toLowerCase()} direction`,
);
}
if (
derived === 'DOMESTIC' &&
(origin.country !== YardCountry.ETHIOPIA ||
destination.country !== YardCountry.ETHIOPIA)
) {
throw new BadRequestException(
`Route ${origin.label}${destination.label}: intercity service only runs between Ethiopian yards`,
);
}
}
}
/** Create a new contract (DRAFT) with its routes and cargo-scope rows. */
async create(
dto: CreateContractDto,
@@ -144,6 +191,7 @@ export class ContractsService {
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
this.assertRouteShape(dto.contractKind, dto.routes);
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
// Stamp the operational profile (importer/exporter) for portal scoping.
let companyProfileId: string | null = null;
@@ -175,6 +223,13 @@ export class ContractsService {
// Customs clearing is owned by the service type, not the customer.
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
// Intercity never crosses a border, so a customs-including service type is
// a contradiction — the wizard hides them, the API enforces it.
if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) {
throw new BadRequestException(
'Intercity contracts cannot use a service type that includes customs clearing',
);
}
// An explicit reference is caller-chosen — a collision there is a real
// conflict and should surface. Auto-generated references retry past a
@@ -360,6 +415,12 @@ export class ContractsService {
if (dto.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope);
if (dto.routes) this.assertRouteShape(contractKind, dto.routes);
if (dto.routes) {
await this.assertRoutesMatchDirection(
dto.tradeDirection ?? existing.tradeDirection,
dto.routes,
);
}
const updates: Record<string, unknown> = {
contractKind,
@@ -385,6 +446,11 @@ export class ContractsService {
const includesCustoms = await this.resolveIncludesCustoms(
dto.serviceTypeId ?? existing.serviceTypeId,
);
if ((dto.tradeDirection ?? existing.tradeDirection) === 'DOMESTIC' && includesCustoms) {
throw new BadRequestException(
'Intercity contracts cannot use a service type that includes customs clearing',
);
}
updates.customsClearingEnabled = includesCustoms;
updates.customsClearingAgent = includesCustoms
? null
@@ -501,6 +567,21 @@ export class ContractsService {
);
}
// Surface the staff "request changes" note so the portal can show the
// customer what to fix. Degrade to null on lookup failure — a missing note
// must never 500 a contract fetch.
if (contract.status === 'CHANGES_REQUESTED') {
try {
const note = await this.contractsRepository.findLatestReviewNote(
contract.id,
'CHANGES_REQUESTED',
);
contract.latestChangeRequestNote = note?.body ?? null;
} catch {
contract.latestChangeRequestNote = null;
}
}
return contract;
}

View File

@@ -120,9 +120,14 @@ export class CreateBookingUnderContractDto {
@IsUUID()
contractRouteId?: string;
@ApiProperty({ description: 'Binding shipment day.', example: '2026-07-15' })
@ApiPropertyOptional({
description:
'Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later.',
example: '2026-07-15',
})
@IsOptional()
@IsDateString()
scheduledDate!: string;
scheduledDate?: string;
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
@IsOptional()

View File

@@ -260,4 +260,11 @@ export class Contract extends BaseEntity {
* ContractsRepository.attachClearancePhases for list responses. Not a column.
*/
clearancePhase?: string | null;
/**
* Body of the most recent CHANGES_REQUESTED review note, attached by
* ContractsService.findById so the portal can show the customer what staff
* asked them to fix. Lives in contract_review_notes, not a column here.
*/
latestChangeRequestNote?: string | null;
}

View File

@@ -11,6 +11,7 @@ import { BillingService } from '../billing/billing.service';
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
import { FilesService } from '../files/files.service';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity';
import {
@@ -53,6 +54,7 @@ export class GlOperationsService {
private readonly filesService: FilesService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly billingService: BillingService,
private readonly notifier: BookingLifecycleNotifierService,
) {}
private get bookings() {
@@ -64,7 +66,11 @@ export class GlOperationsService {
}
private async getBooking(bookingId: string): Promise<Booking> {
const booking = await this.bookings.findOne({ where: { id: bookingId } });
// company is loaded so customer notifications have a phone/email to target.
const booking = await this.bookings.findOne({
where: { id: bookingId },
relations: { company: true },
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
return booking;
}
@@ -447,6 +453,7 @@ export class GlOperationsService {
void userId;
const summary = await this.finalInvoiceSummary(bookingId);
if (!summary) throw new NotFoundException('Final invoice could not be created.');
this.notifier.finalInvoiceCreated(booking, input.amount, input.currency);
return summary;
}
@@ -455,7 +462,7 @@ export class GlOperationsService {
bookingId: string,
file: Express.Multer.File,
): Promise<{ uploaded: boolean }> {
await this.getBooking(bookingId);
const booking = await this.getBooking(bookingId);
if (!file) throw new BadRequestException('No payment slip uploaded');
const invoice = await this.billingService.findInvoice(
@@ -482,6 +489,7 @@ export class GlOperationsService {
code: 'final_invoice_slip',
file,
});
this.notifier.dutySlipUploadedToStaff(booking, 'final');
return { uploaded: true };
}
@@ -490,7 +498,7 @@ export class GlOperationsService {
bookingId: string,
userId?: string,
): Promise<Freight.ClearanceFinalInvoiceSummary> {
await this.getBooking(bookingId);
const booking = await this.getBooking(bookingId);
const invoice = await this.billingService.findInvoice(
Freight.InvoiceSource.Booking,
bookingId,
@@ -507,6 +515,7 @@ export class GlOperationsService {
);
}
await this.billingService.markInvoiceAsPaid(invoice.id);
this.notifier.finalInvoicePaid(booking);
}
void userId;
@@ -575,6 +584,7 @@ export class GlOperationsService {
},
userId,
);
this.notifier.secondDutyAdvised(booking, input.amount, input.currency ?? 'ETB');
return { advised: true, skipped: false };
}
@@ -605,6 +615,7 @@ export class GlOperationsService {
booking.tradeDirection ?? 'IMPORT',
);
await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID');
this.notifier.dutySlipUploadedToStaff(booking, 'second');
return { milestoneCompleted: true };
}

View File

@@ -70,6 +70,18 @@ export class NotificationRecipientsService {
}
}
if (recipients.allBackoffice) {
try {
for (const uid of await this.backoffice.getAllCurrentEmployeeUserIds()) {
ids.add(uid);
}
} catch (err) {
this.logger.warn(
`Failed to resolve allBackoffice recipients: ${(err as Error).message}`,
);
}
}
return [...ids];
}
}

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from '@edr/api-common';
import type { ScheduleTradeDirection } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
@@ -26,6 +27,14 @@ export class Route extends BaseEntity {
@Column({ name: 'status', type: 'varchar', length: 32, default: 'AVAILABLE' })
status!: RouteStatus;
/**
* Trade direction frozen from the yard countries at create/update
* (ET→DJ = EXPORT, DJ→ET = IMPORT, same country = DOMESTIC/"Intercity").
* Consumers (scheduling, booking windows) read this instead of re-deriving.
*/
@Column({ name: 'direction', type: 'varchar', length: 10 })
direction!: ScheduleTradeDirection;
@OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false })
milestones?: RouteMilestone[];
}

View File

@@ -1,6 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
@@ -83,6 +84,7 @@ export class RoutesService {
originYardId: validated.originYardId,
destinationYardId: validated.destinationYardId,
status: dto.status ?? 'AVAILABLE',
direction: validated.direction,
}),
);
@@ -115,6 +117,7 @@ export class RoutesService {
originYardId: milestoneInput?.originYardId ?? existing.originYardId,
destinationYardId:
milestoneInput?.destinationYardId ?? existing.destinationYardId,
...(milestoneInput ? { direction: milestoneInput.direction } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
});
@@ -187,9 +190,18 @@ export class RoutesService {
throw new BadRequestException('Origin and destination yards must be different');
}
const originYardId = normalized[0].yardId;
const destinationYardId = normalized[normalized.length - 1].yardId;
const yardById = new Map(yards.map((yard) => [yard.id, yard]));
const direction = deriveTradeDirection(
yardById.get(originYardId) ?? { country: null },
yardById.get(destinationYardId) ?? { country: null },
);
return {
originYardId: normalized[0].yardId,
destinationYardId: normalized[normalized.length - 1].yardId,
originYardId,
destinationYardId,
direction,
milestones: normalized,
};
}

View File

@@ -1,5 +1,6 @@
import { YardCountry } from '@edr/types';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import { IsBoolean, IsEnum, IsInt, IsOptional, IsUUID, MaxLength, Min, IsString } from 'class-validator';
export class CreateYardDto {
@ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 })
@@ -7,10 +8,9 @@ export class CreateYardDto {
@MaxLength(100)
label!: string;
@ApiProperty({ description: 'Country where the yard is located, e.g. Ethiopia, Djibouti', maxLength: 50 })
@IsString()
@MaxLength(50)
country!: string;
@ApiProperty({ enum: YardCountry, description: 'Country where the yard is located' })
@IsEnum(YardCountry)
country!: YardCountry;
@ApiPropertyOptional({ default: true })
@IsOptional()

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from '@edr/api-common';
import { YardCountry } from '@edr/types';
import { Column, Entity, Index } from 'typeorm';
@Entity({ schema: 'freight', name: 'yards' })
@@ -12,8 +13,11 @@ export class Yard extends BaseEntity {
@Column({ name: 'label', type: 'varchar', length: 100 })
label!: string;
// Constrained to YardCountry by DTO validation + a DB CHECK constraint;
// route/schedule trade direction is derived from this value. Typed as the
// enum's literal values so plain strings from seeds/queries still fit.
@Column({ name: 'country', type: 'varchar', length: 50 })
country!: string;
country!: `${YardCountry}`;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;

View File

@@ -78,6 +78,11 @@ describe('SchedulingRescheduleService', () => {
bookingsRepository as never,
trainSchedulingService as never,
schedulingRescheduleRepository as never,
{
rescheduled: jest.fn(),
removedFromTrain: jest.fn(),
maintenanceMoved: jest.fn(),
} as never, // notifier
);
});

View File

@@ -10,6 +10,7 @@ import { BookingsRepository } from '../bookings/bookings.repository';
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { BookingNotifierService } from '../train-scheduling/booking-notifier.service';
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
@@ -38,6 +39,7 @@ export class SchedulingRescheduleService {
private readonly bookingsRepository: BookingsRepository,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository,
private readonly notifier: BookingNotifierService,
) {}
/** Preview who is retained, displaced, and readmitted on a schedule. */
@@ -193,9 +195,64 @@ export class SchedulingRescheduleService {
displacedBookingIds: dto.displacedBookingIds,
});
// Notify affected customers (SMS + email). Best-effort — a notification
// failure must never fail the reschedule, so each send is fire-and-forget
// inside the notifier. Government pre-empt already notifies via the batch
// displaced() path, so skip removed-from-train notices for that trigger.
// Use the new departure date when the reschedule moved it (the in-memory
// `schedule` still holds the pre-update date).
const effectiveDeparture = dto.newDepartureDate
? new Date(dto.newDepartureDate)
: schedule.scheduledDepartureDate;
await this.notifyRescheduleOutcome(dto, effectiveDeparture);
return { plan, schedule: assignResult };
}
/**
* Fan out reschedule notifications: bookings that stayed on the train hear the
* new departure date; bookings dropped off the train (staff reschedule, not a
* government pre-empt) hear they were removed. Loads each booking with its
* company so the notifier has a phone/email to reach.
*/
private async notifyRescheduleOutcome(
dto: ExecuteRescheduleDto,
newDeparture: Date | null,
): Promise<void> {
const isMaintenance = dto.trigger === 'TRAIN_MAINTENANCE';
const isGovPreempt = dto.trigger === 'GOVERNMENT_PREEMPT';
if (newDeparture) {
for (const bookingId of dto.finalBookingIds) {
const booking = await this.loadBookingForNotify(bookingId);
if (!booking) continue;
if (isMaintenance) {
this.notifier.maintenanceMoved(booking, newDeparture);
} else {
this.notifier.rescheduled(booking, newDeparture);
}
}
}
// Government pre-empt displacements are already announced by the batch
// displaced() notice — don't double-notify. Staff reschedules are not.
if (!isGovPreempt) {
for (const bookingId of dto.displacedBookingIds) {
const booking = await this.loadBookingForNotify(bookingId);
if (!booking) continue;
this.notifier.removedFromTrain(booking);
}
}
}
private async loadBookingForNotify(bookingId: string): Promise<Booking | null> {
try {
return await this.bookingsRepository.findByIdWithFiles(bookingId);
} catch {
return null;
}
}
/** Maintenance shortcut: new departure + rebalance. */
async maintenanceReschedule(
scheduleId: string,

View File

@@ -302,3 +302,41 @@ describe('batch-window board windows (config-driven booking cycles)', () => {
expect(withEarly?.window?.label).toContain('08:00');
});
});
// Regression: a schedule created INSIDE its own window day must open right away
// when the desk is open, and re-deriving after a settings change (close hour
// extended past "now", or lead pulled so the window day becomes today) must
// yield an immediate open — not tomorrow morning.
describe('computeImportWindowTimes — immediate open inside the window day', () => {
// 19:15:17 EAT on Mon 6 Jul = 16:15:17 UTC
const now = new Date('2026-07-06T16:15:17.000Z');
// Departs Thu 9 Jul ~08:53 EAT
const departure = new Date('2026-07-09T05:53:00.000Z');
const base = { importWindowLeadDays: 3, windowOpenHour: 8, windowDurationHours: 0.05 };
it('desk 823, created 19:15 on the window day → opens NOW', () => {
const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 23 }, now);
expect(t.windowOpensAt.getTime()).toBe(now.getTime());
});
it('desk 817, created 19:15 (desk shut) → opens next morning 08:00 EAT', () => {
const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 17 }, now);
expect(t.windowOpensAt.toISOString()).toBe('2026-07-07T05:00:00.000Z');
});
it('close hour extended 17 → 23 after hours: re-derive opens NOW', () => {
// Same call restampPendingWindows makes after the global-rules edit.
const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 23 }, now);
expect(t.windowOpensAt.getTime()).toBe(now.getTime());
});
it('lead 3 → 4 pulls the window day to today: re-derive opens NOW', () => {
const departsJul10 = new Date('2026-07-10T05:53:00.000Z');
const t = computeImportWindowTimes(
departsJul10,
{ ...base, importWindowLeadDays: 4, windowCloseHour: 23 },
now,
);
expect(t.windowOpensAt.getTime()).toBe(now.getTime());
});
});

View File

@@ -282,15 +282,33 @@ export function computeImportWindowTimes(
return { windowOpensAt: opensAt, windowClosesAt: closesAt };
}
/** Export booking window: FCFS from `exportBookingLeadHours` before departure until departure. */
/**
* Export booking window: a single FCFS window from `exportBookingLeadHours`
* before departure until departure. The open honours the daily desk hours —
* when the raw lead instant lands while the desk is shut, the window opens at
* the next desk opening instead (capped at departure, so a config whose desk
* never opens before the train leaves yields a zero-length window rather than
* one that outlives the train).
*/
export function computeExportWindowTimes(
departure: Date,
cfg: { exportBookingLeadHours: number },
cfg: {
exportBookingLeadHours: number;
windowOpenHour: number;
windowCloseHour: number;
},
): InitialWindowTimes {
return {
windowOpensAt: new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000),
windowClosesAt: departure,
};
const rawOpen = new Date(
departure.getTime() - cfg.exportBookingLeadHours * 3_600_000,
);
let opensAt = officeHoursOpen(rawOpen, {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
});
if (opensAt.getTime() > departure.getTime()) {
opensAt = departure;
}
return { windowOpensAt: opensAt, windowClosesAt: departure };
}
/**
@@ -421,7 +439,9 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
* after each close, on the same booking day, until departure. This mirrors
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
* exact windows the engine runs.
* EXPORT: a single FCFS window from `departure exportBookingLeadHours` to departure.
* EXPORT: a single FCFS window from `departure exportBookingLeadHours` to departure,
* with the open shifted to the next desk opening when it lands outside office hours
* (same math as `computeExportWindowTimes`).
*
* `anchorOpensAt` pins the FIRST window's open time to the schedule's stored
* `windowOpensAt` instead of recomputing it from config. Pass it so the board
@@ -436,8 +456,7 @@ export function listConfigBookingWindows(
): BoardWindow[] {
if (direction === 'EXPORT') {
const start =
anchorOpensAt ??
new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000);
anchorOpensAt ?? computeExportWindowTimes(departure, cfg).windowOpensAt;
return [boardWindowFromInterval(start, departure)];
}

View File

@@ -122,6 +122,7 @@ describe('BookingBatchService — PAID reconcile', () => {
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
{ emitPhase: jest.fn() } as never,
);
});

View File

@@ -40,10 +40,11 @@ import {
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { BookingSplitService } from './booking-split.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
/** A train's remaining capacity along the three physical limits the batch enforces. */
interface Capacity {
export interface Capacity {
wagons: number;
weightTons: number;
lengthMeters: number;
@@ -204,6 +205,7 @@ export class BookingBatchService implements OnModuleInit {
private readonly scheduler: SchedulerRegistry,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly billing: BillingService,
private readonly bookingWindowGateway: BookingWindowGateway,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
@Optional() private readonly splitService?: BookingSplitService,
@@ -380,6 +382,18 @@ export class BookingBatchService implements OnModuleInit {
this.logger.log(
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
);
} else {
// Already linked at booking time (export FCFS: the customer books a
// specific train, so allocate() ran up front). allocate() is where the
// payment-settled tracking milestones are written, so on this branch we
// record them here — otherwise a paid, already-linked booking leaves
// FREIGHT_PAYMENT_SETTLED stuck PENDING and the clearance step never ticks.
void this.completeTrackingMilestones(bookingId, [
"WAGON_REQUESTED",
"FREIGHT_PAYMENT_PENDING",
"FREIGHT_PAYMENT_SETTLED",
]);
void this.markWagonAllocatedMilestone(bookingId);
}
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
@@ -1444,6 +1458,48 @@ export class BookingBatchService implements OnModuleInit {
await this.fillSchedule(booking.trainScheduleId);
}
// ---- intercity ride-along API ---------------------------------------------
/**
* Remaining capacity budget (wagons / weight / length) for a schedule, and
* the per-booking need calculator — exposed for the intercity accept flow,
* which reserves ride-along bookings onto import/export trains outside the
* batch engine.
*/
async intercityCapacity(scheduleId: string): Promise<{
budget: Capacity;
needFor: (booking: Booking) => Capacity;
} | null> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !locomotive) return null;
const rules = await this.loadGlobalRules();
const wagonLengths = await this.loadWagonLengths();
const limits = await this.capacityLimits(locomotive, rules);
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) };
}
/**
* Accept an intercity booking onto the given train. Commercial bookings get
* the same pay-window lifecycle as a batch reservation (deadline, invoice
* due-date sync, pay-now notify, settle on the window tick), so payment →
* allocation needs no special path. Government bookings allocate directly.
*/
async acceptIntercity(booking: Booking, scheduleId: string): Promise<void> {
if (booking.isGovernment) {
await this.dataSource
.getRepository(Booking)
.update(booking.id, { trainScheduleId: scheduleId });
booking.trainScheduleId = scheduleId;
await this.allocate(scheduleId, booking, 'gov');
return;
}
await this.reserve(booking, scheduleId);
this.armSettle(scheduleId);
}
// ---- mutations ------------------------------------------------------------
/**
@@ -1473,6 +1529,12 @@ export class BookingBatchService implements OnModuleInit {
"PREPAID",
);
await this.notifier.payNow(booking, deadline);
// Customer tracking: a wagon slot is reserved and the freight pay window is
// open. Doc-trigger path — silent no-op for bookings without milestone rows.
void this.completeTrackingMilestones(booking.id, [
"WAGON_REQUESTED",
"FREIGHT_PAYMENT_PENDING",
]);
}
/** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */
@@ -1504,6 +1566,15 @@ export class BookingBatchService implements OnModuleInit {
this.notifier.secured(booking, reason);
void this.triggerWagonAllocation(scheduleId);
void this.markWagonAllocatedMilestone(booking.id);
// Customer tracking: freight payment settled (commercial pay-window path).
// Government allocations don't pay upfront — theirs stay pending.
if (reason === 'paid') {
void this.completeTrackingMilestones(booking.id, [
'WAGON_REQUESTED',
'FREIGHT_PAYMENT_PENDING',
'FREIGHT_PAYMENT_SETTLED',
]);
}
}
private async markWagonAllocatedMilestone(bookingId: string): Promise<void> {
@@ -1515,6 +1586,27 @@ export class BookingBatchService implements OnModuleInit {
}
}
/**
* Complete customer-tracking milestones on lifecycle events via the
* doc-trigger path — a silent no-op for bookings without milestone rows
* (non-customs bookings). Never blocks the batch action.
*/
private async completeTrackingMilestones(
bookingId: string,
codes: string[],
): Promise<void> {
if (!this.milestoneService) return;
for (const code of codes) {
try {
await this.milestoneService.completeByDocTrigger({ bookingId }, code);
} catch (err) {
this.logger.warn(
`Milestone ${code} completion failed for booking ${bookingId}: ${(err as Error).message}`,
);
}
}
}
/**
* Expire an unpaid reservation and free its capacity. With day-level pooling we
* also clear `trainScheduleId` so the booking is no longer pinned to the train
@@ -1831,6 +1923,17 @@ export class BookingBatchService implements OnModuleInit {
await this.dataSource
.getRepository(TrainSchedule)
.update(scheduleId, { bookingWindowStatus: status });
// Push the change (open / train full / closed) so portal home and GL cards
// flip in real time — FULL in particular happens outside the window tick
// (batch fill, staff mark-paid) and had no live signal before.
try {
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
if (fresh) this.bookingWindowGateway.emitPhase(fresh);
} catch (err) {
this.logger.warn(
`Booking-window push failed for ${scheduleId}: ${(err as Error).message}`,
);
}
}
/** No wagon slots left for allocated + reserved bookings. */

View File

@@ -1,13 +1,23 @@
import { Injectable, Logger } from '@nestjs/common';
import {
NotificationAudience,
NotificationType,
NotifyInput,
} from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { BATCH_TIMEZONE } from './booking-batch.constants';
@Injectable()
export class BookingNotifierService {
private readonly logger = new Logger(BookingNotifierService.name);
constructor(private readonly notifications: NotificationsService) {}
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
) {}
private ref(b: Booking): string {
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
@@ -41,11 +51,34 @@ export class BookingNotifierService {
}
}
/** Persist + push an in-app item to all portal users of the booking's company. */
private inApp(
b: Booking,
title: string,
body: string,
overrides: Partial<NotifyInput> = {},
): void {
if (!b.companyId) return; // government/unlinked bookings have no portal users
void this.inbox.notify({
recipients: { companyId: b.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.SCHEDULE_UPDATE,
title,
body,
link: `/bookings/${b.id}`,
data: { bookingId: b.id, reference: b.reference },
...overrides,
});
}
async payNow(b: Booking, deadline: Date): Promise<void> {
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`;
await this.notifyContact(b, msg, 'PAY NOW');
this.inApp(b, 'Payment window open', msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
/**
@@ -66,6 +99,9 @@ export class BookingNotifierService {
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` +
`(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
this.inApp(b, 'Partial allocation offer', msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
secured(b: Booking, reason: 'paid' | 'gov'): void {
@@ -73,11 +109,13 @@ export class BookingNotifierService {
reason === 'gov' ? ' (government)' : ''
}.`;
void this.notifyContact(b, msg, 'ALLOCATED');
this.inApp(b, 'Wagon allocated', msg);
}
expired(b: Booking): void {
const msg = `Payment window expired for booking ${b.reference ?? b.id}. Reschedule or cancel — no re-approval needed.`;
void this.notifyContact(b, msg, 'EXPIRED');
this.inApp(b, 'Payment window expired', msg);
}
scheduleFull(b: Booking): void {
@@ -100,5 +138,42 @@ export class BookingNotifierService {
displaced(b: Booking): void {
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
void this.notifyContact(b, msg, 'DISPLACED');
this.inApp(b, 'Booking displaced', msg);
}
/**
* Staff rescheduled the train carrying this booking to a new departure date.
* The booking stays on the train — only the date moved.
*/
rescheduled(b: Booking, newDeparture: Date): void {
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
const msg = `Booking ${b.reference ?? b.id} has been rescheduled. New departure date: ${when}.`;
void this.notifyContact(b, msg, 'RESCHEDULED');
this.inApp(b, 'Booking rescheduled', msg);
}
/**
* Booking was removed from its train during a staff reschedule (not a government
* pre-empt). It returns to eligible — the customer must rebook or reschedule.
*/
removedFromTrain(b: Booking): void {
const msg =
`Booking ${b.reference ?? b.id} has been removed from its train during rescheduling. ` +
`Please rebook or select a new schedule from the portal.`;
void this.notifyContact(b, msg, 'REMOVED FROM TRAIN');
this.inApp(b, 'Removed from train', msg);
}
/**
* The train carrying this booking was moved for maintenance to a new departure
* date. The booking stays on the train — only the date moved.
*/
maintenanceMoved(b: Booking, newDeparture: Date): void {
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
const msg =
`The train for booking ${b.reference ?? b.id} was rescheduled for maintenance. ` +
`New departure date: ${when}.`;
void this.notifyContact(b, msg, 'MAINTENANCE RESCHEDULE');
this.inApp(b, 'Train maintenance reschedule', msg);
}
}

View File

@@ -0,0 +1,109 @@
import { BOOKING_WINDOW_WS_EVENTS, BOOKING_WINDOW_WS_NAMESPACE } from '@edr/types';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { io, type Socket } from 'socket.io-client';
import { WsAuthService } from '../notification-inbox/ws-auth.service';
import { BookingWindowGateway } from './booking-window.gateway';
import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
/**
* End-to-end proof the booking-window socket works: boots a real Nest app with
* the gateway, connects a real socket.io client to the namespace, emits a phase
* change, and asserts the client receives the exact payload. If this passes,
* any "no live update" report is environmental (stale server process, wrong
* checkout running, client not connecting) — not the gateway.
*/
describe('BookingWindowGateway (e2e)', () => {
let app: INestApplication;
let gateway: BookingWindowGateway;
let client: Socket;
let baseUrl: string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
BookingWindowGateway,
// Accept any token — auth plumbing is covered by the real WsAuthService.
{ provide: WsAuthService, useValue: { resolveUserId: async () => 'user-1' } },
],
}).compile();
app = moduleRef.createNestApplication();
await app.listen(0);
const address = app.getHttpServer().address() as { port: number };
baseUrl = `http://127.0.0.1:${address.port}`;
gateway = app.get(BookingWindowGateway);
});
afterAll(async () => {
client?.disconnect();
await app?.close();
});
it('authenticated client receives the phase event with the schedule state', async () => {
client = io(`${baseUrl}/${BOOKING_WINDOW_WS_NAMESPACE}`, {
auth: { token: 'any' },
transports: ['websocket'],
});
await new Promise<void>((resolve, reject) => {
client.on('connect', () => resolve());
client.on('connect_error', (err) => reject(err));
});
const received = new Promise<Record<string, unknown>>((resolve) => {
client.on(BOOKING_WINDOW_WS_EVENTS.PHASE, (payload) => resolve(payload));
});
gateway.emitPhase({
id: 'sched-1',
originStationId: 'yard-a',
destinationStationId: 'yard-b',
direction: 'IMPORT',
windowPhase: 'OPEN',
bookingWindowStatus: 'OPEN',
bookingCycleNo: 2,
windowOpensAt: new Date('2026-07-06T16:15:00Z'),
windowClosesAt: new Date('2026-07-06T16:18:00Z'),
docReviewEndsAt: null,
paymentPhaseEndsAt: null,
scheduledDepartureDate: new Date('2026-07-09T05:53:00Z'),
} as unknown as TrainSchedule);
const payload = await received;
expect(payload).toMatchObject({
scheduleId: 'sched-1',
phase: 'OPEN',
bookingWindowStatus: 'OPEN',
bookingCycleNo: 2,
windowOpensAt: '2026-07-06T16:15:00.000Z',
});
});
it('rejects a client whose token does not resolve to a user', async () => {
const moduleRef = await Test.createTestingModule({
providers: [
BookingWindowGateway,
{ provide: WsAuthService, useValue: { resolveUserId: async () => null } },
],
}).compile();
const rejectingApp = moduleRef.createNestApplication();
await rejectingApp.listen(0);
const addr = rejectingApp.getHttpServer().address() as { port: number };
const rejected = io(`http://127.0.0.1:${addr.port}/${BOOKING_WINDOW_WS_NAMESPACE}`, {
auth: { token: 'bad' },
transports: ['websocket'],
reconnection: false,
});
const outcome = await new Promise<string>((resolve) => {
rejected.on('disconnect', () => resolve('disconnected'));
rejected.on('connect_error', () => resolve('rejected'));
// The server accepts the transport then drops it in handleConnection.
setTimeout(() => resolve(rejected.connected ? 'still-connected' : 'disconnected'), 500);
});
rejected.disconnect();
await rejectingApp.close();
expect(outcome).not.toBe('still-connected');
});
});

View File

@@ -0,0 +1,80 @@
import {
BOOKING_WINDOW_WS_EVENTS,
BOOKING_WINDOW_WS_NAMESPACE,
type BookingWindowPhaseEvent,
} from '@edr/types';
import { Logger } from '@nestjs/common';
import {
OnGatewayConnection,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { WsAuthService } from '../notification-inbox/ws-auth.service';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
/**
* Server → client push for booking-window state changes. Same handshake model
* as the notifications gateway: clients only listen, the token is verified on
* connect. Events are broadcast namespace-wide — window state is route-scoped
* public information for signed-in users, and clients filter/invalidate their
* own queries.
*/
@WebSocketGateway({
namespace: BOOKING_WINDOW_WS_NAMESPACE,
cors: { origin: true, credentials: true },
})
export class BookingWindowGateway implements OnGatewayConnection {
private readonly logger = new Logger(BookingWindowGateway.name);
@WebSocketServer()
private readonly server!: Server;
constructor(private readonly wsAuth: WsAuthService) {}
async handleConnection(socket: Socket): Promise<void> {
const userId = await this.wsAuth.resolveUserId(this.extractToken(socket));
if (!userId) {
this.logger.debug(`Rejected booking-window handshake ${socket.id}`);
socket.disconnect(true);
return;
}
socket.data.userId = userId;
// Log at info so "is anyone actually connected?" is answerable from the
// API log when diagnosing missing live updates.
this.logger.log(`Booking-window client connected (user ${userId})`);
}
/** Push a schedule's current window state to every connected client. */
emitPhase(schedule: TrainSchedule): void {
const payload: BookingWindowPhaseEvent = {
scheduleId: schedule.id,
originYardId: schedule.originStationId,
destinationYardId: schedule.destinationStationId,
direction: schedule.direction ?? null,
phase: (schedule.windowPhase ?? 'PRE_WINDOW') as BookingWindowPhaseEvent['phase'],
bookingWindowStatus: schedule.bookingWindowStatus ?? null,
bookingCycleNo: schedule.bookingCycleNo,
windowOpensAt: schedule.windowOpensAt?.toISOString() ?? null,
windowClosesAt: schedule.windowClosesAt?.toISOString() ?? null,
docReviewEndsAt: schedule.docReviewEndsAt?.toISOString() ?? null,
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null,
scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null,
};
this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload);
}
private extractToken(socket: Socket): string | undefined {
const authToken = socket.handshake.auth?.token as string | undefined;
if (authToken) return authToken;
const queryToken = socket.handshake.query?.token;
if (typeof queryToken === 'string') return queryToken;
const header = socket.handshake.headers?.authorization;
if (header?.startsWith('Bearer ')) return header.slice(7);
return undefined;
}
}

View File

@@ -2,13 +2,19 @@ import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/com
import { Cron } from '@nestjs/schedule';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types';
import {
NotificationAudience,
NotificationType,
TrainScheduleStatus as TrainScheduleStatusEnum,
} from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { BookingBatchService } from './booking-batch.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
import { BATCH_TIMEZONE } from './booking-batch.constants';
import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util';
@@ -40,6 +46,8 @@ export class BookingWindowService implements OnModuleInit {
private readonly bookingBatchService: BookingBatchService,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
private readonly gateway: BookingWindowGateway,
) {}
async onModuleInit(): Promise<void> {
@@ -48,7 +56,10 @@ export class BookingWindowService implements OnModuleInit {
);
}
@Cron('* * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE })
// 10-second cadence: every transition is derived from persisted timestamps
// and applied idempotently, so a finer tick only shrinks the lag between a
// deadline passing and the phase actually moving (was a full minute).
@Cron('*/10 * * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE })
async tick(): Promise<void> {
if (this.ticking) return;
this.ticking = true;
@@ -89,9 +100,10 @@ export class BookingWindowService implements OnModuleInit {
await this.settleOverdueReservations();
// Legacy fill (DOMESTIC / pre-migration schedules) every 5th tick.
// Legacy fill (DOMESTIC / pre-migration schedules) every 5 minutes
// (30 ticks at the 10-second cadence).
this.tickCount += 1;
if (this.tickCount % 5 === 0) {
if (this.tickCount % 30 === 0) {
await this.bookingBatchService.runBatchFill();
}
} finally {
@@ -151,6 +163,9 @@ export class BookingWindowService implements OnModuleInit {
? await this.advanceExport(schedule, now)
: await this.advanceImport(schedule, cfg, now);
if (!advanced) return;
// Push the new window state to portal home / backoffice GL sections so
// they refresh instantly instead of waiting out their poll interval.
this.gateway.emitPhase(schedule);
}
}
@@ -169,7 +184,9 @@ export class BookingWindowService implements OnModuleInit {
await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
schedule.bookingWindowStatus = 'OPEN';
}
await this.notifyWindowOpened(schedule);
// Fire-and-forget: a slow SMS/email gateway must not stall the tick loop
// (the `ticking` guard would otherwise delay every schedule's transition).
void this.notifyWindowOpened(schedule);
this.logger.log(`Export booking window opened for schedule ${schedule.id}`);
return true;
}
@@ -216,7 +233,8 @@ export class BookingWindowService implements OnModuleInit {
schedule.bookingWindowStatus = 'OPEN';
}
// Only announce the first opening of the day; reopen cycles don't re-notify.
if (schedule.bookingCycleNo === 1) await this.notifyWindowOpened(schedule);
// Fire-and-forget so a slow SMS/email gateway never stalls the tick loop.
if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule);
this.logger.log(
`Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`,
);
@@ -368,22 +386,26 @@ export class BookingWindowService implements OnModuleInit {
*/
private async notifyWindowOpened(schedule: TrainSchedule): Promise<void> {
try {
const rows: Array<{ phone: string | null; email: string | null }> =
await this.dataSource.query(
`SELECT DISTINCT
COALESCE(co.contact_person_phone, co.phone) AS phone,
COALESCE(co.email, co.general_manager_email) AS email
FROM freight.contract_routes cr
JOIN freight.contracts c
ON c.id = cr.contract_id
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
AND c.deleted_at IS NULL
JOIN freight.companies co ON co.id = c.company_id
WHERE cr.origin_yard_id = $1
AND cr.destination_yard_id = $2
AND cr.deleted_at IS NULL`,
[schedule.originStationId, schedule.destinationStationId],
);
const rows: Array<{
company_id: string;
phone: string | null;
email: string | null;
}> = await this.dataSource.query(
`SELECT DISTINCT
c.company_id,
COALESCE(co.contact_person_phone, co.phone) AS phone,
COALESCE(co.email, co.general_manager_email) AS email
FROM freight.contract_routes cr
JOIN freight.contracts c
ON c.id = cr.contract_id
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
AND c.deleted_at IS NULL
JOIN freight.companies co ON co.id = c.company_id
WHERE cr.origin_yard_id = $1
AND cr.destination_yard_id = $2
AND cr.deleted_at IS NULL`,
[schedule.originStationId, schedule.destinationStationId],
);
if (!rows.length) return;
const closes = schedule.windowClosesAt
@@ -398,6 +420,7 @@ export class BookingWindowService implements OnModuleInit {
const seenPhone = new Set<string>();
const seenEmail = new Set<string>();
const seenCompany = new Set<string>();
for (const r of rows) {
if (r.phone && !seenPhone.has(r.phone)) {
seenPhone.add(r.phone);
@@ -411,9 +434,23 @@ export class BookingWindowService implements OnModuleInit {
.directSend('email', r.email, msg)
.catch((e) => this.logger.warn(`Window-open email failed: ${(e as Error).message}`));
}
// In-app inbox item for every portal user of each eligible company,
// deep-linking to the new-booking page.
if (r.company_id && !seenCompany.has(r.company_id)) {
seenCompany.add(r.company_id);
void this.inbox.notify({
recipients: { companyId: r.company_id },
audience: NotificationAudience.PORTAL,
type: NotificationType.SCHEDULE_UPDATE,
title: 'Booking window open',
body: msg,
link: '/bookings/new',
data: { trainScheduleId: schedule.id },
});
}
}
this.logger.log(
`Notified ${seenPhone.size} phone / ${seenEmail.size} email contacts of open window for schedule ${schedule.id}`,
`Notified ${seenPhone.size} phone / ${seenEmail.size} email / ${seenCompany.size} companies (in-app) of open window for schedule ${schedule.id}`,
);
} catch (err) {
this.logger.warn(

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator';
export class AcceptIntercityBookingsDto {
@ApiProperty({
type: [String],
description:
'Waiting intercity booking ids to accept onto this train, in priority order',
})
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
bookingIds!: string[];
}

View File

@@ -59,4 +59,15 @@ export class UpdateScheduleWindowRuleDto {
@IsInt()
@Min(0)
importWindowLeadDays?: number;
@ApiPropertyOptional({
example: 24,
description:
'Hours before departure the single FCFS export window opens (EXPORT schedules; re-derives the window start)',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
exportBookingLeadHours?: number;
}

View File

@@ -0,0 +1,367 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { BookingBatchService, type Capacity } from './booking-batch.service';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
/**
* Intercity (DOMESTIC) ride-along: intercity bookings never get their own
* train — they ride a passing import/export schedule whose route milestones
* contain the booking's origin strictly before its destination.
*
* Flow: the customer books a corridor with no date; at finalize time staff see
* every waiting intercity booking whose corridor lies on the schedule's route,
* with its wagon/weight/length need against the train's remaining capacity;
* accepting reserves it (pay window → payment → allocation, same lifecycle as
* a batch reservation). Cargo is loaded manually when the train reaches the
* booking's origin yard and unloaded at its destination yard.
*/
@Injectable()
export class IntercityService {
private readonly logger = new Logger(IntercityService.name);
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly bookingBatchService: BookingBatchService,
) {}
/**
* Waiting intercity bookings this schedule could carry, with the train's
* remaining capacity along all three axes (wagons, weight, length) and each
* booking's need, so staff can pick what fits.
*/
async listCandidates(scheduleId: string) {
const schedule = await this.getSchedule(scheduleId);
const milestoneSeq = await this.routeMilestoneSequence(schedule);
const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
const waiting = milestoneSeq
? await this.findWaitingIntercityBookings(milestoneSeq)
: [];
const accepted = await this.findAcceptedIntercityBookings(scheduleId);
return {
scheduleId,
routeId: schedule.routeId ?? null,
remaining: capacity?.budget ?? null,
candidates: waiting.map((booking) => {
const need = capacity?.needFor(booking) ?? null;
return {
...this.mapBooking(booking),
need,
fits: need && capacity ? fits(need, capacity.budget) : false,
};
}),
accepted: accepted.map((booking) => ({
...this.mapBooking(booking),
need: capacity?.needFor(booking) ?? null,
})),
};
}
/**
* Accept selected waiting intercity bookings onto this train, in the given
* order, each re-checked against the shrinking capacity budget. Commercial
* bookings open a pay window (payment → allocation runs on the existing
* settle lifecycle); government bookings allocate immediately.
*/
async acceptBookings(scheduleId: string, bookingIds: string[]) {
if (bookingIds.length === 0) {
throw new BadRequestException('Select at least one intercity booking');
}
const schedule = await this.getSchedule(scheduleId);
const milestoneSeq = await this.routeMilestoneSequence(schedule);
if (!milestoneSeq) {
throw new BadRequestException(
'Schedule has no route milestones — cannot serve intercity corridors',
);
}
const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
if (!capacity) {
throw new BadRequestException(
'Schedule has no locomotive/train set — capacity unknown',
);
}
const accepted: string[] = [];
const rejected: Array<{ bookingId: string; reason: string }> = [];
let budget = capacity.budget;
for (const bookingId of bookingIds) {
const booking = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId }, relations: { bookingContainers: true } });
if (!booking) {
rejected.push({ bookingId, reason: 'Booking not found' });
continue;
}
const notWaiting = this.whyNotWaiting(booking, milestoneSeq);
if (notWaiting) {
rejected.push({ bookingId, reason: notWaiting });
continue;
}
const need = capacity.needFor(booking);
if (!fits(need, budget)) {
rejected.push({
bookingId,
reason: 'Does not fit the remaining wagon/weight/length capacity',
});
continue;
}
await this.bookingBatchService.acceptIntercity(booking, scheduleId);
budget = subtract(budget, need);
accepted.push(bookingId);
this.logger.log(
`Intercity booking ${booking.reference ?? bookingId} accepted onto schedule ${scheduleId}`,
);
}
return { accepted, rejected, remaining: budget };
}
/**
* Mark an accepted intercity booking's cargo as loaded. Only allowed while
* the train is physically at the booking's origin yard: either it has not
* departed yet and the booking boards at the train's own origin, or the
* latest recorded checkpoint is at the booking's origin yard.
*/
async loadBooking(scheduleId: string, bookingId: string) {
const { schedule, booking } = await this.getAcceptedBooking(
scheduleId,
bookingId,
);
if (booking.status !== 'PAID') {
throw new BadRequestException(
`Booking must be paid before loading (currently ${booking.status})`,
);
}
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
await this.dataSource
.getRepository(Booking)
.update(bookingId, { status: 'IN_TRANSIT' });
return { bookingId, status: 'IN_TRANSIT' as const };
}
/**
* Mark an intercity booking's cargo as unloaded at its destination yard —
* requires the latest checkpoint to be at that yard. Completes the booking.
*/
async unloadBooking(scheduleId: string, bookingId: string) {
const { schedule, booking } = await this.getAcceptedBooking(
scheduleId,
bookingId,
);
if (booking.status !== 'IN_TRANSIT') {
throw new BadRequestException(
`Booking must be loaded/in transit before unloading (currently ${booking.status})`,
);
}
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
await this.dataSource
.getRepository(Booking)
.update(bookingId, { status: 'COMPLETED' });
return { bookingId, status: 'COMPLETED' as const };
}
// ---- helpers ---------------------------------------------------------------
private async getSchedule(scheduleId: string): Promise<TrainSchedule> {
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: scheduleId } });
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
return schedule;
}
/**
* yardId → sequenceNo for the schedule's route. Falls back to a two-stop
* origin/destination pseudo-route for legacy schedules without a routeId,
* so an intercity booking exactly matching the train's own corridor still
* qualifies.
*/
private async routeMilestoneSequence(
schedule: TrainSchedule,
): Promise<Map<string, number> | null> {
if (schedule.routeId) {
const milestones = await this.dataSource
.getRepository(RouteMilestone)
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
if (milestones.length >= 2) {
return new Map(milestones.map((m) => [m.yardId, m.sequenceNo]));
}
}
if (schedule.originStationId && schedule.destinationStationId) {
return new Map([
[schedule.originStationId, 1],
[schedule.destinationStationId, 2],
]);
}
return null;
}
/** Waiting = ready intercity bookings not yet on any train, corridor on this route. */
private async findWaitingIntercityBookings(
milestoneSeq: Map<string, number>,
): Promise<Booking[]> {
const pool = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.where(`booking.trade_direction = 'DOMESTIC'`)
.andWhere('booking.train_schedule_id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status = 'APPROVED'))`,
)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
return pool.filter((b) => this.corridorOnRoute(b, milestoneSeq));
}
/** Intercity bookings already reserved/allocated on this schedule. */
private async findAcceptedIntercityBookings(
scheduleId: string,
): Promise<Booking[]> {
return this.dataSource
.getRepository(Booking)
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.where(`booking.trade_direction = 'DOMESTIC'`)
.andWhere('booking.train_schedule_id = :scheduleId', { scheduleId })
.orderBy('booking.created_at', 'ASC')
.getMany();
}
private corridorOnRoute(
booking: Booking,
milestoneSeq: Map<string, number>,
): boolean {
const originSeq = milestoneSeq.get(booking.originYardId);
const destinationSeq = milestoneSeq.get(booking.destinationYardId);
return (
originSeq != null && destinationSeq != null && originSeq < destinationSeq
);
}
private whyNotWaiting(
booking: Booking,
milestoneSeq: Map<string, number>,
): string | null {
if (booking.tradeDirection !== 'DOMESTIC') {
return 'Not an intercity booking';
}
if (booking.trainScheduleId) {
return 'Already assigned to a train';
}
const readyStatus = booking.isGovernment ? 'APPROVED' : 'FULLY_EXECUTED';
if (booking.status !== readyStatus) {
return `Not ready to board (status ${booking.status})`;
}
if (!this.corridorOnRoute(booking, milestoneSeq)) {
return "Corridor is not on this schedule's route";
}
return null;
}
private async getAcceptedBooking(scheduleId: string, bookingId: string) {
const schedule = await this.getSchedule(scheduleId);
const booking = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (booking.trainScheduleId !== scheduleId) {
throw new BadRequestException('Booking is not assigned to this schedule');
}
if (booking.tradeDirection !== 'DOMESTIC') {
throw new BadRequestException('Not an intercity booking');
}
return { schedule, booking };
}
/**
* The train is "at" a yard when the latest recorded checkpoint is that yard,
* or — for a booking boarding at the train's own origin — when the train has
* not recorded any checkpoint yet (still sitting at its origin).
*/
private async assertTrainAtYard(
schedule: TrainSchedule,
yardId: string,
side: 'origin' | 'destination',
): Promise<void> {
const latest = await this.dataSource
.getRepository(TrainCheckpointEvent)
.findOne({
where: { trainScheduleId: schedule.id },
order: { occurredAt: 'DESC', createdAt: 'DESC' },
});
if (!latest) {
if (side === 'origin' && schedule.originStationId === yardId) return;
throw new BadRequestException(
'Train has not reached this yard yet — record its checkpoint first',
);
}
if (latest.yardId !== yardId) {
throw new BadRequestException(
`Train's last recorded position is not at the booking's ${side} yard`,
);
}
}
private mapBooking(booking: Booking) {
return {
id: booking.id,
reference: booking.reference,
status: booking.status,
freightType: booking.freightType,
isGovernment: booking.isGovernment,
customer: booking.company?.name ?? 'Unknown customer',
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
origin:
booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
destination:
booking.destinationYard?.label ??
booking.destinationYard?.code ??
'Unknown destination',
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
paymentDeadline: booking.paymentDeadline?.toISOString() ?? null,
};
}
}
function fits(need: Capacity, budget: Capacity): boolean {
return (
need.wagons <= budget.wagons &&
need.weightTons <= budget.weightTons &&
need.lengthMeters <= budget.lengthMeters
);
}
function subtract(budget: Capacity, need: Capacity): Capacity {
return {
wagons: budget.wagons - need.wagons,
weightTons: budget.weightTons - need.weightTons,
lengthMeters: budget.lengthMeters - need.lengthMeters,
};
}

View File

@@ -20,6 +20,7 @@ import {
TrainSchedulingManage,
TrainSchedulingView,
} from "../../common/booking-guards";
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
import { AssignBookingsDto } from "./dto/assign-bookings.dto";
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto";
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
@@ -47,6 +48,7 @@ import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
import { TrainSchedulingService } from "./train-scheduling.service";
import { BookingBatchService } from "./booking-batch.service";
import { BookingWindowService } from "./booking-window.service";
import { IntercityService } from "./intercity.service";
import { BillingService } from "../billing/billing.service";
@ApiTags("train-scheduling")
@@ -57,6 +59,7 @@ export class TrainSchedulingController {
private readonly trainSchedulingService: TrainSchedulingService,
private readonly bookingBatchService: BookingBatchService,
private readonly bookingWindowService: BookingWindowService,
private readonly intercityService: IntercityService,
private readonly billingService: BillingService,
) { }
@@ -406,6 +409,54 @@ export class TrainSchedulingController {
return this.trainSchedulingService.dispatchSchedule(id);
}
@Get("schedules/:id/intercity-candidates")
@TrainSchedulingView()
@ApiOperation({
summary:
"Waiting intercity bookings this train could carry (corridor on route) + remaining wagon/weight/length capacity",
})
getIntercityCandidates(@Param("id", ParseUUIDPipe) id: string) {
return this.intercityService.listCandidates(id);
}
@Post("schedules/:id/intercity/accept")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)",
})
acceptIntercityBookings(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: AcceptIntercityBookingsDto,
) {
return this.intercityService.acceptBookings(id, dto.bookingIds);
}
@Post("schedules/:id/intercity/:bookingId/load")
@TrainSchedulingManage()
@ApiOperation({
summary: "Confirm intercity cargo loaded (train must be at the booking's origin yard)",
})
loadIntercityBooking(
@Param("id", ParseUUIDPipe) id: string,
@Param("bookingId", ParseUUIDPipe) bookingId: string,
) {
return this.intercityService.loadBooking(id, bookingId);
}
@Post("schedules/:id/intercity/:bookingId/unload")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)",
})
unloadIntercityBooking(
@Param("id", ParseUUIDPipe) id: string,
@Param("bookingId", ParseUUIDPipe) bookingId: string,
) {
return this.intercityService.unloadBooking(id, bookingId);
}
@Get("schedules/:id/import-djibouti")
@TrainSchedulingView()
@ApiOperation({ summary: "Batch 7 import Djibouti gatepass/loading status" })

View File

@@ -1,5 +1,6 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
import { BillingModule } from '../billing/billing.module';
import { BookingsModule } from '../bookings/bookings.module';
@@ -25,10 +26,14 @@ import { TrainSchedulingController } from './train-scheduling.controller';
import { TrainSchedulingService } from './train-scheduling.service';
import { BookingBatchService } from './booking-batch.service';
import { BookingNotifierService } from './booking-notifier.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { BookingWindowService } from './booking-window.service';
import { IntercityService } from './intercity.service';
import { WsAuthService } from '../notification-inbox/ws-auth.service';
import { BookingSplitService } from './booking-split.service';
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
import { NotificationsModule } from '../notifications/notifications.module';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { ContractsModule } from '../contracts/contracts.module';
@Module({
@@ -46,10 +51,13 @@ import { ContractsModule } from '../contracts/contracts.module';
TrainCheckpointEvent,
ImportDjiboutiOperation,
BookingBatchOffer,
// WsAuthService (booking-window gateway handshake) verifies IAM sessions.
Session,
]),
forwardRef(() => BookingsModule),
BillingModule,
NotificationsModule,
NotificationInboxModule,
LocomotivesModule,
WagonTypesModule,
TrainSetsModule,
@@ -64,9 +72,17 @@ import { ContractsModule } from '../contracts/contracts.module';
TrainCheckpointEventsRepository,
BookingBatchService,
BookingNotifierService,
BookingWindowGateway,
WsAuthService,
BookingWindowService,
BookingSplitService,
IntercityService,
],
exports: [
TrainSchedulingService,
BookingBatchService,
BookingWindowService,
BookingNotifierService,
],
exports: [TrainSchedulingService, BookingBatchService, BookingWindowService],
})
export class TrainSchedulingModule {}

View File

@@ -154,6 +154,7 @@ describe('TrainSchedulingService', () => {
{
htmlToPdfBuffer: jest.fn(),
} as never,
{ emitPhase: jest.fn() } as never, // bookingWindowGateway
);
const defaultFleetWagons = [

View File

@@ -12,6 +12,7 @@ import {
Injectable,
Logger,
NotFoundException,
Optional,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm';
@@ -21,6 +22,7 @@ import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { Container } from '../container-management/entities/container.entity';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
@@ -67,6 +69,7 @@ import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduli
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
import { type BookingWindowConfig } from './booking-window.config';
import { BookingWindowGateway } from './booking-window.gateway';
import {
buildCappedWagonPlan,
computeFleetAvailability,
@@ -272,9 +275,63 @@ export class TrainSchedulingService {
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
private readonly warehouseInventoryService: WarehouseInventoryService,
private readonly pdfDocuments: WarehouseReleaseDocumentService,
private readonly bookingWindowGateway: BookingWindowGateway,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
private readonly configService?: ConfigService,
) {}
/**
* Complete customer-tracking clearance milestones for every booking on a
* schedule when a physical lifecycle event fires (dispatch, arrive, load,
* unload, gatepass). Uses the doc-trigger path, which is a silent no-op for
* bookings without milestone rows (non-customs bookings), so this is safe to
* call for every direction and flow. Never blocks the operational action.
*/
private async completeMilestonesForScheduleBookings(
scheduleId: string,
codes: string[],
): Promise<void> {
if (!this.milestoneService || codes.length === 0) return;
try {
const rows: Array<{ booking_id: string }> = await this.dataSource.query(
`SELECT tsb.booking_id
FROM freight.train_schedule_bookings tsb
WHERE tsb.train_schedule_id = $1
AND tsb.deleted_at IS NULL`,
[scheduleId],
);
for (const { booking_id } of rows) {
for (const code of codes) {
await this.milestoneService.completeByDocTrigger(
{ bookingId: booking_id },
code,
);
}
}
} catch (err) {
this.logger.warn(
`Milestone completion (${codes.join(', ')}) failed for schedule ${scheduleId}: ${(err as Error).message}`,
);
}
}
/**
* Push a schedule's current booking-window state over the socket so the
* portal home card and backoffice GL/batch views update in real time —
* used for lifecycle changes outside the window tick (create, cancel,
* finalize, restamp). A push failure must never break the mutation.
*/
private async emitWindowState(scheduleId: string): Promise<void> {
try {
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
if (fresh) this.bookingWindowGateway.emitPhase(fresh);
} catch (err) {
this.logger.warn(
`Booking-window push failed for ${scheduleId}: ${(err as Error).message}`,
);
}
}
async getEligibleBookings(query: GetEligibleBookingsDto) {
// Day-level pooling: when the wizard targets a schedule, surface the whole
// (route, EAT day) pool — not just bookings pre-pinned to that train — by
@@ -408,7 +465,9 @@ export class TrainSchedulingService {
schedule.ruleImportWindowLeadDays ??
liveCfg.importWindowLeadDays,
exportBookingLeadHours:
schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours,
dto.exportBookingLeadHours ??
schedule.ruleExportBookingLeadHours ??
liveCfg.exportBookingLeadHours,
windowOpenHour:
dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
windowCloseHour:
@@ -432,6 +491,12 @@ export class TrainSchedulingService {
schedule.direction === 'EXPORT'
? computeExportWindowTimes(schedule.scheduledDepartureDate, merged)
: computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now);
if (times.windowOpensAt.getTime() >= times.windowClosesAt.getTime()) {
throw new BadRequestException(
'These settings leave no booking window before departure — with the ' +
'desk hours applied, the window would only open once the train has left.',
);
}
await this.dataSource.getRepository(TrainSchedule).update(id, {
windowOpensAt: times.windowOpensAt,
@@ -441,6 +506,7 @@ export class TrainSchedulingService {
this.logger.log(
`Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`,
);
void this.emitWindowState(id);
const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule;
@@ -514,6 +580,7 @@ export class TrainSchedulingService {
`Departure date changed for schedule ${id}${departure.toISOString()} ` +
`(window reopens ${times.windowOpensAt.toISOString()})`,
);
void this.emitWindowState(id);
const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule;
@@ -553,6 +620,9 @@ export class TrainSchedulingService {
...windowRuleSnapshot(cfg),
});
restamped += 1;
// New times take effect immediately on every card (the tick then opens
// the window within seconds if the re-derived open is already due).
void this.emitWindowState(s.id);
}
if (restamped > 0) {
this.logger.log(
@@ -686,10 +756,9 @@ export class TrainSchedulingService {
lockedLocomotives.push(locked);
}
const direction = deriveScheduleDirection(
route.originYard ?? { country: null },
route.destinationYard ?? { country: null },
);
// Frozen on the route at create/update from the yard-country enum;
// getSchedulableRoute already rejected DOMESTIC (intercity).
const direction = this.resolveRouteDirection(route);
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives);
// Effective capacity is capped by the weakest locomotive in the set.
@@ -757,6 +826,8 @@ export class TrainSchedulingService {
});
const created = await this.getTrainScheduleById(createdScheduleId);
// New window announced — portal home / GL cards pick it up immediately.
void this.emitWindowState(createdScheduleId);
return { ...created, warnings: scheduleWarnings };
}
@@ -1088,26 +1159,32 @@ export class TrainSchedulingService {
{ country: schedule.destinationCountry },
);
if (direction === 'IMPORT') {
const result = await this.warehouseInventoryService.autoUnloadArrivedBookings(
scheduleId,
'SYSTEM_TRAIN_ARRIVAL',
);
// Customer tracking: cargo is off the train at the destination yard.
void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']);
return {
direction,
action: 'IMPORT_AUTO_UNLOAD',
status: 'COMPLETED',
result: await this.warehouseInventoryService.autoUnloadArrivedBookings(
scheduleId,
'SYSTEM_TRAIN_ARRIVAL',
),
result,
};
}
if (direction === 'EXPORT' && this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) {
const result = await this.warehouseInventoryService.autoUnloadExportAtDjibouti(
scheduleId,
'SYSTEM_TRAIN_ARRIVAL',
);
// Customer tracking: cargo is off the train at the Djibouti port.
void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']);
return {
direction,
action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD',
status: 'COMPLETED',
result: await this.warehouseInventoryService.autoUnloadExportAtDjibouti(
scheduleId,
'SYSTEM_TRAIN_ARRIVAL',
),
result,
};
}
@@ -1336,6 +1413,8 @@ export class TrainSchedulingService {
}
});
// Finalized — push so portal/GL cards reflect the new state instantly.
void this.emitWindowState(scheduleId);
return this.getTrainScheduleById(scheduleId);
}
@@ -1406,6 +1485,22 @@ export class TrainSchedulingService {
);
}
// Dispatch closed the window — drop it from portal/GL cards right away.
void this.emitWindowState(scheduleId);
// Customer tracking: cargo is on the departing train — loading milestones
// plus the direction's "departed" handoff milestone.
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
void this.completeMilestonesForScheduleBookings(scheduleId, [
// CARGO_ARRIVED is export-only (cargo reached the origin yard) — the
// doc-trigger path no-ops it for import bookings.
'CARGO_ARRIVED',
'READY_FOR_LOADING',
'LOADED',
schedule.direction === 'IMPORT'
? 'DEPARTED_FROM_DJIBOUTI'
: 'DEPARTED_TO_DJIBOUTI',
]);
}
return this.getTrainScheduleById(scheduleId);
}
@@ -1562,6 +1657,13 @@ export class TrainSchedulingService {
LoadingStatus.Loaded,
);
}
// Customer tracking: staff confirmed cargo is on the wagons (CARGO_ARRIVED
// is the export-side "cargo reached origin yard" step that precedes it).
void this.completeMilestonesForScheduleBookings(scheduleId, [
'CARGO_ARRIVED',
'READY_FOR_LOADING',
'LOADED',
]);
return this.getTrainScheduleById(scheduleId);
}
@@ -1625,9 +1727,9 @@ export class TrainSchedulingService {
performedBy: 'DOCUMENT_GENERATION',
});
const html = this.buildImportLoadListHtml(loadList);
// Generic render — NOT the release-order fallback (would mislabel this as a
// gate-clearance / release order when Chromium is unavailable).
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Import marshalling / load list');
// Styled table-aware fallback (marshalling grid) when Chromium is unavailable —
// NOT the release-order fallback (would mislabel this as a gate-clearance order).
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Import marshalling / load list');
const reference = loadList.trainNumber ?? loadList.trainScheduleId;
return {
filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`,
@@ -1645,8 +1747,8 @@ export class TrainSchedulingService {
}
const html = this.buildExportLoadListHtml(schedule);
// Generic render — NOT the release-order fallback (see importLoadListDocument).
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Export marshalling / load list');
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Export marshalling / load list');
const reference = schedule.trainNumber ?? schedule.id;
return {
filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`,
@@ -2115,6 +2217,7 @@ export class TrainSchedulingService {
await this.dataSource
.getRepository(TrainSchedule)
.update(scheduleId, { bookingWindowStatus: status });
void this.emitWindowState(scheduleId);
}
/** Build the ordered station list for a schedule's corridor (origin → milestones → destination). */
@@ -2379,6 +2482,13 @@ export class TrainSchedulingService {
}
});
// Customer tracking: the train reached the corridor's far end.
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
void this.completeMilestonesForScheduleBookings(scheduleId, [
schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI',
]);
}
const detail = await this.getTrainScheduleById(scheduleId);
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
return Object.assign(detail, { warehouseAutomation });
@@ -2455,6 +2565,8 @@ export class TrainSchedulingService {
}
});
// Window retired (DONE) — remove the card from portal/GL lists right away.
void this.emitWindowState(id);
return this.getTrainScheduleById(id);
}
@@ -2751,7 +2863,16 @@ export class TrainSchedulingService {
take: 1,
});
return rows[0] ?? null;
} catch {
} catch (err) {
// A read failure here silently downgrades every booking window to the
// hardcoded defaults (desk 817, duration 3h, lead 3) while the settings
// UI keeps showing the saved row — a maddening mismatch. The usual cause
// is a missing column (migrations not run on this database). Scream.
this.logger.error(
`Failed to read train-scheduling global rules — booking windows are ` +
`running on HARDCODED DEFAULTS (817). Run pending migrations. ` +
`Cause: ${(err as Error).message}`,
);
return null;
}
}
@@ -3447,9 +3568,27 @@ export class TrainSchedulingService {
`Route ${formatRouteLabel(route)} is not available for scheduling (${route.status})`,
);
}
// Intercity (same-country) service is not offered yet — only import/export
// trains can be scheduled.
if (this.resolveRouteDirection(route) === 'DOMESTIC') {
throw new BadRequestException(
`Route ${formatRouteLabel(route)} is an intercity route; intercity scheduling is not available yet`,
);
}
return route;
}
/** Stored route direction, deriving from yard countries for pre-migration rows. */
private resolveRouteDirection(route: Route) {
return (
route.direction ??
deriveScheduleDirection(
route.originYard ?? { country: null },
route.destinationYard ?? { country: null },
)
);
}
private mapEligibleBooking(booking: Booking) {
return {
id: booking.id,
@@ -3762,7 +3901,18 @@ export class TrainSchedulingService {
order: { scheduledDepartureDate: 'ASC' },
});
// A train that has already departed can never be booked, even if the window
// engine hasn't yet flipped its bookingWindowStatus off OPEN. Mirror the
// `scheduled_departure_date >= now()` guard the booking-window SQL uses so a
// past-departure schedule never leaks into the portal day pool, the schedule
// calendar, or the ET GL create-booking gate.
const now = new Date();
return schedules
.filter(
(s) =>
s.scheduledDepartureDate != null &&
s.scheduledDepartureDate > now,
)
.filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status))
.filter((s) => {
// Build the full stop list: origin -> milestones (ordered) -> destination
@@ -4054,6 +4204,7 @@ export class TrainSchedulingService {
: null,
reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null,
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
docReviewMinutes: windowCfg.docReviewMinutes,
paymentWindowMinutes: windowCfg.paymentWindowMinutes,
},

View File

@@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Matches, Min, ValidateNested } from 'class-validator';
import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity';
import { FEE_RULE_BASES, FEE_RULE_TYPES, FeeRuleBasis, FeeRuleType } from '../entities/warehouse-fee-rule.entity';
export class FeeRuleTierDto {
@ApiProperty({ example: 4 })
@@ -82,11 +82,19 @@ export class CreateFeeRuleDto {
@Min(0)
freeDays!: number;
@ApiProperty()
@ApiProperty({ description: 'Day-based fees: rate/day. Double handling: flat rate per basis unit.' })
@IsNumber()
@Min(0)
ratePerDay!: number;
@ApiPropertyOptional({
enum: FEE_RULE_BASES,
description: 'Double-handling charge basis: PER_CONTAINER | PER_TON | PER_ITEM.',
})
@IsOptional()
@IsEnum(FEE_RULE_BASES)
basis?: FeeRuleBasis;
@ApiPropertyOptional({ type: [FeeRuleTierDto] })
@IsOptional()
@IsArray()

View File

@@ -0,0 +1,46 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const HANDOVER_MILE_TYPES = ['SELF_HAUL', 'EDR_LAST_MILE'] as const;
export type HandoverMileType = (typeof HANDOVER_MILE_TYPES)[number];
/**
* One import handover. A booking has a single handover when one truck takes the
* whole booking (`truckAssignmentId` null = per-booking), or one per truck when
* multiple trucks are used. Self-haul handovers are generated on truck arrival
* and signed before the truck leaves; EDR last-mile handovers are generated at
* delivery (after exit).
*/
@Entity({ schema: 'freight', name: 'booking_handovers' })
@Index(['bookingId'])
export class BookingHandover extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
/** Customer self-haul truck this handover belongs to; null = per-booking. */
@Column({ name: 'truck_assignment_id', type: 'uuid', nullable: true })
truckAssignmentId?: string | null;
/** Denormalised plate for display / EDR trucks (which aren't customer trucks). */
@Column({ name: 'truck_plate', type: 'varchar', length: 32, nullable: true })
truckPlate?: string | null;
@Column({ name: 'mile_type', type: 'varchar', length: 20 })
mileType!: HandoverMileType;
@Column({ name: 'reference', type: 'varchar', length: 100 })
reference!: string;
@Column({ name: 'generated_at', type: 'timestamptz', default: () => 'now()' })
generatedAt!: Date;
@Column({ name: 'signed_at', type: 'timestamptz', nullable: true })
signedAt?: Date | null;
@Column({ name: 'signed_by_user_id', type: 'uuid', nullable: true })
signedByUserId?: string | null;
/** EDR last-mile: when the goods were delivered to the customer. */
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
deliveredAt?: Date | null;
}

View File

@@ -1,9 +1,23 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const;
export const FEE_RULE_TYPES = [
'STORAGE_FEE',
'DEMURRAGE_FEE',
'DOUBLE_HANDLING_FEE',
'TRUCK_DETENTION_FEE',
] as const;
export type FeeRuleType = (typeof FEE_RULE_TYPES)[number];
/**
* Charge basis for a DOUBLE_HANDLING_FEE rule (flat rate × the chosen quantity):
* - PER_CONTAINER: booking container count
* - PER_TON: cargo total in tonnes (bulk cargo)
* - PER_ITEM: cargo total item count (break-bulk cargo, e.g. machinery)
*/
export const FEE_RULE_BASES = ['PER_CONTAINER', 'PER_TON', 'PER_ITEM'] as const;
export type FeeRuleBasis = (typeof FEE_RULE_BASES)[number];
export interface WarehouseFeeTier {
fromDay: number;
toDay: number | null;
@@ -60,6 +74,12 @@ export class WarehouseFeeRule extends BaseEntity {
@Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 })
ratePerDay!: number;
// Double-handling only: PER_CONTAINER | PER_TON | PER_MACHINERY. The flat rate
// (rate_per_day, reused as rate-per-unit) is multiplied by the basis quantity;
// free days and tiers do not apply. Null for the day-based fee types.
@Column({ name: 'basis', type: 'varchar', length: 20, nullable: true })
basis?: FeeRuleBasis | null;
@Column({ name: 'tiers', type: 'jsonb', default: () => "'[]'" })
tiers!: WarehouseFeeTier[];

View File

@@ -0,0 +1,123 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, EntityManager, IsNull } from 'typeorm';
import { BookingHandover } from './entities/booking-handover.entity';
/**
* Import handover records. A booking has one handover per truck (single truck ⇒
* one, effectively per-booking; multiple trucks ⇒ one each). Timing by mile type:
* - SELF_HAUL: generated when the customer truck arrives, signed before it leaves.
* - EDR_LAST_MILE: generated at delivery (after exit).
*/
@Injectable()
export class HandoverService {
private readonly logger = new Logger(HandoverService.name);
constructor(private readonly dataSource: DataSource) {}
list(bookingId: string): Promise<BookingHandover[]> {
return this.dataSource.getRepository(BookingHandover).find({
where: { bookingId },
order: { generatedAt: 'ASC' },
});
}
/**
* Self-haul: ensure a handover exists for a customer truck that just arrived.
* Idempotent — one per (booking, truck). Runs inside the caller's transaction
* when a manager is supplied.
*/
async ensureForArrivedTruck(
bookingId: string,
opts: { truckAssignmentId?: string | null; truckPlate?: string | null },
manager?: EntityManager,
): Promise<BookingHandover> {
const m = manager ?? this.dataSource.manager;
const repo = m.getRepository(BookingHandover);
const existing = await repo.findOne({
where: {
bookingId,
truckAssignmentId: opts.truckAssignmentId ?? IsNull(),
},
});
if (existing) return existing;
const reference = await this.generateReference(bookingId, m);
const saved = await repo.save(
repo.create({
bookingId,
truckAssignmentId: opts.truckAssignmentId ?? null,
truckPlate: opts.truckPlate ?? null,
mileType: 'SELF_HAUL',
reference,
generatedAt: new Date(),
}),
);
this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`);
return saved;
}
/**
* EDR last-mile: generate a handover at delivery (after exit). One per EDR
* truck (by plate) or per booking. Idempotent by (booking, plate).
*/
async ensureAtDelivery(
bookingId: string,
opts: { truckPlate?: string | null; truckAssignmentId?: string | null },
manager?: EntityManager,
): Promise<BookingHandover> {
const m = manager ?? this.dataSource.manager;
const repo = m.getRepository(BookingHandover);
const existing = await repo.findOne({
where: {
bookingId,
truckPlate: opts.truckPlate ?? IsNull(),
truckAssignmentId: opts.truckAssignmentId ?? IsNull(),
},
});
if (existing) return existing;
const reference = await this.generateReference(bookingId, m);
return repo.save(
repo.create({
bookingId,
truckAssignmentId: opts.truckAssignmentId ?? null,
truckPlate: opts.truckPlate ?? null,
mileType: 'EDR_LAST_MILE',
reference,
generatedAt: new Date(),
deliveredAt: new Date(),
}),
);
}
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
async signForBooking(bookingId: string, userId?: string | null): Promise<void> {
await this.dataSource
.getRepository(BookingHandover)
.update(
{ bookingId, signedAt: IsNull() },
{ signedAt: new Date(), signedByUserId: userId ?? null },
);
}
/** True when every handover on the booking is signed (and at least one exists). */
async isFullySigned(bookingId: string): Promise<boolean> {
const repo = this.dataSource.getRepository(BookingHandover);
const [total, unsigned] = await Promise.all([
repo.count({ where: { bookingId } }),
repo.count({ where: { bookingId, signedAt: IsNull() } }),
]);
return total > 0 && unsigned === 0;
}
private async generateReference(bookingId: string, manager: EntityManager): Promise<string> {
const [booking] = await manager.query(
`SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
const ref = String(booking?.reference ?? bookingId).replace(/^BK-?/i, '');
const count = await manager.getRepository(BookingHandover).count({ where: { bookingId } });
return `HND-${ref}-${String(count + 1).padStart(2, '0')}`;
}
}

View File

@@ -3,7 +3,7 @@ import { ExchangeService } from '@edr/api-common';
import { DataSource } from 'typeorm';
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
import { FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
interface ItemAttributes {
@@ -16,6 +16,8 @@ interface ItemAttributes {
containerTypeCode: string | null;
inventoryQuantity: number;
bookingContainerCount: number;
/** Booking cargo total in the cargo's unit of measure: tonnes (PER_TON) or item count (PER_ITEM). */
cargoQuantity: number;
facilityId: string | null;
warehouseId: string | null;
yardId: string | null;
@@ -24,6 +26,8 @@ interface ItemAttributes {
export interface FeePreview {
ruleType: FeeRuleType;
/** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */
basis: FeeRuleBasis | null;
ruleId: string | null;
ruleName: string | null;
freeDays: number;
@@ -132,7 +136,8 @@ export class WarehouseFeeService {
b.trade_direction AS "tradeDirection",
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount"
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount",
COALESCE(b.cargo_total_weight_vgm, 0) AS "cargoQuantity"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
@@ -283,6 +288,10 @@ export class WarehouseFeeService {
now: Date,
billingCurrency: string,
): Promise<FeePreview> {
// Double handling is a flat charge (rate × basis quantity), not day-based.
if (ruleType === 'DOUBLE_HANDLING_FEE') {
return this.computeDoubleHandling(rule, item, now, billingCurrency);
}
const start = item.arrivedAt ? new Date(item.arrivedAt) : null;
const endDate = item.gateClearedAt ?? item.releaseDate ?? now;
const endIsOpen = !item.gateClearedAt && !item.releaseDate;
@@ -321,6 +330,7 @@ export class WarehouseFeeService {
return {
ruleType,
basis: null,
ruleId: rule?.id ?? null,
ruleName: rule?.name ?? null,
freeDays,
@@ -340,13 +350,69 @@ export class WarehouseFeeService {
};
}
/**
* Double handling — a flat one-time charge, not time-based. Amount = rate ×
* the basis quantity: PER_CONTAINER (booking container count), or PER_TON /
* PER_ITEM (the booking cargo total in the cargo's unit of measure — tonnes
* for bulk, item count for break-bulk). No free days, no elapsed days, no tiers.
*/
private async computeDoubleHandling(
rule: WarehouseFeeRule | null,
item: ItemAttributes,
now: Date,
billingCurrency: string,
): Promise<FeePreview> {
const basis: FeeRuleBasis = rule?.basis ?? 'PER_CONTAINER';
const rate = Number(rule?.ratePerDay ?? 0);
const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null;
const targetCurrency = this.normalizeCurrency(billingCurrency);
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1));
const containerCount = isContainer
? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity))
: 1;
// PER_TON (tonnes) and PER_ITEM (piece count) both read the cargo total,
// which is stored in the cargo's own unit of measure.
const cargoQuantity = Math.max(0, Number(item.cargoQuantity) || 0);
const quantity = basis === 'PER_CONTAINER' ? containerCount : cargoQuantity;
const sourceAmount = Math.round(rate * quantity * 100) / 100;
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
const convertedRate = ruleCurrency ? await this.convertAmount(rate, ruleCurrency, targetCurrency) : 0;
return {
ruleType: 'DOUBLE_HANDLING_FEE',
basis,
ruleId: rule?.id ?? null,
ruleName: rule?.name ?? null,
freeDays: 0,
ratePerDay: convertedRate,
currency: targetCurrency,
ruleCurrency,
billingCurrency: targetCurrency,
startDate: null,
endDate: now.toISOString(),
endIsOpen: false,
elapsedDays: 0,
chargeableDays: 0,
containerCount,
billableUnits: quantity,
amount,
tiers: [],
};
}
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
const item = await this.loadItem(inventoryId);
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
const now = new Date();
const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE'];
const byType: FeeRuleType[] = [
'DEMURRAGE_FEE',
'STORAGE_FEE',
'DOUBLE_HANDLING_FEE',
'TRUCK_DETENTION_FEE',
];
return Promise.all(
byType.map((type) =>
this.compute(

View File

@@ -15,6 +15,7 @@ import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { UnloadBookingDto } from './dto/unload-booking.dto';
import { SchedulingReadFacade } from './scheduling-read.facade';
import { WarehouseInventoryService } from './warehouse-inventory.service';
import { HandoverService } from './handover.service';
@ApiTags('warehouse-inventory')
@ApiBearerAuth()
@@ -23,6 +24,7 @@ export class WarehouseInventoryController {
constructor(
private readonly inventoryService: WarehouseInventoryService,
private readonly scheduling: SchedulingReadFacade,
private readonly handoverService: HandoverService,
) {}
@Get()
@@ -98,6 +100,27 @@ export class WarehouseInventoryController {
return this.inventoryService.loadedExport();
}
@Get('loadable-trains')
@ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' })
loadableTrains() {
return this.inventoryService.loadableTrains();
}
@Get('train/:scheduleId/loadable-items')
@ApiOperation({ summary: 'Container/cargo inventory assigned to a train, with allocated wagons' })
trainLoadableItems(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.inventoryService.trainLoadableItems(scheduleId);
}
@Post('train/:scheduleId/load')
@ApiOperation({ summary: 'Load selected inventory items onto their allocated wagons for a train' })
loadItemsOntoTrain(
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
@Body() dto: { inventoryIds: string[]; performedBy?: string },
) {
return this.inventoryService.loadItemsOntoTrain(scheduleId, dto.inventoryIds ?? [], dto.performedBy);
}
@Post('bulk-dispatch-export')
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) {
@@ -283,6 +306,19 @@ export class WarehouseInventoryController {
return res.send(buffer);
}
@Get('customer-truck-exit-paper/:assignmentId')
@ApiOperation({ summary: 'Per-truck exit paper PDF (containers loaded on one customer truck)' })
async truckExitPaper(
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
@Res() res: Response,
) {
const { filename, buffer } = await this.inventoryService.truckExitPaper(assignmentId);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Get(':id/grn-document')
@ApiOperation({ summary: 'View goods received note PDF' })
async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
@@ -312,6 +348,18 @@ export class WarehouseInventoryController {
return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub);
}
@Get('bookings/:bookingId/handovers')
@ApiOperation({ summary: 'Handover records for a booking (per-booking or per-truck)' })
bookingHandovers(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.handoverService.list(bookingId);
}
@Get('bookings/:bookingId/container-items')
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.containerItems(bookingId);
}
@Post(':id/deliver')
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {

View File

@@ -38,6 +38,7 @@ import { WarehouseActivityLogService } from './warehouse-activity-log.service';
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
import { HandoverService } from './handover.service';
/** Wagon states that may receive a load (besides being part of an existing schedule). */
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
@@ -272,6 +273,45 @@ export interface BulkDispatchResult {
results: { inventoryId: string; status: string; reason?: string }[];
}
/** An EXPORT train (pre-dispatch schedule) that has inventory waiting to be loaded. */
export interface LoadableTrainRow {
scheduleId: string;
trainNumber: string | null;
origin: string | null;
destination: string | null;
status: string;
departureTime: string | Date | null;
/** Received/ready inventory not yet loaded onto this train. */
readyCount: number;
/** Inventory already loaded onto this train. */
loadedCount: number;
}
/** A warehouse-inventory item (container/cargo) assigned to a train, with its allocated wagon. */
export interface TrainLoadableItemRow {
id: string;
bookingId: string | null;
bookingReference: string | null;
customerName: string | null;
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
grnNumber: string | null;
inspectionStatus: string | null;
status: string;
wagonId: string | null;
wagonNumber: string | null;
sequenceNo: number | null;
/** True only when the item is READY_FOR_LOADING and has an allocated wagon. */
loadable: boolean;
}
export interface TrainLoadResult {
loadedCount: number;
skippedCount: number;
results: { inventoryId: string; status: string; reason?: string }[];
}
export interface AutoUnloadArrivedResult {
unloadedCount: number;
skippedCount: number;
@@ -342,6 +382,7 @@ export class WarehouseInventoryService {
private readonly lastMileService: LastMileService,
private readonly notifications: NotificationsService,
private readonly signatures: SignaturesService,
private readonly handover: HandoverService,
) {}
/**
@@ -928,7 +969,7 @@ export class WarehouseInventoryService {
SET received_to_port = true,
received_at = COALESCE(bcu.received_at, NOW()),
updated_at = NOW()
FROM freight.booking_containers bc
FROM freight.booking_container bc
WHERE bc.id = bcu.booking_container_id
AND bc.booking_id = $1
AND bc.deleted_at IS NULL
@@ -1068,6 +1109,169 @@ export class WarehouseInventoryService {
return this.exportInventoryByStatus('LOADED');
}
// ── Per-train loading (Load to Train tab) ─────────────────────────────────
// Loading follows wagon allocation: staff pick an allocated EXPORT train, see
// the arrived containers/cargoes assigned to it, and load the ready ones onto
// their already-allocated wagons. Reuses the single-item load() machinery.
/** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */
async loadableTrains(): Promise<LoadableTrainRow[]> {
const rows: Array<
LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null }
> = await this.dataSource.query(
`SELECT ts.id AS "scheduleId",
ts.train_number AS "trainNumber",
oy.code AS "origin",
dy.code AS "destination",
oy.country AS "originCountry",
dy.country AS "destinationCountry",
ts.status AS "status",
ts.scheduled_departure_date AS "departureTime",
(SELECT count(*) FROM freight.train_schedule_bookings tsb
JOIN freight.warehouse_inventory inv
ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount",
(SELECT count(*) FROM freight.train_schedule_bookings tsb
JOIN freight.warehouse_inventory inv
ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
AND inv.status = 'LOADED') AS "loadedCount"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.deleted_at IS NULL
AND ts.status = ANY($1)
AND EXISTS (
SELECT 1 FROM freight.train_schedule_bookings tsb2
JOIN freight.warehouse_inventory inv2
ON inv2.booking_id = tsb2.booking_id AND inv2.deleted_at IS NULL
WHERE tsb2.train_schedule_id = ts.id AND tsb2.deleted_at IS NULL
AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
)
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
[['DRAFT', 'SCHEDULED']],
);
return rows
.filter(
(r) =>
deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'EXPORT',
)
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => ({
...rest,
readyCount: Number(rest.readyCount) || 0,
loadedCount: Number(rest.loadedCount) || 0,
}));
}
/**
* Container/cargo inventory items assigned to a train, with the wagon each is
* allocated to. Covers the arrived-but-not-loaded set (RECEIVED..READY_FOR_LOADING)
* plus already-LOADED items, so the "Received" and "Loaded" stage tabs both fill.
*/
async trainLoadableItems(scheduleId: string): Promise<TrainLoadableItemRow[]> {
const rows: Array<Omit<TrainLoadableItemRow, 'loadable'>> = await this.dataSource.query(
`SELECT inv.id AS "id",
inv.booking_id AS "bookingId",
b.reference AS "bookingReference",
company.name AS "customerName",
ct.container_number AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
inv.weight AS "weight",
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber",
inv.inspection_status AS "inspectionStatus",
inv.status AS "status",
wl.wagon_id AS "wagonId",
wl.wagon_number AS "wagonNumber",
wl.sequence_no AS "sequenceNo"
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
LEFT JOIN LATERAL (
SELECT w.id AS wagon_id, w.wagon_number, tsw.sequence_no
FROM freight.wagon_booking_allocations wba
JOIN freight.train_set_wagons tsw
ON tsw.id = wba.train_set_wagon_id
AND tsw.train_set_id = ts.train_set_id
AND tsw.deleted_at IS NULL
JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL
WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL
ORDER BY tsw.sequence_no ASC NULLS LAST
LIMIT 1
) wl ON true
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`,
[scheduleId],
);
return rows.map((r) => ({
...r,
loadable: r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId),
}));
}
/**
* Load the selected inventory items onto their allocated wagons for the given
* train. Each item must be assigned to this train, READY_FOR_LOADING, and have
* an allocated wagon; others are skipped with a reason. When every inventory
* item of a booking is loaded, its train_schedule_bookings.loading_status flips
* to LOADED so the train's confirm-loading/dispatch step reflects reality.
*/
async loadItemsOntoTrain(
scheduleId: string,
inventoryIds: string[],
performedBy?: string,
): Promise<TrainLoadResult> {
const result: TrainLoadResult = { loadedCount: 0, skippedCount: 0, results: [] };
const items = await this.trainLoadableItems(scheduleId);
const byId = new Map(items.map((i) => [i.id, i]));
const affectedBookingIds = new Set<string>();
for (const inventoryId of inventoryIds) {
const skip = (reason: string) => {
result.skippedCount += 1;
result.results.push({ inventoryId, status: 'SKIPPED', reason });
};
const item = byId.get(inventoryId);
if (!item) { skip('Not assigned to this train'); continue; }
if (item.status === 'LOADED') { skip('Already loaded'); continue; }
if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; }
if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; }
try {
await this.load(inventoryId, { wagonId: item.wagonId, loadedBy: performedBy });
result.loadedCount += 1;
result.results.push({ inventoryId, status: 'LOADED' });
if (item.bookingId) affectedBookingIds.add(item.bookingId);
} catch (error) {
skip(error instanceof Error ? error.message : 'Load failed');
}
}
// Flip a booking's train loading_status to LOADED once no un-loaded inventory remains.
for (const bookingId of affectedBookingIds) {
await this.dataSource.query(
`UPDATE freight.train_schedule_bookings tsb
SET loading_status = 'LOADED', updated_at = NOW()
WHERE tsb.train_schedule_id = $1 AND tsb.booking_id = $2 AND tsb.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM freight.warehouse_inventory inv
WHERE inv.booking_id = $2 AND inv.deleted_at IS NULL
AND inv.status NOT IN ('LOADED', 'DISPATCHED')
)`,
[scheduleId, bookingId],
);
}
return result;
}
/** Shared query for the import queues — IMPORT inventory at the given statuses, inspection columns. */
private async importQueueByStatuses(statuses: string[]): Promise<ImportUnloadedRow[]> {
const rows: Array<
@@ -1799,7 +2003,7 @@ export class WarehouseInventoryService {
SET received_to_port = true,
received_at = COALESCE(bcu.received_at, NOW()),
updated_at = NOW()
FROM freight.booking_containers bc, freight.containers cont
FROM freight.booking_container bc, freight.containers cont
WHERE bc.id = bcu.booking_container_id
AND bc.booking_id = $1
AND bc.deleted_at IS NULL
@@ -2080,9 +2284,14 @@ export class WarehouseInventoryService {
[item.bookingId],
);
const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt);
if (usesCustomerTruck && !this.extractCustomerDeliveryApproval(item.notes)) {
// Self-haul: the handover must be signed before the exit paper is issued.
// Prefer the structured handover record; fall back to the legacy note.
const handoverSigned =
(await this.handover.isFullySigned(item.bookingId)) ||
Boolean(this.extractCustomerDeliveryApproval(item.notes));
if (usesCustomerTruck && !handoverSigned) {
throw new BadRequestException(
'Customer must approve delivery (sign the handover) before the exit paper can be generated',
'Customer must sign the handover before the exit paper can be generated',
);
}
}
@@ -2137,6 +2346,16 @@ export class WarehouseInventoryService {
AND deleted_at IS NULL`,
[item.bookingId],
);
// Self-haul: generate the per-booking handover on first truck arrival
// (idempotent). It must be signed before the truck leaves.
const [selfHaul]: Array<{ ok: number }> = await manager.query(
`SELECT 1 AS ok FROM freight.bookings
WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL AND deleted_at IS NULL`,
[item.bookingId],
);
if (selfHaul) {
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
}
}
await this.activityLog.record(
{
@@ -2231,7 +2450,7 @@ export class WarehouseInventoryService {
FROM freight.customer_truck_containers cc
JOIN freight.booking_container_units bcu
ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL
JOIN freight.booking_containers bc
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
AND bc.booking_id = c.booking_id
WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL
@@ -2291,6 +2510,204 @@ export class WarehouseInventoryService {
};
}
/**
* Per-container (or bulk) items of a booking with their lifecycle stage and
* reference sources — drives the container-level detail datatable (stage tabs,
* multiselect load-to-truck, per-item actions).
*/
async containerItems(bookingId: string): Promise<
Array<{
containerNumber: string;
goods: string | null;
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
grnNumber: string | null;
truckAssignmentId: string | null;
truckPlate: string | null;
truckArrived: boolean;
truckLeft: boolean;
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;
}>
> {
const rows: Array<{
containerNumber: string;
goods: string | null;
received: boolean;
grnNumber: string | null;
truckAssignmentId: string | null;
truckPlate: string | null;
truckArrived: boolean;
truckLeft: boolean;
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;
delivered: boolean;
}> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods,
bcu.received_to_port AS received,
bcu.grn_number AS "grnNumber",
ctc.assignment_id AS "truckAssignmentId",
a.plate_number AS "truckPlate",
(a.arrived_at IS NOT NULL) AS "truckArrived",
(a.departed_at IS NOT NULL) AS "truckLeft",
b.reference AS "bookingReference",
b.contract_id AS "contractId",
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
COALESCE(inv.status = 'DELIVERED', false) AS delivered
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
JOIN freight.bookings b ON b.id = bc.booking_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
LEFT JOIN freight.customer_truck_containers ctc
ON ctc.container_number = bcu.container_number
AND ctc.booking_id = b.id AND ctc.deleted_at IS NULL
LEFT JOIN freight.customer_truck_assignments a
ON a.id = ctc.assignment_id AND a.deleted_at IS NULL
LEFT JOIN freight.containers cont ON cont.container_number = bcu.container_number
LEFT JOIN freight.warehouse_inventory inv
ON inv.container_id = cont.id AND inv.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
ORDER BY bcu.container_number`,
[bookingId],
);
return rows.map((r) => ({
containerNumber: r.containerNumber,
goods: r.goods,
stage: r.delivered
? 'DELIVERED'
: r.truckLeft
? 'LEFT'
: r.truckAssignmentId
? 'LOADED'
: r.grnNumber
? 'GRN'
: r.received
? 'RECEIVED'
: 'PENDING',
grnNumber: r.grnNumber,
truckAssignmentId: r.truckAssignmentId,
truckPlate: r.truckPlate,
truckArrived: r.truckArrived,
truckLeft: r.truckLeft,
bookingReference: r.bookingReference,
contractId: r.contractId,
hasLastMile: r.hasLastMile,
}));
}
/**
* Per-truck exit paper: one paper covering the containers loaded on a specific
* customer truck (used when multiple trucks leave separately). Gated on the
* handover being signed and warehouse fees paid.
*/
async truckExitPaper(assignmentId: string): Promise<{ filename: string; buffer: Buffer }> {
const [truck] = await this.dataSource.query(
`SELECT a.booking_id AS "bookingId", a.plate_number AS "plateNumber",
a.driver_name AS "driverName", a.truck_type AS "truckType",
a.gross_weight_kg AS "grossWeightKg", a.departed_at AS "departedAt",
b.reference AS "bookingReference", company.name AS "customerName"
FROM freight.customer_truck_assignments a
JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
WHERE a.id = $1 AND a.deleted_at IS NULL`,
[assignmentId],
);
if (!truck) throw new NotFoundException(`Truck assignment ${assignmentId} not found`);
if (!(await this.handover.isFullySigned(truck.bookingId))) {
throw new BadRequestException('Handover must be signed before the exit paper can be generated');
}
const [inv]: Array<{ id: string }> = await this.dataSource.query(
`SELECT id FROM freight.warehouse_inventory
WHERE booking_id = $1 AND deleted_at IS NULL ORDER BY created_at LIMIT 1`,
[truck.bookingId],
);
if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id);
const containers: Array<{ containerNumber: string; goods: string | null }> =
await this.dataSource.query(
`SELECT c.container_number AS "containerNumber",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods
FROM freight.customer_truck_containers c
JOIN freight.bookings b ON b.id = c.booking_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
WHERE c.assignment_id = $1 AND c.deleted_at IS NULL
ORDER BY c.container_number`,
[assignmentId],
);
const html = this.buildTruckExitPaperHtml({
reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`,
bookingReference: truck.bookingReference,
customerName: truck.customerName,
plateNumber: truck.plateNumber,
driverName: truck.driverName,
truckType: truck.truckType,
grossWeightKg: Number(truck.grossWeightKg ?? 0),
gateOut: truck.departedAt,
containers,
});
return {
filename: `exit-${String(truck.plateNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Warehouse exit paper'),
};
}
private buildTruckExitPaperHtml(data: {
reference: string;
bookingReference: string;
customerName: string | null;
plateNumber: string;
driverName: string;
truckType: string;
grossWeightKg: number;
gateOut: string | Date | null;
containers: Array<{ containerNumber: string; goods: string | null }>;
}): string {
const esc = (v: unknown) =>
String(v ?? '-').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const gateOut = data.gateOut ? new Date(data.gateOut).toLocaleString('en-GB') : '-';
const rows: Array<[string, string]> = [
['Booking Reference', data.bookingReference],
['Customer / Consignee', data.customerName ?? '-'],
['Pickup Truck Plate', data.plateNumber],
['Driver', data.driverName],
['Truck Type', data.truckType],
['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} kg`],
['Gate-Out Time', gateOut],
['Clearance Status', 'CLEARED FOR WAREHOUSE EXIT'],
];
const containerRows = data.containers.length
? data.containers
.map((c) => `<tr><td>${esc(c.containerNumber)}</td><td>${esc(c.goods)}</td></tr>`)
.join('')
: '<tr><td colspan="2">No containers loaded on this truck.</td></tr>';
return `<!doctype html><html><head><meta charset="utf-8" /><title>Warehouse Exit Paper</title>
<style>
body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 24px; }
h1 { font-size: 24px; text-transform: uppercase; margin: 0 0 4px; }
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12px; text-align: left; vertical-align: top; }
th { background: #f8fafc; width: 34%; font-weight: 800; }
.section { margin-top: 18px; font-weight: 800; color: #064c27; text-transform: uppercase; letter-spacing: .1em; }
.ref strong { font-size: 16px; }
</style></head>
<body>
<div style="color:#064c27;font-weight:800;text-transform:uppercase;">Ethio-Djibouti Railway S.C.</div>
<h1>Warehouse Release / Exit Paper</h1>
<div class="ref">Document / Release No. <strong>${esc(data.reference)}</strong></div>
<div class="section">Release Particulars</div>
<table><tbody>${rows.map(([l, v]) => `<tr><th>${esc(l)}</th><td>${esc(v)}</td></tr>`).join('')}</tbody></table>
<div class="section">Containers Leaving on This Truck</div>
<table><thead><tr><th style="width:40%">Container Number</th><th>Goods</th></tr></thead>
<tbody>${containerRows}</tbody></table>
</body></html>`;
}
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
const [row] = await this.dataSource.query(
@@ -2388,7 +2805,17 @@ export class WarehouseInventoryService {
return {
filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
// Styled fallback titled as a GRN (not a release order) for Chromium-less render.
buffer: await this.releaseDocuments.renderStyledDocument(
html,
{
titleLines: ['GOODS RECEIVED', 'NOTE'],
subtitle: 'OFFICIAL WAREHOUSE GOODS RECEIVED NOTE',
sectionTitle: 'RECEIVED PARTICULARS',
refLabel: 'GRN No.',
},
'Goods Received Note',
),
};
}
@@ -2462,6 +2889,10 @@ export class WarehouseInventoryService {
);
});
// Sign the structured handover record(s) for this booking (self-haul: before
// the truck leaves). Kept alongside the legacy approval note.
await this.handover.signForBooking(bookingId, userId);
return {
bookingId,
inventoryId: item.id,
@@ -2589,7 +3020,17 @@ export class WarehouseInventoryService {
return {
filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
// Styled fallback titled as a handover (not a release order) for Chromium-less render.
buffer: await this.releaseDocuments.renderStyledDocument(
html,
{
titleLines: ['IMPORT GOODS', 'HANDOVER', 'DOCUMENT'],
subtitle: 'EDR TO CUSTOMER WAREHOUSE HANDOVER',
sectionTitle: 'HANDOVER PARTICULARS',
refLabel: 'Document / Handover No.',
},
'Import Goods Handover',
),
};
}
@@ -2601,6 +3042,29 @@ export class WarehouseInventoryService {
throw new BadRequestException('A release order must be issued before the goods can be delivered');
}
// Self-haul: the customer's own truck delivers — deliver only after the
// handover is signed AND the truck has left the warehouse holding the goods.
if (item.bookingId) {
const [sh]: Array<{ assignedAt: string | null }> = await this.dataSource.query(
`SELECT customer_truck_assigned_at AS "assignedAt"
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[item.bookingId],
);
if (sh?.assignedAt) {
if (!(await this.handover.isFullySigned(item.bookingId))) {
throw new BadRequestException('Handover must be signed before delivery');
}
const [left]: Array<{ n: string }> = await this.dataSource.query(
`SELECT COUNT(*) AS n FROM freight.customer_truck_assignments
WHERE booking_id = $1 AND departed_at IS NOT NULL AND deleted_at IS NULL`,
[item.bookingId],
);
if (Number(left?.n ?? 0) === 0) {
throw new BadRequestException('Deliver is available only after the customer truck has left');
}
}
}
const receiverName = dto.receiverName.trim();
const deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : new Date();
const weight = Number(item.weight) || 0;
@@ -2645,6 +3109,27 @@ export class WarehouseInventoryService {
},
manager,
);
// Handover on delivery. EDR last-mile generates its handover HERE (after
// exit, on delivery). Self-haul handovers were generated on arrival —
// stamp them delivered.
if (item.bookingId) {
const [b]: Array<{ selfHaul: string | null }> = await manager.query(
`SELECT customer_truck_assigned_at AS "selfHaul"
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[item.bookingId],
);
if (b?.selfHaul) {
await manager.query(
`UPDATE freight.booking_handovers
SET delivered_at = COALESCE(delivered_at, NOW()), updated_at = NOW()
WHERE booking_id = $1 AND deleted_at IS NULL`,
[item.bookingId],
);
} else {
await this.handover.ensureAtDelivery(item.bookingId, {}, manager);
}
}
});
return this.findById(id);

View File

@@ -182,25 +182,38 @@ export class WarehouseInvoiceService {
const items = previews
.filter((p) => p.amount > 0)
.map((p) => {
const feeType: WarehouseFeeType =
p.ruleType === "STORAGE_FEE"
? "STORAGE_FEE"
: isContainer
? "CONTAINER_DEMURRAGE"
: "BULK_DEMURRAGE";
const days = `${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)`;
const tierSuffix = p.tiers.length ? " using tiered tariff" : ` after ${p.freeDays} free`;
let feeType: WarehouseFeeType;
let description: string;
switch (p.ruleType) {
case "STORAGE_FEE":
feeType = "STORAGE_FEE";
description = `Storage fee - ${days}${tierSuffix}`;
break;
case "DOUBLE_HANDLING_FEE": {
feeType = "DOUBLE_HANDLING";
const unit =
p.basis === "PER_TON"
? "ton(s)"
: p.basis === "PER_ITEM"
? "item(s)"
: "container(s)";
description = `Double handling - ${p.billableUnits} ${unit}`;
break;
}
case "TRUCK_DETENTION_FEE":
feeType = "TRUCK_DETENTION";
description = `Truck detention - ${days}${tierSuffix}`;
break;
default:
feeType = isContainer ? "CONTAINER_DEMURRAGE" : "BULK_DEMURRAGE";
description = `${isContainer ? "Container" : "Bulk"} demurrage - ${days}${tierSuffix}`;
}
return {
feeRuleId: p.ruleId,
feeType,
description:
p.ruleType === "STORAGE_FEE"
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length
? " using tiered tariff"
: ` after ${p.freeDays} free`
}`
: `${isContainer ? "Container" : "Bulk"} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length
? " using tiered tariff"
: ` after ${p.freeDays} free`
}`,
description,
quantity: p.billableUnits,
unitRate: p.ratePerDay,
amount: p.amount,

View File

@@ -26,6 +26,8 @@ export const WAREHOUSE_FEE_TYPES = [
'BULK_DEMURRAGE',
'STORAGE_FEE',
'HANDLING_FEE',
'DOUBLE_HANDLING',
'TRUCK_DETENTION',
] as const;
export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number];

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { PdfRenderService } from '../billing/documents/pdf-render.service';
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
const MIN_VALID_PDF_BYTES = 2_000;
@@ -32,16 +33,51 @@ export class WarehouseReleaseDocumentService {
return this.pdf.htmlToPdfBuffer(html, { label });
}
private htmlToBasicPdfBuffer(html: string): Buffer {
/**
* Render a "summary tiles + one table + notice + signatures" document (the
* marshalling / load-list layout) with a STYLED table-aware fallback for when
* Chromium is unavailable — so the manifest draws as a real gridded document
* instead of a flat plain-text dump.
*/
renderTabularDocument(html: string, label = 'Document'): Promise<Buffer> {
return this.pdf.htmlToPdfBuffer(html, {
label,
fallback: (preparedHtml) => buildTabularFallbackPdf(preparedHtml),
});
}
/**
* Render document HTML with a STYLED hand-built fallback (the release layout,
* but with a custom title + section heading) for when Chromium is unavailable.
* Handover / GRN use this so their fallback looks like a proper document —
* not a plain-text dump, and not mislabelled as a release order.
*/
renderStyledDocument(
html: string,
fallbackOpts: { titleLines?: string[]; subtitle?: string; sectionTitle?: string; refLabel?: string },
label = 'Document',
): Promise<Buffer> {
return this.pdf.htmlToPdfBuffer(html, {
label,
fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml, fallbackOpts),
});
}
private htmlToBasicPdfBuffer(
html: string,
opts?: { titleLines?: string[]; subtitle?: string; sectionTitle?: string; refLabel?: string },
): Buffer {
const doc = this.extractReleaseDocument(html);
const titleLines = (opts?.titleLines ?? ['WAREHOUSE GATE', 'CLEARANCE / RELEASE', 'ORDER']).slice(0, 3);
const subtitle = opts?.subtitle ?? 'OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION';
const sectionTitle = opts?.sectionTitle ?? 'RELEASE PARTICULARS';
const refLabel = opts?.refLabel ?? 'Document / Release No.';
const body: string[] = [
this.lineOp(36, 810, 559, 810, '0 0 0', 2.2),
this.textOp('ETHIO-DJIBOUTI RAILWAY S.C.', 36, 787, 9, 'F2', '0.08 0.32 0.18'),
this.textOp('WAREHOUSE GATE', 36, 764, 24, 'F2', '0.02 0.08 0.16'),
this.textOp('CLEARANCE / RELEASE', 36, 742, 24, 'F2', '0.02 0.08 0.16'),
this.textOp('ORDER', 36, 720, 24, 'F2', '0.02 0.08 0.16'),
this.textOp('OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION', 36, 696, 8.5, 'F1', '0.25 0.34 0.45'),
this.textOp('Document / Release No.', 424, 781, 8.5, 'F1', '0.15 0.22 0.32'),
...titleLines.map((line, i) => this.textOp(line, 36, 764 - i * 22, 24, 'F2', '0.02 0.08 0.16')),
this.textOp(subtitle, 36, 764 - titleLines.length * 22 + 2, 8.5, 'F1', '0.25 0.34 0.45'),
this.textOp(refLabel, 424, 781, 8.5, 'F1', '0.15 0.22 0.32'),
this.textOp(doc.reference, 504 - doc.reference.length * 2.2, 761, 15, 'F2', '0.02 0.08 0.16'),
this.textOp(`Issued: ${doc.issuedAt}`, 424, 742, 8.5, 'F1', '0.15 0.22 0.32'),
this.lineOp(36, 682, 559, 682, '0.08 0.32 0.18', 2),
@@ -50,7 +86,7 @@ export class WarehouseReleaseDocumentService {
...this.wrapLines(doc.notice, 68)
.slice(0, 4)
.map((line, index) => this.textOp(line, 52, 659 - index * 12, 9.2, 'F1')),
this.textOp('RELEASE PARTICULARS', 36, 604, 10, 'F2', '0.08 0.32 0.18'),
this.textOp(sectionTitle, 36, 604, 10, 'F2', '0.08 0.32 0.18'),
];
let y = 586;

View File

@@ -15,6 +15,8 @@ import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.en
import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
import { BookingHandover } from './entities/booking-handover.entity';
import { HandoverService } from './handover.service';
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
import { WarehouseLoading } from './entities/warehouse-loading.entity';
import { WarehouseYard } from './entities/warehouse-yard.entity';
@@ -65,6 +67,7 @@ import { WarehousesService } from './warehouses.service';
WarehouseInspectionReport,
WarehouseAllocationRule,
WarehouseFeeRule,
BookingHandover,
]),
BillingModule,
DocumentsModule,
@@ -113,6 +116,7 @@ import { WarehousesService } from './warehouses.service';
WarehouseSchedulingAdapterService,
WarehouseReleaseDocumentService,
SchedulingReadFacade,
HandoverService,
],
exports: [
WarehousesService,

View File

@@ -20,8 +20,8 @@ const COMPANY_TIN = 'FLMDEMO001';
const COMPANY_EMAIL = 'first-last-mile-demo@edr.local';
const YARDS = [
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 },
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 },
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti' as const, displayOrder: 1 },
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia' as const, displayOrder: 2 },
];
const CONTAINER_TYPES = [

View File

@@ -28,17 +28,22 @@ const COMPANY_EMAIL = "train-scheduling-demo@edr.local";
const COMPANY_TIN = "1234567890";
const YARDS = [
{ code: "DJIBOUTI", label: "Djibouti", country: "Djibouti", displayOrder: 1 },
{
code: "DJIBOUTI",
label: "Djibouti",
country: "Djibouti" as const,
displayOrder: 1,
},
{
code: "ADDIS_ABABA",
label: "Addis Ababa",
country: "Ethiopia",
country: "Ethiopia" as const,
displayOrder: 2,
},
{
code: "DIRE_DAWA",
label: "Dire Dawa",
country: "Ethiopia",
country: "Ethiopia" as const,
displayOrder: 3,
},
];

View File

@@ -1,30 +1,20 @@
import { Injectable, Logger } from "@nestjs/common";
import {
Application,
Organization,
OrganizationConfiguration,
Permission,
Position,
PositionPermission,
PositionType,
Role,
RolePermission,
Unit,
} from "@tria-plc/iamapi-common";
import { DataSource, EntityManager, In } from "typeorm";
import { DataSource, EntityManager } from "typeorm";
import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum";
import { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from "./freight-permissions.registry";
import {
EDR_FREIGHT_POSITIONS,
EDR_FREIGHT_ROLES,
type FreightSeedPosition,
type FreightSeedRole,
EDR_FREIGHT_APPLICATION,
EDR_FREIGHT_PERMISSIONS,
} from "./edr-freight.seed";
const EDR_UNIT_KEY = "edr_freight_hq";
const EDR_UNIT_NAME = { en: "EDR Freight HQ" };
const EDR_POSITION_TYPE_KEY = "edr_freight_role";
const EDR_POSITION_TYPE_NAME = { en: "EDR Freight Role" };
const EDR_UNIT_KEY = "edr_freight_app";
const EDR_UNIT_NAME = { en: "EDR Freight App" };
const EDR_ORG_KEY = "edr_freight";
const EDR_ORG_NAME = { en: "EDR Freight" };
@@ -51,22 +41,15 @@ export class EdrOrgSeeder {
const organization = await this.ensureOrganization(manager);
await this.ensureOrganizationConfiguration(manager, organization.id);
await this.ensureRoles(manager, EDR_FREIGHT_ROLES);
await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES);
await this.ensureSuperAdminPermissions(manager);
await this.ensureDefaultUnit(manager, organization.id);
// Positions-as-roles: seed operational positions and grant their
// permissions via PositionPermission (not Role/RolePermission).
const unit = await this.ensureDefaultUnit(manager, organization.id);
const positionType = await this.ensureDefaultPositionType(manager, unit.id);
await this.ensurePositions(
manager,
organization.id,
unit.id,
positionType.id,
EDR_FREIGHT_POSITIONS,
);
await this.ensurePositionPermissions(manager, unit.id, EDR_FREIGHT_POSITIONS);
const application = await this.ensureApplication(manager);
await this.ensurePermissions(manager, application.id);
// Roles, positions and their permission links are intentionally NOT
// seeded for now — only the application-scoped permission catalog,
// mirroring how the default IAM seed relates permissions to their
// application. Grants are assigned later through the IAM UI.
});
this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`);
@@ -128,111 +111,6 @@ export class EdrOrgSeeder {
);
}
private async ensureRoles(manager: EntityManager, seedRoles: FreightSeedRole[]) {
await manager.getRepository(Role).upsert(
seedRoles.map(({ key, name }) => ({ key, name })),
{
conflictPaths: { key: true },
},
);
this.logger.log(
`Ensured EDR roles '${seedRoles.map((role) => role.key).join("', '")}'`,
);
}
private async ensureRolePermissions(
manager: EntityManager,
seedRoles: FreightSeedRole[],
) {
const permissionKeys = [...new Set(seedRoles.flatMap((role) => role.permissionKeys))];
if (!permissionKeys.length) {
this.logger.log("No EDR role permissions configured; skipping role-permission links");
return;
}
const roleRepository = manager.getRepository(Role);
const rolePermissionRepository = manager.getRepository(RolePermission);
const roles = await roleRepository.find({
where: { key: In(seedRoles.map((role) => role.key)) },
select: { id: true, key: true },
});
const seededPermissions = await manager.getRepository(Permission).find({
where: { key: In(permissionKeys) },
select: { id: true, key: true },
});
const roleByKey = new Map(roles.map((role) => [role.key, role]));
const permissionByKey = new Map(
seededPermissions.map((permission) => [permission.key, permission]),
);
const rolePermissions = seedRoles.flatMap((role) => {
const seededRole = roleByKey.get(role.key);
if (!seededRole) {
throw new Error(`missing_role:${role.key}`);
}
return role.permissionKeys.map((permissionKey) => {
const seededPermission = permissionByKey.get(permissionKey);
if (!seededPermission) {
throw new Error(`missing_permission:${permissionKey}`);
}
return {
roleId: seededRole.id,
permissionId: seededPermission.id,
};
});
});
await rolePermissionRepository.upsert(rolePermissions, {
conflictPaths: { roleId: true, permissionId: true },
});
this.logger.log(`Ensured ${rolePermissions.length} EDR role-permission links`);
}
private async ensureSuperAdminPermissions(manager: EntityManager) {
const role = await manager.getRepository(Role).findOne({
where: { key: ERoleKey.SUPER_ADMIN },
select: { id: true, key: true },
});
if (!role) {
this.logger.warn(
`Role ${ERoleKey.SUPER_ADMIN} not found; skipping booking/rule-engine super_admin links`,
);
return;
}
const permissions = await manager.getRepository(Permission).find({
where: { key: In(BOOKING_RULE_ENGINE_PERMISSION_KEYS) },
select: { id: true, key: true },
});
if (!permissions.length) {
this.logger.warn('No booking/rule-engine permissions found for super_admin');
return;
}
await manager.getRepository(RolePermission).upsert(
permissions.map((permission) => ({
roleId: role.id,
permissionId: permission.id,
})),
{ conflictPaths: { roleId: true, permissionId: true } },
);
this.logger.log(
`Ensured ${permissions.length} booking+rule-engine permissions on super_admin`,
);
}
private async ensureDefaultUnit(
manager: EntityManager,
organizationId: string,
@@ -258,120 +136,51 @@ export class EdrOrgSeeder {
return { id: unit.id };
}
private async ensureDefaultPositionType(
private async ensureApplication(
manager: EntityManager,
unitId: string,
): Promise<{ id: string }> {
const positionTypeRepository = manager.getRepository(PositionType);
const applicationRepository = manager.getRepository(Application);
// PositionType has no unique constraint on (key, unitId); find-then-insert.
let positionType = await positionTypeRepository.findOne({
where: { key: EDR_POSITION_TYPE_KEY, unitId },
const application = await applicationRepository.findOne({
where: { key: EDR_FREIGHT_APPLICATION.key },
select: { id: true },
});
if (!positionType) {
const insertResult = await positionTypeRepository.insert({
key: EDR_POSITION_TYPE_KEY,
name: EDR_POSITION_TYPE_NAME,
isSystem: true,
unitId,
if (!application?.id) {
const insertResult = await applicationRepository.insert({
id: EDR_FREIGHT_APPLICATION.id,
key: EDR_FREIGHT_APPLICATION.key,
name: { ...EDR_FREIGHT_APPLICATION.name },
});
this.logger.log(`Seeded EDR position type '${EDR_POSITION_TYPE_KEY}'`);
this.logger.log(`Seeded EDR application '${EDR_FREIGHT_APPLICATION.key}'`);
return { id: insertResult.identifiers[0]?.id as string };
}
this.logger.log(`Ensured EDR position type '${EDR_POSITION_TYPE_KEY}'`);
return { id: positionType.id };
this.logger.log(`Ensured EDR application '${EDR_FREIGHT_APPLICATION.key}'`);
return { id: application.id };
}
private async ensurePositions(
private async ensurePermissions(
manager: EntityManager,
organizationId: string,
unitId: string,
positionTypeId: string,
seedPositions: FreightSeedPosition[],
applicationId: string,
) {
await manager.getRepository(Position).upsert(
seedPositions.map(({ key, name, rank }) => ({
key,
name,
rank,
organizationId,
unitId,
positionTypeId,
const permissionRepository = manager.getRepository(Permission);
// Upsert by key so reruns are idempotent; applicationId ties every
// permission to the EDR Freight application (also backfills rows that
// were previously seeded without the relation).
await permissionRepository.upsert(
EDR_FREIGHT_PERMISSIONS.map((permission) => ({
id: permission.id,
key: permission.key,
name: { ...permission.name },
applicationId,
})),
{
conflictPaths: { key: true, unitId: true },
},
{ conflictPaths: { key: true } },
);
this.logger.log(
`Ensured ${seedPositions.length} EDR positions '${seedPositions
.map((position) => position.key)
.join("', '")}'`,
);
}
private async ensurePositionPermissions(
manager: EntityManager,
unitId: string,
seedPositions: FreightSeedPosition[],
) {
const permissionKeys = [
...new Set(seedPositions.flatMap((position) => position.permissionKeys)),
];
if (!permissionKeys.length) {
this.logger.log(
"No EDR position permissions configured; skipping position-permission links",
);
return;
}
const positions = await manager.getRepository(Position).find({
where: { key: In(seedPositions.map((position) => position.key)), unitId },
select: { id: true, key: true },
});
const seededPermissions = await manager.getRepository(Permission).find({
where: { key: In(permissionKeys) },
select: { id: true, key: true },
});
const positionByKey = new Map(
positions.map((position) => [position.key, position]),
);
const permissionByKey = new Map(
seededPermissions.map((permission) => [permission.key, permission]),
);
const positionPermissions = seedPositions.flatMap((position) => {
const seededPosition = positionByKey.get(position.key);
if (!seededPosition) {
throw new Error(`missing_position:${position.key}`);
}
return position.permissionKeys.map((permissionKey) => {
const seededPermission = permissionByKey.get(permissionKey);
if (!seededPermission) {
throw new Error(`missing_permission:${permissionKey}`);
}
return {
positionId: seededPosition.id as string,
permissionId: seededPermission.id,
};
});
});
await manager.getRepository(PositionPermission).upsert(positionPermissions, {
conflictPaths: { positionId: true, permissionId: true },
});
this.logger.log(
`Ensured ${positionPermissions.length} EDR position-permission links`,
`Ensured ${EDR_FREIGHT_PERMISSIONS.length} permissions on application '${EDR_FREIGHT_APPLICATION.key}'`,
);
}
}

View File

@@ -60,6 +60,7 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'),
perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'),
perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'),
perm('a1000001-0001-4000-8000-000000000024', 'edr_freight_app:bookings:create', 'Create booking'),
];
/**
@@ -117,11 +118,209 @@ export const GAP_CONTROLLER_PERMISSIONS: FreightPermissionSeed[] = [
perm('c1000001-0001-4000-8000-000000000001', 'edr_freight_app:allocation:manage', 'Allocate containers to vehicles'),
];
/**
* Advanced backoffice resources — full CRUD + workflow-action keys.
* See docs/rbac/freight-backoffice-permissions.md. Additive only: the existing
* bookings/contracts/rule-engine/allocation keys above are unchanged.
*/
// C. Customers
export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [
perm('d1a00001-0001-4000-8000-000000000001', 'edr_freight_app:customers:view', 'View customers'),
perm('d1a00001-0001-4000-8000-000000000002', 'edr_freight_app:customers:create', 'Create customer'),
perm('d1a00001-0001-4000-8000-000000000003', 'edr_freight_app:customers:update', 'Update customer'),
perm('d1a00001-0001-4000-8000-000000000004', 'edr_freight_app:customers:deactivate', 'Deactivate customer'),
perm('d1a00001-0001-4000-8000-000000000005', 'edr_freight_app:customers:verify', 'Verify customer (KYC/Fayda)'),
];
// D. Finance — payments + invoices
export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
perm('d2a00001-0001-4000-8000-000000000001', 'edr_freight_app:payments:view', 'View payments'),
perm('d2a00001-0001-4000-8000-000000000002', 'edr_freight_app:payments:verify', 'Verify/settle payment'),
perm('d2a00001-0001-4000-8000-000000000003', 'edr_freight_app:payments:refund', 'Refund payment'),
perm('d2b00001-0001-4000-8000-000000000001', 'edr_freight_app:invoices:view', 'View invoices'),
perm('d2b00001-0001-4000-8000-000000000002', 'edr_freight_app:invoices:create', 'Generate invoice'),
perm('d2b00001-0001-4000-8000-000000000003', 'edr_freight_app:invoices:cancel', 'Cancel invoice'),
perm('d2b00001-0001-4000-8000-000000000004', 'edr_freight_app:invoices:export', 'Download invoice document'),
];
// E. First / last mile operations
export const MILE_PERMISSIONS: FreightPermissionSeed[] = [
perm('d3a00001-0001-4000-8000-000000000001', 'edr_freight_app:first_mile:view', 'View first-mile'),
perm('d3a00001-0001-4000-8000-000000000002', 'edr_freight_app:first_mile:accept', 'Accept first-mile request'),
perm('d3a00001-0001-4000-8000-000000000003', 'edr_freight_app:first_mile:create', 'Create first-mile'),
perm('d3a00001-0001-4000-8000-000000000004', 'edr_freight_app:first_mile:update', 'Update first-mile'),
perm('d3a00001-0001-4000-8000-000000000005', 'edr_freight_app:first_mile:delete', 'Delete first-mile'),
perm('d3a00001-0001-4000-8000-000000000006', 'edr_freight_app:first_mile:assign_vehicles', 'Assign first-mile vehicles'),
perm('d3a00001-0001-4000-8000-000000000007', 'edr_freight_app:first_mile:set_distances', 'Set first-mile distances'),
perm('d3a00001-0001-4000-8000-000000000008', 'edr_freight_app:first_mile:generate_invoice', 'Generate first-mile invoice'),
perm('d3b00001-0001-4000-8000-000000000001', 'edr_freight_app:last_mile:view', 'View last-mile'),
perm('d3b00001-0001-4000-8000-000000000002', 'edr_freight_app:last_mile:accept', 'Accept last-mile request'),
perm('d3b00001-0001-4000-8000-000000000003', 'edr_freight_app:last_mile:create', 'Create last-mile'),
perm('d3b00001-0001-4000-8000-000000000004', 'edr_freight_app:last_mile:update', 'Update last-mile'),
perm('d3b00001-0001-4000-8000-000000000005', 'edr_freight_app:last_mile:delete', 'Delete last-mile'),
perm('d3b00001-0001-4000-8000-000000000006', 'edr_freight_app:last_mile:assign_vehicles', 'Assign last-mile vehicles'),
perm('d3b00001-0001-4000-8000-000000000007', 'edr_freight_app:last_mile:set_distances', 'Set last-mile distances'),
perm('d3b00001-0001-4000-8000-000000000008', 'edr_freight_app:last_mile:generate_invoice', 'Generate last-mile invoice'),
];
// F. Fleet — rail assets (splits the flat fleet:view/manage)
export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
perm('e1a00001-0001-4000-8000-000000000001', 'edr_freight_app:locomotives:view', 'View locomotives'),
perm('e1a00001-0001-4000-8000-000000000002', 'edr_freight_app:locomotives:create', 'Create locomotive'),
perm('e1a00001-0001-4000-8000-000000000003', 'edr_freight_app:locomotives:update', 'Update locomotive'),
perm('e1a00001-0001-4000-8000-000000000004', 'edr_freight_app:locomotives:delete', 'Delete locomotive'),
perm('e1b00001-0001-4000-8000-000000000001', 'edr_freight_app:wagons:view', 'View wagons'),
perm('e1b00001-0001-4000-8000-000000000002', 'edr_freight_app:wagons:create', 'Create wagon'),
perm('e1b00001-0001-4000-8000-000000000003', 'edr_freight_app:wagons:update', 'Update wagon'),
perm('e1b00001-0001-4000-8000-000000000004', 'edr_freight_app:wagons:delete', 'Delete wagon'),
perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'),
perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'),
perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'),
perm('e1c00001-0001-4000-8000-000000000004', 'edr_freight_app:trains:delete', 'Delete train'),
perm('e1c00001-0001-4000-8000-000000000005', 'edr_freight_app:trains:assign_wagons', 'Assign wagons to train'),
perm('e1d00001-0001-4000-8000-000000000001', 'edr_freight_app:routes:view', 'View routes'),
perm('e1d00001-0001-4000-8000-000000000002', 'edr_freight_app:routes:create', 'Create route'),
perm('e1d00001-0001-4000-8000-000000000003', 'edr_freight_app:routes:update', 'Update route'),
perm('e1d00001-0001-4000-8000-000000000004', 'edr_freight_app:routes:delete', 'Delete route'),
perm('e1e00001-0001-4000-8000-000000000001', 'edr_freight_app:containers:view', 'View containers'),
perm('e1e00001-0001-4000-8000-000000000002', 'edr_freight_app:containers:create', 'Create container'),
perm('e1e00001-0001-4000-8000-000000000003', 'edr_freight_app:containers:update', 'Update container'),
perm('e1e00001-0001-4000-8000-000000000004', 'edr_freight_app:containers:delete', 'Delete container'),
perm('e1f00001-0001-4000-8000-000000000001', 'edr_freight_app:cargoes:view', 'View cargoes'),
perm('e1f00001-0001-4000-8000-000000000002', 'edr_freight_app:cargoes:create', 'Create cargo'),
perm('e1f00001-0001-4000-8000-000000000003', 'edr_freight_app:cargoes:update', 'Update cargo'),
perm('e1f00001-0001-4000-8000-000000000004', 'edr_freight_app:cargoes:delete', 'Delete cargo'),
];
// G. Fleet — road & telemetry
export const FLEET_ROAD_PERMISSIONS: FreightPermissionSeed[] = [
perm('e2a00001-0001-4000-8000-000000000001', 'edr_freight_app:vehicles:view', 'View vehicles'),
perm('e2a00001-0001-4000-8000-000000000002', 'edr_freight_app:vehicles:create', 'Create vehicle'),
perm('e2a00001-0001-4000-8000-000000000003', 'edr_freight_app:vehicles:update', 'Update vehicle'),
perm('e2a00001-0001-4000-8000-000000000004', 'edr_freight_app:vehicles:delete', 'Delete vehicle'),
perm('e2b00001-0001-4000-8000-000000000001', 'edr_freight_app:drivers:view', 'View drivers'),
perm('e2b00001-0001-4000-8000-000000000002', 'edr_freight_app:drivers:create', 'Create driver'),
perm('e2b00001-0001-4000-8000-000000000003', 'edr_freight_app:drivers:update', 'Update driver'),
perm('e2b00001-0001-4000-8000-000000000004', 'edr_freight_app:drivers:delete', 'Delete driver'),
perm('e2c00001-0001-4000-8000-000000000001', 'edr_freight_app:tracking:view', 'Track vehicles'),
perm('e2d00001-0001-4000-8000-000000000001', 'edr_freight_app:fuel:view', 'View fuel purchases'),
perm('e2d00001-0001-4000-8000-000000000002', 'edr_freight_app:fuel:create', 'Create fuel purchase'),
perm('e2d00001-0001-4000-8000-000000000003', 'edr_freight_app:fuel:update', 'Update fuel purchase'),
perm('e2d00001-0001-4000-8000-000000000004', 'edr_freight_app:fuel:delete', 'Delete fuel purchase'),
perm('e2d00001-0001-4000-8000-000000000005', 'edr_freight_app:fuel:approve', 'Approve fuel purchase'),
perm('e2e00001-0001-4000-8000-000000000001', 'edr_freight_app:maintenance:view', 'View maintenance'),
perm('e2e00001-0001-4000-8000-000000000002', 'edr_freight_app:maintenance:create', 'Create maintenance'),
perm('e2e00001-0001-4000-8000-000000000003', 'edr_freight_app:maintenance:update', 'Update maintenance'),
perm('e2e00001-0001-4000-8000-000000000004', 'edr_freight_app:maintenance:delete', 'Delete maintenance'),
perm('e2e00001-0001-4000-8000-000000000005', 'edr_freight_app:maintenance:complete', 'Complete maintenance'),
perm('e2f00001-0001-4000-8000-000000000001', 'edr_freight_app:fleet_reports:view', 'View fleet financial reports'),
perm('e2f00001-0001-4000-8000-000000000002', 'edr_freight_app:fleet_reports:export', 'Export fleet financial reports'),
perm('e2000001-0001-4000-8000-000000000001', 'edr_freight_app:fleet_dashboard:view', 'View fleet dashboard'),
];
// H. Warehouse management
export const WAREHOUSE_PERMISSIONS: FreightPermissionSeed[] = [
perm('f1000001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_dashboard:view', 'View warehouse dashboard'),
perm('f1a00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouses:view', 'View warehouses'),
perm('f1a00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouses:create', 'Create warehouse'),
perm('f1a00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouses:update', 'Update warehouse'),
perm('f1a00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouses:delete', 'Delete warehouse'),
perm('f1b00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_yards:view', 'View warehouse yards'),
perm('f1b00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_yards:create', 'Create warehouse yard'),
perm('f1b00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_yards:update', 'Update warehouse yard'),
perm('f1b00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_yards:delete', 'Delete warehouse yard'),
perm('f1c00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_zones:view', 'View warehouse zones'),
perm('f1c00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_zones:create', 'Create warehouse zone'),
perm('f1c00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_zones:update', 'Update warehouse zone'),
perm('f1d00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_allocation_rules:view', 'View allocation rules'),
perm('f1d00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_allocation_rules:create', 'Create allocation rule'),
perm('f1d00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_allocation_rules:update', 'Update allocation rule'),
perm('f1d00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_allocation_rules:delete', 'Delete allocation rule'),
perm('f1e00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_fee_rules:view', 'View fee rules'),
perm('f1e00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_fee_rules:create', 'Create fee rule'),
perm('f1e00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_fee_rules:update', 'Update fee rule'),
perm('f1e00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_fee_rules:delete', 'Delete fee rule'),
perm('f1f00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_inspection_reports:view', 'View inspection reports'),
perm('f1f00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_inspection_reports:create', 'Create inspection report'),
perm('f1f00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_inspection_reports:update', 'Update inspection report'),
];
// I. Port & terminal — inventory movement + interchange + fee invoices
export const PORT_TERMINAL_PERMISSIONS: FreightPermissionSeed[] = [
perm('f2a00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_inventory:view', 'View terminal inventory'),
perm('f2a00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_inventory:receive', 'Receive inventory'),
perm('f2a00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_inventory:move', 'Move/store/reserve inventory'),
perm('f2a00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_inventory:load', 'Load inventory'),
perm('f2a00001-0001-4000-8000-000000000005', 'edr_freight_app:warehouse_inventory:unload', 'Unload inventory'),
perm('f2a00001-0001-4000-8000-000000000006', 'edr_freight_app:warehouse_inventory:dispatch', 'Dispatch inventory'),
perm('f2a00001-0001-4000-8000-000000000007', 'edr_freight_app:warehouse_inventory:gate_pass', 'Gate-clearance inventory'),
perm('f2a00001-0001-4000-8000-000000000008', 'edr_freight_app:warehouse_inventory:release', 'Release inventory'),
perm('f2a00001-0001-4000-8000-000000000009', 'edr_freight_app:warehouse_inventory:deliver', 'Deliver inventory'),
perm('f2a00001-0001-4000-8000-00000000000a', 'edr_freight_app:warehouse_inventory:inspect', 'Inspect inventory'),
perm('f2b00001-0001-4000-8000-000000000001', 'edr_freight_app:interchange_documents:view', 'View interchange documents'),
perm('f2b00001-0001-4000-8000-000000000002', 'edr_freight_app:interchange_documents:generate', 'Generate interchange document'),
perm('f2b00001-0001-4000-8000-000000000003', 'edr_freight_app:interchange_documents:acknowledge', 'Acknowledge interchange document'),
perm('f2b00001-0001-4000-8000-000000000004', 'edr_freight_app:interchange_documents:dispute', 'Dispute interchange document'),
perm('f2b00001-0001-4000-8000-000000000005', 'edr_freight_app:interchange_documents:cancel', 'Cancel interchange document'),
perm('f2c00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_fee_invoices:view', 'View warehouse fee invoices'),
perm('f2c00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_fee_invoices:generate', 'Generate warehouse fee invoice'),
perm('f2c00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_fee_invoices:cancel', 'Cancel warehouse fee invoice'),
perm('f2c00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_fee_invoices:pay', 'Pay warehouse fee invoice'),
];
// E'. Train-scheduling finer actions (augment existing view/manage)
export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [
perm('a2a00001-0001-4000-8000-000000000001', 'edr_freight_app:train_scheduling:create', 'Create train schedule'),
perm('a2a00001-0001-4000-8000-000000000002', 'edr_freight_app:train_scheduling:update', 'Update train schedule'),
perm('a2a00001-0001-4000-8000-000000000003', 'edr_freight_app:train_scheduling:cancel', 'Cancel train schedule'),
perm('a2a00001-0001-4000-8000-000000000004', 'edr_freight_app:train_scheduling:reschedule', 'Reschedule train'),
perm('a2a00001-0001-4000-8000-000000000005', 'edr_freight_app:train_scheduling:rules_manage', 'Manage global scheduling rules'),
];
// L. Administration & settings (split from the coarse admin umbrella)
export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
perm('b3a00001-0001-4000-8000-000000000001', 'edr_freight_app:config:contract_validity:view', 'View contract validity periods'),
perm('b3a00001-0001-4000-8000-000000000002', 'edr_freight_app:config:contract_validity:manage', 'Manage contract validity periods'),
perm('b4a00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:file_upload:view', 'View file-upload settings'),
perm('b4a00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:file_upload:manage', 'Manage file-upload settings'),
perm('b4b00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:dropdown:view', 'View dropdown settings'),
perm('b4b00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:dropdown:manage', 'Manage dropdown settings'),
];
// M. Staff / IAM admin — NEW keys only. The employee_registration / role_assignment
// / hierarchy_* / position_types:view keys are seeded separately in edr-freight.seed.ts.
export const STAFF_IAM_PERMISSIONS: FreightPermissionSeed[] = [
perm('c2a00001-0001-4000-8000-000000000001', 'edr_freight_app:staff:roles:view', 'View roles'),
perm('c2a00001-0001-4000-8000-000000000002', 'edr_freight_app:staff:roles:create', 'Create role'),
perm('c2a00001-0001-4000-8000-000000000003', 'edr_freight_app:staff:roles:update', 'Update role'),
perm('c2a00001-0001-4000-8000-000000000004', 'edr_freight_app:staff:roles:delete', 'Delete role'),
perm('c2b00001-0001-4000-8000-000000000001', 'edr_freight_app:staff:permissions:view', 'View permission assignments'),
perm('c2b00001-0001-4000-8000-000000000002', 'edr_freight_app:staff:permissions:assign', 'Assign permissions'),
perm('c2c00001-0001-4000-8000-000000000001', 'edr_freight_app:position_types:create', 'Create position type'),
perm('c2c00001-0001-4000-8000-000000000002', 'edr_freight_app:position_types:update', 'Update position type'),
perm('c2c00001-0001-4000-8000-000000000003', 'edr_freight_app:position_types:delete', 'Delete position type'),
];
export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
...CUSTOMER_PERMISSIONS,
...FINANCE_PERMISSIONS,
...MILE_PERMISSIONS,
...FLEET_RAIL_PERMISSIONS,
...FLEET_ROAD_PERMISSIONS,
...WAREHOUSE_PERMISSIONS,
...PORT_TERMINAL_PERMISSIONS,
...SCHEDULING_EXTRA_PERMISSIONS,
...CONFIG_SETTINGS_PERMISSIONS,
...STAFF_IAM_PERMISSIONS,
];
export const BOOKING_RULE_ENGINE_PERMISSIONS = [
...BOOKING_PERMISSIONS,
...CONTRACT_PERMISSIONS,
...RULE_ENGINE_PERMISSIONS,
...GAP_CONTROLLER_PERMISSIONS,
...ADVANCED_BACKOFFICE_PERMISSIONS,
];
export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map(
@@ -131,6 +330,7 @@ export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIO
export const FREIGHT_PERMS = {
bookings: {
view: 'edr_freight_app:bookings:view',
create: 'edr_freight_app:bookings:create',
clearanceView: 'edr_freight_app:bookings:clearance_view',
staffAccept: 'edr_freight_app:bookings:staff_accept',
requestChanges: 'edr_freight_app:bookings:request_changes',
@@ -168,6 +368,11 @@ export const FREIGHT_PERMS = {
trainScheduling: {
view: 'edr_freight_app:train_scheduling:view',
manage: 'edr_freight_app:train_scheduling:manage',
create: 'edr_freight_app:train_scheduling:create',
update: 'edr_freight_app:train_scheduling:update',
cancel: 'edr_freight_app:train_scheduling:cancel',
reschedule: 'edr_freight_app:train_scheduling:reschedule',
rulesManage: 'edr_freight_app:train_scheduling:rules_manage',
},
fleet: {
view: 'edr_freight_app:fleet:view',
@@ -183,6 +388,244 @@ export const FREIGHT_PERMS = {
allocation: {
manage: 'edr_freight_app:allocation:manage',
},
customers: {
view: 'edr_freight_app:customers:view',
create: 'edr_freight_app:customers:create',
update: 'edr_freight_app:customers:update',
deactivate: 'edr_freight_app:customers:deactivate',
verify: 'edr_freight_app:customers:verify',
},
payments: {
view: 'edr_freight_app:payments:view',
verify: 'edr_freight_app:payments:verify',
refund: 'edr_freight_app:payments:refund',
},
invoices: {
view: 'edr_freight_app:invoices:view',
create: 'edr_freight_app:invoices:create',
cancel: 'edr_freight_app:invoices:cancel',
export: 'edr_freight_app:invoices:export',
},
firstMile: {
view: 'edr_freight_app:first_mile:view',
accept: 'edr_freight_app:first_mile:accept',
create: 'edr_freight_app:first_mile:create',
update: 'edr_freight_app:first_mile:update',
delete: 'edr_freight_app:first_mile:delete',
assignVehicles: 'edr_freight_app:first_mile:assign_vehicles',
setDistances: 'edr_freight_app:first_mile:set_distances',
generateInvoice: 'edr_freight_app:first_mile:generate_invoice',
},
lastMile: {
view: 'edr_freight_app:last_mile:view',
accept: 'edr_freight_app:last_mile:accept',
create: 'edr_freight_app:last_mile:create',
update: 'edr_freight_app:last_mile:update',
delete: 'edr_freight_app:last_mile:delete',
assignVehicles: 'edr_freight_app:last_mile:assign_vehicles',
setDistances: 'edr_freight_app:last_mile:set_distances',
generateInvoice: 'edr_freight_app:last_mile:generate_invoice',
},
locomotives: {
view: 'edr_freight_app:locomotives:view',
create: 'edr_freight_app:locomotives:create',
update: 'edr_freight_app:locomotives:update',
delete: 'edr_freight_app:locomotives:delete',
},
wagons: {
view: 'edr_freight_app:wagons:view',
create: 'edr_freight_app:wagons:create',
update: 'edr_freight_app:wagons:update',
delete: 'edr_freight_app:wagons:delete',
},
trains: {
view: 'edr_freight_app:trains:view',
create: 'edr_freight_app:trains:create',
update: 'edr_freight_app:trains:update',
delete: 'edr_freight_app:trains:delete',
assignWagons: 'edr_freight_app:trains:assign_wagons',
},
routes: {
view: 'edr_freight_app:routes:view',
create: 'edr_freight_app:routes:create',
update: 'edr_freight_app:routes:update',
delete: 'edr_freight_app:routes:delete',
},
containers: {
view: 'edr_freight_app:containers:view',
create: 'edr_freight_app:containers:create',
update: 'edr_freight_app:containers:update',
delete: 'edr_freight_app:containers:delete',
},
cargoes: {
view: 'edr_freight_app:cargoes:view',
create: 'edr_freight_app:cargoes:create',
update: 'edr_freight_app:cargoes:update',
delete: 'edr_freight_app:cargoes:delete',
},
vehicles: {
view: 'edr_freight_app:vehicles:view',
create: 'edr_freight_app:vehicles:create',
update: 'edr_freight_app:vehicles:update',
delete: 'edr_freight_app:vehicles:delete',
},
drivers: {
view: 'edr_freight_app:drivers:view',
create: 'edr_freight_app:drivers:create',
update: 'edr_freight_app:drivers:update',
delete: 'edr_freight_app:drivers:delete',
},
tracking: {
view: 'edr_freight_app:tracking:view',
},
fuel: {
view: 'edr_freight_app:fuel:view',
create: 'edr_freight_app:fuel:create',
update: 'edr_freight_app:fuel:update',
delete: 'edr_freight_app:fuel:delete',
approve: 'edr_freight_app:fuel:approve',
},
maintenance: {
view: 'edr_freight_app:maintenance:view',
create: 'edr_freight_app:maintenance:create',
update: 'edr_freight_app:maintenance:update',
delete: 'edr_freight_app:maintenance:delete',
complete: 'edr_freight_app:maintenance:complete',
},
fleetReports: {
view: 'edr_freight_app:fleet_reports:view',
export: 'edr_freight_app:fleet_reports:export',
},
fleetDashboard: {
view: 'edr_freight_app:fleet_dashboard:view',
},
warehouseDashboard: {
view: 'edr_freight_app:warehouse_dashboard:view',
},
warehouses: {
view: 'edr_freight_app:warehouses:view',
create: 'edr_freight_app:warehouses:create',
update: 'edr_freight_app:warehouses:update',
delete: 'edr_freight_app:warehouses:delete',
},
warehouseYards: {
view: 'edr_freight_app:warehouse_yards:view',
create: 'edr_freight_app:warehouse_yards:create',
update: 'edr_freight_app:warehouse_yards:update',
delete: 'edr_freight_app:warehouse_yards:delete',
},
warehouseZones: {
view: 'edr_freight_app:warehouse_zones:view',
create: 'edr_freight_app:warehouse_zones:create',
update: 'edr_freight_app:warehouse_zones:update',
},
warehouseAllocationRules: {
view: 'edr_freight_app:warehouse_allocation_rules:view',
create: 'edr_freight_app:warehouse_allocation_rules:create',
update: 'edr_freight_app:warehouse_allocation_rules:update',
delete: 'edr_freight_app:warehouse_allocation_rules:delete',
},
warehouseFeeRules: {
view: 'edr_freight_app:warehouse_fee_rules:view',
create: 'edr_freight_app:warehouse_fee_rules:create',
update: 'edr_freight_app:warehouse_fee_rules:update',
delete: 'edr_freight_app:warehouse_fee_rules:delete',
},
warehouseInspectionReports: {
view: 'edr_freight_app:warehouse_inspection_reports:view',
create: 'edr_freight_app:warehouse_inspection_reports:create',
update: 'edr_freight_app:warehouse_inspection_reports:update',
},
warehouseInventory: {
view: 'edr_freight_app:warehouse_inventory:view',
receive: 'edr_freight_app:warehouse_inventory:receive',
move: 'edr_freight_app:warehouse_inventory:move',
load: 'edr_freight_app:warehouse_inventory:load',
unload: 'edr_freight_app:warehouse_inventory:unload',
dispatch: 'edr_freight_app:warehouse_inventory:dispatch',
gatePass: 'edr_freight_app:warehouse_inventory:gate_pass',
release: 'edr_freight_app:warehouse_inventory:release',
deliver: 'edr_freight_app:warehouse_inventory:deliver',
inspect: 'edr_freight_app:warehouse_inventory:inspect',
},
interchangeDocuments: {
view: 'edr_freight_app:interchange_documents:view',
generate: 'edr_freight_app:interchange_documents:generate',
acknowledge: 'edr_freight_app:interchange_documents:acknowledge',
dispute: 'edr_freight_app:interchange_documents:dispute',
cancel: 'edr_freight_app:interchange_documents:cancel',
},
warehouseFeeInvoices: {
view: 'edr_freight_app:warehouse_fee_invoices:view',
generate: 'edr_freight_app:warehouse_fee_invoices:generate',
cancel: 'edr_freight_app:warehouse_fee_invoices:cancel',
pay: 'edr_freight_app:warehouse_fee_invoices:pay',
},
config: {
contractValidity: {
view: 'edr_freight_app:config:contract_validity:view',
manage: 'edr_freight_app:config:contract_validity:manage',
},
},
settings: {
fileUpload: {
view: 'edr_freight_app:settings:file_upload:view',
manage: 'edr_freight_app:settings:file_upload:manage',
},
dropdown: {
view: 'edr_freight_app:settings:dropdown:view',
manage: 'edr_freight_app:settings:dropdown:manage',
},
},
staff: {
roles: {
view: 'edr_freight_app:staff:roles:view',
create: 'edr_freight_app:staff:roles:create',
update: 'edr_freight_app:staff:roles:update',
delete: 'edr_freight_app:staff:roles:delete',
},
permissions: {
view: 'edr_freight_app:staff:permissions:view',
assign: 'edr_freight_app:staff:permissions:assign',
},
// Seeded in edr-freight.seed.ts (EDR_FREIGHT_PERMISSIONS) — surfaced here for gating.
employeeRegistration: {
view: 'edr_freight_app:employee_registration:view',
create: 'edr_freight_app:employee_registration:create',
update: 'edr_freight_app:employee_registration:update',
activate: 'edr_freight_app:employee_registration:activate',
deactivate: 'edr_freight_app:employee_registration:deactivate',
},
roleAssignment: {
view: 'edr_freight_app:role_assignment:view',
assign: 'edr_freight_app:role_assignment:assign',
replace: 'edr_freight_app:role_assignment:replace',
},
hierarchyUnits: {
view: 'edr_freight_app:hierarchy_units:view',
create: 'edr_freight_app:hierarchy_units:create',
update: 'edr_freight_app:hierarchy_units:update',
delete: 'edr_freight_app:hierarchy_units:delete',
},
hierarchyPositions: {
view: 'edr_freight_app:hierarchy_positions:view',
create: 'edr_freight_app:hierarchy_positions:create',
update: 'edr_freight_app:hierarchy_positions:update',
delete: 'edr_freight_app:hierarchy_positions:delete',
changeParent: 'edr_freight_app:hierarchy_positions:change_parent',
},
hierarchyEmployeeAssignment: {
view: 'edr_freight_app:hierarchy_employee_assignment:view',
invite: 'edr_freight_app:hierarchy_employee_assignment:invite',
assign: 'edr_freight_app:hierarchy_employee_assignment:assign',
},
positionTypes: {
view: 'edr_freight_app:position_types:view',
create: 'edr_freight_app:position_types:create',
update: 'edr_freight_app:position_types:update',
delete: 'edr_freight_app:position_types:delete',
},
},
} as const;
const allRuleEngineViewKeys = () =>
@@ -326,12 +769,11 @@ export const POSITION_PERMISSION_PRESETS = {
]),
} as const;
/** Derive the module bucket from the resource segment of a permission key. */
const moduleOf = (key: string): string => key.split(':')[1] ?? 'other';
export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({
key: p.key,
label: p.name.en,
module: p.key.includes(':bookings:')
? 'bookings'
: p.key.includes(':contracts:')
? 'contracts'
: 'rule_engine',
module: moduleOf(p.key),
}));

View File

@@ -16,8 +16,8 @@ const COMPANY_TIN = 'PAIDMILE001';
const COMPANY_EMAIL = 'paid-mile-demo@edr.local';
const YARDS = [
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 },
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 },
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti' as const, displayOrder: 1 },
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia' as const, displayOrder: 2 },
];
const CONTAINER_TYPES = [