diff --git a/.github/scripts/scan.js b/.github/scripts/scan.js index 7b1924b3f..2526dff2b 100644 --- a/.github/scripts/scan.js +++ b/.github/scripts/scan.js @@ -35,23 +35,50 @@ const CONFIG = { "tailwind.config.js", "tailwind.config.ts", "tailwind.config.cjs", + "tailwind.config.mjs", "tailwind.js", "postcss.config.js", + "postcss.config.ts", "postcss.config.mjs", "postcss.config.cjs", "babel.config.js", + "babel.config.ts", + "babel.config.mjs", "babel.config.cjs", "next.config.js", + "next.config.ts", "next.config.mjs", "next.config.cjs", + "eslint.config.js", + "eslint.config.ts", + "eslint.config.mjs", + "eslint.config.cjs", "astro.config.mjs", "astro.config.js", "vite.config.js", "vite.config.ts", + "vite.config.mjs", + "vite.config.cjs", "webpack.config.js", "webpack.mix.js", + "svelte.config.js", + "nuxt.config.ts", ], + // Font files — the campaign appends JS payloads to web fonts, which are + // never executed directly but are fetched by the build and used as a + // staging blob. Binary formats, so they are read as latin1. + fontExtensions: [".woff", ".woff2", ".ttf", ".otf", ".eot"], + + // Minimum number of \uXXXX escape sequences in one file before it is + // treated as deliberately obfuscated. Legitimate source files use a + // handful at most; PolinRider samples carry 400–600. + maxLegitUnicodeEscapes: 20, + + // Any single line longer than this inside a config file means code was + // appended past the real export block. + maxLegitConfigLineLength: 1000, + // Files intentionally containing malware indicators for scanner logic/tests. // These filenames are skipped before malware rules are evaluated. ignoredFilenames: ["scan.js"], @@ -187,14 +214,166 @@ const RULES = [ }, }, + { + id: "POLINRIDER-018", + severity: "CRITICAL", + description: + "Unicode-escape string obfuscation — the payload writes plain ASCII literals such as require and https as \\u0068\\u0074\\u0074\\u0070 so that grep, code review, and GitHub diff search cannot see the API calls it makes", + test(content) { + // Only printable-ASCII escapes count. Minified vendor bundles legitimately + // carry hundreds of \uXXXX escapes, but those decode to emoji, diacritics + // and CJK ranges — escaping ASCII that could have been written literally + // has no purpose other than hiding it from a reader. + const asciiEscapes = (content.match(/\\u00[0-7][0-9a-fA-F]/g) || []).filter( + (e) => { + const code = parseInt(e.slice(2), 16); + return code >= 0x20 && code <= 0x7e; + }, + ); + return asciiEscapes.length >= CONFIG.maxLegitUnicodeEscapes + ? [ + `${asciiEscapes.length} printable-ASCII \\uXXXX escapes (threshold: ${CONFIG.maxLegitUnicodeEscapes})`, + ] + : []; + }, + }, + + { + id: "POLINRIDER-019", + severity: "CRITICAL", + description: + "Ethereum dead-drop C2 resolver — the attacker wallet's latest transaction encodes the C2 IP addresses in the 'to' field, so the C2 can be rotated without touching the implant", + test(_content, _filePath, _lines, decoded) { + const iocs = [ + "0xa322e5f3d311d3080e6f0121063e9adc2490ef1a", + "eth.blockscout.com", + "eth_getBlockByNumber", + "eth_getTransactionCount", + "eth_blockNumber", + "ethereum-rpc.publicnode.com", + "eth-mainnet.public.blastapi.io", + "1rpc.io/eth", + "eth.drpc.org", + ]; + const hay = decoded.toLowerCase(); + return iocs.filter((i) => hay.includes(i)); + }, + }, + + { + id: "POLINRIDER-020", + severity: "CRITICAL", + description: + "Remote code execution stager — fetches a payload over HTTP, XOR-decrypts it, eval()s it in-process and re-launches it as a detached hidden node -e child process that survives the build exiting", + test(_content, _filePath, _lines, decoded) { + const hits = []; + if (/spawn\s*\(\s*["']node["']\s*,\s*\[\s*["']-e["']/.test(decoded)) + hits.push('spawn("node", ["-e", ])'); + if (/detached\s*:\s*(!0|true)/.test(decoded)) + hits.push("detached child process"); + if (/stdio\s*:\s*["']ignore["']/.test(decoded)) + hits.push('stdio:"ignore" (output suppressed)'); + if (/\beval\s*\(\s*\w+\s*\+/.test(decoded)) + hits.push("eval() of concatenated remote string"); + if (/x-payload-b64/i.test(decoded)) + hits.push("x-payload-b64 C2 response header"); + // Only report when this is a genuine stager, not an isolated keyword. + return hits.length >= 2 ? hits : []; + }, + }, + + { + id: "POLINRIDER-021", + severity: "CRITICAL", + description: + "Campaign marker + Node internals capture via dot notation — the implant stores its victim/campaign ID and re-exposes require/module on globalThis so later stages can load native modules from inside an ES module", + test(_content, _filePath, _lines, decoded) { + const hits = []; + const marker = decoded.match( + /global\s*\.\s*[a-zA-Z_$]\w*\s*=\s*["']([A-Z]{0,2}\d[\d-]{3,})["']/, + ); + if (marker) hits.push(`campaign marker: "${marker[1]}"`); + if (/global\s*\.\s*\w+\s*=\s*require\b/.test(decoded)) + hits.push("global. = require"); + if (/global\s*\.\s*\w+\s*=\s*module\b/.test(decoded)) + hits.push("global. = module"); + return hits; + }, + }, + + { + id: "POLINRIDER-022", + severity: "CRITICAL", + description: + "Web font file carrying an executable payload — .woff/.woff2 files are treated as opaque binary assets by reviewers and linters, so the campaign uses them to smuggle JavaScript past code review", + test(content, filePath) { + const ext = path.extname(filePath).toLowerCase(); + if (!CONFIG.fontExtensions.includes(ext)) return []; + + const hits = []; + const magic = content.slice(0, 4); + const expected = { ".woff": "wOFF", ".woff2": "wOF2", ".otf": "OTTO" }; + if (expected[ext] && magic !== expected[ext]) { + hits.push( + `bad magic bytes: expected "${expected[ext]}", got "${magic.replace(/[^\x20-\x7e]/g, ".")}"`, + ); + } + const codeMarkers = [ + "require(", + "eval(", + "child_process", + "global.", + "createRequire", + "process.env", + ]; + const found = codeMarkers.filter((m) => content.includes(m)); + if (found.length > 0) { + hits.push(`embedded JS markers: ${found.join(", ")}`); + } + return hits; + }, + }, + + { + id: "POLINRIDER-023", + severity: "CRITICAL", + description: + ".vscode/tasks.json configured to auto-execute on folder open — gives the campaign code execution the moment a developer opens the repo in VS Code, before any build or install command is run", + test(content, filePath) { + if (!/\.vscode[\\/]tasks\.json$/.test(filePath.replace(/\\/g, "/"))) + return []; + const hits = []; + if (/"runOn"\s*:\s*"folderOpen"/.test(content)) + hits.push('runOn: "folderOpen" (executes without user action)'); + const cmd = content.match(/"command"\s*:\s*"([^"]{0,120})"/); + if (cmd && /node|curl|wget|powershell|bash|-e\b|eval/i.test(cmd[1])) + hits.push(`command: "${cmd[1]}"`); + return hits.length >= 1 ? hits : []; + }, + }, + + { + id: "POLINRIDER-024", + severity: "HIGH", + description: + ".gitignore lists this campaign's persistence artifacts — the implant appends these entries so its own dropped files never appear in git status and the developer never sees them", + test(content, filePath) { + if (path.basename(filePath) !== ".gitignore") return []; + const lines = content.split("\n").map((l) => l.trim()); + return CONFIG.persistenceArtifacts.filter((a) => lines.includes(a)).map( + (a) => `.gitignore hides "${a}"`, + ); + }, + }, + // ── 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) { + "Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, BSC and Ethereum as dead-drop C2 resolvers", + test(_content, _filePath, _lines, decoded) { const endpoints = [ "trongrid.io", "aptoslabs.com", @@ -202,7 +381,7 @@ const RULES = [ "bsc-rpc.publicnode.com", "eth_getTransactionByHash", ]; - return endpoints.filter((e) => content.includes(e)); + return endpoints.filter((e) => decoded.includes(e)); }, }, @@ -210,11 +389,10 @@ const RULES = [ 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"] - : []; + "Hidden process spawn with windowsHide — used by InvisibleFerret / BeaverTail stager to launch detached Node.js child processes invisibly. Matches both true and its minified form !0", + test(_content, _filePath, _lines, decoded) { + const m = decoded.match(/windowsHide\s*:\s*(true|!0)/); + return m ? [`windowsHide:${m[1]} found`] : []; }, }, @@ -237,7 +415,10 @@ const RULES = [ 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) { + test(_content, filePath, lines) { + // Binary assets contain long runs of 0x20 as padding — not an indicator. + if (CONFIG.fontExtensions.includes(path.extname(filePath).toLowerCase())) + return []; const hits = []; lines.forEach((line, i) => { const spaceRun = line.match(/\s{100,}/); @@ -269,6 +450,27 @@ const RULES = [ }, }, + { + id: "POLINRIDER-025", + severity: "HIGH", + description: + "Minified code appended past the end of a config file — real config files are hand-written and line-wrapped; a single multi-thousand-character line means a payload was concatenated onto the original file", + test(_content, filePath, lines) { + const base = path.basename(filePath).toLowerCase(); + if (!CONFIG.targetedFilenames.some((f) => f.toLowerCase() === base)) + return []; + const hits = []; + lines.forEach((line, i) => { + if (line.length > CONFIG.maxLegitConfigLineLength) { + hits.push( + `line ${i + 1}: ${line.length} chars (threshold: ${CONFIG.maxLegitConfigLineLength})`, + ); + } + }); + return hits; + }, + }, + { id: "POLINRIDER-014", severity: "HIGH", @@ -330,25 +532,58 @@ const RULES = [ // ─── Scanner Engine ──────────────────────────────────────────────────────────── +/** + * Resolve \uXXXX and \xXX escapes so string-matching rules see the real API + * calls. The campaign writes every literal as escapes specifically to defeat + * grep, so rules that match on plain text must run against this form. + * The decoded text is appended to the original rather than replacing it, so a + * rule can still match either representation with one pass. + */ +function deobfuscate(content) { + if (!/\\[ux]/.test(content)) return content; + const decoded = content + .replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (m, h) => { + try { + return String.fromCodePoint(parseInt(h, 16)); + } catch { + return m; + } + }) + .replace(/\\u([0-9a-fA-F]{4})/g, (_m, h) => + String.fromCharCode(parseInt(h, 16)), + ) + .replace(/\\x([0-9a-fA-F]{2})/g, (_m, h) => + String.fromCharCode(parseInt(h, 16)), + ); + return content + "\n/* --- deobfuscated --- */\n" + decoded; +} + function scanFile(filePath) { if (shouldIgnoreFile(filePath)) { return { filePath, findings: [], skipped: true }; } + // Fonts are binary. latin1 maps bytes 1:1 to chars, so magic-byte checks and + // ASCII payload searches both work without mangling the content. + const isBinary = CONFIG.fontExtensions.includes( + path.extname(filePath).toLowerCase(), + ); + let content; try { - content = fs.readFileSync(filePath, "utf8"); + content = fs.readFileSync(filePath, isBinary ? "latin1" : "utf8"); } catch (err) { return { filePath, error: err.message, findings: [] }; } const lines = content.split("\n"); + const decoded = deobfuscate(content); const findings = []; for (const rule of RULES) { let matches; try { - matches = rule.test(content, filePath, lines); + matches = rule.test(content, filePath, lines, decoded); } catch (err) { matches = [`[rule error: ${err.message}]`]; } @@ -388,6 +623,7 @@ function walkDir(dir, results = []) { } else if (entry.isFile() && !shouldIgnoreFile(full)) { const ext = path.extname(entry.name).toLowerCase(); const base = entry.name.toLowerCase(); + const rel = full.replace(/\\/g, "/"); // Scan all JS/TS config files + any file matching a targeted name const isTargetedName = CONFIG.targetedFilenames.some( @@ -399,8 +635,18 @@ function walkDir(dir, results = []) { const isJsLike = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes( ext, ); + const isFont = CONFIG.fontExtensions.includes(ext); + const isGitignore = base === ".gitignore"; + const isVsCodeTask = /\.vscode\/tasks\.json$/.test(rel); - if (isTargetedName || isPersistenceArtifact || isJsLike) { + if ( + isTargetedName || + isPersistenceArtifact || + isJsLike || + isFont || + isGitignore || + isVsCodeTask + ) { results.push(full); } } @@ -532,10 +778,141 @@ function printReport(allResults, { json = false, outputFile = null } = {}) { return infected.length > 0; } +// ─── Self-test ───────────────────────────────────────────────────────────────── + +/** + * Runs the rule set against synthetic samples. Guards the two properties that + * matter: the live payload is still caught, and minified vendor bundles that + * legitimately contain \uXXXX escapes are still not flagged. + * Run with: node scan.js --self-test + */ +function selfTest() { + const os = require("os"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "polinrider-selftest-")); + const write = (name, body) => { + const p = path.join(tmp, name); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, body); + return p; + }; + + const esc = (s) => + [...s].map((c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0")).join(""); + + const cases = []; + + // 1. The live payload shape: ASCII-escaped strings + ETH dead drop + stager. + cases.push({ + name: "infected postcss.config.js", + file: write( + "infected/postcss.config.js", + `export default { plugins: {} };` + + " ".repeat(300) + + `global.i="A8-4299";global.r=require;global.m=module;` + + `const http=require("${esc("http")}"),{spawn}=require("${esc("child_process")}");` + + `S="0xa322E5f3D311D3080e6f0121063e9aDC2490Ef1a".toLowerCase(),` + + `I="${esc("https://eth.blockscout.com/api")}";` + + `rc(t,"${esc("eth_getBlockByNumber")}");eval(r+o);` + + `spawn("node",["-e",r+o],{detached:!0,stdio:"${esc("ignore")}",windowsHide:!0});`, + ), + expect: true, + }); + + // 2. Clean config — must stay silent. + cases.push({ + name: "clean postcss.config.js", + file: write( + "clean/postcss.config.js", + "export default {\n plugins: {\n tailwindcss: {},\n autoprefixer: {},\n },\n};\n", + ), + expect: false, + }); + + // 3. Minified vendor bundle with many non-ASCII escapes — must stay silent. + cases.push({ + name: "minified vendor bundle", + file: write( + "vendor/emoji.min.js", + "var e=[" + + Array.from({ length: 400 }, (_, i) => `"\\ud83d\\ude${(i % 90) + 10}"`).join(",") + + "];", + ), + expect: false, + }); + + // 4. Font carrying an appended JS payload. + cases.push({ + name: "trojanised .woff2", + file: write( + "fonts/bad.woff2", + "wOF2" + "".repeat(64) + 'require("child_process");global.x=1;eval(a+b);', + ), + expect: true, + }); + + // 5. Clean font — correct magic, no code markers. + cases.push({ + name: "clean .woff2", + file: write("fonts/good.woff2", "wOF2" + "".repeat(200)), + expect: false, + }); + + // 6. .gitignore hiding persistence artifacts. + cases.push({ + name: ".gitignore with persistence artifacts", + file: write( + "ignore/.gitignore", + "node_modules/\ntemp_auto_push.bat\nbranch_structure.json\n", + ), + expect: true, + }); + + // 7. VS Code task auto-executing on folder open. + cases.push({ + name: ".vscode/tasks.json auto-exec", + file: write( + "vscode/.vscode/tasks.json", + JSON.stringify({ + version: "2.0.0", + tasks: [ + { + label: "build", + command: "node -e require('http')", + runOptions: { runOn: "folderOpen" }, + }, + ], + }), + ), + expect: true, + }); + + let failed = 0; + for (const c of cases) { + const { findings } = scanFile(c.file); + const detected = findings.length > 0; + const ok = detected === c.expect; + if (!ok) failed++; + const ids = findings.map((f) => f.id).join(", ") || "none"; + console.log( + ` ${ok ? `${ANSI.green}PASS${ANSI.reset}` : `${ANSI.red}FAIL${ANSI.reset}`} ` + + `${c.name} — expected ${c.expect ? "detection" : "clean"}, got: ${ids}`, + ); + } + + fs.rmSync(tmp, { recursive: true, force: true }); + console.log( + failed === 0 + ? `\n${ANSI.green}${ANSI.bold}Self-test passed (${cases.length}/${cases.length}).${ANSI.reset}\n` + : `\n${ANSI.red}${ANSI.bold}Self-test FAILED: ${failed}/${cases.length} case(s).${ANSI.reset}\n`, + ); + process.exit(failed === 0 ? 0 : 1); +} + // ─── CLI Entry Point ─────────────────────────────────────────────────────────── function main() { const args = process.argv.slice(2); + if (args.includes("--self-test")) return selfTest(); const jsonFlag = args.includes("--json"); const outputFileIdx = args.indexOf("--output"); const outputFile = outputFileIdx !== -1 ? args[outputFileIdx + 1] : null; diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bdb4c5ff5..f8a1eb68e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,8 +10,16 @@ permissions: contents: read jobs: + # Supply-chain gate. Every other job depends on this, so a malware detection + # blocks the entire deploy before any build, migration or container starts. + malware-scan: + name: Malware gate + uses: ./.github/workflows/malware-scan.yml + secrets: inherit + detect-changes: name: Detect changed services + needs: malware-scan runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }} outputs: matrix: ${{ steps.filter.outputs.matrix }} @@ -90,7 +98,7 @@ jobs: deploy: name: Deploy ${{ matrix.service }} - needs: detect-changes + needs: [malware-scan, detect-changes] if: ${{ needs.detect-changes.outputs.matrix != '[]' }} runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }} strategy: diff --git a/.github/workflows/malware-scan.yml b/.github/workflows/malware-scan.yml new file mode 100644 index 000000000..751118839 --- /dev/null +++ b/.github/workflows/malware-scan.yml @@ -0,0 +1,161 @@ +name: Malware Scan + +# Supply-chain malware gate for the PolinRider / Famous Chollima campaign. +# +# Runs standalone on every push and pull request, and is also called by +# deploy.yml as a required first job — a detection fails this workflow, which +# blocks every downstream deploy job from starting. + +on: + push: + # dev and staging are already gated through deploy.yml's required + # malware-scan job — no need to scan those pushes twice. + branches-ignore: + - dev + - staging + pull_request: + workflow_call: + secrets: + TELEGRAM_BOT_TOKEN: + required: false + TELEGRAM_CHAT_ID: + required: false + +permissions: + contents: read + +# A detection on a ref should not be raced by a newer run of the same ref. +concurrency: + group: malware-scan-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + scan: + name: Scan for PolinRider malware + # Plain `self-hosted` — GitHub applies this label to every self-hosted + # runner automatically. The scan is host-agnostic, unlike the deploy jobs + # which pin to a branch-specific runner. + runs-on: self-hosted + outputs: + infected: ${{ steps.scan.outputs.infected }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Verify scanner rules still work + # Fails if someone weakens a detection rule or introduces a false + # positive against minified vendor bundles. + run: node .github/scripts/scan.js --self-test + + - name: Scan repository + id: scan + run: | + set -uo pipefail + + # Actions runs this with `bash -e`, so the non-zero exit must be + # caught with `||` rather than read back from $? afterwards. + STATUS=0 + node .github/scripts/scan.js --json --output malware-report.json . || STATUS=$? + + if [ "$STATUS" -eq 0 ]; then + echo "infected=false" >> "$GITHUB_OUTPUT" + echo "No malware detected." + exit 0 + fi + + echo "infected=true" >> "$GITHUB_OUTPUT" + + # Human-readable run for the log, so the failure is legible in the UI. + node .github/scripts/scan.js . || true + exit 1 + + - name: Build alert message + id: message + if: failure() && steps.scan.outputs.infected == 'true' + run: | + set -euo pipefail + + FILES=$(jq -r '.results[].filePath' malware-report.json | head -20) + COUNT=$(jq -r '.infectedFiles' malware-report.json) + RULES=$(jq -r '[.results[].findings[] | select(.severity=="CRITICAL") | .id] | unique | join(", ")' malware-report.json) + + { + echo "message<> "$GITHUB_OUTPUT" + + - name: Notify Telegram + if: failure() && steps.scan.outputs.infected == 'true' + env: + BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + TEXT: ${{ steps.message.outputs.message }} + run: | + set -uo pipefail + + if [ -z "${BOT_TOKEN:-}" ] || [ -z "${CHAT_ID:-}" ]; then + echo "::warning::TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID not set — skipping notification." + exit 0 + fi + + # No parse_mode: the payload contains characters Telegram's Markdown + # parser would reject, and a failed notification is worse than plain text. + HTTP=$(curl -sS -o /tmp/tg.out -w '%{http_code}' \ + -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \ + --data-urlencode "chat_id=${CHAT_ID}" \ + --data-urlencode "text=${TEXT}" \ + --data-urlencode "disable_web_page_preview=true") || true + + if [ "${HTTP:-000}" != "200" ]; then + echo "::warning::Telegram notification failed (HTTP ${HTTP:-000}): $(cat /tmp/tg.out 2>/dev/null | head -c 300)" + else + echo "Telegram alert sent." + fi + rm -f /tmp/tg.out + + - name: Upload scan report + if: always() && hashFiles('malware-report.json') != '' + uses: actions/upload-artifact@v4 + with: + name: malware-report-${{ github.run_id }} + path: malware-report.json + retention-days: 30 + + - name: Job summary + if: always() + run: | + set -uo pipefail + RESULT="${{ steps.scan.outputs.infected }}" + + if [ "$RESULT" = "true" ]; then + { + echo "## 🚨 Malware detected — deployment blocked" + echo "" + echo '```' + jq -r '.results[] | .filePath, (.findings[] | " [\(.id)] \(.severity) — \(.description)")' \ + malware-report.json 2>/dev/null | head -100 || true + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + elif [ "$RESULT" = "false" ]; then + echo "## ✅ No malware detected" >> "$GITHUB_STEP_SUMMARY" + else + # The scan step never produced a verdict — treat as inconclusive + # rather than clean, so a broken scanner is never read as a pass. + echo "## ⚠️ Scan did not complete — verdict unknown" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.gitignore b/.gitignore index 977a34353..63784b865 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,4 @@ RUNNING_LOCALLY.md # Generated per-shard compose file for the integration suite (it.mjs). integration/.it-shards.yaml -branch_structure.json -temp_auto_push.bat -temp_interactive_push.bat + diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 1b9f6564e..c98990d25 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -110,6 +110,7 @@ import { AiModule } from "./modules/ai/ai.module"; import { AuditModule } from "./modules/audit/audit.module"; import { LoggerMiddleware } from "./logger.middleware"; import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; +import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache"; if (!process.env.APPLICATION_NAME) { process.env.APPLICATION_NAME = "freight"; @@ -273,6 +274,9 @@ if (!process.env.APPLICATION_NAME) { ApprovedFirstLastMileDemoBookingsSeeder, PaidImportExportMileDemoSeeder, LoginAudienceMiddleware, + // Feeds position-TYPE grants to the synchronous permission checks — without + // it, staff whose permissions live on their position type resolve to none. + PositionTypePermissionsCache, ], }) export class AppModule implements OnApplicationBootstrap { diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 852df2f15..49bd31c61 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -57,6 +57,18 @@ export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); export const BookingDocReviewAlert = () => BookingStaff(FREIGHT_PERMS.bookings.docReviewAlert); +/** Staff wagon-cancellation history list (admin side). */ +export const WagonCancellationView = () => + BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationView); + +/** Staff void of a customer's pending (fee-unpaid) wagon cancellation. */ +export const WagonCancellationVoid = () => + BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationVoid); + +/** Staff rebook of a customer's wagon-cancellation credit on their behalf. */ +export const WagonCancellationRebook = () => + BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationRebook); + export const TrainSchedulingView = () => BookingStaff(FREIGHT_PERMS.trainScheduling.view); diff --git a/apps/edr-freight-api/src/common/freight-permission.util.spec.ts b/apps/edr-freight-api/src/common/freight-permission.util.spec.ts index d0d01535a..f3931f78a 100644 --- a/apps/edr-freight-api/src/common/freight-permission.util.spec.ts +++ b/apps/edr-freight-api/src/common/freight-permission.util.spec.ts @@ -1,6 +1,9 @@ import { assertCanApproveContractStep, canEditContractStep, + collectPermissionKeys, + hasFreightPermission, + setPositionTypePermissionResolver, } from './freight-permission.util'; import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; @@ -49,3 +52,72 @@ describe('canEditContractStep (strict per-step edit gate)', () => { ); }); }); + +/** + * The GL lockout regression: positions created through the admin UI keep their + * grants on the position TYPE, and the JWT only ever snapshots DIRECT position + * permissions. Without the type resolver those staff resolved to zero + * permissions, so every gated route rejected them — which is what kept GL + * officers out of their own clearance detail pages. + */ +describe('collectPermissionKeys — position-type grants', () => { + const CLEARANCE = FREIGHT_PERMS.contracts.clearanceReview; + + afterEach(() => { + setPositionTypePermissionResolver(() => []); + }); + + const glOfficer = { + roles: [], + permissions: [], + employee: { + position: { + permissions: [], // admin-created position carries NO direct grants + positionType: { key: 'commercial-global-logistics-(et)-officer' }, + }, + }, + }; + + it('resolves permissions carried by the position type', () => { + setPositionTypePermissionResolver((key) => + key === 'commercial-global-logistics-(et)-officer' ? [CLEARANCE] : [], + ); + + expect(collectPermissionKeys(glOfficer)).toContain(CLEARANCE); + expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(true); + }); + + it('handles the array-shaped employee payload too', () => { + setPositionTypePermissionResolver(() => [CLEARANCE]); + + const arrayShaped = { + roles: [], + permissions: [], + employee: [ + { + positions: [ + { permissions: [], positionType: { key: 'djibouti-gl-officer' } }, + ], + }, + ], + }; + + expect(hasFreightPermission(arrayShaped, CLEARANCE)).toBe(true); + }); + + it('still rejects when neither the position nor its type grants it', () => { + setPositionTypePermissionResolver(() => []); + + expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(false); + }); + + it('keeps direct position permissions working with no resolver installed', () => { + const direct = { + roles: [], + permissions: [], + employee: { position: { permissions: [{ key: CLEARANCE }] } }, + }; + + expect(hasFreightPermission(direct, CLEARANCE)).toBe(true); + }); +}); diff --git a/apps/edr-freight-api/src/common/freight-permission.util.ts b/apps/edr-freight-api/src/common/freight-permission.util.ts index 56c0e77c2..bc98a6df4 100644 --- a/apps/edr-freight-api/src/common/freight-permission.util.ts +++ b/apps/edr-freight-api/src/common/freight-permission.util.ts @@ -42,12 +42,41 @@ export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boo return isSuperAdmin(user) || isOrganizationAdmin(user); } -/** Flat permission keys from JWT / session user (roles + position permissions). */ +/** + * Permissions carried by a position TYPE rather than the position itself. + * + * The JWT snapshots only DIRECT position permissions, so type-level grants — + * which is where admin-created positions keep theirs — are absent from the + * token entirely. This resolver is installed at startup + * (see `PositionTypePermissionsCache`) so the synchronous permission checks + * below can still see them. Left as a no-op resolver until then, which + * degrades to the old position-only behaviour rather than throwing. + */ +let positionTypePermissionResolver: (positionTypeKey: string) => string[] = () => + []; + +export function setPositionTypePermissionResolver( + resolver: (positionTypeKey: string) => string[], +): void { + positionTypePermissionResolver = resolver; +} + +/** + * Flat permission keys from JWT / session user: roles, position permissions, + * and the grants held by each position's TYPE. + */ export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] { if (!user) return []; const keys = new Set(); + const addTypePermissions = (positionType: PositionTypeLike | null | undefined) => { + if (!positionType?.key) return; + for (const key of positionTypePermissionResolver(positionType.key)) { + keys.add(key); + } + }; + for (const p of user.permissions ?? []) { if (p.key) keys.add(p.key); } @@ -63,6 +92,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri for (const p of pos.permissions ?? []) { if (p.key) keys.add(p.key); } + addTypePermissions(pos.positionType); } } return [...keys]; @@ -71,6 +101,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri for (const p of employee.position?.permissions ?? []) { if (p.key) keys.add(p.key); } + addTypePermissions(employee.position?.positionType); for (const delegated of employee.delegatedPositions ?? []) { for (const p of delegated.permissions ?? []) { if (p.key) keys.add(p.key); diff --git a/apps/edr-freight-api/src/common/position-type-permissions.cache.ts b/apps/edr-freight-api/src/common/position-type-permissions.cache.ts new file mode 100644 index 000000000..2848ed0b2 --- /dev/null +++ b/apps/edr-freight-api/src/common/position-type-permissions.cache.ts @@ -0,0 +1,83 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import { setPositionTypePermissionResolver } from './freight-permission.util'; + +/** + * Permissions granted to a position TYPE (`iam.position_type_permissions`). + * + * A position type is the platform's notion of a role, and positions created + * through the admin UI carry their grants there rather than on the position + * itself. The JWT only ever snapshots DIRECT position permissions, so those + * grants are invisible to `collectPermissionKeys` — staff on such a position + * resolve to zero permissions and every permission-gated route rejects them. + * + * The permission checks (`hasFreightPermission`, `FreightPermissionGuard`) are + * synchronous and sit on the request path, so the mapping is held in memory and + * refreshed periodically rather than queried per request. The dataset is tiny + * (tens of types, a few hundred rows), so a full reload is cheaper than any + * incremental scheme. + */ +@Injectable() +export class PositionTypePermissionsCache implements OnModuleInit { + private readonly logger = new Logger(PositionTypePermissionsCache.name); + + /** position_type key → permission keys. Empty until the first load lands. */ + private byPositionTypeKey = new Map(); + + // ponytail: fixed 5-min refresh, no invalidation hook. A permission granted + // in the admin UI takes up to one interval to reach the guards. Wire the + // grant mutation to call `refresh()` if that lag ever matters. + private static readonly REFRESH_INTERVAL_MS = 5 * 60 * 1000; + + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + async onModuleInit(): Promise { + await this.refresh(); + // Hand the lookup to the permission utils, whose checks are synchronous and + // therefore cannot query IAM themselves. + setPositionTypePermissionResolver((positionTypeKey) => + this.get(positionTypeKey), + ); + const timer = setInterval(() => { + void this.refresh(); + }, PositionTypePermissionsCache.REFRESH_INTERVAL_MS); + // Never hold the process open for a cache refresh. + timer.unref?.(); + } + + /** Permission keys for a position-type key ([] when unknown/not loaded). */ + get(positionTypeKey: string | undefined | null): string[] { + if (!positionTypeKey) return []; + return this.byPositionTypeKey.get(positionTypeKey) ?? []; + } + + /** Reload the whole mapping. Failures keep the previous snapshot in place. */ + async refresh(): Promise { + try { + const rows: { position_type_key: string; permission_key: string }[] = + await this.dataSource.query( + `SELECT pt.key AS position_type_key, perm.key AS permission_key + FROM iam.position_type_permissions ptp + JOIN iam.position_types pt ON pt.id = ptp.position_type_id + JOIN iam.permissions perm ON perm.id = ptp.permission_id`, + ); + + const next = new Map(); + for (const row of rows) { + if (!row.position_type_key || !row.permission_key) continue; + const keys = next.get(row.position_type_key); + if (keys) keys.push(row.permission_key); + else next.set(row.position_type_key, [row.permission_key]); + } + this.byPositionTypeKey = next; + } catch (err) { + // iam schema unreachable — keep serving the previous snapshot rather than + // dropping every type-derived permission and locking staff out. + this.logger.warn( + `Position-type permission refresh failed: ${(err as Error).message}`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/3300000000000-BookingWagonCancellations.ts b/apps/edr-freight-api/src/migrations/3300000000000-BookingWagonCancellations.ts new file mode 100644 index 000000000..0d506cff0 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3300000000000-BookingWagonCancellations.ts @@ -0,0 +1,65 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Partial wagon cancellation with rebooking credit. + * + * One row per cancellation cycle on a PAID booking: the customer asks to drop + * N wagons, pays a per-wagon cancellation fee (rates row + * rate_type = 'CANCELLATION_FEE', rate_unit = 'PER_WAGON'), and the dropped cargo becomes a + * rebookable credit. The credit is redeemed by creating a fresh booking + * through the normal under-contract create path (which re-checks contract + * validity and caps), immediately marked PAID — the freight was already paid + * on the original booking, only the fee is new money. + * + * cancelled_quantities carries what was cut, in the booking's own terms: + * `{ bulkTons }` for bulk, `{ bySize: { "20": 4, "40": 3 } }` for container. + * Container numbers are NOT stored here — they are recovered at rebook time + * from the unit rows the reduction soft-deleted (same hybrid pattern as + * RemainderPlacementService). + */ +export class BookingWagonCancellations3300000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.booking_wagon_cancellations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id), + rebooked_booking_id uuid REFERENCES freight.bookings(id), + wagons_cancelled numeric(6,2) NOT NULL CHECK (wagons_cancelled > 0), + weight_tons numeric(12,3) NOT NULL DEFAULT 0, + cancelled_quantities jsonb NOT NULL, + credit_amount numeric(14,2) NOT NULL DEFAULT 0, + fee_rate_id uuid REFERENCES freight.rates(id), + fee_amount numeric(14,2) NOT NULL CHECK (fee_amount >= 0), + fee_currency varchar(8) NOT NULL DEFAULT 'ETB', + fee_invoice_id uuid REFERENCES freight.invoices(id), + fee_paid_at timestamptz, + status varchar(30) NOT NULL DEFAULT 'FEE_PENDING', + reason text, + requested_by_user_id uuid, + rebooked_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + // One open (fee-unpaid) cancellation per booking — closes the double-click + // race without app-level locking. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_open_wagon_cancellation_per_booking + ON freight.booking_wagon_cancellations (booking_id) + WHERE status = 'FEE_PENDING' AND deleted_at IS NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bwc_booking + ON freight.booking_wagon_cancellations (booking_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bwc_status + ON freight.booking_wagon_cancellations (status) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_wagon_cancellations`); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-me.service.ts b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts index 006b482f4..6bcfd964d 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-me.service.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts @@ -35,10 +35,57 @@ export class FreightMeService { } } + /** + * Permissions granted to the position's TYPE (`iam.position_type_permissions`). + * A position type is the platform's notion of a role, and admin-created + * positions carry their grants there rather than on the position itself — but + * the JWT only ever snapshots direct position permissions. Without this, staff + * on such a position resolve to zero permissions and every permission-gated + * route rejects them (this is what locked GL officers out of their clearance + * detail pages). Resolved live from IAM, same as the position type above. + */ + private async lookupPositionTypePermissions( + positionId: string | undefined, + ): Promise { + if (!positionId) return []; + try { + const rows: { key: string }[] = await this.dataSource.query( + `SELECT DISTINCT perm.key + FROM iam.positions p + JOIN iam.position_type_permissions ptp + ON ptp.position_type_id = p.position_type_id + JOIN iam.permissions perm ON perm.id = ptp.permission_id + WHERE p.id = $1`, + [positionId], + ); + return rows.map((r) => r.key).filter(Boolean); + } catch { + return []; // iam schema unreachable — degrade to position-only permissions + } + } + async getEnrichedProfile(user: TCurrentUser) { - const positionType = await this.lookupPositionType( - user.employee?.position?.id, + const positionId = user.employee?.position?.id; + const [positionType, positionTypePermissionKeys] = await Promise.all([ + this.lookupPositionType(positionId), + this.lookupPositionTypePermissions(positionId), + ]); + + // Merge the type-level grants into the position's own permission list so + // BOTH consumers see them: `collectPermissionKeys` below, and the + // backoffice's `getPermissionKeys`, which walks this same nested array. + const positionPermissions = [ + ...(user.employee?.position?.permissions ?? []), + ]; + const seenPermissionKeys = new Set( + positionPermissions.map((p) => p?.key).filter(Boolean), ); + for (const key of positionTypePermissionKeys) { + if (!seenPermissionKeys.has(key)) { + seenPermissionKeys.add(key); + positionPermissions.push({ key } as (typeof positionPermissions)[number]); + } + } const employee = user.employee ? [ @@ -56,7 +103,7 @@ export class FreightMeService { name: user.employee.position.name, isDelegate: user.employee.position.isDelegate, parentPositionId: user.employee.position.parentPositionId, - permissions: user.employee.position.permissions ?? [], + permissions: positionPermissions, positionType, }, ] @@ -65,7 +112,15 @@ export class FreightMeService { ] : []; - const permissionKeys = collectPermissionKeys(user); + // `collectPermissionKeys` reads the raw token (position-level only), so + // union the type-level grants in — the backoffice prefers this flat list + // over the nested array and would otherwise still see none of them. + const permissionKeys = [ + ...new Set([ + ...collectPermissionKeys(user), + ...positionTypePermissionKeys, + ]), + ]; return { id: user.id, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index cd803d719..91b8362e4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -20,6 +20,10 @@ import { FirstMileService } from "../first-mile/first-mile.service"; import { BookingBatchService } from "../train-scheduling/booking-batch.service"; import { PriceLineItemDto } from "./dto/generate-price-response.dto"; import { BookingsRepository } from "./bookings.repository"; +import { + BookingWagonCancellationService, + WAGON_CANCEL_FEE_INVOICE_TYPE, +} from "./booking-wagon-cancellation.service"; import { Booking } from "./entities/booking.entity"; /** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */ @@ -58,6 +62,8 @@ export class BookingInvoiceService { private readonly firstMile: FirstMileService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatch: BookingBatchService, + @Inject(forwardRef(() => BookingWagonCancellationService)) + private readonly wagonCancellations: BookingWagonCancellationService, ) { } /** @@ -90,6 +96,21 @@ export class BookingInvoiceService { return this.billing.generateInvoice(input); } + /** + * Cancel the booking's open PREPAID invoice, if any — used when a + * changes-requested resubmit restates the cargo, so the re-priced booking can + * be re-invoiced. Throws when the invoice already has payments recorded + * (cargo must not change out from under recorded money). + */ + async cancelUnpaidInvoiceForBooking(bookingId: string): Promise { + const existing = await this.billing.findPayable( + Freight.InvoiceSource.Booking, + bookingId, + "PREPAID", + ); + if (existing) await this.billing.cancelInvoice(existing.id); + } + /** * React to a booking invoice being paid — the settlement branch point. Per-type * reactions live here (not in the payment process): each invoice type advances @@ -108,6 +129,11 @@ export class BookingInvoiceService { await this.bookingBatch.reviveOfferForInvoice(payload.invoiceId); await this.advanceBookingOnPayment(payload.sourceId); break; + case WAGON_CANCEL_FEE_INVOICE_TYPE: + // Partial wagon cancellation: the fee settled — reduce the booking and + // release the cancelled wagons (T2 of the cancellation cycle). + await this.wagonCancellations.onFeePaid(payload.invoiceId); + break; default: this.logger.warn( `Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 181c47b2d..7cc92be87 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -8,6 +8,9 @@ import { Optional, } from "@nestjs/common"; import { EventEmitter2, OnEvent } from "@nestjs/event-emitter"; +import { DataSource } from "typeorm"; + +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { BookingBatchService, @@ -56,11 +59,18 @@ export class BookingTransitionService { private readonly bookingClearanceService: BookingClearanceService, @Inject(forwardRef(() => ClearanceWorkflowService)) private readonly workflowService: ClearanceWorkflowService, - private readonly invoiceService: BookingInvoiceService, + // forwardRef: booking-invoice.service now pulls in the wagon-cancellation + // service, whose cross-module imports close a require cycle through this + // file — without it the class is undefined at decorator time. + @Inject(forwardRef(() => BookingInvoiceService)) + private readonly invoiceService: BookingInvoiceService, private readonly containerValidationService: ContainerValidationService, private readonly notifier: BookingLifecycleNotifierService, private readonly events: EventEmitter2, @Optional() private readonly milestoneService?: ClearanceMilestoneService, + // Optional + last so the hand-constructed service in *.spec.ts files keeps + // compiling; Nest injects it normally at runtime. + @Optional() private readonly dataSource?: DataSource, ) {} private isPhasedCustoms(booking: Booking): boolean { @@ -429,6 +439,20 @@ export class BookingTransitionService { return fresh; } + /** + * Customer self-service cancel, allowed only before payment — no fee. + * SELECTED_FOR_BATCH releases the wagon hold immediately; earlier statuses + * take the plain cancel path (open invoices expired, nothing reserved yet). + * Anything past payment falls through to cancel()'s status assertion. + */ + async customerCancel(bookingId: string, reason?: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + if (booking.status === "SELECTED_FOR_BATCH") { + return this.cancelHold(bookingId, reason); + } + return this.cancel(bookingId, reason ?? "Customer cancelled before payment"); + } + async cancel(bookingId: string, reason: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ @@ -978,6 +1002,15 @@ export class BookingTransitionService { // booking through the space checks below AND is persisted so the accept / // reserve path locks onto that train (pickExportSchedule honors it). const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null; + // Export rail rides the exact train the customer picked — never an + // auto-assigned one. Both portal flows (clearance + contract completion) + // surface a picker, so a missing id is an invalid submission, not a + // legitimate "let the system choose". + if (isExportTrain && !requestedId) { + throw new BadRequestException( + "Select a train for the chosen shipment day.", + ); + } const scheduledBooking = { ...booking, scheduledDate: date, @@ -1244,6 +1277,12 @@ export class BookingTransitionService { /** Flat list of physical container numbers on this booking (for the * customer truck-assignment container picker). */ containerNumbers: string[]; + /** The allocated train, when the booking is placed on a schedule. */ + trainSchedule?: { + trainNumber: string | null; + reference: string | null; + scheduledDepartureDate: Date | null; + } | null; } > { // This enrichment runs AFTER the transition has committed. A failure here @@ -1296,6 +1335,32 @@ export class BookingTransitionService { `enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`, ); } + // Allocated train: number + schedule reference for the detail headers + // (portal and backoffice). Degrades to null like every fragile field here. + let trainSchedule: { + trainNumber: string | null; + reference: string | null; + scheduledDepartureDate: Date | null; + } | null = null; + if (booking.trainScheduleId && this.dataSource) { + try { + const s = await this.dataSource.getRepository(TrainSchedule).findOne({ + where: { id: booking.trainScheduleId }, + }); + if (s) { + trainSchedule = { + trainNumber: s.trainNumber ?? null, + reference: s.reference ?? null, + scheduledDepartureDate: s.scheduledDepartureDate ?? null, + }; + } + } catch (err) { + this.logger.warn( + `enrichBookingResponse: train-schedule lookup failed for ${booking.id}: ${(err as Error).message}`, + ); + } + } + // Physical container numbers entered at booking time (booking_container // units), flattened for the customer truck-assignment container picker. const containerNumbers = (booking.bookingContainers ?? []) @@ -1310,6 +1375,7 @@ export class BookingTransitionService { nextStep, activeBatchOffer, containerNumbers, + trainSchedule, }; } } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts new file mode 100644 index 000000000..c576f5a43 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -0,0 +1,1124 @@ +import { + BadRequestException, + ConflictException, + forwardRef, + Inject, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { Freight, NotificationAudience, NotificationType } from '@edr/types'; +import { DataSource, EntityManager, In } from 'typeorm'; + +import { BillingService } from '../billing/billing.service'; +import { ContractBookingService } from '../contracts/contract-booking.service'; +import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; +import { CreateBookingUnderContractDto } from '../contracts/dto/create-booking-under-contract.dto'; +import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; +import { FirstMileService } from '../first-mile/first-mile.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; +import { Rate } from '../rule-engine/entities/rate.entity'; +import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; +import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity'; +import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity'; +import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { + BookingWagonCancellationsRepository, + WagonCancellationListFilter, +} from './booking-wagon-cancellations.repository'; +import { BookingsRepository } from './bookings.repository'; +import { + RebookCancelledWagonsDto, + RequestWagonCancellationDto, +} from './dto/wagon-cancellation.dto'; +import { Booking } from './entities/booking.entity'; +import { BookingContainer } from './entities/booking-container.entity'; +import { BookingContainerUnit } from './entities/booking-container-unit.entity'; +import { + BookingWagonCancellation, + CancelledQuantities, + CancelledUnitSnapshot, +} from './entities/booking-wagon-cancellation.entity'; + +/** + * rates.rate_type of the cancellation fee — an existing rate-engine type + * (trigger CANCELLATION, never auto-applied to booking pricing). Staff + * configure it in the normal rates UI; the wagon flow requires the PER_WAGON + * unit so the fee scales with the cancelled wagon count. + */ +export const WAGON_CANCELLATION_FEE_RATE_TYPE = 'CANCELLATION_FEE'; +/** invoices.type of the fee invoice — the settlement branch key in BookingInvoiceService. */ +export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE'; + +const round2 = (n: number): number => Math.round(n * 100) / 100; +const round3 = (n: number): number => Math.round(n * 1000) / 1000; + +interface RequestedCut { + wagons: number; + weightTons: number; + quantities: CancelledQuantities; +} + +/** + * Partial wagon cancellation on a PAID booking, with a rebooking credit. + * + * Lifecycle (one ledger row per cycle, see BookingWagonCancellation): + * T1 request — validate + price the fee, open the fee invoice. Nothing else + * moves: the wagons stay allocated until the fee is money. + * T2 fee paid — reduce the booking in place (applySplit mechanics: soft-delete + * the cut units LIFO), release the surplus wagon allocations, + * snapshot the cut units on the ledger row → CREDIT_AVAILABLE. + * T3 rebook — customer picks a day only. The credit becomes a REAL booking + * via ContractBookingService.createUnderContract (which re-checks + * contract validity + caps), immediately marked PAID — the + * freight was paid on the original booking; only the fee was new + * money. Clearance milestones are copied from the source booking + * (the cargo is already cleared; clearance follows cargo, not + * train date). + * + * The cycle is repeatable by construction: the rebooked booking is a normal + * PAID booking, so it can itself be partially cancelled again. + */ +@Injectable() +export class BookingWagonCancellationService { + private readonly logger = new Logger(BookingWagonCancellationService.name); + + constructor( + private readonly dataSource: DataSource, + private readonly repo: BookingWagonCancellationsRepository, + private readonly bookingsRepository: BookingsRepository, + private readonly billing: BillingService, + @Inject(forwardRef(() => ContractBookingService)) + private readonly contractBooking: ContractBookingService, + @Inject(forwardRef(() => ClearanceMilestoneService)) + private readonly clearanceMilestones: ClearanceMilestoneService, + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatch: BookingBatchService, + @Inject(forwardRef(() => TrainSchedulingService)) + private readonly trainScheduling: TrainSchedulingService, + @Inject(forwardRef(() => FirstMileService)) + private readonly firstMile: FirstMileService, + private readonly inbox: NotificationInboxService, + ) {} + + // ── T1: request ──────────────────────────────────────────────────────────── + + /** Fee/credit preview for the confirm dialog — same math as the request, no writes. */ + async previewCancellation( + bookingId: string, + dto: RequestWagonCancellationDto, + ): Promise<{ + wagons: number; + weightTons: number; + feePerWagon: number; + feeAmount: number; + feeCurrency: string; + creditAmount: number; + }> { + const booking = await this.loadCancellableBooking(bookingId); + const cut = await this.resolveRequestedCut(booking, dto); + const rate = await this.feeRate(); + const feeAmount = round2(Number(rate.rateValue) * cut.wagons); + return { + wagons: cut.wagons, + weightTons: cut.weightTons, + feePerWagon: Number(rate.rateValue), + feeAmount, + feeCurrency: rate.currency, + creditAmount: this.creditFor(booking, cut.wagons), + }; + } + + async requestCancellation( + bookingId: string, + dto: RequestWagonCancellationDto, + userId?: string, + ): Promise { + const booking = await this.loadCancellableBooking(bookingId); + const open = await this.repo.findOpenForBooking(bookingId); + if (open) { + throw new ConflictException( + 'This booking already has a cancellation awaiting its fee. Pay or withdraw it first.', + ); + } + + const cut = await this.resolveRequestedCut(booking, dto); + const rate = await this.feeRate(); + const feeAmount = round2(Number(rate.rateValue) * cut.wagons); + const creditAmount = this.creditFor(booking, cut.wagons); + + const row = await this.repo.create({ + bookingId, + wagonsCancelled: cut.wagons, + weightTons: cut.weightTons, + cancelledQuantities: cut.quantities, + creditAmount, + feeRateId: rate.id, + feeAmount, + feeCurrency: rate.currency, + status: 'FEE_PENDING', + reason: dto.reason ?? null, + requestedByUserId: userId ?? null, + }); + + // The fee invoice rides the booking's own invoice list (source=booking), so + // the portal's existing invoice/pay stack picks it up with zero new payment + // code. Settlement branches on type in BookingInvoiceService. + const invoice = await this.billing.generateInvoice({ + source: Freight.InvoiceSource.Booking, + sourceId: bookingId, + type: WAGON_CANCEL_FEE_INVOICE_TYPE, + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency: rate.currency, + lines: [ + { + chargeType: 'CANCELLATION_FEE', + description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference}`, + quantity: cut.wagons, + unitRate: Number(rate.rateValue), + amount: feeAmount, + currency: rate.currency, + metadata: { wagonCancellationId: row.id }, + }, + ], + totalAmount: feeAmount, + status: Freight.InvoiceStatus.Issued, + }); + let updated = await this.repo.update(row.id, { feeInvoiceId: invoice.id }); + + // Policy: the cancelled wagons leave the schedule NOW — capacity frees for + // other customers immediately; the fee is still owed before the credit can + // be rebooked. A withdraw/void re-allocates (or errors when the train has + // no room left). If this release fails, T2 releases instead (flag unset). + try { + const released = await this.releaseAtRequest(bookingId, cut); + if (released) { + updated = await this.repo.update(row.id, { + cancelledQuantities: { ...cut.quantities, releasedAtRequest: true }, + }); + } + } catch (err) { + this.logger.error( + `Request-time wagon release failed for cancellation ${row.id}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + this.notifyStaff( + booking, + 'Wagon cancellation requested', + `${booking.reference}: customer asked to cancel ${cut.wagons} wagon(s); fee invoice ${invoice.invoiceNumber} issued.`, + ); + return updated ?? row; + } + + /** + * Void a FEE_PENDING request (customer withdraw or staff void). The wagons + * left the schedule at request time, so voiding must first put them back: + * the schedule's auto-allocation is re-run and the result verified — if the + * train has no room left, the void FAILS with a clear error and the request + * stays FEE_PENDING (pay the fee and rebook the credit instead). + */ + async withdraw(cancellationId: string): Promise { + const row = await this.mustFind(cancellationId); + if (row.status !== 'FEE_PENDING') { + throw new BadRequestException( + `Only a fee-pending cancellation can be withdrawn (status is ${row.status}).`, + ); + } + + if (row.cancelledQuantities.releasedAtRequest) { + const booking = await this.bookingsRepository.findById(row.bookingId); + const scheduleId = booking?.trainScheduleId; + if (booking && scheduleId) { + try { + await this.trainScheduling.tryAutoWagonAllocation(scheduleId); + } catch (err) { + this.logger.warn( + `Re-allocation on withdraw failed for booking ${row.bookingId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + // ponytail: allocation rows ≈ wagons (20ft pairs share one row/wagon); + // switch to a weight-based check if mixed loads ever make this lie. + const rows = await this.dataSource.getRepository(WagonBookingAllocation).count({ + where: { bookingId: row.bookingId }, + }); + if (rows < Math.round(Number(booking.wagonsRequired ?? 0))) { + throw new ConflictException( + 'The train has no free wagon space left to restore the cancelled wagons — the request cannot be withdrawn. Pay the cancellation fee and rebook the credit on another day instead.', + ); + } + } + } + + if (row.feeInvoiceId) await this.billing.cancelInvoice(row.feeInvoiceId); + return (await this.repo.update(row.id, { status: 'WITHDRAWN' }))!; + } + + // ── T2: fee settled ───────────────────────────────────────────────────────── + + /** + * The fee invoice settled — reduce the booking and free the wagons. Called + * from BookingInvoiceService's paid handler. Idempotent: a duplicate webhook + * finds the row already past FEE_PENDING and returns. + */ + async onFeePaid(feeInvoiceId: string): Promise { + const row = await this.repo.findByFeeInvoiceId(feeInvoiceId); + if (!row) { + this.logger.warn(`No wagon cancellation for paid fee invoice ${feeInvoiceId}.`); + return; + } + if (row.status !== 'FEE_PENDING') return; + + // The fee can settle after loading started (slow payment). Never cut + // loaded cargo: leave the row FEE_PENDING and alert staff to resolve + // (reschedule the cut or refund the fee by hand). Skipped when the wagons + // already left the schedule at request time — loading of the KEPT wagons + // is then irrelevant to this cut. + const releasedEarly = !!row.cancelledQuantities.releasedAtRequest; + const bookingNow = await this.bookingsRepository.findById(row.bookingId); + const movingNow = releasedEarly + ? 0 + : await this.dataSource.getRepository(WagonBookingAllocation).count({ + where: { bookingId: row.bookingId, status: In(['LOADED', 'DEPARTED']) }, + }); + if (!releasedEarly && (bookingNow?.loadedAt || movingNow > 0)) { + this.logger.error( + `Wagon cancellation ${row.id}: fee paid but loading already started on booking ${row.bookingId} — left FEE_PENDING for manual resolution.`, + ); + if (bookingNow) { + this.notifyStaff( + bookingNow, + 'Wagon cancellation fee paid after loading started', + `${bookingNow.reference}: the customer paid the cancellation fee for ${row.wagonsCancelled} wagon(s), but loading has already started. Resolve manually (adjust the cut or refund the fee).`, + ); + } + return; + } + + await this.dataSource.transaction(async (manager) => { + const booking = await manager.getRepository(Booking).findOne({ + where: { id: row.bookingId }, + lock: { mode: 'pessimistic_write' }, + }); + if (!booking) throw new NotFoundException(`Booking ${row.bookingId} not found.`); + + const quantities = { ...row.cancelledQuantities }; + let droppedWeight = 0; + + if (quantities.bySize && Object.keys(quantities.bySize).length) { + // Specific-wagon requests already carry the exact unit snapshots; + // quantity requests trim LIFO and snapshot here. + const units = quantities.units?.length + ? await this.reduceContainerUnitsExact(manager, booking, quantities.units) + : await this.reduceContainerLines(manager, booking, quantities.bySize); + quantities.units = units; + droppedWeight = round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)); + if (!releasedEarly) { + await this.releaseContainerAllocations( + manager, + booking.id, + units.map((u) => u.containerNumber), + ); + } + } else { + droppedWeight = Number(quantities.bulkTons ?? row.weightTons); + await this.reduceBulk(manager, booking, droppedWeight); + if (!releasedEarly) { + await this.releaseBulkAllocations( + manager, + booking.id, + Number(row.wagonsCancelled), + quantities.allocationIds, + ); + } + } + + // Mirror applySplit's bookkeeping: preSplitQuantities feeds the ONE_TIME + // exact-remainder assertion at rebook time; isSplit releases the + // single-active-booking slot so the rebooked booking may be created. + const preSplitQuantities = + booking.preSplitQuantities ?? (await this.currentQuantities(manager, booking, droppedWeight)); + + await manager.getRepository(Booking).update(booking.id, { + wagonsRequired: round2(Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled)), + cargoTotalWeightVgm: round3(Number(booking.cargoTotalWeightVgm) - droppedWeight), + totalAmount: round2(Number(booking.totalAmount) - Number(row.creditAmount)), + isSplit: true, + preSplitQuantities, + } as never); + + await manager.getRepository(BookingWagonCancellation).update(row.id, { + status: 'CREDIT_AVAILABLE', + feePaidAt: new Date(), + weightTons: droppedWeight, + cancelledQuantities: quantities, + }); + }); + + const booking = await this.bookingsRepository.findById(row.bookingId); + if (booking) { + this.notifyCustomer( + booking, + 'Wagon cancellation confirmed', + `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`, + ); + } + this.logger.log( + `Wagon cancellation ${row.id}: fee paid, booking ${row.bookingId} reduced by ${row.wagonsCancelled} wagon(s).`, + ); + } + + // ── T3: rebook ────────────────────────────────────────────────────────────── + + async rebook( + cancellationId: string, + dto: RebookCancelledWagonsDto, + userId?: string, + ): Promise<{ cancellation: BookingWagonCancellation; bookingId: string }> { + const row = await this.mustFind(cancellationId); + if (row.status !== 'CREDIT_AVAILABLE') { + throw new BadRequestException( + `This credit cannot be rebooked (status is ${row.status}).`, + ); + } + const source = await this.bookingsRepository.findById(row.bookingId); + if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`); + if (!source.contractId) { + throw new BadRequestException('The original booking has no contract to rebook under.'); + } + // Friendly pre-check; createUnderContract re-asserts inside its own guards. + if ( + source.contractValidUntil && + new Date(source.contractValidUntil).getTime() < Date.now() + ) { + throw new BadRequestException( + 'Contract validity has expired — ask EDR staff to extend the contract before rebooking.', + ); + } + + const createDto = this.buildRebookDto(row, dto.scheduledDate); + const created = await this.contractBooking.createUnderContract( + source.contractId, + createDto, + { id: userId ?? source.createdByUserId ?? undefined }, + // System actor: carries the create-booking key so the GL gate passes on + // Path B (customs-clearance) contracts; harmless on Path A. + { permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] }, + ); + const newBookingId = created.booking.id; + + // The freight is already paid (credit) — mark PAID and let the existing + // paid-booking machinery place it. No invoice is generated for it. + await this.dataSource.getRepository(Booking).update(newBookingId, { + paymentStatus: 'PAID', + status: 'PAID', + }); + await this.copyClearanceState(source, newBookingId); + + try { + await this.firstMile.acceptBooking(newBookingId); + } catch (err) { + this.logger.error( + `First-mile accept failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + try { + await this.bookingBatch.ensurePaidBookingAllocated(newBookingId); + } catch (err) { + this.logger.error( + `Allocation failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const updated = (await this.repo.update(row.id, { + status: 'REBOOKED', + rebookedBookingId: newBookingId, + rebookedAt: new Date(), + }))!; + + this.notifyCustomer( + source, + 'Cancelled wagons rebooked', + `Your ${row.wagonsCancelled} cancelled wagon(s) from ${source.reference} are rebooked for ${dto.scheduledDate}. No new freight charge — your credit covered it.`, + newBookingId, + ); + return { cancellation: updated, bookingId: newBookingId }; + } + + // ── History ──────────────────────────────────────────────────────────────── + + list(filter: WagonCancellationListFilter) { + return this.repo.list(filter); + } + + findById(id: string): Promise { + return this.mustFind(id); + } + + // ── internals ────────────────────────────────────────────────────────────── + + private async mustFind(id: string): Promise { + const row = await this.repo.findById(id); + if (!row) throw new NotFoundException(`Wagon cancellation ${id} not found.`); + return row; + } + + /** PAID booking, not yet moving, with a contract to rebook under later. */ + private async loadCancellableBooking(bookingId: string): Promise { + const booking = await this.bookingsRepository.findById(bookingId); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found.`); + if (booking.paymentStatus !== 'PAID' || booking.status !== 'PAID') { + throw new BadRequestException( + 'Only a paid booking can cancel wagons. Before payment, cancel the booking itself — no fee applies.', + ); + } + if (!booking.contractId) { + throw new BadRequestException( + 'Wagon cancellation needs a contract booking (the credit is rebooked under the contract).', + ); + } + // Cancellation is allowed strictly BEFORE loading/dispatch: both signals + // checked — per-wagon allocation status and the booking-level loading stamp + // (some flows confirm loading on the booking without flipping allocations). + if (booking.loadedAt) { + throw new BadRequestException( + 'Cargo loading is confirmed for this booking — wagons can no longer be cancelled.', + ); + } + const moving = await this.dataSource.getRepository(WagonBookingAllocation).count({ + where: { bookingId, status: In(['LOADED', 'DEPARTED']) }, + }); + if (moving > 0) { + throw new BadRequestException( + 'Loading has started for this booking — wagons can no longer be cancelled.', + ); + } + return booking; + } + + /** Validate the requested cut against the live booking and size it in wagons/tons. */ + private async resolveRequestedCut( + booking: Booking, + dto: RequestWagonCancellationDto, + ): Promise { + const totalWagons = Number(booking.wagonsRequired ?? 0); + if (totalWagons <= 0) { + throw new BadRequestException('This booking has no wagon requirement to cancel from.'); + } + + if (dto.wagonAllocationIds?.length) { + return this.resolveCutFromAllocations(booking, dto.wagonAllocationIds, totalWagons); + } + + if (booking.freightType === 'CONTAINER') { + if (!dto.containers?.length) { + throw new BadRequestException('Specify the container units to cancel per size.'); + } + const lines = await this.dataSource.getRepository(BookingContainer).find({ + where: { bookingId: booking.id }, + }); + const liveBySize = new Map(); + for (const line of lines) { + const size = line.containerSize ?? ''; + liveBySize.set(size, (liveBySize.get(size) ?? 0) + Number(line.quantity ?? 0)); + } + const bySize: Record = {}; + let wagons = 0; + for (const cut of dto.containers) { + const live = liveBySize.get(cut.containerSize) ?? 0; + if (cut.quantity > live) { + throw new BadRequestException( + `Cannot cancel ${cut.quantity} × ${cut.containerSize}ft — the booking only has ${live}.`, + ); + } + bySize[cut.containerSize] = cut.quantity; + wagons += cut.quantity * wagonsPerUnitForSize(Number(cut.containerSize)); + } + wagons = round2(wagons); + if (wagons >= totalWagons) { + throw new BadRequestException( + 'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.', + ); + } + // Snapshot the LIFO-picked physical units up front (read-only — cargo is + // cut only when the fee settles) so the wagons carrying them can be + // released from the schedule at request time and the portal can show + // which containers are leaving. + const unitRepo = this.dataSource.getRepository(BookingContainerUnit); + const units: CancelledUnitSnapshot[] = []; + let requested = 0; + for (const cut of dto.containers) { + requested += cut.quantity; + let need = cut.quantity; + const sizeLines = lines + .filter((l) => (l.containerSize ?? '') === cut.containerSize) + .sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt)); + for (const line of sizeLines) { + if (need <= 0) break; + const us = await unitRepo.find({ + where: { bookingContainerId: line.id }, + order: { sortOrder: 'DESC', createdAt: 'DESC' }, + take: need, + }); + for (const u of us) { + units.push({ + containerSize: cut.containerSize, + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? null, + vgmTons: Number(u.vgmTons), + isHazardous: u.isHazardous, + isReefer: u.isReefer, + }); + need--; + } + } + } + const weightShare = round3( + Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons), + ); + return { + wagons, + weightTons: weightShare, + // Bookings without unit records fall back to the T2 LIFO trim. + quantities: { bySize, ...(units.length === requested ? { units } : {}) }, + }; + } + + // BULK: the customer cancels wagons; tons follow the booking's own + // tons-per-wagon ratio. + const wagons = round2(Number(dto.wagons ?? 0)); + if (!wagons || wagons <= 0) { + throw new BadRequestException('Specify how many wagons to cancel.'); + } + if (wagons >= totalWagons) { + throw new BadRequestException( + 'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.', + ); + } + // ponytail: proportional sizing (tons/wagon = total/wagons). PER_ITEM item + // rounding happens here too; switch to items_per_wagon_map sizing if bulk + // PER_ITEM cancels ever need to be exact per item. + let tons = Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons); + const isPerItem = booking.bulkTotalWeightTons != null; + tons = isPerItem ? Math.floor(tons) : round3(tons); + if (tons <= 0) { + throw new BadRequestException('The requested cut is too small to release cargo.'); + } + return { wagons, weightTons: tons, quantities: { bulkTons: tons } }; + } + + /** + * Specific-wagon cancellation: the customer picked wagons in the Wagons tab. + * Everything is derived from the selected allocations — container bookings + * get their exact unit snapshots up front (T2 then cuts precisely these, + * not a LIFO guess), bulk gets the wagons' actual allocated tonnage. + */ + private async resolveCutFromAllocations( + booking: Booking, + allocationIds: string[], + totalWagons: number, + ): Promise { + const allocations = await this.dataSource.getRepository(WagonBookingAllocation).find({ + where: { id: In(allocationIds), bookingId: booking.id }, + relations: { containerItems: true }, + }); + if (allocations.length !== allocationIds.length) { + throw new BadRequestException( + 'Some selected wagons no longer belong to this booking — refresh and pick again.', + ); + } + const notCancellable = allocations.filter( + (a) => a.status !== 'PLANNED' && a.status !== 'RESERVED', + ); + if (notCancellable.length) { + throw new BadRequestException( + 'A selected wagon is already loaded or departed and cannot be cancelled.', + ); + } + + const wagons = allocations.length; + if (wagons >= totalWagons) { + throw new BadRequestException( + 'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.', + ); + } + + if (booking.freightType !== 'CONTAINER') { + const allocated = allocations.reduce( + (s, a) => s + Number(a.allocatedWeightTons || 0), + 0, + ); + const tons = + allocated > 0 + ? round3(allocated) + : round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons)); + return { + wagons, + weightTons: tons, + quantities: { bulkTons: tons, allocationIds }, + }; + } + + // Container: the selected wagons' items name the exact physical boxes. + const numbers = allocations + .flatMap((a) => a.containerItems ?? []) + .map((i) => i.containerNumber) + .filter((n): n is string => !!n); + if (!numbers.length) { + throw new BadRequestException( + 'The selected wagons carry no container records — cancel by quantity instead.', + ); + } + const lines = await this.dataSource.getRepository(BookingContainer).find({ + where: { bookingId: booking.id }, + }); + const unitRepo = this.dataSource.getRepository(BookingContainerUnit); + const units: CancelledUnitSnapshot[] = []; + const bySize: Record = {}; + for (const line of lines) { + const size = line.containerSize ?? ''; + const lineUnits = await unitRepo.find({ where: { bookingContainerId: line.id } }); + for (const u of lineUnits) { + if (!numbers.includes(u.containerNumber)) continue; + units.push({ + containerSize: size, + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? null, + vgmTons: Number(u.vgmTons), + isHazardous: u.isHazardous, + isReefer: u.isReefer, + }); + bySize[size] = (bySize[size] ?? 0) + 1; + } + } + if (units.length !== numbers.length) { + throw new BadRequestException( + 'Wagon container records are out of sync with the booking — contact EDR support.', + ); + } + return { + wagons, + weightTons: round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)), + quantities: { bySize, units, allocationIds }, + }; + } + + /** Credit = the cancelled share of the ORIGINAL price (old-price rebooking). */ + private creditFor(booking: Booking, wagons: number): number { + const totalWagons = Number(booking.wagonsRequired ?? 0); + if (totalWagons <= 0) return 0; + return round2(Number(booking.totalAmount) * (wagons / totalWagons)); + } + + private async feeRate(): Promise { + const rate = await this.dataSource.getRepository(Rate).findOne({ + where: { + rateType: WAGON_CANCELLATION_FEE_RATE_TYPE, + rateUnit: 'PER_WAGON', + status: 'LIVE', + }, + order: { createdAt: 'DESC' }, + }); + if (!rate) { + throw new BadRequestException( + 'No LIVE per-wagon CANCELLATION_FEE rate is configured — ask EDR to set it in the rate engine (unit PER_WAGON).', + ); + } + return rate; + } + + /** + * Trim `bySize` units off the booking's container lines, newest line first, + * LIFO within a line — the exact applySplit mechanics. Returns snapshots of + * every physical unit soft-deleted, for later reconstruction. + */ + private async reduceContainerLines( + manager: EntityManager, + booking: Booking, + bySize: Record, + ): Promise { + const snapshots: CancelledUnitSnapshot[] = []; + for (const [size, toDrop] of Object.entries(bySize)) { + let remaining = toDrop; + const lines = await manager.getRepository(BookingContainer).find({ + where: { bookingId: booking.id, containerSize: size }, + order: { createdAt: 'DESC' }, + }); + const live = lines.reduce((s, l) => s + Number(l.quantity ?? 0), 0); + if (live < toDrop) { + throw new BadRequestException( + `Booking changed since the request: only ${live} × ${size}ft left, cannot cancel ${toDrop}.`, + ); + } + for (const line of lines) { + if (remaining <= 0) break; + const qty = Number(line.quantity ?? 0); + const drop = Math.min(remaining, qty); + remaining -= drop; + + const units = await manager.getRepository(BookingContainerUnit).find({ + where: { bookingContainerId: line.id }, + order: { sortOrder: 'DESC', createdAt: 'DESC' }, + take: drop, + }); + for (const u of units) { + snapshots.push({ + containerSize: size, + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? null, + vgmTons: Number(u.vgmTons), + isHazardous: u.isHazardous, + isReefer: u.isReefer, + }); + } + if (units.length < drop) { + throw new BadRequestException( + `Booking line ${line.id} has ${units.length} physical unit record(s) but ${drop} must be cancelled — units out of sync.`, + ); + } + const droppedVgm = round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)); + + if (drop === qty) { + await manager.getRepository(BookingContainer).softDelete(line.id); + await manager + .getRepository(BookingContainerUnit) + .softDelete(units.map((u) => u.id)); + continue; + } + await manager.getRepository(BookingContainerUnit).softDelete(units.map((u) => u.id)); + const keptUnits = await manager.getRepository(BookingContainerUnit).find({ + where: { bookingContainerId: line.id }, + }); + await manager.getRepository(BookingContainer).update(line.id, { + quantity: qty - drop, + wagonsRequired: round2((qty - drop) * wagonsPerUnitForSize(Number(size))), + totalVgmTons: round3(Number(line.totalVgmTons) - droppedVgm), + hazardousQuantity: keptUnits.filter((u) => u.isHazardous).length, + reeferQuantity: keptUnits.filter((u) => u.isReefer).length, + }); + } + } + return snapshots; + } + + /** + * Release the cancelled wagons from the schedule at REQUEST time. Returns + * true when something was actually released (booking was on a train) — the + * caller then stamps `releasedAtRequest` so T2 skips its release step. + */ + private async releaseAtRequest(bookingId: string, cut: RequestedCut): Promise { + const had = await this.dataSource.getRepository(WagonBookingAllocation).count({ + where: { bookingId }, + }); + if (had === 0) return false; + + await this.dataSource.transaction(async (manager) => { + if (cut.quantities.units?.length) { + await this.releaseContainerAllocations( + manager, + bookingId, + cut.quantities.units.map((u) => u.containerNumber), + ); + } else if (!cut.quantities.bySize) { + await this.releaseBulkAllocations( + manager, + bookingId, + cut.wagons, + cut.quantities.allocationIds, + ); + } + // Container booking without unit records: nothing to match on — the + // wagons release at T2 via the LIFO trim instead. + }); + + const left = await this.dataSource.getRepository(WagonBookingAllocation).count({ + where: { bookingId }, + }); + return left < had; + } + + /** + * Cut EXACTLY the snapshotted units (specific-wagon cancellation): soft-delete + * them and rebalance each affected line. Returns the snapshots of the units + * actually cut, so drift since the request fails loudly instead of guessing. + */ + private async reduceContainerUnitsExact( + manager: EntityManager, + booking: Booking, + wanted: CancelledUnitSnapshot[], + ): Promise { + const numbers = wanted.map((u) => u.containerNumber); + const lines = await manager.getRepository(BookingContainer).find({ + where: { bookingId: booking.id }, + }); + const cut: CancelledUnitSnapshot[] = []; + for (const line of lines) { + const size = line.containerSize ?? ''; + const lineUnits = await manager.getRepository(BookingContainerUnit).find({ + where: { bookingContainerId: line.id }, + }); + const doomed = lineUnits.filter((u) => numbers.includes(u.containerNumber)); + if (!doomed.length) continue; + + await manager.getRepository(BookingContainerUnit).softDelete(doomed.map((u) => u.id)); + for (const u of doomed) { + cut.push({ + containerSize: size, + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? null, + vgmTons: Number(u.vgmTons), + isHazardous: u.isHazardous, + isReefer: u.isReefer, + }); + } + const kept = lineUnits.filter((u) => !numbers.includes(u.containerNumber)); + if (!kept.length) { + await manager.getRepository(BookingContainer).softDelete(line.id); + continue; + } + const doomedVgm = round3(doomed.reduce((s, u) => s + Number(u.vgmTons || 0), 0)); + await manager.getRepository(BookingContainer).update(line.id, { + quantity: kept.length, + wagonsRequired: round2(kept.length * wagonsPerUnitForSize(Number(size))), + totalVgmTons: round3(Number(line.totalVgmTons) - doomedVgm), + hazardousQuantity: kept.filter((u) => u.isHazardous).length, + reeferQuantity: kept.filter((u) => u.isReefer).length, + }); + } + if (cut.length !== wanted.length) { + throw new BadRequestException( + `Booking changed since the request: ${cut.length}/${wanted.length} selected container(s) still on it.`, + ); + } + return cut; + } + + private async reduceBulk( + manager: EntityManager, + booking: Booking, + tons: number, + ): Promise { + if (tons >= Number(booking.cargoTotalWeightVgm)) { + throw new BadRequestException( + 'Booking changed since the request: the cut no longer leaves any cargo.', + ); + } + if (booking.bulkTotalWeightTons != null) { + const share = tons / Number(booking.cargoTotalWeightVgm); + await manager.getRepository(Booking).update(booking.id, { + bulkTotalWeightTons: round3(Number(booking.bulkTotalWeightTons) * (1 - share)), + }); + } + } + + /** + * Free the wagon capacity of the cancelled container units. Items are matched + * by container number; an allocation left with no items is deleted whole + * (hard delete — the unassignBooking convention for allocation rows). + * A booking not yet placed on a train simply has nothing to release. + */ + private async releaseContainerAllocations( + manager: EntityManager, + bookingId: string, + containerNumbers: string[], + ): Promise { + if (!containerNumbers.length) return; + const allocations = await manager.getRepository(WagonBookingAllocation).find({ + where: { bookingId }, + relations: { containerItems: true }, + }); + for (const alloc of allocations) { + const items = alloc.containerItems ?? []; + const cut = items.filter( + (i) => i.containerNumber && containerNumbers.includes(i.containerNumber), + ); + if (!cut.length) continue; + await manager + .getRepository(WagonAllocationContainerItem) + .delete(cut.map((i) => i.id)); + if (cut.length === items.length) { + await manager.getRepository(WagonBookingAllocation).delete(alloc.id); + } else { + const cutWeight = cut.reduce((s, i) => s + Number(i.grossWeightTons ?? 0), 0); + await manager.getRepository(WagonBookingAllocation).update(alloc.id, { + allocatedWeightTons: round3(Number(alloc.allocatedWeightTons) - cutWeight), + }); + } + } + } + + /** + * Free whole bulk wagons — the customer-picked allocations when given + * (specific-wagon cancel), topping up newest-first for any picked id that no + * longer exists (re-batch between request and fee payment). + */ + private async releaseBulkAllocations( + manager: EntityManager, + bookingId: string, + wagons: number, + pickedIds?: string[], + ): Promise { + const toFree = Math.round(wagons); + if (toFree <= 0) return; + let allocations: WagonBookingAllocation[] = []; + if (pickedIds?.length) { + allocations = await manager.getRepository(WagonBookingAllocation).find({ + where: { id: In(pickedIds), bookingId }, + }); + } + if (allocations.length < toFree) { + const have = new Set(allocations.map((a) => a.id)); + const fill = await manager.getRepository(WagonBookingAllocation).find({ + where: { bookingId }, + order: { createdAt: 'DESC' }, + }); + for (const a of fill) { + if (allocations.length >= toFree) break; + if (!have.has(a.id)) allocations.push(a); + } + } + allocations = allocations.slice(0, toFree); + if (!allocations.length) return; + const ids = allocations.map((a) => a.id); + await manager + .getRepository(WagonAllocationBulkLoad) + .delete({ wagonBookingAllocationId: In(ids) }); + await manager.getRepository(WagonBookingAllocation).delete(ids); + } + + /** Pre-reduction quantities snapshot (only when the booking was never split before). */ + private async currentQuantities( + manager: EntityManager, + booking: Booking, + _droppedWeight: number, + ): Promise<{ bulkTons?: number; bySize?: Record }> { + if (booking.freightType !== 'CONTAINER') { + return { bulkTons: Number(booking.cargoTotalWeightVgm) }; + } + // Lines were already reduced inside this transaction — read them with + // deleted rows included to reconstruct the pre-cut ledger. + const lines = await manager.getRepository(BookingContainer).find({ + where: { bookingId: booking.id }, + withDeleted: true, + }); + const bySize: Record = {}; + for (const line of lines) { + const size = line.containerSize ?? ''; + bySize[size] = (bySize[size] ?? 0) + Number(line.quantity ?? 0); + } + return { bySize }; + } + + /** The create-DTO that reconstructs the cancelled cargo on the chosen day. */ + private buildRebookDto( + row: BookingWagonCancellation, + scheduledDate: string, + ): CreateBookingUnderContractDto { + const dto: CreateBookingUnderContractDto = { scheduledDate }; + const q = row.cancelledQuantities; + + if (q.bySize && Object.keys(q.bySize).length) { + const units = q.units ?? []; + dto.containers = Object.entries(q.bySize).map(([size, quantity]) => { + const sized = units.filter((u) => u.containerSize === size); + if (sized.length !== quantity) { + throw new BadRequestException( + `Credit is missing unit snapshots for size ${size} (${sized.length}/${quantity}) — contact EDR support.`, + ); + } + return { + containerSize: size, + quantity, + units: sized.map((u) => ({ + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? undefined, + vgmTons: u.vgmTons, + isHazardous: u.isHazardous, + isReefer: u.isReefer, + })), + hazardousQuantity: sized.filter((u) => u.isHazardous).length, + reeferQuantity: sized.filter((u) => u.isReefer).length, + }; + }); + return dto; + } + + dto.bulkLines = [{ cargoWeightTons: Number(q.bulkTons ?? row.weightTons) }]; + return dto; + } + + /** + * Carry the source booking's finished clearance onto the rebooked one: the + * cargo is already cleared; a new train date needs no new customs cycle. + * Seeds the standard milestone set idempotently, then mirrors every + * non-pending milestone status from the source by milestone code. + */ + private async copyClearanceState(source: Booking, newBookingId: string): Promise { + const repo = this.dataSource.getRepository(ClearanceMilestone); + const sourceMilestones = await repo.find({ where: { bookingId: source.id } }); + if (!sourceMilestones.length) return; + + try { + await this.clearanceMilestones.ensureBookingMilestones( + newBookingId, + source.tradeDirection, + ); + const targets = await repo.find({ where: { bookingId: newBookingId } }); + const byCode = new Map(targets.map((m) => [m.milestoneCode, m])); + for (const src of sourceMilestones) { + if (src.status === 'PENDING') continue; + const target = byCode.get(src.milestoneCode); + if (!target) continue; + await repo.update(target.id, { + status: src.status, + triggeredAt: src.triggeredAt, + triggeredByUserId: src.triggeredByUserId, + triggeredByDoc: src.triggeredByDoc, + note: src.note, + metadata: src.metadata, + }); + } + if (source.clearanceCurrentPhase) { + await this.dataSource.getRepository(Booking).update(newBookingId, { + clearanceCurrentPhase: source.clearanceCurrentPhase, + preClearanceFinalizedAt: source.preClearanceFinalizedAt, + dutyRequired: source.dutyRequired, + }); + } + } catch (err) { + // Clearance copy must never lose a paid rebooking — staff can re-complete + // milestones by hand if this ever fails. + this.logger.error( + `Clearance copy ${source.id} → ${newBookingId} failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + private notifyCustomer(booking: Booking, title: string, body: string, linkBookingId?: string): void { + void this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title, + body, + link: `/bookings/${linkBookingId ?? booking.id}`, + data: { bookingId: linkBookingId ?? booking.id, reference: booking.reference }, + }); + } + + private notifyStaff(booking: Booking, title: string, body: string): void { + void this.inbox.notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.BOOKING_STATUS, + title, + body, + link: `/bookings/${booking.id}`, + data: { bookingId: booking.id, reference: booking.reference }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellations.repository.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellations.repository.ts new file mode 100644 index 000000000..64cde269f --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellations.repository.ts @@ -0,0 +1,85 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, SelectQueryBuilder } from 'typeorm'; + +import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity'; + +export interface WagonCancellationListFilter { + status?: string[]; + /** Booking reference / company name search (staff list). */ + search?: string; + companyId?: string; + bookingId?: string; + from?: Date; + to?: Date; + page?: number; + pageSize?: number; +} + +@Injectable() +export class BookingWagonCancellationsRepository extends BaseRepository { + constructor( + @InjectRepository(BookingWagonCancellation) + repository: Repository, + ) { + super(repository); + } + + /** The one open (fee-unpaid) cancellation of a booking, if any. */ + findOpenForBooking(bookingId: string): Promise { + return this.repository.findOne({ + where: { bookingId, status: 'FEE_PENDING' }, + }); + } + + findByFeeInvoiceId(feeInvoiceId: string): Promise { + return this.repository.findOne({ where: { feeInvoiceId } }); + } + + /** Paged history — staff see everything, customers are scoped by companyId. */ + async list( + filter: WagonCancellationListFilter, + ): Promise<{ items: BookingWagonCancellation[]; total: number }> { + const page = Math.max(1, filter.page ?? 1); + const pageSize = Math.min(100, Math.max(1, filter.pageSize ?? 10)); + + const qb = this.baseQuery(); + if (filter.bookingId) { + qb.andWhere('(bwc.booking_id = :bookingId OR bwc.rebooked_booking_id = :bookingId)', { + bookingId: filter.bookingId, + }); + } + if (filter.companyId) { + qb.andWhere('booking.company_id = :companyId', { companyId: filter.companyId }); + } + if (filter.status?.length) { + qb.andWhere('bwc.status IN (:...statuses)', { statuses: filter.status }); + } + if (filter.search) { + qb.andWhere('(booking.reference ILIKE :search OR company.name ILIKE :search)', { + search: `%${filter.search}%`, + }); + } + if (filter.from) qb.andWhere('bwc.created_at >= :from', { from: filter.from }); + if (filter.to) qb.andWhere('bwc.created_at <= :to', { to: filter.to }); + + // Property path (not raw column): skip/take builds a distinct-id subquery + // and the ORDER BY must resolve inside it. + const [items, total] = await qb + .orderBy('bwc.createdAt', 'DESC') + .skip((page - 1) * pageSize) + .take(pageSize) + .getManyAndCount(); + return { items, total }; + } + + private baseQuery(): SelectQueryBuilder { + return this.repository + .createQueryBuilder('bwc') + .leftJoinAndSelect('bwc.booking', 'booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('bwc.rebookedBooking', 'rebookedBooking') + .leftJoinAndSelect('bwc.feeInvoice', 'feeInvoice'); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index f214b3308..a4ba83dd9 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -72,6 +72,12 @@ import { GenerateGrnDto } from './dto/generate-grn.dto'; import { ContainerReceiptService } from './container-receipt.service'; import { SignContractDto } from './dto/sign-contract.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; +import { BookingWagonCancellationService } from './booking-wagon-cancellation.service'; +import { + FilterWagonCancellationsDto, + RebookCancelledWagonsDto, + RequestWagonCancellationDto, +} from './dto/wagon-cancellation.dto'; import { type AuthUserPayload, resolveAuthUserId, @@ -151,6 +157,7 @@ export class BookingsController { private readonly firstMileService: FirstMileService, private readonly lastMileService: LastMileService, private readonly userTradeAccessService: UserTradeAccessService, + private readonly wagonCancellationService: BookingWagonCancellationService, ) {} @Post() @@ -507,6 +514,150 @@ export class BookingsController { res.send(buffer); } + @Get(':id/wagons') + @ApiOperation({ + summary: + 'Allocated wagons for a booking (JSON) — empty until the paid booking is placed on a train', + }) + async wagonAllocations( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.bookingsService.wagonAllocations(id); + } + + // ── Partial wagon cancellation (paid bookings) ──────────────────────────── + // Customer endpoints are ownership-scoped (no portal permission keys); the + // staff history/void/rebook variants are permission-gated below. + + @Post(':id/wagon-cancellations/preview') + @ApiOperation({ summary: 'Preview the fee/credit of a partial wagon cancellation (no writes)' }) + async previewWagonCancellation( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RequestWagonCancellationDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.wagonCancellationService.previewCancellation(id, dto); + } + + @Post(':id/wagon-cancellations') + @ApiOperation({ + summary: + 'Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles', + }) + async requestWagonCancellation( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RequestWagonCancellationDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.wagonCancellationService.requestCancellation(id, dto, user?.id); + } + + @Get(':id/wagon-cancellations') + @ApiOperation({ summary: 'Wagon-cancellation history of one booking (owner or staff)' }) + async listBookingWagonCancellations( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + const staff = + hasFreightPermission(user, FREIGHT_PERMS.bookings.view) || + hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView); + if (!staff) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.wagonCancellationService.list({ bookingId: id, pageSize: 100 }); + } + + @Get('wagon-cancellations/my') + @ApiOperation({ summary: 'Wagon-cancellation history of the calling customer (paginated, filterable)' }) + async listMyWagonCancellations( + @Query() filter: FilterWagonCancellationsDto, + @CurrentUser() user: TCurrentUser, + ) { + const companyId = await this.bookingsService.resolveCustomerCompanyId(user?.id ?? ''); + if (!companyId) throw new ForbiddenException('No customer company for this user.'); + return this.wagonCancellationService.list({ + companyId, + status: filter.statuses, + search: filter.search, + from: filter.from ? new Date(filter.from) : undefined, + to: filter.to ? new Date(filter.to) : undefined, + page: filter.page, + pageSize: filter.pageSize, + }); + } + + @Get('wagon-cancellations/history') + @WagonCancellationView() + @ApiOperation({ summary: 'All wagon cancellations (staff, paginated, filterable)' }) + async listAllWagonCancellations(@Query() filter: FilterWagonCancellationsDto) { + return this.wagonCancellationService.list({ + status: filter.statuses, + search: filter.search, + from: filter.from ? new Date(filter.from) : undefined, + to: filter.to ? new Date(filter.to) : undefined, + page: filter.page, + pageSize: filter.pageSize, + }); + } + + @Post('wagon-cancellations/:cancellationId/withdraw') + @ApiOperation({ summary: 'Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)' }) + async withdrawWagonCancellation( + @Param('cancellationId', ParseUUIDPipe) cancellationId: string, + @CurrentUser() user: TCurrentUser, + ) { + await this.assertWagonCancellationActor( + cancellationId, + user, + FREIGHT_PERMS.bookings.wagonCancellationVoid, + ); + return this.wagonCancellationService.withdraw(cancellationId); + } + + @Post('wagon-cancellations/:cancellationId/rebook') + @ApiOperation({ + summary: + 'Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)', + }) + async rebookWagonCancellation( + @Param('cancellationId', ParseUUIDPipe) cancellationId: string, + @Body() dto: RebookCancelledWagonsDto, + @CurrentUser() user: TCurrentUser, + ) { + await this.assertWagonCancellationActor( + cancellationId, + user, + FREIGHT_PERMS.bookings.wagonCancellationRebook, + ); + return this.wagonCancellationService.rebook(cancellationId, dto, user?.id); + } + + /** Owner-or-staff gate shared by the per-cancellation actions. */ + private async assertWagonCancellationActor( + cancellationId: string, + user: TCurrentUser, + staffPermission: string, + ): Promise { + if (hasFreightPermission(user, staffPermission)) return; + const row = await this.wagonCancellationService.findById(cancellationId); + const booking = await this.bookingsService.findById(row.bookingId); + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + @Get(':id/customer-trucks') @MixedAudience([ FREIGHT_PERMS.bookings.view, @@ -1382,6 +1533,19 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(":id/customer-cancel") + @ApiOperation({ + summary: + "Customer cancels their own booking before payment — no cancellation fee", + }) + async customerCancel( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: RejectBookingDto, + ) { + const booking = await this.transitionService.customerCancel(id, dto.reason); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(":id/cancel-hold") @PortalCustomer() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 16c381697..658629592 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -39,6 +39,9 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingContractSignature } from './entities/booking-contract-signature.entity'; import { BookingReviewNote } from './entities/booking-review-note.entity'; import { Booking } from './entities/booking.entity'; +import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity'; +import { BookingWagonCancellationsRepository } from './booking-wagon-cancellations.repository'; +import { BookingWagonCancellationService } from './booking-wagon-cancellation.service'; import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; @@ -66,6 +69,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingReviewNote, BookingContractSignature, BookingContainerAllocation, + BookingWagonCancellation, CustomerTruckAssignment, CustomerTruckContainer, ]), @@ -110,6 +114,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; CustomerTruckAssignmentsRepository, CustomerTruckService, ContainerReceiptService, + BookingWagonCancellationsRepository, + BookingWagonCancellationService, ], exports: [ BookingsService, @@ -121,6 +127,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; ConsolidationService, CustomerTruckService, ContainerReceiptService, + BookingWagonCancellationService, ], }) export class BookingsModule { } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 12c56ac13..e4b902fe1 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -339,6 +339,64 @@ export class BookingsService { }; } + /** + * Allocated wagons of a booking as JSON — the portal's "Wagons" tab. Same + * join chain as the carriage acceptance sheet, but structured (containers as + * an array per wagon, bulk load description when the wagon carries bulk). + * Empty array until the booking has been allocated onto a train. + */ + async wagonAllocations(bookingId: string): Promise { + return this.dataSource.query( + `SELECT a.id AS "allocationId", + tsw.sequence_no AS "sequenceNo", + w.wagon_number AS "wagonNumber", + COALESCE(wt.name, wt.code) AS "wagonType", + wt.code AS "wagonTypeCode", + wt.tare_weight_tons AS "tareWeightTons", + tsw.capacity_tons AS "capacityTons", + tsw.length_meters AS "lengthMeters", + a.allocated_weight_tons AS "allocatedWeightTons", + a.load_type AS "loadType", + a.status AS "status", + s.train_number AS "trainNumber", + s.scheduled_departure_date AS "departureAt", + so.label AS "originStation", + sd.label AS "destinationStation", + bl.cargo_description AS "bulkCargoDescription", + bl.quantity AS "bulkQuantity", + COALESCE( + json_agg( + json_build_object( + 'containerNumber', ci.container_number, + 'sealNumber', ci.seal_number, + 'positionOnWagon', ci.position_on_wagon, + 'grossWeightTons', ci.gross_weight_tons + ) ORDER BY ci.position_on_wagon, ci.container_number + ) FILTER (WHERE ci.id IS NOT NULL), + '[]' + ) AS "containers" + FROM freight.wagon_booking_allocations a + JOIN freight.train_set_wagons tsw + ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL + LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id + LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + LEFT JOIN freight.train_schedules s + ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL + LEFT JOIN freight.yards so ON so.id = s.origin_station_id + LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id + LEFT JOIN freight.wagon_allocation_container_items ci + ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL + LEFT JOIN freight.wagon_allocation_bulk_loads bl + ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL + WHERE a.booking_id = $1 AND a.deleted_at IS NULL + GROUP BY tsw.id, a.id, w.wagon_number, wt.name, wt.code, wt.tare_weight_tons, + s.train_number, s.scheduled_departure_date, so.label, sd.label, + bl.cargo_description, bl.quantity + ORDER BY tsw.sequence_no`, + [bookingId], + ); + } + /** * Split the booking amount across its wagons, proportional to allocated weight * (equal shares when no weights are recorded). The last row absorbs the rounding @@ -1425,7 +1483,46 @@ export class BookingsService { tradeDirection, ); } - if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate); + // Re-pinning the departure day on an edit (e.g. fixing a CHANGES_REQUESTED + // booking) must obey the same gate as creation: the route needs an OPEN + // departure on that EAT day that can carry the cargo. Skipped when the day + // didn't change, for general contracts (period-based, no pinned day) and + // for intercity (staff assign a passing train later). + if (dto.scheduledDate) { + const day = eatDay(new Date(dto.scheduledDate)); + const dayChanged = + !existing.scheduledDate || eatDay(existing.scheduledDate) !== day; + if ( + dayChanged && + existing.bookingType !== 'GENERAL_CONTRACT' && + tradeDirection !== 'DOMESTIC' + ) { + const { hasDeparture, hasCompatible } = + await this.trainSchedulingService.checkDayCargoCompatibility( + originYardId, + destinationYardId, + day, + { + freightType: freightType as 'CONTAINER' | 'BULK', + cargoTypeId, + containerTypeIds: containers + .map((c) => c.containerTypeId) + .filter((cid): cid is string => Boolean(cid)), + }, + ); + if (!hasDeparture) { + throw new BadRequestException( + 'No departures available on the selected day for this route', + ); + } + if (!hasCompatible) { + throw new BadRequestException( + 'No wagon on the selected day can carry this cargo type — please choose another day', + ); + } + } + updates.scheduledDate = new Date(dto.scheduledDate); + } if (dto.estimatedShipmentDate) updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate); if (dto.startDate) updates.startDate = new Date(dto.startDate); diff --git a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts new file mode 100644 index 000000000..6a5dfe112 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts @@ -0,0 +1,112 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayNotEmpty, + IsArray, + IsDateString, + IsIn, + IsInt, + IsNumber, + IsOptional, + IsString, + IsUUID, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; + +import { WAGON_CANCELLATION_STATUSES } from '../entities/booking-wagon-cancellation.entity'; + +export class CancelContainerLineDto { + @ApiProperty({ description: 'Container size (ft) as stored on the booking line, e.g. "20", "40"' }) + @IsString() + containerSize!: string; + + @ApiProperty({ description: 'How many units of this size to cancel' }) + @IsInt() + @Min(1) + quantity!: number; +} + +export class RequestWagonCancellationDto { + @ApiPropertyOptional({ + description: + 'Cancel SPECIFIC allocated wagons: wagon_booking_allocation ids from GET /bookings/:id/wagons. ' + + 'When set, wagons/containers are derived from the selected wagons and the other fields are ignored.', + type: [String], + }) + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + wagonAllocationIds?: string[]; + + @ApiPropertyOptional({ + description: 'BULK bookings: number of wagons to cancel (tons derived proportionally)', + }) + @IsOptional() + @IsNumber() + @Min(0.5) + wagons?: number; + + @ApiPropertyOptional({ + description: 'CONTAINER bookings: units to cancel per size (wagons derived per size)', + type: [CancelContainerLineDto], + }) + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => CancelContainerLineDto) + containers?: CancelContainerLineDto[]; + + @ApiPropertyOptional({ description: 'Customer reason for the cancellation' }) + @IsOptional() + @IsString() + @MaxLength(1000) + reason?: string; +} + +export class RebookCancelledWagonsDto { + @ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' }) + @IsDateString() + scheduledDate!: string; +} + +export class FilterWagonCancellationsDto { + @ApiPropertyOptional({ enum: WAGON_CANCELLATION_STATUSES, isArray: true }) + @IsOptional() + @IsArray() + @IsIn(WAGON_CANCELLATION_STATUSES as readonly string[], { each: true }) + statuses?: string[]; + + @ApiPropertyOptional({ description: 'Booking reference / company name search' }) + @IsOptional() + @IsString() + @MaxLength(120) + search?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + from?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + to?: string; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ default: 10 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts new file mode 100644 index 000000000..723ea5d1c --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts @@ -0,0 +1,137 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Invoice } from '../../billing/entities/invoice.entity'; +import { Rate } from '../../rule-engine/entities/rate.entity'; +import { Booking } from './booking.entity'; + +export const WAGON_CANCELLATION_STATUSES = [ + // Requested; fee invoice open; wagons still allocated to the customer. + 'FEE_PENDING', + // Fee settled; booking reduced, wagons freed; credit waiting for a rebook. + 'CREDIT_AVAILABLE', + // Credit redeemed into a new PAID booking (rebookedBookingId). + 'REBOOKED', + // Customer/staff voided the request before paying the fee. Nothing changed. + 'WITHDRAWN', + // Reserved for a future expiry policy; not set by code today. + 'EXPIRED', +] as const; + +export type WagonCancellationStatus = (typeof WAGON_CANCELLATION_STATUSES)[number]; + +/** Snapshot of one physical container unit cut by the cancellation. */ +export interface CancelledUnitSnapshot { + containerSize: string; + containerNumber: string; + sealNumber?: string | null; + vgmTons: number; + isHazardous: boolean; + isReefer: boolean; +} + +/** What the cancellation cut, in the booking's own quantity terms. */ +export interface CancelledQuantities { + /** Bulk bookings: tons cut (PER_ITEM cargo: item count, matching cargoTotalWeightVgm). */ + bulkTons?: number; + /** Container bookings: units cut per container size. */ + bySize?: Record; + /** + * Container bookings: the exact physical units cut. Snapshotted at request + * time when the customer picked specific wagons, otherwise at fee settlement + * (LIFO trim). The rebook reconstructs the new booking from THESE — never + * from a soft-deleted-row scan, which could pick up units dropped by an + * unrelated batch split on the same booking. + */ + units?: CancelledUnitSnapshot[]; + /** + * Specific-wagon cancellation: the wagon_booking_allocation ids the customer + * picked in the Wagons tab. T2 releases exactly these (fallback to + * newest-first for any id that no longer exists, e.g. after a re-batch). + */ + allocationIds?: string[]; + /** + * The wagon allocations were already released from the schedule at REQUEST + * time (policy: wagons free up immediately; the fee is still owed before the + * credit can be rebooked). Tells T2 to skip its release step so it never + * deletes wagons the batch engine re-assigned in between. + */ + releasedAtRequest?: boolean; +} + +/** + * One partial-wagon-cancellation cycle on a PAID booking — the audit trail and + * the state machine. The credit itself is not a wallet balance: redeeming it + * creates a real booking through the under-contract create path and marks it + * PAID (see BookingWagonCancellationService). + */ +@Entity({ schema: 'freight', name: 'booking_wagon_cancellations' }) +@Index(['bookingId']) +@Index(['status']) +export class BookingWagonCancellation extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'rebooked_booking_id', type: 'uuid', nullable: true }) + rebookedBookingId?: string | null; + + @ManyToOne(() => Booking, { nullable: true }) + @JoinColumn({ name: 'rebooked_booking_id' }) + rebookedBooking?: Booking | null; + + @Column({ name: 'wagons_cancelled', type: 'numeric', precision: 6, scale: 2 }) + wagonsCancelled!: number; + + @Column({ name: 'weight_tons', type: 'numeric', precision: 12, scale: 3, default: 0 }) + weightTons!: number; + + @Column({ name: 'cancelled_quantities', type: 'jsonb' }) + cancelledQuantities!: CancelledQuantities; + + /** + * The freight value of the cancelled part at the ORIGINAL booking's price — + * informational (shown to the customer as "credit worth"); no refund is ever + * issued from it, the credit is redeemed by rebooking. + */ + @Column({ name: 'credit_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) + creditAmount!: number; + + @Column({ name: 'fee_rate_id', type: 'uuid', nullable: true }) + feeRateId?: string | null; + + @ManyToOne(() => Rate, { nullable: true }) + @JoinColumn({ name: 'fee_rate_id' }) + feeRate?: Rate | null; + + @Column({ name: 'fee_amount', type: 'numeric', precision: 14, scale: 2 }) + feeAmount!: number; + + @Column({ name: 'fee_currency', type: 'varchar', length: 8, default: 'ETB' }) + feeCurrency!: string; + + @Column({ name: 'fee_invoice_id', type: 'uuid', nullable: true }) + feeInvoiceId?: string | null; + + @ManyToOne(() => Invoice, { nullable: true }) + @JoinColumn({ name: 'fee_invoice_id' }) + feeInvoice?: Invoice | null; + + @Column({ name: 'fee_paid_at', type: 'timestamptz', nullable: true }) + feePaidAt?: Date | null; + + @Column({ name: 'status', type: 'varchar', length: 30, default: 'FEE_PENDING' }) + status!: string; + + @Column({ name: 'reason', type: 'text', nullable: true }) + reason?: string | null; + + @Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true }) + requestedByUserId?: string | null; + + @Column({ name: 'rebooked_at', type: 'timestamptz', nullable: true }) + rebookedAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts new file mode 100644 index 000000000..d5d15d73d --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts @@ -0,0 +1,115 @@ +import { ContractBookingService } from './contract-booking.service'; + +/** + * OPERATION_CHANGES_REQUESTED resubmit with restated cargo. Operations can ask + * for the cargo itself to change, so a completion payload that restates + * containers must cancel the unpaid invoice, wipe the persisted cargo and + * re-run the fresh-completion path (re-persist, re-price, re-invoice). A + * payload without cargo keeps the day-only resubmit behavior. + */ +describe('ContractBookingService — changes-requested resubmit restating cargo', () => { + const CONTRACT = { + id: 'c-1', + reference: 'CTR-1', + contractKind: 'GENERAL', + freightType: 'CONTAINER', + tradeDirection: 'IMPORT', + customsClearingEnabled: false, + contractValidUntil: null, + cargoScope: [], + }; + + const bookingWithCargo = () => ({ + id: 'b-1', + contractId: 'c-1', + reference: 'BKG-1', + status: 'OPERATION_CHANGES_REQUESTED', + bookingContainers: [{ containerSize: '20FT', quantity: 4 }], + cargoTotalWeightVgm: 80, + originYardId: 'y-o', + destinationYardId: 'y-d', + }); + + function makeService() { + const bookingsRepository = { + findByIdWithFiles: jest.fn().mockResolvedValue(bookingWithCargo()), + deleteContainers: jest.fn().mockResolvedValue(undefined), + update: jest.fn().mockResolvedValue(undefined), + }; + const invoiceService = { + cancelUnpaidInvoiceForBooking: jest.fn().mockResolvedValue(undefined), + }; + const trainSchedulingService = { + assertBookingWindowOpen: jest.fn().mockResolvedValue(undefined), + }; + const contractsRepository = { + findByIdWithRelations: jest.fn().mockResolvedValue(CONTRACT), + }; + const service = new ContractBookingService( + contractsRepository as never, + bookingsRepository as never, + {} as never, // bookingPricingService + {} as never, // consolidationService + {} as never, // containerTypesService + {} as never, // ruleEngineService + {} as never, // milestoneService + invoiceService as never, + {} as never, // bookingNotifier + {} as never, // dataSource + trainSchedulingService as never, + {} as never, // bookingBatchService + {} as never, // bookingTransitionService + ); + return { service, bookingsRepository, invoiceService }; + } + + // Both paths dead-end into a downstream private assert we replace with a + // sentinel — which path threw tells us which branch the resubmit took. + const SENTINEL = new Error('reached-branch'); + + it('restated cargo cancels the invoice, wipes cargo and re-runs fresh completion', async () => { + const { service, bookingsRepository, invoiceService } = makeService(); + // First gate inside the fresh-completion (!hasCargo) path. + jest + .spyOn( + service as never as { assertWithinQuantityCap: () => Promise }, + 'assertWithinQuantityCap', + ) + .mockRejectedValue(SENTINEL); + + await expect( + service.completeUnderContract('c-1', 'b-1', { + scheduledDate: new Date().toISOString(), + containers: [{ containerSize: '20FT', quantity: 2 }], + } as never), + ).rejects.toBe(SENTINEL); + + expect(invoiceService.cancelUnpaidInvoiceForBooking).toHaveBeenCalledWith('b-1'); + expect(bookingsRepository.deleteContainers).toHaveBeenCalledWith('b-1'); + expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', { + cargoTotalWeightVgm: 0, + }); + }); + + it('a day-only resubmit keeps the persisted cargo and invoice untouched', async () => { + const { service, bookingsRepository, invoiceService } = makeService(); + // First call inside the day-only (hasCargo) resubmit path. + jest + .spyOn( + service as never as { + assertPersistedContainersAvailable: () => Promise; + }, + 'assertPersistedContainersAvailable', + ) + .mockRejectedValue(SENTINEL); + + await expect( + service.completeUnderContract('c-1', 'b-1', { + scheduledDate: new Date().toISOString(), + } as never), + ).rejects.toBe(SENTINEL); + + expect(invoiceService.cancelUnpaidInvoiceForBooking).not.toHaveBeenCalled(); + expect(bookingsRepository.deleteContainers).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 7e0a23e3f..59afdb585 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -98,6 +98,9 @@ export class ContractBookingService { private readonly containerTypesService: ContainerTypesService, private readonly ruleEngineService: RuleEngineService, private readonly milestoneService: ClearanceMilestoneService, + // forwardRef: part of the booking-invoice ⇄ wagon-cancellation ⇄ contracts + // require cycle (see BookingTransitionService). + @Inject(forwardRef(() => BookingInvoiceService)) private readonly invoiceService: BookingInvoiceService, private readonly bookingNotifier: BookingLifecycleNotifierService, private readonly dataSource: DataSource, @@ -692,11 +695,30 @@ export class ContractBookingService { }); const freightType = contract.freightType; - const hasCargo = + let hasCargo = (booking.bookingContainers?.length ?? 0) > 0 || Number(booking.cargoTotalWeightVgm) > 0; const warnings: string[] = []; + // Operations may return a booking asking for the CARGO to change (fewer or + // more containers), not just the day. A resubmit whose payload restates the + // cargo therefore starts the completion over: cancel the unpaid invoice + // first (it throws if money is already recorded — cargo must not change + // under a paid invoice), then wipe the persisted cargo so the fresh- + // completion path below re-persists, re-prices and re-invoices from the + // payload. A resubmit without cargo keeps today's day-only behavior. + const restatesCargo = Boolean( + dto.containers?.length || dto.bulkLines?.length, + ); + if (hasCargo && restatesCargo) { + await this.invoiceService.cancelUnpaidInvoiceForBooking(booking.id); + await this.bookingsRepository.deleteContainers(booking.id); + await this.bookingsRepository.update(booking.id, { + cargoTotalWeightVgm: 0, + } as never); + hasCargo = false; + } + // EXPORT rides whole or not at all (no split concept): the chosen day must // have a single open train that carries the whole booking. First completion // sizes from the dto's cargo; a changes-requested resubmit (cargo already @@ -1863,6 +1885,9 @@ export class ContractBookingService { async validateShipment( contractId: string, dto: CreateBookingUnderContractDto, + // Completion/resubmit preview: the booking being completed must not clash + // with its own persisted containers. + excludeBookingId?: string, ): Promise<{ overweightLines: Array<{ containerTypeCode: string; @@ -2024,6 +2049,7 @@ export class ContractBookingService { originYardId: route?.originYardId, destinationYardId: route?.destinationYardId, }, + excludeBookingId, ); containerClashErrors = clashes.map( (c) => diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 10a1f42d2..6273be365 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -1145,8 +1145,11 @@ export class ContractsController { validateShipment( @Param('id', ParseUUIDPipe) id: string, @Body() dto: CreateBookingUnderContractDto, + // Completion/resubmit preview: exclude this booking's own persisted + // containers from the same-train clash check. + @Query('bookingId') bookingId?: string, ) { - return this.contractBookingService.validateShipment(id, dto); + return this.contractBookingService.validateShipment(id, dto, bookingId); } @Get(':id/capacity') diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts index 45370f546..b7b907e5b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -31,6 +31,19 @@ export function paymentDrainMs(): number { ); } +/** + * ISO timestamp of the end of a pay window's drain tail, for client display + * (the "payment processing" countdown). Null in ⇒ null out. + */ +export function paymentDrainEndsAtIso( + deadline: Date | string | null | undefined, +): string | null { + if (deadline == null) return null; + const ms = new Date(deadline).getTime(); + if (!Number.isFinite(ms)) return null; + return new Date(ms + paymentDrainMs()).toISOString(); +} + /** * A pay window AND its drain tail have closed. * diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts index 69e60dd3a..dbf0cae96 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts @@ -14,6 +14,7 @@ import { Server, Socket } from 'socket.io'; import { WsAuthService } from '../notification-inbox/ws-auth.service'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { paymentDrainEndsAtIso } from './booking-batch.constants'; /** * Server → client push for booking-window state changes. Same handshake model @@ -61,6 +62,7 @@ export class BookingWindowGateway implements OnGatewayConnection { windowClosesAt: schedule.windowClosesAt?.toISOString() ?? null, docReviewEndsAt: schedule.docReviewEndsAt?.toISOString() ?? null, paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null, + paymentDrainEndsAt: paymentDrainEndsAtIso(schedule.paymentPhaseEndsAt), scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null, }; this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/pay-window-drain.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/pay-window-drain.spec.ts index 6054f28b2..bde0e88ec 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/pay-window-drain.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/pay-window-drain.spec.ts @@ -1,5 +1,6 @@ import { DEFAULT_PAYMENT_DRAIN_MINUTES, + paymentDrainEndsAtIso, paymentDrainMs, payWindowLapsed, } from "./booking-batch.constants"; @@ -64,4 +65,16 @@ describe("payWindowLapsed — pay-window drain tail", () => { expect(paymentDrainMs()).toBe(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN); } }); + + it("paymentDrainEndsAtIso reports deadline + drain, null/garbage-safe", () => { + expect(paymentDrainEndsAtIso(deadline)).toBe( + new Date(at(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN)).toISOString(), + ); + expect(paymentDrainEndsAtIso(deadline.toISOString())).toBe( + new Date(at(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN)).toISOString(), + ); + expect(paymentDrainEndsAtIso(null)).toBeNull(); + expect(paymentDrainEndsAtIso(undefined)).toBeNull(); + expect(paymentDrainEndsAtIso("not-a-date")).toBeNull(); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index df5c44fde..9a03f665c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -153,6 +153,7 @@ import { DEFAULT_CONTAINER_WAGON_CAPACITY_TONS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_TARE_TONS, + paymentDrainEndsAtIso, } from './booking-batch.constants'; import { orderConsistWagons } from './consist-order.util'; import { @@ -1546,23 +1547,10 @@ export class TrainSchedulingService { } : globalCfg; - // Staff cannot schedule inside the lead window — there must be room for a - // booking window before departure. IMPORT/DOMESTIC lead is in whole EAT - // days (lead 3, today 11th → first allowed departure is the 14th); EXPORT - // lead is in hours (24h = 1 day ahead). Checked against the schedule's OWN - // lead, so a custom lead is honoured rather than rejected by the global one. - const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date()); - if (departure.getTime() < earliest.getTime()) { - const detail = - direction === 'EXPORT' - ? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead` - : `at least ${windowCfg.importWindowLeadDays} day(s) ahead`; - throw new BadRequestException( - `Departure ${departure.toISOString()} is inside the booking lead window; ` + - `${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` + - `(earliest ${earliest.toISOString()})`, - ); - } + // Short-notice trains are allowed: a departure inside the booking lead + // window is NOT rejected — the window just opens immediately (opensAt is + // clamped to `now` below) instead of waiting out a lead that has already + // passed. Only `updateScheduleDate` still enforces the lead floor. // Freeze the rule this schedule is born with. A later global-rules edit // only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an @@ -1577,6 +1565,11 @@ export class TrainSchedulingService { ...ruleSnapshot, ...computeImportWindowTimes(departure, windowCfg, new Date()), }; + // Inside-lead departure (e.g. a huge configured lead): the raw open lands + // in the past — clamp it to `now` so the window tick opens it immediately. + if (computedTimes.windowOpensAt.getTime() < Date.now()) { + computedTimes.windowOpensAt = new Date(); + } if ( computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime() ) { @@ -6948,6 +6941,7 @@ export class TrainSchedulingService { windowClosesAt: r.window_closes_at, docReviewEndsAt: r.doc_review_ends_at, paymentPhaseEndsAt: r.payment_phase_ends_at, + paymentDrainEndsAt: paymentDrainEndsAtIso(r.payment_phase_ends_at), bookingWindowStatus: r.booking_window_status, bookingCycleNo: r.booking_cycle_no, departureDate: r.scheduled_departure_date, diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 1465bab85..11f0ee40c 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1,4 +1,4 @@ -const EDR_FREIGHT_APP_KEY = 'edr_freight_app'; +const EDR_FREIGHT_APP_KEY = "edr_freight_app"; export type FreightPermissionSeed = { id: string; @@ -8,33 +8,30 @@ export type FreightPermissionSeed = { }; export const RULE_ENGINE_RESOURCE_SLUGS = [ - 'cargo-types', - 'container-types', - 'wagon-types', - 'service-types', - 'yards', - 'shipping-lines', - 'weight-limit-rules', - 'priority-configs', - 'rates', - 'approval-rules', - 'yard-distances', + "cargo-types", + "container-types", + "wagon-types", + "service-types", + "yards", + "shipping-lines", + "weight-limit-rules", + "priority-configs", + "rates", + "approval-rules", + "yard-distances", // Keep new slugs at the END: ruleEngineCrudId derives ids from list index, // so a mid-list insert would shift ids already seeded for later slugs. - 'truck-types', - 'transit-agents', + "truck-types", + "transit-agents", ] as const; -export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number]; +export type RuleEngineResourceSlug = + (typeof RULE_ENGINE_RESOURCE_SLUGS)[number]; const slugToResourceKey = (slug: RuleEngineResourceSlug): string => - slug.replace(/-/g, '_'); + slug.replace(/-/g, "_"); -const perm = ( - id: string, - key: string, - en: string, -): FreightPermissionSeed => ({ +const perm = (id: string, key: string, en: string): FreightPermissionSeed => ({ id, key, name: { am: en, en }, @@ -42,30 +39,135 @@ const perm = ( }); export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ - perm('a1000001-0001-4000-8000-000000000001', 'edr_freight_app:bookings:view', 'View bookings'), - perm('a1000001-0001-4000-8000-000000000002', 'edr_freight_app:bookings:staff_accept', 'Accept booking intake'), - perm('a1000001-0001-4000-8000-000000000003', 'edr_freight_app:bookings:request_changes', 'Request booking changes'), - perm('a1000001-0001-4000-8000-000000000004', 'edr_freight_app:bookings:reject', 'Reject booking submission'), - perm('a1000001-0001-4000-8000-000000000005', 'edr_freight_app:bookings:approve_line_staff', 'Approve as line staff'), - perm('a1000001-0001-4000-8000-000000000006', 'edr_freight_app:bookings:approve_director', 'Approve as director'), - perm('a1000001-0001-4000-8000-000000000007', 'edr_freight_app:bookings:approve_ceo', 'Approve as CEO'), - perm('a1000001-0001-4000-8000-000000000008', 'edr_freight_app:bookings:reject_approval', 'Reject at approval step'), - perm('a1000001-0001-4000-8000-000000000009', 'edr_freight_app:bookings:generate_contract', 'Generate contract'), - perm('a1000001-0001-4000-8000-00000000000a', 'edr_freight_app:bookings:sign_staff', 'Staff contract signature'), - perm('a1000001-0001-4000-8000-00000000000d', 'edr_freight_app:bookings:operations', 'Booking operations'), - perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'), - perm('a1000001-0001-4000-8000-000000000023', 'edr_freight_app:bookings:clearance_view', 'View customs-clearance queue'), - perm('a1000001-0001-4000-8000-000000000020', 'edr_freight_app:bookings:review_documents', 'Review clearance documents'), - perm('a1000001-0001-4000-8000-000000000021', 'edr_freight_app:bookings:upload_clearance_output', 'Upload customs output documents'), - perm('a1000001-0001-4000-8000-000000000022', 'edr_freight_app:bookings:finalize_clearance', 'Finalize document clearance'), - perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'), - perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'), - perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'), - perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'), - perm('a1000001-0001-4000-8000-000000000024', 'edr_freight_app:bookings:create', 'Create booking'), + perm( + "a1000001-0001-4000-8000-000000000001", + "edr_freight_app:bookings:view", + "View bookings", + ), + perm( + "a1000001-0001-4000-8000-000000000002", + "edr_freight_app:bookings:staff_accept", + "Accept booking intake", + ), + perm( + "a1000001-0001-4000-8000-000000000003", + "edr_freight_app:bookings:request_changes", + "Request booking changes", + ), + perm( + "a1000001-0001-4000-8000-000000000004", + "edr_freight_app:bookings:reject", + "Reject booking submission", + ), + perm( + "a1000001-0001-4000-8000-000000000005", + "edr_freight_app:bookings:approve_line_staff", + "Approve as line staff", + ), + perm( + "a1000001-0001-4000-8000-000000000006", + "edr_freight_app:bookings:approve_director", + "Approve as director", + ), + perm( + "a1000001-0001-4000-8000-000000000007", + "edr_freight_app:bookings:approve_ceo", + "Approve as CEO", + ), + perm( + "a1000001-0001-4000-8000-000000000008", + "edr_freight_app:bookings:reject_approval", + "Reject at approval step", + ), + perm( + "a1000001-0001-4000-8000-000000000009", + "edr_freight_app:bookings:generate_contract", + "Generate contract", + ), + perm( + "a1000001-0001-4000-8000-00000000000a", + "edr_freight_app:bookings:sign_staff", + "Staff contract signature", + ), + perm( + "a1000001-0001-4000-8000-00000000000d", + "edr_freight_app:bookings:operations", + "Booking operations", + ), + perm( + "a1000001-0001-4000-8000-00000000000e", + "edr_freight_app:bookings:cancel", + "Cancel booking", + ), + perm( + "a1000001-0001-4000-8000-000000000023", + "edr_freight_app:bookings:clearance_view", + "View customs-clearance queue", + ), + perm( + "a1000001-0001-4000-8000-000000000020", + "edr_freight_app:bookings:review_documents", + "Review clearance documents", + ), + perm( + "a1000001-0001-4000-8000-000000000021", + "edr_freight_app:bookings:upload_clearance_output", + "Upload customs output documents", + ), + perm( + "a1000001-0001-4000-8000-000000000022", + "edr_freight_app:bookings:finalize_clearance", + "Finalize document clearance", + ), + perm( + "a1000001-0001-4000-8000-00000000000f", + "edr_freight_app:train_scheduling:view", + "View train scheduling", + ), + perm( + "a1000001-0001-4000-8000-000000000011", + "edr_freight_app:fleet:view", + "View fleet", + ), + perm( + "a1000001-0001-4000-8000-000000000012", + "edr_freight_app:fleet:manage", + "Manage fleet", + ), + perm( + "a1000001-0001-4000-8000-000000000013", + "edr_freight_app:admin", + "Freight administration", + ), + perm( + "a1000001-0001-4000-8000-000000000024", + "edr_freight_app:bookings:create", + "Create booking", + ), // Header alarm for the document-review deadline: its own key so only the // position types that actually decide operation requests are alerted. - perm('a1000001-0001-4000-8000-000000000025', 'edr_freight_app:bookings:doc_review_alert', 'See document-review deadline alarm'), + perm( + "a1000001-0001-4000-8000-000000000025", + "edr_freight_app:bookings:doc_review_alert", + "See document-review deadline alarm", + ), + // Partial wagon cancellation (paid bookings): staff-side keys. The customer + // portal needs none — customer actions are ownership-scoped on the API. + perm( + "a1000001-0001-4000-8000-000000000026", + "edr_freight_app:bookings:wagon_cancellation_view", + "View wagon cancellation history", + ), + perm( + "a1000001-0001-4000-8000-000000000027", + "edr_freight_app:bookings:wagon_cancellation_void", + "Void a pending wagon cancellation", + ), + perm( + "a1000001-0001-4000-8000-000000000028", + "edr_freight_app:bookings:wagon_cancellation_rebook", + "Rebook cancelled wagons for a customer", + ), ]; /** @@ -73,38 +175,130 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ * approval/sign/clearance permissions but scoped to the new contracts module. */ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [ - perm('a3000001-0001-4000-8000-000000000001', 'edr_freight_app:contracts:view', 'View contracts'), + perm( + "a3000001-0001-4000-8000-000000000001", + "edr_freight_app:contracts:view", + "View contracts", + ), // Intake actions are split per freight type (bulk vs container) — fresh ids // because the seeder upserts ON CONFLICT (key); reusing the old ids with new // keys would PK-collide with the legacy staff_accept/request_changes/reject rows. - perm('a3000001-0001-4000-8000-000000000011', 'edr_freight_app:contracts:staff_accept:bulk', 'Accept bulk contract intake'), - perm('a3000001-0001-4000-8000-000000000012', 'edr_freight_app:contracts:staff_accept:container', 'Accept container contract intake'), - perm('a3000001-0001-4000-8000-000000000013', 'edr_freight_app:contracts:request_changes:bulk', 'Request bulk contract changes'), - perm('a3000001-0001-4000-8000-000000000014', 'edr_freight_app:contracts:request_changes:container', 'Request container contract changes'), - perm('a3000001-0001-4000-8000-000000000015', 'edr_freight_app:contracts:reject:bulk', 'Reject bulk contract'), - perm('a3000001-0001-4000-8000-000000000016', 'edr_freight_app:contracts:reject:container', 'Reject container contract'), - perm('a3000001-0001-4000-8000-000000000005', 'edr_freight_app:contracts:approve_line_staff', 'Approve contract as line staff'), - perm('a3000001-0001-4000-8000-000000000006', 'edr_freight_app:contracts:approve_director', 'Approve contract as director'), - perm('a3000001-0001-4000-8000-000000000007', 'edr_freight_app:contracts:approve_ceo', 'Approve contract as CEO'), - perm('a3000001-0001-4000-8000-000000000008', 'edr_freight_app:contracts:generate_contract', 'Generate contract document'), + perm( + "a3000001-0001-4000-8000-000000000011", + "edr_freight_app:contracts:staff_accept:bulk", + "Accept bulk contract intake", + ), + perm( + "a3000001-0001-4000-8000-000000000012", + "edr_freight_app:contracts:staff_accept:container", + "Accept container contract intake", + ), + perm( + "a3000001-0001-4000-8000-000000000013", + "edr_freight_app:contracts:request_changes:bulk", + "Request bulk contract changes", + ), + perm( + "a3000001-0001-4000-8000-000000000014", + "edr_freight_app:contracts:request_changes:container", + "Request container contract changes", + ), + perm( + "a3000001-0001-4000-8000-000000000015", + "edr_freight_app:contracts:reject:bulk", + "Reject bulk contract", + ), + perm( + "a3000001-0001-4000-8000-000000000016", + "edr_freight_app:contracts:reject:container", + "Reject container contract", + ), + perm( + "a3000001-0001-4000-8000-000000000005", + "edr_freight_app:contracts:approve_line_staff", + "Approve contract as line staff", + ), + perm( + "a3000001-0001-4000-8000-000000000006", + "edr_freight_app:contracts:approve_director", + "Approve contract as director", + ), + perm( + "a3000001-0001-4000-8000-000000000007", + "edr_freight_app:contracts:approve_ceo", + "Approve contract as CEO", + ), + perm( + "a3000001-0001-4000-8000-000000000008", + "edr_freight_app:contracts:generate_contract", + "Generate contract document", + ), // Staff counter-signature is split per freight type too — fresh ids for the // same reason as the intake keys above. - perm('a3000001-0001-4000-8000-000000000017', 'edr_freight_app:contracts:sign_staff:bulk', 'Staff contract signature: bulk'), - perm('a3000001-0001-4000-8000-000000000018', 'edr_freight_app:contracts:sign_staff:container', 'Staff contract signature: container'), - perm('a3000001-0001-4000-8000-00000000000a', 'edr_freight_app:contracts:clearance_review', 'Review pre-booking clearance docs'), - perm('a3000001-0001-4000-8000-00000000000b', 'edr_freight_app:contracts:finalize_clearance', 'Finalize pre-booking clearance'), - perm('a3000001-0001-4000-8000-00000000000c', 'edr_freight_app:contracts:create_booking', 'GL ET create booking under contract'), - perm('a3000001-0001-4000-8000-00000000000d', 'edr_freight_app:contracts:ops_clearance_review', 'Operations review of self-clearance docs (Path A)'), - perm('a3000001-0001-4000-8000-00000000000e', 'edr_freight_app:contracts:clearance_et_actions', 'GL Ethiopia phased clearance actions'), - perm('a3000001-0001-4000-8000-00000000000f', 'edr_freight_app:contracts:clearance_dj_actions', 'GL Djibouti phased clearance actions'), - perm('a3000001-0001-4000-8000-000000000010', 'edr_freight_app:contracts:clearance_duty_advise', 'Advise contract duty/tax'), + perm( + "a3000001-0001-4000-8000-000000000017", + "edr_freight_app:contracts:sign_staff:bulk", + "Staff contract signature: bulk", + ), + perm( + "a3000001-0001-4000-8000-000000000018", + "edr_freight_app:contracts:sign_staff:container", + "Staff contract signature: container", + ), + perm( + "a3000001-0001-4000-8000-00000000000a", + "edr_freight_app:contracts:clearance_review", + "Review pre-booking clearance docs", + ), + perm( + "a3000001-0001-4000-8000-00000000000b", + "edr_freight_app:contracts:finalize_clearance", + "Finalize pre-booking clearance", + ), + perm( + "a3000001-0001-4000-8000-00000000000c", + "edr_freight_app:contracts:create_booking", + "GL ET create booking under contract", + ), + perm( + "a3000001-0001-4000-8000-00000000000d", + "edr_freight_app:contracts:ops_clearance_review", + "Operations review of self-clearance docs (Path A)", + ), + perm( + "a3000001-0001-4000-8000-00000000000e", + "edr_freight_app:contracts:clearance_et_actions", + "GL Ethiopia phased clearance actions", + ), + perm( + "a3000001-0001-4000-8000-00000000000f", + "edr_freight_app:contracts:clearance_dj_actions", + "GL Djibouti phased clearance actions", + ), + perm( + "a3000001-0001-4000-8000-000000000010", + "edr_freight_app:contracts:clearance_duty_advise", + "Advise contract duty/tax", + ), // Hazardous contracts get two extra approval steps ahead of the normal chain. // Each has its own permission so the two desks are genuinely separate people. - perm('a3000001-0001-4000-8000-000000000019', 'edr_freight_app:contracts:hazardous_approval_one', 'Hazardous approval — first review'), - perm('a3000001-0001-4000-8000-00000000001a', 'edr_freight_app:contracts:hazardous_approval_two', 'Hazardous approval — second review'), + perm( + "a3000001-0001-4000-8000-000000000019", + "edr_freight_app:contracts:hazardous_approval_one", + "Hazardous approval — first review", + ), + perm( + "a3000001-0001-4000-8000-00000000001a", + "edr_freight_app:contracts:hazardous_approval_two", + "Hazardous approval — second review", + ), // Freeze/unfreeze a signed contract. One key covers both directions — whoever // may suspend must be able to lift it again. - perm('a3000001-0001-4000-8000-00000000001b', 'edr_freight_app:contracts:suspend', 'Suspend / resume a signed contract'), + perm( + "a3000001-0001-4000-8000-00000000001b", + "edr_freight_app:contracts:suspend", + "Suspend / resume a signed contract", + ), ]; // Historical ids. EdrOrgSeeder no longer sends them — it upserts on `key` and @@ -114,25 +308,25 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [ // owned …001b, and reusing it for transit-agents crashed boot with a PK 23505 // on every environment that still had the retired row. const RULE_ENGINE_VIEW_IDS: Record = { - 'cargo-types': 'b2000001-0001-4000-8000-000000000001', - 'container-types': 'b2000001-0001-4000-8000-000000000003', - 'wagon-types': 'b2000001-0001-4000-8000-000000000015', - 'truck-types': 'b2000001-0001-4000-8000-00000000001a', - 'service-types': 'b2000001-0001-4000-8000-000000000005', - yards: 'b2000001-0001-4000-8000-000000000007', - 'shipping-lines': 'b2000001-0001-4000-8000-000000000009', - 'weight-limit-rules': 'b2000001-0001-4000-8000-00000000000b', - 'priority-configs': 'b2000001-0001-4000-8000-00000000000f', - rates: 'b2000001-0001-4000-8000-000000000011', - 'approval-rules': 'b2000001-0001-4000-8000-000000000013', - 'yard-distances': 'b2000001-0001-4000-8000-000000000018', - 'transit-agents': 'b2000003-0001-4000-8000-000000000001', + "cargo-types": "b2000001-0001-4000-8000-000000000001", + "container-types": "b2000001-0001-4000-8000-000000000003", + "wagon-types": "b2000001-0001-4000-8000-000000000015", + "truck-types": "b2000001-0001-4000-8000-00000000001a", + "service-types": "b2000001-0001-4000-8000-000000000005", + yards: "b2000001-0001-4000-8000-000000000007", + "shipping-lines": "b2000001-0001-4000-8000-000000000009", + "weight-limit-rules": "b2000001-0001-4000-8000-00000000000b", + "priority-configs": "b2000001-0001-4000-8000-00000000000f", + rates: "b2000001-0001-4000-8000-000000000011", + "approval-rules": "b2000001-0001-4000-8000-000000000013", + "yard-distances": "b2000001-0001-4000-8000-000000000018", + "transit-agents": "b2000003-0001-4000-8000-000000000001", }; // CRUD replaces the retired coarse `:manage`. New ids live in a fresh block // (b2000002-…) so a stale `:manage` grant can never silently confer a CRUD // action — the migration re-grants create/update/delete explicitly. -const RULE_ENGINE_CRUD_ACTIONS = ['create', 'update', 'delete'] as const; +const RULE_ENGINE_CRUD_ACTIONS = ["create", "update", "delete"] as const; type RuleEngineCrudAction = (typeof RULE_ENGINE_CRUD_ACTIONS)[number]; const ruleEngineCrudId = ( slug: RuleEngineResourceSlug, @@ -140,9 +334,9 @@ const ruleEngineCrudId = ( ): string => { const n = RULE_ENGINE_RESOURCE_SLUGS.indexOf(slug) * 3 + - RULE_ENGINE_CRUD_ACTIONS.indexOf(action) + + RULE_ENGINE_CRUD_ACTIONS.indexOf(action) + 1; // 1..36 - return `b2000002-0001-4000-8000-${n.toString(16).padStart(12, '0')}`; + return `b2000002-0001-4000-8000-${n.toString(16).padStart(12, "0")}`; }; /** @@ -150,34 +344,61 @@ const ruleEngineCrudId = ( * propose a change; only `approve` lets someone put it into effect. Only listed * slugs get the permission. */ -const RULE_ENGINE_APPROVE_PERMISSION_IDS: Partial> = { - rates: 'b2000001-0001-4000-8000-000000000017', +const RULE_ENGINE_APPROVE_PERMISSION_IDS: Partial< + Record +> = { + rates: "b2000001-0001-4000-8000-000000000017", }; -export type RuleEngineApprovableSlug = 'rates'; +export type RuleEngineApprovableSlug = "rates"; -export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESOURCE_SLUGS.flatMap( - (slug) => { +export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = + RULE_ENGINE_RESOURCE_SLUGS.flatMap((slug) => { const resource = slugToResourceKey(slug); const approveId = RULE_ENGINE_APPROVE_PERMISSION_IDS[slug]; return [ - perm(RULE_ENGINE_VIEW_IDS[slug], `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`), - perm(ruleEngineCrudId(slug, 'create'), `edr_freight_app:rule_engine:${resource}:create`, `Create ${slug}`), - perm(ruleEngineCrudId(slug, 'update'), `edr_freight_app:rule_engine:${resource}:update`, `Update ${slug}`), - perm(ruleEngineCrudId(slug, 'delete'), `edr_freight_app:rule_engine:${resource}:delete`, `Delete ${slug}`), + perm( + RULE_ENGINE_VIEW_IDS[slug], + `edr_freight_app:rule_engine:${resource}:view`, + `View ${slug}`, + ), + perm( + ruleEngineCrudId(slug, "create"), + `edr_freight_app:rule_engine:${resource}:create`, + `Create ${slug}`, + ), + perm( + ruleEngineCrudId(slug, "update"), + `edr_freight_app:rule_engine:${resource}:update`, + `Update ${slug}`, + ), + perm( + ruleEngineCrudId(slug, "delete"), + `edr_freight_app:rule_engine:${resource}:delete`, + `Delete ${slug}`, + ), ...(approveId - ? [perm(approveId, `edr_freight_app:rule_engine:${resource}:approve`, `Approve ${slug} changes`)] + ? [ + perm( + approveId, + `edr_freight_app:rule_engine:${resource}:approve`, + `Approve ${slug} changes`, + ), + ] : []), ]; - }, -); + }); /** * Container-allocation permission for the previously-unguarded * booking allocate-containers endpoint. */ export const GAP_CONTROLLER_PERMISSIONS: FreightPermissionSeed[] = [ - perm('c1000001-0001-4000-8000-000000000001', 'edr_freight_app:allocation:manage', 'Allocate containers to vehicles'), + perm( + "c1000001-0001-4000-8000-000000000001", + "edr_freight_app:allocation:manage", + "Allocate containers to vehicles", + ), ]; /** @@ -188,211 +409,839 @@ export const GAP_CONTROLLER_PERMISSIONS: FreightPermissionSeed[] = [ // C. Customers export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [ - perm('d1a00001-0001-4000-8000-000000000001', 'edr_freight_app:customers:view', 'View customers'), - perm('d1a00001-0001-4000-8000-000000000002', 'edr_freight_app:customers:create', 'Create customer'), - perm('d1a00001-0001-4000-8000-000000000003', 'edr_freight_app:customers:update', 'Update customer'), - perm('d1a00001-0001-4000-8000-000000000004', 'edr_freight_app:customers:deactivate', 'Deactivate customer'), - perm('d1a00001-0001-4000-8000-000000000005', 'edr_freight_app:customers:verify', 'Verify customer (KYC/Fayda)'), - perm('d1a00001-0001-4000-8000-000000000006', 'edr_freight_app:customers:reset-password', 'Trigger customer password reset'), + perm( + "d1a00001-0001-4000-8000-000000000001", + "edr_freight_app:customers:view", + "View customers", + ), + perm( + "d1a00001-0001-4000-8000-000000000002", + "edr_freight_app:customers:create", + "Create customer", + ), + perm( + "d1a00001-0001-4000-8000-000000000003", + "edr_freight_app:customers:update", + "Update customer", + ), + perm( + "d1a00001-0001-4000-8000-000000000004", + "edr_freight_app:customers:deactivate", + "Deactivate customer", + ), + perm( + "d1a00001-0001-4000-8000-000000000005", + "edr_freight_app:customers:verify", + "Verify customer (KYC/Fayda)", + ), + perm( + "d1a00001-0001-4000-8000-000000000006", + "edr_freight_app:customers:reset-password", + "Trigger customer password reset", + ), ]; // D. Finance — payments + invoices export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ - perm('d2a00001-0001-4000-8000-000000000001', 'edr_freight_app:payments:view', 'View payments'), - perm('d2b00001-0001-4000-8000-000000000001', 'edr_freight_app:invoices:view', 'View invoices'), - perm('d2b00001-0001-4000-8000-000000000004', 'edr_freight_app:invoices:export', 'Download invoice document'), + perm( + "d2a00001-0001-4000-8000-000000000001", + "edr_freight_app:payments:view", + "View payments", + ), + perm( + "d2b00001-0001-4000-8000-000000000001", + "edr_freight_app:invoices:view", + "View invoices", + ), + perm( + "d2b00001-0001-4000-8000-000000000004", + "edr_freight_app:invoices:export", + "Download invoice document", + ), ]; // E. First / last mile operations export const MILE_PERMISSIONS: FreightPermissionSeed[] = [ - perm('d3a00001-0001-4000-8000-000000000001', 'edr_freight_app:first_mile:view', 'View first-mile'), - perm('d3a00001-0001-4000-8000-000000000002', 'edr_freight_app:first_mile:accept', 'Accept first-mile request'), - perm('d3a00001-0001-4000-8000-000000000003', 'edr_freight_app:first_mile:create', 'Create first-mile'), - perm('d3a00001-0001-4000-8000-000000000004', 'edr_freight_app:first_mile:update', 'Update first-mile'), - perm('d3a00001-0001-4000-8000-000000000005', 'edr_freight_app:first_mile:delete', 'Delete first-mile'), - perm('d3a00001-0001-4000-8000-000000000006', 'edr_freight_app:first_mile:assign_vehicles', 'Assign first-mile vehicles'), - perm('d3a00001-0001-4000-8000-000000000007', 'edr_freight_app:first_mile:set_distances', 'Set first-mile distances'), - perm('d3a00001-0001-4000-8000-000000000008', 'edr_freight_app:first_mile:generate_invoice', 'Generate first-mile invoice'), - perm('d3b00001-0001-4000-8000-000000000001', 'edr_freight_app:last_mile:view', 'View last-mile'), - perm('d3b00001-0001-4000-8000-000000000002', 'edr_freight_app:last_mile:accept', 'Accept last-mile request'), - perm('d3b00001-0001-4000-8000-000000000003', 'edr_freight_app:last_mile:create', 'Create last-mile'), - perm('d3b00001-0001-4000-8000-000000000004', 'edr_freight_app:last_mile:update', 'Update last-mile'), - perm('d3b00001-0001-4000-8000-000000000005', 'edr_freight_app:last_mile:delete', 'Delete last-mile'), - perm('d3b00001-0001-4000-8000-000000000006', 'edr_freight_app:last_mile:assign_vehicles', 'Assign last-mile vehicles'), - perm('d3b00001-0001-4000-8000-000000000007', 'edr_freight_app:last_mile:set_distances', 'Set last-mile distances'), - perm('d3b00001-0001-4000-8000-000000000008', 'edr_freight_app:last_mile:generate_invoice', 'Generate last-mile invoice'), - perm('d3b00001-0001-4000-8000-000000000009', 'edr_freight_app:last_mile:request_view', 'View last-mile confirmation requests'), - perm('d3b00001-0001-4000-8000-00000000000a', 'edr_freight_app:last_mile:request_review', 'Review last-mile confirmation requests (T&M dept)'), - perm('d3b00001-0001-4000-8000-00000000000b', 'edr_freight_app:last_mile:request_approve', 'Approve/reject last-mile confirmation requests'), + perm( + "d3a00001-0001-4000-8000-000000000001", + "edr_freight_app:first_mile:view", + "View first-mile", + ), + perm( + "d3a00001-0001-4000-8000-000000000002", + "edr_freight_app:first_mile:accept", + "Accept first-mile request", + ), + perm( + "d3a00001-0001-4000-8000-000000000003", + "edr_freight_app:first_mile:create", + "Create first-mile", + ), + perm( + "d3a00001-0001-4000-8000-000000000004", + "edr_freight_app:first_mile:update", + "Update first-mile", + ), + perm( + "d3a00001-0001-4000-8000-000000000005", + "edr_freight_app:first_mile:delete", + "Delete first-mile", + ), + perm( + "d3a00001-0001-4000-8000-000000000006", + "edr_freight_app:first_mile:assign_vehicles", + "Assign first-mile vehicles", + ), + perm( + "d3a00001-0001-4000-8000-000000000007", + "edr_freight_app:first_mile:set_distances", + "Set first-mile distances", + ), + perm( + "d3a00001-0001-4000-8000-000000000008", + "edr_freight_app:first_mile:generate_invoice", + "Generate first-mile invoice", + ), + perm( + "d3b00001-0001-4000-8000-000000000001", + "edr_freight_app:last_mile:view", + "View last-mile", + ), + perm( + "d3b00001-0001-4000-8000-000000000002", + "edr_freight_app:last_mile:accept", + "Accept last-mile request", + ), + perm( + "d3b00001-0001-4000-8000-000000000003", + "edr_freight_app:last_mile:create", + "Create last-mile", + ), + perm( + "d3b00001-0001-4000-8000-000000000004", + "edr_freight_app:last_mile:update", + "Update last-mile", + ), + perm( + "d3b00001-0001-4000-8000-000000000005", + "edr_freight_app:last_mile:delete", + "Delete last-mile", + ), + perm( + "d3b00001-0001-4000-8000-000000000006", + "edr_freight_app:last_mile:assign_vehicles", + "Assign last-mile vehicles", + ), + perm( + "d3b00001-0001-4000-8000-000000000007", + "edr_freight_app:last_mile:set_distances", + "Set last-mile distances", + ), + perm( + "d3b00001-0001-4000-8000-000000000008", + "edr_freight_app:last_mile:generate_invoice", + "Generate last-mile invoice", + ), + perm( + "d3b00001-0001-4000-8000-000000000009", + "edr_freight_app:last_mile:request_view", + "View last-mile confirmation requests", + ), + perm( + "d3b00001-0001-4000-8000-00000000000a", + "edr_freight_app:last_mile:request_review", + "Review last-mile confirmation requests (T&M dept)", + ), + perm( + "d3b00001-0001-4000-8000-00000000000b", + "edr_freight_app:last_mile:request_approve", + "Approve/reject last-mile confirmation requests", + ), ]; // F. Fleet — rail assets (splits the flat fleet:view/manage) export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [ - perm('e1a00001-0001-4000-8000-000000000001', 'edr_freight_app:locomotives:view', 'View locomotives'), - perm('e1a00001-0001-4000-8000-000000000002', 'edr_freight_app:locomotives:create', 'Create locomotive'), - perm('e1a00001-0001-4000-8000-000000000003', 'edr_freight_app:locomotives:update', 'Update locomotive'), - perm('e1a00001-0001-4000-8000-000000000004', 'edr_freight_app:locomotives:delete', 'Delete locomotive'), - perm('e1a00001-0001-4000-8000-000000000005', 'edr_freight_app:locomotives:hard_delete', 'Permanently delete locomotive'), - perm('e1b00001-0001-4000-8000-000000000001', 'edr_freight_app:wagons:view', 'View wagons'), - perm('e1b00001-0001-4000-8000-000000000002', 'edr_freight_app:wagons:create', 'Create wagon'), - perm('e1b00001-0001-4000-8000-000000000003', 'edr_freight_app:wagons:update', 'Update wagon'), - perm('e1b00001-0001-4000-8000-000000000004', 'edr_freight_app:wagons:delete', 'Delete wagon'), - perm('e1b00001-0001-4000-8000-000000000005', 'edr_freight_app:wagons:transfer_request', 'Request wagon transfer'), - perm('e1b00001-0001-4000-8000-000000000006', 'edr_freight_app:wagons:transfer_fulfill', 'Fulfil wagon transfer (OCC)'), - perm('e1b00001-0001-4000-8000-000000000007', 'edr_freight_app:wagons:transfer_history_all', "View all staff's transfer history"), + perm( + "e1a00001-0001-4000-8000-000000000001", + "edr_freight_app:locomotives:view", + "View locomotives", + ), + perm( + "e1a00001-0001-4000-8000-000000000002", + "edr_freight_app:locomotives:create", + "Create locomotive", + ), + perm( + "e1a00001-0001-4000-8000-000000000003", + "edr_freight_app:locomotives:update", + "Update locomotive", + ), + perm( + "e1a00001-0001-4000-8000-000000000004", + "edr_freight_app:locomotives:delete", + "Delete locomotive", + ), + perm( + "e1a00001-0001-4000-8000-000000000005", + "edr_freight_app:locomotives:hard_delete", + "Permanently delete locomotive", + ), + perm( + "e1b00001-0001-4000-8000-000000000001", + "edr_freight_app:wagons:view", + "View wagons", + ), + perm( + "e1b00001-0001-4000-8000-000000000002", + "edr_freight_app:wagons:create", + "Create wagon", + ), + perm( + "e1b00001-0001-4000-8000-000000000003", + "edr_freight_app:wagons:update", + "Update wagon", + ), + perm( + "e1b00001-0001-4000-8000-000000000004", + "edr_freight_app:wagons:delete", + "Delete wagon", + ), + perm( + "e1b00001-0001-4000-8000-000000000005", + "edr_freight_app:wagons:transfer_request", + "Request wagon transfer", + ), + perm( + "e1b00001-0001-4000-8000-000000000006", + "edr_freight_app:wagons:transfer_fulfill", + "Fulfil wagon transfer (OCC)", + ), + perm( + "e1b00001-0001-4000-8000-000000000007", + "edr_freight_app:wagons:transfer_history_all", + "View all staff's transfer history", + ), // The transfer desk is its own screen, so it carries its own per-action keys — // seeing the queue, withdrawing a request and short-closing one are separate // grants from filing or fulfilling. - perm('e1b00001-0001-4000-8000-000000000008', 'edr_freight_app:wagons:transfer_view', 'View wagon transfer requests'), - perm('e1b00001-0001-4000-8000-000000000009', 'edr_freight_app:wagons:transfer_cancel', 'Withdraw a wagon transfer request'), - perm('e1b00001-0001-4000-8000-00000000000a', 'edr_freight_app:wagons:transfer_close_short', 'Close a transfer request short of the requested count'), - perm('e1b00001-0001-4000-8000-00000000000b', 'edr_freight_app:wagons:hard_delete', 'Permanently delete wagon'), - perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'), - perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'), - perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'), - perm('e1c00001-0001-4000-8000-000000000004', 'edr_freight_app:trains:delete', 'Delete train'), - perm('e1c00001-0001-4000-8000-000000000005', 'edr_freight_app:trains:assign_wagons', 'Assign wagons to train'), - perm('e1d00001-0001-4000-8000-000000000001', 'edr_freight_app:routes:view', 'View routes'), - perm('e1d00001-0001-4000-8000-000000000002', 'edr_freight_app:routes:create', 'Create route'), - perm('e1d00001-0001-4000-8000-000000000003', 'edr_freight_app:routes:update', 'Update route'), - perm('e1d00001-0001-4000-8000-000000000004', 'edr_freight_app:routes:delete', 'Delete route'), - perm('e1d00001-0001-4000-8000-000000000005', 'edr_freight_app:routes:hard_delete', 'Permanently delete route'), - perm('e1e00001-0001-4000-8000-000000000001', 'edr_freight_app:containers:view', 'View containers'), - perm('e1e00001-0001-4000-8000-000000000002', 'edr_freight_app:containers:create', 'Create container'), - perm('e1e00001-0001-4000-8000-000000000003', 'edr_freight_app:containers:update', 'Update container'), - perm('e1e00001-0001-4000-8000-000000000004', 'edr_freight_app:containers:delete', 'Delete container'), - perm('e1f00001-0001-4000-8000-000000000001', 'edr_freight_app:cargoes:view', 'View cargoes'), - perm('e1f00001-0001-4000-8000-000000000002', 'edr_freight_app:cargoes:create', 'Create cargo'), - perm('e1f00001-0001-4000-8000-000000000003', 'edr_freight_app:cargoes:update', 'Update cargo'), - perm('e1f00001-0001-4000-8000-000000000004', 'edr_freight_app:cargoes:delete', 'Delete cargo'), + perm( + "e1b00001-0001-4000-8000-000000000008", + "edr_freight_app:wagons:transfer_view", + "View wagon transfer requests", + ), + perm( + "e1b00001-0001-4000-8000-000000000009", + "edr_freight_app:wagons:transfer_cancel", + "Withdraw a wagon transfer request", + ), + perm( + "e1b00001-0001-4000-8000-00000000000a", + "edr_freight_app:wagons:transfer_close_short", + "Close a transfer request short of the requested count", + ), + perm( + "e1b00001-0001-4000-8000-00000000000b", + "edr_freight_app:wagons:hard_delete", + "Permanently delete wagon", + ), + perm( + "e1c00001-0001-4000-8000-000000000001", + "edr_freight_app:trains:view", + "View trains", + ), + perm( + "e1c00001-0001-4000-8000-000000000002", + "edr_freight_app:trains:create", + "Create train", + ), + perm( + "e1c00001-0001-4000-8000-000000000003", + "edr_freight_app:trains:update", + "Update train", + ), + perm( + "e1c00001-0001-4000-8000-000000000004", + "edr_freight_app:trains:delete", + "Delete train", + ), + perm( + "e1c00001-0001-4000-8000-000000000005", + "edr_freight_app:trains:assign_wagons", + "Assign wagons to train", + ), + perm( + "e1d00001-0001-4000-8000-000000000001", + "edr_freight_app:routes:view", + "View routes", + ), + perm( + "e1d00001-0001-4000-8000-000000000002", + "edr_freight_app:routes:create", + "Create route", + ), + perm( + "e1d00001-0001-4000-8000-000000000003", + "edr_freight_app:routes:update", + "Update route", + ), + perm( + "e1d00001-0001-4000-8000-000000000004", + "edr_freight_app:routes:delete", + "Delete route", + ), + perm( + "e1d00001-0001-4000-8000-000000000005", + "edr_freight_app:routes:hard_delete", + "Permanently delete route", + ), + perm( + "e1e00001-0001-4000-8000-000000000001", + "edr_freight_app:containers:view", + "View containers", + ), + perm( + "e1e00001-0001-4000-8000-000000000002", + "edr_freight_app:containers:create", + "Create container", + ), + perm( + "e1e00001-0001-4000-8000-000000000003", + "edr_freight_app:containers:update", + "Update container", + ), + perm( + "e1e00001-0001-4000-8000-000000000004", + "edr_freight_app:containers:delete", + "Delete container", + ), + perm( + "e1f00001-0001-4000-8000-000000000001", + "edr_freight_app:cargoes:view", + "View cargoes", + ), + perm( + "e1f00001-0001-4000-8000-000000000002", + "edr_freight_app:cargoes:create", + "Create cargo", + ), + perm( + "e1f00001-0001-4000-8000-000000000003", + "edr_freight_app:cargoes:update", + "Update cargo", + ), + perm( + "e1f00001-0001-4000-8000-000000000004", + "edr_freight_app:cargoes:delete", + "Delete cargo", + ), // NB: id prefixes must stay hex — 'e1g…' once crashed the boot seeder // (postgres: invalid input syntax for type uuid). - perm('e1900001-0001-4000-8000-000000000001', 'edr_freight_app:consignments:view', 'View consignments'), - perm('e1900001-0001-4000-8000-000000000002', 'edr_freight_app:consignments:create', 'Create consignment'), + perm( + "e1900001-0001-4000-8000-000000000001", + "edr_freight_app:consignments:view", + "View consignments", + ), + perm( + "e1900001-0001-4000-8000-000000000002", + "edr_freight_app:consignments:create", + "Create consignment", + ), ]; // G. Fleet — road & telemetry export const FLEET_ROAD_PERMISSIONS: FreightPermissionSeed[] = [ - perm('e2a00001-0001-4000-8000-000000000001', 'edr_freight_app:vehicles:view', 'View vehicles'), - perm('e2a00001-0001-4000-8000-000000000002', 'edr_freight_app:vehicles:create', 'Create vehicle'), - perm('e2a00001-0001-4000-8000-000000000003', 'edr_freight_app:vehicles:update', 'Update vehicle'), - perm('e2a00001-0001-4000-8000-000000000004', 'edr_freight_app:vehicles:delete', 'Delete vehicle'), - perm('e2b00001-0001-4000-8000-000000000001', 'edr_freight_app:drivers:view', 'View drivers'), - perm('e2b00001-0001-4000-8000-000000000002', 'edr_freight_app:drivers:create', 'Create driver'), - perm('e2b00001-0001-4000-8000-000000000003', 'edr_freight_app:drivers:update', 'Update driver'), - perm('e2b00001-0001-4000-8000-000000000004', 'edr_freight_app:drivers:delete', 'Delete driver'), - perm('e2c00001-0001-4000-8000-000000000001', 'edr_freight_app:tracking:view', 'Track vehicles'), - perm('e2c00001-0001-4000-8000-000000000002', 'edr_freight_app:tracking:manage', 'Manage GPS trackers'), - perm('e2d00001-0001-4000-8000-000000000001', 'edr_freight_app:fuel:view', 'View fuel purchases'), - perm('e2d00001-0001-4000-8000-000000000002', 'edr_freight_app:fuel:create', 'Create fuel purchase'), - perm('e2d00001-0001-4000-8000-000000000003', 'edr_freight_app:fuel:update', 'Update fuel purchase'), - perm('e2d00001-0001-4000-8000-000000000004', 'edr_freight_app:fuel:delete', 'Delete fuel purchase'), - perm('e2e00001-0001-4000-8000-000000000001', 'edr_freight_app:maintenance:view', 'View maintenance'), - perm('e2e00001-0001-4000-8000-000000000002', 'edr_freight_app:maintenance:create', 'Create maintenance'), - perm('e2e00001-0001-4000-8000-000000000003', 'edr_freight_app:maintenance:update', 'Update maintenance'), - perm('e2e00001-0001-4000-8000-000000000004', 'edr_freight_app:maintenance:delete', 'Delete maintenance'), - perm('e2f00001-0001-4000-8000-000000000001', 'edr_freight_app:fleet_reports:view', 'View fleet financial reports'), - perm('e2f00001-0001-4000-8000-000000000002', 'edr_freight_app:fleet_reports:export', 'Export fleet financial reports'), - perm('e2000001-0001-4000-8000-000000000001', 'edr_freight_app:fleet_dashboard:view', 'View fleet dashboard'), + perm( + "e2a00001-0001-4000-8000-000000000001", + "edr_freight_app:vehicles:view", + "View vehicles", + ), + perm( + "e2a00001-0001-4000-8000-000000000002", + "edr_freight_app:vehicles:create", + "Create vehicle", + ), + perm( + "e2a00001-0001-4000-8000-000000000003", + "edr_freight_app:vehicles:update", + "Update vehicle", + ), + perm( + "e2a00001-0001-4000-8000-000000000004", + "edr_freight_app:vehicles:delete", + "Delete vehicle", + ), + perm( + "e2b00001-0001-4000-8000-000000000001", + "edr_freight_app:drivers:view", + "View drivers", + ), + perm( + "e2b00001-0001-4000-8000-000000000002", + "edr_freight_app:drivers:create", + "Create driver", + ), + perm( + "e2b00001-0001-4000-8000-000000000003", + "edr_freight_app:drivers:update", + "Update driver", + ), + perm( + "e2b00001-0001-4000-8000-000000000004", + "edr_freight_app:drivers:delete", + "Delete driver", + ), + perm( + "e2c00001-0001-4000-8000-000000000001", + "edr_freight_app:tracking:view", + "Track vehicles", + ), + perm( + "e2c00001-0001-4000-8000-000000000002", + "edr_freight_app:tracking:manage", + "Manage GPS trackers", + ), + perm( + "e2d00001-0001-4000-8000-000000000001", + "edr_freight_app:fuel:view", + "View fuel purchases", + ), + perm( + "e2d00001-0001-4000-8000-000000000002", + "edr_freight_app:fuel:create", + "Create fuel purchase", + ), + perm( + "e2d00001-0001-4000-8000-000000000003", + "edr_freight_app:fuel:update", + "Update fuel purchase", + ), + perm( + "e2d00001-0001-4000-8000-000000000004", + "edr_freight_app:fuel:delete", + "Delete fuel purchase", + ), + perm( + "e2e00001-0001-4000-8000-000000000001", + "edr_freight_app:maintenance:view", + "View maintenance", + ), + perm( + "e2e00001-0001-4000-8000-000000000002", + "edr_freight_app:maintenance:create", + "Create maintenance", + ), + perm( + "e2e00001-0001-4000-8000-000000000003", + "edr_freight_app:maintenance:update", + "Update maintenance", + ), + perm( + "e2e00001-0001-4000-8000-000000000004", + "edr_freight_app:maintenance:delete", + "Delete maintenance", + ), + perm( + "e2f00001-0001-4000-8000-000000000001", + "edr_freight_app:fleet_reports:view", + "View fleet financial reports", + ), + perm( + "e2f00001-0001-4000-8000-000000000002", + "edr_freight_app:fleet_reports:export", + "Export fleet financial reports", + ), + perm( + "e2000001-0001-4000-8000-000000000001", + "edr_freight_app:fleet_dashboard:view", + "View fleet dashboard", + ), ]; // H. Warehouse management export const WAREHOUSE_PERMISSIONS: FreightPermissionSeed[] = [ - perm('f1000001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_dashboard:view', 'View warehouse dashboard'), - perm('f1a00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouses:view', 'View warehouses'), - perm('f1a00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouses:create', 'Create warehouse'), - perm('f1a00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouses:update', 'Update warehouse'), - perm('f1a00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouses:delete', 'Delete warehouse'), - perm('f1b00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_yards:view', 'View warehouse yards'), - perm('f1b00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_yards:create', 'Create warehouse yard'), - perm('f1b00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_yards:update', 'Update warehouse yard'), - perm('f1b00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_yards:delete', 'Delete warehouse yard'), - perm('f1c00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_zones:view', 'View warehouse zones'), - perm('f1c00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_zones:create', 'Create warehouse zone'), - perm('f1c00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_zones:update', 'Update warehouse zone'), - perm('f1d00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_allocation_rules:view', 'View allocation rules'), - perm('f1d00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_allocation_rules:create', 'Create allocation rule'), - perm('f1d00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_allocation_rules:update', 'Update allocation rule'), - perm('f1d00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_allocation_rules:delete', 'Delete allocation rule'), - perm('f1e00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_fee_rules:view', 'View fee rules'), - perm('f1e00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_fee_rules:create', 'Create fee rule'), - perm('f1e00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_fee_rules:update', 'Update fee rule'), - perm('f1e00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_fee_rules:delete', 'Delete fee rule'), - perm('f1f00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_inspection_reports:view', 'View inspection reports'), - perm('f1f00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_inspection_reports:create', 'Create inspection report'), - perm('f1f00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_inspection_reports:update', 'Update inspection report'), + perm( + "f1000001-0001-4000-8000-000000000001", + "edr_freight_app:warehouse_dashboard:view", + "View warehouse dashboard", + ), + perm( + "f1a00001-0001-4000-8000-000000000001", + "edr_freight_app:warehouses:view", + "View warehouses", + ), + perm( + "f1a00001-0001-4000-8000-000000000002", + "edr_freight_app:warehouses:create", + "Create warehouse", + ), + perm( + "f1a00001-0001-4000-8000-000000000003", + "edr_freight_app:warehouses:update", + "Update warehouse", + ), + perm( + "f1a00001-0001-4000-8000-000000000004", + "edr_freight_app:warehouses:delete", + "Delete warehouse", + ), + perm( + "f1b00001-0001-4000-8000-000000000001", + "edr_freight_app:warehouse_yards:view", + "View warehouse yards", + ), + perm( + "f1b00001-0001-4000-8000-000000000002", + "edr_freight_app:warehouse_yards:create", + "Create warehouse yard", + ), + perm( + "f1b00001-0001-4000-8000-000000000003", + "edr_freight_app:warehouse_yards:update", + "Update warehouse yard", + ), + perm( + "f1b00001-0001-4000-8000-000000000004", + "edr_freight_app:warehouse_yards:delete", + "Delete warehouse yard", + ), + perm( + "f1c00001-0001-4000-8000-000000000001", + "edr_freight_app:warehouse_zones:view", + "View warehouse zones", + ), + perm( + "f1c00001-0001-4000-8000-000000000002", + "edr_freight_app:warehouse_zones:create", + "Create warehouse zone", + ), + perm( + "f1c00001-0001-4000-8000-000000000003", + "edr_freight_app:warehouse_zones:update", + "Update warehouse zone", + ), + perm( + "f1d00001-0001-4000-8000-000000000001", + "edr_freight_app:warehouse_allocation_rules:view", + "View allocation rules", + ), + perm( + "f1d00001-0001-4000-8000-000000000002", + "edr_freight_app:warehouse_allocation_rules:create", + "Create allocation rule", + ), + perm( + "f1d00001-0001-4000-8000-000000000003", + "edr_freight_app:warehouse_allocation_rules:update", + "Update allocation rule", + ), + perm( + "f1d00001-0001-4000-8000-000000000004", + "edr_freight_app:warehouse_allocation_rules:delete", + "Delete allocation rule", + ), + perm( + "f1e00001-0001-4000-8000-000000000001", + "edr_freight_app:warehouse_fee_rules:view", + "View fee rules", + ), + perm( + "f1e00001-0001-4000-8000-000000000002", + "edr_freight_app:warehouse_fee_rules:create", + "Create fee rule", + ), + perm( + "f1e00001-0001-4000-8000-000000000003", + "edr_freight_app:warehouse_fee_rules:update", + "Update fee rule", + ), + perm( + "f1e00001-0001-4000-8000-000000000004", + "edr_freight_app:warehouse_fee_rules:delete", + "Delete fee rule", + ), + perm( + "f1f00001-0001-4000-8000-000000000001", + "edr_freight_app:warehouse_inspection_reports:view", + "View inspection reports", + ), + perm( + "f1f00001-0001-4000-8000-000000000002", + "edr_freight_app:warehouse_inspection_reports:create", + "Create inspection report", + ), + perm( + "f1f00001-0001-4000-8000-000000000003", + "edr_freight_app:warehouse_inspection_reports:update", + "Update inspection report", + ), ]; // I. Port & terminal — inventory movement + interchange + fee invoices export const PORT_TERMINAL_PERMISSIONS: FreightPermissionSeed[] = [ - perm('f2a00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_inventory:view', 'View terminal inventory'), - perm('f2a00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_inventory:receive', 'Receive inventory'), - perm('f2a00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_inventory:move', 'Move/store/reserve inventory'), - perm('f2a00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_inventory:load', 'Load inventory'), - perm('f2a00001-0001-4000-8000-000000000005', 'edr_freight_app:warehouse_inventory:unload', 'Unload inventory'), - perm('f2a00001-0001-4000-8000-000000000006', 'edr_freight_app:warehouse_inventory:dispatch', 'Dispatch inventory'), - perm('f2a00001-0001-4000-8000-000000000007', 'edr_freight_app:warehouse_inventory:gate_pass', 'Gate-clearance inventory'), - perm('f2a00001-0001-4000-8000-000000000008', 'edr_freight_app:warehouse_inventory:release', 'Release inventory'), - perm('f2a00001-0001-4000-8000-000000000009', 'edr_freight_app:warehouse_inventory:deliver', 'Deliver inventory'), - perm('f2a00001-0001-4000-8000-00000000000a', 'edr_freight_app:warehouse_inventory:inspect', 'Inspect inventory'), - perm('f2b00001-0001-4000-8000-000000000001', 'edr_freight_app:interchange_documents:view', 'View interchange documents'), - perm('f2b00001-0001-4000-8000-000000000002', 'edr_freight_app:interchange_documents:generate', 'Generate interchange document'), - perm('f2b00001-0001-4000-8000-000000000003', 'edr_freight_app:interchange_documents:acknowledge', 'Acknowledge interchange document'), - perm('f2b00001-0001-4000-8000-000000000004', 'edr_freight_app:interchange_documents:dispute', 'Dispute interchange document'), - perm('f2b00001-0001-4000-8000-000000000005', 'edr_freight_app:interchange_documents:cancel', 'Cancel interchange document'), - perm('f2c00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_fee_invoices:view', 'View warehouse fee invoices'), - perm('f2c00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_fee_invoices:generate', 'Generate warehouse fee invoice'), - perm('f2c00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_fee_invoices:cancel', 'Cancel warehouse fee invoice'), - perm('f2c00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_fee_invoices:pay', 'Pay warehouse fee invoice'), + perm( + "f2a00001-0001-4000-8000-000000000001", + "edr_freight_app:warehouse_inventory:view", + "View terminal inventory", + ), + perm( + "f2a00001-0001-4000-8000-000000000002", + "edr_freight_app:warehouse_inventory:receive", + "Receive inventory", + ), + perm( + "f2a00001-0001-4000-8000-000000000003", + "edr_freight_app:warehouse_inventory:move", + "Move/store/reserve inventory", + ), + perm( + "f2a00001-0001-4000-8000-000000000004", + "edr_freight_app:warehouse_inventory:load", + "Load inventory", + ), + perm( + "f2a00001-0001-4000-8000-000000000005", + "edr_freight_app:warehouse_inventory:unload", + "Unload inventory", + ), + perm( + "f2a00001-0001-4000-8000-000000000006", + "edr_freight_app:warehouse_inventory:dispatch", + "Dispatch inventory", + ), + perm( + "f2a00001-0001-4000-8000-000000000007", + "edr_freight_app:warehouse_inventory:gate_pass", + "Gate-clearance inventory", + ), + perm( + "f2a00001-0001-4000-8000-000000000008", + "edr_freight_app:warehouse_inventory:release", + "Release inventory", + ), + perm( + "f2a00001-0001-4000-8000-000000000009", + "edr_freight_app:warehouse_inventory:deliver", + "Deliver inventory", + ), + perm( + "f2a00001-0001-4000-8000-00000000000a", + "edr_freight_app:warehouse_inventory:inspect", + "Inspect inventory", + ), + perm( + "f2b00001-0001-4000-8000-000000000001", + "edr_freight_app:interchange_documents:view", + "View interchange documents", + ), + perm( + "f2b00001-0001-4000-8000-000000000002", + "edr_freight_app:interchange_documents:generate", + "Generate interchange document", + ), + perm( + "f2b00001-0001-4000-8000-000000000003", + "edr_freight_app:interchange_documents:acknowledge", + "Acknowledge interchange document", + ), + perm( + "f2b00001-0001-4000-8000-000000000004", + "edr_freight_app:interchange_documents:dispute", + "Dispute interchange document", + ), + perm( + "f2b00001-0001-4000-8000-000000000005", + "edr_freight_app:interchange_documents:cancel", + "Cancel interchange document", + ), + perm( + "f2c00001-0001-4000-8000-000000000001", + "edr_freight_app:warehouse_fee_invoices:view", + "View warehouse fee invoices", + ), + perm( + "f2c00001-0001-4000-8000-000000000002", + "edr_freight_app:warehouse_fee_invoices:generate", + "Generate warehouse fee invoice", + ), + perm( + "f2c00001-0001-4000-8000-000000000003", + "edr_freight_app:warehouse_fee_invoices:cancel", + "Cancel warehouse fee invoice", + ), + perm( + "f2c00001-0001-4000-8000-000000000004", + "edr_freight_app:warehouse_fee_invoices:pay", + "Pay warehouse fee invoice", + ), ]; // E'. Train-scheduling finer actions (augment existing view/manage) export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [ - perm('a2a00001-0001-4000-8000-000000000001', 'edr_freight_app:train_scheduling:create', 'Create train schedule'), - perm('a2a00001-0001-4000-8000-000000000002', 'edr_freight_app:train_scheduling:update', 'Update train schedule'), - perm('a2a00001-0001-4000-8000-000000000003', 'edr_freight_app:train_scheduling:cancel', 'Cancel train schedule'), - perm('a2a00001-0001-4000-8000-000000000004', 'edr_freight_app:train_scheduling:reschedule', 'Reschedule train'), - perm('a2a00001-0001-4000-8000-000000000005', 'edr_freight_app:train_scheduling:rules_manage', 'Manage global scheduling rules'), + perm( + "a2a00001-0001-4000-8000-000000000001", + "edr_freight_app:train_scheduling:create", + "Create train schedule", + ), + perm( + "a2a00001-0001-4000-8000-000000000002", + "edr_freight_app:train_scheduling:update", + "Update train schedule", + ), + perm( + "a2a00001-0001-4000-8000-000000000003", + "edr_freight_app:train_scheduling:cancel", + "Cancel train schedule", + ), + perm( + "a2a00001-0001-4000-8000-000000000004", + "edr_freight_app:train_scheduling:reschedule", + "Reschedule train", + ), + perm( + "a2a00001-0001-4000-8000-000000000005", + "edr_freight_app:train_scheduling:rules_manage", + "Manage global scheduling rules", + ), ]; // L. Administration & settings (split from the coarse admin umbrella) export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [ - perm('b4a00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:file_upload:view', 'View file-upload settings'), - perm('b4a00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:file_upload:manage', 'Manage file-upload settings'), - perm('b4b00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:dropdown:view', 'View dropdown settings'), - perm('b4b00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:dropdown:manage', 'Manage dropdown settings'), - perm('b4c00001-0001-4000-8000-000000000001', 'edr_freight_app:audit:view', 'View audit logs'), + perm( + "b4a00001-0001-4000-8000-000000000001", + "edr_freight_app:settings:file_upload:view", + "View file-upload settings", + ), + perm( + "b4a00001-0001-4000-8000-000000000002", + "edr_freight_app:settings:file_upload:manage", + "Manage file-upload settings", + ), + perm( + "b4b00001-0001-4000-8000-000000000001", + "edr_freight_app:settings:dropdown:view", + "View dropdown settings", + ), + perm( + "b4b00001-0001-4000-8000-000000000002", + "edr_freight_app:settings:dropdown:manage", + "Manage dropdown settings", + ), + perm( + "b4c00001-0001-4000-8000-000000000001", + "edr_freight_app:audit:view", + "View audit logs", + ), ]; // M. Staff / IAM admin — NEW keys only. The employee_registration / role_assignment // / hierarchy_* / position_types:view keys are seeded separately in edr-freight.seed.ts. export const STAFF_IAM_PERMISSIONS: FreightPermissionSeed[] = [ - perm('c2a00001-0001-4000-8000-000000000001', 'edr_freight_app:staff:roles:view', 'View roles'), - perm('c2a00001-0001-4000-8000-000000000002', 'edr_freight_app:staff:roles:create', 'Create role'), - perm('c2a00001-0001-4000-8000-000000000003', 'edr_freight_app:staff:roles:update', 'Update role'), - perm('c2a00001-0001-4000-8000-000000000004', 'edr_freight_app:staff:roles:delete', 'Delete role'), - perm('c2b00001-0001-4000-8000-000000000001', 'edr_freight_app:staff:permissions:view', 'View permission assignments'), - perm('c2b00001-0001-4000-8000-000000000002', 'edr_freight_app:staff:permissions:assign', 'Assign permissions'), - perm('c2c00001-0001-4000-8000-000000000001', 'edr_freight_app:position_types:create', 'Create position type'), - perm('c2c00001-0001-4000-8000-000000000002', 'edr_freight_app:position_types:update', 'Update position type'), - perm('c2c00001-0001-4000-8000-000000000003', 'edr_freight_app:position_types:delete', 'Delete position type'), + perm( + "c2a00001-0001-4000-8000-000000000001", + "edr_freight_app:staff:roles:view", + "View roles", + ), + perm( + "c2a00001-0001-4000-8000-000000000002", + "edr_freight_app:staff:roles:create", + "Create role", + ), + perm( + "c2a00001-0001-4000-8000-000000000003", + "edr_freight_app:staff:roles:update", + "Update role", + ), + perm( + "c2a00001-0001-4000-8000-000000000004", + "edr_freight_app:staff:roles:delete", + "Delete role", + ), + perm( + "c2b00001-0001-4000-8000-000000000001", + "edr_freight_app:staff:permissions:view", + "View permission assignments", + ), + perm( + "c2b00001-0001-4000-8000-000000000002", + "edr_freight_app:staff:permissions:assign", + "Assign permissions", + ), + perm( + "c2c00001-0001-4000-8000-000000000001", + "edr_freight_app:position_types:create", + "Create position type", + ), + perm( + "c2c00001-0001-4000-8000-000000000002", + "edr_freight_app:position_types:update", + "Update position type", + ), + perm( + "c2c00001-0001-4000-8000-000000000003", + "edr_freight_app:position_types:delete", + "Delete position type", + ), ]; // O. Granular splits of previously-shared keys: money/irreversible actions that // used to ride on a broader permission (staff_accept, train_scheduling:update, // the admin umbrella) get their own grant so departments can hold them apart. export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [ - perm('a5a00001-0001-4000-8000-000000000001', 'edr_freight_app:bookings:government_expedite', 'Expedite a government booking (bypass payment)'), - perm('a5b00001-0001-4000-8000-000000000001', 'edr_freight_app:contracts:edit_document', 'Edit contract document articles'), - perm('a5b00001-0001-4000-8000-000000000002', 'edr_freight_app:contracts:final_invoice_raise', 'Raise final invoice (GL DJ)'), - perm('a5b00001-0001-4000-8000-000000000003', 'edr_freight_app:contracts:final_invoice_confirm', 'Confirm final-invoice payment slip'), - perm('a5c00001-0001-4000-8000-000000000001', 'edr_freight_app:train_scheduling:dispatch', 'Finalize / dispatch a train schedule'), - perm('a5c00001-0001-4000-8000-000000000002', 'edr_freight_app:train_scheduling:mark_paid', 'Mark a reserved booking paid (staff)'), - perm('a5c00001-0001-4000-8000-000000000003', 'edr_freight_app:train_scheduling:expire_booking', 'Expire a reserved booking'), - perm('b4d00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:exchange_rate:view', 'View exchange-rate settings'), - perm('b4d00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:exchange_rate:manage', 'Set the USD-ETB fallback rate'), - perm('b4e00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:contract_templates:view', 'View contract templates'), - perm('b4e00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:contract_templates:manage', 'Edit contract templates & articles'), + perm( + "a5a00001-0001-4000-8000-000000000001", + "edr_freight_app:bookings:government_expedite", + "Expedite a government booking (bypass payment)", + ), + perm( + "a5b00001-0001-4000-8000-000000000001", + "edr_freight_app:contracts:edit_document", + "Edit contract document articles", + ), + perm( + "a5b00001-0001-4000-8000-000000000002", + "edr_freight_app:contracts:final_invoice_raise", + "Raise final invoice (GL DJ)", + ), + perm( + "a5b00001-0001-4000-8000-000000000003", + "edr_freight_app:contracts:final_invoice_confirm", + "Confirm final-invoice payment slip", + ), + perm( + "a5c00001-0001-4000-8000-000000000001", + "edr_freight_app:train_scheduling:dispatch", + "Finalize / dispatch a train schedule", + ), + perm( + "a5c00001-0001-4000-8000-000000000002", + "edr_freight_app:train_scheduling:mark_paid", + "Mark a reserved booking paid (staff)", + ), + perm( + "a5c00001-0001-4000-8000-000000000003", + "edr_freight_app:train_scheduling:expire_booking", + "Expire a reserved booking", + ), + perm( + "b4d00001-0001-4000-8000-000000000001", + "edr_freight_app:settings:exchange_rate:view", + "View exchange-rate settings", + ), + perm( + "b4d00001-0001-4000-8000-000000000002", + "edr_freight_app:settings:exchange_rate:manage", + "Set the USD-ETB fallback rate", + ), + perm( + "b4e00001-0001-4000-8000-000000000001", + "edr_freight_app:settings:contract_templates:view", + "View contract templates", + ), + perm( + "b4e00001-0001-4000-8000-000000000002", + "edr_freight_app:settings:contract_templates:manage", + "Edit contract templates & articles", + ), ]; // N. Previously-ungated staff surfaces (support inbox, procurement, compliance, @@ -400,21 +1249,81 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [ // (staff directory, trade access) and the dashboard/report reads the frontend // already gated on but were never seeded (overview:view / reports:view). export const AUDIENCE_GAP_PERMISSIONS: FreightPermissionSeed[] = [ - perm('a4a00001-0001-4000-8000-000000000001', 'edr_freight_app:support:agent_view', 'View support inbox (agent)'), - perm('a4a00001-0001-4000-8000-000000000002', 'edr_freight_app:support:agent_send', 'Reply / start support threads (agent)'), - perm('a4b00001-0001-4000-8000-000000000001', 'edr_freight_app:procurement:view', 'View procurement & asset lifecycle'), - perm('a4b00001-0001-4000-8000-000000000002', 'edr_freight_app:procurement:vendor_manage', 'Manage vendors'), - perm('a4b00001-0001-4000-8000-000000000003', 'edr_freight_app:procurement:acquisition_manage', 'Manage asset acquisitions'), - perm('a4b00001-0001-4000-8000-000000000004', 'edr_freight_app:procurement:disposal_manage', 'Manage asset disposals'), - perm('a4c00001-0001-4000-8000-000000000001', 'edr_freight_app:compliance:view', 'View vehicle compliance'), - perm('a4c00001-0001-4000-8000-000000000002', 'edr_freight_app:compliance:manage', 'Manage vehicle compliance'), - perm('a4d00001-0001-4000-8000-000000000001', 'edr_freight_app:facilities:view', 'View facilities'), - perm('a4d00001-0001-4000-8000-000000000002', 'edr_freight_app:facilities:manage', 'Manage facilities'), - perm('a4e00001-0001-4000-8000-000000000001', 'edr_freight_app:staff:users:view', 'List staff users (pickers)'), - perm('a4e00001-0001-4000-8000-000000000002', 'edr_freight_app:trade_access:view', 'View trade-direction access'), - perm('a4e00001-0001-4000-8000-000000000003', 'edr_freight_app:trade_access:manage', 'Manage trade-direction access'), - perm('a4f00001-0001-4000-8000-000000000001', 'edr_freight_app:overview:view', 'View backoffice overview dashboard'), - perm('a4f00001-0001-4000-8000-000000000002', 'edr_freight_app:reports:view', 'Run backoffice reports'), + perm( + "a4a00001-0001-4000-8000-000000000001", + "edr_freight_app:support:agent_view", + "View support inbox (agent)", + ), + perm( + "a4a00001-0001-4000-8000-000000000002", + "edr_freight_app:support:agent_send", + "Reply / start support threads (agent)", + ), + perm( + "a4b00001-0001-4000-8000-000000000001", + "edr_freight_app:procurement:view", + "View procurement & asset lifecycle", + ), + perm( + "a4b00001-0001-4000-8000-000000000002", + "edr_freight_app:procurement:vendor_manage", + "Manage vendors", + ), + perm( + "a4b00001-0001-4000-8000-000000000003", + "edr_freight_app:procurement:acquisition_manage", + "Manage asset acquisitions", + ), + perm( + "a4b00001-0001-4000-8000-000000000004", + "edr_freight_app:procurement:disposal_manage", + "Manage asset disposals", + ), + perm( + "a4c00001-0001-4000-8000-000000000001", + "edr_freight_app:compliance:view", + "View vehicle compliance", + ), + perm( + "a4c00001-0001-4000-8000-000000000002", + "edr_freight_app:compliance:manage", + "Manage vehicle compliance", + ), + perm( + "a4d00001-0001-4000-8000-000000000001", + "edr_freight_app:facilities:view", + "View facilities", + ), + perm( + "a4d00001-0001-4000-8000-000000000002", + "edr_freight_app:facilities:manage", + "Manage facilities", + ), + perm( + "a4e00001-0001-4000-8000-000000000001", + "edr_freight_app:staff:users:view", + "List staff users (pickers)", + ), + perm( + "a4e00001-0001-4000-8000-000000000002", + "edr_freight_app:trade_access:view", + "View trade-direction access", + ), + perm( + "a4e00001-0001-4000-8000-000000000003", + "edr_freight_app:trade_access:manage", + "Manage trade-direction access", + ), + perm( + "a4f00001-0001-4000-8000-000000000001", + "edr_freight_app:overview:view", + "View backoffice overview dashboard", + ), + perm( + "a4f00001-0001-4000-8000-000000000002", + "edr_freight_app:reports:view", + "Run backoffice reports", + ), ]; export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ @@ -440,84 +1349,87 @@ export const BOOKING_RULE_ENGINE_PERMISSIONS = [ ...ADVANCED_BACKOFFICE_PERMISSIONS, ]; -export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map( - (p) => p.key, -); +export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = + BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => p.key); export const FREIGHT_PERMS = { bookings: { - view: 'edr_freight_app:bookings:view', - create: 'edr_freight_app:bookings:create', - clearanceView: 'edr_freight_app:bookings:clearance_view', - staffAccept: 'edr_freight_app:bookings:staff_accept', - requestChanges: 'edr_freight_app:bookings:request_changes', - reject: 'edr_freight_app:bookings:reject', - approveLineStaff: 'edr_freight_app:bookings:approve_line_staff', - approveDirector: 'edr_freight_app:bookings:approve_director', - approveCeo: 'edr_freight_app:bookings:approve_ceo', - rejectApproval: 'edr_freight_app:bookings:reject_approval', - generateContract: 'edr_freight_app:bookings:generate_contract', - signStaff: 'edr_freight_app:bookings:sign_staff', - operations: 'edr_freight_app:bookings:operations', - cancel: 'edr_freight_app:bookings:cancel', - reviewDocuments: 'edr_freight_app:bookings:review_documents', - uploadClearanceOutput: 'edr_freight_app:bookings:upload_clearance_output', - finalizeClearance: 'edr_freight_app:bookings:finalize_clearance', - docReviewAlert: 'edr_freight_app:bookings:doc_review_alert', - governmentExpedite: 'edr_freight_app:bookings:government_expedite', + view: "edr_freight_app:bookings:view", + create: "edr_freight_app:bookings:create", + clearanceView: "edr_freight_app:bookings:clearance_view", + staffAccept: "edr_freight_app:bookings:staff_accept", + requestChanges: "edr_freight_app:bookings:request_changes", + reject: "edr_freight_app:bookings:reject", + approveLineStaff: "edr_freight_app:bookings:approve_line_staff", + approveDirector: "edr_freight_app:bookings:approve_director", + approveCeo: "edr_freight_app:bookings:approve_ceo", + rejectApproval: "edr_freight_app:bookings:reject_approval", + generateContract: "edr_freight_app:bookings:generate_contract", + signStaff: "edr_freight_app:bookings:sign_staff", + operations: "edr_freight_app:bookings:operations", + cancel: "edr_freight_app:bookings:cancel", + reviewDocuments: "edr_freight_app:bookings:review_documents", + uploadClearanceOutput: "edr_freight_app:bookings:upload_clearance_output", + finalizeClearance: "edr_freight_app:bookings:finalize_clearance", + docReviewAlert: "edr_freight_app:bookings:doc_review_alert", + governmentExpedite: "edr_freight_app:bookings:government_expedite", + wagonCancellationView: "edr_freight_app:bookings:wagon_cancellation_view", + wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void", + wagonCancellationRebook: + "edr_freight_app:bookings:wagon_cancellation_rebook", }, contracts: { - view: 'edr_freight_app:contracts:view', + view: "edr_freight_app:contracts:view", staffAccept: { - bulk: 'edr_freight_app:contracts:staff_accept:bulk', - container: 'edr_freight_app:contracts:staff_accept:container', + bulk: "edr_freight_app:contracts:staff_accept:bulk", + container: "edr_freight_app:contracts:staff_accept:container", }, requestChanges: { - bulk: 'edr_freight_app:contracts:request_changes:bulk', - container: 'edr_freight_app:contracts:request_changes:container', + bulk: "edr_freight_app:contracts:request_changes:bulk", + container: "edr_freight_app:contracts:request_changes:container", }, reject: { - bulk: 'edr_freight_app:contracts:reject:bulk', - container: 'edr_freight_app:contracts:reject:container', + bulk: "edr_freight_app:contracts:reject:bulk", + container: "edr_freight_app:contracts:reject:container", }, - approveLineStaff: 'edr_freight_app:contracts:approve_line_staff', - approveDirector: 'edr_freight_app:contracts:approve_director', - approveCeo: 'edr_freight_app:contracts:approve_ceo', - hazardousApprovalOne: 'edr_freight_app:contracts:hazardous_approval_one', - hazardousApprovalTwo: 'edr_freight_app:contracts:hazardous_approval_two', - generateContract: 'edr_freight_app:contracts:generate_contract', + approveLineStaff: "edr_freight_app:contracts:approve_line_staff", + approveDirector: "edr_freight_app:contracts:approve_director", + approveCeo: "edr_freight_app:contracts:approve_ceo", + hazardousApprovalOne: "edr_freight_app:contracts:hazardous_approval_one", + hazardousApprovalTwo: "edr_freight_app:contracts:hazardous_approval_two", + generateContract: "edr_freight_app:contracts:generate_contract", signStaff: { - bulk: 'edr_freight_app:contracts:sign_staff:bulk', - container: 'edr_freight_app:contracts:sign_staff:container', + bulk: "edr_freight_app:contracts:sign_staff:bulk", + container: "edr_freight_app:contracts:sign_staff:container", }, - clearanceReview: 'edr_freight_app:contracts:clearance_review', - finalizeClearance: 'edr_freight_app:contracts:finalize_clearance', - createBooking: 'edr_freight_app:contracts:create_booking', - opsClearanceReview: 'edr_freight_app:contracts:ops_clearance_review', - clearanceEtActions: 'edr_freight_app:contracts:clearance_et_actions', - clearanceDjActions: 'edr_freight_app:contracts:clearance_dj_actions', - clearanceDutyAdvise: 'edr_freight_app:contracts:clearance_duty_advise', - suspend: 'edr_freight_app:contracts:suspend', - editDocument: 'edr_freight_app:contracts:edit_document', - finalInvoiceRaise: 'edr_freight_app:contracts:final_invoice_raise', - finalInvoiceConfirm: 'edr_freight_app:contracts:final_invoice_confirm', + clearanceReview: "edr_freight_app:contracts:clearance_review", + finalizeClearance: "edr_freight_app:contracts:finalize_clearance", + createBooking: "edr_freight_app:contracts:create_booking", + opsClearanceReview: "edr_freight_app:contracts:ops_clearance_review", + clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions", + clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions", + clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise", + suspend: "edr_freight_app:contracts:suspend", + editDocument: "edr_freight_app:contracts:edit_document", + finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise", + finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm", }, trainScheduling: { - view: 'edr_freight_app:train_scheduling:view', - create: 'edr_freight_app:train_scheduling:create', - update: 'edr_freight_app:train_scheduling:update', - cancel: 'edr_freight_app:train_scheduling:cancel', - reschedule: 'edr_freight_app:train_scheduling:reschedule', - rulesManage: 'edr_freight_app:train_scheduling:rules_manage', - dispatch: 'edr_freight_app:train_scheduling:dispatch', - markPaid: 'edr_freight_app:train_scheduling:mark_paid', - expireBooking: 'edr_freight_app:train_scheduling:expire_booking', + view: "edr_freight_app:train_scheduling:view", + create: "edr_freight_app:train_scheduling:create", + update: "edr_freight_app:train_scheduling:update", + cancel: "edr_freight_app:train_scheduling:cancel", + reschedule: "edr_freight_app:train_scheduling:reschedule", + rulesManage: "edr_freight_app:train_scheduling:rules_manage", + dispatch: "edr_freight_app:train_scheduling:dispatch", + markPaid: "edr_freight_app:train_scheduling:mark_paid", + expireBooking: "edr_freight_app:train_scheduling:expire_booking", }, fleet: { - view: 'edr_freight_app:fleet:view', - manage: 'edr_freight_app:fleet:manage', + view: "edr_freight_app:fleet:view", + manage: "edr_freight_app:fleet:manage", }, - admin: 'edr_freight_app:admin', + admin: "edr_freight_app:admin", ruleEngine: { view: (slug: RuleEngineResourceSlug) => `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`, @@ -531,322 +1443,322 @@ export const FREIGHT_PERMS = { `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`, }, allocation: { - manage: 'edr_freight_app:allocation:manage', + manage: "edr_freight_app:allocation:manage", }, customers: { - view: 'edr_freight_app:customers:view', - create: 'edr_freight_app:customers:create', - update: 'edr_freight_app:customers:update', - deactivate: 'edr_freight_app:customers:deactivate', - verify: 'edr_freight_app:customers:verify', - resetPassword: 'edr_freight_app:customers:reset-password', + view: "edr_freight_app:customers:view", + create: "edr_freight_app:customers:create", + update: "edr_freight_app:customers:update", + deactivate: "edr_freight_app:customers:deactivate", + verify: "edr_freight_app:customers:verify", + resetPassword: "edr_freight_app:customers:reset-password", }, payments: { - view: 'edr_freight_app:payments:view', + view: "edr_freight_app:payments:view", }, invoices: { - view: 'edr_freight_app:invoices:view', - export: 'edr_freight_app:invoices:export', + view: "edr_freight_app:invoices:view", + export: "edr_freight_app:invoices:export", }, firstMile: { - view: 'edr_freight_app:first_mile:view', - accept: 'edr_freight_app:first_mile:accept', - create: 'edr_freight_app:first_mile:create', - update: 'edr_freight_app:first_mile:update', - delete: 'edr_freight_app:first_mile:delete', - assignVehicles: 'edr_freight_app:first_mile:assign_vehicles', - setDistances: 'edr_freight_app:first_mile:set_distances', - generateInvoice: 'edr_freight_app:first_mile:generate_invoice', + view: "edr_freight_app:first_mile:view", + accept: "edr_freight_app:first_mile:accept", + create: "edr_freight_app:first_mile:create", + update: "edr_freight_app:first_mile:update", + delete: "edr_freight_app:first_mile:delete", + assignVehicles: "edr_freight_app:first_mile:assign_vehicles", + setDistances: "edr_freight_app:first_mile:set_distances", + generateInvoice: "edr_freight_app:first_mile:generate_invoice", }, lastMile: { - view: 'edr_freight_app:last_mile:view', - accept: 'edr_freight_app:last_mile:accept', - create: 'edr_freight_app:last_mile:create', - update: 'edr_freight_app:last_mile:update', - delete: 'edr_freight_app:last_mile:delete', - assignVehicles: 'edr_freight_app:last_mile:assign_vehicles', - setDistances: 'edr_freight_app:last_mile:set_distances', - generateInvoice: 'edr_freight_app:last_mile:generate_invoice', + view: "edr_freight_app:last_mile:view", + accept: "edr_freight_app:last_mile:accept", + create: "edr_freight_app:last_mile:create", + update: "edr_freight_app:last_mile:update", + delete: "edr_freight_app:last_mile:delete", + assignVehicles: "edr_freight_app:last_mile:assign_vehicles", + setDistances: "edr_freight_app:last_mile:set_distances", + generateInvoice: "edr_freight_app:last_mile:generate_invoice", // Pre-approval confirmation stage (Truck & Machinery department): view/review // a submitted request, approve/reject it. - requestView: 'edr_freight_app:last_mile:request_view', - requestReview: 'edr_freight_app:last_mile:request_review', - requestApprove: 'edr_freight_app:last_mile:request_approve', + requestView: "edr_freight_app:last_mile:request_view", + requestReview: "edr_freight_app:last_mile:request_review", + requestApprove: "edr_freight_app:last_mile:request_approve", }, locomotives: { - view: 'edr_freight_app:locomotives:view', - create: 'edr_freight_app:locomotives:create', - update: 'edr_freight_app:locomotives:update', - delete: 'edr_freight_app:locomotives:delete', + view: "edr_freight_app:locomotives:view", + create: "edr_freight_app:locomotives:create", + update: "edr_freight_app:locomotives:update", + delete: "edr_freight_app:locomotives:delete", /** * Permanently purge the row — irreversible, and separate from `delete` * (which only decommissions) so it can be granted to far fewer people. */ - hardDelete: 'edr_freight_app:locomotives:hard_delete', + hardDelete: "edr_freight_app:locomotives:hard_delete", }, wagons: { - view: 'edr_freight_app:wagons:view', - create: 'edr_freight_app:wagons:create', - update: 'edr_freight_app:wagons:update', - delete: 'edr_freight_app:wagons:delete', + view: "edr_freight_app:wagons:view", + create: "edr_freight_app:wagons:create", + update: "edr_freight_app:wagons:update", + delete: "edr_freight_app:wagons:delete", /** Permanently purge the row — irreversible; see locomotives.hardDelete. */ - hardDelete: 'edr_freight_app:wagons:hard_delete', + hardDelete: "edr_freight_app:wagons:hard_delete", // Requester creates a transfer request; OCC fulfils it (picks the wagons and // executes the move). Distinct keys so OCC can hold fulfil without request. - transferRequest: 'edr_freight_app:wagons:transfer_request', - transferFulfill: 'edr_freight_app:wagons:transfer_fulfill', + transferRequest: "edr_freight_app:wagons:transfer_request", + transferFulfill: "edr_freight_app:wagons:transfer_fulfill", /** Open the transfer-requests desk (list + detail). */ - transferView: 'edr_freight_app:wagons:transfer_view', + transferView: "edr_freight_app:wagons:transfer_view", /** Withdraw a request that has not moved any wagon yet. */ - transferCancel: 'edr_freight_app:wagons:transfer_cancel', + transferCancel: "edr_freight_app:wagons:transfer_cancel", /** End a request short — anyone who can fulfil may also do this. */ - transferCloseShort: 'edr_freight_app:wagons:transfer_close_short', + transferCloseShort: "edr_freight_app:wagons:transfer_close_short", // Admin: read every staffer's transfer history. Without it, a user only sees // their own (the /history endpoint uses the caller id, backend-enforced). - transferHistoryAll: 'edr_freight_app:wagons:transfer_history_all', + transferHistoryAll: "edr_freight_app:wagons:transfer_history_all", }, trains: { - view: 'edr_freight_app:trains:view', - create: 'edr_freight_app:trains:create', - update: 'edr_freight_app:trains:update', - delete: 'edr_freight_app:trains:delete', - assignWagons: 'edr_freight_app:trains:assign_wagons', + view: "edr_freight_app:trains:view", + create: "edr_freight_app:trains:create", + update: "edr_freight_app:trains:update", + delete: "edr_freight_app:trains:delete", + assignWagons: "edr_freight_app:trains:assign_wagons", }, routes: { - view: 'edr_freight_app:routes:view', - create: 'edr_freight_app:routes:create', - update: 'edr_freight_app:routes:update', - delete: 'edr_freight_app:routes:delete', + view: "edr_freight_app:routes:view", + create: "edr_freight_app:routes:create", + update: "edr_freight_app:routes:update", + delete: "edr_freight_app:routes:delete", /** Permanently purge the row — irreversible; see locomotives.hardDelete. */ - hardDelete: 'edr_freight_app:routes:hard_delete', + hardDelete: "edr_freight_app:routes:hard_delete", }, containers: { - view: 'edr_freight_app:containers:view', - create: 'edr_freight_app:containers:create', - update: 'edr_freight_app:containers:update', - delete: 'edr_freight_app:containers:delete', + view: "edr_freight_app:containers:view", + create: "edr_freight_app:containers:create", + update: "edr_freight_app:containers:update", + delete: "edr_freight_app:containers:delete", }, cargoes: { - view: 'edr_freight_app:cargoes:view', - create: 'edr_freight_app:cargoes:create', - update: 'edr_freight_app:cargoes:update', - delete: 'edr_freight_app:cargoes:delete', + view: "edr_freight_app:cargoes:view", + create: "edr_freight_app:cargoes:create", + update: "edr_freight_app:cargoes:update", + delete: "edr_freight_app:cargoes:delete", }, consignments: { - view: 'edr_freight_app:consignments:view', - create: 'edr_freight_app:consignments:create', + view: "edr_freight_app:consignments:view", + create: "edr_freight_app:consignments:create", }, vehicles: { - view: 'edr_freight_app:vehicles:view', - create: 'edr_freight_app:vehicles:create', - update: 'edr_freight_app:vehicles:update', - delete: 'edr_freight_app:vehicles:delete', + view: "edr_freight_app:vehicles:view", + create: "edr_freight_app:vehicles:create", + update: "edr_freight_app:vehicles:update", + delete: "edr_freight_app:vehicles:delete", }, drivers: { - view: 'edr_freight_app:drivers:view', - create: 'edr_freight_app:drivers:create', - update: 'edr_freight_app:drivers:update', - delete: 'edr_freight_app:drivers:delete', + view: "edr_freight_app:drivers:view", + create: "edr_freight_app:drivers:create", + update: "edr_freight_app:drivers:update", + delete: "edr_freight_app:drivers:delete", }, tracking: { - view: 'edr_freight_app:tracking:view', - manage: 'edr_freight_app:tracking:manage', + view: "edr_freight_app:tracking:view", + manage: "edr_freight_app:tracking:manage", }, fuel: { - view: 'edr_freight_app:fuel:view', - create: 'edr_freight_app:fuel:create', - update: 'edr_freight_app:fuel:update', - delete: 'edr_freight_app:fuel:delete', + view: "edr_freight_app:fuel:view", + create: "edr_freight_app:fuel:create", + update: "edr_freight_app:fuel:update", + delete: "edr_freight_app:fuel:delete", }, maintenance: { - view: 'edr_freight_app:maintenance:view', - create: 'edr_freight_app:maintenance:create', - update: 'edr_freight_app:maintenance:update', - delete: 'edr_freight_app:maintenance:delete', + view: "edr_freight_app:maintenance:view", + create: "edr_freight_app:maintenance:create", + update: "edr_freight_app:maintenance:update", + delete: "edr_freight_app:maintenance:delete", }, fleetReports: { - view: 'edr_freight_app:fleet_reports:view', - export: 'edr_freight_app:fleet_reports:export', + view: "edr_freight_app:fleet_reports:view", + export: "edr_freight_app:fleet_reports:export", }, fleetDashboard: { - view: 'edr_freight_app:fleet_dashboard:view', + view: "edr_freight_app:fleet_dashboard:view", }, warehouseDashboard: { - view: 'edr_freight_app:warehouse_dashboard:view', + view: "edr_freight_app:warehouse_dashboard:view", }, warehouses: { - view: 'edr_freight_app:warehouses:view', - create: 'edr_freight_app:warehouses:create', - update: 'edr_freight_app:warehouses:update', - delete: 'edr_freight_app:warehouses:delete', + view: "edr_freight_app:warehouses:view", + create: "edr_freight_app:warehouses:create", + update: "edr_freight_app:warehouses:update", + delete: "edr_freight_app:warehouses:delete", }, warehouseYards: { - view: 'edr_freight_app:warehouse_yards:view', - create: 'edr_freight_app:warehouse_yards:create', - update: 'edr_freight_app:warehouse_yards:update', - delete: 'edr_freight_app:warehouse_yards:delete', + view: "edr_freight_app:warehouse_yards:view", + create: "edr_freight_app:warehouse_yards:create", + update: "edr_freight_app:warehouse_yards:update", + delete: "edr_freight_app:warehouse_yards:delete", }, warehouseZones: { - view: 'edr_freight_app:warehouse_zones:view', - create: 'edr_freight_app:warehouse_zones:create', - update: 'edr_freight_app:warehouse_zones:update', + view: "edr_freight_app:warehouse_zones:view", + create: "edr_freight_app:warehouse_zones:create", + update: "edr_freight_app:warehouse_zones:update", }, warehouseAllocationRules: { - view: 'edr_freight_app:warehouse_allocation_rules:view', - create: 'edr_freight_app:warehouse_allocation_rules:create', - update: 'edr_freight_app:warehouse_allocation_rules:update', - delete: 'edr_freight_app:warehouse_allocation_rules:delete', + view: "edr_freight_app:warehouse_allocation_rules:view", + create: "edr_freight_app:warehouse_allocation_rules:create", + update: "edr_freight_app:warehouse_allocation_rules:update", + delete: "edr_freight_app:warehouse_allocation_rules:delete", }, warehouseFeeRules: { - view: 'edr_freight_app:warehouse_fee_rules:view', - create: 'edr_freight_app:warehouse_fee_rules:create', - update: 'edr_freight_app:warehouse_fee_rules:update', - delete: 'edr_freight_app:warehouse_fee_rules:delete', + view: "edr_freight_app:warehouse_fee_rules:view", + create: "edr_freight_app:warehouse_fee_rules:create", + update: "edr_freight_app:warehouse_fee_rules:update", + delete: "edr_freight_app:warehouse_fee_rules:delete", }, warehouseInspectionReports: { - view: 'edr_freight_app:warehouse_inspection_reports:view', - create: 'edr_freight_app:warehouse_inspection_reports:create', - update: 'edr_freight_app:warehouse_inspection_reports:update', + view: "edr_freight_app:warehouse_inspection_reports:view", + create: "edr_freight_app:warehouse_inspection_reports:create", + update: "edr_freight_app:warehouse_inspection_reports:update", }, warehouseInventory: { - view: 'edr_freight_app:warehouse_inventory:view', - receive: 'edr_freight_app:warehouse_inventory:receive', - move: 'edr_freight_app:warehouse_inventory:move', - load: 'edr_freight_app:warehouse_inventory:load', - unload: 'edr_freight_app:warehouse_inventory:unload', - dispatch: 'edr_freight_app:warehouse_inventory:dispatch', - gatePass: 'edr_freight_app:warehouse_inventory:gate_pass', - release: 'edr_freight_app:warehouse_inventory:release', - deliver: 'edr_freight_app:warehouse_inventory:deliver', - inspect: 'edr_freight_app:warehouse_inventory:inspect', + view: "edr_freight_app:warehouse_inventory:view", + receive: "edr_freight_app:warehouse_inventory:receive", + move: "edr_freight_app:warehouse_inventory:move", + load: "edr_freight_app:warehouse_inventory:load", + unload: "edr_freight_app:warehouse_inventory:unload", + dispatch: "edr_freight_app:warehouse_inventory:dispatch", + gatePass: "edr_freight_app:warehouse_inventory:gate_pass", + release: "edr_freight_app:warehouse_inventory:release", + deliver: "edr_freight_app:warehouse_inventory:deliver", + inspect: "edr_freight_app:warehouse_inventory:inspect", }, interchangeDocuments: { - view: 'edr_freight_app:interchange_documents:view', - generate: 'edr_freight_app:interchange_documents:generate', - acknowledge: 'edr_freight_app:interchange_documents:acknowledge', - dispute: 'edr_freight_app:interchange_documents:dispute', - cancel: 'edr_freight_app:interchange_documents:cancel', + view: "edr_freight_app:interchange_documents:view", + generate: "edr_freight_app:interchange_documents:generate", + acknowledge: "edr_freight_app:interchange_documents:acknowledge", + dispute: "edr_freight_app:interchange_documents:dispute", + cancel: "edr_freight_app:interchange_documents:cancel", }, warehouseFeeInvoices: { - view: 'edr_freight_app:warehouse_fee_invoices:view', - generate: 'edr_freight_app:warehouse_fee_invoices:generate', - cancel: 'edr_freight_app:warehouse_fee_invoices:cancel', - pay: 'edr_freight_app:warehouse_fee_invoices:pay', + view: "edr_freight_app:warehouse_fee_invoices:view", + generate: "edr_freight_app:warehouse_fee_invoices:generate", + cancel: "edr_freight_app:warehouse_fee_invoices:cancel", + pay: "edr_freight_app:warehouse_fee_invoices:pay", }, settings: { fileUpload: { - view: 'edr_freight_app:settings:file_upload:view', - manage: 'edr_freight_app:settings:file_upload:manage', + view: "edr_freight_app:settings:file_upload:view", + manage: "edr_freight_app:settings:file_upload:manage", }, dropdown: { - view: 'edr_freight_app:settings:dropdown:view', - manage: 'edr_freight_app:settings:dropdown:manage', + view: "edr_freight_app:settings:dropdown:view", + manage: "edr_freight_app:settings:dropdown:manage", }, exchangeRate: { - view: 'edr_freight_app:settings:exchange_rate:view', - manage: 'edr_freight_app:settings:exchange_rate:manage', + view: "edr_freight_app:settings:exchange_rate:view", + manage: "edr_freight_app:settings:exchange_rate:manage", }, contractTemplates: { - view: 'edr_freight_app:settings:contract_templates:view', - manage: 'edr_freight_app:settings:contract_templates:manage', + view: "edr_freight_app:settings:contract_templates:view", + manage: "edr_freight_app:settings:contract_templates:manage", }, }, audit: { - view: 'edr_freight_app:audit:view', + view: "edr_freight_app:audit:view", }, support: { - agentView: 'edr_freight_app:support:agent_view', - agentSend: 'edr_freight_app:support:agent_send', + agentView: "edr_freight_app:support:agent_view", + agentSend: "edr_freight_app:support:agent_send", }, procurement: { - view: 'edr_freight_app:procurement:view', - vendorManage: 'edr_freight_app:procurement:vendor_manage', - acquisitionManage: 'edr_freight_app:procurement:acquisition_manage', - disposalManage: 'edr_freight_app:procurement:disposal_manage', + view: "edr_freight_app:procurement:view", + vendorManage: "edr_freight_app:procurement:vendor_manage", + acquisitionManage: "edr_freight_app:procurement:acquisition_manage", + disposalManage: "edr_freight_app:procurement:disposal_manage", }, compliance: { - view: 'edr_freight_app:compliance:view', - manage: 'edr_freight_app:compliance:manage', + view: "edr_freight_app:compliance:view", + manage: "edr_freight_app:compliance:manage", }, facilities: { - view: 'edr_freight_app:facilities:view', - manage: 'edr_freight_app:facilities:manage', + view: "edr_freight_app:facilities:view", + manage: "edr_freight_app:facilities:manage", }, tradeAccess: { - view: 'edr_freight_app:trade_access:view', - manage: 'edr_freight_app:trade_access:manage', + view: "edr_freight_app:trade_access:view", + manage: "edr_freight_app:trade_access:manage", }, overview: { - view: 'edr_freight_app:overview:view', + view: "edr_freight_app:overview:view", }, reports: { - view: 'edr_freight_app:reports:view', + view: "edr_freight_app:reports:view", }, staff: { users: { - view: 'edr_freight_app:staff:users:view', + view: "edr_freight_app:staff:users:view", }, roles: { - view: 'edr_freight_app:staff:roles:view', - create: 'edr_freight_app:staff:roles:create', - update: 'edr_freight_app:staff:roles:update', - delete: 'edr_freight_app:staff:roles:delete', + view: "edr_freight_app:staff:roles:view", + create: "edr_freight_app:staff:roles:create", + update: "edr_freight_app:staff:roles:update", + delete: "edr_freight_app:staff:roles:delete", }, permissions: { - view: 'edr_freight_app:staff:permissions:view', - assign: 'edr_freight_app:staff:permissions:assign', + view: "edr_freight_app:staff:permissions:view", + assign: "edr_freight_app:staff:permissions:assign", }, // Seeded in edr-freight.seed.ts (EDR_FREIGHT_PERMISSIONS) — surfaced here for gating. employeeRegistration: { - view: 'edr_freight_app:employee_registration:view', - create: 'edr_freight_app:employee_registration:create', - update: 'edr_freight_app:employee_registration:update', - activate: 'edr_freight_app:employee_registration:activate', - deactivate: 'edr_freight_app:employee_registration:deactivate', + view: "edr_freight_app:employee_registration:view", + create: "edr_freight_app:employee_registration:create", + update: "edr_freight_app:employee_registration:update", + activate: "edr_freight_app:employee_registration:activate", + deactivate: "edr_freight_app:employee_registration:deactivate", }, roleAssignment: { - view: 'edr_freight_app:role_assignment:view', - assign: 'edr_freight_app:role_assignment:assign', - replace: 'edr_freight_app:role_assignment:replace', + view: "edr_freight_app:role_assignment:view", + assign: "edr_freight_app:role_assignment:assign", + replace: "edr_freight_app:role_assignment:replace", }, hierarchyUnits: { - view: 'edr_freight_app:hierarchy_units:view', - create: 'edr_freight_app:hierarchy_units:create', - update: 'edr_freight_app:hierarchy_units:update', - delete: 'edr_freight_app:hierarchy_units:delete', + view: "edr_freight_app:hierarchy_units:view", + create: "edr_freight_app:hierarchy_units:create", + update: "edr_freight_app:hierarchy_units:update", + delete: "edr_freight_app:hierarchy_units:delete", }, hierarchyPositions: { - view: 'edr_freight_app:hierarchy_positions:view', - create: 'edr_freight_app:hierarchy_positions:create', - update: 'edr_freight_app:hierarchy_positions:update', - delete: 'edr_freight_app:hierarchy_positions:delete', - changeParent: 'edr_freight_app:hierarchy_positions:change_parent', + view: "edr_freight_app:hierarchy_positions:view", + create: "edr_freight_app:hierarchy_positions:create", + update: "edr_freight_app:hierarchy_positions:update", + delete: "edr_freight_app:hierarchy_positions:delete", + changeParent: "edr_freight_app:hierarchy_positions:change_parent", }, hierarchyEmployeeAssignment: { - view: 'edr_freight_app:hierarchy_employee_assignment:view', - invite: 'edr_freight_app:hierarchy_employee_assignment:invite', - assign: 'edr_freight_app:hierarchy_employee_assignment:assign', + view: "edr_freight_app:hierarchy_employee_assignment:view", + invite: "edr_freight_app:hierarchy_employee_assignment:invite", + assign: "edr_freight_app:hierarchy_employee_assignment:assign", }, positionTypes: { - view: 'edr_freight_app:position_types:view', - create: 'edr_freight_app:position_types:create', - update: 'edr_freight_app:position_types:update', - delete: 'edr_freight_app:position_types:delete', + view: "edr_freight_app:position_types:view", + create: "edr_freight_app:position_types:create", + update: "edr_freight_app:position_types:update", + delete: "edr_freight_app:position_types:delete", }, }, } as const; /** Both arms of a freight-type-split permission (for one-of route guards). */ -export const bothFreightTypes = (p: { bulk: string; container: string }): string[] => [ - p.bulk, - p.container, -]; +export const bothFreightTypes = (p: { + bulk: string; + container: string; +}): string[] => [p.bulk, p.container]; /** The arm of a freight-type-split permission matching a contract's freightType. */ export const forFreightType = ( p: { bulk: string; container: string }, freightType: string, -): string => (freightType === 'BULK' ? p.bulk : p.container); +): string => (freightType === "BULK" ? p.bulk : p.container); const allRuleEngineViewKeys = () => RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s)); @@ -906,6 +1818,8 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.rejectApproval, FREIGHT_PERMS.bookings.cancel, + FREIGHT_PERMS.bookings.wagonCancellationView, + FREIGHT_PERMS.bookings.wagonCancellationVoid, FREIGHT_PERMS.contracts.view, ...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept), ...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges), @@ -920,6 +1834,7 @@ export const ROLE_PERMISSION_PRESETS = { ...STAFF_DASHBOARD_KEYS, FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.operations, + FREIGHT_PERMS.bookings.wagonCancellationView, // They are the ones who accept/reject operation requests, so they are the // ones the doc-review countdown is for. FREIGHT_PERMS.bookings.docReviewAlert, @@ -971,6 +1886,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.export, FREIGHT_PERMS.payments.view, + FREIGHT_PERMS.bookings.wagonCancellationView, ], // Global Logistics: manages ONLY the customs-clearance queue. Scoped out of // the general booking-request list (no bookings:view) — instead a dedicated @@ -1018,6 +1934,9 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.rejectApproval, FREIGHT_PERMS.bookings.cancel, + FREIGHT_PERMS.bookings.wagonCancellationView, + FREIGHT_PERMS.bookings.wagonCancellationVoid, + FREIGHT_PERMS.bookings.wagonCancellationRebook, FREIGHT_PERMS.bookings.generateContract, FREIGHT_PERMS.bookings.signStaff, FREIGHT_PERMS.bookings.reviewDocuments, @@ -1066,7 +1985,20 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.invoices.export, FREIGHT_PERMS.payments.view, ]), - director: dedupe([...ROLE_PERMISSION_PRESETS.director]), + // Director additionally manages train scheduling + rail fleet (same block the + // operation officer/chief hold), on top of the approval-chain role preset. + director: dedupe([ + ...ROLE_PERMISSION_PRESETS.director, + FREIGHT_PERMS.trainScheduling.view, + FREIGHT_PERMS.trainScheduling.create, + FREIGHT_PERMS.trainScheduling.update, + FREIGHT_PERMS.trainScheduling.cancel, + FREIGHT_PERMS.trainScheduling.reschedule, + FREIGHT_PERMS.trainScheduling.rulesManage, + FREIGHT_PERMS.fleet.view, + FREIGHT_PERMS.fleet.manage, + ...FLEET_GRANULAR_KEYS, + ]), ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]), ethiopianGl: dedupe([...ROLE_PERMISSION_PRESETS.glEthiopia]), djiboutiGl: dedupe([...ROLE_PERMISSION_PRESETS.glDjibouti]), @@ -1135,7 +2067,7 @@ export const POSITION_PERMISSION_PRESETS = { } as const; /** Derive the module bucket from the resource segment of a permission key. */ -const moduleOf = (key: string): string => key.split(':')[1] ?? 'other'; +const moduleOf = (key: string): string => key.split(":")[1] ?? "other"; export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({ key: p.key, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 4c5016e94..6fd666a8d 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -29,6 +29,7 @@ import { Wallet, LifeBuoy, TrainFront, + XCircle, } from "lucide-react"; import { useEffect } from "react"; import { @@ -54,6 +55,7 @@ import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; +import WagonCancellationsPage from "./pages/bookings/WagonCancellationsPage"; import ContractRequestsPage from "./pages/contracts/ContractRequestsPage"; import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPage"; import ContractViewPage from "./pages/contracts/ContractViewPage"; @@ -184,6 +186,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.bookings.view, }, + { + label: "Wagon cancellations", + href: "/dashboard/wagon-cancellations", + icon: , + permission: FREIGHT_PERMS.bookings.wagonCancellationView, + }, // Operations hub: per-shipment clearance-document review for services // WITHOUT customs clearing (self-clearance) — bookings only. { @@ -901,6 +909,16 @@ const App = () => { } /> } /> + + + + } + /> diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts index d3d3e3bc1..c32ab38b2 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts @@ -144,4 +144,10 @@ export interface BookingDetailView { bookingContainers?: BookingContainerView[]; reviewNotes?: BookingReviewNoteView[]; files?: BookingFileView[]; + /** The allocated train, present once the booking is placed on a schedule. */ + trainSchedule?: { + trainNumber: string | null; + reference: string | null; + scheduledDepartureDate: string | null; + } | null; } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx index 16b1ab602..f3b56a464 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx @@ -1,6 +1,6 @@ import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core"; import { DateInput } from "@mantine/dates"; -import { AlertTriangle, Send } from "lucide-react"; +import { AlertTriangle, Pencil, Send } from "lucide-react"; import { useState } from "react"; import { Link } from "react-router-dom"; import toast from "react-hot-toast"; @@ -16,6 +16,9 @@ export interface BookingChangesRequestedAlertProps { scheduledDate?: string | null; /** GL Ethiopia owns customs bookings, so only they get the resubmit control. */ canResubmit: boolean; + /** Completion-form route for editing the cargo before resubmitting — + * rendered only for resubmit-capable users when provided. */ + editHref?: string; onResubmitted?: () => void; } @@ -33,6 +36,7 @@ export function BookingChangesRequestedAlert({ note, scheduledDate, canResubmit, + editHref, onResubmitted, }: BookingChangesRequestedAlertProps) { const [day, setDay] = useState( @@ -122,6 +126,18 @@ export function BookingChangesRequestedAlert({ > Resubmit to Operations + {editHref ? ( + + ) : null} ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx index 61f8251c9..f665f388b 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -225,7 +225,13 @@ export function ExportClearanceStepper({ diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 52f7ee40d..42ecbce61 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -961,7 +961,7 @@ export default function GlCreateBookingForm() { // modal falls back to the contract unit-rate estimate while it loads. const validateShipmentMutation = useMutation({ mutationFn: (dto: Freight.CreateBookingUnderContractDto) => - contractsService.validateShipment(id ?? "", dto), + contractsService.validateShipment(id ?? "", dto, completeBookingId), }); const validation = validateShipmentMutation.data ?? null; diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx index ba9e01402..df1a66330 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx @@ -39,6 +39,8 @@ interface WindowRow { windowClosesAt: string | null; docReviewEndsAt: string | null; paymentPhaseEndsAt: string | null; + /** End of the payment drain tail — pending payments may settle until then. */ + paymentDrainEndsAt?: string | null; bookingWindowStatus: string; bookingCycleNo: number; departureDate: string; @@ -94,16 +96,29 @@ const COUNTDOWN_TEXT: Partial< PRE_WINDOW: { label: "Opens in", expiredText: "Opening now…" }, OPEN: { label: "Closes in", expiredText: "Review starting…" }, DOC_REVIEW: { label: "Doc review ends in", expiredText: "Payment starting…" }, - PAYMENT: { label: "Payment ends in", expiredText: "Closing…" }, + PAYMENT: { label: "Payment ends in", expiredText: "Finalizing…" }, }; -function phaseCountdown( - w: WindowRow, -): { label: string; deadline: string; expiredText: string } | null { +function phaseCountdown(w: WindowRow): { + label: string; + deadline: string; + expiredText: string; + graceDeadline?: string | null; + graceLabel?: string; +} | null { const state = bookingWindowUiState(w); const text = COUNTDOWN_TEXT[state.kind]; if (!state.countdownTo || !text) return null; - return { ...text, deadline: state.countdownTo }; + // Once the pay deadline lapses, pending payments still settle during the + // drain tail — count it down as "processing" instead of a stale "closing". + const grace = + state.kind === "PAYMENT" && w.paymentDrainEndsAt + ? { + graceDeadline: w.paymentDrainEndsAt, + graceLabel: "Processing payments — closes in", + } + : undefined; + return { ...text, deadline: state.countdownTo, ...grace }; } /** Badge label + Mantine color per UI state — same state the countdown uses. */ @@ -225,6 +240,8 @@ function WindowCard({ w }: { w: WindowRow }) { deadline={cd.deadline} label={cd.label} expiredText={cd.expiredText} + graceDeadline={cd.graceDeadline} + graceLabel={cd.graceLabel} size="xs" /> diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 88074735b..926637db2 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -357,7 +357,14 @@ export function PhasedClearanceActionPanel({ diff --git a/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts index b9195b1ff..59318f703 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts @@ -30,6 +30,7 @@ interface WindowRow { windowClosesAt: string | null; docReviewEndsAt: string | null; paymentPhaseEndsAt: string | null; + paymentDrainEndsAt?: string | null; bookingWindowStatus: string; bookingCycleNo: number; departureDate: string; @@ -55,6 +56,7 @@ function applyEvent(row: T, event: BookingWindowPhaseEvent) windowClosesAt: event.windowClosesAt, docReviewEndsAt: event.docReviewEndsAt, paymentPhaseEndsAt: event.paymentPhaseEndsAt, + paymentDrainEndsAt: event.paymentDrainEndsAt, departureDate: event.scheduledDepartureDate ?? row.departureDate, }; } diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index c26a3f3a1..48c78af40 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -53,6 +53,10 @@ export const FREIGHT_PERMS = { finalizeClearance: "edr_freight_app:bookings:finalize_clearance", docReviewAlert: "edr_freight_app:bookings:doc_review_alert", governmentExpedite: "edr_freight_app:bookings:government_expedite", + wagonCancellationView: "edr_freight_app:bookings:wagon_cancellation_view", + wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void", + wagonCancellationRebook: + "edr_freight_app:bookings:wagon_cancellation_rebook", }, contracts: { view: "edr_freight_app:contracts:view", @@ -382,6 +386,14 @@ export function getPermissionKeys(user: AuthUser | null | undefined): string[] { for (const p of pos.permissions ?? []) { if (p.key) keys.add(p.key); } + // Positions created through the admin UI keep their grants on the + // position TYPE, not the position — miss these and such staff resolve to + // zero permissions and every gated route rejects them. `/api/me` folds + // them into the position's permission list, but older payloads may still + // carry them separately. + for (const p of pos.positionType?.permissions ?? []) { + if (p.key) keys.add(p.key); + } } } return [...keys]; @@ -597,7 +609,9 @@ export function canViewScheduling(user: AuthUser | null | undefined): boolean { } /** Any train-scheduling write action (create / update / cancel / reschedule). */ -export function canManageScheduling(user: AuthUser | null | undefined): boolean { +export function canManageScheduling( + user: AuthUser | null | undefined, +): boolean { return ( hasPermission(user, FREIGHT_PERMS.trainScheduling.create) || hasPermission(user, FREIGHT_PERMS.trainScheduling.update) || diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx new file mode 100644 index 000000000..fff83d479 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx @@ -0,0 +1,420 @@ +import { + Anchor, + Badge, + Box, + Button, + Card, + Group, + Modal, + Select, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { DateInput } from "@mantine/dates"; +import { useDebouncedValue } from "@mantine/hooks"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Search, XCircle } from "lucide-react"; +import { useMemo, useState } from "react"; +import toast from "react-hot-toast"; +import { Link } from "react-router-dom"; + +import { api } from "@/auth/http"; +import { useAuth } from "@/auth/useAuth"; +import { PageContainer, PageHeader } from "@/components/page"; +import { toDayString } from "@/hooks/useListControls"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { + DataTable, + DataTableFooter, + usePagination, + type ColumnDef, +} from "@edr/ui-common"; + +type WagonCancellationStatus = + | "FEE_PENDING" + | "CREDIT_AVAILABLE" + | "REBOOKED" + | "WITHDRAWN" + | "EXPIRED"; + +interface WagonCancellation { + id: string; + bookingId: string; + rebookedBookingId?: string | null; + wagonsCancelled: number; + weightTons: number; + creditAmount: number; + feeAmount: number; + feeCurrency: string; + feeInvoiceId?: string | null; + feePaidAt?: string | null; + status: WagonCancellationStatus; + reason?: string | null; + rebookedAt?: string | null; + createdAt: string; + booking?: { id: string; reference: string; company?: { name: string } }; + rebookedBooking?: { id: string; reference: string }; + feeInvoice?: { invoiceNumber: string; status: string }; +} + +interface WagonCancellationListResponse { + items: WagonCancellation[]; + total: number; +} + +const STATUS_CHIP: Record< + WagonCancellationStatus, + { label: string; color: string } +> = { + FEE_PENDING: { label: "Fee pending", color: "yellow" }, + CREDIT_AVAILABLE: { label: "Credit available", color: "edr-green" }, + REBOOKED: { label: "Rebooked", color: "indigo" }, + WITHDRAWN: { label: "Withdrawn", color: "gray" }, + EXPIRED: { label: "Expired", color: "red" }, +}; + +const STATUS_FILTER_OPTIONS = ( + Object.keys(STATUS_CHIP) as WagonCancellationStatus[] +).map((s) => ({ value: s, label: STATUS_CHIP[s].label })); + +function StatusChip({ status }: { status: WagonCancellationStatus }) { + const chip = STATUS_CHIP[status] ?? { label: status, color: "gray" }; + return ( + + {chip.label} + + ); +} + +function formatDate(iso: string | null | undefined): string { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) + ? "—" + : d.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function formatAmount(amount: number, currency: string): string { + return `${currency} ${Number(amount).toLocaleString(undefined, { + minimumFractionDigits: 2, + })}`; +} + +/** + * Staff view of partial wagon cancellations: every slice of capacity a + * customer gave back, its cancellation fee, and where the credit went + * (rebooked, still available, expired, or the request was voided). + */ +export default function WagonCancellationsPage() { + const { user } = useAuth(); + const canVoid = hasPermission( + user, + FREIGHT_PERMS.bookings.wagonCancellationVoid, + ); + + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [status, setStatus] = useState(null); + const [search, setSearch] = useState(""); + const [debouncedSearch] = useDebouncedValue(search, 300); + const [from, setFrom] = useState(null); + const [to, setTo] = useState(null); + const [voiding, setVoiding] = useState(null); + + const resetPage = () => + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + + const filter = useMemo( + () => ({ + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + ...(status ? { statuses: status } : {}), + ...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}), + ...(from ? { from: toDayString(from) } : {}), + ...(to ? { to: toDayString(to) } : {}), + }), + [pagination.pageIndex, pagination.pageSize, status, debouncedSearch, from, to], + ); + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ["bookings", "wagon-cancellations", filter], + queryFn: async () => { + const res = await api.get( + "/bookings/wagon-cancellations/history", + { params: filter }, + ); + return res.data; + }, + }); + const rows = data?.items ?? []; + const total = data?.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + + const withdraw = useMutation({ + mutationFn: (id: string) => + api.post(`/bookings/wagon-cancellations/${id}/withdraw`), + }); + + const columns: ColumnDef[] = [ + { + id: "requested", + header: () => Requested, + cell: ({ row }) => ( + + {formatDate(row.original.createdAt)} + + ), + }, + { + id: "booking", + header: () => Booking, + cell: ({ row }) => ( + + {row.original.booking?.reference ?? row.original.bookingId} + + ), + }, + { + id: "company", + header: () => Company, + cell: ({ row }) => ( + {row.original.booking?.company?.name ?? "—"} + ), + }, + { + id: "wagons", + header: () => Wagons, + cell: ({ row }) => {row.original.wagonsCancelled}, + }, + { + id: "fee", + header: () => Fee, + cell: ({ row }) => ( + + {formatAmount(row.original.feeAmount, row.original.feeCurrency)} + + ), + }, + { + id: "credit", + header: () => Credit, + cell: ({ row }) => ( + + {formatAmount(row.original.creditAmount, row.original.feeCurrency)} + + ), + }, + { + id: "status", + header: () => Status, + cell: ({ row }) => , + }, + { + id: "rebookedAs", + header: () => Rebooked as, + cell: ({ row }) => { + const r = row.original; + if (!r.rebookedBookingId) return ; + return ( + + {r.rebookedBooking?.reference ?? r.rebookedBookingId} + + ); + }, + }, + { + id: "actions", + header: () => , + cell: ({ row }) => { + const r = row.original; + if (r.status !== "FEE_PENDING" || !canVoid) return null; + return ( + + + + ); + }, + }, + ]; + + return ( + + + + + + + + } + value={search} + onChange={(e) => { + setSearch(e.currentTarget.value); + resetPage(); + }} + w={260} + radius="md" + /> +