mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
automation of loading and unloading
This commit is contained in:
14
.dockerignore
Normal file
14
.dockerignore
Normal file
@@ -0,0 +1,14 @@
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/.turbo
|
||||
**/.git
|
||||
**/.github
|
||||
**/.vscode
|
||||
**/.idea
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
**/coverage
|
||||
**/*.tsbuildinfo
|
||||
**/*.log
|
||||
.DS_Store
|
||||
583
.github/scripts/scan.js
vendored
Normal file
583
.github/scripts/scan.js
vendored
Normal file
@@ -0,0 +1,583 @@
|
||||
/**
|
||||
* PolinRider / Famous Chollima Supply-Chain Malware Scanner
|
||||
*
|
||||
* Detects the specific injection pattern used in the PolinRider campaign
|
||||
* attributed to North Korean APT (Void Dokkaebi / Famous Chollima / UNC5342).
|
||||
*
|
||||
* IOCs sourced from:
|
||||
* - Direct analysis of the injected tailwind.config.js sample
|
||||
* - Socket Security report (May 2026) on roberts/leads compromise
|
||||
* - PolinRider technical analysis report (Trend Micro / safedep.io)
|
||||
*
|
||||
* Zero external dependencies — runs on any Node.js >= 14.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
|
||||
// ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
const CONFIG = {
|
||||
// Maximum legitimate size for JS config files.
|
||||
// Real tailwind/postcss/babel configs are rarely > 3 KB.
|
||||
// Injected files jump to 5–8 KB instantly.
|
||||
maxLegitConfigBytes: 3072,
|
||||
|
||||
// Minimum whitespace run on a single line that signals hidden payload.
|
||||
// The campaign uses ~280–510 spaces to push payload off-screen.
|
||||
minSuspiciousInlineSpaces: 100,
|
||||
|
||||
// Files that are high-value injection targets for this campaign.
|
||||
targetedFilenames: [
|
||||
"tailwind.config.js",
|
||||
"tailwind.config.ts",
|
||||
"tailwind.config.cjs",
|
||||
"tailwind.js",
|
||||
"postcss.config.js",
|
||||
"postcss.config.mjs",
|
||||
"postcss.config.cjs",
|
||||
"babel.config.js",
|
||||
"babel.config.cjs",
|
||||
"next.config.js",
|
||||
"next.config.mjs",
|
||||
"next.config.cjs",
|
||||
"astro.config.mjs",
|
||||
"astro.config.js",
|
||||
"vite.config.js",
|
||||
"vite.config.ts",
|
||||
"webpack.config.js",
|
||||
"webpack.mix.js",
|
||||
],
|
||||
|
||||
// Files intentionally containing malware indicators for scanner logic/tests.
|
||||
// These filenames are skipped before malware rules are evaluated.
|
||||
ignoredFilenames: ["scan.js"],
|
||||
|
||||
// Filesystem paths that indicate active persistence mechanisms
|
||||
persistenceArtifacts: [
|
||||
"temp_auto_push.bat",
|
||||
"temp_interactive_push.bat",
|
||||
"branch_structure.json",
|
||||
// Note: queue.bat and .plist are OS-level; checked separately
|
||||
],
|
||||
};
|
||||
|
||||
// ─── IOC Definitions ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Each rule has:
|
||||
* id – unique rule identifier for reporting
|
||||
* severity – CRITICAL | HIGH | MEDIUM
|
||||
* description – human-readable explanation
|
||||
* test(content, filePath, lines) – returns array of match details or []
|
||||
*/
|
||||
const RULES = [
|
||||
// ── Tier 1: Definitive campaign signatures ─────────────────────────────────
|
||||
|
||||
{
|
||||
id: "POLINRIDER-001",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
"PolinRider string-shuffler variable _$_1e42 — present in every known sample of this campaign",
|
||||
test(content) {
|
||||
const matches = [];
|
||||
const re = /_\$_1e42/g;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
matches.push(`offset ${m.index}`);
|
||||
}
|
||||
return matches;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-002",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
"PolinRider campaign marker global['!'] assignment — used to route C2 traffic",
|
||||
test(content) {
|
||||
const matches = [];
|
||||
const re = /global\s*\[\s*['"]!\s*['"]\s*\]\s*=/g;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const snippet = content
|
||||
.slice(m.index, m.index + 40)
|
||||
.replace(/\n/g, "\\n");
|
||||
matches.push(`"${snippet}"`);
|
||||
}
|
||||
return matches;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-003",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
'PolinRider shuffler seed string "rmcej%otb%" — embedded in the string-decryption bootstrap of the specific variant targeting this repo',
|
||||
test(content) {
|
||||
return content.includes("rmcej%otb%") ? ["seed string found"] : [];
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-004",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
"Known C2 IP addresses associated with PolinRider infrastructure",
|
||||
test(content) {
|
||||
const knownC2 = ["198.105.127.210", "166.88.54.158", "23.27.202.27"];
|
||||
return knownC2.filter((ip) => content.includes(ip));
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-005",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
"Known TRON blockchain wallet addresses used as dead-drop C2 resolvers",
|
||||
test(content) {
|
||||
const wallets = [
|
||||
"TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP",
|
||||
"TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG",
|
||||
];
|
||||
return wallets.filter((w) => content.includes(w));
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-006",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
"Known Aptos blockchain addresses used as fallback dead-drop resolvers",
|
||||
test(content) {
|
||||
const addrs = [
|
||||
"0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e",
|
||||
"0x3f0e5781d0855fb460661ac63257376db1941b2bb522499e4757ecb3ebd5dce3",
|
||||
];
|
||||
return addrs.filter((a) => content.includes(a));
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-007",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
"Known XOR decryption keys used to decrypt the second-stage payload from BSC transactions",
|
||||
test(content) {
|
||||
const keys = ["2[gWfGj;<:-93Z^C", "m6:tTh^D)cBz?NM]"];
|
||||
return keys.filter((k) => content.includes(k));
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-008",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
"Known SHA-256 hash of compromised tailwind.js file (Socket Security, 2026-05-31)",
|
||||
test(content) {
|
||||
const knownHashes = new Set([
|
||||
"96afdba882046385242cbed46871e41147c8055c5d9eff7460847b2c01a77dc3",
|
||||
"522b28a2f78771715497ba53729d4ab9a50e982322c391379f3bddf7c8cb363f",
|
||||
]);
|
||||
const hash = crypto.createHash("sha256").update(content).digest("hex");
|
||||
return knownHashes.has(hash) ? [`SHA-256: ${hash}`] : [];
|
||||
},
|
||||
},
|
||||
|
||||
// ── Tier 2: Behavioral / structural indicators ─────────────────────────────
|
||||
|
||||
{
|
||||
id: "POLINRIDER-009",
|
||||
severity: "HIGH",
|
||||
description:
|
||||
"Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, and BSC as dead-drop C2 resolvers",
|
||||
test(content) {
|
||||
const endpoints = [
|
||||
"trongrid.io",
|
||||
"aptoslabs.com",
|
||||
"bsc-dataseed.binance.org",
|
||||
"bsc-rpc.publicnode.com",
|
||||
"eth_getTransactionByHash",
|
||||
];
|
||||
return endpoints.filter((e) => content.includes(e));
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-010",
|
||||
severity: "HIGH",
|
||||
description:
|
||||
"Hidden process spawn with windowsHide:true — used by InvisibleFerret / BeaverTail stager to launch detached Node.js child processes invisibly",
|
||||
test(content) {
|
||||
return /windowsHide\s*:\s*true/.test(content)
|
||||
? ["windowsHide:true found"]
|
||||
: [];
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-011",
|
||||
severity: "HIGH",
|
||||
description:
|
||||
"Duplicate createRequire injection at file top — campaign restores require() for ES module environments by prepending two identical import statements",
|
||||
test(content) {
|
||||
const matches =
|
||||
content.match(/import\s*\{\s*createRequire\s*\}\s*from/g) || [];
|
||||
return matches.length >= 2
|
||||
? [`Found ${matches.length} duplicate createRequire imports`]
|
||||
: [];
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-012",
|
||||
severity: "HIGH",
|
||||
description:
|
||||
"Payload hidden after large horizontal whitespace (>100 spaces on one line) — evasion technique to hide code off-screen in editors and GitHub diff views",
|
||||
test(content, _filePath, lines) {
|
||||
const hits = [];
|
||||
lines.forEach((line, i) => {
|
||||
const spaceRun = line.match(/\s{100,}/);
|
||||
if (spaceRun) {
|
||||
hits.push(`line ${i + 1}: ${spaceRun[0].length} consecutive spaces`);
|
||||
}
|
||||
});
|
||||
return hits;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-013",
|
||||
severity: "HIGH",
|
||||
description:
|
||||
"Config file size anomaly — legitimate tailwind/postcss/babel configs are < 3 KB; injected files jump to 5–8 KB",
|
||||
test(content, filePath) {
|
||||
const bytes = Buffer.byteLength(content, "utf8");
|
||||
const base = path.basename(filePath).toLowerCase();
|
||||
const isTargeted = CONFIG.targetedFilenames.some(
|
||||
(f) => f.toLowerCase() === base,
|
||||
);
|
||||
if (isTargeted && bytes > CONFIG.maxLegitConfigBytes) {
|
||||
return [
|
||||
`${bytes} bytes (threshold: ${CONFIG.maxLegitConfigBytes} bytes)`,
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-014",
|
||||
severity: "HIGH",
|
||||
description:
|
||||
"Persistence artifact detected — files used by temp_auto_push.bat to rewrite git history and propagate infection to all branches",
|
||||
test(_content, filePath) {
|
||||
const base = path.basename(filePath);
|
||||
return CONFIG.persistenceArtifacts.includes(base) ? [base] : [];
|
||||
},
|
||||
},
|
||||
|
||||
// ── Tier 3: Supporting behavioral indicators ───────────────────────────────
|
||||
|
||||
{
|
||||
id: "POLINRIDER-015",
|
||||
severity: "MEDIUM",
|
||||
description:
|
||||
"Campaign marker pattern — numeric string assigned to global['!'], used to select C2 tier (alpha/beta/fallback)",
|
||||
test(content) {
|
||||
const matches = [];
|
||||
// Matches patterns like '8-3317', '9-0264-2', '8-3946-1', 'A4-1928'
|
||||
const re =
|
||||
/global\s*\[\s*['"]!\s*['"]\s*\]\s*=\s*['"]([A-Z]?\d[\d-]+)['"]/g;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
matches.push(`marker value: "${m[1]}"`);
|
||||
}
|
||||
return matches;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-016",
|
||||
severity: "MEDIUM",
|
||||
description:
|
||||
"sfL obfuscation function — secondary string-shuffler present in multi-stage loader variant",
|
||||
test(content) {
|
||||
// sfL appears as a named function used to decode the larger payload blob
|
||||
const occurrences = (content.match(/\bsfL\b/g) || []).length;
|
||||
return occurrences >= 3 ? [`sfL referenced ${occurrences} times`] : [];
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-017",
|
||||
severity: "MEDIUM",
|
||||
description:
|
||||
"global require/module injection — bootloader dynamically restores Node.js internals to bypass ES module restrictions",
|
||||
test(content) {
|
||||
const hits = [];
|
||||
if (/global\s*\[.*\]\s*=\s*require/.test(content))
|
||||
hits.push("global[x] = require");
|
||||
if (/global\s*\[.*module.*\]\s*=\s*module/.test(content))
|
||||
hits.push("global[x] = module");
|
||||
return hits;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Scanner Engine ────────────────────────────────────────────────────────────
|
||||
|
||||
function scanFile(filePath) {
|
||||
if (shouldIgnoreFile(filePath)) {
|
||||
return { filePath, findings: [], skipped: true };
|
||||
}
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(filePath, "utf8");
|
||||
} catch (err) {
|
||||
return { filePath, error: err.message, findings: [] };
|
||||
}
|
||||
|
||||
const lines = content.split("\n");
|
||||
const findings = [];
|
||||
|
||||
for (const rule of RULES) {
|
||||
let matches;
|
||||
try {
|
||||
matches = rule.test(content, filePath, lines);
|
||||
} catch (err) {
|
||||
matches = [`[rule error: ${err.message}]`];
|
||||
}
|
||||
|
||||
if (matches && matches.length > 0) {
|
||||
findings.push({
|
||||
id: rule.id,
|
||||
severity: rule.severity,
|
||||
description: rule.description,
|
||||
matches,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { filePath, findings };
|
||||
}
|
||||
|
||||
function shouldIgnoreFile(filePath) {
|
||||
const base = path.basename(filePath).toLowerCase();
|
||||
return CONFIG.ignoredFilenames.some((f) => f.toLowerCase() === base);
|
||||
}
|
||||
|
||||
function walkDir(dir, results = []) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
||||
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkDir(full, results);
|
||||
} else if (entry.isFile() && !shouldIgnoreFile(full)) {
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
const base = entry.name.toLowerCase();
|
||||
|
||||
// Scan all JS/TS config files + any file matching a targeted name
|
||||
const isTargetedName = CONFIG.targetedFilenames.some(
|
||||
(f) => f.toLowerCase() === base,
|
||||
);
|
||||
const isPersistenceArtifact = CONFIG.persistenceArtifacts.some(
|
||||
(f) => f.toLowerCase() === base,
|
||||
);
|
||||
const isJsLike = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes(
|
||||
ext,
|
||||
);
|
||||
|
||||
if (isTargetedName || isPersistenceArtifact || isJsLike) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Reporting ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const SEVERITY_RANK = { CRITICAL: 3, HIGH: 2, MEDIUM: 1 };
|
||||
const ANSI = {
|
||||
reset: "\x1b[0m",
|
||||
bold: "\x1b[1m",
|
||||
red: "\x1b[31m",
|
||||
yellow: "\x1b[33m",
|
||||
cyan: "\x1b[36m",
|
||||
green: "\x1b[32m",
|
||||
dim: "\x1b[2m",
|
||||
};
|
||||
|
||||
function colorSeverity(sev) {
|
||||
if (sev === "CRITICAL") return `${ANSI.bold}${ANSI.red}${sev}${ANSI.reset}`;
|
||||
if (sev === "HIGH") return `${ANSI.yellow}${sev}${ANSI.reset}`;
|
||||
return `${ANSI.cyan}${sev}${ANSI.reset}`;
|
||||
}
|
||||
|
||||
function printReport(allResults, { json = false, outputFile = null } = {}) {
|
||||
const infected = allResults.filter(
|
||||
(r) => r.findings && r.findings.length > 0,
|
||||
);
|
||||
const errors = allResults.filter((r) => r.error);
|
||||
|
||||
if (json) {
|
||||
const report = {
|
||||
scannedAt: new Date().toISOString(),
|
||||
totalFilesScanned: allResults.length,
|
||||
infectedFiles: infected.length,
|
||||
results: infected,
|
||||
errors,
|
||||
};
|
||||
const out = JSON.stringify(report, null, 2);
|
||||
if (outputFile) {
|
||||
fs.writeFileSync(outputFile, out);
|
||||
console.log(`JSON report written to: ${outputFile}`);
|
||||
} else {
|
||||
console.log(out);
|
||||
}
|
||||
return infected.length > 0;
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
console.log(
|
||||
`\n${ANSI.bold}╔══════════════════════════════════════════════════════════╗`,
|
||||
);
|
||||
console.log(`║ PolinRider / Famous Chollima Malware Scanner ║`);
|
||||
console.log(
|
||||
`╚══════════════════════════════════════════════════════════╝${ANSI.reset}`,
|
||||
);
|
||||
console.log(
|
||||
`${ANSI.dim}Scanned ${allResults.length} files · ${new Date().toISOString()}${ANSI.reset}\n`,
|
||||
);
|
||||
|
||||
if (infected.length === 0) {
|
||||
console.log(
|
||||
`${ANSI.green}${ANSI.bold}✓ No infections detected.${ANSI.reset}\n`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`${ANSI.red}${ANSI.bold}✗ INFECTION DETECTED in ${infected.length} file(s)${ANSI.reset}\n`,
|
||||
);
|
||||
|
||||
for (const result of infected) {
|
||||
// Sort findings by severity descending
|
||||
const sorted = [...result.findings].sort(
|
||||
(a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity],
|
||||
);
|
||||
const topSev = sorted[0].severity;
|
||||
|
||||
console.log(
|
||||
` ${colorSeverity(topSev)} ${ANSI.bold}${result.filePath}${ANSI.reset}`,
|
||||
);
|
||||
for (const f of sorted) {
|
||||
console.log(
|
||||
` ${ANSI.dim}[${f.id}]${ANSI.reset} ${colorSeverity(f.severity)} — ${f.description}`,
|
||||
);
|
||||
for (const m of f.matches) {
|
||||
console.log(` → ${m}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log(`${ANSI.bold}Remediation steps:${ANSI.reset}`);
|
||||
console.log(
|
||||
` 1. Immediately isolate the affected machine from the network.`,
|
||||
);
|
||||
console.log(
|
||||
` 2. Do NOT run npm install, npm build, or any script on this repo.`,
|
||||
);
|
||||
console.log(
|
||||
` 3. Check for running node.exe / node processes with obfuscated args.`,
|
||||
);
|
||||
console.log(
|
||||
` 4. Remove all code after the legitimate config closing block.`,
|
||||
);
|
||||
console.log(
|
||||
` 5. Remove duplicate 'import { createRequire }' lines at file top.`,
|
||||
);
|
||||
console.log(
|
||||
` 6. Recover the git repository from a clean local clone (see docs).`,
|
||||
);
|
||||
console.log(
|
||||
` 7. Revoke ALL secrets, tokens, and credentials in .env and CI.`,
|
||||
);
|
||||
console.log(
|
||||
` 8. See full remediation guide in the attached incident report.\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.log(`${ANSI.yellow}Scan errors (${errors.length}):${ANSI.reset}`);
|
||||
for (const e of errors) {
|
||||
console.log(` ${e.filePath}: ${e.error}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
return infected.length > 0;
|
||||
}
|
||||
|
||||
// ─── CLI Entry Point ───────────────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const jsonFlag = args.includes("--json");
|
||||
const outputFileIdx = args.indexOf("--output");
|
||||
const outputFile = outputFileIdx !== -1 ? args[outputFileIdx + 1] : null;
|
||||
|
||||
// Positional args after flags are scan targets
|
||||
const targets = args.filter(
|
||||
(a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--output",
|
||||
);
|
||||
|
||||
if (targets.length === 0) {
|
||||
console.error(
|
||||
"Usage: scan.js [--json] [--output report.json] <path> [path...]",
|
||||
);
|
||||
console.error(
|
||||
" path can be a file or directory (directories are walked recursively)",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const filesToScan = [];
|
||||
for (const target of targets) {
|
||||
if (!fs.existsSync(target)) {
|
||||
console.error(`Path not found: ${target}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const stat = fs.statSync(target);
|
||||
if (stat.isDirectory()) {
|
||||
const found = walkDir(target);
|
||||
filesToScan.push(...found);
|
||||
} else {
|
||||
filesToScan.push(target);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
const unique = [...new Set(filesToScan)];
|
||||
const allResults = unique.map(scanFile);
|
||||
|
||||
const infected = printReport(allResults, { json: jsonFlag, outputFile });
|
||||
|
||||
// Exit code 1 if any infection found — used by CI to block deployments
|
||||
process.exit(infected ? 1 : 0);
|
||||
}
|
||||
|
||||
main();
|
||||
157
.github/workflows/deploy.yml
vendored
Normal file
157
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,157 @@
|
||||
name: Deploy Stacks
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- staging
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
name: Detect changed services
|
||||
runs-on: self-hosted
|
||||
outputs:
|
||||
matrix: ${{ steps.filter.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Determine changed services
|
||||
id: filter
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
ALL_SERVICES=(
|
||||
"freight-api"
|
||||
"freight-portal"
|
||||
"freight-backoffice"
|
||||
"passenger-api"
|
||||
"passenger-portal"
|
||||
"passenger-backoffice"
|
||||
"payment-api"
|
||||
)
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .)
|
||||
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CHANGED=$(git diff --name-only HEAD~1 HEAD)
|
||||
echo "=== Changed files ==="
|
||||
echo "$CHANGED"
|
||||
echo "====================="
|
||||
|
||||
SERVICES=()
|
||||
|
||||
NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$"
|
||||
|
||||
GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$"
|
||||
|
||||
DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true)
|
||||
if [ -z "$DEPLOYABLE" ]; then
|
||||
echo "Only non-deployable files changed. Skipping deploy."
|
||||
echo "matrix=[]" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if echo "$CHANGED" | grep -qE "$GLOBAL_PATTERN"; then
|
||||
echo "Global file(s) changed — deploying all services."
|
||||
JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .)
|
||||
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-freight-web-portal/" && SERVICES+=("freight-portal")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-freight-web-backoffice/" && SERVICES+=("freight-backoffice")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-payment-api/" && SERVICES+=("payment-api")
|
||||
|
||||
SERVICES=($(printf '%s\n' "${SERVICES[@]}" | sort -u))
|
||||
|
||||
if [ ${#SERVICES[@]} -eq 0 ]; then
|
||||
echo "No deployable service changes detected."
|
||||
echo "matrix=[]" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Services to deploy: ${SERVICES[*]}"
|
||||
JSON=$(printf '%s\n' "${SERVICES[@]}" | jq -R . | jq -sc .)
|
||||
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
deploy:
|
||||
name: Deploy ${{ matrix.service }}
|
||||
needs: detect-changes
|
||||
if: ${{ needs.detect-changes.outputs.matrix != '[]' }}
|
||||
runs-on: self-hosted
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
service: ${{ fromJson(needs.detect-changes.outputs.matrix) }}
|
||||
env:
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
DEPLOY_USER: tria
|
||||
DOCKER_BUILDKIT: "1"
|
||||
COMPOSE_DOCKER_CLI_BUILD: "1"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve project and build env file
|
||||
run: |
|
||||
case "${{ matrix.service }}" in
|
||||
freight-api|freight-portal|freight-backoffice)
|
||||
echo "PROJECT=edr-freight" >> "$GITHUB_ENV"
|
||||
echo "BUILD_ENV_FILE=freight-web.build.env" >> "$GITHUB_ENV"
|
||||
;;
|
||||
passenger-api|passenger-portal|passenger-backoffice)
|
||||
echo "PROJECT=edr-passenger" >> "$GITHUB_ENV"
|
||||
echo "BUILD_ENV_FILE=passenger-web.build.env" >> "$GITHUB_ENV"
|
||||
;;
|
||||
payment-api)
|
||||
echo "PROJECT=edr-payment" >> "$GITHUB_ENV"
|
||||
echo "BUILD_ENV_FILE=payment-web.build.env" >> "$GITHUB_ENV"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown service: ${{ matrix.service }}" && exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Sync environment from server
|
||||
run: |
|
||||
chmod +x scripts/deploy/*.sh
|
||||
./scripts/deploy/sync-env-from-server.sh "${{ matrix.service }}"
|
||||
|
||||
- name: Set compose project name
|
||||
run: |
|
||||
set -euo pipefail
|
||||
branch_slug=$(echo "${BRANCH}" | tr "[:upper:]" "[:lower:]" | sed -E "s/[^a-z0-9]+/-/g; s/^-+//; s/-+$//")
|
||||
echo "COMPOSE_PROJECT_NAME=${PROJECT}-${branch_slug}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Configure npm auth for Docker builds
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: ./scripts/deploy/create-npmrc.sh
|
||||
|
||||
- name: Build ${{ matrix.service }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker compose --project-name "${COMPOSE_PROJECT_NAME}" build "${{ matrix.service }}"
|
||||
|
||||
- name: Deploy ${{ matrix.service }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate
|
||||
|
||||
- name: Remove npm credentials from workspace
|
||||
if: always()
|
||||
run: rm -f .npmrc .npmrc_temp
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,6 +23,7 @@ coverage/
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
.npmrc
|
||||
|
||||
# emacs cache files
|
||||
*~
|
||||
|
||||
6
.gitmodules
vendored
Normal file
6
.gitmodules
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
[submodule "user-management"]
|
||||
path = user-management
|
||||
url = git@github.com:Tria-plc/iamui.git
|
||||
[submodule "apps/edr-freight-web/backoffice/user-management"]
|
||||
path = apps/edr-freight-web/backoffice/user-management
|
||||
url = git@github.com:Tria-plc/iamui.git
|
||||
@@ -12,6 +12,7 @@ Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Fre
|
||||
| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 |
|
||||
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 |
|
||||
| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 |
|
||||
| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 |
|
||||
| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 |
|
||||
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 |
|
||||
|
||||
@@ -73,6 +74,7 @@ The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are
|
||||
- `edr-freight-web/portal`: 5173
|
||||
- `edr-freight-web/backoffice`: 5183
|
||||
- `edr-passenger-api`: 3002
|
||||
- `edr-payment-api`: 3003
|
||||
- `edr-passenger-web/portal`: 5174
|
||||
- `edr-passenger-web/backoffice`: 5184
|
||||
|
||||
@@ -80,6 +82,7 @@ The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are
|
||||
|
||||
- `postgres-freight` (port 5433): database `edr_freight` — freight API only.
|
||||
- `postgres-passenger` (port 5434): database `edr_passenger` — passenger API only.
|
||||
- `edr_payment` schema — lives in the same Postgres database as the domain system (whatever the passenger `DATABASE_URL` points at) but is owned exclusively by `apps/edr-payment-api`. Dedicated DB user, no cross-schema FKs, domain apps have no grants on it (see `docs/payment-service/`).
|
||||
- Each app owns its own DB. No cross-database joins; cross-domain data flows through API calls or message queues.
|
||||
|
||||
## Adding a new module to a NestJS app
|
||||
|
||||
233
DEPLOYMENT.md
Normal file
233
DEPLOYMENT.md
Normal file
@@ -0,0 +1,233 @@
|
||||
# Deployment Runbook
|
||||
|
||||
This document explains how deployments work for the EDR platform using Docker, GitHub Actions, and self-hosted runners.
|
||||
|
||||
## Overview
|
||||
|
||||
- Monorepo contains 6 deployable services:
|
||||
- `freight-api`
|
||||
- `freight-portal`
|
||||
- `freight-backoffice`
|
||||
- `passenger-api`
|
||||
- `passenger-portal`
|
||||
- `passenger-backoffice`
|
||||
- Deployments run through one workflow: `.github/workflows/deploy.yml`
|
||||
- Each service is built/deployed independently in parallel (matrix jobs).
|
||||
- Docker Compose project names are branch-aware to avoid environment collisions on the same host.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker Engine with Compose plugin on the self-hosted runner.
|
||||
- GitHub self-hosted runner registered for this repository.
|
||||
- Repository secret configured:
|
||||
- `NPM_TOKEN` (for private `@tria-plc/*` package install during Docker build)
|
||||
- Server-side env files created for each branch/environment.
|
||||
|
||||
## Server Environment Files
|
||||
|
||||
`sync-env-from-server.sh` reads env files from:
|
||||
|
||||
`/home/<DEPLOY_USER>/environment/edr/<branch-slug>/<project>/`
|
||||
|
||||
Where:
|
||||
|
||||
- `<DEPLOY_USER>` defaults to `tria` (overridable by `DEPLOY_USER`)
|
||||
- `<branch-slug>` is derived from Git branch (lowercase, non-alphanumeric replaced with `-`)
|
||||
- `<project>` is `edr-freight` or `edr-passenger`
|
||||
|
||||
### Required files per project
|
||||
|
||||
For `edr-freight`:
|
||||
|
||||
- `freight-api.env`
|
||||
- `freight-portal.env`
|
||||
- `freight-backoffice.env`
|
||||
- optional: `freight-web.build.env`
|
||||
|
||||
For `edr-passenger`:
|
||||
|
||||
- `passenger-api.env`
|
||||
- `passenger-portal.env`
|
||||
- `passenger-backoffice.env`
|
||||
- optional: `passenger-web.build.env`
|
||||
|
||||
### Required env key
|
||||
|
||||
Each service env file must contain:
|
||||
|
||||
- `PORT=<number>`
|
||||
|
||||
The sync script validates this and fails if missing.
|
||||
|
||||
### Build env files (optional)
|
||||
|
||||
Used for additional build-time variables (example: Vite API URL for freight web), with `export` syntax:
|
||||
|
||||
```bash
|
||||
export FREIGHT_VITE_API_URL=https://freight-api.example.com/api
|
||||
```
|
||||
|
||||
These are injected into `GITHUB_ENV` during workflow execution.
|
||||
|
||||
> **Passenger web:** `NEXT_PUBLIC_API_URL` does **not** need a separate build env file. Place it directly in the service runtime env file (`passenger-portal.env` / `passenger-backoffice.env`) and the sync script will forward it to the build automatically.
|
||||
|
||||
## Docker Compose Port Mapping
|
||||
|
||||
`docker-compose.yaml` uses per-service env variables for host/container port mappings:
|
||||
|
||||
- `FREIGHT_API_PORT`
|
||||
- `PASSENGER_API_PORT`
|
||||
- `FREIGHT_PORTAL_PORT`
|
||||
- `FREIGHT_BACKOFFICE_PORT`
|
||||
- `PASSENGER_PORTAL_PORT`
|
||||
- `PASSENGER_BACKOFFICE_PORT`
|
||||
|
||||
`scripts/deploy/sync-env-from-server.sh` extracts `PORT` from each synced `.env` and exports the corresponding `*_PORT` variable to `GITHUB_ENV`.
|
||||
|
||||
## Passenger Web Docker Configuration
|
||||
|
||||
The passenger web apps (portal and backoffice) are deployed as **Next.js applications** using a dedicated Dockerfile:
|
||||
|
||||
- Dockerfile: `infrastructure/docker/Dockerfile.passenger-web`
|
||||
- Apps: `apps/edr-passenger-web/portal` and `apps/edr-passenger-web/backoffice`
|
||||
|
||||
### Key differences from freight-web
|
||||
|
||||
| Aspect | Freight Web | Passenger Web |
|
||||
| --- | --- | --- |
|
||||
| Framework | Vite (SPA) | Next.js (SSR/SSG) |
|
||||
| Deployment | Static export + nginx | Node.js server |
|
||||
| Dockerfile | `Dockerfile.web` | `Dockerfile.passenger-web` |
|
||||
| Final port (container) | 80 (nginx) | driven by `PORT` in service `.env` |
|
||||
| Build arg | `TURBO_FILTER` | `APP_PACKAGE` + `APP_PATH` + `NEXT_PUBLIC_API_URL` |
|
||||
|
||||
### Build arguments
|
||||
|
||||
The Dockerfile accepts the following build args:
|
||||
|
||||
- `APP_PACKAGE`: Turbo package filter (e.g., `@edr/passenger-portal`)
|
||||
- `APP_PATH`: App directory path (e.g., `apps/edr-passenger-web/portal`)
|
||||
- `NEXT_PUBLIC_API_URL`: API URL visible to browser — sourced from `NEXT_PUBLIC_API_URL` in the service `.env` file
|
||||
|
||||
### Port mapping
|
||||
|
||||
Both host and container ports are driven by `PORT` in the service env file. The sync script reads `PORT`, exports `PASSENGER_PORTAL_PORT` / `PASSENGER_BACKOFFICE_PORT` to `GITHUB_ENV`, and `docker-compose.yaml` uses those variables for both sides of the mapping:
|
||||
|
||||
```
|
||||
${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174}
|
||||
```
|
||||
|
||||
This ensures `docker ps` shows `0.0.0.0:<port>-><port>/tcp` with matching ports.
|
||||
|
||||
### Runtime
|
||||
|
||||
The final image runs:
|
||||
|
||||
```bash
|
||||
npx next start
|
||||
```
|
||||
|
||||
Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose) to determine which port to listen on.
|
||||
|
||||
## GitHub Actions Deployment Flow
|
||||
|
||||
Workflow file: `.github/workflows/deploy.yml`
|
||||
|
||||
### 1) `prepare` job
|
||||
|
||||
- Checks out repository once.
|
||||
- Creates workspace artifact (`workspace.tgz`) and uploads it.
|
||||
|
||||
### 2) `deploy` matrix job (parallel)
|
||||
|
||||
For each service:
|
||||
|
||||
- Downloads and extracts workspace artifact.
|
||||
- Syncs that service env file from server path.
|
||||
- Computes branch slug and sets:
|
||||
- `COMPOSE_PROJECT_NAME=<project>-<branch-slug>`
|
||||
- Creates `.npmrc`/`.npmrc_temp` from `NPM_TOKEN`.
|
||||
- Runs:
|
||||
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" build <service>`
|
||||
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" up -d <service>`
|
||||
- Cleans `.npmrc`/`.npmrc_temp`.
|
||||
|
||||
## Branch/Environment Isolation
|
||||
|
||||
Compose project name is generated as:
|
||||
|
||||
`<project>-<branch-slug>`
|
||||
|
||||
Examples:
|
||||
|
||||
- `edr-freight-main`
|
||||
- `edr-freight-staging`
|
||||
- `edr-passenger-dev`
|
||||
|
||||
This prevents container/network/volume name collisions between branches.
|
||||
|
||||
## Local Manual Deployment (Optional)
|
||||
|
||||
From repo root:
|
||||
|
||||
```bash
|
||||
DOCKER_BUILDKIT=1 docker compose build <service>
|
||||
docker compose up -d <service>
|
||||
```
|
||||
|
||||
If private packages are required locally, create `.npmrc`:
|
||||
|
||||
```bash
|
||||
cat <<EOF > .npmrc
|
||||
@tria-plc:registry=https://npm.pkg.github.com
|
||||
//npm.pkg.github.com/:_authToken=<YOUR_TOKEN>
|
||||
always-auth=true
|
||||
EOF
|
||||
```
|
||||
|
||||
## Passenger API Startup Behavior
|
||||
|
||||
Passenger container entrypoint runs on startup:
|
||||
|
||||
1. `npm run prisma:generate`
|
||||
2. `npm run prisma:migrate` (deploy mode)
|
||||
3. `npm run prisma:seed`
|
||||
4. starts API process
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Missing env file
|
||||
|
||||
Error:
|
||||
|
||||
- `Missing env file: ...`
|
||||
|
||||
Fix:
|
||||
|
||||
- Create the required file in the server env directory for that project/branch slug.
|
||||
|
||||
### Missing PORT in env file
|
||||
|
||||
Error:
|
||||
|
||||
- `Missing required PORT in env file: ...`
|
||||
|
||||
Fix:
|
||||
|
||||
- Add `PORT=<number>` to that service env file.
|
||||
|
||||
### Private package install fails
|
||||
|
||||
Check:
|
||||
|
||||
- `NPM_TOKEN` exists in repo secrets.
|
||||
- Workflow created `.npmrc` successfully.
|
||||
|
||||
### Prisma seed/migrate failures (passenger)
|
||||
|
||||
Check:
|
||||
|
||||
- `DATABASE_URL` in `passenger-api.env`
|
||||
- DB reachability from runner host/container network
|
||||
- migration history consistency
|
||||
|
||||
763
README.md
763
README.md
@@ -1,3 +1,98 @@
|
||||
# EDR Platform - Ethio-Djibouti Railway Passenger API
|
||||
|
||||
Enterprise-grade NestJS REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with TypeScript, PostgreSQL, and Prisma ORM.
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
### 🆕 NEW: Age-Based Pricing, Verifayda 2.0 & Multi-Currency
|
||||
|
||||
#### Age-Based Pricing
|
||||
- **ADULT** (≥5 years): Pay 100% of base fare
|
||||
- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100%
|
||||
- Automatic age calculation from date of birth
|
||||
- Example: 2 adults + 3 children = 4× base fare (first child free)
|
||||
|
||||
#### Verifayda 2.0 Integration
|
||||
- Real-time Ethiopian national ID verification
|
||||
- Retrieves passenger data from government database
|
||||
- National IDs NOT stored (policy compliant)
|
||||
- Non-Ethiopians use passport (no verification required)
|
||||
- Booking fails if verification unsuccessful
|
||||
|
||||
#### Multi-Currency Support
|
||||
- **Transaction Currency**: ETB (Ethiopian Birr)
|
||||
- **Display Currencies**: ETB, DJF (Djiboutian Franc), USD (US Dollar)
|
||||
- Real-time exchange rate conversion
|
||||
- Prices shown in user's preferred currency
|
||||
- Exchange rates: ETB→DJF=3.25, ETB→USD=0.018
|
||||
|
||||
### Core Modules
|
||||
- **Authentication & Authorization** - Dual authentication system:
|
||||
- **Passenger Auth**: JWT-based auth with OTP verification, password reset, account lockout
|
||||
- **Corporate IAM**: Integration with @tria-plc corporate identity system for back-office operations (agents, supervisors, admins)
|
||||
- Role-based access control (RBAC) with granular permissions
|
||||
- **Age-Based Pricing** - Smart passenger categorization:
|
||||
- **ADULT** (≥5 years): Full fare
|
||||
- **CHILD** (<5 years): First child free, subsequent children full fare
|
||||
- Automatic age calculation from date of birth
|
||||
- **Verifayda 2.0 Integration** - Ethiopian national ID verification:
|
||||
- Real-time verification via government API
|
||||
- Retrieves passenger data (name, DOB, nationality)
|
||||
- National IDs NOT stored (policy compliant)
|
||||
- Non-Ethiopians use passport (no verification)
|
||||
- **Multi-Currency Support** - Display prices in multiple currencies:
|
||||
- **ETB** (Ethiopian Birr) - Transaction currency
|
||||
- **DJF** (Djiboutian Franc) - Display option
|
||||
- **USD** (US Dollar) - Display option
|
||||
- Real-time exchange rate conversion
|
||||
- **Booking Management** - Complete booking lifecycle:
|
||||
- **Guest Booking**: Book without login, optional account creation
|
||||
- **Saved Passengers**: Store passenger details for quick rebooking
|
||||
- Modification, cancellation, refunds, and fare breakdown
|
||||
- Multi-segment journey support
|
||||
- **Payment Integration** - Multi-provider support (Telebirr, CBE Birr, eBirr, Card, Wallet) with webhook handling
|
||||
- **Seat Management** - Real-time seat inventory:
|
||||
- Seat holds with 5-minute expiry
|
||||
- Seat releases and blocking with coach/class management
|
||||
- Segment-based seat availability (partial journey bookings)
|
||||
- Auto-assign seats with contiguous algorithm
|
||||
- CSV import/export for seat configurations
|
||||
- **Ticketing** - QR code and barcode generation, PDF tickets, gate validation with audit logs
|
||||
- **Agent Operations** - Counter booking, shift management, commission tracking, and reconciliation
|
||||
- **Passenger Services** - Profile management, traveler profiles, saved routes, and preferences
|
||||
- **Loyalty Program** - Points accumulation, tier management (Bronze/Silver/Gold/Platinum), and rewards
|
||||
- **Wallet System** - Balance management, top-up, transaction ledger
|
||||
- **Live Tracking** - Real-time trip status, location updates, delay notifications, crowd signals
|
||||
- **Notifications** - Multi-channel (Email, SMS, Push) with templating engine
|
||||
- **Support System** - FAQ management, live chat conversations
|
||||
- **Reports & Analytics** - Revenue reports, occupancy analytics, agent sales tracking
|
||||
- **Route Management** - Route configuration, stops, fare rules, baggage allowance
|
||||
|
||||
### Technical Features
|
||||
- **Security** - Password hashing (bcrypt), JWT tokens, rate limiting, audit logging
|
||||
- **Validation** - Request validation with class-validator, DTO transformation
|
||||
- **Documentation** - Auto-generated Swagger/OpenAPI docs at `/api-docs`
|
||||
- **Error Handling** - Global exception filters with standardized error responses
|
||||
- **Database** - PostgreSQL with Prisma ORM, migrations, and comprehensive seeding
|
||||
- **Scheduling** - Cron jobs for automated tasks (seat release, report generation)
|
||||
- **Event System** - Event-driven architecture with @nestjs/event-emitter
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
- **Node.js** >= 20.x
|
||||
- **pnpm** >= 9.x (`npm install -g pnpm`)
|
||||
- **PostgreSQL** >= 15.x
|
||||
- **Git**
|
||||
|
||||
## 🛠️ Installation & Setup
|
||||
|
||||
### 1. Clone Repository
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd edr-platform
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
# EDR Platform
|
||||
|
||||
Monorepo for the **Ethio-Djibouti Railway** digital platform. Hosts two product lines — **Freight Management** and **Passenger Management** — each with a NestJS API plus React portal and back-office web apps, sharing TypeScript types, NestJS utilities, and a React component library.
|
||||
@@ -185,6 +280,674 @@ Authentication is provided by an external `@edr/iamui-common` / `@tria-plc/iamap
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 3. Environment Configuration
|
||||
```bash
|
||||
# Copy environment template
|
||||
cp apps/edr-passenger-api/.env.example apps/edr-passenger-api/.env
|
||||
|
||||
# Edit .env file with your configuration
|
||||
```
|
||||
|
||||
#### Required Environment Variables
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `NODE_ENV` | Environment mode | `development` |
|
||||
| `PORT` | HTTP server port | `4000` |
|
||||
| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@localhost:5432/edr_passenger` |
|
||||
| `JWT_SECRET` | JWT signing secret (change in production) | `your-secret-key` |
|
||||
| `JWT_EXPIRES_IN` | JWT token expiry | `7d` |
|
||||
| `PORTAL_URL` | Web app CORS origin | `http://localhost:3000` |
|
||||
| `BACK_OFFICE_URL` | Admin portal CORS origin | `http://localhost:3001` |
|
||||
| `SENDGRID_API_KEY` | SendGrid API key (optional) | `SG.xxx` |
|
||||
| `SENDGRID_FROM_EMAIL` | Email sender address | `noreply@edr-platform.com` |
|
||||
|
||||
#### Verifayda 2.0 Configuration (Ethiopian National ID Verification)
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `VERIFAYDA_ENABLED` | Enable Verifayda integration | `true` or `false` |
|
||||
| `VERIFAYDA_API_URL` | Verifayda API endpoint | `https://api.verifayda.gov.et/v2` |
|
||||
| `VERIFAYDA_API_KEY` | API key for Verifayda service | `your-verifayda-api-key` |
|
||||
|
||||
**Note:** When `VERIFAYDA_ENABLED=false`, verification is skipped (development mode only).
|
||||
|
||||
#### Corporate IAM Configuration (Back-office Authentication)
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `IAM_ENABLED` | Enable corporate IAM integration | `true` or `false` |
|
||||
| `IAM_API_URL` | Corporate IAM API endpoint | `https://iam.tria-plc.com/api` |
|
||||
| `IAM_API_KEY` | API key for IAM service | `your-iam-api-key` |
|
||||
|
||||
**Note:** When `IAM_ENABLED=false`, IAM-protected routes allow access without validation (development mode only).
|
||||
|
||||
#### Optional: Payment Provider Configuration
|
||||
```bash
|
||||
# Telebirr Configuration
|
||||
TELEBIRR_BASE_URL=https://api.telebirr.com
|
||||
TELEBIRR_MERCHANT_CODE=your-merchant-code
|
||||
TELEBIRR_APP_SECRET=your-app-secret
|
||||
# ... see .env.example for complete list
|
||||
```
|
||||
|
||||
### 4. Database Setup
|
||||
|
||||
#### Start PostgreSQL
|
||||
```bash
|
||||
# Using Docker (recommended)
|
||||
docker run --name edr-postgres \
|
||||
-e POSTGRES_USER=edr \
|
||||
-e POSTGRES_PASSWORD=edr_secret \
|
||||
-e POSTGRES_DB=edr_passenger \
|
||||
-p 5432:5432 \
|
||||
-d postgres:15
|
||||
|
||||
# Or use your local PostgreSQL installation
|
||||
```
|
||||
|
||||
#### Generate Prisma Client
|
||||
```bash
|
||||
pnpm --filter @edr/passenger-api run prisma:generate
|
||||
```
|
||||
|
||||
#### Run Migrations
|
||||
```bash
|
||||
pnpm --filter @edr/passenger-api run prisma:migrate:dev
|
||||
```
|
||||
|
||||
#### Seed Database
|
||||
```bash
|
||||
pnpm --filter @edr/passenger-api run prisma:seed
|
||||
```
|
||||
|
||||
**Seed Data Includes:**
|
||||
- 21 Stations (Complete Ethiopian-Djibouti Railway with country codes)
|
||||
- 1 Route with 21 stops and fare rules
|
||||
- 2 Train services with 4 trips
|
||||
- 360 seats across 12 coaches (Economy Regular, Economy Bed, VIP Bed classes)
|
||||
- 3 User accounts (Admin, Passenger, Agent)
|
||||
- Fare rules for ADULT and CHILD passenger categories
|
||||
- Currency exchange rates (ETB, DJF, USD)
|
||||
- Baggage allowance rules
|
||||
- Notification templates
|
||||
- Promotions and FAQ content
|
||||
- Menu items and station crowd signals
|
||||
- Fraud detection rules
|
||||
- Saved passenger profiles for testing
|
||||
|
||||
### 5. Start Development Server
|
||||
```bash
|
||||
pnpm --filter @edr/passenger-api run dev
|
||||
```
|
||||
|
||||
**API Server:** http://localhost:4000
|
||||
**Swagger Docs:** http://localhost:4000/api-docs
|
||||
|
||||
## 🔑 Default Credentials
|
||||
|
||||
After seeding, use these credentials to test the API:
|
||||
|
||||
| Role | Email | Password | Description |
|
||||
|------|-------|----------|-------------|
|
||||
| **Admin** | `admin@edr-platform.com` | `admin123` | Full system access, reports, agent management |
|
||||
| **Passenger** | `kelemu@email.com` | `password123` | Regular user with loyalty (Silver) and wallet |
|
||||
| **Agent** | `agent@edr-platform.com` | `agent123` | Counter booking agent with commission tracking |
|
||||
|
||||
## 📚 API Documentation
|
||||
|
||||
### Swagger UI
|
||||
Interactive API documentation available at: **http://localhost:4000/api-docs**
|
||||
|
||||
### Authentication Methods
|
||||
|
||||
The API uses two authentication schemes:
|
||||
|
||||
#### 1. JWT Authentication (Passenger-facing)
|
||||
- **Used for**: Passenger bookings, profile management, wallet, loyalty
|
||||
- **Header**: `Authorization: Bearer <jwt-token>`
|
||||
- **Obtain token**: `POST /auth/login` with passenger credentials
|
||||
- **Swagger Security**: `JWT-auth`
|
||||
|
||||
#### 2. IAM Authentication (Back-office)
|
||||
- **Used for**: Agent operations, fraud detection, reports, admin functions
|
||||
- **Header**: `Authorization: Bearer <iam-token>`
|
||||
- **Obtain token**: From corporate IAM system (https://iam.tria-plc.com)
|
||||
- **Swagger Security**: `IAM-auth`
|
||||
- **Roles**: AGENT, SUPERVISOR, ADMIN, STAFF
|
||||
|
||||
### API Endpoints Overview
|
||||
|
||||
| Module | Base Path | Auth Type | Description |
|
||||
|--------|-----------|-----------|-------------|
|
||||
| **Auth** | `/auth` | Public/JWT | Register, login, OTP verification, password reset |
|
||||
| **Passengers** | `/passengers` | Public/JWT | Verifayda verification, international registration, profiles |
|
||||
| **Search** | `/search` | Public | Trip search, availability, fare quotes |
|
||||
| **Stations** | `/stations` | Public/JWT | Station directory and information |
|
||||
| **Seats** | `/seats` | JWT/IAM | Seat maps, holds, releases, blocking |
|
||||
| **Bookings** | `/bookings` | Public/JWT | Guest booking, create, modify, cancel bookings |
|
||||
| **Payments** | `/payments` | JWT/Public | Payment initiation, webhooks, refunds |
|
||||
| **Tickets** | `/tickets` | JWT/IAM | Ticket generation, QR/barcode, validation |
|
||||
| **Notifications** | `/notifications` | JWT | In-app notifications, preferences |
|
||||
| **Loyalty** | `/loyalty` | JWT | Points, tiers, rewards redemption |
|
||||
| **Wallet** | `/wallet` | JWT | Balance, top-up, transaction history |
|
||||
| **Promotions** | `/promos` | Public/JWT | Active promotions, promo code validation |
|
||||
| **Live Tracking** | `/live` | Public/JWT | Real-time trip status, crowd signals |
|
||||
| **Support** | `/support` | Public/JWT | FAQ, chat conversations |
|
||||
| **Dashboard** | `/dashboard` | JWT | Home screen aggregated data |
|
||||
| **Routes** | `/routes` | JWT/IAM | Reusable route templates with ordered stops |
|
||||
| **Schedules** | `/schedules` | JWT/IAM | Trip schedules, fare rules, status updates |
|
||||
| **Fleet** | `/fleet` | JWT/IAM | Train services, coaches, seat configurations |
|
||||
| **Seat Classes** | `/seat-classes` or `/classes` | Public/JWT/IAM | Seat class management and configuration |
|
||||
| **Segment Seats** | `/segments/seats` | Public/JWT | Segment-based seat availability and booking |
|
||||
| **Agents** | `/agents` | IAM | Agent booking, shifts, commissions, reconciliation |
|
||||
| **Fraud Detection** | `/fraud` | IAM | Fraud alerts, rules management, user blocking |
|
||||
| **Reports** | `/reports` | IAM | Revenue, occupancy, agent sales analytics |
|
||||
|
||||
### Example API Calls
|
||||
|
||||
#### 1. Register Passenger
|
||||
```bash
|
||||
POST /auth/register
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"phone": "+251911234567",
|
||||
"fullName": "John Doe",
|
||||
"password": "SecurePass123"
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Login (Passenger)
|
||||
```bash
|
||||
POST /auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "SecurePass123"
|
||||
}
|
||||
|
||||
# Response includes JWT token
|
||||
{
|
||||
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"user": { "id": "uuid", "role": "PASSENGER" }
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Verify Ethiopian National ID (Verifayda)
|
||||
```bash
|
||||
POST /passengers/verify-fayda
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"nationalId": "ET123456789"
|
||||
}
|
||||
|
||||
# Response with verified passenger data
|
||||
{
|
||||
"verified": true,
|
||||
"passengerData": {
|
||||
"fullName": "Abebe Kebede",
|
||||
"dateOfBirth": "1985-03-15T00:00:00.000Z",
|
||||
"gender": "Male",
|
||||
"nationality": "Ethiopian"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Universal Passenger Registration (NEW)
|
||||
```bash
|
||||
# Guest Ethiopian with Fayda verification
|
||||
POST /passengers/register
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"passengerName": "Abebe Kebede",
|
||||
"dateOfBirth": "1985-03-15",
|
||||
"nationalId": "ET123456789",
|
||||
"phone": "+251911234567",
|
||||
"deviceId": "device-uuid-123"
|
||||
}
|
||||
|
||||
# Logged-in user with JWT token
|
||||
POST /passengers/register
|
||||
Authorization: Bearer <jwt-token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"passengerName": "Abebe Kebede",
|
||||
"dateOfBirth": "1985-03-15",
|
||||
"nationalId": "ET123456789",
|
||||
"phone": "+251911234567"
|
||||
}
|
||||
|
||||
# International passenger (passport)
|
||||
POST /passengers/register
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"passengerName": "John Smith",
|
||||
"dateOfBirth": "1990-07-20",
|
||||
"passportNumber": "P1234567",
|
||||
"passportCountry": "Kenya",
|
||||
"nationality": "Kenyan",
|
||||
"phone": "+254712345678",
|
||||
"email": "john@example.com",
|
||||
"deviceId": "device-uuid-123"
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. Get User Profile (NEW)
|
||||
```bash
|
||||
GET /auth/profile
|
||||
Authorization: Bearer <jwt-token>
|
||||
|
||||
# Response includes user, passenger, loyalty, and wallet details
|
||||
{
|
||||
"id": "uuid",
|
||||
"email": "user@example.com",
|
||||
"phone": "+251911234567",
|
||||
"fullName": "John Doe",
|
||||
"role": "PASSENGER",
|
||||
"nationality": "Ethiopian",
|
||||
"faydaVerified": true,
|
||||
"faydaVerifiedAt": "2024-01-15T10:30:00.000Z",
|
||||
"passenger": {
|
||||
"id": "uuid",
|
||||
"loyalty": {
|
||||
"tier": "SILVER",
|
||||
"pointsBalance": 1500,
|
||||
"lifetimePoints": 3000
|
||||
},
|
||||
"wallet": {
|
||||
"balanceMinor": 50000,
|
||||
"currency": "ETB"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 6. Search Trips
|
||||
```bash
|
||||
POST /search
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"originStationId": "uuid",
|
||||
"destinationStationId": "uuid",
|
||||
"date": "2026-06-15",
|
||||
"adultCount": 2,
|
||||
"childCount": 1
|
||||
}
|
||||
```
|
||||
|
||||
#### 7. Get Fare Quote
|
||||
```bash
|
||||
POST /search/fare-quote
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"tripId": "uuid",
|
||||
"serviceClass": "ECONOMY_REGULAR",
|
||||
"adultCount": 2,
|
||||
"childCount": 1,
|
||||
"displayCurrency": "USD"
|
||||
}
|
||||
|
||||
# Response includes age-based pricing breakdown
|
||||
{
|
||||
"baseFareMinor": 35000,
|
||||
"adultCount": 2,
|
||||
"adultFareMinor": 70000,
|
||||
"childCount": 1,
|
||||
"freeChildrenCount": 1,
|
||||
"paidChildrenCount": 0,
|
||||
"childFareMinor": 0,
|
||||
"totalMinor": 73500,
|
||||
"currency": "ETB",
|
||||
"displayCurrency": "USD",
|
||||
"displayTotalMinor": 1323
|
||||
}
|
||||
```
|
||||
|
||||
#### 8. Guest Booking (No Login Required)
|
||||
```bash
|
||||
POST /bookings/guest
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"tripId": "uuid",
|
||||
"holdId": "uuid",
|
||||
"serviceClass": "ECONOMY_REGULAR",
|
||||
"displayCurrency": "ETB",
|
||||
"passengers": [
|
||||
{
|
||||
"seatId": "uuid",
|
||||
"passengerName": "Abebe Kebede",
|
||||
"dateOfBirth": "1985-03-15",
|
||||
"idDocumentType": "NATIONAL_ID",
|
||||
"idDocumentNumber": "ET123456789"
|
||||
}
|
||||
],
|
||||
"createAccount": false,
|
||||
"savePassengerDetails": true,
|
||||
"deviceId": "device-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
#### 9. Agent Booking (IAM Auth)
|
||||
```bash
|
||||
POST /agents/bookings
|
||||
Authorization: Bearer <iam-token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"tripId": "uuid",
|
||||
"seats": [...],
|
||||
"paymentMethod": "CASH",
|
||||
"cashReceived": 50000
|
||||
}
|
||||
```
|
||||
|
||||
## 🏗️ Project Structure
|
||||
|
||||
```
|
||||
apps/edr-passenger-api/
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # Database schema (40+ models)
|
||||
│ ├── seed.ts # Comprehensive seed script
|
||||
│ └── migrations/ # Database migrations
|
||||
├── src/
|
||||
│ ├── common/ # Shared utilities
|
||||
│ │ ├── filters/ # Exception filters
|
||||
│ │ ├── interceptors/ # Response interceptors
|
||||
│ │ ├── pipes/ # Validation pipes
|
||||
│ │ ├── i18n/ # Internationalization
|
||||
│ │ ├── jwt.guard.ts # JWT authentication guard (passengers)
|
||||
│ │ ├── jwt.strategy.ts # Passport JWT strategy
|
||||
│ │ ├── iam-adapter.ts # Corporate IAM guard (back-office)
|
||||
│ │ ├── iam.module.ts # IAM module
|
||||
│ │ ├── roles.guard.ts # RBAC authorization guard
|
||||
│ │ ├── roles.decorator.ts # Roles decorator
|
||||
│ │ ├── prisma.service.ts # Prisma client service
|
||||
│ │ └── prisma.module.ts # Prisma module
|
||||
│ ├── config/ # Configuration files
|
||||
│ │ ├── app.config.ts # App configuration
|
||||
│ │ ├── database.config.ts # Database configuration
|
||||
│ │ └── telebirr.config.ts # Payment provider config
|
||||
│ ├── modules/ # Feature modules
|
||||
│ │ ├── auth/ # Authentication & authorization (JWT)
|
||||
│ │ ├── agents/ # Agent operations (IAM-protected)
|
||||
│ │ ├── bookings/ # Booking management (JWT)
|
||||
│ │ ├── currency/ # Currency conversion service
|
||||
│ │ ├── dashboard/ # Dashboard aggregations (JWT)
|
||||
│ │ ├── fleet/ # Train fleet management (JWT/IAM)
|
||||
│ │ ├── fraud/ # Fraud detection (IAM-protected)
|
||||
│ │ ├── live/ # Live tracking (JWT)
|
||||
│ │ ├── loyalty/ # Loyalty program (JWT)
|
||||
│ │ ├── notifications/ # Notification system (JWT)
|
||||
│ │ ├── passengers/ # Passenger management (JWT)
|
||||
│ │ ├── payments/ # Payment processing (JWT/Webhooks)
|
||||
│ │ ├── promos/ # Promotions (JWT)
|
||||
│ │ ├── reports/ # Reports & analytics (IAM-protected)
|
||||
│ │ ├── schedules/ # Trip schedules (JWT/IAM)
|
||||
│ │ ├── search/ # Trip search (JWT)
|
||||
│ │ ├── seats/ # Seat management (JWT/IAM)
|
||||
│ │ ├── segments/ # Journey segments (JWT)
|
||||
│ │ ├── stations/ # Station management (JWT)
|
||||
│ │ ├── support/ # Customer support (JWT)
|
||||
│ │ ├── tickets/ # Ticketing (JWT/IAM)
|
||||
│ │ ├── verifayda/ # Verifayda 2.0 integration
|
||||
│ │ └── wallet/ # Wallet system (JWT)
|
||||
│ ├── app.module.ts # Root application module
|
||||
│ └── main.ts # Application entry point
|
||||
├── test/ # E2E tests
|
||||
├── .env.example # Environment template
|
||||
├── Dockerfile # Docker configuration
|
||||
├── nest-cli.json # NestJS CLI configuration
|
||||
├── package.json # Dependencies & scripts
|
||||
├── tsconfig.json # TypeScript configuration
|
||||
└── tsconfig.build.json # Build configuration
|
||||
```
|
||||
|
||||
## 🗄️ Database Schema
|
||||
|
||||
### Key Models (40+ total)
|
||||
|
||||
**Core Entities:**
|
||||
- `User`, `Session`, `Passenger`, `Agent`
|
||||
- `Station`, `Route`, `RouteStop`, `RouteFareRule`
|
||||
- `TrainService`, `Trip`, `TripStopTime`, `Coach`, `Seat`
|
||||
- `Booking`, `BookingSeat`, `Ticket`
|
||||
- `PaymentIntent`, `PaymentRefund`, `PaymentWebhookEvent`
|
||||
|
||||
**Enhanced Features:**
|
||||
- `OtpCode`, `PasswordResetToken` (Auth)
|
||||
- `AgentBooking`, `AgentShift`, `AgentCommission` (Agents)
|
||||
- `BookingModification`, `BookingCancellation` (Booking lifecycle)
|
||||
- `GateValidationLog` (Ticket validation)
|
||||
- `BaggageAllowance`, `BaggageBooking` (Baggage)
|
||||
- `LoyaltyAccount`, `LoyaltyLedgerEntry`, `LoyaltyReward`
|
||||
- `WalletAccount`, `WalletLedgerEntry`
|
||||
- `Notification`, `NotificationTemplate`
|
||||
- `AuditLog`, `OperationalReport`
|
||||
- `SeatBlock`, `SeatHold`
|
||||
- `CurrencyExchangeRate` (Multi-currency)
|
||||
- `VerifaydaVerification` (National ID verification)
|
||||
- `SavedPassengerProfile` (Guest booking)
|
||||
- `SeatClass` (Seat class configuration)
|
||||
- `JourneySegment` (Multi-segment journeys)
|
||||
|
||||
## 🔧 Available Scripts
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm --filter @edr/passenger-api run dev # Start with hot-reload
|
||||
|
||||
# Build
|
||||
pnpm --filter @edr/passenger-api run build # Compile TypeScript
|
||||
|
||||
# Production
|
||||
pnpm --filter @edr/passenger-api run start # Run compiled code
|
||||
|
||||
# Testing
|
||||
pnpm --filter @edr/passenger-api run test # Unit tests
|
||||
pnpm --filter @edr/passenger-api run test:e2e # E2E tests
|
||||
|
||||
# Code Quality
|
||||
pnpm --filter @edr/passenger-api run lint # ESLint
|
||||
pnpm --filter @edr/passenger-api run type-check # TypeScript check
|
||||
|
||||
# Database
|
||||
pnpm --filter @edr/passenger-api run prisma:generate # Generate Prisma client
|
||||
pnpm --filter @edr/passenger-api run prisma:migrate:dev # Run migrations (local dev)
|
||||
pnpm --filter @edr/passenger-api run prisma:seed # Seed database
|
||||
```
|
||||
|
||||
## 🐳 Docker Deployment
|
||||
|
||||
All six apps build from Dockerfiles: each API has its own (`apps/edr-freight-api/Dockerfile`, `apps/edr-passenger-api/Dockerfile`); Vite frontends share `infrastructure/docker/Dockerfile.web` and are served with **nginx**. APIs run on **Node 22**.
|
||||
|
||||
**Prerequisites**
|
||||
|
||||
- Docker with BuildKit enabled
|
||||
- A local [`.npmrc`](.gitignore) with GitHub Packages auth for `@tria-plc/*` (required for **freight** API and web images)
|
||||
- External Postgres for each API (compose does **not** include databases)
|
||||
- Copy `apps/edr-freight-api/.env.example` → `.env` and `apps/edr-passenger-api/.env.example` → `.env` with real connection strings
|
||||
|
||||
### Build and run (all apps)
|
||||
|
||||
```bash
|
||||
# From monorepo root
|
||||
DOCKER_BUILDKIT=1 pnpm docker:build
|
||||
pnpm docker:up
|
||||
```
|
||||
|
||||
Or without pnpm scripts:
|
||||
|
||||
```bash
|
||||
DOCKER_BUILDKIT=1 docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
| Service | URL (default) |
|
||||
|---------|----------------|
|
||||
| Freight API | http://localhost:3001 |
|
||||
| Passenger API | http://localhost:4000 |
|
||||
| Freight portal | http://localhost:5173 |
|
||||
| Freight backoffice | http://localhost:5183 |
|
||||
| Passenger portal | http://localhost:5174 |
|
||||
| Passenger backoffice | http://localhost:5184 |
|
||||
|
||||
### Build a single service
|
||||
|
||||
```bash
|
||||
docker compose build freight-api
|
||||
docker compose build passenger-portal
|
||||
```
|
||||
|
||||
Freight images mount `.npmrc` as a BuildKit secret during `pnpm install`. Passenger web images do not require private packages.
|
||||
|
||||
### `VITE_API_URL` (frontends)
|
||||
|
||||
API URLs are **baked in at image build time** (`import.meta.env.VITE_API_URL`). Defaults in [`docker-compose.yaml`](docker-compose.yaml) use `http://localhost:3001/api` (freight) and `http://localhost:4000` (passenger) for local smoke tests. Override build args for production, e.g.:
|
||||
|
||||
```bash
|
||||
docker compose build freight-portal \
|
||||
--build-arg VITE_API_URL=https://freight-api.example.com/api
|
||||
```
|
||||
|
||||
### Migrations
|
||||
|
||||
- **Freight API:** TypeORM migrations are not run on container startup — apply them separately before deploy.
|
||||
- **Passenger API:** On each container start, the entrypoint runs `npm run prisma:migrate` and `npm run prisma:seed` (same `package.json` scripts as `pnpm run`) before starting the server. Ensure `DATABASE_URL` in `.env` points at a reachable Postgres instance.
|
||||
|
||||
For local development, use `pnpm --filter @edr/passenger-api run prisma:migrate:dev` instead of `prisma:migrate`.
|
||||
|
||||
### GitHub Actions (self-hosted runner)
|
||||
|
||||
Two workflows deploy independently on push to `main`, `develop`, or `staging`:
|
||||
|
||||
| Workflow | Services | Server env root |
|
||||
|----------|----------|-----------------|
|
||||
| [`.github/workflows/deploy-freight.yml`](.github/workflows/deploy-freight.yml) | freight-api, freight-portal, freight-backoffice | `/home/user/environmen/edr-freight/<branch>/` |
|
||||
| [`.github/workflows/deploy-passenger.yml`](.github/workflows/deploy-passenger.yml) | passenger-api, passenger-portal, passenger-backoffice | `/home/user/environmen/edr-passenger/<branch>/` |
|
||||
|
||||
**On the runner**, place env files before the first deploy (example for branch `main`):
|
||||
|
||||
```text
|
||||
/home/user/environmen/edr-freight/main/
|
||||
freight-api.env
|
||||
freight-portal.env # optional runtime env for Vite/nginx
|
||||
freight-backoffice.env
|
||||
freight-web.build.env # exports FREIGHT_VITE_API_URL=...
|
||||
|
||||
/home/user/environmen/edr-passenger/main/
|
||||
passenger-api.env
|
||||
passenger-portal.env
|
||||
passenger-backoffice.env
|
||||
passenger-web.build.env # exports PASSENGER_VITE_API_URL=...
|
||||
```
|
||||
|
||||
Example `freight-web.build.env`:
|
||||
|
||||
```bash
|
||||
export FREIGHT_VITE_API_URL=https://freight-api.example.com/api
|
||||
```
|
||||
|
||||
The workflow copies `*.env` into each app directory, creates `.npmrc` from the `NPM_TOKEN` repository secret, then runs `docker compose build` and `docker compose up -d` for that stack.
|
||||
|
||||
## 🔒 Security Best Practices
|
||||
|
||||
1. **Environment Variables** - Never commit `.env` files. Use secrets management in production.
|
||||
2. **JWT Secret** - Use strong, randomly generated secrets (min 32 characters).
|
||||
3. **Password Hashing** - Bcrypt with salt rounds (default: 10).
|
||||
4. **Rate Limiting** - Implement rate limiting for auth endpoints.
|
||||
5. **CORS** - Configure allowed origins in production.
|
||||
6. **HTTPS** - Always use HTTPS in production.
|
||||
7. **Database** - Use connection pooling and prepared statements (Prisma handles this).
|
||||
8. **Audit Logging** - All sensitive operations are logged in `AuditLog` table.
|
||||
9. **Dual Authentication** - Passenger routes use JWT, back-office routes use corporate IAM.
|
||||
10. **IAM Integration** - Corporate IAM validates tokens against centralized identity service.
|
||||
11. **Role-Based Access** - Granular permissions enforced via IAM roles (AGENT, SUPERVISOR, ADMIN).
|
||||
12. **Token Validation** - IAM tokens validated in real-time with 5-second timeout.
|
||||
|
||||
## 📊 Monitoring & Logging
|
||||
|
||||
- **Application Logs** - NestJS built-in logger
|
||||
- **Database Queries** - Prisma query logging (enable in development)
|
||||
- **Audit Trail** - All user actions logged in `AuditLog` table
|
||||
- **Error Tracking** - Global exception filters with detailed error responses
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# Unit tests
|
||||
pnpm --filter @edr/passenger-api run test
|
||||
|
||||
# E2E tests
|
||||
pnpm --filter @edr/passenger-api run test:e2e
|
||||
|
||||
# Test coverage
|
||||
pnpm --filter @edr/passenger-api run test:cov
|
||||
```
|
||||
|
||||
## 🚀 Production Deployment
|
||||
|
||||
### Pre-deployment Checklist
|
||||
- [ ] Update environment variables (JWT_SECRET, DATABASE_URL, etc.)
|
||||
- [ ] Configure IAM integration (IAM_ENABLED=true, IAM_API_URL, IAM_API_KEY)
|
||||
- [ ] Configure Verifayda integration (VERIFAYDA_ENABLED=true, VERIFAYDA_API_KEY)
|
||||
- [ ] Set up currency exchange rate sync (external API)
|
||||
- [ ] Set NODE_ENV=production
|
||||
- [ ] Configure CORS origins (PORTAL_URL, BACK_OFFICE_URL)
|
||||
- [ ] Set up SSL/TLS certificates
|
||||
- [ ] Configure database connection pooling
|
||||
- [ ] Set up monitoring and logging
|
||||
- [ ] Configure backup strategy
|
||||
- [ ] Test payment provider integrations
|
||||
- [ ] Verify IAM token validation endpoint
|
||||
- [ ] Test Verifayda verification with real national IDs
|
||||
- [ ] Verify currency conversion accuracy
|
||||
- [ ] Test age-based pricing calculations
|
||||
- [ ] Review security settings and audit logs
|
||||
- [ ] Test both JWT and IAM authentication flows
|
||||
|
||||
### Deployment Steps
|
||||
```bash
|
||||
# 1. Build application
|
||||
pnpm --filter @edr/passenger-api run build
|
||||
|
||||
# 2. Run migrations
|
||||
pnpm --filter @edr/passenger-api run prisma:migrate
|
||||
|
||||
# 3. Start production server
|
||||
NODE_ENV=production pnpm --filter @edr/passenger-api run start:prod
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit changes (`git commit -m 'Add amazing feature'`)
|
||||
4. Push to branch (`git push origin feature/amazing-feature`)
|
||||
5. Open Pull Request
|
||||
|
||||
## 📝 License
|
||||
|
||||
This project is proprietary and confidential.
|
||||
|
||||
## 📧 Support
|
||||
|
||||
For technical support or questions:
|
||||
- Email: support@edr-platform.com
|
||||
- Documentation: http://localhost:4000/api-docs
|
||||
|
||||
---
|
||||
|
||||
**Built with ❤️ for Ethio-Djibouti Railway**
|
||||
### Start local databases
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
# App
|
||||
NODE_ENV=development
|
||||
# Copy to .env for local/docker compose (not committed).
|
||||
PORT=3001
|
||||
|
||||
# Database
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5433
|
||||
DB_NAME=edr_freight
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=
|
||||
DB_NAME=edr_freight
|
||||
|
||||
# Telebirr payment gateway (freight merchant credentials)
|
||||
TELEBIRR_BASE_URL=
|
||||
TELEBIRR_WEB_BASE_URL=
|
||||
TELEBIRR_FABRIC_APP_ID=
|
||||
TELEBIRR_APP_SECRET=
|
||||
TELEBIRR_MERCHANT_APP_ID=
|
||||
TELEBIRR_MERCHANT_CODE=
|
||||
TELEBIRR_NOTIFY_URL=https://freight-api.edr.et/payments/webhooks/telebirr
|
||||
TELEBIRR_RETURN_URL=
|
||||
TELEBIRR_TIMEOUT_EXPRESS=15m
|
||||
TELEBIRR_PRIVATE_KEY=
|
||||
TELEBIRR_PUBLIC_KEY=
|
||||
TELEBIRR_INSECURE_TLS=false
|
||||
# JWT (used by @tria-plc/api-common SharedAuthModule)
|
||||
JWT_SECRET=
|
||||
JWT_ACCESS_TOKEN_SECRET=
|
||||
|
||||
@@ -1,26 +1,37 @@
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
# syntax=docker/dockerfile:1
|
||||
# Build from monorepo root: docker build -f apps/edr-freight-api/Dockerfile .
|
||||
|
||||
FROM node:24.15.0-alpine AS base
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/edr-freight-api/package.json ./apps/edr-freight-api/
|
||||
COPY packages ./packages
|
||||
RUN pnpm install --frozen-lockfile --filter @edr/freight-api...
|
||||
FROM base AS pruner
|
||||
COPY . .
|
||||
RUN pnpm dlx turbo prune "@edr/freight-api" --docker
|
||||
|
||||
FROM deps AS build
|
||||
COPY apps/edr-freight-api ./apps/edr-freight-api
|
||||
RUN pnpm --filter @edr/freight-api build
|
||||
FROM base AS installer
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
FROM node:20-alpine AS runtime
|
||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
WORKDIR /app/apps/edr-freight-api
|
||||
FROM base AS builder
|
||||
COPY --from=installer /app/ .
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
RUN pnpm turbo build --filter="@edr/freight-api..."
|
||||
|
||||
FROM base AS deployer
|
||||
COPY --from=builder /app/ .
|
||||
RUN pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
|
||||
|
||||
FROM node:24.15.0-alpine AS runner
|
||||
RUN apk add --no-cache libc6-compat
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=deps /app/node_modules ./../../node_modules
|
||||
COPY --from=deps /app/apps/edr-freight-api/node_modules ./node_modules
|
||||
COPY --from=build /app/apps/edr-freight-api/dist ./dist
|
||||
COPY --from=build /app/apps/edr-freight-api/package.json ./package.json
|
||||
|
||||
WORKDIR /app
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 --ingroup nodejs nestjs
|
||||
COPY --from=deployer --chown=nestjs:nodejs /deploy .
|
||||
USER nestjs
|
||||
EXPOSE 3001
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
||||
@@ -14,22 +14,29 @@
|
||||
"test": "jest",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"seed:wagons": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-wagons.ts",
|
||||
"type-check": "tsc --noEmit"
|
||||
"type-check": "tsc --noEmit",
|
||||
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
|
||||
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
|
||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
"@edr/payment-providers": "workspace:*",
|
||||
"@edr/types": "workspace:*",
|
||||
"@golevelup/nestjs-rabbitmq": "^5.5.0",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/event-emitter": "^2.0.4",
|
||||
"@nestjs/mapped-types": "^2.1.1",
|
||||
"@nestjs/microservices": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@tria-plc/api-common": "^1.4.0",
|
||||
"@tria-plc/iamapi-common": "^0.5.1",
|
||||
"@tria-plc/api-common": "^1.4.3",
|
||||
"@tria-plc/iamapi-common": "^0.6.6",
|
||||
"amqp-connection-manager": "^5.0.0",
|
||||
"amqplib": "^2.0.1",
|
||||
"axios": "^1.16.1",
|
||||
@@ -41,7 +48,9 @@
|
||||
"pg": "^8.13.0",
|
||||
"puppeteer": "^24.2.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.30"
|
||||
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
@@ -64,7 +73,6 @@
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typeorm": "^1.0.0",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"jest": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module, OnApplicationBootstrap } from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { DataSource, DataSourceOptions } from "typeorm";
|
||||
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
|
||||
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
|
||||
@@ -8,17 +9,21 @@ import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.
|
||||
|
||||
import appConfig from "./config/app.config";
|
||||
import databaseConfig from "./config/database.config";
|
||||
import telebirrConfig from "./config/telebirr.config";
|
||||
import rabbitmqConfig from "./config/rabbitmq.config";
|
||||
|
||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||
import { SignaturesModule } from "./modules/signatures/signatures.module";
|
||||
import { FilesModule } from "./modules/files/files.module";
|
||||
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||
|
||||
//import { TrainsModule } from "./modules/trains/trains.module";
|
||||
// import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
|
||||
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
|
||||
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
||||
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
|
||||
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
|
||||
import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module";
|
||||
import { CustomersModule } from "./modules/customers/customers.module";
|
||||
import { CompaniesModule } from "./modules/companies/companies.module";
|
||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||
@@ -44,6 +49,8 @@ import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||
import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
||||
import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
|
||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
//New Trains, Wagons, Container and Cargo management modules
|
||||
import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { WagonsModule } from './modules/wagons/wagons.module';
|
||||
@@ -52,13 +59,16 @@ import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||
import { RoutesModule } from './modules/routes/routes.module';
|
||||
import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
||||
import { FacilitiesModule } from './modules/facilities/facilities.module';
|
||||
import { OverviewModule } from './modules/overview/overview.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [appConfig, databaseConfig],
|
||||
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
// EventEmitterModule.forRoot(),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
||||
@@ -78,6 +88,7 @@ import { FacilitiesModule } from './modules/facilities/facilities.module';
|
||||
permissions: EDR_FREIGHT_PERMISSIONS,
|
||||
}),
|
||||
BookingsModule,
|
||||
SignaturesModule,
|
||||
FilesModule,
|
||||
ConsignmentsModule,
|
||||
LocomotivesModule,
|
||||
@@ -85,6 +96,7 @@ import { FacilitiesModule } from './modules/facilities/facilities.module';
|
||||
TrainSetsModule,
|
||||
TrainSchedulesModule,
|
||||
TrainSchedulingModule,
|
||||
SchedulingRescheduleModule,
|
||||
CustomersModule,
|
||||
CompaniesModule,
|
||||
TrackingModule,
|
||||
@@ -106,8 +118,20 @@ import { FacilitiesModule } from './modules/facilities/facilities.module';
|
||||
RoutesModule,
|
||||
FacilitiesModule,
|
||||
WarehousesModule,
|
||||
OverviewModule,
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
DemoUsersSeeder,
|
||||
FreightStaffUsersSeeder,
|
||||
DemoBookingsSeeder,
|
||||
PricingDataSeeder,
|
||||
FileUploadSettingsSeeder,
|
||||
FreightPermissionKeyMigrationSeeder,
|
||||
DemoFreightDataSeeder,
|
||||
IndodeFacilitySeeder,
|
||||
Batch14TestDataSeeder,
|
||||
],
|
||||
providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder, IndodeFacilitySeeder, Batch14TestDataSeeder],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
@@ -120,9 +144,12 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||
private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
|
||||
private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
) { }
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
await this.freightPermissionKeyMigrationSeeder.run();
|
||||
await this.seeder.run();
|
||||
await this.edrOrgSeeder.run();
|
||||
await this.demoUsersSeeder.run();
|
||||
@@ -132,5 +159,8 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
await this.fileUploadSettingsSeeder.run();
|
||||
await this.indodeFacilitySeeder.run();
|
||||
await this.batch14TestDataSeeder.run();
|
||||
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
|
||||
// Each block self-guards on an empty-table check, so this is safe every boot.
|
||||
await this.demoFreightDataSeeder.run();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,3 +15,16 @@ export const BookingStaff = (permission: string | string[]) =>
|
||||
);
|
||||
|
||||
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
|
||||
|
||||
export const TrainSchedulingView = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
|
||||
|
||||
export const TrainSchedulingManage = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.manage);
|
||||
|
||||
export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view);
|
||||
|
||||
export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage);
|
||||
|
||||
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
|
||||
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { deriveTradeDirection } from './derive-trade-direction.util';
|
||||
|
||||
describe('deriveTradeDirection', () => {
|
||||
it('returns IMPORT when origin is Djibouti', () => {
|
||||
expect(deriveTradeDirection({ country: 'Djibouti' }, { country: 'Ethiopia' })).toBe(
|
||||
'IMPORT',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns EXPORT when destination is Djibouti and origin is not', () => {
|
||||
expect(deriveTradeDirection({ country: 'Ethiopia' }, { country: 'Djibouti' })).toBe(
|
||||
'EXPORT',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns DOMESTIC for intra-Ethiopia routes', () => {
|
||||
expect(deriveTradeDirection({ country: 'Ethiopia' }, { country: 'Ethiopia' })).toBe(
|
||||
'DOMESTIC',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ScheduleTradeDirection } from '@edr/types';
|
||||
|
||||
type YardLike = { country?: string | null };
|
||||
|
||||
/** Derive booking/schedule trade direction from origin and destination yard countries. */
|
||||
export function deriveTradeDirection(
|
||||
originYard: YardLike,
|
||||
destinationYard: YardLike,
|
||||
): ScheduleTradeDirection {
|
||||
const originCountry = originYard.country?.trim();
|
||||
const destinationCountry = destinationYard.country?.trim();
|
||||
|
||||
if (originCountry === 'Djibouti') {
|
||||
return 'IMPORT';
|
||||
}
|
||||
if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') {
|
||||
return 'EXPORT';
|
||||
}
|
||||
return 'DOMESTIC';
|
||||
}
|
||||
51
apps/edr-freight-api/src/common/guards/service-auth.guard.ts
Normal file
51
apps/edr-freight-api/src/common/guards/service-auth.guard.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { Request } from "express";
|
||||
|
||||
/**
|
||||
* Shared-secret guard for endpoints only the payment microservice may call
|
||||
* (e.g. /internal/payments/mark-paid). The secret is the same SERVICE_AUTH_TOKEN
|
||||
* the payment service uses on its own internal surface.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ServiceAuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(ServiceAuthGuard.name);
|
||||
private readonly token = process.env.SERVICE_AUTH_TOKEN ?? "";
|
||||
private warned = false;
|
||||
|
||||
constructor() {
|
||||
if (!this.token && process.env.NODE_ENV === "production") {
|
||||
throw new Error("SERVICE_AUTH_TOKEN must be set in production");
|
||||
}
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
if (!this.token) {
|
||||
if (!this.warned) {
|
||||
this.logger.warn(
|
||||
"SERVICE_AUTH_TOKEN unset — internal endpoints are UNGUARDED (dev only)",
|
||||
);
|
||||
this.warned = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const header = request.headers["x-service-token"];
|
||||
const bearer = request.headers.authorization?.replace(/^Bearer\s+/i, "");
|
||||
const presented = (Array.isArray(header) ? header[0] : header) ?? bearer ?? "";
|
||||
|
||||
const expected = Buffer.from(this.token);
|
||||
const actual = Buffer.from(presented);
|
||||
const valid =
|
||||
expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||
if (!valid) throw new UnauthorizedException("Invalid service token");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,31 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
|
||||
const numberFromEnv = (key: string, fallback: number): number => {
|
||||
const value = Number(process.env[key]);
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback;
|
||||
};
|
||||
|
||||
export default registerAs("app", () => ({
|
||||
env: process.env.NODE_ENV ?? "development",
|
||||
port: parseInt(process.env.PORT ?? "3001", 10),
|
||||
apiPrefix: "api",
|
||||
trainScheduling: {
|
||||
maxTrainWeightTons: numberFromEnv("TRAIN_SCHEDULING_MAX_WEIGHT_TONS", 3500),
|
||||
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),
|
||||
maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53),
|
||||
},
|
||||
cbeExchange: {
|
||||
/** ethio.forex CBET page — scraped for USD buying/selling rates. */
|
||||
scrapeUrl:
|
||||
process.env.CBE_EXCHANGE_SCRAPE_URL ??
|
||||
process.env.CBE_EXCHANGE_API_URL ??
|
||||
"https://ethio.forex/bank/CBET",
|
||||
/** @deprecated use scrapeUrl — kept for backward-compatible config reads */
|
||||
apiUrl:
|
||||
process.env.CBE_EXCHANGE_SCRAPE_URL ??
|
||||
process.env.CBE_EXCHANGE_API_URL ??
|
||||
"https://ethio.forex/bank/CBET",
|
||||
fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130),
|
||||
cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -117,7 +117,7 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
|
||||
],
|
||||
migrationsRun: true,
|
||||
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
|
||||
synchronize: false,
|
||||
synchronize: true,
|
||||
logging: process.env.NODE_ENV === "development",
|
||||
};
|
||||
});
|
||||
|
||||
10
apps/edr-freight-api/src/config/dmoney.config.ts
Normal file
10
apps/edr-freight-api/src/config/dmoney.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
|
||||
export default registerAs("dmoney", () => ({
|
||||
baseUrl: process.env.DMONEY_BASE_URL ?? "",
|
||||
appId: process.env.DMONEY_APP_ID ?? "",
|
||||
appSecret: process.env.DMONEY_APP_SECRET ?? "",
|
||||
publicKey: process.env.DMONEY_PUBLIC_KEY ?? "",
|
||||
privateKey: process.env.DMONEY_PRIVATE_KEY ?? "",
|
||||
notifyUrl: process.env.DMONEY_NOTIFY_URL ?? ""
|
||||
}));
|
||||
11
apps/edr-freight-api/src/config/rabbitmq.config.ts
Normal file
11
apps/edr-freight-api/src/config/rabbitmq.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
/**
|
||||
* RabbitMQ connection for the payment-event consumer (payment microservice -> freight).
|
||||
* Points at the dedicated `payment` vhost on the shared broker.
|
||||
*/
|
||||
export default registerAs('rabbitmq', () => ({
|
||||
url: process.env.PAYMENT_RABBITMQ_URL ?? 'amqp://localhost:5672/payment',
|
||||
/** Max unacked payment events held by this consumer at once. */
|
||||
prefetch: parseInt(process.env.PAYMENT_EVENTS_PREFETCH ?? '10', 10),
|
||||
}));
|
||||
16
apps/edr-freight-api/src/config/telebirr.config.ts
Normal file
16
apps/edr-freight-api/src/config/telebirr.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
|
||||
export default registerAs("telebirr", () => ({
|
||||
baseUrl: process.env.TELEBIRR_BASE_URL ?? "",
|
||||
webBaseUrl: process.env.TELEBIRR_WEB_BASE_URL ?? "",
|
||||
fabricAppId: process.env.TELEBIRR_FABRIC_APP_ID ?? "",
|
||||
appSecret: process.env.TELEBIRR_APP_SECRET ?? "",
|
||||
merchantAppId: process.env.TELEBIRR_MERCHANT_APP_ID ?? "",
|
||||
merchantCode: process.env.TELEBIRR_MERCHANT_CODE ?? "",
|
||||
notifyUrl: process.env.TELEBIRR_NOTIFY_URL ?? "",
|
||||
returnUrl: process.env.TELEBIRR_RETURN_URL ?? "",
|
||||
timeoutExpress: process.env.TELEBIRR_TIMEOUT_EXPRESS ?? "15m",
|
||||
privateKey: process.env.TELEBIRR_PRIVATE_KEY ?? "",
|
||||
publicKey: process.env.TELEBIRR_PUBLIC_KEY ?? "",
|
||||
insecureTls: process.env.TELEBIRR_INSECURE_TLS === "true",
|
||||
}));
|
||||
@@ -61,7 +61,10 @@ export class ContractPricingScheduleBuilder {
|
||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—',
|
||||
containerLines: (booking.bookingContainers ?? []).map((c) => ({
|
||||
label:
|
||||
c.containerType?.label ?? c.containerType?.code ?? c.containerTypeId,
|
||||
c.containerType?.label ??
|
||||
c.containerType?.code ??
|
||||
c.containerTypeId ??
|
||||
'—',
|
||||
quantity: c.quantity,
|
||||
vgmPerUnitTons: Number(c.vgmPerUnitTons),
|
||||
})),
|
||||
|
||||
@@ -14,6 +14,10 @@ export class NormalizeWeightLimitTradeDirectionBoth1749000000000
|
||||
UPDATE freight.weight_limit_rules
|
||||
SET trade_direction = 'BOTH'
|
||||
WHERE trade_direction::text = 'ANY';
|
||||
|
||||
UPDATE freight.weight_limit_rules
|
||||
SET trade_direction = 'IMPORT'
|
||||
WHERE trade_direction IS NULL;
|
||||
EXCEPTION WHEN undefined_table OR undefined_column THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddSchedulingAllocationEnhancements1750400000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddSchedulingAllocationEnhancements1750400000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS wagons_required NUMERIC(6,2) NULL,
|
||||
ADD COLUMN IF NOT EXISTS scheduling_status VARCHAR(30) NOT NULL DEFAULT 'NOT_SCHEDULED',
|
||||
ADD COLUMN IF NOT EXISTS hold_started_at TIMESTAMPTZ NULL,
|
||||
ADD COLUMN IF NOT EXISTS hold_expires_at TIMESTAMPTZ NULL,
|
||||
ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS train_number VARCHAR(20) NULL,
|
||||
ADD COLUMN IF NOT EXISTS direction VARCHAR(10) NULL,
|
||||
ADD COLUMN IF NOT EXISTS actual_departure_at TIMESTAMPTZ NULL,
|
||||
ADD COLUMN IF NOT EXISTS actual_arrival_at TIMESTAMPTZ NULL,
|
||||
ADD COLUMN IF NOT EXISTS prepared_by_user_id UUID NULL,
|
||||
ADD COLUMN IF NOT EXISTS checked_by_user_id UUID NULL,
|
||||
ADD COLUMN IF NOT EXISTS max_wagons INT NOT NULL DEFAULT 53;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_set_wagons
|
||||
ADD COLUMN IF NOT EXISTS physical_wagon_id UUID NULL,
|
||||
ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'PLANNED';
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_booking_allocations
|
||||
ADD COLUMN IF NOT EXISTS load_type VARCHAR(20) NULL,
|
||||
ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'PLANNED',
|
||||
ADD COLUMN IF NOT EXISTS confirmed_at TIMESTAMPTZ NULL,
|
||||
ADD COLUMN IF NOT EXISTS confirmed_by_user_id UUID NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_types
|
||||
ADD COLUMN IF NOT EXISTS equated_length_m NUMERIC(10,3) NULL,
|
||||
ADD COLUMN IF NOT EXISTS tare_weight_tons NUMERIC(10,3) NULL,
|
||||
ADD COLUMN IF NOT EXISTS supports_container BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS max_container_gross_t NUMERIC(10,3) NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons
|
||||
ADD COLUMN IF NOT EXISTS train_set_wagon_id UUID NULL,
|
||||
ADD COLUMN IF NOT EXISTS current_train_schedule_id UUID NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.containers
|
||||
ADD COLUMN IF NOT EXISTS booking_id UUID NULL,
|
||||
ADD COLUMN IF NOT EXISTS wagon_booking_allocation_id UUID NULL,
|
||||
ADD COLUMN IF NOT EXISTS booking_container_id UUID NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargoes
|
||||
ADD COLUMN IF NOT EXISTS wagon_booking_allocation_id UUID NULL,
|
||||
ADD COLUMN IF NOT EXISTS booking_id UUID NULL,
|
||||
ADD COLUMN IF NOT EXISTS load_type VARCHAR(20) NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargoes
|
||||
ALTER COLUMN container_id DROP NOT NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.wagon_allocation_container_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
wagon_booking_allocation_id UUID NOT NULL,
|
||||
booking_container_id UUID NULL,
|
||||
container_id UUID NULL,
|
||||
container_number VARCHAR(64) NULL,
|
||||
container_type_id UUID NOT NULL,
|
||||
position_on_wagon SMALLINT NULL,
|
||||
seal_number VARCHAR(64) NULL,
|
||||
chassis_number VARCHAR(64) NULL,
|
||||
gross_weight_tons NUMERIC(10,3) NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT fk_waci_allocation FOREIGN KEY (wagon_booking_allocation_id)
|
||||
REFERENCES freight.wagon_booking_allocations(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_waci_booking_container FOREIGN KEY (booking_container_id)
|
||||
REFERENCES freight.booking_container(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_waci_container FOREIGN KEY (container_id)
|
||||
REFERENCES freight.containers(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_waci_container_type FOREIGN KEY (container_type_id)
|
||||
REFERENCES freight.container_types(id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.wagon_allocation_bulk_loads (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
wagon_booking_allocation_id UUID NOT NULL UNIQUE,
|
||||
booking_id UUID NOT NULL,
|
||||
cargo_type_id UUID NULL,
|
||||
cargo_description TEXT NULL,
|
||||
pricing_unit VARCHAR(20) NOT NULL DEFAULT 'PER_TON',
|
||||
quantity NUMERIC(12,3) NOT NULL DEFAULT 0,
|
||||
weight_tons NUMERIC(10,3) NOT NULL DEFAULT 0,
|
||||
truck_plate_number VARCHAR(32) NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT fk_wabl_allocation FOREIGN KEY (wagon_booking_allocation_id)
|
||||
REFERENCES freight.wagon_booking_allocations(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_wabl_booking FOREIGN KEY (booking_id)
|
||||
REFERENCES freight.bookings(id),
|
||||
CONSTRAINT fk_wabl_cargo_type FOREIGN KEY (cargo_type_id)
|
||||
REFERENCES freight.cargo_types(id) ON DELETE SET NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_scheduling_status
|
||||
ON freight.bookings(scheduling_status);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_schedules_train_number
|
||||
ON freight.train_schedules(train_number);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_set_wagons_physical_wagon
|
||||
ON freight.train_set_wagons(physical_wagon_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wagons_train_set_wagon_id
|
||||
ON freight.wagons(train_set_wagon_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wagons_current_train_schedule_id
|
||||
ON freight.wagons(current_train_schedule_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_waci_allocation
|
||||
ON freight.wagon_allocation_container_items(wagon_booking_allocation_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wabl_booking
|
||||
ON freight.wagon_allocation_bulk_loads(booking_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.train_set_wagons
|
||||
ADD CONSTRAINT fk_train_set_wagons_physical_wagon
|
||||
FOREIGN KEY (physical_wagon_id) REFERENCES freight.wagons(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.wagons
|
||||
ADD CONSTRAINT fk_wagons_train_set_wagon
|
||||
FOREIGN KEY (train_set_wagon_id) REFERENCES freight.train_set_wagons(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.wagons
|
||||
ADD CONSTRAINT fk_wagons_current_train_schedule
|
||||
FOREIGN KEY (current_train_schedule_id) REFERENCES freight.train_schedules(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.containers
|
||||
ADD CONSTRAINT fk_containers_booking
|
||||
FOREIGN KEY (booking_id) REFERENCES freight.bookings(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.containers
|
||||
ADD CONSTRAINT fk_containers_wagon_allocation
|
||||
FOREIGN KEY (wagon_booking_allocation_id) REFERENCES freight.wagon_booking_allocations(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.containers
|
||||
ADD CONSTRAINT fk_containers_booking_container
|
||||
FOREIGN KEY (booking_container_id) REFERENCES freight.booking_container(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.cargoes
|
||||
ADD CONSTRAINT fk_cargoes_wagon_allocation
|
||||
FOREIGN KEY (wagon_booking_allocation_id) REFERENCES freight.wagon_booking_allocations(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.cargoes
|
||||
ADD CONSTRAINT fk_cargoes_booking
|
||||
FOREIGN KEY (booking_id) REFERENCES freight.bookings(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.wagon_types SET
|
||||
equated_length_m = 1.3,
|
||||
tare_weight_tons = 22.4,
|
||||
supports_container = true,
|
||||
max_container_gross_t = 30.48
|
||||
WHERE code = 'NW5';
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.wagon_types SET
|
||||
equated_length_m = 1.6,
|
||||
tare_weight_tons = 25.2,
|
||||
supports_container = false
|
||||
WHERE code = 'PW2';
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.wagon_types SET
|
||||
equated_length_m = 1.5,
|
||||
tare_weight_tons = 25.2,
|
||||
supports_container = false
|
||||
WHERE code = 'KW2';
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.wagon_types SET
|
||||
equated_length_m = 1.3,
|
||||
tare_weight_tons = 23.4,
|
||||
supports_container = false
|
||||
WHERE code = 'CW3';
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.wagon_types SET
|
||||
equated_length_m = 1.3,
|
||||
tare_weight_tons = 24.8,
|
||||
supports_container = false
|
||||
WHERE code = 'CW4';
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_allocation_bulk_loads;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_allocation_container_items;`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargoes
|
||||
ALTER COLUMN container_id SET NOT NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS wagons_required,
|
||||
DROP COLUMN IF EXISTS scheduling_status,
|
||||
DROP COLUMN IF EXISTS hold_started_at,
|
||||
DROP COLUMN IF EXISTS hold_expires_at,
|
||||
DROP COLUMN IF EXISTS scheduled_at;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP COLUMN IF EXISTS train_number,
|
||||
DROP COLUMN IF EXISTS direction,
|
||||
DROP COLUMN IF EXISTS actual_departure_at,
|
||||
DROP COLUMN IF EXISTS actual_arrival_at,
|
||||
DROP COLUMN IF EXISTS prepared_by_user_id,
|
||||
DROP COLUMN IF EXISTS checked_by_user_id,
|
||||
DROP COLUMN IF EXISTS max_wagons;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_set_wagons
|
||||
DROP COLUMN IF EXISTS physical_wagon_id,
|
||||
DROP COLUMN IF EXISTS status;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_booking_allocations
|
||||
DROP COLUMN IF EXISTS load_type,
|
||||
DROP COLUMN IF EXISTS status,
|
||||
DROP COLUMN IF EXISTS confirmed_at,
|
||||
DROP COLUMN IF EXISTS confirmed_by_user_id;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_types
|
||||
DROP COLUMN IF EXISTS equated_length_m,
|
||||
DROP COLUMN IF EXISTS tare_weight_tons,
|
||||
DROP COLUMN IF EXISTS supports_container,
|
||||
DROP COLUMN IF EXISTS max_container_gross_t;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons
|
||||
DROP COLUMN IF EXISTS train_set_wagon_id,
|
||||
DROP COLUMN IF EXISTS current_train_schedule_id;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.containers
|
||||
DROP COLUMN IF EXISTS booking_id,
|
||||
DROP COLUMN IF EXISTS wagon_booking_allocation_id,
|
||||
DROP COLUMN IF EXISTS booking_container_id;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargoes
|
||||
DROP COLUMN IF EXISTS wagon_booking_allocation_id,
|
||||
DROP COLUMN IF EXISTS booking_id,
|
||||
DROP COLUMN IF EXISTS load_type;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddWagonReadiness1750500000000 implements MigrationInterface {
|
||||
name = 'AddWagonReadiness1750500000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons
|
||||
ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wagons_readiness
|
||||
ON freight.wagons (readiness)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_readiness`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons
|
||||
DROP COLUMN IF EXISTS readiness
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddGovernmentBookingFields1750600000000 implements MigrationInterface {
|
||||
name = 'AddGovernmentBookingFields1750600000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS is_government BOOLEAN NOT NULL DEFAULT false
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS government_institution VARCHAR(255) NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ALTER COLUMN company_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_is_government
|
||||
ON freight.bookings (is_government)
|
||||
WHERE is_government = true AND deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_is_government`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings
|
||||
SET company_id = '00000000-0000-0000-0000-000000000000'
|
||||
WHERE company_id IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ALTER COLUMN company_id SET NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS government_institution
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS is_government
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateSchedulingEvents1750700000000 implements MigrationInterface {
|
||||
name = 'CreateSchedulingEvents1750700000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.scheduling_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
train_schedule_id UUID NOT NULL,
|
||||
trigger VARCHAR(40) NOT NULL,
|
||||
actor_user_id UUID NULL,
|
||||
reason TEXT NULL,
|
||||
plan_snapshot JSONB NOT NULL DEFAULT '{}',
|
||||
displaced_booking_ids JSONB NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduling_events_train_schedule_id
|
||||
ON freight.scheduling_events (train_schedule_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_scheduling_events_train_schedule_id`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.scheduling_events`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/** 20ft = 0.5 wagon slots (2 per wagon); 40ft = 1.0 wagon slot (1 per wagon). */
|
||||
export class FixContainerWagonsPerUnit1750800000000 implements MigrationInterface {
|
||||
name = 'FixContainerWagonsPerUnit1750800000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const hasContainerTypes = await queryRunner.hasTable('freight.container_types');
|
||||
if (!hasContainerTypes) {
|
||||
return;
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.container_types
|
||||
SET wagons_per_unit = 0.50
|
||||
WHERE size_ft = 20 OR code LIKE '20%';
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.container_types
|
||||
SET wagons_per_unit = 1.00
|
||||
WHERE size_ft = 40 OR code LIKE '40%';
|
||||
`);
|
||||
|
||||
const hasBookingContainer = await queryRunner.hasTable('freight.booking_container');
|
||||
if (!hasBookingContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.booking_container bc
|
||||
SET wagons_required = CEILING(bc.quantity * ct.wagons_per_unit)
|
||||
FROM freight.container_types ct
|
||||
WHERE ct.id = bc.container_type_id;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const hasContainerTypes = await queryRunner.hasTable('freight.container_types');
|
||||
if (!hasContainerTypes) {
|
||||
return;
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.container_types SET wagons_per_unit = 1.00;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddContainerNumberToBookingContainer1750900000000 implements MigrationInterface {
|
||||
name = "AddContainerNumberToBookingContainer1750900000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_container
|
||||
ALTER COLUMN container_type_id DROP NOT NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_container
|
||||
ADD COLUMN container_number varchar(64);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_allocation_container_items
|
||||
ALTER COLUMN container_type_id DROP NOT NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_allocation_container_items
|
||||
ALTER COLUMN container_type_id SET NOT NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_container
|
||||
DROP COLUMN container_number;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_container
|
||||
ALTER COLUMN container_type_id SET NOT NULL;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class CreateTrainSchedulingGlobalRules1751000000000 implements MigrationInterface {
|
||||
name = "CreateTrainSchedulingGlobalRules1751000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.train_scheduling_global_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
max_train_length_meters numeric(10, 2) NOT NULL DEFAULT 760,
|
||||
max_train_weight_tons numeric(10, 3) NOT NULL DEFAULT 3500,
|
||||
max_wagons_per_train integer NOT NULL DEFAULT 53,
|
||||
max_20ft_container_weight_tons numeric(8, 3) NOT NULL DEFAULT 30,
|
||||
max_20ft_pair_weight_diff_tons numeric(8, 3) NOT NULL DEFAULT 10,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.train_scheduling_global_rules (
|
||||
max_train_length_meters,
|
||||
max_train_weight_tons,
|
||||
max_wagons_per_train,
|
||||
max_20ft_container_weight_tons,
|
||||
max_20ft_pair_weight_diff_tons
|
||||
) VALUES (760, 3500, 53, 30, 10);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_scheduling_global_rules;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddDeletedAtToTrainSchedulingGlobalRules1751000000001
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddDeletedAtToTrainSchedulingGlobalRules1751000000001";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
ADD COLUMN IF NOT EXISTS deleted_at timestamptz NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
DROP COLUMN IF EXISTS deleted_at;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddLocomotiveReadiness1781000000000 implements MigrationInterface {
|
||||
name = 'AddLocomotiveReadiness1781000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.locomotives
|
||||
ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_locomotives_readiness
|
||||
ON freight.locomotives (readiness)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_locomotives_readiness`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.locomotives
|
||||
DROP COLUMN IF EXISTS readiness
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateTrainCheckpointEvents1781000000001 implements MigrationInterface {
|
||||
name = 'CreateTrainCheckpointEvents1781000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.train_checkpoint_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
train_schedule_id UUID NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
|
||||
yard_id UUID NOT NULL,
|
||||
sequence_no INT NOT NULL,
|
||||
kind VARCHAR(20) NOT NULL,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
note TEXT NULL,
|
||||
recorded_by_user_id UUID NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_checkpoint_events_schedule
|
||||
ON freight.train_checkpoint_events (train_schedule_id, sequence_no)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.idx_train_checkpoint_events_schedule`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_checkpoint_events`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBatchBookingFields1781000000002 implements MigrationInterface {
|
||||
name = 'AddBatchBookingFields1781000000002';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Booking → target schedule (pool membership) + 1h pay-window deadline.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS train_schedule_id UUID NULL,
|
||||
ADD COLUMN IF NOT EXISTS payment_deadline TIMESTAMPTZ NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_train_schedule_id
|
||||
ON freight.bookings (train_schedule_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
// TrainSchedule → booking-window status (OPEN/FULL/CLOSED).
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS booking_window_status VARCHAR(10) NOT NULL DEFAULT 'OPEN'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_schedules_booking_window_status
|
||||
ON freight.train_schedules (booking_window_status)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.idx_train_schedules_booking_window_status`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS booking_window_status`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_train_schedule_id`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS train_schedule_id,
|
||||
DROP COLUMN IF EXISTS payment_deadline
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddSelectedForBatchStatus1781000000003 implements MigrationInterface {
|
||||
name = 'AddSelectedForBatchStatus1781000000003';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS selected_for_batch_at TIMESTAMPTZ NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings
|
||||
SET
|
||||
status = 'SELECTED_FOR_BATCH',
|
||||
selected_for_batch_at = COALESCE(
|
||||
payment_deadline - INTERVAL '5 minutes',
|
||||
updated_at
|
||||
)
|
||||
WHERE status = 'AWAITING_PAYMENT'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings
|
||||
SET status = 'AWAITING_PAYMENT'
|
||||
WHERE status = 'SELECTED_FOR_BATCH'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS selected_for_batch_at
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Allow DOMESTIC trade direction on weight_limit_rules (domestic corridor bookings).
|
||||
*/
|
||||
export class AddDomesticWeightLimitTradeDirection1781000000004
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddDomesticWeightLimitTradeDirection1781000000004';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TYPE freight.weight_limit_rules_trade_direction_enum ADD VALUE 'DOMESTIC';
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
WHEN undefined_object THEN
|
||||
BEGIN
|
||||
ALTER TYPE weight_limit_rules_trade_direction_enum ADD VALUE 'DOMESTIC';
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// PostgreSQL does not support removing enum values safely.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||
|
||||
export class CreateTrainCompositionRemovalLog1781000000005 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'train_composition_removal_logs',
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'uuid',
|
||||
isPrimary: true,
|
||||
default: 'uuid_generate_v4()',
|
||||
},
|
||||
{
|
||||
name: 'schedule_id',
|
||||
type: 'uuid',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'booking_id',
|
||||
type: 'uuid',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'booking_reference',
|
||||
type: 'varchar',
|
||||
length: '64',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'removed_by_user_id',
|
||||
type: 'uuid',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'removed_at',
|
||||
type: 'timestamptz',
|
||||
default: 'NOW()',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
type: 'text',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'created_at',
|
||||
type: 'timestamptz',
|
||||
default: 'NOW()',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'updated_at',
|
||||
type: 'timestamptz',
|
||||
default: 'NOW()',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'deleted_at',
|
||||
type: 'timestamptz',
|
||||
isNullable: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'freight.train_composition_removal_logs',
|
||||
new TableIndex({
|
||||
columnNames: ['schedule_id'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.train_composition_removal_logs', true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class WagonLocomotiveYardLink1782000000000 implements MigrationInterface {
|
||||
name = 'WagonLocomotiveYardLink1782000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons
|
||||
ADD COLUMN IF NOT EXISTS "current_yard_id" UUID NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_wagon_current_yard'
|
||||
) THEN
|
||||
ALTER TABLE freight.wagons
|
||||
ADD CONSTRAINT "FK_wagon_current_yard"
|
||||
FOREIGN KEY ("current_yard_id") REFERENCES freight.yards(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_wagon_current_yard_id"
|
||||
ON freight.wagons ("current_yard_id");
|
||||
`);
|
||||
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_readiness`);
|
||||
await queryRunner.query(`ALTER TABLE freight.wagons DROP COLUMN IF EXISTS readiness;`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.locomotives
|
||||
ADD COLUMN IF NOT EXISTS "current_yard_id" UUID NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_locomotive_current_yard'
|
||||
) THEN
|
||||
ALTER TABLE freight.locomotives
|
||||
ADD CONSTRAINT "FK_locomotive_current_yard"
|
||||
FOREIGN KEY ("current_yard_id") REFERENCES freight.yards(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_locomotive_current_yard_id"
|
||||
ON freight.locomotives ("current_yard_id");
|
||||
`);
|
||||
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_locomotives_readiness`);
|
||||
await queryRunner.query(`ALTER TABLE freight.locomotives DROP COLUMN IF EXISTS readiness;`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons
|
||||
ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY';
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.locomotives
|
||||
ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY';
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wagons_readiness
|
||||
ON freight.wagons (readiness)
|
||||
WHERE deleted_at IS NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_locomotives_readiness
|
||||
ON freight.locomotives (readiness)
|
||||
WHERE deleted_at IS NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_wagon_current_yard_id"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_locomotive_current_yard_id"`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons DROP CONSTRAINT IF EXISTS "FK_wagon_current_yard";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.locomotives DROP CONSTRAINT IF EXISTS "FK_locomotive_current_yard";
|
||||
`);
|
||||
await queryRunner.query(`ALTER TABLE freight.wagons DROP COLUMN IF EXISTS "current_yard_id";`);
|
||||
await queryRunner.query(`ALTER TABLE freight.locomotives DROP COLUMN IF EXISTS "current_yard_id";`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddPaymentWebhookEventAndRefund1782000000001 implements MigrationInterface {
|
||||
name = "AddPaymentWebhookEventAndRefund1782000000001";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Enum for webhook provider — shares the same values as payments_method_enum
|
||||
// but is a separate type so both tables remain independently evolvable.
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE freight.payment_webhook_method_enum AS ENUM ('telebirr', 'cbe-birr', 'ebirr');
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.payment_webhook_events (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
provider freight.payment_webhook_method_enum NOT NULL,
|
||||
external_event_id varchar(255) NOT NULL,
|
||||
merchant_order_id varchar(255),
|
||||
provider_txn_id varchar(255),
|
||||
signature_valid boolean NOT NULL,
|
||||
status varchar(100) NOT NULL,
|
||||
payload jsonb NOT NULL,
|
||||
received_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
processed_at TIMESTAMP,
|
||||
processing_error text,
|
||||
|
||||
CONSTRAINT PK_payment_webhook_events PRIMARY KEY (id),
|
||||
CONSTRAINT UQ_payment_webhook_events_provider_event UNIQUE (provider, external_event_id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IDX_payment_webhook_events_merchant_order_id
|
||||
ON freight.payment_webhook_events (merchant_order_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.payment_refunds (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
payment_id uuid NOT NULL,
|
||||
amount_minor int NOT NULL,
|
||||
reason varchar(255),
|
||||
provider_refund_id varchar(255),
|
||||
status varchar(50) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT PK_payment_refunds PRIMARY KEY (id),
|
||||
CONSTRAINT FK_payment_refunds_payment
|
||||
FOREIGN KEY (payment_id)
|
||||
REFERENCES freight.payments (id)
|
||||
ON DELETE RESTRICT
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.payment_refunds;`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.IDX_payment_webhook_events_merchant_order_id;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.payment_webhook_events;`);
|
||||
await queryRunner.query(`DROP TYPE IF EXISTS freight.payment_webhook_method_enum;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class ExtendPaymentMethodEnum1782000000002 implements MigrationInterface {
|
||||
name = "ExtendPaymentMethodEnum1782000000002";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'waafi';`);
|
||||
await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'card';`);
|
||||
await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'dmoney';`);
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// PostgreSQL does not support removing enum values directly.
|
||||
// To roll back, recreate the type without the added values and update the column.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ReplacePriorityRulesWithPriorityConfigs1783000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.priority_configs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
type VARCHAR(20) NOT NULL CHECK (type IN ('WAGON', 'CURRENCY')),
|
||||
label VARCHAR(100) NOT NULL,
|
||||
currency VARCHAR(5) NULL,
|
||||
min_wagon_count INT NOT NULL,
|
||||
max_wagon_count INT NOT NULL,
|
||||
score_points INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT false,
|
||||
display_order INT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT chk_wagon_range CHECK (min_wagon_count <= max_wagon_count),
|
||||
CONSTRAINT chk_currency_for_type CHECK (
|
||||
(type = 'WAGON' AND currency IS NULL) OR
|
||||
(type = 'CURRENCY' AND currency IS NOT NULL)
|
||||
)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX idx_priority_configs_type_active ON freight.priority_configs (type, is_active);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX idx_priority_configs_currency_type ON freight.priority_configs (currency, type);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.priority_configs;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateSavedSignatures1784000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.saved_signatures (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL,
|
||||
signer_display_name VARCHAR(200) NOT NULL,
|
||||
signature_file_id UUID NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT uq_saved_signatures_user_id UNIQUE (user_id)
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.saved_signatures;`);
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { BackofficeService } from "./backoffice.service";
|
||||
import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto";
|
||||
import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto";
|
||||
|
||||
@ApiTags("backoffice")
|
||||
@Controller("backoffice")
|
||||
@FreightAdmin()
|
||||
export class BackofficeController {
|
||||
constructor(private readonly backofficeService: BackofficeService) {}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { BillingService } from "./billing.service";
|
||||
|
||||
@ApiTags("billing")
|
||||
@Controller("billing")
|
||||
@FreightAdmin()
|
||||
export class BillingController {
|
||||
constructor(private readonly billingService: BillingService) {}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Readable } from 'stream';
|
||||
@@ -19,9 +22,13 @@ import { assertBookingStatus } from './booking-status.util';
|
||||
import { ContractViewDto } from './dto/contract-view.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
|
||||
@Injectable()
|
||||
export class BookingContractService {
|
||||
private readonly logger = new Logger(BookingContractService.name);
|
||||
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly filesService: FilesService,
|
||||
@@ -30,6 +37,9 @@ export class BookingContractService {
|
||||
private readonly viewModelBuilder: ContractViewModelBuilder,
|
||||
private readonly renderer: ContractRendererService,
|
||||
private readonly pdfService: ContractPdfService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly signaturesService: SignaturesService,
|
||||
) {}
|
||||
|
||||
buildContractSummary(booking: Booking): string {
|
||||
@@ -67,10 +77,16 @@ export class BookingContractService {
|
||||
return { summary };
|
||||
}
|
||||
|
||||
async getContractView(bookingId: string): Promise<ContractViewDto> {
|
||||
async getContractView(
|
||||
bookingId: string,
|
||||
viewerUserId?: string,
|
||||
): Promise<ContractViewDto> {
|
||||
const { view } = await this.viewModelBuilder.build(bookingId);
|
||||
await this.inlineSignatureImages(view.signatures);
|
||||
const html = this.renderer.render(view);
|
||||
const savedSignature = viewerUserId
|
||||
? ((await this.signaturesService.getForUser(viewerUserId)) ?? undefined)
|
||||
: undefined;
|
||||
return {
|
||||
bookingId: view.bookingId,
|
||||
reference: view.reference,
|
||||
@@ -82,6 +98,7 @@ export class BookingContractService {
|
||||
canSignStaff: view.canSignStaff,
|
||||
hasContractDocument: view.hasContractDocument,
|
||||
signatures: view.signatures,
|
||||
savedSignature,
|
||||
pricingSchedule: view.pricing as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
@@ -92,7 +109,16 @@ export class BookingContractService {
|
||||
|
||||
const templateKey = this.templateResolver.resolve(booking);
|
||||
const summary = this.buildContractSummary(booking);
|
||||
|
||||
// PDF rendering (Puppeteer/Chromium) is best-effort and must NOT block the contract
|
||||
// from becoming ready — the document is (re)rendered lazily on view/download.
|
||||
try {
|
||||
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download once Chromium is available.`,
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
@@ -177,6 +203,23 @@ export class BookingContractService {
|
||||
ipAddress: options.ipAddress ?? null,
|
||||
});
|
||||
|
||||
// Persist the just-used signature to the signer's reusable profile so they
|
||||
// don't have to redraw it on the next contract. Best-effort: a failure here
|
||||
// must never block contract execution.
|
||||
if (options.signerUserId) {
|
||||
try {
|
||||
await this.signaturesService.upsertForUser({
|
||||
userId: options.signerUserId,
|
||||
signerDisplayName: dto.signerDisplayName,
|
||||
signatureImageBase64: dto.signatureImageBase64,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Could not save reusable signature for user ${options.signerUserId}: ${err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
if (role === 'CUSTOMER') {
|
||||
@@ -191,11 +234,20 @@ export class BookingContractService {
|
||||
}
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, updates as never);
|
||||
if (role === 'STAFF' && updated?.trainScheduleId) {
|
||||
this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId);
|
||||
}
|
||||
try {
|
||||
await this.upsertContractPdf(
|
||||
bookingId,
|
||||
booking.reference,
|
||||
booking.contractTemplateKey ?? this.templateResolver.resolve(booking),
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Signed-contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`,
|
||||
);
|
||||
}
|
||||
return updated!;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
|
||||
statuses: readonly string[] | null;
|
||||
}> = [
|
||||
{ key: 'all', statuses: null },
|
||||
{ key: 'intake', statuses: ['SUBMITTED'] },
|
||||
{ key: 'intake', statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'] },
|
||||
{
|
||||
key: 'in_approval',
|
||||
statuses: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
|
||||
@@ -28,7 +28,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
|
||||
{ key: 'payment', statuses: ['FULLY_EXECUTED'] },
|
||||
{
|
||||
key: 'operations',
|
||||
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED','PAID'],
|
||||
statuses: ['IN_TRANSIT', 'PAID'],
|
||||
},
|
||||
{ key: 'completed', statuses: ['COMPLETED'] },
|
||||
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },
|
||||
|
||||
@@ -14,6 +14,11 @@ export function computeNextStep(
|
||||
const { status } = booking;
|
||||
|
||||
switch (status) {
|
||||
case 'PRICE_CHANGED_PENDING_CONFIRM':
|
||||
return {
|
||||
action: 'CONFIRM_SUBMIT',
|
||||
description: 'Price has changed since preview; confirm to submit booking',
|
||||
};
|
||||
case 'SUBMITTED':
|
||||
return {
|
||||
action: 'ACCEPT_INTAKE',
|
||||
|
||||
@@ -4,56 +4,49 @@ import { Booking } from './entities/booking.entity';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
||||
import { PaymentService } from '../payment/payment.service';
|
||||
|
||||
import { PaymentStatus } from '../payment/entities/payment.entity';
|
||||
import { PaymentMethodTypeEnum } from '../payment/payments.dto';
|
||||
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
|
||||
|
||||
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
|
||||
"action-required",
|
||||
"processing",
|
||||
"success",
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class BookingPaymentService {
|
||||
constructor(private readonly bookingsRepository: BookingsRepository, private readonly paymentService: PaymentService) { }
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly paymentService: PaymentService,
|
||||
) { }
|
||||
|
||||
async pay(
|
||||
bookingId: string,
|
||||
): Promise<{ redirectUrl: string }> {
|
||||
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED']);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
|
||||
|
||||
// const receipt = this.buildMockReceipt(booking);
|
||||
const existing = await this.paymentService.findBookingById(bookingId);
|
||||
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
||||
if (existing.clientAction) {
|
||||
const action = existing.clientAction as { type?: string; url?: string };
|
||||
if (action.type === "REDIRECT" && action.url) {
|
||||
return { redirectUrl: action.url };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// const updated = await this.bookingsRepository.update(bookingId, {
|
||||
// status: 'PAID',
|
||||
// paymentStatus: 'PAID',
|
||||
// } as never);
|
||||
const resp = await this.paymentService.pay(booking.totalAmount, "ETB", "telebirr", "payment for booking", 'booking', (_) => {
|
||||
return new Promise((resp, _) => {
|
||||
resp({
|
||||
id: booking.id,
|
||||
type: "booking"
|
||||
})
|
||||
const resp = await this.paymentService.initiatePayment({
|
||||
bookingId,
|
||||
method: PaymentMethodTypeEnum.TELEBIRR,
|
||||
platform: "web",
|
||||
});
|
||||
})
|
||||
|
||||
const action = resp.clientAction as { type?: string; url?: string } | undefined;
|
||||
return {
|
||||
redirectUrl: resp.clientAction.type == "REDIRECT" ? `http://localhost:3001/api/payments/telebirr/${booking.id}` : ""
|
||||
redirectUrl: action?.type === "REDIRECT" ? (action.url ?? "") : "",
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// private buildMockReceipt(booking: Booking): InAppPaymentReceipt {
|
||||
// const timestamp = Date.now();
|
||||
// const isEtb = booking.paymentCurrency === 'ETB';
|
||||
// const prefix = isEtb ? 'TB' : 'CARD';
|
||||
// const provider = isEtb ? 'TELEBIRR' : 'CARD';
|
||||
|
||||
// return {
|
||||
// success: true,
|
||||
// provider,
|
||||
// providerRef: `${prefix}-${booking.reference}-${timestamp}`,
|
||||
// amount: booking.totalAmount,
|
||||
// currency: booking.paymentCurrency,
|
||||
// paidAt: new Date().toISOString(),
|
||||
// };
|
||||
// }
|
||||
|
||||
private async requireBooking(id: string): Promise<Booking> {
|
||||
const booking = await this.bookingsRepository.findById(id);
|
||||
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import type { Booking } from './entities/booking.entity';
|
||||
import type { Rate } from '../rule-engine/entities/rate.entity';
|
||||
|
||||
const MOCK_CBE_RATE = 130;
|
||||
|
||||
describe('BookingPricingService — domestic corridor', () => {
|
||||
const intercityBulkUsd: Rate = {
|
||||
id: 'rate-intercity-bulk-usd',
|
||||
rateType: 'INTERCITY_BULK',
|
||||
currency: 'USD',
|
||||
rateValue: 35,
|
||||
rateUnit: 'PER_TON',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
} as Rate;
|
||||
|
||||
const intercityContainerUsd: Rate = {
|
||||
id: 'rate-intercity-container-usd',
|
||||
rateType: 'INTERCITY_CONTAINER',
|
||||
currency: 'USD',
|
||||
rateValue: 400,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
} as Rate;
|
||||
|
||||
let service: BookingPricingService;
|
||||
let bookingsRepository: { calculateWagonCount: jest.Mock };
|
||||
let ratesService: { findLiveRates: jest.Mock };
|
||||
let cbeExchangeService: { getUsdToEtbRate: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) };
|
||||
ratesService = {
|
||||
findLiveRates: jest.fn().mockResolvedValue([intercityBulkUsd, intercityContainerUsd]),
|
||||
};
|
||||
cbeExchangeService = {
|
||||
getUsdToEtbRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
|
||||
};
|
||||
|
||||
service = new BookingPricingService(
|
||||
bookingsRepository as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
ratesService as never,
|
||||
{} as never,
|
||||
cbeExchangeService as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('prices domestic bulk in ETB using INTERCITY_BULK USD rate × CBE exchange rate', async () => {
|
||||
const booking = {
|
||||
id: 'b-1',
|
||||
freightType: 'BULK',
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'ETB',
|
||||
cargoTotalWeightVgm: 120,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: { containers: [] },
|
||||
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||||
|
||||
expect(result.lineItems).toHaveLength(1);
|
||||
expect(result.lineItems[0].code).toBe('INTERCITY_BULK');
|
||||
expect(result.lineItems[0].currency).toBe('ETB');
|
||||
expect(result.lineItems[0].amount).toBe(Math.round(35 * 120 * MOCK_CBE_RATE));
|
||||
});
|
||||
|
||||
it('prices domestic bulk in USD using INTERCITY_BULK USD rate directly', async () => {
|
||||
const booking = {
|
||||
id: 'b-1-usd',
|
||||
freightType: 'BULK',
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'USD',
|
||||
cargoTotalWeightVgm: 120,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: { containers: [] },
|
||||
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||||
|
||||
expect(result.lineItems).toHaveLength(1);
|
||||
expect(result.lineItems[0].code).toBe('INTERCITY_BULK');
|
||||
expect(result.lineItems[0].currency).toBe('USD');
|
||||
expect(result.lineItems[0].amount).toBe(35 * 120);
|
||||
});
|
||||
|
||||
it('prices domestic container in ETB using INTERCITY_CONTAINER USD fallback × CBE rate', async () => {
|
||||
const booking = {
|
||||
id: 'b-2',
|
||||
freightType: 'CONTAINER',
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'ETB',
|
||||
cargoTotalWeightVgm: 50,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: {
|
||||
containers: Array<{ containerTypeId: string; quantity: number }>;
|
||||
},
|
||||
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, {
|
||||
containers: [{ containerTypeId: 'ct-20', quantity: 3 }],
|
||||
});
|
||||
|
||||
expect(result.lineItems.some((l) => l.code === 'INTERCITY_CONTAINER')).toBe(true);
|
||||
const line = result.lineItems.find((l) => l.code === 'INTERCITY_CONTAINER')!;
|
||||
expect(line.currency).toBe('ETB');
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { ContainerTypesService } from '../rule-engine/services/container-types.s
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { ServiceTypesService } from '../rule-engine/services/service-types.service';
|
||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
|
||||
import {
|
||||
AppliedCargoModifier,
|
||||
BookingEvaluationInput,
|
||||
@@ -14,6 +15,24 @@ import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
|
||||
export interface ComputedPriceResult {
|
||||
lineItems: PriceLineItemDto[];
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
usedRates: Rate[];
|
||||
appliedModifiers: AppliedCargoModifier[];
|
||||
priorityScore: number;
|
||||
warnings: string[];
|
||||
hardBlocked: string[];
|
||||
}
|
||||
|
||||
type StoredPricingBreakdown = {
|
||||
lineItems?: PriceLineItemDto[];
|
||||
totalAmount?: number;
|
||||
currency?: string;
|
||||
generatedAt?: string;
|
||||
} | null;
|
||||
|
||||
@Injectable()
|
||||
export class BookingPricingService {
|
||||
constructor(
|
||||
@@ -22,62 +41,135 @@ export class BookingPricingService {
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly ratesService: RatesService,
|
||||
private readonly serviceTypesService: ServiceTypesService,
|
||||
private readonly cbeExchangeService: CbeExchangeService,
|
||||
) {}
|
||||
|
||||
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
assertBookingStatus(booking, ['DRAFT']);
|
||||
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
|
||||
|
||||
const evalInput = await this.buildEvalInputForBooking(booking);
|
||||
console.log('evalInput----', evalInput);
|
||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||
this.ruleEngineService.assertNoHardBlocks(ruleResult);
|
||||
|
||||
const lineItems: PriceLineItemDto[] = [];
|
||||
let total = 0;
|
||||
|
||||
const baseLines = await this.computeBaseRailLines(booking, evalInput);
|
||||
for (const line of baseLines) {
|
||||
lineItems.push(line);
|
||||
total += line.amount;
|
||||
}
|
||||
|
||||
for (const mod of ruleResult.appliedModifiers) {
|
||||
const item: PriceLineItemDto = {
|
||||
code: mod.surchargeTypeCode,
|
||||
description: `Surcharge: ${mod.surchargeTypeCode}`,
|
||||
amount: mod.calculatedAmount,
|
||||
currency: mod.currency,
|
||||
};
|
||||
lineItems.push(item);
|
||||
total += mod.calculatedAmount;
|
||||
}
|
||||
|
||||
await this.persistPriceRun(bookingId, ruleResult.appliedModifiers, total);
|
||||
const computed = await this.computePriceForBooking(booking);
|
||||
this.ruleEngineService.assertNoHardBlocks({
|
||||
priorityScore: computed.priorityScore,
|
||||
appliedModifiers: computed.appliedModifiers,
|
||||
containerWeightResults: [],
|
||||
warnings: computed.warnings,
|
||||
hardBlocked: computed.hardBlocked,
|
||||
requiresDirectorApproval: false,
|
||||
});
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
totalAmount: total,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
totalAmount: computed.totalAmount,
|
||||
priorityScore: computed.priorityScore,
|
||||
pricingBreakdown: {
|
||||
lineItems,
|
||||
totalAmount: total,
|
||||
currency: booking.paymentCurrency,
|
||||
lineItems: computed.lineItems,
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
} as never);
|
||||
|
||||
return {
|
||||
bookingId,
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
lineItems: computed.lineItems,
|
||||
warnings: computed.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
async computePriceForBooking(booking: Booking): Promise<ComputedPriceResult> {
|
||||
const evalInput = await this.buildEvalInputForBooking(booking);
|
||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1;
|
||||
|
||||
const lineItems: PriceLineItemDto[] = [];
|
||||
let total = 0;
|
||||
|
||||
const { lineItems: baseLines, usedRates: baseRates } =
|
||||
await this.computeBaseRailLinesWithRates(booking, evalInput);
|
||||
for (const line of baseLines) {
|
||||
lineItems.push(line);
|
||||
total += line.amount;
|
||||
}
|
||||
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const rateById = new Map(liveRates.map((r) => [r.id, r]));
|
||||
const usedRatesMap = new Map(baseRates.map((r) => [r.id, r]));
|
||||
|
||||
for (const mod of ruleResult.appliedModifiers) {
|
||||
const usdAmount = mod.calculatedAmount;
|
||||
const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
const item: PriceLineItemDto = {
|
||||
code: mod.surchargeTypeCode,
|
||||
description: `Surcharge: ${mod.surchargeTypeCode}`,
|
||||
amount: convertedAmount,
|
||||
currency: paymentCurrency,
|
||||
};
|
||||
lineItems.push(item);
|
||||
total += convertedAmount;
|
||||
|
||||
const rate = rateById.get(mod.rateId);
|
||||
if (rate) usedRatesMap.set(rate.id, rate);
|
||||
}
|
||||
|
||||
return {
|
||||
lineItems,
|
||||
totalAmount: total,
|
||||
currency: booking.paymentCurrency,
|
||||
lineItems,
|
||||
usedRates: [...usedRatesMap.values()],
|
||||
appliedModifiers: ruleResult.appliedModifiers,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
warnings: ruleResult.warnings,
|
||||
hardBlocked: ruleResult.hardBlocked,
|
||||
};
|
||||
}
|
||||
|
||||
pricesMatch(stored: StoredPricingBreakdown, computed: ComputedPriceResult): boolean {
|
||||
if (!stored?.lineItems?.length) return false;
|
||||
if (Number(stored.totalAmount) !== computed.totalAmount) return false;
|
||||
return (
|
||||
this.lineItemsSignature(stored.lineItems) ===
|
||||
this.lineItemsSignature(computed.lineItems)
|
||||
);
|
||||
}
|
||||
|
||||
async createPricingSnapshots(
|
||||
bookingId: string,
|
||||
usedRates: Rate[],
|
||||
appliedModifiers: AppliedCargoModifier[],
|
||||
): Promise<void> {
|
||||
await this.bookingsRepository.clearPricingArtifacts(bookingId);
|
||||
const snapshots = await this.ruleEngineService.snapshotRates(bookingId, usedRates);
|
||||
|
||||
const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id]));
|
||||
const rows = appliedModifiers
|
||||
.map((m) => {
|
||||
const snapshotId = snapshotByRateId.get(m.rateId);
|
||||
if (!snapshotId) return null;
|
||||
return {
|
||||
bookingId,
|
||||
surchargeTypeId: m.surchargeTypeId,
|
||||
triggerValue: m.triggerValue,
|
||||
calculatedAmount: m.calculatedAmount,
|
||||
rateSnapshotId: snapshotId,
|
||||
};
|
||||
})
|
||||
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.bookingsRepository.createCargoModifiers(rows);
|
||||
}
|
||||
}
|
||||
|
||||
async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
|
||||
const containers = await Promise.all(
|
||||
(booking.bookingContainers ?? []).map(async (bc) => {
|
||||
(booking.bookingContainers ?? [])
|
||||
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
|
||||
.map(async (bc) => {
|
||||
const ct = await this.containerTypesService.findById(bc.containerTypeId);
|
||||
const vgm = Number(bc.vgmPerUnitTons);
|
||||
const qty = bc.quantity;
|
||||
@@ -90,6 +182,17 @@ export class BookingPricingService {
|
||||
};
|
||||
}),
|
||||
);
|
||||
// Wagon count is persisted per container line at booking creation; sum it.
|
||||
const totalWagons =
|
||||
booking.freightType === 'CONTAINER'
|
||||
? Math.ceil(
|
||||
(booking.bookingContainers ?? []).reduce(
|
||||
(sum, bc) => sum + Number(bc.wagonsRequired ?? 0),
|
||||
0,
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId: booking.cargoTypeId ?? null,
|
||||
@@ -97,8 +200,10 @@ export class BookingPricingService {
|
||||
paymentCurrency: booking.paymentCurrency,
|
||||
tradeDirection: booking.tradeDirection,
|
||||
isHazardous: booking.isHazardous,
|
||||
isGovernment: booking.isGovernment,
|
||||
allowConsolidation: booking.allowConsolidation,
|
||||
shippingLineId: booking.shippingLineId,
|
||||
totalWagons,
|
||||
containers,
|
||||
};
|
||||
}
|
||||
@@ -115,11 +220,7 @@ export class BookingPricingService {
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
}> {
|
||||
const stored = booking.pricingBreakdown as {
|
||||
lineItems?: PriceLineItemDto[];
|
||||
totalAmount?: number;
|
||||
currency?: string;
|
||||
} | null;
|
||||
const stored = booking.pricingBreakdown as StoredPricingBreakdown;
|
||||
|
||||
if (stored?.lineItems?.length) {
|
||||
return {
|
||||
@@ -129,41 +230,28 @@ export class BookingPricingService {
|
||||
};
|
||||
}
|
||||
|
||||
const evalInput = await this.buildEvalInputForBooking(booking);
|
||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||
const lineItems: PriceLineItemDto[] = [];
|
||||
let total = 0;
|
||||
const computed = await this.computePriceForBooking(booking);
|
||||
|
||||
const baseLines = await this.computeBaseRailLines(booking, evalInput);
|
||||
for (const line of baseLines) {
|
||||
lineItems.push(line);
|
||||
total += line.amount;
|
||||
}
|
||||
|
||||
for (const mod of ruleResult.appliedModifiers) {
|
||||
lineItems.push({
|
||||
code: mod.surchargeTypeCode,
|
||||
description: `Surcharge: ${mod.surchargeTypeCode}`,
|
||||
amount: mod.calculatedAmount,
|
||||
currency: mod.currency,
|
||||
});
|
||||
total += mod.calculatedAmount;
|
||||
}
|
||||
|
||||
if (lineItems.length === 0) {
|
||||
total = Number(booking.totalAmount);
|
||||
lineItems.push({
|
||||
if (computed.lineItems.length === 0) {
|
||||
const total = Number(booking.totalAmount);
|
||||
return {
|
||||
lineItems: [
|
||||
{
|
||||
code: 'TOTAL',
|
||||
description: 'Contract total',
|
||||
amount: total,
|
||||
currency: booking.paymentCurrency,
|
||||
});
|
||||
},
|
||||
],
|
||||
totalAmount: total,
|
||||
currency: booking.paymentCurrency,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
lineItems,
|
||||
totalAmount: total || Number(booking.totalAmount),
|
||||
currency: booking.paymentCurrency,
|
||||
lineItems: computed.lineItems,
|
||||
totalAmount: computed.totalAmount || Number(booking.totalAmount),
|
||||
currency: computed.currency,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -190,14 +278,16 @@ export class BookingPricingService {
|
||||
return score;
|
||||
}
|
||||
|
||||
private async computeBaseRailLines(
|
||||
private async computeBaseRailLinesWithRates(
|
||||
booking: Booking,
|
||||
evalInput: BookingEvaluationInput,
|
||||
): Promise<PriceLineItemDto[]> {
|
||||
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const currency = booking.paymentCurrency;
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1;
|
||||
const isBulk = booking.freightType === 'BULK';
|
||||
console.log('liveRates----', liveRates);
|
||||
|
||||
const rateType =
|
||||
booking.tradeDirection === 'IMPORT'
|
||||
? isBulk
|
||||
@@ -207,45 +297,50 @@ console.log('liveRates----', liveRates);
|
||||
? isBulk
|
||||
? 'BULK_EXPORT'
|
||||
: 'CONTAINER_EXPORT'
|
||||
: isBulk
|
||||
? 'INTERCITY_BULK'
|
||||
: 'INTERCITY_CONTAINER';
|
||||
|
||||
|
||||
console.log('rateType----', rateType);
|
||||
|
||||
const lines: PriceLineItemDto[] = [];
|
||||
const usedRatesMap = new Map<string, Rate>();
|
||||
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
|
||||
|
||||
for (const container of evalInput.containers) {
|
||||
console.log('container----', container);
|
||||
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency);
|
||||
console.log('rate----', rate);
|
||||
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD');
|
||||
if (!rate) continue;
|
||||
|
||||
const amount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||
usedRatesMap.set(rate.id, rate);
|
||||
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: `Base rail (${rateType})`,
|
||||
amount,
|
||||
currency: rate.currency,
|
||||
currency: paymentCurrency,
|
||||
});
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
const fallback = liveRates.find(
|
||||
(r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE',
|
||||
(r) => r.rateType === rateType && r.currency === 'USD' && r.status === 'LIVE',
|
||||
);
|
||||
if (fallback) {
|
||||
const amount = this.amountForRate(fallback, 1, wagonCount);
|
||||
usedRatesMap.set(fallback.id, fallback);
|
||||
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const quantity =
|
||||
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
|
||||
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: `Base rail (${rateType})`,
|
||||
amount,
|
||||
currency: fallback.currency,
|
||||
currency: paymentCurrency,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
||||
}
|
||||
|
||||
private pickRate(
|
||||
@@ -281,31 +376,15 @@ console.log('liveRates----', liveRates);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistPriceRun(
|
||||
bookingId: string,
|
||||
modifiers: AppliedCargoModifier[],
|
||||
_total: number,
|
||||
): Promise<void> {
|
||||
await this.bookingsRepository.clearPricingArtifacts(bookingId);
|
||||
const snapshots = await this.ruleEngineService.snapshotLiveRates(bookingId);
|
||||
|
||||
const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id]));
|
||||
const rows = modifiers
|
||||
.map((m) => {
|
||||
const snapshotId = snapshotByRateId.get(m.rateId);
|
||||
if (!snapshotId) return null;
|
||||
return {
|
||||
bookingId,
|
||||
surchargeTypeId: m.surchargeTypeId,
|
||||
triggerValue: m.triggerValue,
|
||||
calculatedAmount: m.calculatedAmount,
|
||||
rateSnapshotId: snapshotId,
|
||||
};
|
||||
})
|
||||
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.bookingsRepository.createCargoModifiers(rows);
|
||||
}
|
||||
private lineItemsSignature(items: PriceLineItemDto[]): string {
|
||||
return JSON.stringify(
|
||||
[...items]
|
||||
.map((item) => ({
|
||||
code: item.code,
|
||||
amount: item.amount,
|
||||
currency: item.currency,
|
||||
}))
|
||||
.sort((a, b) => a.code.localeCompare(b.code)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In, Not } from 'typeorm';
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { In, Not } from "typeorm";
|
||||
|
||||
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||
import { ContainerType } from "../rule-engine/entities/container-type.entity";
|
||||
import {
|
||||
CARGO_TYPES_REPOSITORY,
|
||||
ICargoTypesRepository,
|
||||
} from '../rule-engine/interfaces/cargo-types.repository.interface';
|
||||
} from "../rule-engine/interfaces/cargo-types.repository.interface";
|
||||
import {
|
||||
CONTAINER_TYPES_REPOSITORY,
|
||||
IContainerTypesRepository,
|
||||
} from '../rule-engine/interfaces/container-types.repository.interface';
|
||||
} from "../rule-engine/interfaces/container-types.repository.interface";
|
||||
import {
|
||||
IServiceTypesRepository,
|
||||
SERVICE_TYPES_REPOSITORY,
|
||||
} from '../rule-engine/interfaces/service-types.repository.interface';
|
||||
} from "../rule-engine/interfaces/service-types.repository.interface";
|
||||
import {
|
||||
IShippingLinesRepository,
|
||||
SHIPPING_LINES_REPOSITORY,
|
||||
} from '../rule-engine/interfaces/shipping-lines.repository.interface';
|
||||
} from "../rule-engine/interfaces/shipping-lines.repository.interface";
|
||||
import {
|
||||
IYardsRepository,
|
||||
YARDS_REPOSITORY,
|
||||
} from '../rule-engine/interfaces/yards.repository.interface';
|
||||
} from "../rule-engine/interfaces/yards.repository.interface";
|
||||
import {
|
||||
BookingReferenceCargoTypeChildDto,
|
||||
BookingReferenceCargoTypeGroupDto,
|
||||
@@ -32,9 +32,9 @@ import {
|
||||
BookingReferenceServiceDto,
|
||||
BookingReferenceShippingLineDto,
|
||||
BookingReferenceYardDto,
|
||||
} from './dto/booking-reference-data.dto';
|
||||
} from "./dto/booking-reference-data.dto";
|
||||
|
||||
const LEGACY_YARD_CODES = ['LEGACY_ORIGIN', 'LEGACY_DEST'] as const;
|
||||
const LEGACY_YARD_CODES = ["LEGACY_ORIGIN", "LEGACY_DEST"] as const;
|
||||
|
||||
export function buildCargoTypeTree(
|
||||
rows: CargoType[],
|
||||
@@ -42,13 +42,16 @@ export function buildCargoTypeTree(
|
||||
const active = rows.filter((r) => r.isActive);
|
||||
const parents = active
|
||||
.filter((r) => !r.parentGroupId)
|
||||
.sort((a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code));
|
||||
.sort(
|
||||
(a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
|
||||
);
|
||||
|
||||
return parents.map((parent) => {
|
||||
const children = active
|
||||
.filter((r) => r.parentGroupId === parent.id)
|
||||
.sort(
|
||||
(a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
|
||||
(a, b) =>
|
||||
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
|
||||
)
|
||||
.map(
|
||||
(child): BookingReferenceCargoTypeChildDto => ({
|
||||
@@ -79,14 +82,14 @@ export function groupContainersBySize(
|
||||
|
||||
for (const ct of active) {
|
||||
const sizeKey =
|
||||
ct.sizeFt != null && ct.sizeFt > 0 ? `${ct.sizeFt}ft` : 'other';
|
||||
ct.sizeFt != null && ct.sizeFt > 0 ? `${ct.sizeFt}ft` : "other";
|
||||
const list = bySize.get(sizeKey) ?? [];
|
||||
list.push(ct);
|
||||
bySize.set(sizeKey, list);
|
||||
}
|
||||
|
||||
const sortSizeKey = (key: string): number => {
|
||||
if (key === 'other') return Number.MAX_SAFE_INTEGER;
|
||||
if (key === "other") return Number.MAX_SAFE_INTEGER;
|
||||
const n = parseInt(key, 10);
|
||||
return Number.isNaN(n) ? Number.MAX_SAFE_INTEGER - 1 : n;
|
||||
};
|
||||
@@ -126,7 +129,7 @@ export class BookingReferenceDataService {
|
||||
private readonly shippingLinesRepository: IShippingLinesRepository,
|
||||
@Inject(CARGO_TYPES_REPOSITORY)
|
||||
private readonly cargoTypesRepository: ICargoTypesRepository,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
async getReferenceData(): Promise<BookingReferenceDataDto> {
|
||||
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
|
||||
@@ -136,23 +139,23 @@ export class BookingReferenceDataService {
|
||||
isActive: true,
|
||||
code: Not(In([...LEGACY_YARD_CODES])),
|
||||
},
|
||||
order: { displayOrder: 'ASC', code: 'ASC' },
|
||||
order: { displayOrder: "ASC", code: "ASC" },
|
||||
}),
|
||||
this.containerTypesRepository.findAll({
|
||||
where: { isActive: true },
|
||||
order: { displayOrder: 'ASC', code: 'ASC' },
|
||||
order: { displayOrder: "ASC", code: "ASC" },
|
||||
}),
|
||||
this.serviceTypesRepository.findAll({
|
||||
where: { isActive: true },
|
||||
order: { displayOrder: 'ASC', code: 'ASC' },
|
||||
order: { displayOrder: "ASC", code: "ASC" },
|
||||
}),
|
||||
this.shippingLinesRepository.findAll({
|
||||
where: { isActive: true },
|
||||
order: { label: 'ASC', code: 'ASC' },
|
||||
order: { label: "ASC", code: "ASC" },
|
||||
}),
|
||||
this.cargoTypesRepository.findAll({
|
||||
where: { isActive: true },
|
||||
order: { displayOrder: 'ASC', code: 'ASC' },
|
||||
order: { displayOrder: "ASC", code: "ASC" },
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -168,9 +171,8 @@ export class BookingReferenceDataService {
|
||||
containers: groupContainersBySize(containerTypes),
|
||||
service: serviceTypes.map(
|
||||
(s): BookingReferenceServiceDto => ({
|
||||
id: s.id,
|
||||
name: s.serviceName,
|
||||
code: s.code,
|
||||
...s,
|
||||
}),
|
||||
),
|
||||
shipping_line: shippingLines.map(
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||
@@ -8,6 +14,8 @@ import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
|
||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { BookingsService } from './bookings.service';
|
||||
|
||||
@@ -22,7 +30,7 @@ export class BookingTransitionService {
|
||||
private readonly bookingsService: BookingsService,
|
||||
) {}
|
||||
|
||||
async submit(bookingId: string): Promise<Booking> {
|
||||
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
|
||||
|
||||
@@ -32,14 +40,119 @@ export class BookingTransitionService {
|
||||
);
|
||||
}
|
||||
|
||||
const computed = await this.pricingService.computePriceForBooking(booking);
|
||||
this.ruleEngineService.assertNoHardBlocks({
|
||||
priorityScore: computed.priorityScore,
|
||||
appliedModifiers: computed.appliedModifiers,
|
||||
containerWeightResults: [],
|
||||
warnings: computed.warnings,
|
||||
hardBlocked: computed.hardBlocked,
|
||||
requiresDirectorApproval: false,
|
||||
});
|
||||
|
||||
const stored = booking.pricingBreakdown as {
|
||||
lineItems?: PriceLineItemDto[];
|
||||
totalAmount?: number;
|
||||
} | null;
|
||||
const unchanged = this.pricingService.pricesMatch(stored, computed);
|
||||
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
|
||||
await this.ruleEngineService.snapshotLiveRates(bookingId);
|
||||
|
||||
if (unchanged) {
|
||||
await this.pricingService.createPricingSnapshots(
|
||||
bookingId,
|
||||
computed.usedRates,
|
||||
computed.appliedModifiers,
|
||||
);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'SUBMITTED',
|
||||
priorityScore,
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
|
||||
const finalBooking = await this.bookingsService.findById(updated!.id);
|
||||
return {
|
||||
bookingId: finalBooking.id,
|
||||
status: finalBooking.status,
|
||||
priceChanged: false,
|
||||
totalAmount: Number(finalBooking.totalAmount),
|
||||
currency: finalBooking.paymentCurrency,
|
||||
lineItems: computed.lineItems,
|
||||
};
|
||||
}
|
||||
|
||||
const previousTotalAmount = Number(booking.totalAmount);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
totalAmount: computed.totalAmount,
|
||||
priorityScore: computed.priorityScore,
|
||||
pricingBreakdown: {
|
||||
lineItems: computed.lineItems,
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
status: 'PRICE_CHANGED_PENDING_CONFIRM',
|
||||
} as never);
|
||||
|
||||
const updatedBooking = await this.bookingsService.findById(bookingId);
|
||||
return {
|
||||
bookingId: updatedBooking.id,
|
||||
status: updatedBooking.status,
|
||||
priceChanged: true,
|
||||
previousTotalAmount,
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
lineItems: computed.lineItems,
|
||||
message: 'Price has changed since preview. Confirm to submit with the updated price.',
|
||||
};
|
||||
}
|
||||
|
||||
async confirmSubmit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['PRICE_CHANGED_PENDING_CONFIRM']);
|
||||
|
||||
if (Number(booking.totalAmount) <= 0) {
|
||||
throw new BadRequestException('No price to confirm');
|
||||
}
|
||||
|
||||
const computed = await this.pricingService.computePriceForBooking(booking);
|
||||
this.ruleEngineService.assertNoHardBlocks({
|
||||
priorityScore: computed.priorityScore,
|
||||
appliedModifiers: computed.appliedModifiers,
|
||||
containerWeightResults: [],
|
||||
warnings: computed.warnings,
|
||||
hardBlocked: computed.hardBlocked,
|
||||
requiresDirectorApproval: false,
|
||||
});
|
||||
|
||||
await this.pricingService.createPricingSnapshots(
|
||||
bookingId,
|
||||
computed.usedRates,
|
||||
computed.appliedModifiers,
|
||||
);
|
||||
|
||||
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'SUBMITTED',
|
||||
priorityScore,
|
||||
totalAmount: computed.totalAmount,
|
||||
pricingBreakdown: {
|
||||
lineItems: computed.lineItems,
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
} as never);
|
||||
|
||||
const finalBooking = await this.bookingsService.findById(updated!.id);
|
||||
return {
|
||||
bookingId: finalBooking.id,
|
||||
status: finalBooking.status,
|
||||
priceChanged: false,
|
||||
totalAmount: Number(finalBooking.totalAmount),
|
||||
currency: finalBooking.paymentCurrency,
|
||||
lineItems: computed.lineItems,
|
||||
message: 'Booking submitted with confirmed price.',
|
||||
};
|
||||
}
|
||||
|
||||
async requestChanges(
|
||||
@@ -77,6 +190,16 @@ export class BookingTransitionService {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['SUBMITTED']);
|
||||
|
||||
// Consolidation gate: a booking whose containers don't fill whole wagons
|
||||
// cannot be accepted until it is paired with a complementary booking.
|
||||
const gate = await this.bookingsService.resolveConsolidationGate(bookingId);
|
||||
if (gate.blocked) {
|
||||
throw new ConflictException(
|
||||
gate.message ??
|
||||
'Booking requires consolidation and cannot be accepted until a partner is found.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId: booking.cargoTypeId,
|
||||
@@ -279,6 +402,7 @@ export class BookingTransitionService {
|
||||
assertBookingStatus(booking, [
|
||||
'DRAFT',
|
||||
'SUBMITTED',
|
||||
'PRICE_CHANGED_PENDING_CONFIRM',
|
||||
'CHANGES_REQUESTED',
|
||||
'PENDING_APPROVAL',
|
||||
'CONTRACT_READY',
|
||||
|
||||
@@ -39,6 +39,7 @@ import { CreateBookingDto } from './dto/create-booking.dto';
|
||||
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
|
||||
import { FilterBookingDto } from './dto/filter-booking.dto';
|
||||
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||
import {
|
||||
ApproveStepDto,
|
||||
CancelBookingDto,
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from '../../common/resolve-auth-user-id';
|
||||
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
|
||||
|
||||
@ApiTags('bookings')
|
||||
@Controller('bookings')
|
||||
@@ -71,13 +73,30 @@ export class BookingsController {
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
|
||||
@ApiBody({ type: CreateBookingDto })
|
||||
create(
|
||||
async create(
|
||||
@Body() dto: CreateBookingDto,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@Request() req: { user?: { id?: string; sub?: string } },
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const userId = req.user?.id ?? req.user?.sub;
|
||||
return this.bookingsService.create(dto, files ?? [], userId);
|
||||
if (dto.isGovernment) {
|
||||
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
|
||||
}
|
||||
const result = await this.bookingsService.create(dto, files ?? [], user?.id);
|
||||
|
||||
// Staff-created commercial bookings skip the draft stage: auto generate-price + submit.
|
||||
const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
|
||||
if (isStaff && !dto.isGovernment) {
|
||||
try {
|
||||
await this.pricingService.generatePrice(result.booking.id);
|
||||
await this.transitionService.submit(result.booking.id);
|
||||
const submitted = await this.bookingsService.findById(result.booking.id);
|
||||
return { booking: submitted, warnings: result.warnings };
|
||||
} catch {
|
||||
// If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually.
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@@ -109,6 +128,20 @@ export class BookingsController {
|
||||
return this.bookingsService.getListSummary(filter);
|
||||
}
|
||||
|
||||
@Get('my')
|
||||
@ApiOperation({
|
||||
summary: "List the current customer's bookings ready for payment",
|
||||
description:
|
||||
'Bookings owned by the authenticated user\'s company that are payable ' +
|
||||
'(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.',
|
||||
})
|
||||
findMyPayable(
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Query() filter: FilterBookingDto,
|
||||
) {
|
||||
return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter);
|
||||
}
|
||||
|
||||
@Get('queues/:queue')
|
||||
@ApiOperation({
|
||||
summary: 'List bookings for a dashboard queue',
|
||||
@@ -165,17 +198,36 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(':id/generate-price')
|
||||
@ApiOperation({ summary: 'Generate price preview (DRAFT only)' })
|
||||
@ApiOperation({
|
||||
summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)',
|
||||
description:
|
||||
'Computes and stores a price preview on the booking. Does not create rate snapshots.',
|
||||
})
|
||||
@ApiOkResponse({ type: GeneratePriceResponseDto })
|
||||
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.pricingService.generatePrice(id);
|
||||
}
|
||||
|
||||
@Post(':id/submit')
|
||||
@ApiOperation({ summary: 'Customer submit booking' })
|
||||
async submit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const booking = await this.transitionService.submit(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
@ApiOperation({
|
||||
summary: 'Customer submit booking',
|
||||
description:
|
||||
'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.',
|
||||
})
|
||||
@ApiOkResponse({ type: SubmitBookingResponseDto })
|
||||
submit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.submit(id);
|
||||
}
|
||||
|
||||
@Post(':id/confirm-submit')
|
||||
@ApiOperation({
|
||||
summary: 'Confirm submit after price change',
|
||||
description:
|
||||
'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.',
|
||||
})
|
||||
@ApiOkResponse({ type: SubmitBookingResponseDto })
|
||||
confirmSubmit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.confirmSubmit(id);
|
||||
}
|
||||
|
||||
@Post(':id/staff/request-changes')
|
||||
@@ -224,6 +276,20 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/government-expedite')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })
|
||||
async governmentExpedite(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.bookingsService.governmentExpedite(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/approve')
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||
@@ -276,8 +342,12 @@ export class BookingsController {
|
||||
@Get(':id/contract/view')
|
||||
@ApiOkResponse({ type: ContractViewDto })
|
||||
@ApiOperation({ summary: 'Contract HTML view for portal and backoffice' })
|
||||
getContractView(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.contractService.getContractView(id);
|
||||
getContractView(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Request() req: { user?: { id?: string; sub?: string } },
|
||||
) {
|
||||
const userId = req.user?.id ?? req.user?.sub;
|
||||
return this.contractService.getContractView(id, userId);
|
||||
}
|
||||
|
||||
@Get(':id/contract/document')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
// import { CustomersModule } from '../customers/customers.module';
|
||||
@@ -6,6 +6,7 @@ import { CompaniesModule } from '../companies/companies.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { MinioModule } from '../minio/minio.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPaymentService } from './booking-payment.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
@@ -29,6 +30,8 @@ import { ContractRendererService } from '../../contracts/contract-renderer.servi
|
||||
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||
import { PaymentModule } from '../payment/payment.module';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -42,11 +45,13 @@ import { PaymentModule } from '../payment/payment.module';
|
||||
BookingContractSignature,
|
||||
]),
|
||||
PaymentModule,
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
CompaniesModule,
|
||||
// CustomersModule,
|
||||
RuleEngineModule,
|
||||
SignaturesModule,
|
||||
],
|
||||
controllers: [BookingsController, PayController],
|
||||
providers: [
|
||||
@@ -63,6 +68,7 @@ import { PaymentModule } from '../payment/payment.module';
|
||||
ContractPricingScheduleBuilder,
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
CbeExchangeService,
|
||||
],
|
||||
exports: [BookingsService, BookingsRepository],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
|
||||
function mockQueryBuilder() {
|
||||
const qb = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
|
||||
};
|
||||
return qb;
|
||||
}
|
||||
|
||||
describe('BookingsRepository', () => {
|
||||
let repository: jest.Mocked<Repository<Booking>>;
|
||||
let dataSource: { getRepository: jest.Mock };
|
||||
let bookingsRepository: BookingsRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = {
|
||||
createQueryBuilder: jest.fn(),
|
||||
} as unknown as jest.Mocked<Repository<Booking>>;
|
||||
dataSource = { getRepository: jest.fn() };
|
||||
bookingsRepository = new BookingsRepository(repository, dataSource as unknown as DataSource);
|
||||
});
|
||||
|
||||
it('findEligibleForScheduling does not filter by schedule date', async () => {
|
||||
const qb = mockQueryBuilder();
|
||||
const bookings = [
|
||||
{ id: 'b1', scheduledDate: new Date('2026-06-20T08:00:00.000Z') },
|
||||
{ id: 'b2', scheduledDate: new Date('2026-06-21T14:00:00.000Z') },
|
||||
];
|
||||
qb.getMany.mockResolvedValue(bookings);
|
||||
repository.createQueryBuilder.mockReturnValue(qb as never);
|
||||
|
||||
const result = await bookingsRepository.findEligibleForScheduling({
|
||||
originStationId: 'yard-origin',
|
||||
destinationStationId: 'yard-destination',
|
||||
freightType: 'CONTAINER',
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
const dateFilters = qb.andWhere.mock.calls.filter(([clause]) =>
|
||||
String(clause).includes('scheduled_date'),
|
||||
);
|
||||
expect(dateFilters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('applyListFilters excludes assigned bookings when assignedToSchedule is false', async () => {
|
||||
const qb = mockQueryBuilder();
|
||||
repository.createQueryBuilder.mockReturnValue(qb as never);
|
||||
dataSource.getRepository.mockReturnValue({ find: jest.fn().mockResolvedValue([]) });
|
||||
|
||||
await bookingsRepository.findAllPaginated({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
assignedToSchedule: 'false',
|
||||
});
|
||||
|
||||
expect(qb.andWhere).toHaveBeenCalledWith(expect.stringContaining('NOT EXISTS'));
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { SchedulingStatus } from '@edr/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, FindOptionsWhere, Repository, SelectQueryBuilder } from 'typeorm';
|
||||
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
@@ -9,6 +10,7 @@ import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import { BookingContainer } from './entities/booking-container.entity';
|
||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import {
|
||||
BookingContractSignature,
|
||||
@@ -20,6 +22,8 @@ import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
|
||||
export interface BookingListFilterOptions {
|
||||
statuses?: string[];
|
||||
status?: string;
|
||||
schedulingStatuses?: string[];
|
||||
assignedToSchedule?: 'true' | 'false';
|
||||
companyId?: string;
|
||||
contractType?: string;
|
||||
serviceTypeId?: string;
|
||||
@@ -27,6 +31,8 @@ export interface BookingListFilterOptions {
|
||||
freightType?: string;
|
||||
tradeDirection?: string;
|
||||
paymentCurrency?: string;
|
||||
paymentStatus?: string;
|
||||
excludePaymentStatus?: string;
|
||||
allowConsolidation?: boolean;
|
||||
consolidationPaired?: string;
|
||||
}
|
||||
@@ -87,6 +93,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
|
||||
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
|
||||
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
|
||||
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
|
||||
.where('booking.id = :id', { id })
|
||||
.leftJoinAndMapMany(
|
||||
'booking.files',
|
||||
@@ -171,7 +178,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.andWhere('b.allowConsolidation = true')
|
||||
.andWhere('b.consolidationPartnerId IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', {
|
||||
statuses: ['DRAFT', 'PENDING_CONSOLIDATION'],
|
||||
statuses: ['DRAFT', 'SUBMITTED', 'PENDING_CONSOLIDATION'],
|
||||
})
|
||||
.andWhere('b.originYardId = :originYardId', {
|
||||
originYardId: booking.originYardId,
|
||||
@@ -208,15 +215,27 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Pair two bookings for consolidation. */
|
||||
/**
|
||||
* Pair two bookings for consolidation. Both return to SUBMITTED so staff can
|
||||
* accept them into the approval chain; the link itself (consolidationPartnerId)
|
||||
* marks them as consolidated in the UI.
|
||||
*/
|
||||
async pairConsolidation(bookingId: string, partnerId: string): Promise<void> {
|
||||
await this.repository.update(bookingId, {
|
||||
consolidationPartnerId: partnerId,
|
||||
status: 'CONSOLIDATED',
|
||||
status: 'SUBMITTED',
|
||||
} as never);
|
||||
await this.repository.update(partnerId, {
|
||||
consolidationPartnerId: bookingId,
|
||||
status: 'CONSOLIDATED',
|
||||
status: 'SUBMITTED',
|
||||
} as never);
|
||||
}
|
||||
|
||||
/** Park a booking that needs consolidation but has no partner yet. */
|
||||
async parkForConsolidation(bookingId: string): Promise<void> {
|
||||
await this.repository.update(bookingId, {
|
||||
consolidationPartnerId: null,
|
||||
status: 'PENDING_CONSOLIDATION',
|
||||
} as never);
|
||||
}
|
||||
|
||||
@@ -345,6 +364,26 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId });
|
||||
}
|
||||
|
||||
async hasPricingArtifacts(bookingId: string): Promise<boolean> {
|
||||
const snapshotCount = await this.dataSource
|
||||
.getRepository(BookingRateSnapshot)
|
||||
.count({ where: { bookingId } });
|
||||
const modifierCount = await this.dataSource
|
||||
.getRepository(BookingCargoModifier)
|
||||
.count({ where: { bookingId } });
|
||||
return snapshotCount > 0 || modifierCount > 0;
|
||||
}
|
||||
|
||||
async invalidatePricingPreview(bookingId: string): Promise<void> {
|
||||
if (await this.hasPricingArtifacts(bookingId)) {
|
||||
await this.clearPricingArtifacts(bookingId);
|
||||
}
|
||||
await this.update(bookingId, {
|
||||
totalAmount: 0,
|
||||
pricingBreakdown: null,
|
||||
} as never);
|
||||
}
|
||||
|
||||
/** Queue listing with optional bulk exclusion for LINE_STAFF. */
|
||||
async findQueue(options: {
|
||||
status: string | string[];
|
||||
@@ -403,21 +442,42 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
||||
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
|
||||
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
|
||||
.where('booking.deleted_at IS NULL');
|
||||
|
||||
this.applyListFilters(qb, options);
|
||||
|
||||
if (options.sortBy === 'isGovernment') {
|
||||
qb.orderBy('booking.isGovernment', 'DESC')
|
||||
.addOrderBy('booking.priorityScore', 'DESC')
|
||||
.addOrderBy('booking.scheduledDate', 'ASC');
|
||||
} else {
|
||||
const sortField =
|
||||
options.sortBy === 'priorityScore'
|
||||
? 'booking.priorityScore'
|
||||
: options.sortBy === 'scheduledDate'
|
||||
? 'booking.scheduledDate'
|
||||
: 'booking.createdAt';
|
||||
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
|
||||
}
|
||||
|
||||
const [items, total] = await qb
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize)
|
||||
.getManyAndCount();
|
||||
|
||||
if (items.length) {
|
||||
const links = await this.dataSource.getRepository(TrainScheduleBooking).find({
|
||||
where: { bookingId: In(items.map((item) => item.id)) },
|
||||
select: { bookingId: true, trainScheduleId: true },
|
||||
});
|
||||
const scheduleByBooking = new Map(links.map((link) => [link.bookingId, link.trainScheduleId]));
|
||||
for (const item of items) {
|
||||
(item as Booking & { trainScheduleId?: string | null }).trainScheduleId =
|
||||
scheduleByBooking.get(item.id) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
@@ -526,6 +586,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
paymentCurrency: options.paymentCurrency,
|
||||
});
|
||||
}
|
||||
if (options.paymentStatus) {
|
||||
qb.andWhere('booking.payment_status = :paymentStatus', {
|
||||
paymentStatus: options.paymentStatus,
|
||||
});
|
||||
}
|
||||
if (options.excludePaymentStatus) {
|
||||
qb.andWhere('booking.payment_status != :excludePaymentStatus', {
|
||||
excludePaymentStatus: options.excludePaymentStatus,
|
||||
});
|
||||
}
|
||||
if (options.allowConsolidation !== undefined) {
|
||||
qb.andWhere('booking.allow_consolidation = :allowConsolidation', {
|
||||
allowConsolidation: options.allowConsolidation,
|
||||
@@ -536,6 +606,26 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
} else if (options.consolidationPaired === 'false') {
|
||||
qb.andWhere('booking.consolidation_partner_id IS NULL');
|
||||
}
|
||||
if (options.schedulingStatuses?.length) {
|
||||
qb.andWhere('booking.scheduling_status IN (:...schedulingStatuses)', {
|
||||
schedulingStatuses: options.schedulingStatuses,
|
||||
});
|
||||
}
|
||||
if (options.assignedToSchedule === 'true') {
|
||||
qb.andWhere(
|
||||
`EXISTS (
|
||||
SELECT 1 FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.booking_id = booking.id AND tsb.deleted_at IS NULL
|
||||
)`,
|
||||
);
|
||||
} else if (options.assignedToSchedule === 'false') {
|
||||
qb.andWhere(
|
||||
`NOT EXISTS (
|
||||
SELECT 1 FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.booking_id = booking.id AND tsb.deleted_at IS NULL
|
||||
)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async findAndCountFiltered(where: FindOptionsWhere<Booking>, options: {
|
||||
@@ -585,4 +675,192 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
}
|
||||
return repo.save(repo.create(data));
|
||||
}
|
||||
|
||||
private bookingRepo(manager?: EntityManager) {
|
||||
return manager ? manager.getRepository(Booking) : this.repository;
|
||||
}
|
||||
|
||||
findEligibleForScheduling(options: {
|
||||
freightType?: string;
|
||||
originStationId?: string;
|
||||
destinationStationId?: string;
|
||||
schedulingStatus?: string;
|
||||
trainScheduleId?: string;
|
||||
}): Promise<Booking[]> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||
.leftJoin(
|
||||
TrainScheduleBooking,
|
||||
'scheduleBooking',
|
||||
'scheduleBooking.booking_id = booking.id',
|
||||
)
|
||||
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
|
||||
.andWhere('scheduleBooking.id IS NULL');
|
||||
|
||||
// Mirror the automatic batch pool: a schedule only ever considers bookings that
|
||||
// targeted THAT schedule (same as findBatchPool's train_schedule_id filter).
|
||||
if (options.trainScheduleId) {
|
||||
qb.andWhere('booking.train_schedule_id = :trainScheduleId', {
|
||||
trainScheduleId: options.trainScheduleId,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.freightType) {
|
||||
qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType });
|
||||
}
|
||||
|
||||
if (options.originStationId) {
|
||||
qb.andWhere('booking.originYardId = :originStationId', {
|
||||
originStationId: options.originStationId,
|
||||
});
|
||||
}
|
||||
if (options.destinationStationId) {
|
||||
qb.andWhere('booking.destinationYardId = :destinationStationId', {
|
||||
destinationStationId: options.destinationStationId,
|
||||
});
|
||||
}
|
||||
if (options.schedulingStatus) {
|
||||
qb.andWhere('booking.scheduling_status = :schedulingStatus', {
|
||||
schedulingStatus: options.schedulingStatus,
|
||||
});
|
||||
}
|
||||
|
||||
return qb
|
||||
.orderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.scheduled_date', 'ASC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ready, not-yet-allocated bookings targeting a schedule (the batch pool).
|
||||
* Commercial = FULLY_EXECUTED; government = APPROVED or PAID (skips contract).
|
||||
* Ordered government → priority → contract-sign time.
|
||||
*/
|
||||
findBatchPool(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
.andWhere('sb.id IS NULL')
|
||||
.andWhere(
|
||||
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
||||
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
|
||||
)
|
||||
.orderBy('booking.is_government', 'DESC')
|
||||
.addOrderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.fully_executed_at', 'ASC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
|
||||
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
.orderBy('booking.is_government', 'DESC')
|
||||
.addOrderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
|
||||
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
.andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** PAID bookings targeting a schedule that have no train_schedule_bookings link yet. */
|
||||
findPaidUnlinkedForSchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoin(
|
||||
TrainScheduleBooking,
|
||||
'scheduleBooking',
|
||||
'scheduleBooking.booking_id = booking.id',
|
||||
)
|
||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
.andWhere(`booking.status = 'PAID'`)
|
||||
.andWhere('scheduleBooking.id IS NULL')
|
||||
.orderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Commercial bookings already allocated to a schedule, lowest-priority first (for government preempt). */
|
||||
findAllocatedCommercialForSchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.innerJoin(
|
||||
TrainScheduleBooking,
|
||||
'sb',
|
||||
'sb.booking_id = booking.id AND sb.train_schedule_id = :scheduleId',
|
||||
{ scheduleId },
|
||||
)
|
||||
.where('booking.is_government = false')
|
||||
.orderBy('booking.priority_score', 'ASC')
|
||||
.addOrderBy('booking.created_at', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
findByIdsForScheduling(bookingIds: string[], manager?: EntityManager): Promise<Booking[]> {
|
||||
if (!bookingIds.length) return Promise.resolve([]);
|
||||
return this.bookingRepo(manager).find({
|
||||
where: { id: In(bookingIds) },
|
||||
relations: {
|
||||
company: true,
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
bookingContainers: { containerType: true },
|
||||
cargoType: true,
|
||||
},
|
||||
order: { priorityScore: 'DESC', createdAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async updateSchedulingFields(
|
||||
bookingId: string,
|
||||
fields: Partial<
|
||||
Pick<
|
||||
Booking,
|
||||
'schedulingStatus' | 'wagonsRequired' | 'scheduledAt' | 'holdStartedAt' | 'holdExpiresAt'
|
||||
>
|
||||
>,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
await this.bookingRepo(manager).update(bookingId, fields as never);
|
||||
}
|
||||
|
||||
async setHoldWindowOnPaid(bookingId: string, manager?: EntityManager): Promise<void> {
|
||||
const now = new Date();
|
||||
const expires = new Date(now.getTime() + 3 * 60 * 60 * 1000);
|
||||
await this.updateSchedulingFields(
|
||||
bookingId,
|
||||
{
|
||||
schedulingStatus: SchedulingStatus.Holding,
|
||||
holdStartedAt: now,
|
||||
holdExpiresAt: expires,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { SchedulingStatus } from '@edr/types';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
@@ -13,6 +14,12 @@ import {
|
||||
BookingEvaluationInput,
|
||||
RuleEngineService,
|
||||
} from '../rule-engine/rule-engine.service';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { assertFreightShape } from './booking-freight.util';
|
||||
@@ -39,6 +46,7 @@ const NEEDS_ACTION_STATUSES = [
|
||||
@Injectable()
|
||||
export class BookingsService {
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly minioService: MinioService,
|
||||
@@ -49,6 +57,36 @@ export class BookingsService {
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
) {}
|
||||
|
||||
/** Resolve trade direction from yard countries; reject client mismatch. */
|
||||
private async resolveTradeDirectionForBooking(
|
||||
originYardId: string,
|
||||
destinationYardId: string,
|
||||
provided?: string,
|
||||
): Promise<string> {
|
||||
const yards = await this.dataSource.getRepository(Yard).find({
|
||||
where: { id: In([originYardId, destinationYardId]) },
|
||||
});
|
||||
const origin = yards.find((y) => y.id === originYardId);
|
||||
const destination = yards.find((y) => y.id === destinationYardId);
|
||||
if (!origin) {
|
||||
throw new BadRequestException(`Origin yard ${originYardId} not found`);
|
||||
}
|
||||
if (!destination) {
|
||||
throw new BadRequestException(`Destination yard ${destinationYardId} not found`);
|
||||
}
|
||||
if (originYardId === destinationYardId) {
|
||||
throw new BadRequestException('Origin and destination yards must differ');
|
||||
}
|
||||
|
||||
const expected = deriveTradeDirection(origin, destination);
|
||||
if (provided && provided !== expected) {
|
||||
throw new BadRequestException(
|
||||
`tradeDirection must be ${expected} for the selected yard pair (got ${provided})`,
|
||||
);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
/** Generate a unique booking reference number. */
|
||||
private async generateReference(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
@@ -64,6 +102,7 @@ export class BookingsService {
|
||||
paymentCurrency: string;
|
||||
tradeDirection: string;
|
||||
isHazardous?: boolean;
|
||||
isGovernment?: boolean;
|
||||
allowConsolidation?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
containers: CreateBookingContainerDto[];
|
||||
@@ -81,9 +120,13 @@ export class BookingsService {
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
totalVgmTons,
|
||||
isReefer: ct.isReefer,
|
||||
wagonsRequired: c.quantity * (Number(ct.wagonsPerUnit) || 1),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const totalWagons = Math.ceil(
|
||||
containers.reduce((sum, c) => sum + c.wagonsRequired, 0),
|
||||
);
|
||||
|
||||
return {
|
||||
freightType: dto.freightType,
|
||||
@@ -92,9 +135,11 @@ export class BookingsService {
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
isHazardous: dto.isHazardous ?? false,
|
||||
isGovernment: dto.isGovernment ?? false,
|
||||
allowConsolidation:
|
||||
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
totalWagons,
|
||||
containers,
|
||||
};
|
||||
}
|
||||
@@ -159,6 +204,48 @@ export class BookingsService {
|
||||
return { booking: pending, messages };
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidation gate used at staff-accept time. Returns the (possibly newly
|
||||
* paired) booking plus whether it still needs a consolidation partner.
|
||||
* When a booking needs consolidation and none is found, it is parked in
|
||||
* PENDING_CONSOLIDATION and `blocked` is true so the caller refuses the accept.
|
||||
*/
|
||||
async resolveConsolidationGate(bookingId: string): Promise<{
|
||||
booking: Booking;
|
||||
blocked: boolean;
|
||||
message?: string;
|
||||
}> {
|
||||
let booking = await this.findById(bookingId);
|
||||
|
||||
// Already paired — passes the gate.
|
||||
if (booking.consolidationPartnerId) {
|
||||
return { booking, blocked: false };
|
||||
}
|
||||
|
||||
const needs =
|
||||
await this.consolidationService.needsConsolidationFromBooking(booking);
|
||||
if (!needs) {
|
||||
return { booking, blocked: false };
|
||||
}
|
||||
|
||||
// A partner may have appeared since submission — try to pair now.
|
||||
const result = await this.tryAutoConsolidate(booking);
|
||||
booking = result.booking;
|
||||
if (booking.consolidationPartnerId) {
|
||||
return { booking, blocked: false, message: result.messages.join(' ') };
|
||||
}
|
||||
|
||||
// Still no partner — park it and block the accept.
|
||||
await this.bookingsRepository.parkForConsolidation(booking.id);
|
||||
booking = await this.findById(booking.id);
|
||||
const slots = await this.consolidationService.slotsFromBooking(booking);
|
||||
return {
|
||||
booking,
|
||||
blocked: true,
|
||||
message: this.consolidationService.describePending(booking, slots),
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a new freight booking. */
|
||||
async create(
|
||||
dto: CreateBookingDto,
|
||||
@@ -178,8 +265,15 @@ export class BookingsService {
|
||||
// customerId = customer.id;
|
||||
// }
|
||||
|
||||
let companyId = dto.companyId;
|
||||
if (!companyId) {
|
||||
const isGovernment = dto.isGovernment === true;
|
||||
|
||||
let companyId: string | null | undefined = dto.companyId;
|
||||
if (isGovernment) {
|
||||
if (!dto.governmentInstitution?.trim()) {
|
||||
throw new BadRequestException('governmentInstitution is required for government bookings');
|
||||
}
|
||||
companyId = dto.companyId ?? null;
|
||||
} else if (!companyId) {
|
||||
if (!userId) {
|
||||
throw new BadRequestException(
|
||||
'companyId is required or must be resolvable from auth token',
|
||||
@@ -189,6 +283,25 @@ export class BookingsService {
|
||||
companyId = company.id;
|
||||
}
|
||||
|
||||
// Schedule targeting: when provided, the schedule must be OPEN and on the same route.
|
||||
if (dto.trainScheduleId) {
|
||||
const schedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id: dto.trainScheduleId } });
|
||||
if (!schedule) {
|
||||
throw new BadRequestException(`Train schedule ${dto.trainScheduleId} not found`);
|
||||
}
|
||||
if (schedule.bookingWindowStatus !== 'OPEN') {
|
||||
throw new BadRequestException('Selected schedule is no longer accepting bookings');
|
||||
}
|
||||
if (
|
||||
schedule.originStationId !== dto.originYardId ||
|
||||
schedule.destinationStationId !== dto.destinationYardId
|
||||
) {
|
||||
throw new BadRequestException('Selected schedule is not on the booking route');
|
||||
}
|
||||
}
|
||||
|
||||
const reference = dto.reference || (await this.generateReference());
|
||||
const containers = dto.containers ?? [];
|
||||
assertFreightShape({
|
||||
@@ -197,6 +310,12 @@ export class BookingsService {
|
||||
containers,
|
||||
});
|
||||
|
||||
const tradeDirection = await this.resolveTradeDirectionForBooking(
|
||||
dto.originYardId,
|
||||
dto.destinationYardId,
|
||||
dto.tradeDirection,
|
||||
);
|
||||
|
||||
const allowConsolidation =
|
||||
dto.freightType === 'CONTAINER'
|
||||
? await this.resolveConsolidation(containers, dto.allowConsolidation)
|
||||
@@ -207,8 +326,9 @@ export class BookingsService {
|
||||
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId : null,
|
||||
serviceTypeId: dto.serviceTypeId,
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
tradeDirection,
|
||||
isHazardous: dto.isHazardous,
|
||||
isGovernment,
|
||||
allowConsolidation,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
containers,
|
||||
@@ -220,8 +340,11 @@ export class BookingsService {
|
||||
|
||||
const booking = await this.bookingsRepository.create({
|
||||
reference,
|
||||
companyId,
|
||||
companyId: companyId ?? null,
|
||||
isGovernment,
|
||||
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
|
||||
trainId: dto.trainId,
|
||||
trainScheduleId: dto.trainScheduleId ?? null,
|
||||
contractType: dto.contractType,
|
||||
previousContractId: dto.previousContractId,
|
||||
serviceTypeId: dto.serviceTypeId,
|
||||
@@ -230,7 +353,7 @@ export class BookingsService {
|
||||
equipmentReturn: dto.equipmentReturn,
|
||||
originYardId: dto.originYardId,
|
||||
destinationYardId: dto.destinationYardId,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
tradeDirection,
|
||||
freightType: dto.freightType,
|
||||
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
|
||||
cargoFreeText: dto.cargoFreeText,
|
||||
@@ -300,12 +423,13 @@ export class BookingsService {
|
||||
const freightType = (dto.freightType ?? existing.freightType) as FreightType;
|
||||
let containers =
|
||||
dto.containers ??
|
||||
existing.bookingContainers?.map((bc) => ({
|
||||
(existing.bookingContainers ?? [])
|
||||
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
|
||||
.map((bc) => ({
|
||||
containerTypeId: bc.containerTypeId,
|
||||
quantity: bc.quantity,
|
||||
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
|
||||
})) ??
|
||||
[];
|
||||
}));
|
||||
|
||||
let cargoTypeId =
|
||||
dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId;
|
||||
@@ -324,6 +448,14 @@ export class BookingsService {
|
||||
|
||||
assertFreightShape({ freightType, cargoTypeId, containers });
|
||||
|
||||
const originYardId = dto.originYardId ?? existing.originYardId;
|
||||
const destinationYardId = dto.destinationYardId ?? existing.destinationYardId;
|
||||
const tradeDirection = await this.resolveTradeDirectionForBooking(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
dto.tradeDirection,
|
||||
);
|
||||
|
||||
const allowConsolidation =
|
||||
freightType === 'CONTAINER'
|
||||
? await this.resolveConsolidation(
|
||||
@@ -337,7 +469,7 @@ export class BookingsService {
|
||||
cargoTypeId,
|
||||
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
|
||||
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
||||
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
|
||||
tradeDirection,
|
||||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||
allowConsolidation,
|
||||
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
|
||||
@@ -348,12 +480,22 @@ export class BookingsService {
|
||||
this.ruleEngineService.assertNoHardBlocks(ruleResult);
|
||||
warnings.push(...ruleResult.warnings);
|
||||
|
||||
const pricingFieldsChanged = this.pricingRelevantFieldsChanged(
|
||||
existing,
|
||||
dto,
|
||||
freightType,
|
||||
cargoTypeId,
|
||||
allowConsolidation,
|
||||
containers,
|
||||
);
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
...dto,
|
||||
freightType,
|
||||
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
|
||||
allowConsolidation,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
tradeDirection,
|
||||
};
|
||||
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
||||
if (dto.startDate) updates.startDate = new Date(dto.startDate);
|
||||
@@ -375,6 +517,10 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
|
||||
if (pricingFieldsChanged) {
|
||||
await this.bookingsRepository.invalidatePricingPreview(id);
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
await this.filesService.uploadMany(id, 'bookings', files);
|
||||
}
|
||||
@@ -390,6 +536,19 @@ export class BookingsService {
|
||||
return { booking, warnings };
|
||||
}
|
||||
|
||||
/** Parse comma-separated scheduling status query values. */
|
||||
private parseSchedulingStatusFilter(filter: FilterBookingDto): {
|
||||
schedulingStatuses?: string[];
|
||||
} {
|
||||
const raw = filter.schedulingStatuses;
|
||||
if (!raw) return {};
|
||||
const schedulingStatuses = raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return schedulingStatuses.length ? { schedulingStatuses } : {};
|
||||
}
|
||||
|
||||
/** Parse comma-separated or repeated status query values. */
|
||||
private parseStatusFilter(filter: FilterBookingDto): {
|
||||
statuses?: string[];
|
||||
@@ -420,11 +579,14 @@ export class BookingsService {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
const statusFilter = this.parseStatusFilter(filter);
|
||||
const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter);
|
||||
|
||||
return this.bookingsRepository.findAllPaginated({
|
||||
page,
|
||||
pageSize,
|
||||
...statusFilter,
|
||||
...schedulingStatusFilter,
|
||||
assignedToSchedule: filter.assignedToSchedule,
|
||||
companyId: filter.companyId,
|
||||
contractType: filter.contractType,
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
@@ -432,6 +594,7 @@ export class BookingsService {
|
||||
freightType: filter.freightType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
paymentStatus: filter.paymentStatus,
|
||||
allowConsolidation: filter.allowConsolidation,
|
||||
consolidationPaired: filter.consolidationPaired,
|
||||
sortBy: filter.sortBy,
|
||||
@@ -439,6 +602,35 @@ export class BookingsService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Booking statuses at which a customer can pay (mirrors booking-payment.service). */
|
||||
private static readonly PAYABLE_STATUSES = [
|
||||
'FULLY_EXECUTED',
|
||||
'SELECTED_FOR_BATCH',
|
||||
'AWAITING_PAYMENT',
|
||||
];
|
||||
|
||||
/**
|
||||
* List the current customer's bookings that are ready for payment:
|
||||
* payable status AND not yet PAID. Company scope is derived from the
|
||||
* authenticated user and cannot be widened by the caller.
|
||||
*/
|
||||
async findMyPayable(
|
||||
userId: string,
|
||||
filter: FilterBookingDto,
|
||||
): Promise<{ items: Booking[]; total: number }> {
|
||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
|
||||
return this.bookingsRepository.findAllPaginated({
|
||||
page: filter.page ?? 1,
|
||||
pageSize: filter.pageSize ?? 20,
|
||||
statuses: BookingsService.PAYABLE_STATUSES,
|
||||
excludePaymentStatus: 'PAID',
|
||||
companyId: company.id,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
/** Aggregate metrics and tab counts for the backoffice booking list. */
|
||||
async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
|
||||
const page = filter.page ?? 1;
|
||||
@@ -453,6 +645,7 @@ export class BookingsService {
|
||||
freightType: filter.freightType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
paymentStatus: filter.paymentStatus,
|
||||
allowConsolidation: filter.allowConsolidation,
|
||||
consolidationPaired: filter.consolidationPaired,
|
||||
};
|
||||
@@ -648,4 +841,86 @@ export class BookingsService {
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private pricingRelevantFieldsChanged(
|
||||
existing: Booking,
|
||||
dto: UpdateBookingDto,
|
||||
freightType: FreightType,
|
||||
cargoTypeId: string | null | undefined,
|
||||
allowConsolidation: boolean,
|
||||
containers: CreateBookingContainerDto[],
|
||||
): boolean {
|
||||
if (dto.freightType !== undefined && dto.freightType !== existing.freightType) {
|
||||
return true;
|
||||
}
|
||||
if (dto.tradeDirection !== undefined && dto.tradeDirection !== existing.tradeDirection) {
|
||||
return true;
|
||||
}
|
||||
if (dto.paymentCurrency !== undefined && dto.paymentCurrency !== existing.paymentCurrency) {
|
||||
return true;
|
||||
}
|
||||
if (dto.isHazardous !== undefined && dto.isHazardous !== existing.isHazardous) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
dto.allowConsolidation !== undefined &&
|
||||
dto.allowConsolidation !== existing.allowConsolidation
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (dto.shippingLineId !== undefined && dto.shippingLineId !== existing.shippingLineId) {
|
||||
return true;
|
||||
}
|
||||
if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== existing.cargoTypeId) {
|
||||
return true;
|
||||
}
|
||||
if (dto.containers !== undefined) {
|
||||
const existingContainers = (existing.bookingContainers ?? [])
|
||||
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
|
||||
.map((bc) => ({
|
||||
containerTypeId: bc.containerTypeId,
|
||||
quantity: bc.quantity,
|
||||
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
|
||||
}));
|
||||
if (JSON.stringify(existingContainers) !== JSON.stringify(containers)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (
|
||||
freightType !== existing.freightType ||
|
||||
(cargoTypeId ?? null) !== (existing.cargoTypeId ?? null) ||
|
||||
allowConsolidation !== existing.allowConsolidation
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Staff expedite: mark a government booking PAID and ready for scheduling (no commercial hold). */
|
||||
async governmentExpedite(id: string, staffUserId: string): Promise<Booking> {
|
||||
const booking = await this.findById(id);
|
||||
if (!booking.isGovernment) {
|
||||
throw new BadRequestException('Only government bookings can be expedited');
|
||||
}
|
||||
const blocked = ['PAID', 'IN_TRANSIT', 'COMPLETED', 'CANCELLED', 'REJECTED'];
|
||||
if (blocked.includes(booking.status)) {
|
||||
throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(id, {
|
||||
status: 'PAID',
|
||||
paymentStatus: 'PAID',
|
||||
schedulingStatus: SchedulingStatus.Eligible,
|
||||
holdStartedAt: null,
|
||||
holdExpiresAt: null,
|
||||
});
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
id,
|
||||
`Government booking expedited to PAID by staff (${staffUserId})`,
|
||||
'STAFF_NOTE',
|
||||
staffUserId,
|
||||
);
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,11 +76,12 @@ export class ConsolidationService {
|
||||
}
|
||||
|
||||
async slotsFromBooking(booking: Booking): Promise<ConsolidationSlot[]> {
|
||||
const lines =
|
||||
booking.bookingContainers?.map((bc) => ({
|
||||
const lines = (booking.bookingContainers ?? [])
|
||||
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
|
||||
.map((bc) => ({
|
||||
containerTypeId: bc.containerTypeId,
|
||||
quantity: bc.quantity,
|
||||
})) ?? [];
|
||||
}));
|
||||
return this.slotsFromContainerLines(lines);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,14 @@ export class ContractSignatureDto {
|
||||
signatureImageUrl?: string | null;
|
||||
}
|
||||
|
||||
export class SavedSignatureViewDto {
|
||||
@ApiProperty()
|
||||
signerDisplayName!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
signatureImageUrl?: string | null;
|
||||
}
|
||||
|
||||
export class ContractViewDto {
|
||||
@ApiProperty()
|
||||
bookingId!: string;
|
||||
@@ -45,6 +53,9 @@ export class ContractViewDto {
|
||||
@ApiProperty({ type: [ContractSignatureDto] })
|
||||
signatures!: ContractSignatureDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: SavedSignatureViewDto })
|
||||
savedSignature?: SavedSignatureViewDto;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
pricingSchedule?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
IsString,
|
||||
IsUUID,
|
||||
Min,
|
||||
MinLength,
|
||||
Validate,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
@@ -66,7 +67,21 @@ export class CreateBookingDto {
|
||||
// @IsUUID()
|
||||
// customerId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Staff only: government booking flag' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isGovernment?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required when isGovernment is true' })
|
||||
@ValidateIf((o) => o.isGovernment === true)
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
governmentInstitution?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
|
||||
@ValidateIf((o) => o.isGovernment !== true)
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyId?: string;
|
||||
@@ -76,6 +91,12 @@ export class CreateBookingDto {
|
||||
@IsUUID()
|
||||
trainId?: string;
|
||||
|
||||
/** Target schedule this booking is created against (required by the backoffice create form). */
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Target train schedule (pool membership)' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainScheduleId?: string;
|
||||
|
||||
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
PAYMENT_CURRENCIES,
|
||||
TRADE_DIRECTIONS,
|
||||
} from './create-booking.dto';
|
||||
import { PAYMENT_STATUSES } from '../entities/booking.entity';
|
||||
|
||||
export class FilterBookingDto {
|
||||
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
|
||||
@@ -65,6 +66,11 @@ export class FilterBookingDto {
|
||||
@IsIn([...PAYMENT_CURRENCIES])
|
||||
paymentCurrency?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: PAYMENT_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...PAYMENT_STATUSES])
|
||||
paymentStatus?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
@@ -84,8 +90,25 @@ export class FilterBookingDto {
|
||||
@Transform(({ value }) => (value ? parseInt(value, 10) : 20))
|
||||
pageSize?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Comma-separated scheduling statuses (NOT_SCHEDULED,HOLDING,ELIGIBLE,SCHEDULED)',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
if (Array.isArray(value)) return value.map(String).join(',');
|
||||
return String(value);
|
||||
})
|
||||
schedulingStatuses?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter by train schedule assignment' })
|
||||
@IsOptional()
|
||||
@IsIn(['true', 'false'])
|
||||
assignedToSchedule?: 'true' | 'false';
|
||||
|
||||
@ApiPropertyOptional({ default: 'createdAt' })
|
||||
@IsOptional()
|
||||
@IsIn(['createdAt', 'priorityScore', 'scheduledDate', 'isGovernment'])
|
||||
sortBy?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
import { PriceLineItemDto } from './generate-price-response.dto';
|
||||
|
||||
export class SubmitBookingResponseDto {
|
||||
@ApiProperty()
|
||||
bookingId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
status!: string;
|
||||
|
||||
@ApiProperty()
|
||||
priceChanged!: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
previousTotalAmount?: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalAmount!: number;
|
||||
|
||||
@ApiProperty()
|
||||
currency!: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [PriceLineItemDto] })
|
||||
lineItems?: PriceLineItemDto[];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
message?: string;
|
||||
}
|
||||
@@ -15,12 +15,15 @@ export class BookingContainer extends BaseEntity {
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: 'container_type_id', type: 'uuid' })
|
||||
containerTypeId!: string;
|
||||
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
|
||||
containerTypeId?: string | null;
|
||||
|
||||
@ManyToOne(() => ContainerType)
|
||||
@ManyToOne(() => ContainerType, { nullable: true })
|
||||
@JoinColumn({ name: 'container_type_id' })
|
||||
containerType?: ContainerType;
|
||||
containerType?: ContainerType | null;
|
||||
|
||||
@Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true })
|
||||
containerNumber?: string | null;
|
||||
|
||||
@Column({ name: 'quantity', type: 'smallint' })
|
||||
quantity!: number;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Booking } from './booking.entity';
|
||||
|
||||
export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION'] as const;
|
||||
export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION', 'STAFF_NOTE'] as const;
|
||||
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'booking_review_note' })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { SchedulingStatus } from '@edr/types';
|
||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
// import { Customer } from '../../customers/entities/customer.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
@@ -17,6 +18,7 @@ import { BookingReviewNote } from './booking-review-note.entity';
|
||||
export const BOOKING_STATUSES = [
|
||||
'DRAFT',
|
||||
'SUBMITTED',
|
||||
'PRICE_CHANGED_PENDING_CONFIRM',
|
||||
'CHANGES_REQUESTED',
|
||||
'PENDING_APPROVAL',
|
||||
'APPROVED_PENDING_SIGNATURE',
|
||||
@@ -27,6 +29,8 @@ export const BOOKING_STATUSES = [
|
||||
'CONTRACT_READY',
|
||||
'SIGNED_CUSTOMER',
|
||||
'FULLY_EXECUTED',
|
||||
'SELECTED_FOR_BATCH',
|
||||
'EXPIRED',
|
||||
'PNR_GENERATED',
|
||||
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||
'PAID',
|
||||
@@ -53,6 +57,16 @@ export type PaymentStatus = (typeof PAYMENT_STATUSES)[number];
|
||||
export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||
export type FreightType = (typeof FREIGHT_TYPES)[number];
|
||||
|
||||
export const SCHEDULING_STATUSES = [
|
||||
SchedulingStatus.NotScheduled,
|
||||
SchedulingStatus.Holding,
|
||||
SchedulingStatus.Eligible,
|
||||
SchedulingStatus.Scheduled,
|
||||
SchedulingStatus.Dispatched,
|
||||
] as const;
|
||||
|
||||
export type BookingSchedulingStatus = (typeof SCHEDULING_STATUSES)[number];
|
||||
|
||||
/** Statuses where the customer may edit booking fields. */
|
||||
export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [
|
||||
'DRAFT',
|
||||
@@ -71,16 +85,24 @@ export class Booking extends BaseEntity {
|
||||
// @JoinColumn({ name: 'customer_id' })
|
||||
// customer?: Customer;
|
||||
|
||||
@Column({ name: 'company_id', type: 'uuid' })
|
||||
companyId!: string;
|
||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
||||
companyId?: string | null;
|
||||
|
||||
@ManyToOne(() => Company)
|
||||
@ManyToOne(() => Company, { nullable: true })
|
||||
@JoinColumn({ name: 'company_id' })
|
||||
company?: Company;
|
||||
company?: Company | null;
|
||||
|
||||
@Column({ name: 'is_government', type: 'boolean', default: false })
|
||||
isGovernment!: boolean;
|
||||
|
||||
@Column({ name: 'government_institution', type: 'varchar', length: 255, nullable: true })
|
||||
governmentInstitution?: string | null;
|
||||
|
||||
/** @deprecated Fleet master data link — scheduling uses train_schedule_bookings instead. */
|
||||
@Column({ name: 'train_id', type: 'uuid', nullable: true })
|
||||
trainId?: string | null;
|
||||
|
||||
/** @deprecated Use train_schedule_bookings for operational scheduling. */
|
||||
@ManyToOne(() => Train, { nullable: true })
|
||||
@JoinColumn({ name: 'train_id' })
|
||||
train?: Train | null;
|
||||
@@ -242,6 +264,34 @@ export class Booking extends BaseEntity {
|
||||
@JoinColumn({ name: 'consolidation_partner_id' })
|
||||
consolidationPartner?: Booking | null;
|
||||
|
||||
@Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true })
|
||||
wagonsRequired?: number | null;
|
||||
|
||||
@Column({ name: 'scheduling_status', type: 'varchar', length: 30, default: 'NOT_SCHEDULED' })
|
||||
schedulingStatus!: string;
|
||||
|
||||
@Column({ name: 'hold_started_at', type: 'timestamptz', nullable: true })
|
||||
holdStartedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'hold_expires_at', type: 'timestamptz', nullable: true })
|
||||
holdExpiresAt?: Date | null;
|
||||
|
||||
|
||||
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
|
||||
scheduledAt?: Date | null;
|
||||
|
||||
/** The schedule this booking targets (pool membership), set at creation. FK to train_schedules. */
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||
trainScheduleId?: string | null;
|
||||
|
||||
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
|
||||
@Column({ name: 'payment_deadline', type: 'timestamptz', nullable: true })
|
||||
paymentDeadline?: Date | null;
|
||||
|
||||
/** When the batch engine picked this booking and opened the pay window. */
|
||||
@Column({ name: 'selected_for_batch_at', type: 'timestamptz', nullable: true })
|
||||
selectedForBatchAt?: Date | null;
|
||||
|
||||
@OneToMany(() => BookingContainer, (bc) => bc.booking)
|
||||
bookingContainers?: BookingContainer[];
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { CreateCargoDto } from './dto/create-cargo.dto';
|
||||
import { UpdateCargoDto } from './dto/update-cargo.dto';
|
||||
import { LoadCargoDto } from './dto/load-cargo.dto';
|
||||
@@ -18,10 +19,12 @@ import { CargoesService } from './cargoes.service';
|
||||
|
||||
@ApiTags('cargoes')
|
||||
@Controller('cargoes')
|
||||
@FleetView()
|
||||
export class CargoesController {
|
||||
constructor(private readonly cargoesService: CargoesService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Create a new cargo' })
|
||||
create(@Body() dto: CreateCargoDto) {
|
||||
return this.cargoesService.create(dto);
|
||||
@@ -40,30 +43,35 @@ export class CargoesController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Update a cargo' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) {
|
||||
return this.cargoesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Delete a cargo' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.cargoesService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/load')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Load cargo into a container' })
|
||||
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) {
|
||||
return this.cargoesService.loadCargo(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/unload')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Unload cargo from container' })
|
||||
unload(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.cargoesService.unloadCargo(id);
|
||||
}
|
||||
|
||||
@Post(':id/deliver')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Mark cargo as delivered' })
|
||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) {
|
||||
return this.cargoesService.deliverCargo(id, dto);
|
||||
|
||||
@@ -161,9 +161,12 @@ export class CargoesService {
|
||||
if (dto?.receiverName) cargo.receiverName = dto.receiverName;
|
||||
if (dto?.deliveryRemarks) cargo.deliveryRemarks = dto.deliveryRemarks;
|
||||
|
||||
const remaining = await this.cargoRepo.count({
|
||||
const remaining =
|
||||
cargo.containerId != null
|
||||
? await this.cargoRepo.count({
|
||||
where: { containerId: cargo.containerId, status: 'LOADED' },
|
||||
});
|
||||
})
|
||||
: 0;
|
||||
if (remaining === 0 && cargo.container) {
|
||||
cargo.container.status = 'AVAILABLE';
|
||||
await this.containerRepo.save(cargo.container);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// apps/edr-freight-api/src/modules/cargoes/entities/cargo.entity.ts
|
||||
import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Container } from '../../container-management/entities/container.entity';
|
||||
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
|
||||
@Entity({ name: 'cargoes', schema: 'freight' })
|
||||
export class Cargo extends BaseEntity {
|
||||
@@ -11,8 +13,8 @@ export class Cargo extends BaseEntity {
|
||||
@Column({ name: 'shipment_id', type: 'uuid' })
|
||||
shipmentId!: string;
|
||||
|
||||
@Column({ name: 'container_id', type: 'uuid' })
|
||||
containerId!: string;
|
||||
@Column({ name: 'container_id', type: 'uuid', nullable: true })
|
||||
containerId!: string | null;
|
||||
|
||||
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
|
||||
cargoTypeId!: string | null; // optional link to cargo_types table
|
||||
@@ -48,8 +50,25 @@ export class Cargo extends BaseEntity {
|
||||
@Column({ name: 'delivery_remarks', type: 'text', nullable: true })
|
||||
deliveryRemarks!: string | null;
|
||||
|
||||
@Column({ name: 'wagon_booking_allocation_id', type: 'uuid', nullable: true })
|
||||
wagonBookingAllocationId!: string | null;
|
||||
|
||||
@ManyToOne(() => WagonBookingAllocation, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'wagon_booking_allocation_id' })
|
||||
wagonBookingAllocation?: WagonBookingAllocation | null;
|
||||
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId!: string | null;
|
||||
|
||||
@ManyToOne(() => Booking, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking | null;
|
||||
|
||||
@Column({ name: 'load_type', type: 'varchar', length: 20, nullable: true })
|
||||
loadType!: string | null;
|
||||
|
||||
// Relationship to Container
|
||||
@ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT' })
|
||||
@ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT', nullable: true })
|
||||
@JoinColumn({ name: 'container_id' })
|
||||
container!: Container;
|
||||
container!: Container | null;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
const DEFAULT_SCRAPE_URL = 'https://ethio.forex/bank/CBET';
|
||||
|
||||
/** Matches USD buying/selling embedded in ethio.forex CBET page HTML (after entity unescape). */
|
||||
const USD_RATE_REGEX =
|
||||
/currency_code":\[0,"USD"\],"currency_name":\[0,"US DOLLAR"\],"buying":\[0,([\d.]+)\],"selling":\[0,([\d.]+)\]/;
|
||||
|
||||
@Injectable()
|
||||
export class CbeExchangeService {
|
||||
private readonly logger = new Logger(CbeExchangeService.name);
|
||||
private cachedRate: number | null = null;
|
||||
private cacheExpiresAt = 0;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
/**
|
||||
* Returns the current CBE USD→ETB **selling** rate scraped from ethio.forex.
|
||||
* Cached for CBE_EXCHANGE_CACHE_TTL_MS; falls back to CBE_EXCHANGE_FALLBACK_RATE on failure.
|
||||
*/
|
||||
async getUsdToEtbRate(): Promise<number> {
|
||||
const now = Date.now();
|
||||
|
||||
if (this.cachedRate !== null && now < this.cacheExpiresAt) {
|
||||
return this.cachedRate;
|
||||
}
|
||||
|
||||
const scrapeUrl = this.getScrapeUrl();
|
||||
const fallbackRate =
|
||||
this.configService.get<number>('app.cbeExchange.fallbackRate') ?? 130;
|
||||
const cacheTtlMs =
|
||||
this.configService.get<number>('app.cbeExchange.cacheTtlMs') ?? 3_600_000;
|
||||
|
||||
try {
|
||||
const response = await fetch(scrapeUrl, {
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`CBE scrape responded with status ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const rates = this.parseScrapedRates(html);
|
||||
|
||||
if (!rates) {
|
||||
throw new Error('USD rate not found in ethio.forex page HTML');
|
||||
}
|
||||
|
||||
const rate = rates.selling;
|
||||
if (!Number.isFinite(rate) || rate <= 0) {
|
||||
throw new Error(`Invalid selling rate parsed: ${rate}`);
|
||||
}
|
||||
|
||||
this.cachedRate = rate;
|
||||
this.cacheExpiresAt = now + cacheTtlMs;
|
||||
this.logger.log(
|
||||
`CBE USD→ETB rate refreshed from ethio.forex — buying=${rates.buying} selling=${rate}`,
|
||||
);
|
||||
return rate;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to scrape CBE exchange rate — using fallback ${fallbackRate} ETB/USD. Error: ${(err as Error).message}`,
|
||||
);
|
||||
|
||||
if (this.cachedRate !== null) {
|
||||
this.logger.warn(`Using previously cached CBE rate: ${this.cachedRate}`);
|
||||
return this.cachedRate;
|
||||
}
|
||||
|
||||
return fallbackRate;
|
||||
}
|
||||
}
|
||||
|
||||
private getScrapeUrl(): string {
|
||||
const configured =
|
||||
this.configService.get<string>('app.cbeExchange.scrapeUrl') ??
|
||||
this.configService.get<string>('app.cbeExchange.apiUrl');
|
||||
return configured?.trim() || DEFAULT_SCRAPE_URL;
|
||||
}
|
||||
|
||||
private parseScrapedRates(
|
||||
html: string,
|
||||
): { buying: number; selling: number } | null {
|
||||
const decoded = this.unescapeHtml(html);
|
||||
const match = USD_RATE_REGEX.exec(decoded);
|
||||
if (!match) return null;
|
||||
|
||||
const buying = Number(match[1]);
|
||||
const selling = Number(match[2]);
|
||||
if (!Number.isFinite(buying) || !Number.isFinite(selling)) return null;
|
||||
|
||||
return { buying, selling };
|
||||
}
|
||||
|
||||
private unescapeHtml(html: string): string {
|
||||
return html
|
||||
.replace(/"/g, '"')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import { FreightAdmin } from '../../common/booking-guards';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||
@@ -15,6 +16,7 @@ import { ResponseFFClientDto } from './dto/response-ff-client.dto';
|
||||
import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
|
||||
|
||||
interface CurrentIamUser {
|
||||
id: string;
|
||||
@@ -45,6 +47,12 @@ export class CompaniesController {
|
||||
return new ProfileResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
@ApiOperation({ summary: 'Get portal dashboard KPIs (delivered, spend, freight volume) for the current user' })
|
||||
async getDashboard(@CurrentUser() user: CurrentIamUser): Promise<DashboardSummaryResponseDto> {
|
||||
return this.companiesService.getDashboardSummary(user.id);
|
||||
}
|
||||
|
||||
@Patch('profile')
|
||||
@ApiOperation({ summary: 'Update profile (flattened settings page)' })
|
||||
async updateProfile(
|
||||
@@ -75,6 +83,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' })
|
||||
async create(@Body() dto: CreateCompanyDto): Promise<ResponseCompanyDto> {
|
||||
const company = await this.companiesService.createCompany(dto);
|
||||
@@ -112,6 +121,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Update a company' })
|
||||
async update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -122,6 +132,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Soft-delete a company' })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
@@ -140,6 +151,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post(':companyId/profiles')
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
|
||||
async createProfile(
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@@ -168,6 +180,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post('ff-clients')
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Link a forwarder to a client company' })
|
||||
async createFFClient(@Body() dto: CreateFFClientDto): Promise<ResponseFFClientDto> {
|
||||
const client = await this.companiesService.createFFClient(dto);
|
||||
@@ -184,6 +197,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Delete('ff-clients/:id')
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Remove a forwarder-client relationship' })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
|
||||
@@ -6,14 +6,16 @@ import { CompaniesService } from './companies.service';
|
||||
import { CompaniesRepository } from './companies.repository';
|
||||
import { ExternalProfileRepository } from './external-profile.repository';
|
||||
import { FFClientRepository } from './ff-client.repository';
|
||||
import { CompanyDashboardRepository } from './company-dashboard.repository';
|
||||
import { Company } from './entities/company.entity';
|
||||
import { ExternalProfile } from './entities/external-profile.entity';
|
||||
import { FFClient } from './entities/ff-client.entity';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient]), FilesModule],
|
||||
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient, Booking]), FilesModule],
|
||||
controllers: [CompaniesController],
|
||||
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository],
|
||||
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository, CompanyDashboardRepository],
|
||||
exports: [CompaniesService],
|
||||
})
|
||||
export class CompaniesModule {}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common
|
||||
import { CompaniesRepository } from './companies.repository';
|
||||
import { ExternalProfileRepository } from './external-profile.repository';
|
||||
import { FFClientRepository } from './ff-client.repository';
|
||||
import { CompanyDashboardRepository } from './company-dashboard.repository';
|
||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||
import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
|
||||
@@ -9,6 +10,7 @@ import { CreateFFClientDto } from './dto/create-ff-client.dto';
|
||||
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
|
||||
import { Company } from './entities/company.entity';
|
||||
import { ExternalProfile } from './entities/external-profile.entity';
|
||||
import { FFClient } from './entities/ff-client.entity';
|
||||
@@ -27,6 +29,7 @@ export class CompaniesService {
|
||||
private readonly companiesRepo: CompaniesRepository,
|
||||
private readonly profilesRepo: ExternalProfileRepository,
|
||||
private readonly ffClientsRepo: FFClientRepository,
|
||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||
) {}
|
||||
|
||||
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
||||
@@ -98,6 +101,122 @@ export class CompaniesService {
|
||||
return { profile, company };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard KPIs for the portal home (MyPortalPage), aggregated from the
|
||||
* current user's company bookings. All figures are scoped to that company.
|
||||
*
|
||||
* Note: delivered/spend/volume all derive from the bookings table — there is
|
||||
* no separate data source for them. On-time delivery rate is replaced by
|
||||
* completion rate (delivered ÷ committed): the schema has no ETA /
|
||||
* promised-delivery date, so on-time cannot be computed.
|
||||
*
|
||||
* Period attribution uses booking.created_at: there is no delivery-date
|
||||
* column, so "delivered YTD" counts bookings created this year that reached a
|
||||
* delivered/completed status.
|
||||
*/
|
||||
async getDashboardSummary(userId: string): Promise<DashboardSummaryResponseDto> {
|
||||
// A user without a company profile has no bookings — return an empty summary
|
||||
// rather than 404, so the portal home still renders.
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
const companyId = profile?.company?.id ?? profile?.companyId ?? null;
|
||||
if (!companyId) return this.emptyDashboardSummary();
|
||||
|
||||
const now = new Date();
|
||||
const yearStart = new Date(now.getFullYear(), 0, 1);
|
||||
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
|
||||
// Same point in the previous year, so YoY compares like-for-like windows.
|
||||
const prevYearToDate = new Date(prevYearStart.getTime() + (now.getTime() - yearStart.getTime()));
|
||||
|
||||
const [
|
||||
deliveredThis,
|
||||
committedThis,
|
||||
spendThisByCcy,
|
||||
spendPrevByCcy,
|
||||
tonnageThis,
|
||||
tonnagePrev,
|
||||
monthlyRows,
|
||||
] = await Promise.all([
|
||||
this.dashboardRepo.countDelivered(companyId, yearStart, now),
|
||||
this.dashboardRepo.countCommitted(companyId, yearStart, now),
|
||||
this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now),
|
||||
this.dashboardRepo.sumPaidSpendByCurrency(companyId, prevYearStart, prevYearToDate),
|
||||
this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now),
|
||||
this.dashboardRepo.sumCommittedTonnage(companyId, prevYearStart, prevYearToDate),
|
||||
this.dashboardRepo.monthlyCommittedTonnage(companyId, this.monthsAgo(now, 5), now),
|
||||
]);
|
||||
|
||||
// Spend can span currencies; report the dominant one (prefer ETB on ties).
|
||||
const spend = this.pickCurrencyTotal(spendThisByCcy);
|
||||
const spendPrev = spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
|
||||
|
||||
return {
|
||||
deliveredCount: deliveredThis,
|
||||
// Share of committed bookings that reached delivered/completed.
|
||||
completionRate: committedThis > 0 ? Math.round((deliveredThis / committedThis) * 100) : 0,
|
||||
spendYtd: spend.total,
|
||||
spendCurrency: spend.currency,
|
||||
spendYtdChangePct: this.changePct(spend.total, spendPrev),
|
||||
freightVolume: {
|
||||
totalTonnes: Math.round(tonnageThis),
|
||||
totalValue: spend.total,
|
||||
currency: spend.currency,
|
||||
ytdChangePct: this.changePct(tonnageThis, tonnagePrev),
|
||||
monthly: this.buildMonthlySeries(now, monthlyRows),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private emptyDashboardSummary(): DashboardSummaryResponseDto {
|
||||
const now = new Date();
|
||||
return {
|
||||
deliveredCount: 0,
|
||||
completionRate: 0,
|
||||
spendYtd: 0,
|
||||
spendCurrency: 'ETB',
|
||||
spendYtdChangePct: 0,
|
||||
freightVolume: {
|
||||
totalTonnes: 0,
|
||||
totalValue: 0,
|
||||
currency: 'ETB',
|
||||
ytdChangePct: 0,
|
||||
monthly: this.buildMonthlySeries(now, []),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** First day of the month `n` months before `from`. */
|
||||
private monthsAgo(from: Date, n: number): Date {
|
||||
return new Date(from.getFullYear(), from.getMonth() - n, 1);
|
||||
}
|
||||
|
||||
/** Pick the currency with the largest total, preferring ETB on ties / when empty. */
|
||||
private pickCurrencyTotal(totals: { currency: string; total: number }[]): { currency: string; total: number } {
|
||||
if (totals.length === 0) return { currency: 'ETB', total: 0 };
|
||||
return totals.reduce((best, cur) => (cur.total > best.total ? cur : best));
|
||||
}
|
||||
|
||||
/** Percentage change vs a prior value, rounded; 0 when there is no prior base. */
|
||||
private changePct(current: number, previous: number): number {
|
||||
if (previous <= 0) return 0;
|
||||
return Math.round(((current - previous) / previous) * 100);
|
||||
}
|
||||
|
||||
/** Build a fixed 6-month tonnage series ending on `now`, zero-filling gaps. */
|
||||
private buildMonthlySeries(
|
||||
now: Date,
|
||||
rows: { year: number; month: number; tonnes: number }[],
|
||||
): { month: string; tonnes: number }[] {
|
||||
const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes]));
|
||||
const series: { month: string; tonnes: number }[] = [];
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
|
||||
series.push({ month: labels[d.getMonth()], tonnes: Math.round(byKey.get(key) ?? 0) });
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
|
||||
await this.findCompanyById(id);
|
||||
const updated = await this.companiesRepo.update(id, dto);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
/** Booking statuses that represent a delivered/finished shipment. */
|
||||
const DELIVERED_STATUSES = ['DELIVERED', 'COMPLETED'] as const;
|
||||
|
||||
/**
|
||||
* Statuses that represent real, committed freight (excludes drafts and dead
|
||||
* bookings) — used for tonnage so cancelled/expired drafts don't inflate volume.
|
||||
*/
|
||||
const COMMITTED_STATUSES = [
|
||||
'APPROVED',
|
||||
'CONTRACT_READY',
|
||||
'SIGNED_CUSTOMER',
|
||||
'FULLY_EXECUTED',
|
||||
'SELECTED_FOR_BATCH',
|
||||
'PNR_GENERATED',
|
||||
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||
'PAID',
|
||||
'IN_TRANSIT',
|
||||
'COMPLETED',
|
||||
'DELIVERED',
|
||||
'CONSOLIDATED',
|
||||
] as const;
|
||||
|
||||
export interface CurrencyTotal {
|
||||
currency: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface MonthlyTonnage {
|
||||
year: number;
|
||||
month: number; // 1-12
|
||||
tonnes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only aggregation queries against the bookings table, scoped to a
|
||||
* company, that back the portal dashboard. Lives in the companies module so it
|
||||
* can be exposed via `companies.controller` without a circular dependency on
|
||||
* BookingsModule (which already imports CompaniesModule).
|
||||
*/
|
||||
@Injectable()
|
||||
export class CompanyDashboardRepository {
|
||||
constructor(
|
||||
@InjectRepository(Booking)
|
||||
private readonly bookings: Repository<Booking>,
|
||||
) {}
|
||||
|
||||
/** Count of delivered/completed bookings for a company within [from, to). */
|
||||
async countDelivered(companyId: string, from: Date, to: Date): Promise<number> {
|
||||
return this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...DELIVERED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.getCount();
|
||||
}
|
||||
|
||||
/** Count of committed (non-draft, non-dead) bookings for a company within [from, to). */
|
||||
async countCommitted(companyId: string, from: Date, to: Date): Promise<number> {
|
||||
return this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.getCount();
|
||||
}
|
||||
|
||||
/** Sum of paid booking totals, grouped by currency, within [from, to). */
|
||||
async sumPaidSpendByCurrency(companyId: string, from: Date, to: Date): Promise<CurrencyTotal[]> {
|
||||
const rows = await this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('b.payment_currency', 'currency')
|
||||
.addSelect('COALESCE(SUM(b.total_amount), 0)', 'total')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere("b.payment_status = 'PAID'")
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.groupBy('b.payment_currency')
|
||||
.getRawMany<{ currency: string; total: string }>();
|
||||
|
||||
return rows.map((r) => ({ currency: r.currency ?? 'ETB', total: Number(r.total) }));
|
||||
}
|
||||
|
||||
/** Total committed tonnage (cargo VGM) for a company within [from, to). */
|
||||
async sumCommittedTonnage(companyId: string, from: Date, to: Date): Promise<number> {
|
||||
const row = await this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.getRawOne<{ total: string }>();
|
||||
|
||||
return Number(row?.total ?? 0);
|
||||
}
|
||||
|
||||
/** Committed tonnage grouped by calendar month within [from, to). */
|
||||
async monthlyCommittedTonnage(companyId: string, from: Date, to: Date): Promise<MonthlyTonnage[]> {
|
||||
const rows = await this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('EXTRACT(YEAR FROM b.created_at)', 'year')
|
||||
.addSelect('EXTRACT(MONTH FROM b.created_at)', 'month')
|
||||
.addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.groupBy('year')
|
||||
.addGroupBy('month')
|
||||
.getRawMany<{ year: string; month: string; total: string }>();
|
||||
|
||||
return rows.map((r) => ({
|
||||
year: Number(r.year),
|
||||
month: Number(r.month),
|
||||
tonnes: Number(r.total),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class FreightVolumePointDto {
|
||||
@ApiProperty({ example: 'May', description: 'Short month label' })
|
||||
month!: string;
|
||||
|
||||
@ApiProperty({ example: 940, description: 'Tonnage shipped in the month' })
|
||||
tonnes!: number;
|
||||
}
|
||||
|
||||
export class FreightVolumeDto {
|
||||
@ApiProperty({ example: 4180, description: 'Total tonnage shipped year-to-date' })
|
||||
totalTonnes!: number;
|
||||
|
||||
@ApiProperty({ example: 1240000, description: 'Total committed freight value year-to-date' })
|
||||
totalValue!: number;
|
||||
|
||||
@ApiProperty({ example: 'ETB' })
|
||||
currency!: string;
|
||||
|
||||
@ApiProperty({ example: 16, description: 'Tonnage change vs same period last year, in percent' })
|
||||
ytdChangePct!: number;
|
||||
|
||||
@ApiProperty({ type: [FreightVolumePointDto], description: 'Monthly tonnage series (oldest first, last 6 months)' })
|
||||
monthly!: FreightVolumePointDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* KPIs for the portal dashboard (MyPortalPage), aggregated from the current
|
||||
* user's company bookings. All figures are scoped to that company.
|
||||
*
|
||||
* Note: every metric here derives from the bookings table — there is no
|
||||
* separate "non-booking" data source for delivered/spend/volume. On-time
|
||||
* delivery rate is replaced by completion rate: no ETA / promised-delivery
|
||||
* column exists in the schema, so on-time cannot be computed, whereas
|
||||
* completion rate (delivered ÷ committed) can.
|
||||
*/
|
||||
export class DashboardSummaryResponseDto {
|
||||
@ApiProperty({ example: 12, description: 'Bookings delivered/completed year-to-date' })
|
||||
deliveredCount!: number;
|
||||
|
||||
@ApiProperty({
|
||||
example: 92,
|
||||
description: 'Share of committed bookings that have been delivered/completed (YTD), in percent',
|
||||
})
|
||||
completionRate!: number;
|
||||
|
||||
@ApiProperty({ example: 1240000, description: 'Total paid spend year-to-date' })
|
||||
spendYtd!: number;
|
||||
|
||||
@ApiProperty({ example: 'ETB' })
|
||||
spendCurrency!: string;
|
||||
|
||||
@ApiProperty({ example: 16, description: 'Spend change vs same period last year, in percent' })
|
||||
spendYtdChangePct!: number;
|
||||
|
||||
@ApiProperty({ type: FreightVolumeDto })
|
||||
freightVolume!: FreightVolumeDto;
|
||||
}
|
||||
@@ -9,16 +9,19 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FleetManage, FleetView } from "../../common/booking-guards";
|
||||
import { ConsignmentsService } from "./consignments.service";
|
||||
import { CreateConsignmentDto } from "./dto/create-consignment.dto";
|
||||
import { FilterConsignmentDto } from "./dto/filter-consignment.dto";
|
||||
|
||||
@ApiTags("consignments")
|
||||
@Controller("consignments")
|
||||
@FleetView()
|
||||
export class ConsignmentsController {
|
||||
constructor(private readonly consignmentsService: ConsignmentsService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: "Create a new consignment" })
|
||||
create(@Body() dto: CreateConsignmentDto) {
|
||||
return this.consignmentsService.create(dto);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { CreateContainerDto } from './dto/create-container.dto';
|
||||
import { UpdateContainerDto } from './dto/update-container.dto';
|
||||
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
|
||||
@@ -17,10 +18,12 @@ import { ContainersService } from './containers.service';
|
||||
|
||||
@ApiTags('containers')
|
||||
@Controller('containers')
|
||||
@FleetView()
|
||||
export class ContainersController {
|
||||
constructor(private readonly containersService: ContainersService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Create a new container' })
|
||||
create(@Body() dto: CreateContainerDto) {
|
||||
return this.containersService.create(dto);
|
||||
@@ -39,24 +42,28 @@ export class ContainersController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Update a container' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) {
|
||||
return this.containersService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Delete a container' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.containersService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/assign-wagon')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Assign container to a wagon' })
|
||||
assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) {
|
||||
return this.containersService.assignToWagon(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/unassign-wagon')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Unassign container from wagon' })
|
||||
unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.containersService.unassignFromWagon(id);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts
|
||||
import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../../bookings/entities/booking-container.entity';
|
||||
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||
import { Cargo } from '../../cargoes/entities/cargoes.entity';
|
||||
|
||||
@@ -34,7 +37,27 @@ sealNumber!: string | null;
|
||||
@Column({ type: 'varchar', default: 'AVAILABLE' })
|
||||
status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED
|
||||
|
||||
// Relationship to Wagon
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId!: string | null;
|
||||
|
||||
@ManyToOne(() => Booking, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking | null;
|
||||
|
||||
@Column({ name: 'wagon_booking_allocation_id', type: 'uuid', nullable: true })
|
||||
wagonBookingAllocationId!: string | null;
|
||||
|
||||
@ManyToOne(() => WagonBookingAllocation, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'wagon_booking_allocation_id' })
|
||||
wagonBookingAllocation?: WagonBookingAllocation | null;
|
||||
|
||||
@Column({ name: 'booking_container_id', type: 'uuid', nullable: true })
|
||||
bookingContainerId!: string | null;
|
||||
|
||||
@ManyToOne(() => BookingContainer, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'booking_container_id' })
|
||||
bookingContainer?: BookingContainer | null;
|
||||
|
||||
@ManyToOne(() => Wagon, (wagon) => wagon.containers, { onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'wagon_id' })
|
||||
wagon!: Wagon | null;
|
||||
|
||||
@@ -16,12 +16,14 @@ import {
|
||||
|
||||
import { ApiOperation } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { CustomersService } from "./customers.service";
|
||||
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
||||
import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
||||
import { Customer } from "./entities/customer.entity";
|
||||
|
||||
@Controller("customers")
|
||||
@FreightAdmin()
|
||||
export class CustomersController {
|
||||
constructor(private readonly customersService: CustomersService) {}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
|
||||
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
|
||||
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
|
||||
@@ -24,6 +25,9 @@ import { DropdownSettingsService } from "./dropdown-settings.service";
|
||||
export class DropdownSettingsController {
|
||||
constructor(private readonly service: DropdownSettingsService) {}
|
||||
|
||||
// Reads stay open: the customer portal fetches these to render dynamic
|
||||
// dropdowns (by-code). Only writes are admin-guarded.
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List all dropdown settings" })
|
||||
list() {
|
||||
@@ -43,12 +47,14 @@ export class DropdownSettingsController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Create a new dropdown setting" })
|
||||
create(@Body() dto: CreateDropdownSettingDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Update a dropdown setting's metadata" })
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -58,6 +64,7 @@ export class DropdownSettingsController {
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Soft-delete a dropdown setting" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
@@ -67,6 +74,7 @@ export class DropdownSettingsController {
|
||||
/* ------------------------- option routes ------------------------- */
|
||||
|
||||
@Put(":id/options")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Replace the full option list for a setting" })
|
||||
replaceOptions(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -76,6 +84,7 @@ export class DropdownSettingsController {
|
||||
}
|
||||
|
||||
@Post(":id/options")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Append a single option to a setting" })
|
||||
addOption(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -85,6 +94,7 @@ export class DropdownSettingsController {
|
||||
}
|
||||
|
||||
@Patch("options/:optionId")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Update a single option" })
|
||||
updateOption(
|
||||
@Param("optionId", ParseUUIDPipe) optionId: string,
|
||||
@@ -94,6 +104,7 @@ export class DropdownSettingsController {
|
||||
}
|
||||
|
||||
@Delete("options/:optionId")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Soft-delete a single option" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
removeOption(@Param("optionId", ParseUUIDPipe) optionId: string) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { CreateFileUploadFieldDto } from "./dto/create-file-upload-field.dto";
|
||||
import { CreateFileUploadSettingDto } from "./dto/create-file-upload-setting.dto";
|
||||
import { UpdateFileUploadFieldDto } from "./dto/update-file-upload-field.dto";
|
||||
@@ -24,6 +25,9 @@ import { FileUploadSettingsService } from "./file-upload-settings.service";
|
||||
export class FileUploadSettingsController {
|
||||
constructor(private readonly service: FileUploadSettingsService) {}
|
||||
|
||||
// Reads stay open: the customer portal fetches these to render dynamic
|
||||
// upload forms (by-code / by-entity). Only writes are admin-guarded.
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List all file upload settings" })
|
||||
list() {
|
||||
@@ -49,12 +53,14 @@ export class FileUploadSettingsController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Create a new file upload setting" })
|
||||
create(@Body() dto: CreateFileUploadSettingDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Update a file upload setting's metadata" })
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -64,6 +70,7 @@ export class FileUploadSettingsController {
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Soft-delete a file upload setting" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
@@ -73,6 +80,7 @@ export class FileUploadSettingsController {
|
||||
/* ------------------------- field routes ------------------------- */
|
||||
|
||||
@Put(":id/fields")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Replace the full field list for a setting" })
|
||||
replaceFields(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -82,6 +90,7 @@ export class FileUploadSettingsController {
|
||||
}
|
||||
|
||||
@Post(":id/fields")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Append a single field to a setting" })
|
||||
addField(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -91,6 +100,7 @@ export class FileUploadSettingsController {
|
||||
}
|
||||
|
||||
@Patch("fields/:fieldId")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Update a single field" })
|
||||
updateField(
|
||||
@Param("fieldId", ParseUUIDPipe) fieldId: string,
|
||||
@@ -100,6 +110,7 @@ export class FileUploadSettingsController {
|
||||
}
|
||||
|
||||
@Delete("fields/:fieldId")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Soft-delete a single field" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
removeField(@Param("fieldId", ParseUUIDPipe) fieldId: string) {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsIn, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
import { IsIn, IsNumber, IsOptional, IsString, MaxLength, Min, IsUUID } from 'class-validator';
|
||||
|
||||
import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity';
|
||||
import {
|
||||
LOCOMOTIVE_STATUSES,
|
||||
LOCOMOTIVE_TYPES,
|
||||
} from '../entities/locomotive.entity';
|
||||
|
||||
export class CreateLocomotiveDto {
|
||||
@ApiProperty({ example: 'LOCO-001' })
|
||||
@@ -24,6 +27,11 @@ export class CreateLocomotiveDto {
|
||||
@IsIn([...LOCOMOTIVE_STATUSES])
|
||||
status!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Current yard location' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
currentYardId?: string;
|
||||
|
||||
@ApiProperty({ example: 3500 })
|
||||
@Transform(({ value }) => Number(value))
|
||||
@IsNumber()
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional } from 'class-validator';
|
||||
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity';
|
||||
import {
|
||||
LOCOMOTIVE_STATUSES,
|
||||
LOCOMOTIVE_TYPES,
|
||||
} from '../entities/locomotive.entity';
|
||||
|
||||
export class FilterLocomotivesDto {
|
||||
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
|
||||
@@ -13,4 +16,9 @@ export class FilterLocomotivesDto {
|
||||
@IsOptional()
|
||||
@IsIn([...LOCOMOTIVE_TYPES])
|
||||
locomotiveType?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by current yard' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
currentYardId?: string;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, OneToMany } from 'typeorm';
|
||||
import { Column, Entity, Index, OneToMany, ManyToOne, JoinColumn } from 'typeorm';
|
||||
|
||||
import { TrainSet } from '../../train-sets/entities/train-set.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
|
||||
export const LOCOMOTIVE_STATUSES = [
|
||||
'AVAILABLE',
|
||||
@@ -21,6 +22,7 @@ export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number];
|
||||
@Entity({ schema: 'freight', name: 'locomotives' })
|
||||
@Index(['code'])
|
||||
@Index(['status'])
|
||||
@Index(['currentYardId'])
|
||||
export class Locomotive extends BaseEntity {
|
||||
@Column({ name: 'code', type: 'varchar', length: 32, unique: true })
|
||||
code!: string;
|
||||
@@ -40,6 +42,13 @@ export class Locomotive extends BaseEntity {
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
|
||||
status!: LocomotiveStatus;
|
||||
|
||||
@Column({ name: 'current_yard_id', type: 'uuid', nullable: true })
|
||||
currentYardId!: string | null;
|
||||
|
||||
@ManyToOne(() => Yard, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'current_yard_id' })
|
||||
currentYard?: Yard | null;
|
||||
|
||||
@Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||
powerKw?: number | null;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
||||
@@ -9,6 +10,7 @@ import { LocomotivesService } from './locomotives.service';
|
||||
@ApiTags('locomotives')
|
||||
@ApiBearerAuth()
|
||||
@Controller('locomotives')
|
||||
@FleetView()
|
||||
export class LocomotivesController {
|
||||
constructor(private readonly locomotivesService: LocomotivesService) {}
|
||||
|
||||
@@ -25,18 +27,21 @@ export class LocomotivesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Create a locomotive' })
|
||||
create(@Body() dto: CreateLocomotiveDto) {
|
||||
return this.locomotivesService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Update a locomotive' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) {
|
||||
return this.locomotivesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/decommission')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Decommission a locomotive' })
|
||||
decommission(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.locomotivesService.decommission(id);
|
||||
|
||||
@@ -3,7 +3,12 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common
|
||||
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
||||
import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity';
|
||||
|
||||
import {
|
||||
Locomotive,
|
||||
type LocomotiveStatus,
|
||||
type LocomotiveType,
|
||||
} from './entities/locomotive.entity';
|
||||
import { LocomotivesRepository } from './locomotives.repository';
|
||||
|
||||
@Injectable()
|
||||
@@ -17,7 +22,9 @@ export class LocomotivesService {
|
||||
...(filter.locomotiveType
|
||||
? { locomotiveType: filter.locomotiveType as LocomotiveType }
|
||||
: {}),
|
||||
...(filter.currentYardId ? { currentYardId: filter.currentYardId } : {}),
|
||||
},
|
||||
relations: { currentYard: true },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
@@ -34,6 +41,7 @@ export class LocomotivesService {
|
||||
name: dto.name?.trim() || null,
|
||||
locomotiveType: dto.locomotiveType as LocomotiveType,
|
||||
status: dto.status as LocomotiveStatus,
|
||||
currentYardId: dto.currentYardId ?? null,
|
||||
maxPullWeightTons: dto.maxPullWeightTons,
|
||||
maxTrainLengthMeters: dto.maxTrainLengthMeters,
|
||||
powerKw: dto.powerKw ?? null,
|
||||
@@ -43,7 +51,9 @@ export class LocomotivesService {
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Locomotive> {
|
||||
const locomotive = await this.locomotivesRepository.findById(id);
|
||||
const locomotive = await this.locomotivesRepository.findById(id, {
|
||||
relations: { currentYard: true },
|
||||
});
|
||||
|
||||
if (!locomotive) {
|
||||
throw new NotFoundException(`Locomotive ${id} not found`);
|
||||
@@ -67,6 +77,10 @@ export class LocomotivesService {
|
||||
locomotiveType:
|
||||
dto.locomotiveType === undefined ? locomotive.locomotiveType : dto.locomotiveType as LocomotiveType,
|
||||
status: dto.status === undefined ? locomotive.status : dto.status as LocomotiveStatus,
|
||||
currentYardId:
|
||||
dto.currentYardId === undefined
|
||||
? locomotive.currentYardId
|
||||
: (dto.currentYardId ?? null),
|
||||
name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null,
|
||||
powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null,
|
||||
tractionForceKn:
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional } from 'class-validator';
|
||||
|
||||
const OVERVIEW_RANGES = ['7d', '30d', '90d'] as const;
|
||||
|
||||
export type OverviewRangeQuery = (typeof OVERVIEW_RANGES)[number];
|
||||
|
||||
export class OverviewQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: OVERVIEW_RANGES,
|
||||
default: '30d',
|
||||
description: 'Time range for trend charts',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(OVERVIEW_RANGES)
|
||||
range?: OverviewRangeQuery = '30d';
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class OverviewBookingKpisDto {
|
||||
@ApiProperty() totalActive!: number;
|
||||
@ApiProperty() needsAction!: number;
|
||||
@ApiProperty() urgent!: number;
|
||||
@ApiProperty() inApproval!: number;
|
||||
@ApiProperty() submittedToday!: number;
|
||||
}
|
||||
|
||||
export class OverviewOperationsKpisDto {
|
||||
@ApiProperty() trainsActive!: number;
|
||||
@ApiProperty() wagonsAvailable!: number;
|
||||
@ApiProperty() containersInTransit!: number;
|
||||
@ApiProperty() cargoesLoaded!: number;
|
||||
}
|
||||
|
||||
export class OverviewCustomerKpisDto {
|
||||
@ApiProperty() totalCustomers!: number;
|
||||
@ApiProperty() newCustomersThisMonth!: number;
|
||||
}
|
||||
|
||||
export class OverviewBillingKpisDto {
|
||||
@ApiProperty() revenueMtdEtb!: number;
|
||||
@ApiProperty() revenueMtdUsd!: number;
|
||||
@ApiProperty() pendingPayments!: number;
|
||||
@ApiProperty() successfulPaymentsMtd!: number;
|
||||
}
|
||||
|
||||
export class OverviewStaffKpisDto {
|
||||
@ApiProperty() activeEmployees!: number;
|
||||
@ApiProperty() activeUsers!: number;
|
||||
}
|
||||
|
||||
export class OverviewKpisDto {
|
||||
@ApiProperty({ type: OverviewBookingKpisDto })
|
||||
bookings!: OverviewBookingKpisDto;
|
||||
|
||||
@ApiProperty({ type: OverviewOperationsKpisDto })
|
||||
operations!: OverviewOperationsKpisDto;
|
||||
|
||||
@ApiProperty({ type: OverviewCustomerKpisDto })
|
||||
customers!: OverviewCustomerKpisDto;
|
||||
|
||||
@ApiProperty({ type: OverviewBillingKpisDto })
|
||||
billing!: OverviewBillingKpisDto;
|
||||
|
||||
@ApiProperty({ type: OverviewStaffKpisDto })
|
||||
staff!: OverviewStaffKpisDto;
|
||||
}
|
||||
|
||||
export class OverviewTrendPointDto {
|
||||
@ApiProperty({ example: '2026-06-01' }) date!: string;
|
||||
@ApiProperty() count!: number;
|
||||
}
|
||||
|
||||
export class OverviewStatusCountDto {
|
||||
@ApiProperty() status!: string;
|
||||
@ApiProperty() count!: number;
|
||||
}
|
||||
|
||||
export class OverviewPipelineCountDto {
|
||||
@ApiProperty() stage!: string;
|
||||
@ApiProperty() count!: number;
|
||||
}
|
||||
|
||||
export class OverviewPaymentTrendPointDto {
|
||||
@ApiProperty({ example: '2026-06-01' }) date!: string;
|
||||
@ApiProperty() amountEtb!: number;
|
||||
@ApiProperty() amountUsd!: number;
|
||||
}
|
||||
|
||||
export class OverviewRecentBookingDto {
|
||||
@ApiProperty() id!: string;
|
||||
@ApiProperty() reference!: string;
|
||||
@ApiProperty() customerLabel!: string;
|
||||
@ApiProperty() status!: string;
|
||||
@ApiProperty() priorityScore!: number;
|
||||
@ApiProperty({ nullable: true }) totalAmount!: number | null;
|
||||
@ApiProperty({ nullable: true }) paymentCurrency!: string | null;
|
||||
@ApiProperty() createdAt!: string;
|
||||
}
|
||||
|
||||
export class OverviewResponseDto {
|
||||
@ApiProperty({ type: OverviewKpisDto })
|
||||
kpis!: OverviewKpisDto;
|
||||
|
||||
@ApiProperty({ type: [OverviewTrendPointDto] })
|
||||
bookingTrend!: OverviewTrendPointDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
bookingsByStatus!: OverviewStatusCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewPipelineCountDto] })
|
||||
bookingsByPipeline!: OverviewPipelineCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewPaymentTrendPointDto] })
|
||||
paymentTrend!: OverviewPaymentTrendPointDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewRecentBookingDto] })
|
||||
recentBookings!: OverviewRecentBookingDto[];
|
||||
|
||||
@ApiProperty() generatedAt!: string;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
OverviewBillingKpisDto,
|
||||
OverviewBookingKpisDto,
|
||||
OverviewCustomerKpisDto,
|
||||
OverviewOperationsKpisDto,
|
||||
OverviewPaymentTrendPointDto,
|
||||
OverviewPipelineCountDto,
|
||||
OverviewRecentBookingDto,
|
||||
OverviewStaffKpisDto,
|
||||
OverviewStatusCountDto,
|
||||
OverviewTrendPointDto,
|
||||
} from './overview-response.dto';
|
||||
|
||||
export class OverviewLabelCountDto {
|
||||
@ApiProperty() label!: string;
|
||||
@ApiProperty() count!: number;
|
||||
}
|
||||
|
||||
export class OverviewPaymentMethodDto {
|
||||
@ApiProperty() method!: string;
|
||||
@ApiProperty() count!: number;
|
||||
@ApiProperty() amountEtb!: number;
|
||||
@ApiProperty() amountUsd!: number;
|
||||
}
|
||||
|
||||
export class OverviewCurrencyAmountDto {
|
||||
@ApiProperty() currency!: string;
|
||||
@ApiProperty() amount!: number;
|
||||
}
|
||||
|
||||
export class OverviewBookingsTabDto {
|
||||
@ApiProperty({ type: OverviewBookingKpisDto })
|
||||
kpis!: OverviewBookingKpisDto;
|
||||
|
||||
@ApiProperty({ type: [OverviewTrendPointDto] })
|
||||
bookingTrend!: OverviewTrendPointDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
bookingsByStatus!: OverviewStatusCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewPipelineCountDto] })
|
||||
bookingsByPipeline!: OverviewPipelineCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewLabelCountDto] })
|
||||
bookingsByFreightType!: OverviewLabelCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewLabelCountDto] })
|
||||
bookingsByCurrency!: OverviewLabelCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewRecentBookingDto] })
|
||||
recentBookings!: OverviewRecentBookingDto[];
|
||||
|
||||
@ApiProperty()
|
||||
generatedAt!: string;
|
||||
}
|
||||
|
||||
export class OverviewBillingTabDto {
|
||||
@ApiProperty({ type: OverviewBillingKpisDto })
|
||||
kpis!: OverviewBillingKpisDto;
|
||||
|
||||
@ApiProperty({ type: [OverviewPaymentTrendPointDto] })
|
||||
paymentTrend!: OverviewPaymentTrendPointDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
paymentsByStatus!: OverviewStatusCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewPaymentMethodDto] })
|
||||
paymentsByMethod!: OverviewPaymentMethodDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewCurrencyAmountDto] })
|
||||
revenueByCurrency!: OverviewCurrencyAmountDto[];
|
||||
|
||||
@ApiProperty()
|
||||
generatedAt!: string;
|
||||
}
|
||||
|
||||
export class OverviewOperationsTabDto {
|
||||
@ApiProperty({ type: OverviewOperationsKpisDto })
|
||||
kpis!: OverviewOperationsKpisDto;
|
||||
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
trainStatusBreakdown!: OverviewStatusCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
wagonStatusBreakdown!: OverviewStatusCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
containerStatusBreakdown!: OverviewStatusCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
cargoStatusBreakdown!: OverviewStatusCountDto[];
|
||||
|
||||
@ApiProperty()
|
||||
generatedAt!: string;
|
||||
}
|
||||
|
||||
export class OverviewCustomersTabDto {
|
||||
@ApiProperty({ type: OverviewCustomerKpisDto })
|
||||
kpis!: OverviewCustomerKpisDto;
|
||||
|
||||
@ApiProperty({ type: [OverviewTrendPointDto] })
|
||||
customerGrowthTrend!: OverviewTrendPointDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewLabelCountDto] })
|
||||
customersByType!: OverviewLabelCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewLabelCountDto] })
|
||||
topCustomersByBookings!: OverviewLabelCountDto[];
|
||||
|
||||
@ApiProperty()
|
||||
generatedAt!: string;
|
||||
}
|
||||
|
||||
export class OverviewStaffTabDto {
|
||||
@ApiProperty({ type: OverviewStaffKpisDto })
|
||||
kpis!: OverviewStaffKpisDto;
|
||||
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
usersByStatus!: OverviewStatusCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewTrendPointDto] })
|
||||
employeeGrowthTrend!: OverviewTrendPointDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewLabelCountDto] })
|
||||
activeUsersBreakdown!: OverviewLabelCountDto[];
|
||||
|
||||
@ApiProperty()
|
||||
generatedAt!: string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export const OVERVIEW_URGENT_PRIORITY_THRESHOLD = 1000;
|
||||
|
||||
export const OVERVIEW_NEEDS_ACTION_STATUSES = [
|
||||
'SUBMITTED',
|
||||
'PENDING_APPROVAL',
|
||||
'APPROVED_PENDING_SIGNATURE',
|
||||
] as const;
|
||||
|
||||
export const OVERVIEW_IN_APPROVAL_STATUSES = [
|
||||
'PENDING_APPROVAL',
|
||||
'APPROVED_PENDING_SIGNATURE',
|
||||
] as const;
|
||||
|
||||
export const OVERVIEW_CLOSED_STATUSES = [
|
||||
'REJECTED',
|
||||
'CANCELLED',
|
||||
'COMPLETED',
|
||||
] as const;
|
||||
|
||||
export const OVERVIEW_RANGE_DAYS = {
|
||||
'7d': 7,
|
||||
'30d': 30,
|
||||
'90d': 90,
|
||||
} as const;
|
||||
|
||||
export type OverviewRange = keyof typeof OVERVIEW_RANGE_DAYS;
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { BookingView } from '../../common/booking-guards';
|
||||
import { OverviewQueryDto } from './dto/overview-query.dto';
|
||||
import { OverviewResponseDto } from './dto/overview-response.dto';
|
||||
import {
|
||||
OverviewBillingTabDto,
|
||||
OverviewBookingsTabDto,
|
||||
OverviewCustomersTabDto,
|
||||
OverviewOperationsTabDto,
|
||||
OverviewStaffTabDto,
|
||||
} from './dto/overview-tab-response.dto';
|
||||
import { OverviewService } from './overview.service';
|
||||
|
||||
@ApiTags('Overview')
|
||||
@ApiBearerAuth()
|
||||
@Controller('overview')
|
||||
export class OverviewController {
|
||||
constructor(private readonly overviewService: OverviewService) {}
|
||||
|
||||
@Get()
|
||||
@BookingView()
|
||||
@ApiOperation({ summary: 'Aggregated dashboard summary for backoffice overview' })
|
||||
@ApiOkResponse({ type: OverviewResponseDto })
|
||||
getDashboard(@Query() query: OverviewQueryDto): Promise<OverviewResponseDto> {
|
||||
return this.overviewService.getDashboard(query.range ?? '30d');
|
||||
}
|
||||
|
||||
@Get('bookings')
|
||||
@BookingView()
|
||||
@ApiOperation({ summary: 'Bookings tab metrics and charts' })
|
||||
@ApiOkResponse({ type: OverviewBookingsTabDto })
|
||||
getBookingsTab(@Query() query: OverviewQueryDto): Promise<OverviewBookingsTabDto> {
|
||||
return this.overviewService.getBookingsTab(query.range ?? '30d');
|
||||
}
|
||||
|
||||
@Get('billing')
|
||||
@BookingView()
|
||||
@ApiOperation({ summary: 'Billing tab metrics and charts' })
|
||||
@ApiOkResponse({ type: OverviewBillingTabDto })
|
||||
getBillingTab(@Query() query: OverviewQueryDto): Promise<OverviewBillingTabDto> {
|
||||
return this.overviewService.getBillingTab(query.range ?? '30d');
|
||||
}
|
||||
|
||||
@Get('operations')
|
||||
@BookingView()
|
||||
@ApiOperation({ summary: 'Operations tab metrics and charts' })
|
||||
@ApiOkResponse({ type: OverviewOperationsTabDto })
|
||||
getOperationsTab(): Promise<OverviewOperationsTabDto> {
|
||||
return this.overviewService.getOperationsTab();
|
||||
}
|
||||
|
||||
@Get('customers')
|
||||
@BookingView()
|
||||
@ApiOperation({ summary: 'Customers tab metrics and charts' })
|
||||
@ApiOkResponse({ type: OverviewCustomersTabDto })
|
||||
getCustomersTab(@Query() query: OverviewQueryDto): Promise<OverviewCustomersTabDto> {
|
||||
return this.overviewService.getCustomersTab(query.range ?? '30d');
|
||||
}
|
||||
|
||||
@Get('staff')
|
||||
@BookingView()
|
||||
@ApiOperation({ summary: 'Staff tab metrics and charts' })
|
||||
@ApiOkResponse({ type: OverviewStaffTabDto })
|
||||
getStaffTab(@Query() query: OverviewQueryDto): Promise<OverviewStaffTabDto> {
|
||||
return this.overviewService.getStaffTab(query.range ?? '30d');
|
||||
}
|
||||
}
|
||||
34
apps/edr-freight-api/src/modules/overview/overview.module.ts
Normal file
34
apps/edr-freight-api/src/modules/overview/overview.module.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Employee } from '@tria-plc/iamapi-common';
|
||||
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
||||
import { Container } from '../container-management/entities/container.entity';
|
||||
import { Customer } from '../customers/entities/customer.entity';
|
||||
import { PaymentEntity } from '../payment/entities/payment.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { OverviewController } from './overview.controller';
|
||||
import { OverviewRepository } from './overview.repository';
|
||||
import { OverviewService } from './overview.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Booking,
|
||||
PaymentEntity,
|
||||
Customer,
|
||||
Train,
|
||||
Wagon,
|
||||
Container,
|
||||
Cargo,
|
||||
Employee,
|
||||
User,
|
||||
]),
|
||||
],
|
||||
controllers: [OverviewController],
|
||||
providers: [OverviewService, OverviewRepository],
|
||||
})
|
||||
export class OverviewModule {}
|
||||
553
apps/edr-freight-api/src/modules/overview/overview.repository.ts
Normal file
553
apps/edr-freight-api/src/modules/overview/overview.repository.ts
Normal file
@@ -0,0 +1,553 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
|
||||
import { Employee } from '@tria-plc/iamapi-common';
|
||||
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
||||
import { Freight } from '@edr/types';
|
||||
import { Repository, ObjectLiteral } from 'typeorm';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
||||
import { Container } from '../container-management/entities/container.entity';
|
||||
import { Customer } from '../customers/entities/customer.entity';
|
||||
import { PaymentEntity } from '../payment/entities/payment.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import {
|
||||
OVERVIEW_CLOSED_STATUSES,
|
||||
OVERVIEW_IN_APPROVAL_STATUSES,
|
||||
OVERVIEW_NEEDS_ACTION_STATUSES,
|
||||
OVERVIEW_URGENT_PRIORITY_THRESHOLD,
|
||||
} from './overview.constants';
|
||||
|
||||
export type OverviewBookingKpisRow = {
|
||||
totalActive: number;
|
||||
needsAction: number;
|
||||
urgent: number;
|
||||
inApproval: number;
|
||||
submittedToday: number;
|
||||
};
|
||||
|
||||
export type OverviewRecentBookingRow = {
|
||||
id: string;
|
||||
reference: string;
|
||||
customerLabel: string;
|
||||
status: string;
|
||||
priorityScore: number;
|
||||
totalAmount: number | null;
|
||||
paymentCurrency: string | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OverviewRepository {
|
||||
constructor(
|
||||
@InjectRepository(Booking)
|
||||
private readonly bookingRepository: Repository<Booking>,
|
||||
@InjectRepository(PaymentEntity)
|
||||
private readonly paymentRepository: Repository<PaymentEntity>,
|
||||
@InjectRepository(Customer)
|
||||
private readonly customerRepository: Repository<Customer>,
|
||||
@InjectRepository(Train)
|
||||
private readonly trainRepository: Repository<Train>,
|
||||
@InjectRepository(Wagon)
|
||||
private readonly wagonRepository: Repository<Wagon>,
|
||||
@InjectRepository(Container)
|
||||
private readonly containerRepository: Repository<Container>,
|
||||
@InjectRepository(Cargo)
|
||||
private readonly cargoRepository: Repository<Cargo>,
|
||||
@InjectRepository(Employee)
|
||||
private readonly employeeRepository: Repository<Employee>,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
async getBookingKpis(): Promise<OverviewBookingKpisRow> {
|
||||
const row = await this.bookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.select(
|
||||
`COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`,
|
||||
'totalActive',
|
||||
)
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE booking.status IN (:...needsActionStatuses))::int`,
|
||||
'needsAction',
|
||||
)
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE booking.priority_score >= :urgentThreshold)::int`,
|
||||
'urgent',
|
||||
)
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE booking.status IN (:...inApprovalStatuses))::int`,
|
||||
'inApproval',
|
||||
)
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE booking.created_at >= CURRENT_DATE AND booking.status != 'DRAFT')::int`,
|
||||
'submittedToday',
|
||||
)
|
||||
.where('booking.deleted_at IS NULL')
|
||||
.setParameters({
|
||||
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
|
||||
needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES],
|
||||
inApprovalStatuses: [...OVERVIEW_IN_APPROVAL_STATUSES],
|
||||
urgentThreshold: OVERVIEW_URGENT_PRIORITY_THRESHOLD,
|
||||
})
|
||||
.getRawOne<Record<string, string>>();
|
||||
|
||||
return {
|
||||
totalActive: Number(row?.totalActive ?? 0),
|
||||
needsAction: Number(row?.needsAction ?? 0),
|
||||
urgent: Number(row?.urgent ?? 0),
|
||||
inApproval: Number(row?.inApproval ?? 0),
|
||||
submittedToday: Number(row?.submittedToday ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async getOperationsKpis(): Promise<{
|
||||
trainsActive: number;
|
||||
wagonsAvailable: number;
|
||||
containersInTransit: number;
|
||||
cargoesLoaded: number;
|
||||
}> {
|
||||
const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] =
|
||||
await Promise.all([
|
||||
this.trainRepository
|
||||
.createQueryBuilder('train')
|
||||
.where('train.deleted_at IS NULL')
|
||||
.andWhere('train.status IN (:...statuses)', {
|
||||
statuses: [
|
||||
Freight.TrainStatus.InService,
|
||||
Freight.TrainStatus.Scheduled,
|
||||
],
|
||||
})
|
||||
.getCount(),
|
||||
this.wagonRepository
|
||||
.createQueryBuilder('wagon')
|
||||
.where('wagon.deleted_at IS NULL')
|
||||
.andWhere('wagon.status = :status', { status: Freight.WagonStatus.Available })
|
||||
.getCount(),
|
||||
this.containerRepository
|
||||
.createQueryBuilder('container')
|
||||
.where('container.deleted_at IS NULL')
|
||||
.andWhere('container.status = :status', { status: 'IN_TRANSIT' })
|
||||
.getCount(),
|
||||
this.cargoRepository
|
||||
.createQueryBuilder('cargo')
|
||||
.where('cargo.deleted_at IS NULL')
|
||||
.andWhere('cargo.status IN (:...statuses)', {
|
||||
statuses: ['LOADED', 'IN_TRANSIT'],
|
||||
})
|
||||
.getCount(),
|
||||
]);
|
||||
|
||||
return { trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded };
|
||||
}
|
||||
|
||||
async getCustomerKpis(): Promise<{
|
||||
totalCustomers: number;
|
||||
newCustomersThisMonth: number;
|
||||
}> {
|
||||
const row = await this.customerRepository
|
||||
.createQueryBuilder('customer')
|
||||
.select('COUNT(*)::int', 'totalCustomers')
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE customer.created_at >= date_trunc('month', CURRENT_DATE))::int`,
|
||||
'newCustomersThisMonth',
|
||||
)
|
||||
.where('customer.deleted_at IS NULL')
|
||||
.getRawOne<Record<string, string>>();
|
||||
|
||||
return {
|
||||
totalCustomers: Number(row?.totalCustomers ?? 0),
|
||||
newCustomersThisMonth: Number(row?.newCustomersThisMonth ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async getBillingKpis(): Promise<{
|
||||
revenueMtdEtb: number;
|
||||
revenueMtdUsd: number;
|
||||
pendingPayments: number;
|
||||
successfulPaymentsMtd: number;
|
||||
}> {
|
||||
const revenueRow = await this.paymentRepository
|
||||
.createQueryBuilder('payment')
|
||||
.select(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||||
'revenueMtdEtb',
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||
'revenueMtdUsd',
|
||||
)
|
||||
.addSelect(`COUNT(*)::int`, 'successfulPaymentsMtd')
|
||||
.where('payment.status = :status', { status: 'success' })
|
||||
.andWhere(
|
||||
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
|
||||
)
|
||||
.getRawOne<Record<string, string>>();
|
||||
|
||||
const pendingPayments = await this.paymentRepository
|
||||
.createQueryBuilder('payment')
|
||||
.where('payment.status IN (:...statuses)', {
|
||||
statuses: ['action-required', 'processing'],
|
||||
})
|
||||
.getCount();
|
||||
|
||||
return {
|
||||
revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0),
|
||||
revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0),
|
||||
pendingPayments,
|
||||
successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async getStaffKpis(): Promise<{ activeEmployees: number; activeUsers: number }> {
|
||||
const [activeEmployees, activeUsers] = await Promise.all([
|
||||
this.employeeRepository.count({
|
||||
where: { isCurrent: true },
|
||||
}),
|
||||
this.userRepository.count({
|
||||
where: {
|
||||
isActive: true,
|
||||
status: EUserStatus.ACCEPTED,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { activeEmployees, activeUsers };
|
||||
}
|
||||
|
||||
async getBookingTrend(days: number): Promise<{ date: string; count: number }[]> {
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, 'date')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('booking.deleted_at IS NULL')
|
||||
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||
.groupBy('booking.created_at::date')
|
||||
.orderBy('booking.created_at::date', 'ASC')
|
||||
.getRawMany<{ date: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
date: row.date,
|
||||
count: Number(row.count),
|
||||
}));
|
||||
}
|
||||
|
||||
async getStatusCounts(): Promise<Record<string, number>> {
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.select('booking.status', 'status')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('booking.deleted_at IS NULL')
|
||||
.groupBy('booking.status')
|
||||
.getRawMany<{ status: string; count: string }>();
|
||||
|
||||
return Object.fromEntries(
|
||||
rows.map((row) => [row.status, Number(row.count)]),
|
||||
);
|
||||
}
|
||||
|
||||
async getPaymentTrend(
|
||||
days: number,
|
||||
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
|
||||
const rows = await this.paymentRepository
|
||||
.createQueryBuilder('payment')
|
||||
.select(
|
||||
`to_char(COALESCE(payment.paid_at, payment.created_at)::date, 'YYYY-MM-DD')`,
|
||||
'date',
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||||
'amountEtb',
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||
'amountUsd',
|
||||
)
|
||||
.where('payment.status = :status', { status: 'success' })
|
||||
.andWhere(
|
||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||||
{ days },
|
||||
)
|
||||
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
|
||||
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, 'ASC')
|
||||
.getRawMany<{ date: string; amountEtb: string; amountUsd: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
date: row.date,
|
||||
amountEtb: Number(row.amountEtb),
|
||||
amountUsd: Number(row.amountUsd),
|
||||
}));
|
||||
}
|
||||
|
||||
async getRecentBookings(limit: number): Promise<OverviewRecentBookingRow[]> {
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoin('booking.company', 'company')
|
||||
.select('booking.id', 'id')
|
||||
.addSelect('booking.reference', 'reference')
|
||||
.addSelect('COALESCE(company.name, \'—\')', 'customerLabel')
|
||||
.addSelect('booking.status', 'status')
|
||||
.addSelect('booking.priority_score', 'priorityScore')
|
||||
.addSelect('booking.total_amount', 'totalAmount')
|
||||
.addSelect('booking.payment_currency', 'paymentCurrency')
|
||||
.addSelect('booking.created_at', 'createdAt')
|
||||
.where('booking.deleted_at IS NULL')
|
||||
.orderBy('booking.created_at', 'DESC')
|
||||
.limit(limit)
|
||||
.getRawMany<{
|
||||
id: string;
|
||||
reference: string;
|
||||
customerLabel: string;
|
||||
status: string;
|
||||
priorityScore: string;
|
||||
totalAmount: string | null;
|
||||
paymentCurrency: string | null;
|
||||
createdAt: Date;
|
||||
}>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
reference: row.reference,
|
||||
customerLabel: row.customerLabel,
|
||||
status: row.status,
|
||||
priorityScore: Number(row.priorityScore),
|
||||
totalAmount: row.totalAmount != null ? Number(row.totalAmount) : null,
|
||||
paymentCurrency: row.paymentCurrency,
|
||||
createdAt: row.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async getBookingsByFreightType(): Promise<{ label: string; count: number }[]> {
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.select('booking.freight_type', 'label')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('booking.deleted_at IS NULL')
|
||||
.andWhere("booking.status != 'DRAFT'")
|
||||
.groupBy('booking.freight_type')
|
||||
.orderBy('count', 'DESC')
|
||||
.getRawMany<{ label: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
label: row.label,
|
||||
count: Number(row.count),
|
||||
}));
|
||||
}
|
||||
|
||||
async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> {
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.select('booking.payment_currency', 'label')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('booking.deleted_at IS NULL')
|
||||
.andWhere("booking.status != 'DRAFT'")
|
||||
.groupBy('booking.payment_currency')
|
||||
.orderBy('count', 'DESC')
|
||||
.getRawMany<{ label: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
label: row.label,
|
||||
count: Number(row.count),
|
||||
}));
|
||||
}
|
||||
|
||||
async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> {
|
||||
const rows = await this.paymentRepository
|
||||
.createQueryBuilder('payment')
|
||||
.select('payment.status', 'status')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.groupBy('payment.status')
|
||||
.orderBy('count', 'DESC')
|
||||
.getRawMany<{ status: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
status: row.status,
|
||||
count: Number(row.count),
|
||||
}));
|
||||
}
|
||||
|
||||
async getPaymentsByMethod(): Promise<
|
||||
{ method: string; count: number; amountEtb: number; amountUsd: number }[]
|
||||
> {
|
||||
const rows = await this.paymentRepository
|
||||
.createQueryBuilder('payment')
|
||||
.select('payment.method', 'method')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB' AND payment.status = 'success'), 0)`,
|
||||
'amountEtb',
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
|
||||
'amountUsd',
|
||||
)
|
||||
.groupBy('payment.method')
|
||||
.orderBy('count', 'DESC')
|
||||
.getRawMany<{ method: string; count: string; amountEtb: string; amountUsd: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
method: row.method,
|
||||
count: Number(row.count),
|
||||
amountEtb: Number(row.amountEtb),
|
||||
amountUsd: Number(row.amountUsd),
|
||||
}));
|
||||
}
|
||||
|
||||
async getRevenueByCurrency(): Promise<{ currency: string; amount: number }[]> {
|
||||
const rows = await this.paymentRepository
|
||||
.createQueryBuilder('payment')
|
||||
.select('payment.currency', 'currency')
|
||||
.addSelect('COALESCE(SUM(payment.amount), 0)', 'amount')
|
||||
.where('payment.status = :status', { status: 'success' })
|
||||
.andWhere(
|
||||
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
|
||||
)
|
||||
.groupBy('payment.currency')
|
||||
.getRawMany<{ currency: string; amount: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
currency: row.currency,
|
||||
amount: Number(row.amount),
|
||||
}));
|
||||
}
|
||||
|
||||
async getTrainStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
||||
return this.statusBreakdown(this.trainRepository, 'train');
|
||||
}
|
||||
|
||||
async getWagonStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
||||
return this.statusBreakdown(this.wagonRepository, 'wagon');
|
||||
}
|
||||
|
||||
async getContainerStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
||||
return this.statusBreakdown(this.containerRepository, 'container');
|
||||
}
|
||||
|
||||
async getCargoStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
||||
return this.statusBreakdown(this.cargoRepository, 'cargo');
|
||||
}
|
||||
|
||||
private async statusBreakdown(
|
||||
repository: Repository<ObjectLiteral>,
|
||||
alias: string,
|
||||
): Promise<{ status: string; count: number }[]> {
|
||||
const rows = await repository
|
||||
.createQueryBuilder(alias)
|
||||
.select(`${alias}.status`, 'status')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where(`${alias}.deleted_at IS NULL`)
|
||||
.groupBy(`${alias}.status`)
|
||||
.orderBy('count', 'DESC')
|
||||
.getRawMany<{ status: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
status: row.status,
|
||||
count: Number(row.count),
|
||||
}));
|
||||
}
|
||||
|
||||
async getCustomerGrowthTrend(days: number): Promise<{ date: string; count: number }[]> {
|
||||
const rows = await this.customerRepository
|
||||
.createQueryBuilder('customer')
|
||||
.select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, 'date')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('customer.deleted_at IS NULL')
|
||||
.andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||
.groupBy('customer.created_at::date')
|
||||
.orderBy('customer.created_at::date', 'ASC')
|
||||
.getRawMany<{ date: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
date: row.date,
|
||||
count: Number(row.count),
|
||||
}));
|
||||
}
|
||||
|
||||
async getCustomersByType(): Promise<{ label: string; count: number }[]> {
|
||||
const rows = await this.customerRepository
|
||||
.createQueryBuilder('customer')
|
||||
.select(`COALESCE(NULLIF(customer.customer_type, ''), 'Unknown')`, 'label')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('customer.deleted_at IS NULL')
|
||||
.groupBy('customer.customer_type')
|
||||
.orderBy('count', 'DESC')
|
||||
.getRawMany<{ label: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
label: row.label,
|
||||
count: Number(row.count),
|
||||
}));
|
||||
}
|
||||
|
||||
async getTopCustomersByBookings(limit: number): Promise<{ label: string; count: number }[]> {
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoin('booking.company', 'company')
|
||||
.select(`COALESCE(company.name, 'Unknown')`, 'label')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('booking.deleted_at IS NULL')
|
||||
.andWhere("booking.status != 'DRAFT'")
|
||||
.groupBy('company.name')
|
||||
.orderBy('count', 'DESC')
|
||||
.limit(limit)
|
||||
.getRawMany<{ label: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
label: row.label,
|
||||
count: Number(row.count),
|
||||
}));
|
||||
}
|
||||
|
||||
async getUsersByStatus(): Promise<{ status: string; count: number }[]> {
|
||||
const rows = await this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.select('user.status', 'status')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.groupBy('user.status')
|
||||
.orderBy('count', 'DESC')
|
||||
.getRawMany<{ status: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
status: row.status,
|
||||
count: Number(row.count),
|
||||
}));
|
||||
}
|
||||
|
||||
async getEmployeeGrowthTrend(days: number): Promise<{ date: string; count: number }[]> {
|
||||
const rows = await this.employeeRepository
|
||||
.createQueryBuilder('employee')
|
||||
.select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, 'date')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('employee.is_current = true')
|
||||
.andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||
.groupBy('employee.created_at::date')
|
||||
.orderBy('employee.created_at::date', 'ASC')
|
||||
.getRawMany<{ date: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
date: row.date,
|
||||
count: Number(row.count),
|
||||
}));
|
||||
}
|
||||
|
||||
async getActiveUsersBreakdown(): Promise<{ label: string; count: number }[]> {
|
||||
const [active, inactive] = await Promise.all([
|
||||
this.userRepository.count({
|
||||
where: { isActive: true, status: EUserStatus.ACCEPTED },
|
||||
}),
|
||||
this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.where('user.is_active = false OR user.status != :status', {
|
||||
status: EUserStatus.ACCEPTED,
|
||||
})
|
||||
.getCount(),
|
||||
]);
|
||||
|
||||
return [
|
||||
{ label: 'Active', count: active },
|
||||
{ label: 'Inactive', count: inactive },
|
||||
];
|
||||
}
|
||||
}
|
||||
210
apps/edr-freight-api/src/modules/overview/overview.service.ts
Normal file
210
apps/edr-freight-api/src/modules/overview/overview.service.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
BOOKING_LIST_TABS,
|
||||
mapStatusCountsToTabs,
|
||||
} from '../bookings/booking-list-tabs.config';
|
||||
import type { OverviewRangeQuery } from './dto/overview-query.dto';
|
||||
import type { OverviewResponseDto } from './dto/overview-response.dto';
|
||||
import type {
|
||||
OverviewBillingTabDto,
|
||||
OverviewBookingsTabDto,
|
||||
OverviewCustomersTabDto,
|
||||
OverviewOperationsTabDto,
|
||||
OverviewStaffTabDto,
|
||||
} from './dto/overview-tab-response.dto';
|
||||
import { OVERVIEW_RANGE_DAYS } from './overview.constants';
|
||||
import { OverviewRepository } from './overview.repository';
|
||||
|
||||
@Injectable()
|
||||
export class OverviewService {
|
||||
constructor(private readonly overviewRepository: OverviewRepository) {}
|
||||
|
||||
private mapStatusCounts(statusCounts: Record<string, number>) {
|
||||
const pipelineTabs = mapStatusCountsToTabs(statusCounts);
|
||||
const bookingsByPipeline = BOOKING_LIST_TABS.filter(
|
||||
(tab) => tab.key !== 'all',
|
||||
).map((tab) => ({
|
||||
stage: tab.key,
|
||||
count: pipelineTabs[tab.key],
|
||||
}));
|
||||
|
||||
const bookingsByStatus = Object.entries(statusCounts)
|
||||
.map(([status, count]) => ({ status, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
return { bookingsByPipeline, bookingsByStatus };
|
||||
}
|
||||
|
||||
async getDashboard(range: OverviewRangeQuery = '30d'): Promise<OverviewResponseDto> {
|
||||
const days = OVERVIEW_RANGE_DAYS[range];
|
||||
|
||||
const [
|
||||
bookingKpis,
|
||||
operationsKpis,
|
||||
customerKpis,
|
||||
billingKpis,
|
||||
staffKpis,
|
||||
bookingTrend,
|
||||
statusCounts,
|
||||
paymentTrend,
|
||||
recentBookings,
|
||||
] = await Promise.all([
|
||||
this.overviewRepository.getBookingKpis(),
|
||||
this.overviewRepository.getOperationsKpis(),
|
||||
this.overviewRepository.getCustomerKpis(),
|
||||
this.overviewRepository.getBillingKpis(),
|
||||
this.overviewRepository.getStaffKpis(),
|
||||
this.overviewRepository.getBookingTrend(days),
|
||||
this.overviewRepository.getStatusCounts(),
|
||||
this.overviewRepository.getPaymentTrend(days),
|
||||
this.overviewRepository.getRecentBookings(8),
|
||||
]);
|
||||
|
||||
const { bookingsByPipeline, bookingsByStatus } =
|
||||
this.mapStatusCounts(statusCounts);
|
||||
|
||||
return {
|
||||
kpis: {
|
||||
bookings: bookingKpis,
|
||||
operations: operationsKpis,
|
||||
customers: customerKpis,
|
||||
billing: billingKpis,
|
||||
staff: staffKpis,
|
||||
},
|
||||
bookingTrend,
|
||||
bookingsByStatus,
|
||||
bookingsByPipeline,
|
||||
paymentTrend,
|
||||
recentBookings: recentBookings.map((row) => ({
|
||||
...row,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
})),
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getBookingsTab(range: OverviewRangeQuery = '30d'): Promise<OverviewBookingsTabDto> {
|
||||
const days = OVERVIEW_RANGE_DAYS[range];
|
||||
|
||||
const [
|
||||
kpis,
|
||||
bookingTrend,
|
||||
statusCounts,
|
||||
bookingsByFreightType,
|
||||
bookingsByCurrency,
|
||||
recentBookings,
|
||||
] = await Promise.all([
|
||||
this.overviewRepository.getBookingKpis(),
|
||||
this.overviewRepository.getBookingTrend(days),
|
||||
this.overviewRepository.getStatusCounts(),
|
||||
this.overviewRepository.getBookingsByFreightType(),
|
||||
this.overviewRepository.getBookingsByCurrency(),
|
||||
this.overviewRepository.getRecentBookings(8),
|
||||
]);
|
||||
|
||||
const { bookingsByPipeline, bookingsByStatus } =
|
||||
this.mapStatusCounts(statusCounts);
|
||||
|
||||
return {
|
||||
kpis,
|
||||
bookingTrend,
|
||||
bookingsByStatus,
|
||||
bookingsByPipeline,
|
||||
bookingsByFreightType,
|
||||
bookingsByCurrency,
|
||||
recentBookings: recentBookings.map((row) => ({
|
||||
...row,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
})),
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getBillingTab(range: OverviewRangeQuery = '30d'): Promise<OverviewBillingTabDto> {
|
||||
const days = OVERVIEW_RANGE_DAYS[range];
|
||||
|
||||
const [kpis, paymentTrend, paymentsByStatus, paymentsByMethod, revenueByCurrency] =
|
||||
await Promise.all([
|
||||
this.overviewRepository.getBillingKpis(),
|
||||
this.overviewRepository.getPaymentTrend(days),
|
||||
this.overviewRepository.getPaymentsByStatus(),
|
||||
this.overviewRepository.getPaymentsByMethod(),
|
||||
this.overviewRepository.getRevenueByCurrency(),
|
||||
]);
|
||||
|
||||
return {
|
||||
kpis,
|
||||
paymentTrend,
|
||||
paymentsByStatus,
|
||||
paymentsByMethod,
|
||||
revenueByCurrency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getOperationsTab(): Promise<OverviewOperationsTabDto> {
|
||||
const [
|
||||
kpis,
|
||||
trainStatusBreakdown,
|
||||
wagonStatusBreakdown,
|
||||
containerStatusBreakdown,
|
||||
cargoStatusBreakdown,
|
||||
] = await Promise.all([
|
||||
this.overviewRepository.getOperationsKpis(),
|
||||
this.overviewRepository.getTrainStatusBreakdown(),
|
||||
this.overviewRepository.getWagonStatusBreakdown(),
|
||||
this.overviewRepository.getContainerStatusBreakdown(),
|
||||
this.overviewRepository.getCargoStatusBreakdown(),
|
||||
]);
|
||||
|
||||
return {
|
||||
kpis,
|
||||
trainStatusBreakdown,
|
||||
wagonStatusBreakdown,
|
||||
containerStatusBreakdown,
|
||||
cargoStatusBreakdown,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getCustomersTab(range: OverviewRangeQuery = '30d'): Promise<OverviewCustomersTabDto> {
|
||||
const days = OVERVIEW_RANGE_DAYS[range];
|
||||
|
||||
const [kpis, customerGrowthTrend, customersByType, topCustomersByBookings] =
|
||||
await Promise.all([
|
||||
this.overviewRepository.getCustomerKpis(),
|
||||
this.overviewRepository.getCustomerGrowthTrend(days),
|
||||
this.overviewRepository.getCustomersByType(),
|
||||
this.overviewRepository.getTopCustomersByBookings(8),
|
||||
]);
|
||||
|
||||
return {
|
||||
kpis,
|
||||
customerGrowthTrend,
|
||||
customersByType,
|
||||
topCustomersByBookings,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getStaffTab(range: OverviewRangeQuery = '30d'): Promise<OverviewStaffTabDto> {
|
||||
const days = OVERVIEW_RANGE_DAYS[range];
|
||||
|
||||
const [kpis, usersByStatus, employeeGrowthTrend, activeUsersBreakdown] =
|
||||
await Promise.all([
|
||||
this.overviewRepository.getStaffKpis(),
|
||||
this.overviewRepository.getUsersByStatus(),
|
||||
this.overviewRepository.getEmployeeGrowthTrend(days),
|
||||
this.overviewRepository.getActiveUsersBreakdown(),
|
||||
]);
|
||||
|
||||
return {
|
||||
kpis,
|
||||
usersByStatus,
|
||||
employeeGrowthTrend,
|
||||
activeUsersBreakdown,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { IsString } from "class-validator";
|
||||
|
||||
export class InitiateBookingPayment {
|
||||
@IsString()
|
||||
bookingId!: string;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from "typeorm";
|
||||
import { PaymentEntity } from "./payment.entity";
|
||||
|
||||
@Entity({ schema: "freight", name: "payment_refunds" })
|
||||
export class PaymentRefundEntity {
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id!: string;
|
||||
|
||||
@Column({ type: "uuid", name: "payment_id" })
|
||||
paymentId!: string;
|
||||
|
||||
@Column({ type: "int", name: "amount_minor" })
|
||||
amountMinor!: number;
|
||||
|
||||
@Column({ type: "varchar", length: 255, nullable: true })
|
||||
reason?: string;
|
||||
|
||||
@Column({ type: "varchar", length: 255, nullable: true, name: "provider_refund_id" })
|
||||
providerRefundId?: string;
|
||||
|
||||
@Column({ type: "varchar", length: 50 })
|
||||
status!: string;
|
||||
|
||||
@CreateDateColumn({ name: "created_at" })
|
||||
createdAt!: Date;
|
||||
|
||||
@ManyToOne(() => PaymentEntity, (payment) => payment.refunds, { onDelete: "RESTRICT" })
|
||||
@JoinColumn({ name: "payment_id" })
|
||||
payment!: PaymentEntity;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
} from "typeorm";
|
||||
|
||||
export type WebhookPaymentMethod = "telebirr" | "cbe-birr" | "ebirr";
|
||||
|
||||
@Entity({ schema: "freight", name: "payment_webhook_events" })
|
||||
@Unique(["provider", "externalEventId"])
|
||||
@Index(["merchantOrderId"])
|
||||
export class PaymentWebhookEventEntity {
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id!: string;
|
||||
|
||||
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr"] })
|
||||
provider!: WebhookPaymentMethod;
|
||||
|
||||
@Column({ type: "varchar", length: 255, name: "external_event_id" })
|
||||
externalEventId!: string;
|
||||
|
||||
@Column({ type: "varchar", length: 255, nullable: true, name: "merchant_order_id" })
|
||||
merchantOrderId?: string;
|
||||
|
||||
@Column({ type: "varchar", length: 255, nullable: true, name: "provider_txn_id" })
|
||||
providerTxnId?: string;
|
||||
|
||||
@Column({ type: "boolean", name: "signature_valid" })
|
||||
signatureValid!: boolean;
|
||||
|
||||
@Column({ type: "varchar", length: 100 })
|
||||
status!: string;
|
||||
|
||||
@Column({ type: "jsonb" })
|
||||
payload!: Record<string, unknown>;
|
||||
|
||||
@CreateDateColumn({ name: "received_at" })
|
||||
receivedAt!: Date;
|
||||
|
||||
@Column({ type: "timestamp", nullable: true, name: "processed_at" })
|
||||
processedAt?: Date;
|
||||
|
||||
@Column({ type: "text", nullable: true, name: "processing_error" })
|
||||
processingError?: string;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm";
|
||||
import { BaseEntity, Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||
import { PaymentRefundEntity } from "./payment-refund.entity";
|
||||
|
||||
|
||||
type PaymentType = "booking"
|
||||
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr"
|
||||
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank"
|
||||
type Currency = "ETB" | "USD"
|
||||
type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
||||
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
||||
|
||||
@Entity({ schema: 'freight', name: 'payments' })
|
||||
export class PaymentEntity extends BaseEntity {
|
||||
@@ -17,7 +18,7 @@ export class PaymentEntity extends BaseEntity {
|
||||
@Column({ type: "enum", enum: ["booking"] })
|
||||
type!: PaymentType;
|
||||
|
||||
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr"] })
|
||||
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] })
|
||||
method!: PaymentMethod
|
||||
|
||||
@Column({ type: "enum", enum: ["ETB", "USD"] })
|
||||
@@ -32,13 +33,13 @@ export class PaymentEntity extends BaseEntity {
|
||||
@Column({ type: "jsonb", default: {}, name: "raw_initiation" })
|
||||
rawInitiation?: Record<string, unknown>
|
||||
|
||||
@Column({ type: "jsonb", name: "client_action" })
|
||||
@Column({ type: "jsonb", nullable: true, name: "client_action" })
|
||||
clientAction?: Record<string, unknown>;
|
||||
|
||||
@Column({ type: "varchar", length: 255, unique: true, name: "merchant_order_id", })
|
||||
merchantOrderId!: string
|
||||
|
||||
@Column({ type: "varchar", length: 255, unique: true, name: "transaction_id", })
|
||||
@Column({ type: "varchar", length: 255, unique: true, nullable: true, name: "transaction_id", })
|
||||
transactionId?: string
|
||||
|
||||
@Column({ type: "enum", enum: ["action-required", "processing", "success", "failed", "canceled", "refunded"], default: "action-required" })
|
||||
@@ -62,4 +63,7 @@ export class PaymentEntity extends BaseEntity {
|
||||
@CreateDateColumn({ name: "created_at" })
|
||||
createdAt!: Date
|
||||
|
||||
@OneToMany(() => PaymentRefundEntity, (refund) => refund.payment)
|
||||
refunds!: PaymentRefundEntity[];
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payment.dto";
|
||||
import { PaymentService } from "./payment.service";
|
||||
|
||||
/**
|
||||
* Consumer side of the payment microservice's outbox relay.
|
||||
* Only the payment service may call this (shared SERVICE_AUTH_TOKEN).
|
||||
* Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless.
|
||||
* Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available;
|
||||
* this HTTP endpoint remains as a transport-agnostic fallback.
|
||||
*/
|
||||
@ApiTags("Internal Payments")
|
||||
@UseGuards(ServiceAuthGuard)
|
||||
@Controller("internal/payments")
|
||||
export class InternalPaymentController {
|
||||
constructor(private readonly paymentService: PaymentService) { }
|
||||
|
||||
@Post("mark-paid")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)",
|
||||
})
|
||||
async markPaid(@Body() event: PaymentEventDto): Promise<MarkPaidResponseDto> {
|
||||
return this.paymentService.handlePaymentEvent(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from "class-validator";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
PaymentEventType,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
ProviderMethod,
|
||||
} from "@edr/types";
|
||||
|
||||
/**
|
||||
* Wire shape of the PaymentEvent envelope (@edr/types) delivered by the payment
|
||||
* microservice's outbox relay. Delivery is at-least-once — the consumer is idempotent.
|
||||
*/
|
||||
export class PaymentEventDto {
|
||||
@ApiProperty({ enum: [1] }) @IsIn([1]) version!: 1;
|
||||
@ApiProperty() @IsUUID() eventId!: string;
|
||||
@ApiProperty({ enum: ["payment.succeeded", "payment.failed"] })
|
||||
@IsIn(["payment.succeeded", "payment.failed"])
|
||||
eventType!: PaymentEventType;
|
||||
|
||||
@ApiProperty() @IsISO8601() occurredAt!: string;
|
||||
@ApiProperty({ enum: PaymentService }) @IsEnum(PaymentService) service!: string;
|
||||
@ApiProperty() @IsUUID() intentId!: string;
|
||||
@ApiProperty({ enum: PaymentReferenceType })
|
||||
@IsEnum(PaymentReferenceType)
|
||||
referenceType!: string;
|
||||
|
||||
@ApiProperty() @IsString() referenceId!: string;
|
||||
@ApiProperty() @IsString() merchantOrderId!: string;
|
||||
@ApiProperty({ enum: ProviderMethod }) @IsEnum(ProviderMethod) provider!: string;
|
||||
@ApiProperty() @IsInt() @IsPositive() amountMinor!: number;
|
||||
@ApiProperty() @IsString() currency!: string;
|
||||
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() providerTxnId?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsISO8601() paidAt?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() failureCode?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() failureMessage?: string;
|
||||
}
|
||||
|
||||
export class MarkPaidResponseDto {
|
||||
@ApiProperty() processed!: boolean;
|
||||
@ApiPropertyOptional() alreadyFinalized?: boolean;
|
||||
@ApiPropertyOptional() reason?: string;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { BadGatewayException, Injectable, Logger } from "@nestjs/common";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { AxiosError } from "axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import {
|
||||
InitiatePaymentRequest,
|
||||
PaymentIntentSnapshot,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
} from "@edr/types";
|
||||
|
||||
/**
|
||||
* Thin HTTP client for the payment microservice (apps/edr-payment-api).
|
||||
* Domain validation stays in the freight API; provider calls, intents,
|
||||
* and webhooks live in the payment service.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PaymentClientService {
|
||||
private readonly logger = new Logger(PaymentClientService.name);
|
||||
private readonly baseUrl = (
|
||||
process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com"
|
||||
).replace(/\/$/, "");
|
||||
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
|
||||
|
||||
constructor(private readonly http: HttpService) { }
|
||||
|
||||
/** POST /payments/initiate — idempotent per (service, referenceType, referenceId). */
|
||||
async initiate(request: InitiatePaymentRequest): Promise<PaymentIntentSnapshot> {
|
||||
return this.call("POST", "/payments/initiate", request);
|
||||
}
|
||||
|
||||
/** GET /payments/intents?… — active intent by domain reference; null when none exists. */
|
||||
async getIntentByReference(
|
||||
referenceType: PaymentReferenceType,
|
||||
referenceId: string,
|
||||
): Promise<PaymentIntentSnapshot | null> {
|
||||
const query = new URLSearchParams({
|
||||
service: PaymentService.FREIGHT,
|
||||
referenceType,
|
||||
referenceId,
|
||||
});
|
||||
try {
|
||||
return await this.call("GET", `/payments/intents?${query.toString()}`);
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async call<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.request<T>({
|
||||
method,
|
||||
url,
|
||||
data: body,
|
||||
headers: this.serviceToken
|
||||
? { "x-service-token": this.serviceToken }
|
||||
: {},
|
||||
}),
|
||||
);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response) {
|
||||
if (err.response.status === 404) throw err;
|
||||
const detail =
|
||||
(err.response.data as { message?: string | string[] })?.message ??
|
||||
err.message;
|
||||
this.logger.error(
|
||||
`payment service ${method} ${path} → ${err.response.status}: ${detail}`,
|
||||
);
|
||||
throw new BadGatewayException(`Payment service error: ${detail}`);
|
||||
}
|
||||
const message = err instanceof Error && err.message ? err.message : String(err);
|
||||
this.logger.error(`payment service unreachable (${method} ${path}): ${message}`);
|
||||
throw new BadGatewayException("Payment service unreachable");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user