mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into passenger/feat/iam
This commit is contained in:
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();
|
||||
143
.github/workflows/deploy.yml
vendored
143
.github/workflows/deploy.yml
vendored
@@ -1,66 +1,131 @@
|
||||
name: Deploy Stacks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- staging
|
||||
paths:
|
||||
- "apps/edr-freight-api/**"
|
||||
- "apps/edr-freight-web/**"
|
||||
- "apps/edr-passenger-api/**"
|
||||
- "apps/edr-passenger-web/**"
|
||||
- "packages/**"
|
||||
- "infrastructure/docker/Dockerfile.web"
|
||||
- "infrastructure/nginx/**"
|
||||
- "docker-compose.yaml"
|
||||
- "pnpm-lock.yaml"
|
||||
- "scripts/deploy/**"
|
||||
- ".github/workflows/deploy.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
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:
|
||||
include:
|
||||
- project: edr-freight
|
||||
build_env_file: freight-web.build.env
|
||||
service: freight-api
|
||||
# - project: edr-freight
|
||||
# build_env_file: freight-web.build.env
|
||||
# service: freight-portal
|
||||
# - project: edr-freight
|
||||
# build_env_file: freight-web.build.env
|
||||
# service: freight-backoffice
|
||||
- project: edr-passenger
|
||||
build_env_file: passenger-web.build.env
|
||||
service: passenger-api
|
||||
- project: edr-passenger
|
||||
build_env_file: passenger-web.build.env
|
||||
service: passenger-portal
|
||||
- project: edr-passenger
|
||||
build_env_file: passenger-web.build.env
|
||||
service: passenger-backoffice
|
||||
service: ${{ fromJson(needs.detect-changes.outputs.matrix) }}
|
||||
env:
|
||||
PROJECT: ${{ matrix.project }}
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
DEPLOY_USER: tria
|
||||
BUILD_ENV_FILE: ${{ matrix.build_env_file }}
|
||||
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
|
||||
@@ -80,12 +145,12 @@ jobs:
|
||||
- name: Build ${{ matrix.service }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}"
|
||||
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 }}"
|
||||
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate
|
||||
|
||||
- name: Remove npm credentials from workspace
|
||||
if: always()
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -7,6 +7,7 @@ node_modules/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
**/*.tsbuildinfo
|
||||
**/vite.config.ts.timestamp-*.mjs
|
||||
|
||||
# env
|
||||
.env
|
||||
@@ -23,6 +24,8 @@ coverage/
|
||||
.idea/
|
||||
.vscode/
|
||||
.npmrc
|
||||
branch_structure.json
|
||||
temp_auto_push.bat
|
||||
temp_interactive_push.bat
|
||||
|
||||
# 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
|
||||
11
.npmrc
11
.npmrc
@@ -1,11 +0,0 @@
|
||||
# Increase fetch timeouts for network resilience
|
||||
fetch-timeout=60000
|
||||
fetch-retry-mintimeout=20000
|
||||
fetch-retry-maxtimeout=120000
|
||||
|
||||
# GitHub Packages configuration for @tria-plc scope
|
||||
@tria-plc:registry=https://npm.pkg.github.com
|
||||
//npm.pkg.github.com/:_authToken=${GITHUB_PACKAGE_TOKEN}
|
||||
|
||||
# Default registry for other packages
|
||||
registry=https://registry.npmjs.org/
|
||||
@@ -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
|
||||
|
||||
@@ -61,15 +61,16 @@ The sync script validates this and fails if missing.
|
||||
|
||||
### Build env files (optional)
|
||||
|
||||
Used for build-time variables (example: Vite API URLs), with `export` syntax:
|
||||
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
|
||||
export PASSENGER_VITE_API_URL=https://passenger-api.example.com
|
||||
```
|
||||
|
||||
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:
|
||||
@@ -83,6 +84,51 @@ These are injected into `GITHUB_ENV` during workflow execution.
|
||||
|
||||
`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`
|
||||
|
||||
1002
ITMLS_DB_Design.md
Normal file
1002
ITMLS_DB_Design.md
Normal file
File diff suppressed because it is too large
Load Diff
257
README.md
257
README.md
@@ -93,6 +93,189 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend
|
||||
|
||||
| Layer | Tech |
|
||||
| ---------------- | ----------------------------------------------- |
|
||||
| Runtime | Node.js ≥ 20 |
|
||||
| Framework | NestJS 11 (modular architecture) |
|
||||
| Language | TypeScript 5 (strict mode, project-wide) |
|
||||
| ORM | TypeORM 0.3 (UUID PKs, soft deletes, `snake_case` columns) |
|
||||
| Database | PostgreSQL 16 (one DB per domain) |
|
||||
| Validation | class-validator + class-transformer |
|
||||
| API docs | Swagger via `@nestjs/swagger` |
|
||||
| Messaging | `@nestjs/microservices` (inter-service ready) |
|
||||
| Testing | Jest + Supertest |
|
||||
|
||||
### Frontend
|
||||
|
||||
| Layer | Tech |
|
||||
| ---------------- | ----------------------------------------------- |
|
||||
| Framework | React 18 + Vite 5 |
|
||||
| Language | TypeScript 5 (strict) |
|
||||
| Routing | React Router v6 |
|
||||
| Styling | Tailwind CSS v4 (`@tailwindcss/vite`) + `tailwind-merge` + `class-variance-authority` |
|
||||
| UI primitives | Radix UI (meta `radix-ui` package, shadcn-style components) |
|
||||
| Icons | lucide-react |
|
||||
| State / Data | Zustand (client state) · TanStack Query (server state) |
|
||||
| HTTP | Axios |
|
||||
| Auth UI | `@tria-plc/iamui-common` (external IAM) |
|
||||
|
||||
### Shared Packages
|
||||
|
||||
| Package | Purpose |
|
||||
| ---------------------- | -------------------------------------------------------------------- |
|
||||
| `@edr/types` | Shared TypeScript interfaces and enums |
|
||||
| `@edr/api-common` | NestJS decorators, filters, interceptors, pipes, `BaseEntity`, `BaseRepository` |
|
||||
| `@edr/ui-common` | Shared React components (`DashboardLayout`, `Sidebar`, `Button`, `Modal`, etc.) and theme tokens |
|
||||
| `@edr/eslint-config` | Shared ESLint configs (base / nestjs / react) |
|
||||
| `@edr/tsconfig` | Shared TypeScript configs |
|
||||
| `@edr/prettier-config` | Shared Prettier configuration |
|
||||
|
||||
### Tooling
|
||||
|
||||
- **pnpm 9** — workspace package manager (sole supported PM)
|
||||
- **Turborepo 2** — task orchestrator with caching
|
||||
- **Husky + lint-staged + commitlint** — pre-commit ESLint/Prettier and conventional-commit enforcement
|
||||
- **Docker Compose** — local Postgres instances + production stack (see `infrastructure/`)
|
||||
- **Nginx** — reverse proxy / static asset server in production
|
||||
|
||||
### Planned Integrations
|
||||
|
||||
- **MinIO** — S3-compatible object storage for documents (object keys already shaped under `edr-freight/{linkedType}/{ref}/{filename}`)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### High-level layout
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ EDR Platform (monorepo) │
|
||||
├──────────────────────┬───────────────────────────────────────────┤
|
||||
│ Freight domain │ Passenger domain │
|
||||
│ ┌────────────────┐ │ ┌────────────────┐ │
|
||||
│ │ freight-portal │ │ │ passenger- │ │
|
||||
│ │ (React) │ │ │ portal (React)│ │
|
||||
│ ├────────────────┤ │ ├────────────────┤ │
|
||||
│ │ freight- │ │ │ passenger- │ │
|
||||
│ │ backoffice │ │ │ backoffice │ │
|
||||
│ └───────┬────────┘ │ └───────┬────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ │ ▼ │
|
||||
│ ┌────────────────┐ │ ┌────────────────┐ │
|
||||
│ │ freight-api │ │ │ passenger-api │ │
|
||||
│ │ (NestJS) │ │ │ (NestJS) │ │
|
||||
│ └───────┬────────┘ │ └───────┬────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ │ ▼ │
|
||||
│ ┌────────────────┐ │ ┌────────────────┐ │
|
||||
│ │ postgres- │ │ │ postgres- │ │
|
||||
│ │ freight │ │ │ passenger │ │
|
||||
│ └────────────────┘ │ └────────────────┘ │
|
||||
└──────────────────────┴───────────────────────────────────────────┘
|
||||
Shared (workspace) packages
|
||||
@edr/types · @edr/api-common · @edr/ui-common · @edr/{eslint,tsconfig,prettier}-config
|
||||
```
|
||||
|
||||
### Domain isolation
|
||||
|
||||
- **One database per domain.** `postgres-freight` (port 5433, db `edr_freight`) and `postgres-passenger` (port 5434, db `edr_passenger`). No cross-database joins. Cross-domain data flows only through API calls or message queues.
|
||||
- **Each domain owns its data model.** Freight bookings/consignments/shipments/trains/invoices/documents live only in the freight DB; passenger journeys/tickets live only in the passenger DB.
|
||||
|
||||
### NestJS module pattern (per feature)
|
||||
|
||||
```
|
||||
modules/<feature>/
|
||||
entities/<feature>.entity.ts // extends BaseEntity (UUID, timestamps, soft delete)
|
||||
dto/<verb>-<feature>.dto.ts // class-validator DTOs
|
||||
<feature>.module.ts // wires controller + service + repository
|
||||
<feature>.controller.ts // HTTP layer only — no business logic
|
||||
<feature>.service.ts // business logic
|
||||
<feature>.repository.ts // extends BaseRepository<Entity>; services inject this, NEVER `Repository<T>` directly
|
||||
```
|
||||
|
||||
Conventions enforced across the codebase:
|
||||
|
||||
- All entities have UUID primary keys (`@PrimaryGeneratedColumn('uuid')`).
|
||||
- All entities inherit `createdAt` / `updatedAt` / `deletedAt` from `BaseEntity` (soft delete).
|
||||
- DB columns use `snake_case` via `@Column({ name: '...' })`; TS properties stay `camelCase`.
|
||||
- **No `synchronize: true`** in production — schema changes go through TypeORM migrations.
|
||||
- ESLint + Prettier run on pre-commit via Husky + lint-staged.
|
||||
- Conventional-commits enforced via commitlint.
|
||||
|
||||
### Frontend application structure
|
||||
|
||||
```
|
||||
apps/edr-freight-web/portal/src/
|
||||
App.tsx // routes + sidebar definition
|
||||
main.tsx // React Router + QueryClient providers
|
||||
components/
|
||||
Breadcrumbs.tsx // shared local UI
|
||||
ui/ // shadcn-style primitives (Button, Input, Dialog, Label, Textarea)
|
||||
pages/
|
||||
customers/ // CustomersPage + CustomerDetailPage + NewCustomerPage (dialog)
|
||||
bookings/ // ... + multimodal Transport Legs editor
|
||||
consignments/
|
||||
tracking/ // Shipment grid + table view toggle
|
||||
trains/ // Fleet roster
|
||||
billing/ // Invoices
|
||||
documents/ // MinIO-shaped document library
|
||||
hooks/ // TanStack Query hooks (per feature)
|
||||
services/ // Axios clients (per feature)
|
||||
store/ // Zustand stores
|
||||
lib/ // utilities (`cn` helper, formatters)
|
||||
```
|
||||
|
||||
Each feature folder typically contains: `*Page.tsx` (list), `*DetailPage.tsx`, `New*Page.tsx` (create/edit dialog with `mode: "create" | "edit"`), `Delete*Dialog.tsx`, and a `*.mock.ts` seed file used by the current mock UI.
|
||||
|
||||
### Shared layout (`@edr/ui-common`)
|
||||
|
||||
`DashboardLayout` provides the sidebar + header shell shared across all freight and passenger web apps:
|
||||
|
||||
- **Sidebar** — brand-tinted, icon-led navigation. Main brand color: `#10B981` (`rgb(16, 185, 129)` — emerald-500). Icon containers use the filled style: brand-color background with a white icon.
|
||||
- **Header** — language picker, notifications, user dropdown (click-driven, click-outside / Escape close); host apps opt into a light/dark theme toggle via `enableThemeToggle` (Tailwind class-based, persisted in `localStorage`).
|
||||
|
||||
### Auth integration (planned)
|
||||
|
||||
Authentication is provided by an external `@edr/iamui-common` / `@tria-plc/iamapi-common` package. **Do not** implement login/JWT/password logic in this repo. Use placeholder TODO comments next to controllers and `@CurrentUser` decorators (in `@edr/api-common`) until the integration ships.
|
||||
|
||||
---
|
||||
|
||||
## Apps & Ports
|
||||
|
||||
| App | Package name | Purpose | Port |
|
||||
| ------------------------------ | --------------------------- | ---------------------------------------- | ---- |
|
||||
| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight | 3001 |
|
||||
| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customers | 5173 |
|
||||
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight employees | 5183 |
|
||||
| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passengers | 3002 |
|
||||
| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customers | 5174 |
|
||||
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger employees | 5184 |
|
||||
|
||||
`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. Each holds independent `portal/` and `backoffice/` workspace packages declared in `pnpm-workspace.yaml`.
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js ≥ 20
|
||||
- pnpm 9 (`corepack enable && corepack prepare pnpm@9.12.0 --activate`)
|
||||
- Docker (for local Postgres)
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
@@ -255,7 +438,7 @@ The API uses two authentication schemes:
|
||||
| **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` | Public/JWT/IAM | Seat class management and configuration |
|
||||
| **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 |
|
||||
@@ -765,3 +948,75 @@ For technical support or questions:
|
||||
---
|
||||
|
||||
**Built with ❤️ for Ethio-Djibouti Railway**
|
||||
### Start local databases
|
||||
|
||||
```bash
|
||||
docker compose -f infrastructure/docker/docker-compose.db.dev.yml up -d
|
||||
```
|
||||
|
||||
### Run apps
|
||||
|
||||
```bash
|
||||
pnpm dev # every app
|
||||
pnpm dev:freight # freight API + portal + backoffice
|
||||
pnpm dev:passenger # passenger API + portal + backoffice
|
||||
```
|
||||
|
||||
### Common scripts
|
||||
|
||||
| Script | Description |
|
||||
| ------------------- | ------------------------------------ |
|
||||
| `pnpm install` | Install workspace dependencies |
|
||||
| `pnpm dev` | Run every app in watch mode |
|
||||
| `pnpm build` | Build every package and app |
|
||||
| `pnpm test` | Run all tests |
|
||||
| `pnpm lint` | Lint everything |
|
||||
| `pnpm type-check` | Type-check every package |
|
||||
| `pnpm format` | Format with Prettier |
|
||||
|
||||
---
|
||||
|
||||
## Repository Layout
|
||||
|
||||
```
|
||||
.
|
||||
├── apps/
|
||||
│ ├── edr-freight-api/ NestJS — freight backend
|
||||
│ ├── edr-freight-web/
|
||||
│ │ ├── portal/ React — freight customer portal
|
||||
│ │ └── backoffice/ React — freight back-office
|
||||
│ ├── edr-passenger-api/ NestJS — passenger backend
|
||||
│ └── edr-passenger-web/
|
||||
│ ├── portal/ React — passenger customer portal
|
||||
│ └── backoffice/ React — passenger back-office
|
||||
├── packages/
|
||||
│ ├── api-common/ Shared NestJS utilities + BaseEntity/Repository
|
||||
│ ├── types/ Shared TS types/enums
|
||||
│ ├── ui-common/ Shared React components + theme
|
||||
│ └── config/
|
||||
│ ├── eslint/ @edr/eslint-config
|
||||
│ ├── tsconfig/ @edr/tsconfig
|
||||
│ └── prettier/ @edr/prettier-config
|
||||
├── infrastructure/
|
||||
│ ├── docker/ docker-compose files (db.dev / dev / prod)
|
||||
│ └── nginx/ Production nginx config
|
||||
├── CLAUDE.md Developer guide for AI-assisted work
|
||||
├── turbo.json Turborepo task pipeline
|
||||
├── pnpm-workspace.yaml Workspace manifest
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Standards (recap)
|
||||
|
||||
- **TypeScript strict mode** is enabled in every package.
|
||||
- **pnpm only** — never run `npm install` or `yarn`.
|
||||
- **Conventional commits** — enforced via commitlint on every commit.
|
||||
- **NestJS 4-layer pattern** — `module → controller → service → repository`.
|
||||
- **Repository injection** — services inject the custom `*Repository` class, not `Repository<T>`.
|
||||
- **Controllers are thin** — no business logic; delegate to services.
|
||||
- **Migrations only** — never enable TypeORM `synchronize` in production.
|
||||
- **One DB per domain** — no cross-database joins.
|
||||
|
||||
See [`CLAUDE.md`](./CLAUDE.md) for the deeper developer guide used during AI-assisted contributions.
|
||||
|
||||
0
WagonForm.tsx
Normal file
0
WagonForm.tsx
Normal file
@@ -5,3 +5,50 @@ DB_PORT=5433
|
||||
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
|
||||
|
||||
# Portal pages the payment provider redirects the browser to after payment.
|
||||
# Point these at the freight portal's public payment result routes.
|
||||
PAYMENT_RETURN_URL=http://localhost:5173/payment/success
|
||||
PAYMENT_FAILURE_URL=http://localhost:5173/payment/failure
|
||||
# JWT (used by @tria-plc/api-common SharedAuthModule)
|
||||
JWT_SECRET=
|
||||
JWT_ACCESS_TOKEN_SECRET=
|
||||
JWT_REFRESH_TOKEN_SECRET=
|
||||
JWT_EXPIRES_IN=3600
|
||||
# JWT expiry for @tria-plc/api-common token utils (jsonwebtoken timespan format)
|
||||
JWT_ACCESS_TOKEN_EXPIRES=1h
|
||||
JWT_REFRESH_TOKEN_EXPIRES=7d
|
||||
|
||||
# IAM seed defaults (used by @tria-plc/iamapi-common on first boot)
|
||||
SUPER_ADMIN_EMAIL=superadmin@tria.com
|
||||
SUPER_ADMIN_PHONE=
|
||||
DEFAULT_PASSWORD=password@tria
|
||||
|
||||
# Freight org + staff (bookings / rule-engine IAM)
|
||||
SEED_EDR_ORG=true
|
||||
SEED_FREIGHT_STAFF=true
|
||||
|
||||
# MinIO (used by @tria-plc/iamapi-common for file storage)
|
||||
MINIO_ENDPOINT=localhost
|
||||
MINIO_PORT=9000
|
||||
MINIO_USE_SSL=false
|
||||
MINIO_ACCESS_KEY=
|
||||
MINIO_SECRET_KEY=
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": false,
|
||||
"assets": [
|
||||
{
|
||||
"include": "migrations/**/*",
|
||||
"outDir": "dist"
|
||||
},
|
||||
{
|
||||
"include": "contracts/templates/**/*",
|
||||
"watchAssets": true
|
||||
},
|
||||
{
|
||||
"include": "modules/payment/templates/**/*",
|
||||
"watchAssets": true
|
||||
}
|
||||
],
|
||||
"watchAssets": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,44 +4,68 @@
|
||||
"private": true,
|
||||
"description": "EDR Freight Management API",
|
||||
"scripts": {
|
||||
"clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"",
|
||||
"predev": "pnpm run clean",
|
||||
"dev": "nest start --watch",
|
||||
"prebuild": "pnpm run clean",
|
||||
"build": "nest build",
|
||||
"start": "node dist/main.js",
|
||||
"lint": "eslint src",
|
||||
"test": "jest",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"type-check": "tsc --noEmit"
|
||||
"seed:wagons": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-wagons.ts",
|
||||
"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": {
|
||||
"@tria-plc/api-common": "^0.1.0",
|
||||
"@tria-plc/iamapi-common": "^0.1.0",
|
||||
"@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.3",
|
||||
"@tria-plc/iamapi-common": "^0.6.6",
|
||||
"amqp-connection-manager": "^5.0.0",
|
||||
"amqplib": "^2.0.1",
|
||||
"axios": "^1.16.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"handlebars": "^4.7.9",
|
||||
"minio": "7.1.3",
|
||||
"pg": "^8.13.0",
|
||||
"puppeteer": "^24.2.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.20"
|
||||
"typeorm": "^0.3.30"
|
||||
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
"@edr/types": "workspace:*",
|
||||
"@edr/eslint-config": "workspace:*",
|
||||
"@edr/tsconfig": "workspace:*",
|
||||
"@edr/types": "workspace:*",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.0",
|
||||
"@types/amqplib": "^0.10.8",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/pg": "^8.6.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"jest": "^29.7.0",
|
||||
"supertest": "^7.0.0",
|
||||
|
||||
206
apps/edr-freight-api/pnpm-lock.yaml
generated
Normal file
206
apps/edr-freight-api/pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,206 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@edr/api-common':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/api-common
|
||||
'@edr/types':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/types
|
||||
'@nestjs/common':
|
||||
specifier: ^11.0.0
|
||||
version: 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/mapped-types':
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)
|
||||
reflect-metadata:
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2
|
||||
rxjs:
|
||||
specifier: ^7.8.1
|
||||
version: 7.8.2
|
||||
devDependencies:
|
||||
'@edr/eslint-config':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/config/eslint-config
|
||||
'@edr/tsconfig':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/config/tsconfig
|
||||
|
||||
packages:
|
||||
|
||||
'@borewit/text-codec@0.2.2':
|
||||
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
|
||||
|
||||
'@lukeed/csprng@1.1.0':
|
||||
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@nestjs/common@11.1.24':
|
||||
resolution: {integrity: sha512-9zHxaDDM+oXW9As6UsP5yYB+UqczBmpeSCIFWdPEtEukMnZhxODG1BBjaUcdBB8Sc1uzojSJSJlp3yFp853t1g==}
|
||||
peerDependencies:
|
||||
class-transformer: '>=0.4.1'
|
||||
class-validator: '>=0.13.2'
|
||||
reflect-metadata: ^0.1.12 || ^0.2.0
|
||||
rxjs: ^7.1.0
|
||||
peerDependenciesMeta:
|
||||
class-transformer:
|
||||
optional: true
|
||||
class-validator:
|
||||
optional: true
|
||||
|
||||
'@nestjs/mapped-types@2.1.1':
|
||||
resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==}
|
||||
peerDependencies:
|
||||
'@nestjs/common': ^10.0.0 || ^11.0.0
|
||||
class-transformer: ^0.4.0 || ^0.5.0
|
||||
class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0
|
||||
reflect-metadata: ^0.1.12 || ^0.2.0
|
||||
peerDependenciesMeta:
|
||||
class-transformer:
|
||||
optional: true
|
||||
class-validator:
|
||||
optional: true
|
||||
|
||||
'@tokenizer/inflate@0.4.1':
|
||||
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@tokenizer/token@0.3.0':
|
||||
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
file-type@21.3.4:
|
||||
resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
iterare@1.2.1:
|
||||
resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
load-esm@1.0.3:
|
||||
resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==}
|
||||
engines: {node: '>=13.2.0'}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
reflect-metadata@0.2.2:
|
||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||
|
||||
rxjs@7.8.2:
|
||||
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
|
||||
|
||||
strtok3@10.3.5:
|
||||
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
token-types@6.1.2:
|
||||
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
uid@2.0.2:
|
||||
resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
uint8array-extras@1.5.0:
|
||||
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@borewit/text-codec@0.2.2': {}
|
||||
|
||||
'@lukeed/csprng@1.1.0': {}
|
||||
|
||||
'@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||
dependencies:
|
||||
file-type: 21.3.4
|
||||
iterare: 1.2.1
|
||||
load-esm: 1.0.3
|
||||
reflect-metadata: 0.2.2
|
||||
rxjs: 7.8.2
|
||||
tslib: 2.8.1
|
||||
uid: 2.0.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
reflect-metadata: 0.2.2
|
||||
|
||||
'@tokenizer/inflate@0.4.1':
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
token-types: 6.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@tokenizer/token@0.3.0': {}
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
file-type@21.3.4:
|
||||
dependencies:
|
||||
'@tokenizer/inflate': 0.4.1
|
||||
strtok3: 10.3.5
|
||||
token-types: 6.1.2
|
||||
uint8array-extras: 1.5.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
iterare@1.2.1: {}
|
||||
|
||||
load-esm@1.0.3: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
reflect-metadata@0.2.2: {}
|
||||
|
||||
rxjs@7.8.2:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
strtok3@10.3.5:
|
||||
dependencies:
|
||||
'@tokenizer/token': 0.3.0
|
||||
|
||||
token-types@6.1.2:
|
||||
dependencies:
|
||||
'@borewit/text-codec': 0.2.2
|
||||
'@tokenizer/token': 0.3.0
|
||||
ieee754: 1.2.1
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
uid@2.0.2:
|
||||
dependencies:
|
||||
'@lukeed/csprng': 1.1.0
|
||||
|
||||
uint8array-extras@1.5.0: {}
|
||||
@@ -1,36 +1,157 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
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";
|
||||
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
||||
|
||||
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 { CustomersModule } from "./modules/customers/customers.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 { CompaniesModule } from "./modules/companies/companies.module";
|
||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||
import { BillingModule } from "./modules/billing/billing.module";
|
||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||
import { OtpModule } from "./modules/otp/otp.module";
|
||||
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
||||
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
|
||||
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
|
||||
import { FreightAuthModule } from "./modules/auth/freight-auth.module";
|
||||
import {
|
||||
EDR_FREIGHT_APPLICATION,
|
||||
EDR_FREIGHT_PERMISSIONS,
|
||||
} from "./seed/edr-freight.seed";
|
||||
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
||||
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
|
||||
import { PaymentModule } from "./modules/payment/payment.module";
|
||||
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||
import { 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';
|
||||
import { ContainersModule } from './modules/container-management/containers.module';
|
||||
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';
|
||||
import { VehiclesModule } from './modules/vehicles/vehicles.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 =>
|
||||
config.get<TypeOrmModuleOptions>("database")!,
|
||||
dataSourceFactory: async (options) => {
|
||||
if (!options) {
|
||||
throw new Error("Missing TypeORM DataSource options");
|
||||
}
|
||||
await ensurePostgresSchemas(options as DataSourceOptions);
|
||||
const dataSource = new DataSource(options as DataSourceOptions);
|
||||
return dataSource.initialize();
|
||||
},
|
||||
}),
|
||||
SharedAuthModule,
|
||||
IamModule.forRoot({
|
||||
applications: [EDR_FREIGHT_APPLICATION],
|
||||
permissions: EDR_FREIGHT_PERMISSIONS,
|
||||
}),
|
||||
BookingsModule,
|
||||
SignaturesModule,
|
||||
FilesModule,
|
||||
ConsignmentsModule,
|
||||
TrainsModule,
|
||||
CustomersModule,
|
||||
LocomotivesModule,
|
||||
WagonTypesModule,
|
||||
TrainSetsModule,
|
||||
TrainSchedulesModule,
|
||||
TrainSchedulingModule,
|
||||
SchedulingRescheduleModule,
|
||||
CompaniesModule,
|
||||
TrackingModule,
|
||||
BillingModule,
|
||||
NotificationsModule,
|
||||
FileUploadSettingsModule,
|
||||
DropdownSettingsModule,
|
||||
OtpModule,
|
||||
RuleEngineModule,
|
||||
BackofficeModule,
|
||||
DemoPermissionsModule,
|
||||
FreightAuthModule,
|
||||
PaymentModule,
|
||||
//New Modules
|
||||
TrainsModule,
|
||||
WagonsModule,
|
||||
ContainersModule,
|
||||
CargoesModule,
|
||||
RoutesModule,
|
||||
FacilitiesModule,
|
||||
WarehousesModule,
|
||||
OverviewModule,
|
||||
VehiclesModule,
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
DemoUsersSeeder,
|
||||
FreightStaffUsersSeeder,
|
||||
PricingDataSeeder,
|
||||
FileUploadSettingsSeeder,
|
||||
FreightPermissionKeyMigrationSeeder,
|
||||
DemoFreightDataSeeder,
|
||||
IndodeFacilitySeeder,
|
||||
Batch14TestDataSeeder,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private readonly seeder: DataSeeder,
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
||||
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();
|
||||
await this.freightStaffUsersSeeder.run();
|
||||
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
|
||||
// FileUploadSettingsSeeder) are intentionally disabled — they stay
|
||||
// registered as providers but are not run. Re-inject + call .run() to enable.
|
||||
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
|
||||
// rules are disabled inside the seeder). Kept running for the staff users.
|
||||
await this.demoFreightDataSeeder.run();
|
||||
}
|
||||
}
|
||||
|
||||
30
apps/edr-freight-api/src/common/booking-guards.ts
Normal file
30
apps/edr-freight-api/src/common/booking-guards.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { applyDecorators, UseGuards } from '@nestjs/common';
|
||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
|
||||
import { FreightPermissionGuard } from './freight-permission.guard';
|
||||
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
|
||||
|
||||
export const BookingStaff = (permission: string | string[]) =>
|
||||
applyDecorators(
|
||||
UseGuards(
|
||||
JwtGuard,
|
||||
FreightPermissionGuard(
|
||||
Array.isArray(permission) ? permission : [permission],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
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';
|
||||
}
|
||||
38
apps/edr-freight-api/src/common/freight-permission.guard.ts
Normal file
38
apps/edr-freight-api/src/common/freight-permission.guard.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Type,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { hasFreightPermission } from './freight-permission.util';
|
||||
|
||||
export function FreightPermissionGuard(
|
||||
permissions: string[],
|
||||
): Type<CanActivate> {
|
||||
@Injectable()
|
||||
class FreightPermissionsGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
|
||||
const user = request.user;
|
||||
|
||||
if (!permissions?.length) return true;
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Authentication required');
|
||||
}
|
||||
|
||||
if (permissions.some((p) => hasFreightPermission(user, p))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new ForbiddenException(
|
||||
`Missing permission. Required one of: ${permissions.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return FreightPermissionsGuard;
|
||||
}
|
||||
109
apps/edr-freight-api/src/common/freight-permission.util.ts
Normal file
109
apps/edr-freight-api/src/common/freight-permission.util.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
|
||||
|
||||
const SUPER_ADMIN_ROLE = 'super_admin';
|
||||
const ORGANIZATION_ADMIN_ROLE = 'organization_admin';
|
||||
|
||||
type PermissionLike = { key?: string };
|
||||
type MeLikeUser = {
|
||||
roles?: { key?: string }[];
|
||||
permissions?: PermissionLike[];
|
||||
employee?:
|
||||
| {
|
||||
position?: { permissions?: PermissionLike[] };
|
||||
delegatedPositions?: { permissions?: PermissionLike[] }[];
|
||||
}
|
||||
| {
|
||||
positions?: { permissions?: PermissionLike[] }[];
|
||||
}[]
|
||||
| null;
|
||||
};
|
||||
|
||||
export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean {
|
||||
if (!user?.roles?.length) return false;
|
||||
return user.roles.some((r) => r.key === SUPER_ADMIN_ROLE);
|
||||
}
|
||||
|
||||
export function isOrganizationAdmin(user: MeLikeUser | null | undefined): boolean {
|
||||
if (!user?.roles?.length) return false;
|
||||
return user.roles.some((r) => r.key === ORGANIZATION_ADMIN_ROLE);
|
||||
}
|
||||
|
||||
export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boolean {
|
||||
return isSuperAdmin(user) || isOrganizationAdmin(user);
|
||||
}
|
||||
|
||||
/** Flat permission keys from JWT / session user (roles + position permissions). */
|
||||
export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] {
|
||||
if (!user) return [];
|
||||
|
||||
const keys = new Set<string>();
|
||||
|
||||
for (const p of user.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
|
||||
const employee = user.employee;
|
||||
if (!employee) {
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
if (Array.isArray(employee)) {
|
||||
for (const emp of employee) {
|
||||
for (const pos of emp.positions ?? []) {
|
||||
for (const p of pos.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
for (const p of employee.position?.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
for (const delegated of employee.delegatedPositions ?? []) {
|
||||
for (const p of delegated.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
}
|
||||
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
export function hasFreightPermission(
|
||||
user: MeLikeUser | null | undefined,
|
||||
permissionKey: string,
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
if (isSuperAdmin(user)) return true;
|
||||
return collectPermissionKeys(user).includes(permissionKey);
|
||||
}
|
||||
|
||||
export function assertFreightPermission(
|
||||
user: TCurrentUser | MeLikeUser | null | undefined,
|
||||
permissionKey: string,
|
||||
): void {
|
||||
if (hasFreightPermission(user, permissionKey)) return;
|
||||
throw new ForbiddenException(`Missing permission: ${permissionKey}`);
|
||||
}
|
||||
|
||||
const APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
||||
LINE_STAFF: FREIGHT_PERMS.bookings.approveLineStaff,
|
||||
DIRECTOR: FREIGHT_PERMS.bookings.approveDirector,
|
||||
CEO: FREIGHT_PERMS.bookings.approveCeo,
|
||||
};
|
||||
|
||||
export function assertCanApproveBookingStep(
|
||||
user: TCurrentUser | MeLikeUser | null | undefined,
|
||||
requiredRole: string,
|
||||
): void {
|
||||
if (isFreightApprovalAdmin(user)) return;
|
||||
const perm = APPROVE_ROLE_PERMISSION[requiredRole];
|
||||
if (!perm) {
|
||||
throw new ForbiddenException(`Unknown approval role: ${requiredRole}`);
|
||||
}
|
||||
assertFreightPermission(user, perm);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
12
apps/edr-freight-api/src/common/resolve-auth-user-id.ts
Normal file
12
apps/edr-freight-api/src/common/resolve-auth-user-id.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
export type AuthUserPayload = { id?: string; sub?: string } | null | undefined;
|
||||
|
||||
/** Resolve IAM user id from JWT payload attached by JwtGuard. */
|
||||
export function resolveAuthUserId(user: AuthUserPayload): string {
|
||||
const id = user?.id ?? user?.sub;
|
||||
if (!id) {
|
||||
throw new UnauthorizedException('Authentication required');
|
||||
}
|
||||
return id;
|
||||
}
|
||||
18
apps/edr-freight-api/src/common/rule-engine-guards.ts
Normal file
18
apps/edr-freight-api/src/common/rule-engine-guards.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { applyDecorators, UseGuards } from '@nestjs/common';
|
||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
|
||||
import { FreightPermissionGuard } from './freight-permission.guard';
|
||||
import {
|
||||
FREIGHT_PERMS,
|
||||
type RuleEngineResourceSlug,
|
||||
} from '../seed/freight-permissions.registry';
|
||||
|
||||
export const RuleEngineView = (slug: RuleEngineResourceSlug) =>
|
||||
applyDecorators(
|
||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])),
|
||||
);
|
||||
|
||||
export const RuleEngineManage = (slug: RuleEngineResourceSlug) =>
|
||||
applyDecorators(
|
||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])),
|
||||
);
|
||||
15
apps/edr-freight-api/src/common/utils/generate-code.util.ts
Normal file
15
apps/edr-freight-api/src/common/utils/generate-code.util.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Derives a stable, uppercase, underscore-separated code from a human-readable name.
|
||||
*
|
||||
* Examples:
|
||||
* "Hazard Surcharge" → "HAZARD_SURCHARGE"
|
||||
* "20ft Dry Container" → "20FT_DRY_CONTAINER"
|
||||
* "Kality Yard (ET)" → "KALITY_YARD_ET"
|
||||
*/
|
||||
export function generateCode(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,19 +1,123 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
import { join, dirname } from "path";
|
||||
import {
|
||||
DefaultPosition,
|
||||
DefaultUnit,
|
||||
EmployeePosition,
|
||||
Employee,
|
||||
OrganizationConfiguration,
|
||||
GlobalOrganizationConfiguration,
|
||||
OrganizationType,
|
||||
Organization,
|
||||
PositionConfiguration,
|
||||
PositionPermission,
|
||||
PositionTypeConfiguration,
|
||||
PositionTypePermission,
|
||||
PositionType,
|
||||
Position,
|
||||
Project,
|
||||
GlobalUnitConfiguration,
|
||||
Unit,
|
||||
EmployeeSignature,
|
||||
EmployeeStamp,
|
||||
RecordFooter,
|
||||
RecordHeader,
|
||||
Seal,
|
||||
AccountConfiguration,
|
||||
Application,
|
||||
DocumentaryRequirement,
|
||||
Permission,
|
||||
RolePermission,
|
||||
Role,
|
||||
Session,
|
||||
UserCredential,
|
||||
UserDocument,
|
||||
UserRole,
|
||||
UserVerification,
|
||||
User,
|
||||
Notification,
|
||||
NotificationEvent,
|
||||
NotificationPlaceholder,
|
||||
NotificationReceiverField,
|
||||
NotificationReceiver,
|
||||
NotificationTemplate,
|
||||
} from "@tria-plc/iamapi-common";
|
||||
import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity";
|
||||
import { APPLICATION_SEARCH_PATH } from "./ensure-postgres-schemas";
|
||||
|
||||
export default registerAs(
|
||||
"database",
|
||||
(): TypeOrmModuleOptions => ({
|
||||
const iamEntities = [
|
||||
DefaultPosition,
|
||||
DefaultUnit,
|
||||
EmployeePosition,
|
||||
Employee,
|
||||
OrganizationConfiguration,
|
||||
GlobalOrganizationConfiguration,
|
||||
OrganizationSetting,
|
||||
OrganizationType,
|
||||
Organization,
|
||||
PositionConfiguration,
|
||||
PositionPermission,
|
||||
PositionTypeConfiguration,
|
||||
PositionTypePermission,
|
||||
PositionType,
|
||||
Position,
|
||||
Project,
|
||||
GlobalUnitConfiguration,
|
||||
Unit,
|
||||
EmployeeSignature,
|
||||
EmployeeStamp,
|
||||
RecordFooter,
|
||||
RecordHeader,
|
||||
Seal,
|
||||
AccountConfiguration,
|
||||
Application,
|
||||
DocumentaryRequirement,
|
||||
Permission,
|
||||
RolePermission,
|
||||
Role,
|
||||
Session,
|
||||
UserCredential,
|
||||
UserDocument,
|
||||
UserRole,
|
||||
UserVerification,
|
||||
User,
|
||||
Notification,
|
||||
NotificationEvent,
|
||||
NotificationPlaceholder,
|
||||
NotificationReceiverField,
|
||||
NotificationReceiver,
|
||||
NotificationTemplate,
|
||||
];
|
||||
|
||||
const iamMigrationsGlob = join(
|
||||
dirname(require.resolve("@tria-plc/iamapi-common/package.json")),
|
||||
"dist/db/migrations/*.js",
|
||||
);
|
||||
const freightMigrationsGlob = join(__dirname, "../migrations/*.js");
|
||||
|
||||
export default registerAs("database", (): TypeOrmModuleOptions => {
|
||||
return {
|
||||
type: "postgres",
|
||||
host: process.env.DB_HOST ?? "localhost",
|
||||
port: parseInt(process.env.DB_PORT ?? "5433", 10),
|
||||
username: process.env.DB_USER ?? "postgres",
|
||||
password: process.env.DB_PASSWORD ?? "",
|
||||
database: process.env.DB_NAME ?? "edr_freight",
|
||||
entities: [__dirname + "/../**/*.entity.{ts,js}"],
|
||||
migrations: [__dirname + "/../../migrations/*.{ts,js}"],
|
||||
// Never enable synchronize in production. Use migrations.
|
||||
synchronize: process.env.NODE_ENV === "development",
|
||||
schema: "public",
|
||||
extra: {
|
||||
options: `-c search_path=${APPLICATION_SEARCH_PATH}`,
|
||||
},
|
||||
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
|
||||
autoLoadEntities: true,
|
||||
migrations: [
|
||||
// IAM schema + tables must be created before freight migrations
|
||||
iamMigrationsGlob,
|
||||
freightMigrationsGlob,
|
||||
],
|
||||
migrationsRun: true,
|
||||
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
|
||||
synchronize: false,
|
||||
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 ?? ""
|
||||
}));
|
||||
50
apps/edr-freight-api/src/config/ensure-postgres-schemas.ts
Normal file
50
apps/edr-freight-api/src/config/ensure-postgres-schemas.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { DataSource, DataSourceOptions } from "typeorm";
|
||||
|
||||
/** Schemas required before TypeORM migrations and entity access. */
|
||||
export const APPLICATION_SCHEMAS = [
|
||||
"public",
|
||||
"iam",
|
||||
"freight",
|
||||
"audit",
|
||||
] as const;
|
||||
|
||||
export const APPLICATION_SEARCH_PATH = APPLICATION_SCHEMAS.join(",");
|
||||
|
||||
/**
|
||||
* TypeORM creates the migrations table before any migration runs. If `public` was
|
||||
* dropped, current_schema() is null and CREATE TABLE migrations fails.
|
||||
* IAM/freight migrations assume their schemas already exist.
|
||||
*/
|
||||
export async function ensurePostgresSchemas(
|
||||
options: DataSourceOptions,
|
||||
): Promise<void> {
|
||||
const bootstrap = new DataSource({
|
||||
...options,
|
||||
entities: [],
|
||||
migrations: [],
|
||||
migrationsRun: false,
|
||||
synchronize: false,
|
||||
});
|
||||
|
||||
await bootstrap.initialize();
|
||||
|
||||
for (const schema of APPLICATION_SCHEMAS) {
|
||||
if (schema === "public") {
|
||||
await bootstrap.query(`CREATE SCHEMA IF NOT EXISTS public`);
|
||||
await bootstrap.query(`GRANT ALL ON SCHEMA public TO public`);
|
||||
await bootstrap.query(`GRANT CREATE ON SCHEMA public TO public`);
|
||||
} else {
|
||||
await bootstrap.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
|
||||
await bootstrap.query(`GRANT USAGE ON SCHEMA "${schema}" TO public`);
|
||||
await bootstrap.query(
|
||||
`GRANT CREATE ON SCHEMA "${schema}" TO public`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await bootstrap.query(
|
||||
`SET search_path TO ${APPLICATION_SEARCH_PATH}`,
|
||||
);
|
||||
|
||||
await bootstrap.destroy();
|
||||
}
|
||||
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",
|
||||
}));
|
||||
224
apps/edr-freight-api/src/contracts/contract-clause-packs.ts
Normal file
224
apps/edr-freight-api/src/contracts/contract-clause-packs.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import type {
|
||||
Article1Clause,
|
||||
ContractClausePack,
|
||||
ContractDirection,
|
||||
ContractFreight,
|
||||
ContractServiceScope,
|
||||
} from './contract-template.types';
|
||||
|
||||
const STANDARD_CONTRACT_DOCUMENTS = [
|
||||
'Amendments (if any)',
|
||||
'This Contract Agreement',
|
||||
'Final Minutes of Negotiation (if any)',
|
||||
];
|
||||
|
||||
const PAYMENT_OBLIGATION =
|
||||
'Pay 100% transportation fees in advance per train set in accordance with Article 5.';
|
||||
|
||||
const HAZARDOUS_OBLIGATION =
|
||||
'Notify EDR 48 hours in advance for hazardous or valuable cargo.';
|
||||
|
||||
function clonePack(pack: ContractClausePack): ContractClausePack {
|
||||
return {
|
||||
article1: {
|
||||
objective: pack.article1.objective,
|
||||
scope: [...pack.article1.scope],
|
||||
},
|
||||
clientObligations: [...pack.clientObligations],
|
||||
providerObligations: [...pack.providerObligations],
|
||||
contractDocuments: [...pack.contractDocuments],
|
||||
};
|
||||
}
|
||||
|
||||
function applyForwardingOverlay(
|
||||
pack: ContractClausePack,
|
||||
service: ContractServiceScope,
|
||||
): ContractClausePack {
|
||||
if (service !== 'FORWARDING') return pack;
|
||||
|
||||
const next = clonePack(pack);
|
||||
next.article1.scope.push(
|
||||
'First-mile and/or last-mile coordination, documentation, and handover with road or port partners where included in the agreed service scope.',
|
||||
);
|
||||
next.providerObligations.push(
|
||||
'Coordinate first-mile and last-mile logistics with designated partners and keep the Client informed of handover milestones.',
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildImportContainerPack(): ContractClausePack {
|
||||
return {
|
||||
article1: {
|
||||
objective:
|
||||
'To provide railway transportation for 40ft and/or 20ft full containers from SGTD to Dire Dawa, Modjo dry port and/or Galaan Multipurpose port (GMP), and empty container return from those terminals to SGTD.',
|
||||
scope: [
|
||||
'Railway transport service on the agreed import corridor.',
|
||||
'Cargo handling at Galaan Multipurpose port (GMP) where applicable.',
|
||||
],
|
||||
},
|
||||
clientObligations: [
|
||||
'Provide shipment instructions to EDR for container movements on the agreed corridor.',
|
||||
'Meet minimum container supply per terminal (Modjo, Dire Dawa, GMP) as per EDR operational rules.',
|
||||
'Submit required documents to Djibouti Nagad station at least 24 hours before loading.',
|
||||
PAYMENT_OBLIGATION,
|
||||
HAZARDOUS_OBLIGATION,
|
||||
],
|
||||
providerObligations: [
|
||||
'Assign voyage per operational schedule and notify train schedule 48 hours in advance.',
|
||||
'Provide safe transportation and deliver within agreed timelines when documents are complete.',
|
||||
'Return empty containers from Dire Dawa, Modjo and GMP to SGTD within seven (7) calendar days of receipt.',
|
||||
'Maintain cargo liability insurance per wagon.',
|
||||
],
|
||||
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
|
||||
};
|
||||
}
|
||||
|
||||
function buildImportBulkPack(): ContractClausePack {
|
||||
return {
|
||||
article1: {
|
||||
objective:
|
||||
'To provide railway transportation for bulk cargo from SGTD railway freight station at Djibouti to designated Ethiopian rail terminals on the import corridor.',
|
||||
scope: [
|
||||
'Railway bulk transport service on the agreed import corridor.',
|
||||
'Loading and unloading coordination at designated terminals per EDR operational rules.',
|
||||
],
|
||||
},
|
||||
clientObligations: [
|
||||
'Provide accurate commodity description, weight, and shipment instructions for each train movement.',
|
||||
'Ensure cargo is prepared and available at origin per the agreed loading window.',
|
||||
'Submit required customs and operational documents at least 24 hours before loading where applicable.',
|
||||
PAYMENT_OBLIGATION,
|
||||
HAZARDOUS_OBLIGATION,
|
||||
],
|
||||
providerObligations: [
|
||||
'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.',
|
||||
'Provide safe bulk transportation and deliver within agreed timelines when documents are complete.',
|
||||
'Maintain cargo liability insurance per wagon or train consist as applicable.',
|
||||
],
|
||||
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
|
||||
};
|
||||
}
|
||||
|
||||
function buildExportContainerPack(): ContractClausePack {
|
||||
return {
|
||||
article1: {
|
||||
objective:
|
||||
'To provide railway transportation for 40ft and/or 20ft full containers from designated Ethiopian dry ports and terminals to SGTD and related export corridors.',
|
||||
scope: [
|
||||
'Railway export transport service on the agreed corridor.',
|
||||
'Terminal coordination at origin yards for export dispatch where applicable.',
|
||||
],
|
||||
},
|
||||
clientObligations: [
|
||||
'Provide export shipment instructions and container release details for each movement.',
|
||||
'Ensure containers are available at origin terminals per EDR operational windows.',
|
||||
'Submit required export, customs, and operational documents at origin at least 24 hours before loading.',
|
||||
PAYMENT_OBLIGATION,
|
||||
HAZARDOUS_OBLIGATION,
|
||||
],
|
||||
providerObligations: [
|
||||
'Assign voyage per operational schedule and notify train schedule 48 hours in advance.',
|
||||
'Provide safe transportation to SGTD and hand over for export processing when documents are complete.',
|
||||
'Maintain cargo liability insurance per wagon.',
|
||||
],
|
||||
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
|
||||
};
|
||||
}
|
||||
|
||||
function buildExportBulkPack(): ContractClausePack {
|
||||
return {
|
||||
article1: {
|
||||
objective:
|
||||
'To provide railway transportation for bulk export cargo from designated Ethiopian rail terminals to SGTD and related export corridors.',
|
||||
scope: [
|
||||
'Railway bulk export transport on the agreed corridor.',
|
||||
'Loading coordination at origin terminals per EDR operational rules.',
|
||||
],
|
||||
},
|
||||
clientObligations: [
|
||||
'Provide accurate commodity description, weight, and export shipment instructions.',
|
||||
'Ensure bulk cargo is prepared and available at origin per the agreed loading window.',
|
||||
'Submit required export and customs documents at least 24 hours before loading where applicable.',
|
||||
PAYMENT_OBLIGATION,
|
||||
HAZARDOUS_OBLIGATION,
|
||||
],
|
||||
providerObligations: [
|
||||
'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.',
|
||||
'Provide safe bulk transportation to SGTD within agreed timelines when documents are complete.',
|
||||
'Maintain cargo liability insurance per wagon or train consist as applicable.',
|
||||
],
|
||||
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
|
||||
};
|
||||
}
|
||||
|
||||
function buildDomesticContainerPack(): ContractClausePack {
|
||||
return {
|
||||
article1: {
|
||||
objective:
|
||||
'To provide railway transportation for 40ft and/or 20ft containers between designated Ethiopian rail terminals on the domestic corridor.',
|
||||
scope: ['Domestic railway container transport between agreed origin and destination yards.'],
|
||||
},
|
||||
clientObligations: [
|
||||
'Provide shipment instructions for each domestic container movement.',
|
||||
'Ensure containers are available at origin per EDR operational rules.',
|
||||
PAYMENT_OBLIGATION,
|
||||
HAZARDOUS_OBLIGATION,
|
||||
],
|
||||
providerObligations: [
|
||||
'Assign voyage per operational schedule and notify train schedule 48 hours in advance where practicable.',
|
||||
'Provide safe transportation and deliver within agreed timelines when instructions are complete.',
|
||||
'Maintain cargo liability insurance per wagon.',
|
||||
],
|
||||
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
|
||||
};
|
||||
}
|
||||
|
||||
function buildDomesticBulkPack(): ContractClausePack {
|
||||
return {
|
||||
article1: {
|
||||
objective:
|
||||
'To provide railway transportation for bulk cargo between designated Ethiopian rail terminals on the domestic corridor.',
|
||||
scope: ['Domestic railway bulk transport between agreed origin and destination terminals.'],
|
||||
},
|
||||
clientObligations: [
|
||||
'Provide commodity description, weight, and shipment instructions for each movement.',
|
||||
'Ensure cargo is prepared at origin per the agreed loading window.',
|
||||
PAYMENT_OBLIGATION,
|
||||
HAZARDOUS_OBLIGATION,
|
||||
],
|
||||
providerObligations: [
|
||||
'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.',
|
||||
'Provide safe bulk transportation within agreed timelines.',
|
||||
'Maintain cargo liability insurance per wagon or train consist as applicable.',
|
||||
],
|
||||
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
|
||||
};
|
||||
}
|
||||
|
||||
const BASE_PACKS: Record<ContractDirection, Record<ContractFreight, () => ContractClausePack>> = {
|
||||
IMP: {
|
||||
CON: buildImportContainerPack,
|
||||
BULK: buildImportBulkPack,
|
||||
},
|
||||
EXP: {
|
||||
CON: buildExportContainerPack,
|
||||
BULK: buildExportBulkPack,
|
||||
},
|
||||
DOM: {
|
||||
CON: buildDomesticContainerPack,
|
||||
BULK: buildDomesticBulkPack,
|
||||
},
|
||||
};
|
||||
|
||||
export function buildClausePack(
|
||||
direction: ContractDirection,
|
||||
freight: ContractFreight,
|
||||
service: ContractServiceScope,
|
||||
): ContractClausePack {
|
||||
const base = BASE_PACKS[direction][freight]();
|
||||
return applyForwardingOverlay(base, service);
|
||||
}
|
||||
|
||||
export function article1ObjectiveFromClause(article1: Article1Clause): string {
|
||||
return article1.objective;
|
||||
}
|
||||
116
apps/edr-freight-api/src/contracts/contract-pdf.service.ts
Normal file
116
apps/edr-freight-api/src/contracts/contract-pdf.service.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
|
||||
const MIN_VALID_PDF_BYTES = 2_000;
|
||||
|
||||
const PDF_PRINT_STYLES = `
|
||||
<style id="contract-pdf-print-fix">
|
||||
@media print {
|
||||
html, body {
|
||||
background: #fff !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
.cover {
|
||||
min-height: auto !important;
|
||||
page-break-after: always;
|
||||
}
|
||||
.cover-title {
|
||||
margin: 24mm 0 20mm !important;
|
||||
}
|
||||
}
|
||||
</style>`;
|
||||
|
||||
@Injectable()
|
||||
export class ContractPdfService {
|
||||
private readonly logger = new Logger(ContractPdfService.name);
|
||||
|
||||
async htmlToPdfBuffer(html: string): Promise<Buffer> {
|
||||
const preparedHtml = this.injectPdfPrintStyles(html);
|
||||
const executablePath = this.resolveExecutablePath();
|
||||
|
||||
try {
|
||||
const puppeteer = await import('puppeteer');
|
||||
const launchOptions: import('puppeteer').LaunchOptions = {
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
],
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
};
|
||||
|
||||
const browser = await puppeteer.default.launch(launchOptions);
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
|
||||
await page.setContent(preparedHtml, {
|
||||
waitUntil: 'load',
|
||||
timeout: 60_000,
|
||||
});
|
||||
await page.emulateMediaType('print');
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
|
||||
const pdf = await page.pdf({
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
displayHeaderFooter: true,
|
||||
headerTemplate: '<span></span>',
|
||||
footerTemplate:
|
||||
'<div style="width:100%;font-size:8px;color:#64748b;text-align:center;font-family:Arial,sans-serif;">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
|
||||
margin: { top: '18mm', bottom: '22mm', left: '14mm', right: '14mm' },
|
||||
});
|
||||
|
||||
const buffer = Buffer.from(pdf);
|
||||
if (!this.isValidPdf(buffer)) {
|
||||
throw new Error(
|
||||
`Puppeteer produced invalid PDF (${buffer.length} bytes)`,
|
||||
);
|
||||
}
|
||||
this.logger.log(
|
||||
`Contract PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`,
|
||||
);
|
||||
return buffer;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Puppeteer PDF failed (executable=${executablePath ?? 'default'}): ${err}`,
|
||||
);
|
||||
throw new InternalServerErrorException(
|
||||
'Contract PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private injectPdfPrintStyles(html: string): string {
|
||||
if (html.includes('contract-pdf-print-fix')) return html;
|
||||
if (html.includes('</head>')) {
|
||||
return html.replace('</head>', `${PDF_PRINT_STYLES}</head>`);
|
||||
}
|
||||
return `${PDF_PRINT_STYLES}${html}`;
|
||||
}
|
||||
|
||||
private resolveExecutablePath(): string | undefined {
|
||||
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
|
||||
if (fromEnv && existsSync(fromEnv)) return fromEnv;
|
||||
|
||||
const candidates = [
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/google-chrome',
|
||||
];
|
||||
return candidates.find((p) => existsSync(p));
|
||||
}
|
||||
|
||||
private isValidPdf(buffer: Buffer): boolean {
|
||||
return (
|
||||
buffer.length >= MIN_VALID_PDF_BYTES &&
|
||||
buffer.subarray(0, 5).toString('ascii') === '%PDF-'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { BookingPricingService } from '../modules/bookings/booking-pricing.service';
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { PriceLineItemDto } from '../modules/bookings/dto/generate-price-response.dto';
|
||||
|
||||
export interface PricingScheduleRow {
|
||||
label: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface PricingSchedule {
|
||||
lineItems: PricingScheduleRow[];
|
||||
surcharges: PricingScheduleRow[];
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
equipmentReturn?: string;
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
containerLines: Array<{
|
||||
label: string;
|
||||
quantity: number;
|
||||
vgmPerUnitTons: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContractPricingScheduleBuilder {
|
||||
constructor(private readonly pricingService: BookingPricingService) {}
|
||||
|
||||
async build(booking: Booking): Promise<PricingSchedule> {
|
||||
const { lineItems, totalAmount, currency } =
|
||||
await this.pricingService.computeContractLineItems(booking);
|
||||
|
||||
const isSurcharge = (l: PriceLineItemDto) =>
|
||||
l.code.includes('SURCHARGE') || l.description.toLowerCase().includes('surcharge');
|
||||
|
||||
const baseLines = lineItems.filter((l) => !isSurcharge(l));
|
||||
const surchargeLines = lineItems.filter(isSurcharge);
|
||||
|
||||
return {
|
||||
lineItems: baseLines.map((l) => ({
|
||||
label: l.code,
|
||||
description: l.description,
|
||||
amount: l.amount,
|
||||
currency: l.currency,
|
||||
})),
|
||||
surcharges: surchargeLines.map((l) => ({
|
||||
label: l.code,
|
||||
description: l.description,
|
||||
amount: l.amount,
|
||||
currency: l.currency,
|
||||
})),
|
||||
totalAmount,
|
||||
currency,
|
||||
equipmentReturn: booking.equipmentReturn ?? '—',
|
||||
originLabel: booking.originYard?.label ?? booking.originYard?.code ?? '—',
|
||||
destinationLabel:
|
||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—',
|
||||
containerLines: (booking.bookingContainers ?? []).map((c) => ({
|
||||
label:
|
||||
c.containerType?.label ??
|
||||
c.containerType?.code ??
|
||||
c.containerTypeId ??
|
||||
'—',
|
||||
quantity: c.quantity,
|
||||
vgmPerUnitTons: Number(c.vgmPerUnitTons),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ContractRendererService } from './contract-renderer.service';
|
||||
import { getTemplateMeta } from './contract-template.registry';
|
||||
import type { ContractViewModel } from './contract-view-model.builder';
|
||||
|
||||
describe('ContractRendererService', () => {
|
||||
const renderer = new ContractRendererService();
|
||||
renderer.onModuleInit();
|
||||
|
||||
function minimalView(templateKey: string): ContractViewModel {
|
||||
const template = getTemplateMeta(templateKey);
|
||||
return {
|
||||
bookingId: 'test-id',
|
||||
reference: 'BK-TEST-001',
|
||||
status: 'CONTRACT_READY',
|
||||
templateKey,
|
||||
template,
|
||||
contractDate: '1 January 2026',
|
||||
contractYear: 2026,
|
||||
client: {
|
||||
companyName: 'Test Co',
|
||||
companyAddress: 'Addis Ababa',
|
||||
companyLocation: 'Ethiopia',
|
||||
phone: '+251900000000',
|
||||
email: 'test@example.com',
|
||||
tinNumber: '1234567890',
|
||||
vatNumber: 'VAT-001',
|
||||
fanNumber: 'FAN-001',
|
||||
businessLicense: 'BL-001',
|
||||
},
|
||||
provider: {
|
||||
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
|
||||
address: 'Addis Ababa, Ethiopia',
|
||||
phone: '+251 11 872 0000',
|
||||
email: 'info@edr.gov.et',
|
||||
tinNumber: '—',
|
||||
},
|
||||
schedule: {
|
||||
originLabel: 'SGTD',
|
||||
destinationLabel: 'Modjo',
|
||||
tradeDirection: 'IMPORT',
|
||||
freightType: 'CONTAINER',
|
||||
serviceType: 'Rail transport',
|
||||
scheduledDate: '1 January 2026',
|
||||
contractType: 'NEW',
|
||||
cargoDescription: 'Container cargo',
|
||||
totalWeightVgm: '24 tons',
|
||||
equipmentReturn: 'RETURN',
|
||||
hazardousLabel: 'No',
|
||||
firstMilePickupAddress: '—',
|
||||
lastMileDeliveryAddress: '—',
|
||||
},
|
||||
pricing: {
|
||||
lineItems: [{ label: 'RAIL', description: 'Rail transport', amount: 1000, currency: 'ETB' }],
|
||||
surcharges: [],
|
||||
totalAmount: 1000,
|
||||
currency: 'ETB',
|
||||
originLabel: 'SGTD',
|
||||
destinationLabel: 'Modjo',
|
||||
containerLines: [{ label: '40ft', quantity: 2, vgmPerUnitTons: 12 }],
|
||||
},
|
||||
signatures: [],
|
||||
canSignCustomer: true,
|
||||
canSignStaff: false,
|
||||
hasContractDocument: false,
|
||||
hasCustomerSignature: false,
|
||||
hasStaffSignature: false,
|
||||
};
|
||||
}
|
||||
|
||||
it('renders import flagship with Nagad and Article 5', () => {
|
||||
const html = renderer.render(minimalView('IMP_CON_ETB_TRANSPORT_ONLY'));
|
||||
expect(html).toContain('Djibouti Nagad');
|
||||
expect(html).toContain('Article 5: Contract Price');
|
||||
expect(html).toContain('Article 2: Obligations of the Client');
|
||||
});
|
||||
|
||||
it('renders export variant without import empty-return clause', () => {
|
||||
const html = renderer.render(minimalView('EXP_CON_USD_TRANSPORT_ONLY'));
|
||||
expect(html).toContain('export corridors');
|
||||
expect(html).not.toContain('Return empty containers from Dire Dawa');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import Handlebars from 'handlebars';
|
||||
|
||||
import { ContractViewModel } from './contract-view-model.builder';
|
||||
|
||||
@Injectable()
|
||||
export class ContractRendererService implements OnModuleInit {
|
||||
private readonly templatesDir = path.join(__dirname, 'templates');
|
||||
private readonly compiled = new Map<string, Handlebars.TemplateDelegate>();
|
||||
|
||||
onModuleInit(): void {
|
||||
Handlebars.registerHelper('eq', (a: unknown, b: unknown) => a === b);
|
||||
|
||||
const partialsDir = path.join(this.templatesDir, '_partials');
|
||||
if (fs.existsSync(partialsDir)) {
|
||||
for (const file of fs.readdirSync(partialsDir)) {
|
||||
if (!file.endsWith('.hbs')) continue;
|
||||
const name = file.replace(/\.hbs$/, '');
|
||||
const content = fs.readFileSync(path.join(partialsDir, file), 'utf-8');
|
||||
Handlebars.registerPartial(name, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render(view: ContractViewModel): string {
|
||||
const fileName =
|
||||
view.template.templateFile ?? 'generic.hbs';
|
||||
const template = this.getCompiled(fileName);
|
||||
return template({
|
||||
...view,
|
||||
paymentArticle: view.pricing.currency === 'ETB' ? 'ETB' : 'USD',
|
||||
});
|
||||
}
|
||||
|
||||
private getCompiled(fileName: string): Handlebars.TemplateDelegate {
|
||||
const cached = this.compiled.get(fileName);
|
||||
if (cached) return cached;
|
||||
|
||||
const filePath = path.join(this.templatesDir, fileName);
|
||||
const fallbackPath = path.join(this.templatesDir, 'generic.hbs');
|
||||
const source = fs.existsSync(filePath)
|
||||
? fs.readFileSync(filePath, 'utf-8')
|
||||
: fs.readFileSync(fallbackPath, 'utf-8');
|
||||
|
||||
const compiled = Handlebars.compile(source);
|
||||
this.compiled.set(fileName, compiled);
|
||||
return compiled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
CONTRACT_TEMPLATE_KEYS,
|
||||
CONTRACT_TEMPLATE_REGISTRY,
|
||||
getTemplateMeta,
|
||||
isValidTemplateKey,
|
||||
} from './contract-template.registry';
|
||||
|
||||
describe('ContractTemplateRegistry', () => {
|
||||
it('defines exactly 24 template keys', () => {
|
||||
expect(CONTRACT_TEMPLATE_KEYS).toHaveLength(24);
|
||||
expect(Object.keys(CONTRACT_TEMPLATE_REGISTRY)).toHaveLength(24);
|
||||
});
|
||||
|
||||
it('keys match the direction_freight_currency_service pattern', () => {
|
||||
for (const key of CONTRACT_TEMPLATE_KEYS) {
|
||||
expect(isValidTemplateKey(key)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('each meta has non-empty obligations and article1 scope', () => {
|
||||
for (const key of CONTRACT_TEMPLATE_KEYS) {
|
||||
const meta = CONTRACT_TEMPLATE_REGISTRY[key]!;
|
||||
expect(meta.clientObligations.length).toBeGreaterThan(0);
|
||||
expect(meta.providerObligations.length).toBeGreaterThan(0);
|
||||
expect(meta.article1.scope.length).toBeGreaterThan(0);
|
||||
expect(meta.article1.objective.length).toBeGreaterThan(0);
|
||||
expect(meta.contractDocuments.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('IMP_CON_ETB_TRANSPORT_ONLY retains import container flagship clauses', () => {
|
||||
const meta = getTemplateMeta('IMP_CON_ETB_TRANSPORT_ONLY');
|
||||
expect(meta.direction).toBe('IMP');
|
||||
expect(meta.freight).toBe('CON');
|
||||
expect(meta.article1.objective).toContain('SGTD');
|
||||
expect(meta.article1.objective).toContain('empty container return');
|
||||
const clientText = meta.clientObligations.join(' ');
|
||||
expect(clientText).toContain('Djibouti Nagad');
|
||||
const providerText = meta.providerObligations.join(' ');
|
||||
expect(providerText).toContain('seven (7) calendar days');
|
||||
});
|
||||
|
||||
it('EXP_CON_USD_TRANSPORT_ONLY uses export-oriented article1', () => {
|
||||
const meta = getTemplateMeta('EXP_CON_USD_TRANSPORT_ONLY');
|
||||
expect(meta.direction).toBe('EXP');
|
||||
expect(meta.article1.objective).toContain('SGTD');
|
||||
expect(meta.providerObligations.join(' ')).not.toContain(
|
||||
'Return empty containers from Dire Dawa',
|
||||
);
|
||||
});
|
||||
|
||||
it('FORWARDING adds scope and provider obligations', () => {
|
||||
const transport = getTemplateMeta('IMP_CON_ETB_TRANSPORT_ONLY');
|
||||
const forwarding = getTemplateMeta('IMP_CON_ETB_FORWARDING');
|
||||
expect(forwarding.article1.scope.length).toBeGreaterThan(
|
||||
transport.article1.scope.length,
|
||||
);
|
||||
expect(forwarding.providerObligations.length).toBeGreaterThan(
|
||||
transport.providerObligations.length,
|
||||
);
|
||||
});
|
||||
|
||||
it('getTemplateMeta fallback includes clause arrays for unknown keys', () => {
|
||||
const meta = getTemplateMeta('UNKNOWN_KEY');
|
||||
expect(meta.clientObligations.length).toBeGreaterThan(0);
|
||||
expect(meta.article1.scope.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
119
apps/edr-freight-api/src/contracts/contract-template.registry.ts
Normal file
119
apps/edr-freight-api/src/contracts/contract-template.registry.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
article1ObjectiveFromClause,
|
||||
buildClausePack,
|
||||
} from './contract-clause-packs';
|
||||
import type {
|
||||
ContractDirection,
|
||||
ContractFreight,
|
||||
ContractServiceScope,
|
||||
ContractTemplateMeta,
|
||||
} from './contract-template.types';
|
||||
|
||||
export type { ContractTemplateMeta } from './contract-template.types';
|
||||
|
||||
const DIRECTION_LABELS: Record<ContractDirection, string> = {
|
||||
IMP: 'Import',
|
||||
EXP: 'Export',
|
||||
DOM: 'Domestic',
|
||||
};
|
||||
|
||||
const FREIGHT_LABELS: Record<ContractFreight, string> = {
|
||||
CON: 'Container',
|
||||
BULK: 'Bulk',
|
||||
};
|
||||
|
||||
const DIRECTIONS: ContractDirection[] = ['IMP', 'EXP', 'DOM'];
|
||||
const FREIGHTS: ContractFreight[] = ['CON', 'BULK'];
|
||||
const CURRENCIES = ['ETB', 'USD'] as const;
|
||||
const SERVICES: ContractServiceScope[] = ['TRANSPORT_ONLY', 'FORWARDING'];
|
||||
|
||||
const KEY_PATTERN =
|
||||
/^(IMP|EXP|DOM)_(CON|BULK)_(ETB|USD)_(TRANSPORT_ONLY|FORWARDING)$/;
|
||||
|
||||
function buildMeta(
|
||||
dir: ContractDirection,
|
||||
freight: ContractFreight,
|
||||
currency: string,
|
||||
service: ContractServiceScope,
|
||||
): ContractTemplateMeta {
|
||||
const key = `${dir}_${freight}_${currency}_${service}`;
|
||||
const dirLabel = DIRECTION_LABELS[dir];
|
||||
const freightLabel = FREIGHT_LABELS[freight];
|
||||
const serviceLabel =
|
||||
service === 'FORWARDING' ? 'Rail and Forwarding' : 'Transport Only';
|
||||
|
||||
const corridor =
|
||||
dir === 'IMP'
|
||||
? 'from SGTD railway freight station at Djibouti to Ethiopian dry ports and return of empty containers as applicable'
|
||||
: dir === 'EXP'
|
||||
? 'from Ethiopian dry ports to SGTD and related export corridors'
|
||||
: 'between designated Ethiopian rail terminals';
|
||||
|
||||
const clauses = buildClausePack(dir, freight, service);
|
||||
|
||||
return {
|
||||
key,
|
||||
direction: dir,
|
||||
freight,
|
||||
currency,
|
||||
serviceScope: service,
|
||||
title: `${dirLabel} ${freightLabel} Transport Service by Railway (${serviceLabel})`,
|
||||
directionLabel: dirLabel,
|
||||
freightLabel,
|
||||
whereas: `The Client has requested transportation of ${freightLabel.toLowerCase()} cargo ${corridor} using the Addis Ababa–Djibouti Railway line. The Service Provider has agreed to provide services per this contract.`,
|
||||
article1Objective: article1ObjectiveFromClause(clauses.article1),
|
||||
article1: clauses.article1,
|
||||
clientObligations: clauses.clientObligations,
|
||||
providerObligations: clauses.providerObligations,
|
||||
contractDocuments: clauses.contractDocuments,
|
||||
};
|
||||
}
|
||||
|
||||
/** Full template matrix (24 keys). */
|
||||
export const CONTRACT_TEMPLATE_REGISTRY: Record<string, ContractTemplateMeta> =
|
||||
{};
|
||||
|
||||
for (const dir of DIRECTIONS) {
|
||||
for (const freight of FREIGHTS) {
|
||||
for (const currency of CURRENCIES) {
|
||||
for (const service of SERVICES) {
|
||||
const meta = buildMeta(dir, freight, currency, service);
|
||||
CONTRACT_TEMPLATE_REGISTRY[meta.key] = meta;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CONTRACT_TEMPLATE_KEYS = Object.keys(CONTRACT_TEMPLATE_REGISTRY);
|
||||
|
||||
export function listTemplateKeys(): string[] {
|
||||
return CONTRACT_TEMPLATE_KEYS;
|
||||
}
|
||||
|
||||
export function getTemplateMeta(key: string): ContractTemplateMeta {
|
||||
const found = CONTRACT_TEMPLATE_REGISTRY[key];
|
||||
if (found) return found;
|
||||
|
||||
const fallbackClauses = buildClausePack('IMP', 'CON', 'TRANSPORT_ONLY');
|
||||
return {
|
||||
key,
|
||||
direction: 'IMP',
|
||||
freight: 'CON',
|
||||
currency: 'USD',
|
||||
serviceScope: 'TRANSPORT_ONLY',
|
||||
title: 'Freight Contract Agreement',
|
||||
directionLabel: 'Freight',
|
||||
freightLabel: 'Cargo',
|
||||
whereas:
|
||||
'The parties agree to railway freight services as described in the schedule below.',
|
||||
article1Objective: article1ObjectiveFromClause(fallbackClauses.article1),
|
||||
article1: fallbackClauses.article1,
|
||||
clientObligations: fallbackClauses.clientObligations,
|
||||
providerObligations: fallbackClauses.providerObligations,
|
||||
contractDocuments: fallbackClauses.contractDocuments,
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidTemplateKey(key: string): boolean {
|
||||
return KEY_PATTERN.test(key);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ContractTemplateResolver } from './contract-template.resolver';
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
|
||||
|
||||
describe('ContractTemplateResolver', () => {
|
||||
const resolver = new ContractTemplateResolver();
|
||||
|
||||
function booking(partial: Partial<Booking>): Booking {
|
||||
return partial as Booking;
|
||||
}
|
||||
|
||||
it('resolves import container ETB transport-only', () => {
|
||||
const key = resolver.resolve(
|
||||
booking({
|
||||
tradeDirection: 'IMPORT',
|
||||
freightType: 'CONTAINER',
|
||||
paymentCurrency: 'ETB',
|
||||
serviceType: { code: 'RAIL_ONLY', includesFirstMile: false, includesLastMile: false } as ServiceType,
|
||||
}),
|
||||
);
|
||||
expect(key).toBe('IMP_CON_ETB_TRANSPORT_ONLY');
|
||||
});
|
||||
|
||||
it('resolves export bulk USD forwarding', () => {
|
||||
const key = resolver.resolve(
|
||||
booking({
|
||||
tradeDirection: 'EXPORT',
|
||||
freightType: 'BULK',
|
||||
paymentCurrency: 'USD',
|
||||
serviceType: {
|
||||
code: 'RAIL_FORWARDING',
|
||||
includesFirstMile: true,
|
||||
includesLastMile: false,
|
||||
} as ServiceType,
|
||||
}),
|
||||
);
|
||||
expect(key).toBe('EXP_BULK_USD_FORWARDING');
|
||||
});
|
||||
|
||||
it('maps BREAK_BULK cargo to BULK freight', () => {
|
||||
const key = resolver.resolve(
|
||||
booking({
|
||||
tradeDirection: 'IMPORT',
|
||||
freightType: 'CONTAINER',
|
||||
paymentCurrency: 'ETB',
|
||||
cargoType: { code: 'BREAK_BULK_GENERAL' } as CargoType,
|
||||
serviceType: undefined,
|
||||
}),
|
||||
);
|
||||
expect(key).toBe('IMP_BULK_ETB_TRANSPORT_ONLY');
|
||||
});
|
||||
|
||||
it('resolves domestic container', () => {
|
||||
const key = resolver.resolve(
|
||||
booking({
|
||||
tradeDirection: 'DOMESTIC',
|
||||
freightType: 'CONTAINER',
|
||||
paymentCurrency: 'USD',
|
||||
serviceType: undefined,
|
||||
}),
|
||||
);
|
||||
expect(key).toBe('DOM_CON_USD_TRANSPORT_ONLY');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ContractTemplateResolver {
|
||||
resolve(booking: Booking): string {
|
||||
const dir =
|
||||
booking.tradeDirection === 'IMPORT'
|
||||
? 'IMP'
|
||||
: booking.tradeDirection === 'EXPORT'
|
||||
? 'EXP'
|
||||
: 'DOM';
|
||||
|
||||
let freight = booking.freightType === 'BULK' ? 'BULK' : 'CON';
|
||||
const cargoCode = (booking.cargoType as CargoType | undefined)?.code ?? '';
|
||||
if (cargoCode.startsWith('BREAK_BULK')) {
|
||||
freight = 'BULK';
|
||||
}
|
||||
|
||||
const currency = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD';
|
||||
const service = this.resolveServiceScope(booking.serviceType);
|
||||
|
||||
return `${dir}_${freight}_${currency}_${service}`;
|
||||
}
|
||||
|
||||
private resolveServiceScope(
|
||||
serviceType?: ServiceType | null,
|
||||
): 'TRANSPORT_ONLY' | 'FORWARDING' {
|
||||
if (!serviceType) return 'TRANSPORT_ONLY';
|
||||
const code = (serviceType.code ?? '').toUpperCase();
|
||||
if (
|
||||
serviceType.includesFirstMile ||
|
||||
serviceType.includesLastMile ||
|
||||
code.includes('FORWARD')
|
||||
) {
|
||||
return 'FORWARDING';
|
||||
}
|
||||
return 'TRANSPORT_ONLY';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export type ContractDirection = 'IMP' | 'EXP' | 'DOM';
|
||||
export type ContractFreight = 'CON' | 'BULK';
|
||||
export type ContractServiceScope = 'TRANSPORT_ONLY' | 'FORWARDING';
|
||||
|
||||
export interface Article1Clause {
|
||||
objective: string;
|
||||
scope: string[];
|
||||
}
|
||||
|
||||
export interface ContractClausePack {
|
||||
article1: Article1Clause;
|
||||
clientObligations: string[];
|
||||
providerObligations: string[];
|
||||
contractDocuments: string[];
|
||||
}
|
||||
|
||||
export interface ContractTemplateMeta {
|
||||
key: string;
|
||||
direction: ContractDirection;
|
||||
freight: ContractFreight;
|
||||
currency: string;
|
||||
serviceScope: ContractServiceScope;
|
||||
title: string;
|
||||
directionLabel: string;
|
||||
freightLabel: string;
|
||||
whereas: string;
|
||||
/** Summary line for APIs; mirrors article1.objective */
|
||||
article1Objective: string;
|
||||
article1: Article1Clause;
|
||||
clientObligations: string[];
|
||||
providerObligations: string[];
|
||||
contractDocuments: string[];
|
||||
/** Optional dedicated .hbs file; otherwise uses generic.hbs */
|
||||
templateFile?: string;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { BookingsRepository } from '../modules/bookings/bookings.repository';
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import {
|
||||
BookingContractSignature,
|
||||
ContractSignerRole,
|
||||
} from '../modules/bookings/entities/booking-contract-signature.entity';
|
||||
import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder';
|
||||
import { ContractTemplateResolver } from './contract-template.resolver';
|
||||
import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry';
|
||||
|
||||
export interface ContractSignatureView {
|
||||
role: ContractSignerRole;
|
||||
signerDisplayName: string;
|
||||
signedAt: string;
|
||||
signatureImageUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface ContractViewModel {
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
status: string;
|
||||
templateKey: string;
|
||||
template: ContractTemplateMeta;
|
||||
contractDate: string;
|
||||
contractYear: number;
|
||||
client: {
|
||||
companyName: string;
|
||||
companyAddress: string;
|
||||
companyLocation: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
tinNumber: string;
|
||||
vatNumber: string;
|
||||
fanNumber: string;
|
||||
businessLicense: string;
|
||||
};
|
||||
provider: {
|
||||
name: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
tinNumber: string;
|
||||
};
|
||||
schedule: {
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
tradeDirection: string;
|
||||
freightType: string;
|
||||
serviceType: string;
|
||||
scheduledDate: string;
|
||||
contractType: string;
|
||||
cargoDescription: string;
|
||||
totalWeightVgm: string;
|
||||
equipmentReturn: string;
|
||||
hazardousLabel: string;
|
||||
firstMilePickupAddress: string;
|
||||
lastMileDeliveryAddress: string;
|
||||
};
|
||||
pricing: PricingSchedule;
|
||||
signatures: ContractSignatureView[];
|
||||
canSignCustomer: boolean;
|
||||
canSignStaff: boolean;
|
||||
hasContractDocument: boolean;
|
||||
hasCustomerSignature: boolean;
|
||||
hasStaffSignature: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContractViewModelBuilder {
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly templateResolver: ContractTemplateResolver,
|
||||
private readonly pricingBuilder: ContractPricingScheduleBuilder,
|
||||
) {}
|
||||
|
||||
async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> {
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
|
||||
const templateKey =
|
||||
booking.contractTemplateKey ?? this.templateResolver.resolve(booking);
|
||||
const template = getTemplateMeta(templateKey);
|
||||
const pricing = await this.pricingBuilder.build(booking);
|
||||
const signatures = await this.loadSignatures(bookingId);
|
||||
|
||||
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
|
||||
const hasStaff = signatures.some((s) => s.role === 'STAFF');
|
||||
const hasContractFile = Boolean(
|
||||
booking.files?.some((f) => f.code === 'contract'),
|
||||
);
|
||||
|
||||
const view: ContractViewModel = {
|
||||
bookingId: booking.id,
|
||||
reference: booking.reference,
|
||||
status: booking.status,
|
||||
templateKey,
|
||||
template,
|
||||
contractDate: new Date().toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}),
|
||||
contractYear: new Date().getFullYear(),
|
||||
client: {
|
||||
companyName: booking.company?.name ?? 'Client',
|
||||
companyAddress: this.valueOrDash(booking.company?.address),
|
||||
companyLocation: this.valueOrDash(booking.company?.country),
|
||||
phone: this.valueOrDash(booking.company?.phone),
|
||||
email: this.valueOrDash(booking.company?.email),
|
||||
tinNumber: this.valueOrDash(booking.company?.tin),
|
||||
vatNumber: this.valueOrDash(booking.company?.vatNumber),
|
||||
fanNumber: this.valueOrDash(booking.company?.fanNumber),
|
||||
businessLicense: this.valueOrDash(
|
||||
booking.company?.companyProfiles?.[0]?.businessLicense,
|
||||
),
|
||||
},
|
||||
provider: {
|
||||
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
|
||||
address: 'Addis Ababa, Ethiopia',
|
||||
phone: '+251 11 872 0000',
|
||||
email: 'info@edr.gov.et',
|
||||
tinNumber: '—',
|
||||
},
|
||||
schedule: this.buildSchedule(booking),
|
||||
pricing,
|
||||
signatures,
|
||||
canSignCustomer:
|
||||
booking.status === 'CONTRACT_READY' && !hasCustomer,
|
||||
canSignStaff:
|
||||
booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
|
||||
hasContractDocument: hasContractFile,
|
||||
hasCustomerSignature: hasCustomer,
|
||||
hasStaffSignature: hasStaff,
|
||||
};
|
||||
|
||||
return { booking, view };
|
||||
}
|
||||
|
||||
private async loadSignatures(bookingId: string): Promise<ContractSignatureView[]> {
|
||||
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
|
||||
return rows.map((s) => this.toSignatureView(s));
|
||||
}
|
||||
|
||||
toSignatureView(row: BookingContractSignature): ContractSignatureView {
|
||||
return {
|
||||
role: row.signerRole,
|
||||
signerDisplayName: row.signerDisplayName,
|
||||
signedAt: this.formatDate(row.signedAt),
|
||||
signatureImageUrl: row.signatureFile?.url ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private buildSchedule(booking: Booking): ContractViewModel['schedule'] {
|
||||
const cargoName =
|
||||
booking.freightType === 'BULK'
|
||||
? booking.cargoFreeText ||
|
||||
booking.cargoType?.cargoTypeName ||
|
||||
'Bulk commodity'
|
||||
: booking.cargoType?.cargoTypeName || 'Container cargo';
|
||||
const totalWeight = Number(booking.cargoTotalWeightVgm || 0);
|
||||
|
||||
return {
|
||||
originLabel: this.yardLabel(booking.originYard),
|
||||
destinationLabel: this.yardLabel(booking.destinationYard),
|
||||
tradeDirection: this.valueOrDash(booking.tradeDirection),
|
||||
freightType: this.valueOrDash(booking.freightType),
|
||||
serviceType: this.valueOrDash(
|
||||
booking.serviceType?.serviceName ?? booking.serviceType?.code,
|
||||
),
|
||||
scheduledDate: this.formatDate(booking.scheduledDate),
|
||||
contractType: this.valueOrDash(booking.contractType),
|
||||
cargoDescription: this.valueOrDash(cargoName),
|
||||
totalWeightVgm:
|
||||
totalWeight > 0 ? `${totalWeight.toLocaleString()} tons` : '—',
|
||||
equipmentReturn: this.valueOrDash(booking.equipmentReturn),
|
||||
hazardousLabel: booking.isHazardous ? 'Yes' : 'No',
|
||||
firstMilePickupAddress: this.valueOrDash(
|
||||
booking.firstMilePickupAddress,
|
||||
),
|
||||
lastMileDeliveryAddress: this.valueOrDash(
|
||||
booking.lastMileDeliveryAddress,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private yardLabel(yard?: { label?: string; code?: string } | null): string {
|
||||
return this.valueOrDash(yard?.label ?? yard?.code);
|
||||
}
|
||||
|
||||
private formatDate(value?: Date | string | null): string {
|
||||
if (!value) return '—';
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
private valueOrDash(value?: string | number | null): string {
|
||||
if (value === undefined || value === null || value === '') return '—';
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<div class="article">
|
||||
<h2>Article 1: Objective and Scope of Services</h2>
|
||||
<p>
|
||||
<strong>1.1 Objective.</strong>
|
||||
{{template.article1.objective}}
|
||||
</p>
|
||||
{{#if template.article1.scope.length}}
|
||||
<p><strong>1.2 Scope of Services.</strong></p>
|
||||
<ol>
|
||||
{{#each template.article1.scope}}
|
||||
<li>{{this}}</li>
|
||||
{{/each}}
|
||||
</ol>
|
||||
{{/if}}
|
||||
</div>
|
||||
@@ -0,0 +1,66 @@
|
||||
<h2>Article 5: Contract Price and Terms of Payment</h2>
|
||||
<div class="article">
|
||||
<h3>Contract Price</h3>
|
||||
<p>
|
||||
The contract price is calculated based on the agreed railway corridor, cargo details, applicable rate
|
||||
schedule, and any approved operational surcharges.
|
||||
</p>
|
||||
<table class="details-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Corridor</th>
|
||||
<td>{{pricing.originLabel}} → {{pricing.destinationLabel}}</td>
|
||||
<th>Currency</th>
|
||||
<td>{{pricing.currency}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Payment currency</th>
|
||||
<td>{{paymentArticle}}</td>
|
||||
<th>Equipment return</th>
|
||||
<td>{{pricing.equipmentReturn}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{{#if pricing.equipmentReturn}}
|
||||
<p><strong>Equipment return:</strong> {{pricing.equipmentReturn}}</p>
|
||||
{{/if}}
|
||||
|
||||
<h3>Charges</h3>
|
||||
<table class="schedule">
|
||||
<thead>
|
||||
<tr><th>Item</th><th>Description</th><th>Amount</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each pricing.lineItems}}
|
||||
<tr>
|
||||
<td>{{label}}</td>
|
||||
<td>{{description}}</td>
|
||||
<td>{{currency}} {{amount}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{#if pricing.surcharges.length}}
|
||||
<tr>
|
||||
<th colspan="3">Surcharges and Adjustments</th>
|
||||
</tr>
|
||||
{{#each pricing.surcharges}}
|
||||
<tr>
|
||||
<td>{{label}}</td>
|
||||
<td>{{description}}</td>
|
||||
<td>{{currency}} {{amount}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
<tr class="total-row">
|
||||
<td colspan="2"><strong>Total contract value</strong></td>
|
||||
<td><strong>{{pricing.currency}} {{pricing.totalAmount}}</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>Terms of payment</h3>
|
||||
<p>
|
||||
Unless otherwise agreed in writing, the Client shall settle the contract value in
|
||||
<strong>{{paymentArticle}}</strong> before the service is performed and in accordance with EDR payment
|
||||
instructions. Bank charges, penalties, demurrage, storage, and third-party charges remain the
|
||||
responsibility of the Client where applicable.
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
<div class="article">
|
||||
<h2>Article 2: Obligations of the Client</h2>
|
||||
<p>The Client shall perform the following obligations in good faith and within the operational timelines communicated by EDR:</p>
|
||||
<ol>
|
||||
{{#each template.clientObligations}}
|
||||
<li>{{this}}</li>
|
||||
{{/each}}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="article">
|
||||
<h2>Article 3: Obligations of the Service Provider</h2>
|
||||
<p>EDR shall provide the agreed railway freight services in accordance with this Agreement and applicable operational rules:</p>
|
||||
<ol>
|
||||
{{#each template.providerObligations}}
|
||||
<li>{{this}}</li>
|
||||
{{/each}}
|
||||
</ol>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
<div class="article">
|
||||
<h2>Article 6: Contract Documents</h2>
|
||||
<p>The following documents form part of this Agreement and shall be read together with the signed contract:</p>
|
||||
<ol>
|
||||
{{#each template.contractDocuments}}
|
||||
<li>{{this}}</li>
|
||||
{{/each}}
|
||||
</ol>
|
||||
</div>
|
||||
@@ -0,0 +1,65 @@
|
||||
<section class="page-section">
|
||||
<h2>Booking Schedule and Commercial Summary</h2>
|
||||
<table class="details-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Route</th>
|
||||
<td>{{schedule.originLabel}} → {{schedule.destinationLabel}}</td>
|
||||
<th>Trade direction</th>
|
||||
<td>{{schedule.tradeDirection}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Freight type</th>
|
||||
<td>{{schedule.freightType}}</td>
|
||||
<th>Service type</th>
|
||||
<td>{{schedule.serviceType}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Scheduled date</th>
|
||||
<td>{{schedule.scheduledDate}}</td>
|
||||
<th>Contract type</th>
|
||||
<td>{{schedule.contractType}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Cargo</th>
|
||||
<td>{{schedule.cargoDescription}}</td>
|
||||
<th>Total VGM</th>
|
||||
<td>{{schedule.totalWeightVgm}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Equipment return</th>
|
||||
<td>{{schedule.equipmentReturn}}</td>
|
||||
<th>Hazardous cargo</th>
|
||||
<td>{{schedule.hazardousLabel}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>First mile</th>
|
||||
<td>{{schedule.firstMilePickupAddress}}</td>
|
||||
<th>Last mile</th>
|
||||
<td>{{schedule.lastMileDeliveryAddress}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{#if pricing.containerLines.length}}
|
||||
<h3>Container Details</h3>
|
||||
<table class="schedule">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Container type</th>
|
||||
<th>Quantity</th>
|
||||
<th>VGM / unit (tons)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each pricing.containerLines}}
|
||||
<tr>
|
||||
<td>{{label}}</td>
|
||||
<td>{{quantity}}</td>
|
||||
<td>{{vgmPerUnitTons}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{/if}}
|
||||
</section>
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="article">
|
||||
<h2>Article 4: Force Majeure</h2>
|
||||
<p>
|
||||
Neither party shall be liable for delay or non-performance caused by events beyond its reasonable control,
|
||||
including natural disaster, war, civil unrest, government restriction, railway interruption, port closure,
|
||||
or other force majeure events interpreted under the Ethiopian Civil Code.
|
||||
</p>
|
||||
<p>
|
||||
The affected party shall notify the other party promptly and shall use reasonable efforts to reduce the
|
||||
effect of the force majeure event on the performance of this Agreement.
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,44 @@
|
||||
<div class="signatures">
|
||||
<div class="sig-block">
|
||||
<p class="sig-title">For the Service Provider</p>
|
||||
<p><strong>{{provider.name}}</strong></p>
|
||||
{{#if hasStaffSignature}}
|
||||
{{#each signatures}}
|
||||
{{#if (eq role "STAFF")}}
|
||||
<div class="sig-image-box">
|
||||
{{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Staff signature" />{{/if}}
|
||||
</div>
|
||||
<p class="sig-line"><strong>Name:</strong> {{signerDisplayName}}</p>
|
||||
<p class="sig-meta"><strong>Role:</strong> Authorized EDR representative</p>
|
||||
<p class="sig-meta"><strong>Date:</strong> {{signedAt}}</p>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<div class="sig-image-box"><span class="sig-placeholder">Signature pending</span></div>
|
||||
<p class="sig-line"><strong>Name:</strong> Authorized representative</p>
|
||||
<p class="sig-meta"><strong>Role:</strong> EDR representative</p>
|
||||
<p class="sig-meta"><strong>Date:</strong></p>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="sig-block">
|
||||
<p class="sig-title">For the Client</p>
|
||||
<p><strong>{{client.companyName}}</strong></p>
|
||||
{{#if hasCustomerSignature}}
|
||||
{{#each signatures}}
|
||||
{{#if (eq role "CUSTOMER")}}
|
||||
<div class="sig-image-box">
|
||||
{{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Customer signature" />{{/if}}
|
||||
</div>
|
||||
<p class="sig-line"><strong>Name:</strong> {{signerDisplayName}}</p>
|
||||
<p class="sig-meta"><strong>Role:</strong> Authorized client representative</p>
|
||||
<p class="sig-meta"><strong>Date:</strong> {{signedAt}}</p>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<div class="sig-image-box"><span class="sig-placeholder">Signature pending</span></div>
|
||||
<p class="sig-line"><strong>Name:</strong> Client representative</p>
|
||||
<p class="sig-meta"><strong>Role:</strong> Authorized client representative</p>
|
||||
<p class="sig-meta"><strong>Date:</strong></p>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,258 @@
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
@page { size: A4; margin: 18mm 14mm; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #f5f7fb;
|
||||
color: #111827;
|
||||
font-family: "Times New Roman", Times, serif;
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.48;
|
||||
}
|
||||
|
||||
.contract {
|
||||
width: 210mm;
|
||||
min-height: 297mm;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
padding: 18mm 15mm;
|
||||
}
|
||||
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 {
|
||||
color: #0f2742;
|
||||
font-size: 18pt;
|
||||
line-height: 1.25;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h2 {
|
||||
border-bottom: 1.5px solid #1e3a5f;
|
||||
color: #1e3a5f;
|
||||
font-size: 12pt;
|
||||
letter-spacing: 0.03em;
|
||||
margin: 18px 0 10px;
|
||||
padding-bottom: 5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h3 {
|
||||
color: #0f2742;
|
||||
font-size: 10.8pt;
|
||||
margin: 12px 0 6px;
|
||||
}
|
||||
p { margin-bottom: 8px; }
|
||||
ol { margin: 6px 0 0; padding-left: 20px; }
|
||||
li { margin-bottom: 5px; }
|
||||
|
||||
.page-section,
|
||||
.article {
|
||||
margin-bottom: 18px;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.brand-row {
|
||||
align-items: center;
|
||||
border-bottom: 3px solid #1e3a5f;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
.logo-mark {
|
||||
align-items: center;
|
||||
background: #1e3a5f;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 16pt;
|
||||
font-weight: 700;
|
||||
height: 52px;
|
||||
justify-content: center;
|
||||
letter-spacing: 0.08em;
|
||||
width: 72px;
|
||||
}
|
||||
.kicker {
|
||||
color: #1e3a5f;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 10pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.muted {
|
||||
color: #6b7280;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 9pt;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cover {
|
||||
min-height: 255mm;
|
||||
position: relative;
|
||||
}
|
||||
.cover-title {
|
||||
margin: 54mm 0 34mm;
|
||||
text-align: center;
|
||||
}
|
||||
.document-label {
|
||||
color: #6b7280;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 10pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.summary-line {
|
||||
color: #374151;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 9.5pt;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
.meta-grid,
|
||||
.details-table,
|
||||
.schedule {
|
||||
font-size: 9.5pt;
|
||||
margin: 10px 0 16px;
|
||||
}
|
||||
.meta-grid th,
|
||||
.meta-grid td,
|
||||
.details-table th,
|
||||
.details-table td,
|
||||
.schedule th,
|
||||
.schedule td {
|
||||
border: 1px solid #cbd5e1;
|
||||
padding: 7px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.meta-grid th,
|
||||
.details-table th,
|
||||
.schedule th {
|
||||
background: #eef4fb;
|
||||
color: #1e3a5f;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 8.5pt;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.schedule tbody tr:nth-child(even) td { background: #f8fafc; }
|
||||
.total-row td {
|
||||
background: #e8f0f8 !important;
|
||||
color: #0f2742;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.lead {
|
||||
color: #374151;
|
||||
font-size: 10.5pt;
|
||||
}
|
||||
.party-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.party-card {
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
.party-card h3 {
|
||||
background: #1e3a5f;
|
||||
border-radius: 5px;
|
||||
color: #fff;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 9pt;
|
||||
margin: 0 0 10px;
|
||||
padding: 7px 9px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.party-name {
|
||||
color: #0f2742;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: 32% 68%;
|
||||
margin: 0;
|
||||
}
|
||||
dt {
|
||||
color: #475569;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 8.5pt;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px 2px 0;
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.signatures {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
margin-top: 24px;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.sig-block {
|
||||
border: 1.5px solid #1e3a5f;
|
||||
border-radius: 8px;
|
||||
min-height: 96mm;
|
||||
padding: 12px;
|
||||
}
|
||||
.sig-title {
|
||||
color: #1e3a5f;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 9pt;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.sig-image-box {
|
||||
align-items: center;
|
||||
border: 1px dashed #94a3b8;
|
||||
display: flex;
|
||||
height: 28mm;
|
||||
justify-content: center;
|
||||
margin: 14px 0;
|
||||
}
|
||||
.sig-image-box img {
|
||||
display: block;
|
||||
max-height: 24mm;
|
||||
max-width: 70mm;
|
||||
}
|
||||
.sig-placeholder {
|
||||
color: #94a3b8;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
.sig-line {
|
||||
border-top: 1px solid #111827;
|
||||
margin-top: 16px;
|
||||
padding-top: 5px;
|
||||
}
|
||||
.sig-meta {
|
||||
color: #475569;
|
||||
font-size: 9pt;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
.contract {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: auto;
|
||||
}
|
||||
.cover { page-break-after: always; }
|
||||
}
|
||||
</style>
|
||||
91
apps/edr-freight-api/src/contracts/templates/generic.hbs
Normal file
91
apps/edr-freight-api/src/contracts/templates/generic.hbs
Normal file
@@ -0,0 +1,91 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>{{template.title}} — {{reference}}</title>
|
||||
{{> styles}}
|
||||
</head>
|
||||
<body>
|
||||
<main class="contract">
|
||||
<section class="cover page-section">
|
||||
<div class="brand-row">
|
||||
<div class="logo-mark">EDR</div>
|
||||
<div>
|
||||
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
|
||||
<p class="muted">Freight Transport Contract</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cover-title">
|
||||
<p class="document-label">Contract Agreement</p>
|
||||
<h1>{{template.title}}</h1>
|
||||
<p class="summary-line">{{template.directionLabel}} • {{template.freightLabel}} • {{template.currency}} • {{template.serviceScope}}</p>
|
||||
</div>
|
||||
|
||||
<table class="meta-grid">
|
||||
<tr>
|
||||
<th>Contract Ref No.</th>
|
||||
<td>{{reference}}</td>
|
||||
<th>Contract Year</th>
|
||||
<td>{{contractYear}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Contract Date</th>
|
||||
<td>{{contractDate}}</td>
|
||||
<th>Status</th>
|
||||
<td>{{status}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="page-section">
|
||||
<h2>Parties to the Agreement</h2>
|
||||
<p class="lead">
|
||||
This Contract Agreement is made on <strong>{{contractDate}}</strong> between the Service Provider and the Client named below.
|
||||
</p>
|
||||
|
||||
<div class="party-grid">
|
||||
<div class="party-card">
|
||||
<h3>Service Provider</h3>
|
||||
<p class="party-name">{{provider.name}}</p>
|
||||
<dl>
|
||||
<dt>Address</dt><dd>{{provider.address}}</dd>
|
||||
<dt>Phone</dt><dd>{{provider.phone}}</dd>
|
||||
<dt>Email</dt><dd>{{provider.email}}</dd>
|
||||
<dt>TIN</dt><dd>{{provider.tinNumber}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="party-card">
|
||||
<h3>Client</h3>
|
||||
<p class="party-name">{{client.companyName}}</p>
|
||||
<dl>
|
||||
<dt>Address</dt><dd>{{client.companyAddress}}</dd>
|
||||
<dt>Location</dt><dd>{{client.companyLocation}}</dd>
|
||||
<dt>Phone</dt><dd>{{client.phone}}</dd>
|
||||
<dt>Email</dt><dd>{{client.email}}</dd>
|
||||
<dt>TIN</dt><dd>{{client.tinNumber}}</dd>
|
||||
<dt>VAT</dt><dd>{{client.vatNumber}}</dd>
|
||||
<dt>FAN</dt><dd>{{client.fanNumber}}</dd>
|
||||
<dt>Business license</dt><dd>{{client.businessLicense}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{> contract_schedule}}
|
||||
|
||||
<section class="page-section">
|
||||
<h2>Whereas</h2>
|
||||
<p>{{template.whereas}}</p>
|
||||
<p>Now therefore, the parties agree as follows:</p>
|
||||
</section>
|
||||
|
||||
{{> article1}}
|
||||
{{> articles_obligations}}
|
||||
{{> force_majeure}}
|
||||
{{> article5_pricing}}
|
||||
{{> contract_documents}}
|
||||
{{> signatures_block}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
21
apps/edr-freight-api/src/data-source.ts
Normal file
21
apps/edr-freight-api/src/data-source.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// apps/edr-freight-api/src/data-source.ts
|
||||
import 'dotenv/config';
|
||||
import { DataSource } from 'typeorm';
|
||||
//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed
|
||||
|
||||
export const AppDataSource = new DataSource({
|
||||
type: 'postgres',
|
||||
host: process.env.DB_HOST ?? 'localhost',
|
||||
port: Number(process.env.DB_PORT ?? 5433),
|
||||
username: process.env.DB_USER ?? 'postgres',
|
||||
password: process.env.DB_PASSWORD ?? '',
|
||||
database: process.env.DB_NAME ?? 'edr_freight',
|
||||
schema: 'freight', // default schema for entities without an explicit schema
|
||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||
migrations: [__dirname + '/migrations/*{.ts,.js}'],
|
||||
synchronize: false,
|
||||
logging: process.env.TYPEORM_LOGGING === 'true',
|
||||
});
|
||||
|
||||
// Optional: call ensurePostgresSchemas before initializing
|
||||
// But you can also run it separately.
|
||||
@@ -1,4 +1,6 @@
|
||||
import "reflect-metadata";
|
||||
import * as dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
import {
|
||||
@@ -10,7 +12,31 @@ import {
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, { cors: true });
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Dev CORS: reflect any localhost origin and allow credentials so the
|
||||
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
|
||||
// and any other dev port can call the API with cookies + Authorization.
|
||||
// For production, restrict `origin` to known FQDNs.
|
||||
|
||||
app.enableCors({
|
||||
origin: true, // reflect request origin
|
||||
credentials: true,
|
||||
methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Accept",
|
||||
"Authorization",
|
||||
"X-Requested-With",
|
||||
// IAM context headers required by @tria-plc/api-common's JwtGuard
|
||||
"organization-unit-id",
|
||||
"delegator-position-id",
|
||||
"current-project-id",
|
||||
"current-position-id",
|
||||
],
|
||||
exposedHeaders: ["Content-Disposition"],
|
||||
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
|
||||
});
|
||||
|
||||
app.setGlobalPrefix("api");
|
||||
app.useGlobalPipes(createValidationPipe());
|
||||
@@ -27,9 +53,12 @@ async function bootstrap() {
|
||||
SwaggerModule.setup("api/docs", app, document);
|
||||
|
||||
const port = parseInt(process.env.PORT ?? "3001", 10);
|
||||
await app.listen(port);
|
||||
// await app.listen(port, "0.0.0.0");
|
||||
await app.listen(
|
||||
|
||||
port)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[freight-api] listening on http://localhost:${port}`);
|
||||
console.log(`[freight-api] listening on port ${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex, TableForeignKey } from "typeorm";
|
||||
|
||||
export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInterface {
|
||||
name = "AddServiceTypesAndCargoTypes1748427600000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Create service_types table
|
||||
if (!(await queryRunner.hasTable("freight.service_types"))) await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "service_types",
|
||||
schema: "freight",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
type: "uuid",
|
||||
isPrimary: true,
|
||||
generationStrategy: "uuid",
|
||||
default: "uuid_generate_v4()",
|
||||
},
|
||||
{
|
||||
name: "service_name",
|
||||
type: "varchar",
|
||||
length: "255",
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
type: "text",
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: "can_be_booked_alone",
|
||||
type: "boolean",
|
||||
default: true,
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "includes_first_mile",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "includes_last_mile",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "includes_customs",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "priority_bonus_points",
|
||||
type: "int",
|
||||
default: 0,
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "is_active",
|
||||
type: "boolean",
|
||||
default: true,
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "display_order",
|
||||
type: "int",
|
||||
default: 1,
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
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,
|
||||
);
|
||||
|
||||
// Create indexes for service_types
|
||||
await queryRunner.createIndex(
|
||||
"freight.service_types",
|
||||
new TableIndex({
|
||||
name: "IDX_SERVICE_TYPES_IS_ACTIVE",
|
||||
columnNames: ["is_active"],
|
||||
}),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
"freight.service_types",
|
||||
new TableIndex({
|
||||
name: "IDX_SERVICE_TYPES_DISPLAY_ORDER",
|
||||
columnNames: ["display_order"],
|
||||
}),
|
||||
);
|
||||
|
||||
// Create cargo_types table
|
||||
if (!(await queryRunner.hasTable("freight.cargo_types"))) await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "cargo_types",
|
||||
schema: "freight",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
type: "uuid",
|
||||
isPrimary: true,
|
||||
generationStrategy: "uuid",
|
||||
default: "uuid_generate_v4()",
|
||||
},
|
||||
{
|
||||
name: "cargo_type_name",
|
||||
type: "varchar",
|
||||
length: "255",
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "parent_group_id",
|
||||
type: "uuid",
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: "show_free_text_box",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "requires_director_approval",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "is_active",
|
||||
type: "boolean",
|
||||
default: true,
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "display_order",
|
||||
type: "int",
|
||||
default: 1,
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
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,
|
||||
);
|
||||
|
||||
// Create indexes for cargo_types
|
||||
await queryRunner.createIndex(
|
||||
"freight.cargo_types",
|
||||
new TableIndex({
|
||||
name: "IDX_CARGO_TYPES_IS_ACTIVE",
|
||||
columnNames: ["is_active"],
|
||||
}),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
"freight.cargo_types",
|
||||
new TableIndex({
|
||||
name: "IDX_CARGO_TYPES_DISPLAY_ORDER",
|
||||
columnNames: ["display_order"],
|
||||
}),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
"freight.cargo_types",
|
||||
new TableIndex({
|
||||
name: "IDX_CARGO_TYPES_PARENT_GROUP_ID",
|
||||
columnNames: ["parent_group_id"],
|
||||
}),
|
||||
);
|
||||
|
||||
// Create self-referencing foreign key for cargo_types
|
||||
await queryRunner.createForeignKey(
|
||||
"freight.cargo_types",
|
||||
new TableForeignKey({
|
||||
name: "FK_CARGO_TYPES_PARENT_GROUP",
|
||||
columnNames: ["parent_group_id"],
|
||||
referencedSchema: "freight",
|
||||
referencedTableName: "cargo_types",
|
||||
referencedColumnNames: ["id"],
|
||||
onDelete: "SET NULL",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Drop foreign key first
|
||||
await queryRunner.dropForeignKey("freight.cargo_types", "FK_CARGO_TYPES_PARENT_GROUP");
|
||||
|
||||
// Drop cargo_types table
|
||||
await queryRunner.dropTable("freight.cargo_types", true);
|
||||
|
||||
// Drop service_types table
|
||||
await queryRunner.dropTable("freight.service_types", true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
MigrationInterface,
|
||||
QueryRunner,
|
||||
Table,
|
||||
TableIndex,
|
||||
TableForeignKey,
|
||||
TableColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterface {
|
||||
name = 'AddRuleEngineTablesAndCodes1748514000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ── 1. Add `code` column to existing tables ───────────────────────────
|
||||
|
||||
if (!(await queryRunner.hasColumn('freight.service_types', 'code'))) {
|
||||
await queryRunner.addColumn(
|
||||
'freight.service_types',
|
||||
new TableColumn({
|
||||
name: 'code',
|
||||
type: 'varchar',
|
||||
length: '50',
|
||||
isNullable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.service_types SET code = upper(replace(service_name, ' ', '_')) WHERE code IS NULL OR code = ''`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.service_types SET code = 'SERVICE_' || substring(id::text, 1, 8) WHERE code IS NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.service_types ALTER COLUMN code SET NOT NULL`,
|
||||
);
|
||||
const serviceTypesCodeIdx = await queryRunner.query(
|
||||
`SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND indexname = 'idx_service_types_code' LIMIT 1`,
|
||||
);
|
||||
if (serviceTypesCodeIdx.length === 0) {
|
||||
await queryRunner.createIndex(
|
||||
'freight.service_types',
|
||||
new TableIndex({ name: 'IDX_service_types_code', columnNames: ['code'], isUnique: true }),
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await queryRunner.hasColumn('freight.cargo_types', 'code'))) {
|
||||
await queryRunner.addColumn(
|
||||
'freight.cargo_types',
|
||||
new TableColumn({
|
||||
name: 'code',
|
||||
type: 'varchar',
|
||||
length: '50',
|
||||
isNullable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.cargo_types SET code = upper(replace(cargo_type_name, ' ', '_')) WHERE code IS NULL OR code = ''`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.cargo_types SET code = 'CARGO_' || substring(id::text, 1, 8) WHERE code IS NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.cargo_types ALTER COLUMN code SET NOT NULL`,
|
||||
);
|
||||
const cargoTypesCodeIdx = await queryRunner.query(
|
||||
`SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND indexname = 'idx_cargo_types_code' LIMIT 1`,
|
||||
);
|
||||
if (cargoTypesCodeIdx.length === 0) {
|
||||
await queryRunner.createIndex(
|
||||
'freight.cargo_types',
|
||||
new TableIndex({ name: 'IDX_cargo_types_code', columnNames: ['code'], isUnique: true }),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. surcharge_types ────────────────────────────────────────────────
|
||||
|
||||
if (!(await queryRunner.hasTable('freight.surcharge_types'))) await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'surcharge_types',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'code', type: 'varchar', length: '50', isNullable: false },
|
||||
{ name: 'name', type: 'varchar', length: '100', isNullable: false },
|
||||
{ name: 'description', type: 'text', isNullable: true },
|
||||
{ name: 'is_active', type: 'boolean', default: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.surcharge_types',
|
||||
new TableIndex({ name: 'IDX_surcharge_types_code', columnNames: ['code'], isUnique: true }),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.surcharge_types',
|
||||
new TableIndex({ name: 'IDX_surcharge_types_is_active', columnNames: ['is_active'] }),
|
||||
);
|
||||
|
||||
// ── 3. surcharges ─────────────────────────────────────────────────────
|
||||
|
||||
if (!(await queryRunner.hasTable('freight.surcharges'))) await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'surcharges',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'surcharge_type_id', type: 'uuid', isNullable: false },
|
||||
{ name: 'fee_name', type: 'varchar', length: '255', isNullable: false },
|
||||
{ name: 'trigger_description', type: 'text', isNullable: true },
|
||||
{
|
||||
name: 'calculation_method',
|
||||
type: 'enum',
|
||||
enum: ['PER_TON', 'FLAT_FEE', 'PERCENTAGE'],
|
||||
default: `'PER_TON'`,
|
||||
},
|
||||
{ name: 'rate', type: 'numeric', precision: 10, scale: 2, isNullable: false },
|
||||
{ name: 'currency', type: 'char', length: '3', default: `'USD'` },
|
||||
{ name: 'apply_to_rail', type: 'boolean', default: false },
|
||||
{ name: 'apply_to_first_mile', type: 'boolean', default: false },
|
||||
{ name: 'apply_to_last_mile', type: 'boolean', default: false },
|
||||
{ name: 'is_active', type: 'boolean', default: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.surcharges',
|
||||
new TableForeignKey({
|
||||
name: 'FK_surcharges_surcharge_type',
|
||||
columnNames: ['surcharge_type_id'],
|
||||
referencedTableName: 'freight.surcharge_types',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'RESTRICT',
|
||||
}),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.surcharges',
|
||||
new TableIndex({ name: 'IDX_surcharges_surcharge_type_id', columnNames: ['surcharge_type_id'] }),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.surcharges',
|
||||
new TableIndex({ name: 'IDX_surcharges_is_active', columnNames: ['is_active'] }),
|
||||
);
|
||||
|
||||
// ── 4. container_types ────────────────────────────────────────────────
|
||||
|
||||
if (!(await queryRunner.hasTable('freight.container_types'))) await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'container_types',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'size_code', type: 'varchar', length: '20', isNullable: false },
|
||||
{ name: 'description', type: 'varchar', length: '100', isNullable: true },
|
||||
{ name: 'containers_per_wagon', type: 'int', isNullable: false },
|
||||
{ name: 'is_active', type: 'boolean', default: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.container_types',
|
||||
new TableIndex({ name: 'IDX_container_types_size_code', columnNames: ['size_code'], isUnique: true }),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.container_types',
|
||||
new TableIndex({ name: 'IDX_container_types_is_active', columnNames: ['is_active'] }),
|
||||
);
|
||||
|
||||
// ── 5. weight_limit_rules ─────────────────────────────────────────────
|
||||
|
||||
if (!(await queryRunner.hasTable('freight.weight_limit_rules'))) await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'weight_limit_rules',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'container_type_id', type: 'uuid', isNullable: false },
|
||||
{
|
||||
name: 'trade_direction',
|
||||
type: 'enum',
|
||||
enum: ['IMPORT', 'EXPORT', 'BOTH'],
|
||||
isNullable: false,
|
||||
},
|
||||
{ name: 'max_weight_tons', type: 'numeric', precision: 10, scale: 2, isNullable: false },
|
||||
{ name: 'warning_threshold_tons', type: 'numeric', precision: 10, scale: 2, isNullable: false },
|
||||
{
|
||||
name: 'exceeded_action',
|
||||
type: 'enum',
|
||||
enum: ['WARNING_ONLY', 'HARD_BLOCK'],
|
||||
default: `'WARNING_ONLY'`,
|
||||
},
|
||||
{ name: 'surcharge_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'is_active', type: 'boolean', default: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.weight_limit_rules',
|
||||
new TableForeignKey({
|
||||
name: 'FK_weight_limit_rules_container_type',
|
||||
columnNames: ['container_type_id'],
|
||||
referencedTableName: 'freight.container_types',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'RESTRICT',
|
||||
}),
|
||||
);
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.weight_limit_rules',
|
||||
new TableForeignKey({
|
||||
name: 'FK_weight_limit_rules_surcharge',
|
||||
columnNames: ['surcharge_id'],
|
||||
referencedTableName: 'freight.surcharges',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'SET NULL',
|
||||
}),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.weight_limit_rules',
|
||||
new TableIndex({ name: 'IDX_weight_limit_rules_container_type_id', columnNames: ['container_type_id'] }),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.weight_limit_rules',
|
||||
new TableIndex({ name: 'IDX_weight_limit_rules_surcharge_id', columnNames: ['surcharge_id'] }),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.weight_limit_rules',
|
||||
new TableIndex({ name: 'IDX_weight_limit_rules_is_active', columnNames: ['is_active'] }),
|
||||
);
|
||||
|
||||
// ── 6. priority_rules ─────────────────────────────────────────────────
|
||||
|
||||
if (!(await queryRunner.hasTable('freight.priority_rules'))) await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'priority_rules',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{
|
||||
name: 'priority_type',
|
||||
type: 'enum',
|
||||
enum: ['USD_PAYER', 'RAIL_AND_FORWARDING', 'GOVERNMENT_ACCOUNT', 'HIGH_VOLUME_SHIPMENT'],
|
||||
isNullable: false,
|
||||
},
|
||||
{ name: 'rule_name', type: 'varchar', length: '255', isNullable: false },
|
||||
{ name: 'description', type: 'text', isNullable: true },
|
||||
{ name: 'activation_condition', type: 'text', isNullable: true },
|
||||
{ name: 'bonus_points', type: 'int', default: 0 },
|
||||
{ name: 'is_active', type: 'boolean', default: false },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.priority_rules',
|
||||
new TableIndex({ name: 'IDX_priority_rules_priority_type', columnNames: ['priority_type'], isUnique: true }),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.priority_rules',
|
||||
new TableIndex({ name: 'IDX_priority_rules_is_active', columnNames: ['is_active'] }),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.priority_rules', true);
|
||||
await queryRunner.dropTable('freight.weight_limit_rules', true);
|
||||
await queryRunner.dropTable('freight.container_types', true);
|
||||
await queryRunner.dropTable('freight.surcharges', true);
|
||||
await queryRunner.dropTable('freight.surcharge_types', true);
|
||||
await queryRunner.dropIndex('freight.cargo_types', 'IDX_cargo_types_code');
|
||||
await queryRunner.dropColumn('freight.cargo_types', 'code');
|
||||
await queryRunner.dropIndex('freight.service_types', 'IDX_service_types_code');
|
||||
await queryRunner.dropColumn('freight.service_types', 'code');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Pre-ITMLS baseline for fresh databases. Older environments created `freight.bookings`
|
||||
* via synchronize or manual SQL; ItmlsFullSchemaRewrite only ALTERs that table.
|
||||
*/
|
||||
export class CreateFreightLegacyBaseline1748550000000 implements MigrationInterface {
|
||||
name = 'CreateFreightLegacyBaseline1748550000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS freight`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE freight.train_status AS ENUM (
|
||||
'AVAILABLE', 'SCHEDULED', 'IN_SERVICE', 'UNDER_MAINTENANCE', 'OUT_OF_SERVICE'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.trains (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
code VARCHAR(32) NOT NULL UNIQUE,
|
||||
capacity_tons NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
||||
status freight.train_status NOT NULL DEFAULT 'AVAILABLE',
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.bookings (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
reference VARCHAR(64) NOT NULL UNIQUE,
|
||||
customer_id UUID NOT NULL,
|
||||
train_id UUID,
|
||||
status VARCHAR(40) NOT NULL DEFAULT 'DRAFT',
|
||||
scheduled_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
total_amount NUMERIC(14, 2) NOT NULL DEFAULT 0,
|
||||
payment_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||
contract_type VARCHAR(20) NOT NULL DEFAULT 'SPOT',
|
||||
previous_contract_id UUID,
|
||||
trade_direction VARCHAR(10) NOT NULL DEFAULT 'IMPORT',
|
||||
equipment_return VARCHAR(20) NOT NULL DEFAULT 'RETURN',
|
||||
first_mile_pickup_address TEXT,
|
||||
last_mile_delivery_address TEXT,
|
||||
cargo_total_weight_vgm NUMERIC(12, 3) NOT NULL DEFAULT 0,
|
||||
is_hazardous BOOLEAN NOT NULL DEFAULT false,
|
||||
payment_currency VARCHAR(5) NOT NULL DEFAULT 'USD',
|
||||
start_date DATE,
|
||||
end_date DATE,
|
||||
financial_terms TEXT,
|
||||
version_number INT NOT NULL DEFAULT 1,
|
||||
approved_by_staff_id UUID,
|
||||
approved_by_staff_at TIMESTAMPTZ,
|
||||
signed_by_director_id UUID,
|
||||
signed_by_director_at TIMESTAMPTZ,
|
||||
signed_by_ceo_id UUID,
|
||||
signed_by_ceo_at TIMESTAMPTZ,
|
||||
priority_score INT NOT NULL DEFAULT 0,
|
||||
allow_consolidation BOOLEAN NOT NULL DEFAULT false,
|
||||
consolidation_partner_id UUID,
|
||||
origin_station VARCHAR(255),
|
||||
destination_station VARCHAR(255),
|
||||
service_type VARCHAR(100),
|
||||
freight_type VARCHAR(100),
|
||||
freight_subtype VARCHAR(255),
|
||||
containers JSONB,
|
||||
first_mile_enabled BOOLEAN DEFAULT false,
|
||||
last_mile_enabled BOOLEAN DEFAULT false,
|
||||
is_refrigerated BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.bookings CASCADE`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.trains CASCADE`);
|
||||
await queryRunner.query(`DROP TYPE IF EXISTS freight.train_status`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
import {
|
||||
MigrationInterface,
|
||||
QueryRunner,
|
||||
Table,
|
||||
TableForeignKey,
|
||||
TableIndex,
|
||||
TableUnique,
|
||||
} from 'typeorm';
|
||||
|
||||
export class ItmlsFullSchemaRewrite1748600000000 implements MigrationInterface {
|
||||
name = 'ItmlsFullSchemaRewrite1748600000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ── container_types ───────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.container_types RENAME COLUMN size_code TO code;
|
||||
EXCEPTION WHEN undefined_column THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.container_types RENAME COLUMN description TO label;
|
||||
EXCEPTION WHEN undefined_column THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.container_types
|
||||
ADD COLUMN IF NOT EXISTS size_ft SMALLINT,
|
||||
ADD COLUMN IF NOT EXISTS is_reefer BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS is_open_top BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS display_order INT NOT NULL DEFAULT 1;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.container_types
|
||||
ADD COLUMN IF NOT EXISTS wagons_per_unit NUMERIC(4,2);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.container_types
|
||||
SET wagons_per_unit = CASE
|
||||
WHEN containers_per_wagon > 0 THEN ROUND(1.0 / containers_per_wagon, 2)
|
||||
ELSE 1.00
|
||||
END
|
||||
WHERE wagons_per_unit IS NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.container_types
|
||||
SET size_ft = CASE WHEN code LIKE '40%' OR code LIKE '%40%' THEN 40 ELSE 20 END
|
||||
WHERE size_ft IS NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.container_types
|
||||
ALTER COLUMN wagons_per_unit SET NOT NULL,
|
||||
DROP COLUMN IF EXISTS containers_per_wagon;
|
||||
`);
|
||||
|
||||
// ── weight_limit_rules ──────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.weight_limit_rules RENAME COLUMN max_weight_tons TO max_vgm_tons;
|
||||
EXCEPTION WHEN undefined_column THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.weight_limit_rules
|
||||
ALTER COLUMN max_vgm_tons TYPE NUMERIC(8,3);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.weight_limit_rules
|
||||
ADD COLUMN IF NOT EXISTS effective_from DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
ADD COLUMN IF NOT EXISTS effective_to DATE;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.weight_limit_rules
|
||||
DROP COLUMN IF EXISTS warning_threshold_tons,
|
||||
DROP COLUMN IF EXISTS exceeded_action,
|
||||
DROP COLUMN IF EXISTS surcharge_id,
|
||||
DROP COLUMN IF EXISTS is_active;
|
||||
`);
|
||||
|
||||
// ── priority_rules ────────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_rules
|
||||
ADD COLUMN IF NOT EXISTS code VARCHAR(40),
|
||||
ADD COLUMN IF NOT EXISTS label VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS score INT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS condition_currency VARCHAR(5);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.priority_rules
|
||||
SET code = COALESCE(code, upper(priority_type::text)),
|
||||
label = COALESCE(label, rule_name),
|
||||
score = COALESCE(score, bonus_points)
|
||||
WHERE code IS NULL OR label IS NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_rules
|
||||
DROP COLUMN IF EXISTS priority_type,
|
||||
DROP COLUMN IF EXISTS rule_name,
|
||||
DROP COLUMN IF EXISTS bonus_points,
|
||||
DROP COLUMN IF EXISTS activation_condition,
|
||||
DROP COLUMN IF EXISTS description;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_rules
|
||||
ALTER COLUMN code SET NOT NULL,
|
||||
ALTER COLUMN label SET NOT NULL;
|
||||
`);
|
||||
await queryRunner.createIndex(
|
||||
'freight.priority_rules',
|
||||
new TableIndex({ name: 'UQ_priority_rules_code', columnNames: ['code'], isUnique: true }),
|
||||
);
|
||||
|
||||
// ── rates (before surcharge_types.rate_id) ────────────────────────────
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'rates',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'rate_type', type: 'varchar', length: '50' },
|
||||
{ name: 'container_type_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'trade_direction', type: 'varchar', length: '10', isNullable: true },
|
||||
{ name: 'currency', type: 'varchar', length: '5' },
|
||||
{ name: 'rate_value', type: 'numeric', precision: 14, scale: 4 },
|
||||
{ name: 'rate_unit', type: 'varchar', length: '30' },
|
||||
{ name: 'status', type: 'varchar', length: '20', default: "'DRAFT'" },
|
||||
{ name: 'proposed_by_staff_id', type: 'uuid' },
|
||||
{ name: 'approved_by_ceo_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'approved_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'effective_from', type: 'date' },
|
||||
{ name: 'effective_to', type: 'date', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// ── surcharge_types ───────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.surcharge_types RENAME COLUMN name TO label;
|
||||
EXCEPTION WHEN undefined_column THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.surcharge_types
|
||||
ADD COLUMN IF NOT EXISTS trigger_condition VARCHAR(50),
|
||||
ADD COLUMN IF NOT EXISTS rate_id UUID;
|
||||
`);
|
||||
await queryRunner.query(`ALTER TABLE freight.surcharge_types DROP COLUMN IF EXISTS description`);
|
||||
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.surcharges CASCADE`);
|
||||
|
||||
// ── yards ─────────────────────────────────────────────────────────────
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'yards',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'code', type: 'varchar', length: '20' },
|
||||
{ name: 'label', type: 'varchar', length: '100' },
|
||||
{ name: 'country', type: 'varchar', length: '50' },
|
||||
{ name: 'is_active', type: 'boolean', default: true },
|
||||
{ name: 'display_order', type: 'int', default: 1 },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.yards',
|
||||
new TableIndex({ name: 'UQ_yards_code', columnNames: ['code'], isUnique: true }),
|
||||
);
|
||||
|
||||
// ── shipping_lines ────────────────────────────────────────────────────
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'shipping_lines',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'code', type: 'varchar', length: '20' },
|
||||
{ name: 'label', type: 'varchar', length: '100' },
|
||||
{ name: 'mapped_to_code', type: 'varchar', length: '20', isNullable: true },
|
||||
{ name: 'show_extra_fee_notice', type: 'boolean', default: false },
|
||||
{ name: 'is_active', type: 'boolean', default: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// ── approval_rules ────────────────────────────────────────────────────
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'approval_rules',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'requires_director_approval', type: 'boolean' },
|
||||
{ name: 'step_order', type: 'smallint' },
|
||||
{ name: 'required_role', type: 'varchar', length: '30' },
|
||||
{ name: 'action_label', type: 'varchar', length: '50' },
|
||||
{ name: 'blocks_role', type: 'varchar', length: '30', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createUniqueConstraint(
|
||||
'freight.approval_rules',
|
||||
new TableUnique({
|
||||
name: 'UQ_approval_rules_chain_step',
|
||||
columnNames: ['requires_director_approval', 'step_order'],
|
||||
}),
|
||||
);
|
||||
|
||||
// ── bookings ────────────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS origin_yard_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS destination_yard_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS service_type_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS cargo_type_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS cargo_free_text VARCHAR(200),
|
||||
ADD COLUMN IF NOT EXISTS shipping_line_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS pnr_code VARCHAR(50),
|
||||
ADD COLUMN IF NOT EXISTS customer_signed_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS fully_executed_at TIMESTAMPTZ;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.yards (id, code, label, country, is_active, display_order, created_at, updated_at)
|
||||
VALUES
|
||||
(uuid_generate_v4(), 'KALITY', 'Kality Rail Terminal', 'Ethiopia', true, 1, now(), now()),
|
||||
(uuid_generate_v4(), 'MOJO', 'Mojo Dry Port', 'Ethiopia', true, 2, now(), now()),
|
||||
(uuid_generate_v4(), 'DIRE_DAWA', 'Dire Dawa Yard', 'Ethiopia', true, 3, now(), now()),
|
||||
(uuid_generate_v4(), 'DJIB_PORT', 'Djibouti Port Terminal', 'Djibouti', true, 4, now(), now()),
|
||||
(uuid_generate_v4(), 'NAGAD', 'Nagad Terminal, Djibouti', 'Djibouti', true, 5, now(), now()),
|
||||
(uuid_generate_v4(), 'LEGACY_ORIGIN', 'Legacy Origin', 'Ethiopia', true, 99, now(), now()),
|
||||
(uuid_generate_v4(), 'LEGACY_DEST', 'Legacy Destination', 'Ethiopia', true, 100, now(), now())
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.service_types (id, code, service_name, can_be_booked_alone, includes_first_mile, includes_last_mile, includes_customs, priority_bonus_points, is_active, display_order, created_at, updated_at)
|
||||
SELECT uuid_generate_v4(), 'RAIL', 'Rail Transport Only', true, false, false, false, 0, true, 1, now(), now()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.service_types LIMIT 1);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.cargo_types (id, code, cargo_type_name, requires_director_approval, show_free_text_box, is_active, display_order, created_at, updated_at)
|
||||
SELECT uuid_generate_v4(), 'GENERAL', 'General Cargo', false, false, true, 1, now(), now()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types LIMIT 1);
|
||||
`);
|
||||
|
||||
const hasServiceTypeCol = await queryRunner.query(`
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'freight' AND table_name = 'bookings' AND column_name = 'service_type'
|
||||
LIMIT 1;
|
||||
`);
|
||||
|
||||
if (hasServiceTypeCol.length > 0) {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET service_type_id = st.id
|
||||
FROM freight.service_types st
|
||||
WHERE b.service_type_id IS NULL
|
||||
AND (
|
||||
st.code = b.service_type
|
||||
OR upper(replace(st.service_name, ' ', '_')) = upper(b.service_type)
|
||||
OR st.code = upper(replace(b.service_type, ' ', '_'))
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET cargo_type_id = ct.id
|
||||
FROM freight.cargo_types ct
|
||||
WHERE b.cargo_type_id IS NULL
|
||||
AND (
|
||||
ct.code = upper(b.freight_type)
|
||||
OR ct.code = upper(concat(b.freight_type, '_', coalesce(b.freight_subtype, '')))
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET cargo_free_text = b.freight_subtype
|
||||
WHERE b.cargo_free_text IS NULL AND b.freight_subtype IS NOT NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET origin_yard_id = y.id
|
||||
FROM freight.yards y
|
||||
WHERE b.origin_yard_id IS NULL
|
||||
AND (y.label ILIKE b.origin_station OR y.code = upper(replace(b.origin_station, ' ', '_')));
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET destination_yard_id = y.id
|
||||
FROM freight.yards y
|
||||
WHERE b.destination_yard_id IS NULL
|
||||
AND (y.label ILIKE b.destination_station OR y.code = upper(replace(b.destination_station, ' ', '_')));
|
||||
`);
|
||||
}
|
||||
|
||||
const defaultServiceTypeId = await queryRunner.query(
|
||||
`SELECT id FROM freight.service_types ORDER BY display_order LIMIT 1`,
|
||||
);
|
||||
const defaultCargoTypeId = await queryRunner.query(
|
||||
`SELECT id FROM freight.cargo_types ORDER BY display_order LIMIT 1`,
|
||||
);
|
||||
const legacyOriginId = await queryRunner.query(
|
||||
`SELECT id FROM freight.yards WHERE code = 'LEGACY_ORIGIN' LIMIT 1`,
|
||||
);
|
||||
const legacyDestId = await queryRunner.query(
|
||||
`SELECT id FROM freight.yards WHERE code = 'LEGACY_DEST' LIMIT 1`,
|
||||
);
|
||||
|
||||
if (defaultServiceTypeId[0]?.id) {
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.bookings SET service_type_id = $1 WHERE service_type_id IS NULL`,
|
||||
[defaultServiceTypeId[0].id],
|
||||
);
|
||||
}
|
||||
if (defaultCargoTypeId[0]?.id) {
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.bookings SET cargo_type_id = $1 WHERE cargo_type_id IS NULL`,
|
||||
[defaultCargoTypeId[0].id],
|
||||
);
|
||||
}
|
||||
if (legacyOriginId[0]?.id) {
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.bookings SET origin_yard_id = $1 WHERE origin_yard_id IS NULL`,
|
||||
[legacyOriginId[0].id],
|
||||
);
|
||||
}
|
||||
if (legacyDestId[0]?.id) {
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.bookings SET destination_yard_id = $1 WHERE destination_yard_id IS NULL`,
|
||||
[legacyDestId[0].id],
|
||||
);
|
||||
}
|
||||
|
||||
const nullBookings = await queryRunner.query(
|
||||
`SELECT COUNT(*)::int AS cnt FROM freight.bookings WHERE service_type_id IS NULL OR cargo_type_id IS NULL OR origin_yard_id IS NULL OR destination_yard_id IS NULL`,
|
||||
);
|
||||
if (nullBookings[0]?.cnt > 0) {
|
||||
await queryRunner.query(`DELETE FROM freight.bookings WHERE service_type_id IS NULL OR cargo_type_id IS NULL OR origin_yard_id IS NULL OR destination_yard_id IS NULL`);
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ALTER COLUMN service_type_id SET NOT NULL,
|
||||
ALTER COLUMN cargo_type_id SET NOT NULL,
|
||||
ALTER COLUMN origin_yard_id SET NOT NULL,
|
||||
ALTER COLUMN destination_yard_id SET NOT NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS origin_station,
|
||||
DROP COLUMN IF EXISTS destination_station,
|
||||
DROP COLUMN IF EXISTS service_type,
|
||||
DROP COLUMN IF EXISTS freight_type,
|
||||
DROP COLUMN IF EXISTS freight_subtype,
|
||||
DROP COLUMN IF EXISTS containers,
|
||||
DROP COLUMN IF EXISTS first_mile_enabled,
|
||||
DROP COLUMN IF EXISTS last_mile_enabled,
|
||||
DROP COLUMN IF EXISTS is_refrigerated;
|
||||
`);
|
||||
|
||||
// ── booking_container ─────────────────────────────────────────────────
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'booking_container',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'booking_id', type: 'uuid' },
|
||||
{ name: 'container_type_id', type: 'uuid' },
|
||||
{ name: 'quantity', type: 'smallint' },
|
||||
{ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 },
|
||||
{ name: 'total_vgm_tons', type: 'numeric', precision: 12, scale: 3 },
|
||||
{ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2 },
|
||||
{ name: 'weight_limit_rule_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'is_overweight', type: 'boolean', default: false },
|
||||
{ name: 'overweight_excess_tons', type: 'numeric', precision: 10, scale: 3, isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'booking_rate_snapshot',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'booking_id', type: 'uuid' },
|
||||
{ name: 'rate_id', type: 'uuid' },
|
||||
{ name: 'rate_type', type: 'varchar', length: '50' },
|
||||
{ name: 'rate_value', type: 'numeric', precision: 14, scale: 4 },
|
||||
{ name: 'rate_unit', type: 'varchar', length: '30' },
|
||||
{ name: 'currency', type: 'varchar', length: '5' },
|
||||
{ name: 'snapshotted_at', type: 'timestamptz' },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'booking_cargo_modifier',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'booking_id', type: 'uuid' },
|
||||
{ name: 'surcharge_type_id', type: 'uuid' },
|
||||
{ name: 'trigger_value', type: 'numeric', precision: 14, scale: 4, isNullable: true },
|
||||
{ name: 'calculated_amount', type: 'numeric', precision: 14, scale: 2 },
|
||||
{ name: 'rate_snapshot_id', type: 'uuid' },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'booking_approval_step',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'booking_id', type: 'uuid' },
|
||||
{ name: 'approval_rule_id', type: 'uuid' },
|
||||
{ name: 'step_order', type: 'smallint' },
|
||||
{ name: 'required_role', type: 'varchar', length: '30' },
|
||||
{ name: 'status', type: 'varchar', length: '20', default: "'PENDING'" },
|
||||
{ name: 'actioned_by_staff_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'actioned_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'remarks', type: 'text', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// Foreign keys
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.surcharge_types',
|
||||
new TableForeignKey({
|
||||
name: 'FK_surcharge_types_rate_id',
|
||||
columnNames: ['rate_id'],
|
||||
referencedTableName: 'rates',
|
||||
referencedSchema: 'freight',
|
||||
referencedColumnNames: ['id'],
|
||||
}),
|
||||
);
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.booking_container',
|
||||
new TableForeignKey({
|
||||
columnNames: ['booking_id'],
|
||||
referencedTableName: 'bookings',
|
||||
referencedSchema: 'freight',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
}),
|
||||
);
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.bookings',
|
||||
new TableForeignKey({
|
||||
columnNames: ['origin_yard_id'],
|
||||
referencedTableName: 'yards',
|
||||
referencedSchema: 'freight',
|
||||
referencedColumnNames: ['id'],
|
||||
}),
|
||||
);
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.bookings',
|
||||
new TableForeignKey({
|
||||
columnNames: ['destination_yard_id'],
|
||||
referencedTableName: 'yards',
|
||||
referencedSchema: 'freight',
|
||||
referencedColumnNames: ['id'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.booking_approval_step', true);
|
||||
await queryRunner.dropTable('freight.booking_cargo_modifier', true);
|
||||
await queryRunner.dropTable('freight.booking_rate_snapshot', true);
|
||||
await queryRunner.dropTable('freight.booking_container', true);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS origin_station VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS destination_station VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS service_type VARCHAR(30),
|
||||
ADD COLUMN IF NOT EXISTS freight_type VARCHAR(20),
|
||||
ADD COLUMN IF NOT EXISTS freight_subtype VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS containers JSONB,
|
||||
ADD COLUMN IF NOT EXISTS first_mile_enabled BOOLEAN DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS last_mile_enabled BOOLEAN DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS is_refrigerated BOOLEAN DEFAULT false;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS origin_yard_id,
|
||||
DROP COLUMN IF EXISTS destination_yard_id,
|
||||
DROP COLUMN IF EXISTS service_type_id,
|
||||
DROP COLUMN IF EXISTS cargo_type_id,
|
||||
DROP COLUMN IF EXISTS cargo_free_text,
|
||||
DROP COLUMN IF EXISTS shipping_line_id,
|
||||
DROP COLUMN IF EXISTS pnr_code,
|
||||
DROP COLUMN IF EXISTS customer_signed_at,
|
||||
DROP COLUMN IF EXISTS fully_executed_at;
|
||||
`);
|
||||
|
||||
await queryRunner.dropTable('freight.approval_rules', true);
|
||||
await queryRunner.dropTable('freight.shipping_lines', true);
|
||||
await queryRunner.dropTable('freight.yards', true);
|
||||
await queryRunner.dropTable('freight.rates', true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBookingsConfigForeignKeys1748700000000 implements MigrationInterface {
|
||||
name = 'AddBookingsConfigForeignKeys1748700000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Ensure parent config rows exist for backfill
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.service_types (id, code, service_name, can_be_booked_alone, includes_first_mile, includes_last_mile, includes_customs, priority_bonus_points, is_active, display_order, created_at, updated_at)
|
||||
SELECT uuid_generate_v4(), 'RAIL', 'Rail Transport Only', true, false, false, false, 0, true, 1, now(), now()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.service_types LIMIT 1);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.cargo_types (id, code, cargo_type_name, requires_director_approval, show_free_text_box, is_active, display_order, created_at, updated_at)
|
||||
SELECT uuid_generate_v4(), 'GENERAL', 'General Cargo', false, false, true, 1, now(), now()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types LIMIT 1);
|
||||
`);
|
||||
|
||||
// Clear orphan shipping_line references (nullable FK)
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET shipping_line_id = NULL
|
||||
WHERE b.shipping_line_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.shipping_lines sl WHERE sl.id = b.shipping_line_id
|
||||
);
|
||||
`);
|
||||
|
||||
// Backfill required FK columns
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings
|
||||
SET service_type_id = (SELECT id FROM freight.service_types ORDER BY display_order LIMIT 1)
|
||||
WHERE service_type_id IS NULL
|
||||
OR NOT EXISTS (SELECT 1 FROM freight.service_types st WHERE st.id = service_type_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings
|
||||
SET cargo_type_id = (SELECT id FROM freight.cargo_types ORDER BY display_order LIMIT 1)
|
||||
WHERE cargo_type_id IS NULL
|
||||
OR NOT EXISTS (SELECT 1 FROM freight.cargo_types ct WHERE ct.id = cargo_type_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_service_type_id"
|
||||
FOREIGN KEY (service_type_id)
|
||||
REFERENCES freight.service_types(id)
|
||||
ON DELETE RESTRICT;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_cargo_type_id"
|
||||
FOREIGN KEY (cargo_type_id)
|
||||
REFERENCES freight.cargo_types(id)
|
||||
ON DELETE RESTRICT;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_shipping_line_id"
|
||||
FOREIGN KEY (shipping_line_id)
|
||||
REFERENCES freight.shipping_lines(id)
|
||||
ON DELETE SET NULL;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_shipping_line_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_cargo_type_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_service_type_id";
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBookingsRemainingForeignKeys1748800000000 implements MigrationInterface {
|
||||
name = 'AddBookingsRemainingForeignKeys1748800000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const publicCustomersExists = await queryRunner.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'customers'
|
||||
) AS exists
|
||||
`);
|
||||
const hasPublicCustomers = Boolean(publicCustomersExists[0]?.exists);
|
||||
|
||||
// ── freight.bookings: nullable FK cleanup ─────────────────────────────
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET train_id = NULL
|
||||
WHERE b.train_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.id = b.train_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET previous_contract_id = NULL
|
||||
WHERE b.previous_contract_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM freight.bookings pb WHERE pb.id = b.previous_contract_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET consolidation_partner_id = NULL
|
||||
WHERE b.consolidation_partner_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM freight.bookings pb WHERE pb.id = b.consolidation_partner_id);
|
||||
`);
|
||||
|
||||
// Legacy DBs only: public.customers is created/moved in MoveCustomersToFreightSchema (174890).
|
||||
if (hasPublicCustomers) {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_cargo_modifier bcm
|
||||
USING freight.bookings b
|
||||
WHERE bcm.booking_id = b.id
|
||||
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_approval_step bas
|
||||
USING freight.bookings b
|
||||
WHERE bas.booking_id = b.id
|
||||
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_rate_snapshot brs
|
||||
USING freight.bookings b
|
||||
WHERE brs.booking_id = b.id
|
||||
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_container bc
|
||||
USING freight.bookings b
|
||||
WHERE bc.booking_id = b.id
|
||||
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.bookings b
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_customer_id"
|
||||
FOREIGN KEY (customer_id)
|
||||
REFERENCES public.customers(id)
|
||||
ON DELETE RESTRICT;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
// ── freight.bookings FKs ────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_train_id"
|
||||
FOREIGN KEY (train_id)
|
||||
REFERENCES freight.trains(id)
|
||||
ON DELETE SET NULL;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_previous_contract_id"
|
||||
FOREIGN KEY (previous_contract_id)
|
||||
REFERENCES freight.bookings(id)
|
||||
ON DELETE SET NULL;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_consolidation_partner_id"
|
||||
FOREIGN KEY (consolidation_partner_id)
|
||||
REFERENCES freight.bookings(id)
|
||||
ON DELETE SET NULL;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// ── freight.booking_container ─────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.booking_container bc
|
||||
SET weight_limit_rule_id = NULL
|
||||
WHERE bc.weight_limit_rule_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.weight_limit_rules wlr WHERE wlr.id = bc.weight_limit_rule_id
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_container bc
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.container_types ct WHERE ct.id = bc.container_type_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.booking_container
|
||||
ADD CONSTRAINT "FK_booking_container_container_type_id"
|
||||
FOREIGN KEY (container_type_id)
|
||||
REFERENCES freight.container_types(id)
|
||||
ON DELETE RESTRICT;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.booking_container
|
||||
ADD CONSTRAINT "FK_booking_container_weight_limit_rule_id"
|
||||
FOREIGN KEY (weight_limit_rule_id)
|
||||
REFERENCES freight.weight_limit_rules(id)
|
||||
ON DELETE SET NULL;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// ── freight.booking_rate_snapshot ─────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_cargo_modifier bcm
|
||||
USING freight.booking_rate_snapshot brs
|
||||
WHERE bcm.rate_snapshot_id = brs.id
|
||||
AND (
|
||||
NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = brs.booking_id)
|
||||
OR NOT EXISTS (SELECT 1 FROM freight.rates r WHERE r.id = brs.rate_id)
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_rate_snapshot brs
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = brs.booking_id)
|
||||
OR NOT EXISTS (SELECT 1 FROM freight.rates r WHERE r.id = brs.rate_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.booking_rate_snapshot
|
||||
ADD CONSTRAINT "FK_booking_rate_snapshot_booking_id"
|
||||
FOREIGN KEY (booking_id)
|
||||
REFERENCES freight.bookings(id)
|
||||
ON DELETE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.booking_rate_snapshot
|
||||
ADD CONSTRAINT "FK_booking_rate_snapshot_rate_id"
|
||||
FOREIGN KEY (rate_id)
|
||||
REFERENCES freight.rates(id)
|
||||
ON DELETE RESTRICT;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// ── freight.booking_approval_step ─────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_approval_step bas
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = bas.booking_id)
|
||||
OR NOT EXISTS (SELECT 1 FROM freight.approval_rules ar WHERE ar.id = bas.approval_rule_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.booking_approval_step
|
||||
ADD CONSTRAINT "FK_booking_approval_step_booking_id"
|
||||
FOREIGN KEY (booking_id)
|
||||
REFERENCES freight.bookings(id)
|
||||
ON DELETE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.booking_approval_step
|
||||
ADD CONSTRAINT "FK_booking_approval_step_approval_rule_id"
|
||||
FOREIGN KEY (approval_rule_id)
|
||||
REFERENCES freight.approval_rules(id)
|
||||
ON DELETE RESTRICT;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// ── freight.booking_cargo_modifier ────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_cargo_modifier bcm
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = bcm.booking_id)
|
||||
OR NOT EXISTS (SELECT 1 FROM freight.surcharge_types st WHERE st.id = bcm.surcharge_type_id)
|
||||
OR NOT EXISTS (
|
||||
SELECT 1 FROM freight.booking_rate_snapshot brs WHERE brs.id = bcm.rate_snapshot_id
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
ADD CONSTRAINT "FK_booking_cargo_modifier_booking_id"
|
||||
FOREIGN KEY (booking_id)
|
||||
REFERENCES freight.bookings(id)
|
||||
ON DELETE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
ADD CONSTRAINT "FK_booking_cargo_modifier_surcharge_type_id"
|
||||
FOREIGN KEY (surcharge_type_id)
|
||||
REFERENCES freight.surcharge_types(id)
|
||||
ON DELETE RESTRICT;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
ADD CONSTRAINT "FK_booking_cargo_modifier_rate_snapshot_id"
|
||||
FOREIGN KEY (rate_snapshot_id)
|
||||
REFERENCES freight.booking_rate_snapshot(id)
|
||||
ON DELETE RESTRICT;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_rate_snapshot_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_surcharge_type_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_booking_id";
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_approval_step
|
||||
DROP CONSTRAINT IF EXISTS "FK_booking_approval_step_approval_rule_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_approval_step
|
||||
DROP CONSTRAINT IF EXISTS "FK_booking_approval_step_booking_id";
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_rate_snapshot
|
||||
DROP CONSTRAINT IF EXISTS "FK_booking_rate_snapshot_rate_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_rate_snapshot
|
||||
DROP CONSTRAINT IF EXISTS "FK_booking_rate_snapshot_booking_id";
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_container
|
||||
DROP CONSTRAINT IF EXISTS "FK_booking_container_weight_limit_rule_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_container
|
||||
DROP CONSTRAINT IF EXISTS "FK_booking_container_container_type_id";
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_consolidation_partner_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_previous_contract_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_train_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_customer_id";
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class MoveCustomersToFreightSchema1748900000000 implements MigrationInterface {
|
||||
name = 'MoveCustomersToFreightSchema1748900000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.customers (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(150) NOT NULL UNIQUE,
|
||||
phone VARCHAR(20) NOT NULL,
|
||||
company_name VARCHAR(200) NOT NULL,
|
||||
company_email VARCHAR(150) NOT NULL,
|
||||
company_phone VARCHAR(20) NOT NULL,
|
||||
company_location VARCHAR(100) NOT NULL,
|
||||
company_address TEXT NOT NULL,
|
||||
customer_type VARCHAR(32),
|
||||
status VARCHAR(32),
|
||||
contact_person_name VARCHAR(100) NOT NULL,
|
||||
contact_person_phone VARCHAR(20) NOT NULL,
|
||||
tin_number VARCHAR(10) NOT NULL UNIQUE,
|
||||
vat_number VARCHAR(50),
|
||||
fan_number VARCHAR(16) NOT NULL UNIQUE,
|
||||
general_manager_name VARCHAR(100) NOT NULL,
|
||||
general_manager_email VARCHAR(150) NOT NULL,
|
||||
general_manager_phone VARCHAR(20) NOT NULL,
|
||||
poa_name VARCHAR(100),
|
||||
poa_phone VARCHAR(20),
|
||||
poa_address TEXT,
|
||||
poa_email VARCHAR(150),
|
||||
poa_location VARCHAR(100),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_freight_customers_email"
|
||||
ON freight.customers (email);
|
||||
`);
|
||||
// await queryRunner.query(`
|
||||
// CREATE INDEX IF NOT EXISTS "IDX_freight_customers_user_id"
|
||||
// ON freight.customers (user_id);
|
||||
//`);
|
||||
|
||||
// Copy rows from public.customers when that legacy table exists
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE
|
||||
has_public boolean;
|
||||
has_user_id boolean;
|
||||
has_userid boolean;
|
||||
BEGIN
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'customers'
|
||||
) INTO has_public;
|
||||
|
||||
IF NOT has_public THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'customers' AND column_name = 'user_id'
|
||||
) INTO has_user_id;
|
||||
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'customers' AND column_name = 'userid'
|
||||
) INTO has_userid;
|
||||
|
||||
IF has_user_id THEN
|
||||
INSERT INTO freight.customers (
|
||||
id, user_id, first_name, last_name, email, phone,
|
||||
company_name, company_email, company_phone, company_location, company_address,
|
||||
contact_person_name, contact_person_phone, tin_number, vat_number, fan_number,
|
||||
general_manager_name, general_manager_email, general_manager_phone,
|
||||
poa_name, poa_phone, poa_address, poa_email, poa_location, notes,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, user_id, first_name, last_name, email, phone,
|
||||
company_name, company_email, company_phone, company_location, company_address,
|
||||
contact_person_name, contact_person_phone, tin_number, vat_number, fan_number,
|
||||
general_manager_name, general_manager_email, general_manager_phone,
|
||||
poa_name, poa_phone, poa_address, poa_email, poa_location, notes,
|
||||
COALESCE(created_at, now()), COALESCE(updated_at, now())
|
||||
FROM public.customers
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
ELSIF has_userid THEN
|
||||
INSERT INTO freight.customers (
|
||||
id, user_id, first_name, last_name, email, phone,
|
||||
company_name, company_email, company_phone, company_location, company_address,
|
||||
contact_person_name, contact_person_phone, tin_number, vat_number, fan_number,
|
||||
general_manager_name, general_manager_email, general_manager_phone,
|
||||
poa_name, poa_phone, poa_address, poa_email, poa_location, notes,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, userid, firstname, lastname, email, phone,
|
||||
companyname, companyemail, companyphone, companylocation, companyaddress,
|
||||
contactpersonname, contactpersonphone, tinnumber, vatnumber, fannumber,
|
||||
generalmanagername, generalmanageremail, generalmanagerphone,
|
||||
poaname, poaphone, poaaddress, poaemail, poalocation, notes,
|
||||
COALESCE("createdAt", now()), COALESCE("updatedAt", now())
|
||||
FROM public.customers
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_customer_id";
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_cargo_modifier bcm
|
||||
USING freight.bookings b
|
||||
WHERE bcm.booking_id = b.id
|
||||
AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_approval_step bas
|
||||
USING freight.bookings b
|
||||
WHERE bas.booking_id = b.id
|
||||
AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_rate_snapshot brs
|
||||
USING freight.bookings b
|
||||
WHERE brs.booking_id = b.id
|
||||
AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_container bc
|
||||
USING freight.bookings b
|
||||
WHERE bc.booking_id = b.id
|
||||
AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.bookings b
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_customer_id"
|
||||
FOREIGN KEY (customer_id)
|
||||
REFERENCES freight.customers(id)
|
||||
ON DELETE RESTRICT;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_customer_id";
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_customer_id"
|
||||
FOREIGN KEY (customer_id)
|
||||
REFERENCES public.customers(id)
|
||||
ON DELETE RESTRICT;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.customers CASCADE;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Align weight_limit_rules.trade_direction with app code: IMPORT, EXPORT, BOTH (not ANY).
|
||||
*/
|
||||
export class NormalizeWeightLimitTradeDirectionBoth1749000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'NormalizeWeightLimitTradeDirectionBoth1749000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
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 $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// No-op: ANY is not a valid enum value in PostgreSQL.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateFreightFilesTable1749100000000 implements MigrationInterface {
|
||||
name = 'CreateFreightFilesTable1749100000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.files (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
resource_id UUID NOT NULL,
|
||||
resource VARCHAR(100) NOT NULL,
|
||||
code VARCHAR(100) NOT NULL,
|
||||
name VARCHAR(500) NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
mime_type VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_freight_files_resource"
|
||||
ON freight.files (resource_id, resource);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_freight_files_resource_code"
|
||||
ON freight.files (resource_id, resource, code);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.files CASCADE`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class BookingFlowRefactor1749200000000 implements MigrationInterface {
|
||||
name = 'BookingFlowRefactor1749200000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.booking_review_note (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||
author_id UUID,
|
||||
note TEXT NOT NULL,
|
||||
type VARCHAR(30) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_booking_review_note_booking_id
|
||||
ON freight.booking_review_note(booking_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS marketing_approved_by_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS marketing_approved_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS contract_summary TEXT,
|
||||
ADD COLUMN IF NOT EXISTS locked_at TIMESTAMPTZ;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings SET status = 'SUBMITTED'
|
||||
WHERE status IN ('RFQ_SUBMITTED', 'QUOTATION_SENT', 'QUOTATION_APPROVED');
|
||||
UPDATE freight.bookings SET status = 'REJECTED'
|
||||
WHERE status = 'QUOTATION_REJECTED';
|
||||
UPDATE freight.bookings SET status = 'CANCELLED'
|
||||
WHERE status = 'CANCELLED';
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS locked_at,
|
||||
DROP COLUMN IF EXISTS contract_summary,
|
||||
DROP COLUMN IF EXISTS marketing_approved_at,
|
||||
DROP COLUMN IF EXISTS marketing_approved_by_id;
|
||||
`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_review_note;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex, TableUnique } from 'typeorm';
|
||||
|
||||
export class CreateCompaniesModule1749200000000 implements MigrationInterface {
|
||||
name = 'CreateCompaniesModule1749200000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'companies',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||
{ name: 'name', type: 'varchar', length: '200' },
|
||||
{ name: 'type', type: 'varchar', length: '32' },
|
||||
{ name: 'status', type: 'varchar', length: '32', default: "'pending'" },
|
||||
{ name: 'tin', type: 'varchar', length: '10', isUnique: true },
|
||||
{ name: 'vat_number', type: 'varchar', length: '50', isNullable: true },
|
||||
{ name: 'business_license', type: 'varchar', length: '100', isNullable: true },
|
||||
{ name: 'fan_number', type: 'varchar', length: '16', isNullable: true },
|
||||
{ name: 'country', type: 'varchar', length: '32', default: "'Ethiopia'" },
|
||||
{ name: 'address', type: 'text', isNullable: true },
|
||||
{ name: 'phone', type: 'varchar', length: '20', isNullable: true },
|
||||
{ name: 'email', type: 'varchar', length: '150', isNullable: true },
|
||||
{ name: 'website', type: 'varchar', length: '200', isNullable: true },
|
||||
{ name: 'attributes', type: 'jsonb', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'external_profiles',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||
{ name: 'user_id', type: 'uuid' },
|
||||
{ name: 'company_id', type: 'uuid' },
|
||||
{ name: 'first_name', type: 'varchar', length: '100' },
|
||||
{ name: 'last_name', type: 'varchar', length: '100' },
|
||||
{ name: 'email', type: 'varchar', length: '150', isUnique: true },
|
||||
{ name: 'phone', type: 'varchar', length: '20', isNullable: true },
|
||||
{ name: 'national_id', type: 'varchar', length: '50', isNullable: true },
|
||||
{ name: 'job_title', type: 'varchar', length: '100', isNullable: true },
|
||||
{ name: 'is_primary_contact', type: 'boolean', default: false },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
columnNames: ['company_id'],
|
||||
referencedTableName: 'companies',
|
||||
referencedSchema: 'freight',
|
||||
referencedColumnNames: ['id'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'ff_clients',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||
{ name: 'forwarder_company_id', type: 'uuid' },
|
||||
{ name: 'client_company_id', type: 'uuid' },
|
||||
{ name: 'relationship_type', type: 'varchar', length: '32', default: "'managed_account'" },
|
||||
{ name: 'can_book_on_behalf', type: 'boolean', default: true },
|
||||
{ name: 'can_view_documents', type: 'boolean', default: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
columnNames: ['forwarder_company_id'],
|
||||
referencedTableName: 'companies',
|
||||
referencedSchema: 'freight',
|
||||
referencedColumnNames: ['id'],
|
||||
},
|
||||
{
|
||||
columnNames: ['client_company_id'],
|
||||
referencedTableName: 'companies',
|
||||
referencedSchema: 'freight',
|
||||
referencedColumnNames: ['id'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createIndex('freight.companies', new TableIndex({ columnNames: ['tin'] }));
|
||||
await queryRunner.createIndex('freight.companies', new TableIndex({ columnNames: ['type'] }));
|
||||
await queryRunner.createIndex('freight.external_profiles', new TableIndex({ columnNames: ['user_id'] }));
|
||||
await queryRunner.createIndex('freight.external_profiles', new TableIndex({ columnNames: ['company_id'] }));
|
||||
await queryRunner.createIndex('freight.ff_clients', new TableIndex({ columnNames: ['forwarder_company_id'] }));
|
||||
await queryRunner.createIndex('freight.ff_clients', new TableIndex({ columnNames: ['client_company_id'] }));
|
||||
|
||||
await queryRunner.createUniqueConstraint('freight.ff_clients', new TableUnique({
|
||||
columnNames: ['forwarder_company_id', 'client_company_id'],
|
||||
}));
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.ff_clients');
|
||||
await queryRunner.dropTable('freight.external_profiles');
|
||||
await queryRunner.dropTable('freight.companies');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBookingFreightType1749300000000 implements MigrationInterface {
|
||||
name = 'AddBookingFreightType1749300000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS freight_type VARCHAR(20);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET freight_type = 'CONTAINER'
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM freight.booking_container bc WHERE bc.booking_id = b.id
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET freight_type = 'BULK'
|
||||
WHERE freight_type IS NULL
|
||||
AND b.cargo_type_id IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM freight.cargo_types ct
|
||||
WHERE ct.id = b.cargo_type_id AND ct.requires_director_approval = true
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings
|
||||
SET freight_type = 'CONTAINER'
|
||||
WHERE freight_type IS NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ALTER COLUMN cargo_type_id DROP NOT NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ALTER COLUMN freight_type SET NOT NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT chk_bookings_freight_type
|
||||
CHECK (freight_type IN ('CONTAINER', 'BULK'));
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings DROP CONSTRAINT IF EXISTS chk_bookings_freight_type;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS freight_type;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings SET cargo_type_id = (
|
||||
SELECT id FROM freight.cargo_types LIMIT 1
|
||||
) WHERE cargo_type_id IS NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ALTER COLUMN cargo_type_id SET NOT NULL;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddFanNumberToCompanies1749300000000 implements MigrationInterface {
|
||||
name = 'AddFanNumberToCompanies1749300000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// fan_number may already exist when CreateCompaniesModule ran with the full schema
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS fan_number varchar(16) NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS fan_number;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddContractSignatures1749400000000 implements MigrationInterface {
|
||||
name = 'AddContractSignatures1749400000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS contract_template_key VARCHAR(80),
|
||||
ADD COLUMN IF NOT EXISTS contract_generated_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS pricing_breakdown JSONB;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.booking_contract_signatures (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||
signer_role VARCHAR(20) NOT NULL,
|
||||
signer_user_id UUID,
|
||||
signer_display_name VARCHAR(200) NOT NULL,
|
||||
signed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
signature_file_id UUID REFERENCES freight.files(id) ON DELETE SET NULL,
|
||||
consent_text TEXT,
|
||||
ip_address VARCHAR(64),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT uq_booking_contract_signatures_role
|
||||
UNIQUE (booking_id, signer_role)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_booking_contract_signatures_booking_id
|
||||
ON freight.booking_contract_signatures(booking_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_contract_signatures;`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS pricing_breakdown,
|
||||
DROP COLUMN IF EXISTS contract_generated_at,
|
||||
DROP COLUMN IF EXISTS contract_template_key;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddTrainScheduling1749400000000 implements MigrationInterface {
|
||||
name = 'AddTrainScheduling1749400000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.wagon_types (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code VARCHAR(32) NOT NULL UNIQUE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
capacity_tons NUMERIC(10,3) NOT NULL,
|
||||
length_meters NUMERIC(10,3) NOT NULL,
|
||||
max_wagons_per_train INT NULL,
|
||||
supported_load_types TEXT[] NOT NULL DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.locomotives (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code VARCHAR(32) NOT NULL UNIQUE,
|
||||
name VARCHAR(100) NULL,
|
||||
max_pull_weight_tons NUMERIC(10,3) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE',
|
||||
available_from TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.train_sets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
locomotive_id UUID NOT NULL,
|
||||
total_weight_tons NUMERIC(10,3) NOT NULL,
|
||||
total_length_meters NUMERIC(10,3) NOT NULL,
|
||||
wagon_count INT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT fk_train_sets_locomotive FOREIGN KEY (locomotive_id)
|
||||
REFERENCES freight.locomotives(id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.train_set_wagons (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
train_set_id UUID NOT NULL,
|
||||
wagon_type_id UUID NOT NULL,
|
||||
sequence_no INT NOT NULL,
|
||||
capacity_tons NUMERIC(10,3) NOT NULL,
|
||||
length_meters NUMERIC(10,3) NOT NULL,
|
||||
assigned_weight_tons NUMERIC(10,3) NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT uq_train_set_wagons_sequence UNIQUE (train_set_id, sequence_no),
|
||||
CONSTRAINT fk_train_set_wagons_train_set FOREIGN KEY (train_set_id)
|
||||
REFERENCES freight.train_sets(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_train_set_wagons_wagon_type FOREIGN KEY (wagon_type_id)
|
||||
REFERENCES freight.wagon_types(id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.train_schedules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
train_set_id UUID NOT NULL UNIQUE,
|
||||
origin_station_id UUID NOT NULL,
|
||||
destination_station_id UUID NOT NULL,
|
||||
scheduled_departure_date TIMESTAMPTZ NOT NULL,
|
||||
scheduled_arrival_date TIMESTAMPTZ NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT fk_train_schedules_train_set FOREIGN KEY (train_set_id)
|
||||
REFERENCES freight.train_sets(id),
|
||||
CONSTRAINT fk_train_schedules_origin FOREIGN KEY (origin_station_id)
|
||||
REFERENCES freight.yards(id),
|
||||
CONSTRAINT fk_train_schedules_destination FOREIGN KEY (destination_station_id)
|
||||
REFERENCES freight.yards(id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.train_schedule_bookings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
train_schedule_id UUID NOT NULL,
|
||||
booking_id UUID NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT uq_train_schedule_booking UNIQUE (train_schedule_id, booking_id),
|
||||
CONSTRAINT fk_train_schedule_bookings_schedule FOREIGN KEY (train_schedule_id)
|
||||
REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_train_schedule_bookings_booking FOREIGN KEY (booking_id)
|
||||
REFERENCES freight.bookings(id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.wagon_booking_allocations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
train_set_wagon_id UUID NOT NULL,
|
||||
booking_id UUID NOT NULL,
|
||||
allocated_weight_tons NUMERIC(10,3) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT fk_wagon_booking_allocations_wagon FOREIGN KEY (train_set_wagon_id)
|
||||
REFERENCES freight.train_set_wagons(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_wagon_booking_allocations_booking FOREIGN KEY (booking_id)
|
||||
REFERENCES freight.bookings(id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_locomotives_status
|
||||
ON freight.locomotives(status);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_sets_status
|
||||
ON freight.train_sets(status);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_schedules_departure_status
|
||||
ON freight.train_schedules(scheduled_departure_date, status);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wagon_booking_allocations_booking
|
||||
ON freight.wagon_booking_allocations(booking_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_booking_allocations;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedule_bookings;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedules;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_set_wagons;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_sets;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.locomotives;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_types;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddCompanyIdToBookings1749500000000 implements MigrationInterface {
|
||||
name = 'AddCompanyIdToBookings1749500000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ALTER COLUMN customer_id DROP NOT NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS company_id UUID;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_company_id
|
||||
ON freight.bookings(company_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_bookings_company_id'
|
||||
) THEN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_company_id"
|
||||
FOREIGN KEY (company_id)
|
||||
REFERENCES freight.companies(id);
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_company_id";
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings SET customer_id = company_id WHERE customer_id IS NULL AND company_id IS NOT NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ALTER COLUMN customer_id SET NOT NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_bookings_company_id;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS company_id;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBlocksRoleToApprovalStep1749600000000 implements MigrationInterface {
|
||||
name = 'AddBlocksRoleToApprovalStep1749600000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_approval_step
|
||||
ADD COLUMN IF NOT EXISTS blocks_role VARCHAR(30) NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_approval_step
|
||||
DROP COLUMN IF EXISTS blocks_role;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Seed ITMLS US-06 approval chains if missing (standard + bulk).
|
||||
*/
|
||||
export class SeedDefaultApprovalRules1749700000000 implements MigrationInterface {
|
||||
name = 'SeedDefaultApprovalRules1749700000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.approval_rules
|
||||
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
|
||||
SELECT uuid_generate_v4(), false, 1, 'LINE_STAFF', 'Review & Approve', NULL, now(), now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.approval_rules
|
||||
WHERE requires_director_approval = false AND step_order = 1 AND deleted_at IS NULL
|
||||
);
|
||||
|
||||
INSERT INTO freight.approval_rules
|
||||
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
|
||||
SELECT uuid_generate_v4(), false, 2, 'DIRECTOR', 'Final Signature', 'LINE_STAFF', now(), now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.approval_rules
|
||||
WHERE requires_director_approval = false AND step_order = 2 AND deleted_at IS NULL
|
||||
);
|
||||
|
||||
INSERT INTO freight.approval_rules
|
||||
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
|
||||
SELECT uuid_generate_v4(), true, 1, 'DIRECTOR', 'Review & Approve', 'LINE_STAFF', now(), now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.approval_rules
|
||||
WHERE requires_director_approval = true AND step_order = 1 AND deleted_at IS NULL
|
||||
);
|
||||
|
||||
INSERT INTO freight.approval_rules
|
||||
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
|
||||
SELECT uuid_generate_v4(), true, 2, 'CEO', 'Final Signature', NULL, now(), now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.approval_rules
|
||||
WHERE requires_director_approval = true AND step_order = 2 AND deleted_at IS NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// Keep seeded rules on rollback to avoid breaking in-flight bookings.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner, TableIndex } from 'typeorm';
|
||||
|
||||
/**
|
||||
* shipping_lines was created without a unique index on code; seeder upserts require it.
|
||||
*/
|
||||
export class AddShippingLinesCodeUniqueIndex1749800000000 implements MigrationInterface {
|
||||
name = 'AddShippingLinesCodeUniqueIndex1749800000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const existing = await queryRunner.query(
|
||||
`SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND tablename = 'shipping_lines' AND indexdef ILIKE '%UNIQUE%code%' LIMIT 1`,
|
||||
);
|
||||
if (existing.length === 0) {
|
||||
await queryRunner.createIndex(
|
||||
'freight.shipping_lines',
|
||||
new TableIndex({
|
||||
name: 'UQ_shipping_lines_code',
|
||||
columnNames: ['code'],
|
||||
isUnique: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropIndex('freight.shipping_lines', 'UQ_shipping_lines_code');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* file_upload_settings / file_upload_fields entities had no migration; seeder requires both tables.
|
||||
*/
|
||||
export class CreateFileUploadSettingsTables1749900000000 implements MigrationInterface {
|
||||
name = 'CreateFileUploadSettingsTables1749900000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.file_upload_settings (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
code VARCHAR(128) NOT NULL,
|
||||
label VARCHAR(256) NOT NULL,
|
||||
description TEXT,
|
||||
entity VARCHAR(32) NOT NULL DEFAULT 'other',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_file_upload_settings_code"
|
||||
ON freight.file_upload_settings (code);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.file_upload_fields (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
setting_id UUID NOT NULL,
|
||||
file_key VARCHAR(128) NOT NULL,
|
||||
file_label VARCHAR(256) NOT NULL,
|
||||
help_text TEXT,
|
||||
is_required BOOLEAN NOT NULL DEFAULT false,
|
||||
is_multiple BOOLEAN NOT NULL DEFAULT false,
|
||||
max_files INTEGER NOT NULL DEFAULT 1,
|
||||
allowed_extensions TEXT[] NOT NULL DEFAULT '{}'::text[],
|
||||
max_size_mb INTEGER NOT NULL DEFAULT 10,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT "CHK_file_upload_fields_max_files" CHECK (max_files > 0),
|
||||
CONSTRAINT "CHK_file_upload_fields_max_size_mb" CHECK (max_size_mb > 0),
|
||||
CONSTRAINT "FK_file_upload_fields_setting"
|
||||
FOREIGN KEY (setting_id)
|
||||
REFERENCES freight.file_upload_settings(id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_file_upload_fields_setting_file_key"
|
||||
ON freight.file_upload_fields (setting_id, file_key);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.file_upload_fields CASCADE`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.file_upload_settings CASCADE`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddCompanyContactColumns1750000000000 implements MigrationInterface {
|
||||
name = 'AddCompanyContactColumns1750000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS contact_person_name VARCHAR(100);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS contact_person_phone VARCHAR(20);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS general_manager_name VARCHAR(100);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS general_manager_email VARCHAR(150);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS general_manager_phone VARCHAR(20);`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS general_manager_phone;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS general_manager_email;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS general_manager_name;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS contact_person_phone;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS contact_person_name;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Train entity gained extended fields; baseline trains table only had code/capacity/status/notes.
|
||||
*/
|
||||
export class AddTrainExtendedColumns1750000000000 implements MigrationInterface {
|
||||
name = 'AddTrainExtendedColumns1750000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.trains
|
||||
ADD COLUMN IF NOT EXISTS train_number VARCHAR(20),
|
||||
ADD COLUMN IF NOT EXISTS train_name VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS route_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS origin_station_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS destination_station_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS departure_time TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS arrival_time TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS locomotive_number VARCHAR(50),
|
||||
ADD COLUMN IF NOT EXISTS remarks TEXT;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_train_number"
|
||||
ON freight.trains (train_number)
|
||||
WHERE train_number IS NOT NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_train_number"`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.trains
|
||||
DROP COLUMN IF EXISTS remarks,
|
||||
DROP COLUMN IF EXISTS locomotive_number,
|
||||
DROP COLUMN IF EXISTS arrival_time,
|
||||
DROP COLUMN IF EXISTS departure_time,
|
||||
DROP COLUMN IF EXISTS destination_station_id,
|
||||
DROP COLUMN IF EXISTS origin_station_id,
|
||||
DROP COLUMN IF EXISTS route_id,
|
||||
DROP COLUMN IF EXISTS train_name,
|
||||
DROP COLUMN IF EXISTS train_number;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||
|
||||
export class CreateFacilitiesTable1750000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'facilities',
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'uuid',
|
||||
isPrimary: true,
|
||||
generationStrategy: 'uuid',
|
||||
default: 'gen_random_uuid()',
|
||||
},
|
||||
{
|
||||
name: 'code',
|
||||
type: 'varchar',
|
||||
length: '40',
|
||||
isUnique: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'varchar',
|
||||
length: '160',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'facility_type',
|
||||
type: 'varchar',
|
||||
length: '32',
|
||||
},
|
||||
{
|
||||
name: 'facility_status',
|
||||
type: 'varchar',
|
||||
length: '32',
|
||||
default: "'ACTIVE'",
|
||||
},
|
||||
{
|
||||
name: 'location_name',
|
||||
type: 'varchar',
|
||||
length: '200',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'country',
|
||||
type: 'varchar',
|
||||
length: '100',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'city',
|
||||
type: 'varchar',
|
||||
length: '100',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
type: 'text',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'latitude',
|
||||
type: 'numeric',
|
||||
precision: 10,
|
||||
scale: 8,
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'longitude',
|
||||
type: 'numeric',
|
||||
precision: 11,
|
||||
scale: 8,
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'capacity',
|
||||
type: 'numeric',
|
||||
precision: 14,
|
||||
scale: 3,
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'is_active',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
type: 'text',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'created_at',
|
||||
type: 'timestamp',
|
||||
default: 'CURRENT_TIMESTAMP',
|
||||
},
|
||||
{
|
||||
name: 'updated_at',
|
||||
type: 'timestamp',
|
||||
default: 'CURRENT_TIMESTAMP',
|
||||
},
|
||||
{
|
||||
name: 'deleted_at',
|
||||
type: 'timestamp',
|
||||
isNullable: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'freight.facilities',
|
||||
new TableIndex({
|
||||
name: 'idx_facilities_code',
|
||||
columnNames: ['code'],
|
||||
isUnique: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'freight.facilities',
|
||||
new TableIndex({
|
||||
name: 'idx_facilities_status',
|
||||
columnNames: ['facility_status'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.facilities');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn, TableForeignKey } from 'typeorm';
|
||||
|
||||
export class AddFacilityIdToWarehouses1750000000001 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const table = await queryRunner.getTable('freight.warehouses');
|
||||
if (!table) {
|
||||
// warehouses table doesn't exist yet, skip this migration
|
||||
return;
|
||||
}
|
||||
|
||||
const hasColumn = table.columns.some((col) => col.name === 'facility_id');
|
||||
if (hasColumn) {
|
||||
// Column already exists, skip
|
||||
return;
|
||||
}
|
||||
|
||||
await queryRunner.addColumn(
|
||||
'freight.warehouses',
|
||||
new TableColumn({
|
||||
name: 'facility_id',
|
||||
type: 'uuid',
|
||||
isNullable: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.warehouses',
|
||||
new TableForeignKey({
|
||||
columnNames: ['facility_id'],
|
||||
referencedColumnNames: ['id'],
|
||||
referencedTableName: 'facilities',
|
||||
referencedSchema: 'freight',
|
||||
onDelete: 'SET NULL',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const table = await queryRunner.getTable('freight.warehouses');
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
const foreignKey = table.foreignKeys.find((fk) => fk.columnNames.includes('facility_id'));
|
||||
if (foreignKey) {
|
||||
await queryRunner.dropForeignKey('freight.warehouses', foreignKey);
|
||||
}
|
||||
const hasColumn = table.columns.some((col) => col.name === 'facility_id');
|
||||
if (hasColumn) {
|
||||
await queryRunner.dropColumn('freight.warehouses', 'facility_id');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Proof of Delivery (customer pickup) capture on cargoes:
|
||||
* receiver name, delivered/picked-up timestamp, and delivery remarks.
|
||||
*/
|
||||
export class AddProofOfDeliveryToCargoes1750000000002 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const table = await queryRunner.getTable('freight.cargoes');
|
||||
if (!table) {
|
||||
// cargoes table doesn't exist yet, skip this migration
|
||||
return;
|
||||
}
|
||||
|
||||
const columnsToAdd = [
|
||||
{ name: 'receiver_name', type: 'varchar', isNullable: true },
|
||||
{ name: 'delivered_at', type: 'timestamp', isNullable: true },
|
||||
{ name: 'delivery_remarks', type: 'text', isNullable: true },
|
||||
];
|
||||
|
||||
const columnsToCreate = columnsToAdd.filter(
|
||||
(col) => !table.columns.some((c) => c.name === col.name),
|
||||
);
|
||||
|
||||
if (columnsToCreate.length > 0) {
|
||||
await queryRunner.addColumns(
|
||||
'freight.cargoes',
|
||||
columnsToCreate.map((col) => new TableColumn(col)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const table = await queryRunner.getTable('freight.cargoes');
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
|
||||
const columnNames = ['receiver_name', 'delivered_at', 'delivery_remarks'];
|
||||
const columnsToRemove = columnNames.filter((name) =>
|
||||
table.columns.some((c) => c.name === name),
|
||||
);
|
||||
|
||||
if (columnsToRemove.length > 0) {
|
||||
await queryRunner.dropColumns('freight.cargoes', columnsToRemove);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Batch 4.5 — warehouse inspection reports + inventory inspection status.
|
||||
*/
|
||||
export class AddWarehouseInspection1750000000003 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// inventory.inspection_status
|
||||
const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory');
|
||||
if (inventoryTable) {
|
||||
const hasColumn = inventoryTable.columns.some((col) => col.name === 'inspection_status');
|
||||
if (!hasColumn) {
|
||||
await queryRunner.addColumn(
|
||||
'freight.warehouse_inventory',
|
||||
new TableColumn({ name: 'inspection_status', type: 'varchar', length: '20', isNullable: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// warehouse_inspection_reports table
|
||||
const inspectionTable = await queryRunner.getTable('freight.warehouse_inspection_reports');
|
||||
if (!inspectionTable) {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'warehouse_inspection_reports',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
|
||||
{ name: 'inventory_id', type: 'uuid' },
|
||||
{ name: 'booking_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'customer_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'report_type', type: 'varchar', length: '32', default: "'INSPECTION'" },
|
||||
{ name: 'inspection_status', type: 'varchar', length: '20', default: "'NEEDS_REVIEW'" },
|
||||
{ name: 'has_damage', type: 'boolean', default: false },
|
||||
{ name: 'damage_description', type: 'text', isNullable: true },
|
||||
{ name: 'has_weight_loss', type: 'boolean', default: false },
|
||||
{ name: 'expected_weight', type: 'numeric', precision: 14, scale: 3, isNullable: true },
|
||||
{ name: 'actual_weight', type: 'numeric', precision: 14, scale: 3, isNullable: true },
|
||||
{ name: 'weight_loss', type: 'numeric', precision: 14, scale: 3, isNullable: true },
|
||||
{ name: 'weight_loss_unit', type: 'varchar', length: '12', isNullable: true },
|
||||
{ name: 'has_missing_items', type: 'boolean', default: false },
|
||||
{ name: 'missing_items_description', type: 'text', isNullable: true },
|
||||
{ name: 'remarks', type: 'text', isNullable: true },
|
||||
{ name: 'inspected_by_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'inspected_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
indices: [
|
||||
{ name: 'idx_wir_inventory', columnNames: ['inventory_id'] },
|
||||
{ name: 'idx_wir_booking', columnNames: ['booking_id'] },
|
||||
{ name: 'idx_wir_status', columnNames: ['inspection_status'] },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const inspectionTable = await queryRunner.getTable('freight.warehouse_inspection_reports');
|
||||
if (inspectionTable) {
|
||||
await queryRunner.dropTable('freight.warehouse_inspection_reports', true);
|
||||
}
|
||||
|
||||
const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory');
|
||||
if (inventoryTable) {
|
||||
const hasColumn = inventoryTable.columns.some((col) => col.name === 'inspection_status');
|
||||
if (hasColumn) {
|
||||
await queryRunner.dropColumn('freight.warehouse_inventory', 'inspection_status');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddRoutesAndExtendLocomotives1750100000000 implements MigrationInterface {
|
||||
name = 'AddRoutesAndExtendLocomotives1750100000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.locomotives
|
||||
ADD COLUMN IF NOT EXISTS locomotive_type VARCHAR(20) NOT NULL DEFAULT 'DIESEL',
|
||||
ADD COLUMN IF NOT EXISTS max_train_length_meters NUMERIC(10,3) NOT NULL DEFAULT 760,
|
||||
ADD COLUMN IF NOT EXISTS power_kw NUMERIC(10,3) NULL,
|
||||
ADD COLUMN IF NOT EXISTS traction_force_kn NUMERIC(10,3) NULL,
|
||||
ADD COLUMN IF NOT EXISTS max_speed_kmh NUMERIC(10,3) NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.locomotives
|
||||
SET status = 'OUT_OF_SERVICE'
|
||||
WHERE status = 'INACTIVE';
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.routes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(120) NOT NULL UNIQUE,
|
||||
origin_yard_id UUID NOT NULL REFERENCES freight.yards(id),
|
||||
destination_yard_id UUID NOT NULL REFERENCES freight.yards(id),
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.route_milestones (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
route_id UUID NOT NULL REFERENCES freight.routes(id) ON DELETE CASCADE,
|
||||
yard_id UUID NOT NULL REFERENCES freight.yards(id),
|
||||
sequence_no INT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT uq_route_milestones_route_sequence UNIQUE (route_id, sequence_no)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_routes_origin_yard_id
|
||||
ON freight.routes(origin_yard_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_routes_destination_yard_id
|
||||
ON freight.routes(destination_yard_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_routes_is_active
|
||||
ON freight.routes(is_active);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_route_milestones_route_id
|
||||
ON freight.route_milestones(route_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_route_milestones_yard_id
|
||||
ON freight.route_milestones(yard_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.route_milestones;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.routes;`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.locomotives
|
||||
DROP COLUMN IF EXISTS max_speed_kmh,
|
||||
DROP COLUMN IF EXISTS traction_force_kn,
|
||||
DROP COLUMN IF EXISTS power_kw,
|
||||
DROP COLUMN IF EXISTS max_train_length_meters,
|
||||
DROP COLUMN IF EXISTS locomotive_type;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.locomotives
|
||||
SET status = 'INACTIVE'
|
||||
WHERE status = 'OUT_OF_SERVICE';
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateFleetCrudTables1750100000000 implements MigrationInterface {
|
||||
name = 'CreateFleetCrudTables1750100000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS freight`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.wagons (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
wagon_number VARCHAR NOT NULL UNIQUE,
|
||||
wagon_type_id UUID NOT NULL,
|
||||
train_id UUID,
|
||||
sequence_number INT,
|
||||
tare_weight NUMERIC(10, 2) NOT NULL,
|
||||
max_payload_weight NUMERIC(10, 2) NOT NULL,
|
||||
status VARCHAR NOT NULL DEFAULT 'AVAILABLE',
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.containers (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
container_number VARCHAR NOT NULL UNIQUE,
|
||||
container_type_id UUID NOT NULL,
|
||||
wagon_id UUID,
|
||||
position INT,
|
||||
tare_weight NUMERIC(10, 2) NOT NULL,
|
||||
max_gross_weight NUMERIC(10, 2) NOT NULL,
|
||||
seal_number VARCHAR,
|
||||
status VARCHAR NOT NULL DEFAULT 'AVAILABLE',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.cargoes (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
cargo_reference VARCHAR NOT NULL UNIQUE,
|
||||
shipment_id UUID NOT NULL,
|
||||
container_id UUID NOT NULL,
|
||||
cargo_type_id UUID,
|
||||
description TEXT,
|
||||
quantity NUMERIC(12, 3) NOT NULL,
|
||||
weight NUMERIC(10, 2) NOT NULL,
|
||||
volume NUMERIC(10, 2),
|
||||
status VARCHAR NOT NULL DEFAULT 'PENDING',
|
||||
loaded_at TIMESTAMP,
|
||||
unloaded_at TIMESTAMP,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_wagons_train_id" ON freight.wagons (train_id)`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_containers_wagon_id" ON freight.containers (wagon_id)`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_cargoes_container_id" ON freight.cargoes (container_id)`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.wagons
|
||||
ADD CONSTRAINT "FK_wagons_train_id"
|
||||
FOREIGN KEY (train_id) REFERENCES freight.trains(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_wagon_type_id"
|
||||
FOREIGN KEY (wagon_type_id) REFERENCES freight.wagon_types(id);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.containers
|
||||
ADD CONSTRAINT "FK_containers_wagon_id"
|
||||
FOREIGN KEY (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.containers
|
||||
ADD CONSTRAINT "FK_containers_container_type_id"
|
||||
FOREIGN KEY (container_type_id) REFERENCES freight.container_types(id);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.cargoes
|
||||
ADD CONSTRAINT "FK_cargoes_container_id"
|
||||
FOREIGN KEY (container_id) REFERENCES freight.containers(id) ON DELETE RESTRICT;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.cargoes
|
||||
ADD CONSTRAINT "FK_cargoes_cargo_type_id"
|
||||
FOREIGN KEY (cargo_type_id) REFERENCES freight.cargo_types(id);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.cargoes CASCADE`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.containers CASCADE`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagons CASCADE`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddPhysicalWagonToTrainSetWagons1750200000000 implements MigrationInterface {
|
||||
name = 'AddPhysicalWagonToTrainSetWagons1750200000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_set_wagons
|
||||
ADD COLUMN IF NOT EXISTS physical_wagon_id UUID NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE constraint_schema = 'freight'
|
||||
AND table_name = 'train_set_wagons'
|
||||
AND constraint_name = 'fk_train_set_wagons_physical_wagon'
|
||||
) THEN
|
||||
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;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_set_wagons_physical_wagon
|
||||
ON freight.train_set_wagons(physical_wagon_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_set_wagons_physical_wagon;`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_set_wagons
|
||||
DROP CONSTRAINT IF EXISTS fk_train_set_wagons_physical_wagon;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_set_wagons
|
||||
DROP COLUMN IF EXISTS physical_wagon_id;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class SeedDefaultWagonTypes1750200000000 implements MigrationInterface {
|
||||
name = 'SeedDefaultWagonTypes1750200000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.wagon_types (
|
||||
code,
|
||||
name,
|
||||
capacity_tons,
|
||||
length_meters,
|
||||
max_wagons_per_train,
|
||||
supported_load_types,
|
||||
is_active
|
||||
)
|
||||
VALUES
|
||||
('NW7', 'Double deck sedan wagon', 22, 26.066, NULL, ARRAY['vehicles', 'sedan'], true),
|
||||
('NW5', 'Flat wagon (container)', 70, 14.000, 53, ARRAY['container', 'steel', 'machinery'], true),
|
||||
('PW2', 'Box wagon', 70, 17.066, 18, ARRAY['general cargo', 'break bulk'], true),
|
||||
('GW2', 'Tank wagon', 70, 12.228, 37, ARRAY['liquid', 'fuel'], true),
|
||||
('CW4', 'Gondola covered wagon', 70, 13.976, 37, ARRAY['covered bulk cargo'], true),
|
||||
('CW3', 'Gondola open wagon', 70, 13.976, NULL, ARRAY['open bulk cargo'], true),
|
||||
('KW2', 'Hopper covered wagon', 69, 16.466, NULL, ARRAY['bulk grains'], true),
|
||||
('KW3', 'Hopper open wagon', 70, 14.400, NULL, ARRAY['coal', 'bulk cargo'], true),
|
||||
('NW6', 'Flat wagon (long cargo)', 70, 18.560, NULL, ARRAY['long cargo'], true),
|
||||
('BW1', 'Refrigerated wagon', 38, 21.996, NULL, ARRAY['refrigerated cargo'], true)
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
capacity_tons = EXCLUDED.capacity_tons,
|
||||
length_meters = EXCLUDED.length_meters,
|
||||
max_wagons_per_train = EXCLUDED.max_wagons_per_train,
|
||||
supported_load_types = EXCLUDED.supported_load_types,
|
||||
is_active = true,
|
||||
deleted_at = NULL,
|
||||
updated_at = now();
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.wagon_types
|
||||
WHERE code IN ('NW7', 'NW5', 'PW2', 'GW2', 'CW4', 'CW3', 'KW2', 'KW3', 'NW6', 'BW1');
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddCurrentLocationToWagons1750300000000 implements MigrationInterface {
|
||||
name = 'AddCurrentLocationToWagons1750300000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons
|
||||
ADD COLUMN IF NOT EXISTS current_location_yard_id UUID NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE constraint_schema = 'freight'
|
||||
AND table_name = 'wagons'
|
||||
AND constraint_name = 'FK_wagons_current_location_yard_id'
|
||||
) THEN
|
||||
ALTER TABLE freight.wagons
|
||||
ADD CONSTRAINT "FK_wagons_current_location_yard_id"
|
||||
FOREIGN KEY (current_location_yard_id)
|
||||
REFERENCES freight.yards(id)
|
||||
ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_wagons_current_location_yard_id"
|
||||
ON freight.wagons(current_location_yard_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_wagons_current_location_yard_id";`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons
|
||||
DROP CONSTRAINT IF EXISTS "FK_wagons_current_location_yard_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons
|
||||
DROP COLUMN IF EXISTS current_location_yard_id;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddRouteToTrainSchedules1750300000000 implements MigrationInterface {
|
||||
name = 'AddRouteToTrainSchedules1750300000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS route_id UUID NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'fk_train_schedules_route'
|
||||
) THEN
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD CONSTRAINT fk_train_schedules_route
|
||||
FOREIGN KEY (route_id) REFERENCES freight.routes(id);
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_schedules_route_id
|
||||
ON freight.train_schedules(route_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_schedules_route_id;`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP CONSTRAINT IF EXISTS fk_train_schedules_route;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP COLUMN IF EXISTS route_id;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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,214 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
type FleetRow = {
|
||||
code: string;
|
||||
name: string;
|
||||
count: number;
|
||||
start: number;
|
||||
end: number;
|
||||
capacityTons: number;
|
||||
tareWeight: number;
|
||||
lengthMeters: number;
|
||||
supportedLoadTypes: string[];
|
||||
};
|
||||
|
||||
const FLEET: FleetRow[] = [
|
||||
{
|
||||
code: 'PW2',
|
||||
name: 'Box wagon',
|
||||
count: 220,
|
||||
start: 1,
|
||||
end: 220,
|
||||
capacityTons: 70,
|
||||
tareWeight: 25.2,
|
||||
lengthMeters: 17.066,
|
||||
supportedLoadTypes: ['BULK', 'GENERAL_CARGO', 'BAGGED_CARGO', 'BOXED_CARGO'],
|
||||
},
|
||||
{
|
||||
code: 'CW4',
|
||||
name: 'Gondola wagon covered',
|
||||
count: 110,
|
||||
start: 221,
|
||||
end: 330,
|
||||
capacityTons: 70,
|
||||
tareWeight: 24.8,
|
||||
lengthMeters: 13.976,
|
||||
supportedLoadTypes: ['CONTAINER'],
|
||||
},
|
||||
{
|
||||
code: 'CW3',
|
||||
name: 'Gondola wagon',
|
||||
count: 20,
|
||||
start: 331,
|
||||
end: 350,
|
||||
capacityTons: 70,
|
||||
tareWeight: 23.4,
|
||||
lengthMeters: 13.976,
|
||||
supportedLoadTypes: ['BULK', 'COAL', 'ORE'],
|
||||
},
|
||||
{
|
||||
code: 'KW2',
|
||||
name: 'Hopper wagon covered',
|
||||
count: 20,
|
||||
start: 351,
|
||||
end: 370,
|
||||
capacityTons: 69,
|
||||
tareWeight: 25.2,
|
||||
lengthMeters: 16.466,
|
||||
supportedLoadTypes: ['BULK', 'GRAIN'],
|
||||
},
|
||||
{
|
||||
code: 'KW3',
|
||||
name: 'Hopper wagon',
|
||||
count: 20,
|
||||
start: 371,
|
||||
end: 390,
|
||||
capacityTons: 70,
|
||||
tareWeight: 24,
|
||||
lengthMeters: 14.4,
|
||||
supportedLoadTypes: ['BULK', 'COAL'],
|
||||
},
|
||||
{
|
||||
code: 'NW5',
|
||||
name: 'Flat wagon container',
|
||||
count: 550,
|
||||
start: 391,
|
||||
end: 940,
|
||||
capacityTons: 70,
|
||||
tareWeight: 0,
|
||||
lengthMeters: 14,
|
||||
supportedLoadTypes: ['CONTAINER'],
|
||||
},
|
||||
];
|
||||
|
||||
const wagonNumber = (sequence: number) => `ER${String(sequence).padStart(4, '0')}`;
|
||||
|
||||
export class SeedEdRWagonFleet1750400000000 implements MigrationInterface {
|
||||
name = 'SeedEdRWagonFleet1750400000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.wagon_types
|
||||
SET name = 'Flat wagon container',
|
||||
capacity_tons = 70,
|
||||
length_meters = 14.000,
|
||||
supported_load_types = ARRAY['CONTAINER'],
|
||||
max_wagons_per_train = 53,
|
||||
is_active = true,
|
||||
deleted_at = NULL,
|
||||
updated_at = now()
|
||||
WHERE code = 'NW5';
|
||||
`);
|
||||
|
||||
const [defaultLocation] = await queryRunner.query(`
|
||||
SELECT id
|
||||
FROM freight.yards
|
||||
WHERE code IN ('DJIBOUTI', 'DJIB_PORT', 'NAGAD')
|
||||
OR lower(label) LIKE '%djibouti%'
|
||||
ORDER BY
|
||||
CASE code
|
||||
WHEN 'DJIBOUTI' THEN 1
|
||||
WHEN 'DJIB_PORT' THEN 2
|
||||
WHEN 'NAGAD' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
display_order ASC
|
||||
LIMIT 1;
|
||||
`);
|
||||
const defaultLocationYardId = defaultLocation?.id ?? null;
|
||||
|
||||
for (const row of FLEET) {
|
||||
await queryRunner.query(
|
||||
`
|
||||
INSERT INTO freight.wagon_types (
|
||||
code,
|
||||
name,
|
||||
capacity_tons,
|
||||
length_meters,
|
||||
max_wagons_per_train,
|
||||
supported_load_types,
|
||||
is_active
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::text[], true)
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
capacity_tons = EXCLUDED.capacity_tons,
|
||||
length_meters = EXCLUDED.length_meters,
|
||||
max_wagons_per_train = EXCLUDED.max_wagons_per_train,
|
||||
supported_load_types = EXCLUDED.supported_load_types,
|
||||
is_active = true,
|
||||
deleted_at = NULL,
|
||||
updated_at = now();
|
||||
`,
|
||||
[
|
||||
row.code,
|
||||
row.name,
|
||||
row.capacityTons,
|
||||
row.lengthMeters,
|
||||
row.supportedLoadTypes.includes('CONTAINER') ? 53 : 37,
|
||||
row.supportedLoadTypes,
|
||||
],
|
||||
);
|
||||
|
||||
const [typeRecord] = await queryRunner.query(
|
||||
`SELECT id FROM freight.wagon_types WHERE code = $1 LIMIT 1;`,
|
||||
[row.code],
|
||||
);
|
||||
|
||||
if (!typeRecord?.id) {
|
||||
throw new Error(`wagon_type_seed_failed:${row.code}`);
|
||||
}
|
||||
|
||||
if (row.end - row.start + 1 !== row.count) {
|
||||
throw new Error(`wagon_range_mismatch:${row.code}`);
|
||||
}
|
||||
|
||||
for (let sequence = row.start; sequence <= row.end; sequence += 1) {
|
||||
await queryRunner.query(
|
||||
`
|
||||
INSERT INTO freight.wagons (
|
||||
wagon_number,
|
||||
wagon_type_id,
|
||||
tare_weight,
|
||||
max_payload_weight,
|
||||
current_location_yard_id,
|
||||
status,
|
||||
notes
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (wagon_number) DO UPDATE SET
|
||||
wagon_type_id = EXCLUDED.wagon_type_id,
|
||||
tare_weight = EXCLUDED.tare_weight,
|
||||
max_payload_weight = EXCLUDED.max_payload_weight,
|
||||
current_location_yard_id = CASE
|
||||
WHEN freight.wagons.train_id IS NULL THEN EXCLUDED.current_location_yard_id
|
||||
ELSE freight.wagons.current_location_yard_id
|
||||
END,
|
||||
status = CASE
|
||||
WHEN freight.wagons.train_id IS NULL THEN EXCLUDED.status
|
||||
ELSE freight.wagons.status
|
||||
END,
|
||||
notes = EXCLUDED.notes,
|
||||
updated_at = now();
|
||||
`,
|
||||
[
|
||||
wagonNumber(sequence),
|
||||
typeRecord.id,
|
||||
row.tareWeight,
|
||||
row.capacityTons,
|
||||
defaultLocationYardId,
|
||||
defaultLocationYardId ? 'IMPORT_READY' : 'AVAILABLE',
|
||||
`Seeded Ethio-Djibouti Railway ${row.code} fleet record.`,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.wagons
|
||||
WHERE wagon_number BETWEEN 'ER0001' AND 'ER0940';
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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,103 @@
|
||||
import {
|
||||
MigrationInterface,
|
||||
QueryRunner,
|
||||
Table,
|
||||
TableIndex,
|
||||
TableForeignKey,
|
||||
} from "typeorm";
|
||||
|
||||
export class CreateCompanyProfiles1752000000000 implements MigrationInterface {
|
||||
name = "CreateCompanyProfiles1752000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: "freight",
|
||||
name: "company_profiles",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
type: "uuid",
|
||||
isPrimary: true,
|
||||
generationStrategy: "uuid",
|
||||
default: "gen_random_uuid()",
|
||||
},
|
||||
{ name: "company_id", type: "uuid" },
|
||||
{ name: "type", type: "varchar", length: "32" },
|
||||
{ name: "reference", type: "varchar", length: "20", isUnique: true },
|
||||
{
|
||||
name: "status",
|
||||
type: "varchar",
|
||||
length: "32",
|
||||
default: "'active'",
|
||||
},
|
||||
{
|
||||
name: "business_license",
|
||||
type: "varchar",
|
||||
length: "100",
|
||||
isNullable: true,
|
||||
},
|
||||
{ name: "attributes", type: "jsonb", isNullable: true },
|
||||
{ name: "created_at", type: "timestamptz", default: "now()" },
|
||||
{ name: "updated_at", type: "timestamptz", default: "now()" },
|
||||
{ name: "deleted_at", type: "timestamptz", isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createForeignKey(
|
||||
"freight.company_profiles",
|
||||
new TableForeignKey({
|
||||
columnNames: ["company_id"],
|
||||
referencedTableName: "companies",
|
||||
referencedSchema: "freight",
|
||||
referencedColumnNames: ["id"],
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
"freight.company_profiles",
|
||||
new TableIndex({ columnNames: ["company_id"] }),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
"freight.company_profiles",
|
||||
new TableIndex({ columnNames: ["type"] }),
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ex START WITH 1`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_im START WITH 1`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ffe START WITH 1`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_fwj START WITH 1`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_tr START WITH 1`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable("freight.company_profiles");
|
||||
await queryRunner.query(
|
||||
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_ex`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_im`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_ffe`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_fwj`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_tr`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class MoveBusinessLicenseToProfile1752000000001
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'MoveBusinessLicenseToProfile1752000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.company_profiles cp
|
||||
SET business_license = c.business_license
|
||||
FROM freight.companies c
|
||||
WHERE cp.company_id = c.id AND c.business_license IS NOT NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.companies DROP COLUMN IF EXISTS business_license`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.companies ADD COLUMN business_license varchar(100) NULL`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.companies c
|
||||
SET business_license = cp.business_license
|
||||
FROM (
|
||||
SELECT DISTINCT ON (cp2.company_id)
|
||||
cp2.company_id, cp2.business_license
|
||||
FROM freight.company_profiles cp2
|
||||
WHERE cp2.business_license IS NOT NULL
|
||||
ORDER BY cp2.company_id, cp2.created_at
|
||||
) cp
|
||||
WHERE cp.company_id = c.id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateVehiclesTable1770000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'vehicles' AND table_schema = 'freight') THEN
|
||||
CREATE TABLE freight.vehicles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
plate_number VARCHAR NOT NULL UNIQUE,
|
||||
registration_number VARCHAR NOT NULL UNIQUE,
|
||||
vehicle_type VARCHAR NOT NULL,
|
||||
manufacturer VARCHAR NOT NULL,
|
||||
model VARCHAR NOT NULL,
|
||||
year INTEGER NOT NULL,
|
||||
fuel_type VARCHAR NOT NULL,
|
||||
capacity NUMERIC NOT NULL,
|
||||
status VARCHAR DEFAULT 'ACTIVE' NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at TIMESTAMP NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_vehicles_plate_number ON freight.vehicles(plate_number);
|
||||
CREATE INDEX idx_vehicles_registration_number ON freight.vehicles(registration_number);
|
||||
CREATE INDEX idx_vehicles_status ON freight.vehicles(status);
|
||||
CREATE INDEX idx_vehicles_vehicle_type ON freight.vehicles(vehicle_type);
|
||||
CREATE INDEX idx_vehicles_manufacturer ON freight.vehicles(manufacturer);
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.vehicles CASCADE;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class CreatePaymentTable1780639311366 implements MigrationInterface {
|
||||
name = "CreatePaymentTable1780639311366";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE freight.payments_type_enum AS ENUM ('booking');
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE freight.payments_method_enum AS ENUM ('telebirr', 'cbe-birr', 'ebirr');
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE freight.payments_currency_enum AS ENUM ('ETB', 'USD');
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE freight.payments_status_enum AS ENUM (
|
||||
'action-required',
|
||||
'processing',
|
||||
'success',
|
||||
'failed',
|
||||
'canceled',
|
||||
'refunded'
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.payments (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
|
||||
ref_id varchar(255) NOT NULL,
|
||||
|
||||
type freight.payments_type_enum NOT NULL,
|
||||
|
||||
method freight.payments_method_enum NOT NULL,
|
||||
|
||||
currency freight.payments_currency_enum NOT NULL,
|
||||
|
||||
amount numeric NOT NULL,
|
||||
|
||||
raw_initiation jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
client_action json,
|
||||
|
||||
merchant_order_id varchar(255) NOT NULL,
|
||||
|
||||
transaction_id varchar(255),
|
||||
|
||||
status freight.payments_status_enum NOT NULL DEFAULT 'action-required',
|
||||
|
||||
paid_at date,
|
||||
|
||||
refunded_at date,
|
||||
|
||||
expires_at date,
|
||||
|
||||
failer_code varchar(30),
|
||||
|
||||
failer_message varchar(255),
|
||||
|
||||
reason varchar(255),
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT PK_payments PRIMARY KEY (id),
|
||||
|
||||
CONSTRAINT UQ_payments_merchant_order_id UNIQUE (merchant_order_id),
|
||||
|
||||
CONSTRAINT UQ_payments_transaction_id UNIQUE (transaction_id)
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP TABLE IF EXISTS freight.payments;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DROP TYPE IF EXISTS freight.payments_status_enum;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DROP TYPE IF EXISTS freight.payments_currency_enum;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DROP TYPE IF EXISTS freight.payments_method_enum;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DROP TYPE IF EXISTS freight.payments_type_enum;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AlterClientActionToJsonb1780639978834 implements MigrationInterface {
|
||||
name = "AlterClientActionToJsonb1780639978834";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.payments
|
||||
ALTER COLUMN client_action TYPE jsonb
|
||||
USING client_action::jsonb;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.payments
|
||||
ALTER COLUMN client_action DROP DEFAULT;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.payments
|
||||
ALTER COLUMN client_action TYPE json
|
||||
USING client_action::json;
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class UpdatePaymentTimestamp1780644945086 implements MigrationInterface {
|
||||
name = "UpdatePaymentTimestamp1780644945086";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.payments
|
||||
ALTER COLUMN refunded_at TYPE timestamp
|
||||
USING refunded_at::timestamp;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.payments
|
||||
ALTER COLUMN expires_at TYPE timestamp
|
||||
USING expires_at::timestamp;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.payments
|
||||
ALTER COLUMN refunded_at TYPE timestamptz
|
||||
USING refunded_at::timestamptz;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.payments
|
||||
ALTER COLUMN expires_at TYPE timestamptz
|
||||
USING expires_at::timestamptz;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user