mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 19:00:55 +00:00
29
.github/workflows/deploy.yml
vendored
29
.github/workflows/deploy.yml
vendored
@@ -50,10 +50,10 @@ jobs:
|
||||
|
||||
SERVICES=()
|
||||
|
||||
NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$"
|
||||
NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$|^scripts/deploy/sync-env-from-server-jenkins[.]sh$"
|
||||
|
||||
GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$"
|
||||
|
||||
|
||||
DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true)
|
||||
if [ -z "$DEPLOYABLE" ]; then
|
||||
echo "Only non-deployable files changed. Skipping deploy."
|
||||
@@ -142,6 +142,31 @@ jobs:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: ./scripts/deploy/create-npmrc.sh
|
||||
|
||||
- name: Resolve env file path for ${{ matrix.service }}
|
||||
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
|
||||
run: |
|
||||
case "${{ matrix.service }}" in
|
||||
passenger-api) echo "SERVICE_ENV_FILE=apps/edr-passenger-api/.env" >> "$GITHUB_ENV" ;;
|
||||
payment-api) echo "SERVICE_ENV_FILE=apps/edr-payment-api/.env" >> "$GITHUB_ENV" ;;
|
||||
esac
|
||||
|
||||
- name: Build migration image for ${{ matrix.service }}
|
||||
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker build \
|
||||
--secret id=npmrc,src=.npmrc \
|
||||
--target migration \
|
||||
-f "apps/edr-${{ matrix.service }}/Dockerfile" \
|
||||
-t "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" \
|
||||
.
|
||||
|
||||
- name: Run migrations for ${{ matrix.service }}
|
||||
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm --env-file "${SERVICE_ENV_FILE}" "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration"
|
||||
|
||||
- name: Build ${{ matrix.service }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -41,6 +41,7 @@ DEFAULT_PASSWORD=password@tria
|
||||
# Freight org + staff (bookings / rule-engine IAM)
|
||||
SEED_EDR_ORG=true
|
||||
SEED_FREIGHT_STAFF=true
|
||||
SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false
|
||||
|
||||
# MinIO (used by @tria-plc/iamapi-common for file storage)
|
||||
MINIO_ENDPOINT=localhost
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
|
||||
FROM node:24.15.0-alpine AS base
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit
|
||||
# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds.
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
@@ -14,6 +18,7 @@ FROM base AS installer
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
||||
--mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
@@ -23,7 +28,8 @@ RUN pnpm turbo build --filter="@edr/freight-api..."
|
||||
|
||||
FROM base AS deployer
|
||||
COPY --from=builder /app/ .
|
||||
RUN pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
|
||||
|
||||
FROM node:24.15.0-alpine AS runner
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
452
apps/edr-freight-api/html/payment-tester.html
Normal file
452
apps/edr-freight-api/html/payment-tester.html
Normal file
@@ -0,0 +1,452 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EDR Freight — Payment Tester (Telebirr ETB + Card USD)</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1419;
|
||||
--panel: #1a2129;
|
||||
--panel-2: #232d38;
|
||||
--border: #2e3b48;
|
||||
--txt: #e6edf3;
|
||||
--muted: #8b98a5;
|
||||
--accent: #1a73e8;
|
||||
--etb: #00a651; /* telebirr green */
|
||||
--usd: #2563eb; /* card blue */
|
||||
--ok: #2ea043;
|
||||
--warn: #d29922;
|
||||
--err: #f85149;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg); color: var(--txt); line-height: 1.5;
|
||||
}
|
||||
header {
|
||||
padding: 20px 24px; border-bottom: 1px solid var(--border);
|
||||
background: var(--panel); display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
}
|
||||
header h1 { font-size: 1.05rem; margin: 0; font-weight: 600; }
|
||||
header .badge {
|
||||
font-size: .7rem; padding: 2px 8px; border-radius: 999px;
|
||||
background: var(--panel-2); color: var(--muted); border: 1px solid var(--border);
|
||||
}
|
||||
main { max-width: 920px; margin: 0 auto; padding: 24px; display: grid; gap: 20px; }
|
||||
.card {
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 20px;
|
||||
}
|
||||
.card h2 { margin: 0 0 14px; font-size: .8rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
|
||||
label { display: block; font-size: .78rem; color: var(--muted); margin: 0 0 6px; }
|
||||
input, select {
|
||||
width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border);
|
||||
background: var(--panel-2); color: var(--txt); font-size: .9rem; font-family: inherit;
|
||||
}
|
||||
input:focus, select:focus { outline: none; border-color: var(--accent); }
|
||||
.row { display: grid; grid-template-columns: 1fr auto; gap: 10px; align-items: end; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
button {
|
||||
cursor: pointer; border: none; border-radius: 8px; padding: 10px 16px;
|
||||
font-size: .85rem; font-weight: 600; color: #fff; font-family: inherit;
|
||||
background: var(--panel-2); border: 1px solid var(--border); color: var(--txt);
|
||||
transition: filter .15s, opacity .15s;
|
||||
}
|
||||
button:hover:not(:disabled) { filter: brightness(1.15); }
|
||||
button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
button.pay-etb { background: var(--etb); border-color: var(--etb); color: #fff; }
|
||||
button.pay-usd { background: var(--usd); border-color: var(--usd); color: #fff; }
|
||||
button.ghost { background: transparent; }
|
||||
.pay-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 4px; }
|
||||
.pay-buttons button { padding: 16px; font-size: .95rem; display: flex; flex-direction: column; gap: 4px; align-items: center; }
|
||||
.pay-buttons button small { font-weight: 400; opacity: .85; font-size: .72rem; }
|
||||
.booking-meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-top: 14px; }
|
||||
.meta-item { background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
|
||||
.meta-item .k { font-size: .68rem; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||
.meta-item .v { font-size: 1rem; font-weight: 600; margin-top: 2px; word-break: break-all; }
|
||||
.pill { display: inline-block; font-size: .72rem; font-weight: 600; padding: 2px 9px; border-radius: 999px; }
|
||||
.pill.etb { background: rgba(0,166,81,.15); color: #4ade80; }
|
||||
.pill.usd { background: rgba(37,99,235,.18); color: #60a5fa; }
|
||||
.status-line { display: flex; align-items: center; gap: 8px; font-size: .9rem; }
|
||||
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); }
|
||||
.dot.ok { background: var(--ok); } .dot.warn { background: var(--warn); } .dot.err { background: var(--err); }
|
||||
.dot.pulse { animation: pulse 1s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .3; } }
|
||||
pre {
|
||||
background: #0b0f14; border: 1px solid var(--border); border-radius: 8px; padding: 14px;
|
||||
font-size: .76rem; overflow: auto; max-height: 320px; margin: 10px 0 0; color: #c9d1d9;
|
||||
}
|
||||
.log { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: .74rem; }
|
||||
.log-entry { padding: 4px 0; border-bottom: 1px solid var(--border); }
|
||||
.log-entry .ts { color: var(--muted); margin-right: 8px; }
|
||||
.log-entry.req { color: #79c0ff; } .log-entry.res { color: #7ee787; } .log-entry.err { color: var(--err); }
|
||||
.actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 14px; }
|
||||
.hint { font-size: .76rem; color: var(--muted); margin-top: 8px; }
|
||||
a { color: #58a6ff; }
|
||||
.table { width: 100%; border-collapse: collapse; font-size: .8rem; margin-top: 10px; }
|
||||
.table th, .table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
||||
.table th { color: var(--muted); font-weight: 500; font-size: .72rem; text-transform: uppercase; }
|
||||
.table tr.clickable { cursor: pointer; }
|
||||
.table tr.clickable:hover { background: var(--panel-2); }
|
||||
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
@media (max-width: 720px) { .split, .grid-2, .pay-buttons { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>🚂 EDR Freight — Payment Tester</h1>
|
||||
<span class="badge">Telebirr (ETB)</span>
|
||||
<span class="badge">Card (USD)</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- ── Config ───────────────────────────────────────────── -->
|
||||
<section class="card">
|
||||
<h2>API connection</h2>
|
||||
<div class="grid-2">
|
||||
<div>
|
||||
<label>API base URL</label>
|
||||
<input id="apiBase" value="http://localhost:3001/api" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Service / Bearer token (optional)</label>
|
||||
<input id="token" placeholder="Bearer token if guards enabled" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="ghost" id="pingBtn">Test connection</button>
|
||||
<span class="status-line"><span class="dot" id="pingDot"></span><span id="pingTxt">not checked</span></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Pick booking ─────────────────────────────────────── -->
|
||||
<section class="card">
|
||||
<h2>1 · Choose a booking</h2>
|
||||
<div class="row">
|
||||
<div>
|
||||
<label>Booking ID (UUID) — paste directly, or load the list below</label>
|
||||
<input id="bookingId" placeholder="e.g. 9f8c…-uuid" />
|
||||
</div>
|
||||
<button class="ghost" id="loadBookingBtn">Load this booking</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="ghost" id="listBtn">List recent bookings</button>
|
||||
<span class="hint">Currency (ETB vs USD) is set per-booking via <code>paymentCurrency</code>. Pick an ETB booking to test Telebirr, a USD booking to test Card.</span>
|
||||
</div>
|
||||
<div id="bookingList"></div>
|
||||
|
||||
<div id="bookingMeta" style="display:none;">
|
||||
<div class="booking-meta">
|
||||
<div class="meta-item"><div class="k">Reference</div><div class="v" id="mRef">—</div></div>
|
||||
<div class="meta-item"><div class="k">Amount</div><div class="v" id="mAmt">—</div></div>
|
||||
<div class="meta-item"><div class="k">Currency</div><div class="v" id="mCur">—</div></div>
|
||||
<div class="meta-item"><div class="k">Status</div><div class="v" id="mStatus">—</div></div>
|
||||
<div class="meta-item"><div class="k">Pay status</div><div class="v" id="mPay">—</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Pay ──────────────────────────────────────────────── -->
|
||||
<section class="card">
|
||||
<h2>2 · Initiate payment</h2>
|
||||
<div class="grid-2">
|
||||
<div>
|
||||
<label>Platform</label>
|
||||
<select id="platform"><option value="web">web (browser redirect)</option><option value="mobile">mobile (launch app)</option></select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Payer account (optional — Waafi MWALLET / phone)</label>
|
||||
<input id="payerAccount" placeholder="2519…" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="pay-buttons">
|
||||
<button class="pay-etb" id="payTelebirr" disabled>Pay with Telebirr<small>ETB · Ethiopian mobile money</small></button>
|
||||
<button class="pay-usd" id="payCard" disabled>Pay with Card<small>USD · Visa / Mastercard</small></button>
|
||||
</div>
|
||||
<div class="actions" style="margin-top:14px;">
|
||||
<label style="margin:0;display:flex;align-items:center;gap:6px;font-size:.8rem;">
|
||||
<input type="checkbox" id="openTab" checked style="width:auto;" /> open provider checkout in new tab
|
||||
</label>
|
||||
</div>
|
||||
<div class="hint">
|
||||
Telebirr → forces method <code>TELEBIRR</code>. Card → forces method <code>CARD</code>.
|
||||
Each calls <code>POST {base}/payments/initiate</code> and follows the returned <code>clientAction</code> (REDIRECT url for web).
|
||||
</div>
|
||||
<p class="hint" id="initiateResult"></p>
|
||||
</section>
|
||||
|
||||
<!-- ── Status / receipt ─────────────────────────────────── -->
|
||||
<section class="card">
|
||||
<h2>3 · Track intent & receipt</h2>
|
||||
<div class="actions">
|
||||
<button class="ghost" id="pollBtn" disabled>Refresh intent status</button>
|
||||
<label style="margin:0;display:flex;align-items:center;gap:6px;font-size:.8rem;">
|
||||
<input type="checkbox" id="autoPoll" style="width:auto;" /> auto-poll every 3s
|
||||
</label>
|
||||
<span class="status-line"><span class="dot" id="intentDot"></span><span id="intentTxt">no intent yet</span></span>
|
||||
</div>
|
||||
<div class="actions" style="margin-top:6px;">
|
||||
<button class="ghost" id="receiptBtn" disabled>Open receipt (success only)</button>
|
||||
<span class="hint">Receipt = <code>GET {base}/payments/receipt/{merchantOrderId}</code></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Raw / log ────────────────────────────────────────── -->
|
||||
<section class="split">
|
||||
<div class="card">
|
||||
<h2>Last response</h2>
|
||||
<pre id="rawOut">—</pre>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Request log</h2>
|
||||
<div class="log" id="log"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const state = { booking: null, intent: null, merchantOrderId: null, pollTimer: null };
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────
|
||||
const base = () => $("apiBase").value.replace(/\/$/, "");
|
||||
const headers = () => {
|
||||
const h = { "Content-Type": "application/json", Accept: "application/json" };
|
||||
const t = $("token").value.trim();
|
||||
if (t) h["Authorization"] = t.startsWith("Bearer ") ? t : "Bearer " + t;
|
||||
return h;
|
||||
};
|
||||
|
||||
function now() {
|
||||
const d = new Date();
|
||||
return d.toTimeString().slice(0, 8) + "." + String(d.getMilliseconds()).padStart(3, "0");
|
||||
}
|
||||
function log(kind, msg) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "log-entry " + kind;
|
||||
el.innerHTML = '<span class="ts">' + now() + "</span>" + msg;
|
||||
$("log").prepend(el);
|
||||
}
|
||||
function showRaw(obj) {
|
||||
$("rawOut").textContent = typeof obj === "string" ? obj : JSON.stringify(obj, null, 2);
|
||||
}
|
||||
function setDot(id, cls) {
|
||||
const d = $(id);
|
||||
d.className = "dot" + (cls ? " " + cls : "");
|
||||
}
|
||||
|
||||
// Unwrap the @edr/api-common ResponseTransformInterceptor envelope if present.
|
||||
function unwrap(json) {
|
||||
if (json && typeof json === "object" && "data" in json && ("statusCode" in json || "success" in json)) {
|
||||
return json.data;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
async function api(method, path, body) {
|
||||
const url = base() + path;
|
||||
log("req", method + " " + path);
|
||||
let res, text, json;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method,
|
||||
headers: headers(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
log("err", "network error: " + e.message + " — is the API running at " + base() + "?");
|
||||
throw e;
|
||||
}
|
||||
text = await res.text();
|
||||
try { json = text ? JSON.parse(text) : null; } catch { json = text; }
|
||||
if (!res.ok) {
|
||||
const detail = json && json.message ? (Array.isArray(json.message) ? json.message.join(", ") : json.message) : text;
|
||||
log("err", method + " " + path + " → " + res.status + ": " + detail);
|
||||
showRaw(json || text);
|
||||
throw new Error(detail || ("HTTP " + res.status));
|
||||
}
|
||||
log("res", method + " " + path + " → " + res.status);
|
||||
return unwrap(json);
|
||||
}
|
||||
|
||||
// ── ping ────────────────────────────────────────────────
|
||||
$("pingBtn").onclick = async () => {
|
||||
setDot("pingDot", "pulse warn"); $("pingTxt").textContent = "checking…";
|
||||
try {
|
||||
// payment summary is a light, low-side-effect GET
|
||||
await api("GET", "/payments/summary");
|
||||
setDot("pingDot", "ok"); $("pingTxt").textContent = "connected ✓";
|
||||
} catch (e) {
|
||||
setDot("pingDot", "err"); $("pingTxt").textContent = "failed — " + e.message;
|
||||
}
|
||||
};
|
||||
|
||||
// ── list bookings ───────────────────────────────────────
|
||||
$("listBtn").onclick = async () => {
|
||||
try {
|
||||
const data = await api("GET", "/bookings");
|
||||
const items = Array.isArray(data) ? data : (data && data.items) || [];
|
||||
renderBookingList(items.slice(0, 25));
|
||||
} catch (e) { /* logged */ }
|
||||
};
|
||||
|
||||
function renderBookingList(items) {
|
||||
const wrap = $("bookingList");
|
||||
if (!items.length) { wrap.innerHTML = '<p class="hint">No bookings returned.</p>'; return; }
|
||||
let html = '<table class="table"><thead><tr><th>Reference</th><th>Amount</th><th>Cur</th><th>Status</th><th>ID</th></tr></thead><tbody>';
|
||||
for (const b of items) {
|
||||
const cur = b.paymentCurrency || b.currency || "—";
|
||||
const curClass = cur === "ETB" ? "etb" : cur === "USD" ? "usd" : "";
|
||||
const amt = b.totalAmount != null ? b.totalAmount : (b.amount != null ? b.amount : "—");
|
||||
html += '<tr class="clickable" data-id="' + (b.id || "") + '">' +
|
||||
"<td>" + (b.reference || "—") + "</td>" +
|
||||
"<td>" + amt + "</td>" +
|
||||
'<td><span class="pill ' + curClass + '">' + cur + "</span></td>" +
|
||||
"<td>" + (b.status || "—") + "</td>" +
|
||||
'<td style="font-size:.7rem;color:var(--muted);">' + (b.id ? b.id.slice(0, 8) + "…" : "—") + "</td></tr>";
|
||||
}
|
||||
html += "</tbody></table>";
|
||||
wrap.innerHTML = html;
|
||||
wrap.querySelectorAll("tr.clickable").forEach((tr) => {
|
||||
tr.onclick = () => { $("bookingId").value = tr.dataset.id; loadBooking(); };
|
||||
});
|
||||
}
|
||||
|
||||
// ── load one booking ────────────────────────────────────
|
||||
$("loadBookingBtn").onclick = loadBooking;
|
||||
async function loadBooking() {
|
||||
const id = $("bookingId").value.trim();
|
||||
if (!id) { log("err", "enter a booking ID first"); return; }
|
||||
try {
|
||||
const b = await api("GET", "/bookings/" + id);
|
||||
state.booking = b;
|
||||
showRaw(b);
|
||||
renderMeta(b);
|
||||
enablePayButtons(b);
|
||||
$("pollBtn").disabled = false;
|
||||
// pre-load any existing intent
|
||||
refreshIntent(true).catch(() => {});
|
||||
} catch (e) { /* logged */ }
|
||||
}
|
||||
|
||||
function renderMeta(b) {
|
||||
const cur = b.paymentCurrency || b.currency || "—";
|
||||
$("bookingMeta").style.display = "block";
|
||||
$("mRef").textContent = b.reference || "—";
|
||||
$("mAmt").textContent = (b.totalAmount != null ? b.totalAmount : (b.amount != null ? b.amount : "—"));
|
||||
$("mCur").innerHTML = '<span class="pill ' + (cur === "ETB" ? "etb" : cur === "USD" ? "usd" : "") + '">' + cur + "</span>";
|
||||
$("mStatus").textContent = b.status || "—";
|
||||
$("mPay").textContent = b.paymentStatus || "—";
|
||||
}
|
||||
|
||||
function enablePayButtons(b) {
|
||||
const cur = b.paymentCurrency || b.currency || "";
|
||||
const etbBtn = $("payTelebirr"), usdBtn = $("payCard");
|
||||
etbBtn.disabled = false; usdBtn.disabled = false;
|
||||
// soft hint via title; both stay enabled so you can deliberately test mismatch
|
||||
etbBtn.title = cur && cur !== "ETB" ? "Booking currency is " + cur + ", not ETB — Telebirr expects ETB" : "";
|
||||
usdBtn.title = cur && cur !== "USD" ? "Booking currency is " + cur + ", not USD — Card expects USD" : "";
|
||||
}
|
||||
|
||||
// ── initiate payment ────────────────────────────────────
|
||||
async function pay(method, label) {
|
||||
const id = $("bookingId").value.trim();
|
||||
if (!id) { log("err", "pick a booking first"); return; }
|
||||
$("initiateResult").textContent = "Initiating " + label + "…";
|
||||
const body = {
|
||||
bookingId: id,
|
||||
method: method,
|
||||
platform: $("platform").value,
|
||||
};
|
||||
const payer = $("payerAccount").value.trim();
|
||||
if (payer) body.payerAccount = payer;
|
||||
|
||||
try {
|
||||
const resp = await api("POST", "/payments/initiate", body);
|
||||
state.intent = resp;
|
||||
state.merchantOrderId = resp.merchantOrderId || null;
|
||||
showRaw(resp);
|
||||
renderIntent(resp);
|
||||
$("receiptBtn").disabled = !state.merchantOrderId;
|
||||
|
||||
const action = resp.clientAction;
|
||||
if (action && action.type === "REDIRECT" && action.url) {
|
||||
$("initiateResult").innerHTML =
|
||||
label + " intent created (status: " + resp.status + "). " +
|
||||
'Redirect → <a href="' + action.url + '" target="_blank" rel="noopener">' + action.url + "</a>";
|
||||
if ($("openTab").checked) window.open(action.url, "_blank", "noopener");
|
||||
} else if (action && action.type === "LAUNCH_APP") {
|
||||
$("initiateResult").textContent =
|
||||
label + " → LAUNCH_APP (mobile). appId=" + (action.appId || "") + " shortCode=" + (action.shortCode || "");
|
||||
} else if (action && action.type === "COLLECT_OTP") {
|
||||
$("initiateResult").textContent =
|
||||
label + " → COLLECT_OTP. providerOrderId=" + (action.providerOrderId || "") + " — " + (action.message || "");
|
||||
} else {
|
||||
$("initiateResult").textContent = label + " intent created. Status: " + resp.status + " (no redirect action).";
|
||||
}
|
||||
// begin watching
|
||||
if ($("autoPoll").checked) startAutoPoll();
|
||||
} catch (e) {
|
||||
$("initiateResult").textContent = "Failed: " + e.message;
|
||||
}
|
||||
}
|
||||
$("payTelebirr").onclick = () => pay("TELEBIRR", "Telebirr (ETB)");
|
||||
$("payCard").onclick = () => pay("CARD", "Card (USD)");
|
||||
|
||||
// ── intent status ───────────────────────────────────────
|
||||
$("pollBtn").onclick = () => refreshIntent(false);
|
||||
async function refreshIntent(silent) {
|
||||
const id = $("bookingId").value.trim();
|
||||
if (!id) return;
|
||||
try {
|
||||
const resp = await api("GET", "/payments/intents/" + id);
|
||||
state.intent = resp;
|
||||
if (resp.merchantOrderId) state.merchantOrderId = resp.merchantOrderId;
|
||||
$("receiptBtn").disabled = !state.merchantOrderId;
|
||||
showRaw(resp);
|
||||
renderIntent(resp);
|
||||
return resp;
|
||||
} catch (e) {
|
||||
if (!silent) { /* already logged */ }
|
||||
}
|
||||
}
|
||||
|
||||
function renderIntent(resp) {
|
||||
const s = (resp.status || "").toUpperCase();
|
||||
let cls = "warn";
|
||||
if (s === "SUCCEEDED") cls = "ok";
|
||||
else if (s === "FAILED" || s === "CANCELLED") cls = "err";
|
||||
setDot("intentDot", cls);
|
||||
let txt = "status: " + (resp.status || "—");
|
||||
if (resp.merchantOrderId) txt += " · order " + resp.merchantOrderId;
|
||||
if (resp.paidAt) txt += " · paid " + resp.paidAt;
|
||||
if (resp.failureMessage) txt += " · " + resp.failureMessage;
|
||||
$("intentTxt").textContent = txt;
|
||||
if (s === "SUCCEEDED" || s === "FAILED" || s === "CANCELLED") stopAutoPoll();
|
||||
}
|
||||
|
||||
function startAutoPoll() {
|
||||
stopAutoPoll();
|
||||
state.pollTimer = setInterval(() => refreshIntent(true), 3000);
|
||||
}
|
||||
function stopAutoPoll() {
|
||||
if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }
|
||||
}
|
||||
$("autoPoll").onchange = (e) => { if (e.target.checked) startAutoPoll(); else stopAutoPoll(); };
|
||||
|
||||
// ── receipt ─────────────────────────────────────────────
|
||||
$("receiptBtn").onclick = () => {
|
||||
if (!state.merchantOrderId) { log("err", "no merchantOrderId yet — pay first"); return; }
|
||||
const url = base() + "/payments/receipt/" + encodeURIComponent(state.merchantOrderId);
|
||||
log("req", "GET /payments/receipt/" + state.merchantOrderId + " (new tab)");
|
||||
window.open(url, "_blank", "noopener");
|
||||
};
|
||||
|
||||
log("res", "ready — set API base, pick a booking, pay with Telebirr (ETB) or Card (USD)");
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"",
|
||||
"predev": "pnpm run clean",
|
||||
"dev": "nest start --watch",
|
||||
"dev": "nest start --watch --clearScreen false",
|
||||
"prebuild": "pnpm run clean",
|
||||
"build": "nest build",
|
||||
"start": "node dist/main.js",
|
||||
@@ -17,9 +17,24 @@
|
||||
"type-check": "tsc --noEmit",
|
||||
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
|
||||
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
|
||||
"seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts",
|
||||
"seed:warehouse-export-receive-ready": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-export-receive-ready.ts",
|
||||
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
|
||||
"seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts",
|
||||
"seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",
|
||||
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
|
||||
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
|
||||
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
|
||||
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
|
||||
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
|
||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
|
||||
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
|
||||
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
|
||||
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
||||
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
||||
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js",
|
||||
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
|
||||
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
@@ -38,13 +53,15 @@
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.4.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz",
|
||||
"amqp-connection-manager": "^5.0.0",
|
||||
"amqplib": "^2.0.1",
|
||||
"axios": "^1.16.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"libphonenumber-js": "^1.13.6",
|
||||
"minio": "7.1.3",
|
||||
@@ -69,13 +86,15 @@
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/pg": "^8.6.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/vorpal": "^1.12.8",
|
||||
"jest": "^29.7.0",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.5.4"
|
||||
"typescript": "^5.5.4",
|
||||
"vorpal": "^1.12.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module, OnApplicationBootstrap } from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { EventEmitterModule } from "@nestjs/event-emitter";
|
||||
import { DataSource, DataSourceOptions } from "typeorm";
|
||||
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
|
||||
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
|
||||
@@ -13,7 +14,7 @@ import telebirrConfig from "./config/telebirr.config";
|
||||
import rabbitmqConfig from "./config/rabbitmq.config";
|
||||
|
||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||
import { BookingOrdersModule } from "./modules/booking-orders/booking-orders.module";
|
||||
import { ContractsModule } from "./modules/contracts/contracts.module";
|
||||
import { SignaturesModule } from "./modules/signatures/signatures.module";
|
||||
import { FilesModule } from "./modules/files/files.module";
|
||||
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||
@@ -52,8 +53,12 @@ import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
|
||||
import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
|
||||
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
|
||||
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
|
||||
import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
|
||||
import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
|
||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||
//New Trains, Wagons, Container and Cargo management modules
|
||||
import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { WagonsModule } from './modules/wagons/wagons.module';
|
||||
@@ -64,9 +69,12 @@ import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
||||
import { OverviewModule } from './modules/overview/overview.module';
|
||||
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
||||
import { DriversModule } from './modules/drivers/drivers.module';
|
||||
import { FuelModule } from './modules/fuel/fuel.module';
|
||||
import { MaintenanceModule } from './modules/maintenance/maintenance.module';
|
||||
import { FirstMileModule } from './modules/first-mile/first-mile.module';
|
||||
import { LastMileModule } from './modules/last-mile/last-mile.module';
|
||||
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
|
||||
import { ImportOperationsModule } from './modules/import-operations/import-operations.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -75,7 +83,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
|
||||
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
// EventEmitterModule.forRoot(),
|
||||
EventEmitterModule.forRoot(),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
||||
@@ -95,7 +103,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
|
||||
permissions: EDR_FREIGHT_PERMISSIONS,
|
||||
}),
|
||||
BookingsModule,
|
||||
BookingOrdersModule,
|
||||
ContractsModule,
|
||||
SignaturesModule,
|
||||
FilesModule,
|
||||
ConsignmentsModule,
|
||||
@@ -127,9 +135,12 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
|
||||
OverviewModule,
|
||||
VehiclesModule,
|
||||
DriversModule,
|
||||
FuelModule,
|
||||
MaintenanceModule,
|
||||
FirstMileModule,
|
||||
LastMileModule,
|
||||
InterchangeDocumentsModule,
|
||||
ImportOperationsModule,
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
@@ -139,12 +150,16 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
|
||||
FileUploadSettingsSeeder,
|
||||
FreightPermissionKeyMigrationSeeder,
|
||||
DemoFreightDataSeeder,
|
||||
GovCompaniesSeeder,
|
||||
IndodeFacilitySeeder,
|
||||
Batch14TestDataSeeder,
|
||||
Batch5TestDataSeeder,
|
||||
Batch7TestDataSeeder,
|
||||
Batch8TestDataSeeder,
|
||||
WarehouseDemoSeeder,
|
||||
ExportDjiboutiInterchangeDemoSeeder,
|
||||
MarshallingDemoTrainsSeeder,
|
||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
@@ -161,8 +176,11 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
|
||||
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
|
||||
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
||||
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
|
||||
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
private readonly govCompaniesSeeder: GovCompaniesSeeder,
|
||||
) { }
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
@@ -179,6 +197,8 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
await this.batch7TestDataSeeder.run();
|
||||
await this.batch8TestDataSeeder.run();
|
||||
await this.warehouseDemoSeeder.run();
|
||||
await this.exportDjiboutiInterchangeDemoSeeder.run();
|
||||
await this.marshallingDemoTrainsSeeder.run();
|
||||
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
|
||||
// Each block self-guards on an empty-table check, so this is safe every boot.
|
||||
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
|
||||
@@ -187,5 +207,8 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
|
||||
// rules are disabled inside the seeder). Kept running for the staff users.
|
||||
await this.demoFreightDataSeeder.run();
|
||||
// Government entities (with importer/exporter profiles) that government
|
||||
// bookings bill to. Idempotent — keyed by fixed IDs.
|
||||
await this.govCompaniesSeeder.run();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,8 +116,10 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
|
||||
freightMigrationsGlob,
|
||||
],
|
||||
migrationsRun: true,
|
||||
migrationsTransactionMode: "each",
|
||||
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
|
||||
synchronize: false,
|
||||
logging: process.env.NODE_ENV === "development",
|
||||
logging:
|
||||
process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { ContractsRepository } from '../modules/contracts/contracts.repository';
|
||||
import { Contract } from '../modules/contracts/entities/contract.entity';
|
||||
import { ContractRoute } from '../modules/contracts/entities/contract-route.entity';
|
||||
import {
|
||||
ContractSignature,
|
||||
ContractSignerRole,
|
||||
} from '../modules/contracts/entities/contract-signature.entity';
|
||||
import { ContractPricingBreakdown } from '../modules/contracts/contract-pricing.service';
|
||||
import { ContractTemplateResolver } from './contract-template.resolver';
|
||||
import { getTemplateMeta } from './contract-template.registry';
|
||||
import { ContractViewModel } from './contract-view-model.builder';
|
||||
|
||||
/**
|
||||
* Signature row for the contract PDF. Mirrors the booking builder's
|
||||
* `ContractSignatureView` but widens `role` to the contract's signer roles
|
||||
* (CUSTOMER | STAFF | DIRECTOR | CEO).
|
||||
*/
|
||||
export interface ContractDocumentSignatureView {
|
||||
role: ContractSignerRole;
|
||||
signerDisplayName: string;
|
||||
signedAt: string;
|
||||
signatureImageUrl?: string | null;
|
||||
}
|
||||
|
||||
/** A single unit-rate row on the contract PDF — price per unit, NO total. */
|
||||
export interface ContractUnitRateRow {
|
||||
label: string;
|
||||
unitPrice: number;
|
||||
unit: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pricing schedule for a Contract document: a unit-rate schedule (one price per
|
||||
* unit, e.g. "X ETB / container") with NO quantities and NO grand total. Shaped
|
||||
* to stay structurally compatible with the renderer's expectations of
|
||||
* {@link ContractViewModel.pricing} (it reads `currency`).
|
||||
*/
|
||||
export interface ContractUnitRateSchedule {
|
||||
displayMode: 'UNIT_RATES';
|
||||
unitRates: ContractUnitRateRow[];
|
||||
currency: string;
|
||||
equipmentReturn?: string;
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
}
|
||||
|
||||
/** Map a stored contract unit to a human PDF suffix ("/ container", "/ ton", …). */
|
||||
function unitLabel(unit: string): string {
|
||||
switch (unit) {
|
||||
case 'per_container':
|
||||
return 'container';
|
||||
case 'per_ton':
|
||||
return 'ton';
|
||||
case 'per_item':
|
||||
return 'item';
|
||||
case 'per_km':
|
||||
return 'km';
|
||||
default:
|
||||
return 'unit';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the contract PDF view-model from the {@link Contract} aggregate (the new
|
||||
* source of truth) — mirrors {@link ContractViewModelBuilder} but every field is
|
||||
* sourced from the contract, its routes, cargo scope and unit-rate breakdown.
|
||||
* The legacy booking-based builder remains untouched for the migration window.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ContractDocumentViewModelBuilder {
|
||||
constructor(
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly templateResolver: ContractTemplateResolver,
|
||||
) {}
|
||||
|
||||
async build(
|
||||
contractId: string,
|
||||
): Promise<{ contract: Contract; view: ContractViewModel }> {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||
if (!contract) {
|
||||
throw new NotFoundException(`Contract ${contractId} not found`);
|
||||
}
|
||||
|
||||
const templateKey =
|
||||
contract.contractTemplateKey ?? this.templateResolver.resolve(this.toResolverInput(contract));
|
||||
const template = getTemplateMeta(templateKey);
|
||||
const pricing = this.buildPricing(contract);
|
||||
const signatures = await this.loadSignatures(contractId);
|
||||
|
||||
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
|
||||
const hasStaff = signatures.some((s) => s.role === 'STAFF');
|
||||
const hasContractFile = Boolean(
|
||||
contract.files?.some((f) => f.code === 'contract'),
|
||||
);
|
||||
|
||||
const view: ContractViewModel = {
|
||||
bookingId: contract.id,
|
||||
reference: contract.reference,
|
||||
status: contract.status,
|
||||
templateKey,
|
||||
template,
|
||||
contractDate: new Date().toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}),
|
||||
contractYear: new Date().getFullYear(),
|
||||
client: {
|
||||
companyName: contract.company?.name ?? 'Client',
|
||||
companyAddress: this.valueOrDash(contract.company?.address),
|
||||
companyLocation: this.valueOrDash(contract.company?.country),
|
||||
phone: this.valueOrDash(contract.company?.phone),
|
||||
email: this.valueOrDash(contract.company?.email),
|
||||
tinNumber: this.valueOrDash(contract.company?.tin),
|
||||
vatNumber: this.valueOrDash(contract.company?.vatNumber),
|
||||
fanNumber: this.valueOrDash(contract.company?.fanNumber),
|
||||
businessLicense: this.valueOrDash(
|
||||
contract.company?.companyProfiles?.[0]?.businessLicense,
|
||||
),
|
||||
},
|
||||
provider: {
|
||||
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
|
||||
address: 'Addis Ababa, Ethiopia',
|
||||
phone: '+251 11 872 0000',
|
||||
email: 'info@edr.gov.et',
|
||||
tinNumber: '—',
|
||||
},
|
||||
schedule: this.buildSchedule(contract),
|
||||
pricing: pricing as unknown as ContractViewModel['pricing'],
|
||||
// Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking
|
||||
// view-model's narrower CUSTOMER|STAFF role union.
|
||||
signatures: signatures as unknown as ContractViewModel['signatures'],
|
||||
canSignCustomer: contract.status === 'CONTRACT_READY' && !hasCustomer,
|
||||
canSignStaff:
|
||||
contract.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
|
||||
hasContractDocument: hasContractFile,
|
||||
hasCustomerSignature: hasCustomer,
|
||||
hasStaffSignature: hasStaff,
|
||||
};
|
||||
|
||||
return { contract, view };
|
||||
}
|
||||
|
||||
private async loadSignatures(
|
||||
contractId: string,
|
||||
): Promise<ContractDocumentSignatureView[]> {
|
||||
const rows = await this.contractsRepository.findSignatures(contractId);
|
||||
return rows.map((s) => this.toSignatureView(s));
|
||||
}
|
||||
|
||||
toSignatureView(row: ContractSignature): ContractDocumentSignatureView {
|
||||
return {
|
||||
role: row.role,
|
||||
signerDisplayName: row.signerDisplayName,
|
||||
signedAt: this.formatDate(row.signedAt),
|
||||
signatureImageUrl: row.signatureFile?.url ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Unit-rate schedule from the contract's frozen pricing breakdown — NO totals. */
|
||||
private buildPricing(contract: Contract): ContractUnitRateSchedule {
|
||||
const breakdown = contract.pricingBreakdown as ContractPricingBreakdown | null;
|
||||
const currency = breakdown?.currency ?? contract.paymentCurrency;
|
||||
const lineItems = breakdown?.lineItems ?? [];
|
||||
const firstRoute = this.firstRoute(contract);
|
||||
|
||||
return {
|
||||
displayMode: 'UNIT_RATES',
|
||||
unitRates: lineItems.map((line) => ({
|
||||
label: line.label,
|
||||
unitPrice: line.unitPrice,
|
||||
unit: unitLabel(line.unit),
|
||||
currency,
|
||||
})),
|
||||
currency,
|
||||
equipmentReturn: contract.equipmentReturn ?? '—',
|
||||
originLabel: this.yardLabel(firstRoute?.originYard),
|
||||
destinationLabel: this.yardLabel(firstRoute?.destinationYard),
|
||||
};
|
||||
}
|
||||
|
||||
private buildSchedule(contract: Contract): ContractViewModel['schedule'] {
|
||||
const firstRoute = this.firstRoute(contract);
|
||||
const cargoScope = (contract.cargoScope ?? [])[0];
|
||||
const cargoName =
|
||||
cargoScope?.cargoType?.cargoTypeName ||
|
||||
cargoScope?.cargoFreeText ||
|
||||
(cargoScope?.containerSize
|
||||
? `${cargoScope.containerSize} container`
|
||||
: 'Container cargo');
|
||||
|
||||
return {
|
||||
originLabel: this.yardLabel(firstRoute?.originYard),
|
||||
destinationLabel: this.yardLabel(firstRoute?.destinationYard),
|
||||
tradeDirection: this.valueOrDash(contract.tradeDirection),
|
||||
freightType: this.valueOrDash(contract.freightType),
|
||||
serviceType: this.valueOrDash(
|
||||
contract.serviceType?.serviceName ?? contract.serviceType?.code,
|
||||
),
|
||||
// Estimated shipment date was removed from the contract wizard; the
|
||||
// binding scheduled date is set per-booking, not on the contract.
|
||||
scheduledDate: this.formatDate(null),
|
||||
contractType: this.valueOrDash(contract.contractType),
|
||||
cargoDescription: this.valueOrDash(cargoName),
|
||||
totalWeightVgm: '—',
|
||||
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
|
||||
hazardousLabel: contract.isHazardous ? 'Yes' : 'No',
|
||||
firstMilePickupAddress: this.valueOrDash(contract.firstMilePickupAddress),
|
||||
lastMileDeliveryAddress: this.valueOrDash(contract.lastMileDeliveryAddress),
|
||||
};
|
||||
}
|
||||
|
||||
/** The contract's primary route (lowest sortOrder), used for origin/destination labels. */
|
||||
private firstRoute(contract: Contract): ContractRoute | undefined {
|
||||
const routes = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
||||
);
|
||||
return routes[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* The template resolver reads a Booking; a contract carries equivalent fields
|
||||
* under a different shape (cargoType lives on cargoScope). Build a minimal,
|
||||
* structurally-compatible adapter rather than widening the resolver signature.
|
||||
*/
|
||||
private toResolverInput(
|
||||
contract: Contract,
|
||||
): Parameters<ContractTemplateResolver['resolve']>[0] {
|
||||
const cargoType = (contract.cargoScope ?? []).find((c) => c.cargoType)?.cargoType;
|
||||
return {
|
||||
tradeDirection: contract.tradeDirection,
|
||||
freightType: contract.freightType,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
cargoType: cargoType ?? undefined,
|
||||
serviceType: contract.serviceType,
|
||||
} as Parameters<ContractTemplateResolver['resolve']>[0];
|
||||
}
|
||||
|
||||
private yardLabel(yard?: { label?: string; code?: string } | null): string {
|
||||
return this.valueOrDash(yard?.label ?? yard?.code);
|
||||
}
|
||||
|
||||
private formatDate(value?: Date | string | null): string {
|
||||
if (!value) return '—';
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
private valueOrDash(value?: string | number | null): string {
|
||||
if (value === undefined || value === null || value === '') return '—';
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export for callers that want the role union without importing the entity.
|
||||
export type { ContractSignerRole };
|
||||
@@ -80,8 +80,15 @@ export class ContractPdfService {
|
||||
this.logger.error(
|
||||
`Puppeteer PDF failed (executable=${executablePath ?? 'default'}): ${err}`,
|
||||
);
|
||||
const fallback = this.htmlToBasicPdfBuffer(preparedHtml);
|
||||
if (this.isValidPdf(fallback)) {
|
||||
this.logger.warn(
|
||||
`Using basic PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
throw new InternalServerErrorException(
|
||||
'Contract PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
|
||||
'PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -113,4 +120,85 @@ export class ContractPdfService {
|
||||
buffer.subarray(0, 5).toString('ascii') === '%PDF-'
|
||||
);
|
||||
}
|
||||
|
||||
private htmlToBasicPdfBuffer(html: string): Buffer {
|
||||
const text = this.htmlToPlainText(html);
|
||||
const lines = this.wrapLines(text, 92).slice(0, 72);
|
||||
const body = lines
|
||||
.map((line, index) => {
|
||||
const prefix = index === 0 ? '50 790 Td' : '0 -12 Td';
|
||||
return `${prefix} (${this.escapePdfText(line)}) Tj`;
|
||||
})
|
||||
.join('\n');
|
||||
const stream = `BT\n/F1 10 Tf\n12 TL\n${body}\nET`;
|
||||
|
||||
const objects = [
|
||||
'<< /Type /Catalog /Pages 2 0 R >>',
|
||||
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
||||
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
|
||||
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
|
||||
`<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`,
|
||||
];
|
||||
|
||||
let pdf = '%PDF-1.4\n';
|
||||
const offsets: number[] = [0];
|
||||
objects.forEach((object, index) => {
|
||||
offsets.push(Buffer.byteLength(pdf, 'latin1'));
|
||||
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
||||
});
|
||||
while (Buffer.byteLength(pdf, 'latin1') < MIN_VALID_PDF_BYTES) {
|
||||
pdf += '% fallback padding\n';
|
||||
}
|
||||
const xrefOffset = Buffer.byteLength(pdf, 'latin1');
|
||||
pdf += `xref\n0 ${objects.length + 1}\n`;
|
||||
pdf += '0000000000 65535 f \n';
|
||||
for (const offset of offsets.slice(1)) {
|
||||
pdf += `${String(offset).padStart(10, '0')} 00000 n \n`;
|
||||
}
|
||||
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
||||
return Buffer.from(pdf, 'latin1');
|
||||
}
|
||||
|
||||
private htmlToPlainText(html: string): string {
|
||||
return html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<\/(h1|h2|h3|p|div|tr|table|section|header|footer)>/gi, '\n')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/[^\x09\x0a\x0d\x20-\x7e]/g, '-')
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/\s+/g, ' ').trim())
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
private wrapLines(text: string, width: number): string[] {
|
||||
const wrapped: string[] = [];
|
||||
for (const rawLine of text.split('\n')) {
|
||||
const words = rawLine.split(' ');
|
||||
let line = '';
|
||||
for (const word of words) {
|
||||
const next = line ? `${line} ${word}` : word;
|
||||
if (next.length > width && line) {
|
||||
wrapped.push(line);
|
||||
line = word;
|
||||
} else {
|
||||
line = next;
|
||||
}
|
||||
}
|
||||
if (line) wrapped.push(line);
|
||||
}
|
||||
return wrapped.length ? wrapped : ['Document'];
|
||||
}
|
||||
|
||||
private escapePdfText(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,26 @@
|
||||
<p><strong>Equipment return:</strong> {{pricing.equipmentReturn}}</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if pricing.unitRates}}
|
||||
<h3>Unit Rate Schedule</h3>
|
||||
<p>
|
||||
The rates below are the frozen unit prices applicable to this contract. Quantities and the resulting
|
||||
totals are determined per shipment at booking time; no total contract value is fixed at this stage.
|
||||
</p>
|
||||
<table class="schedule">
|
||||
<thead>
|
||||
<tr><th>Item</th><th>Unit price</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each pricing.unitRates}}
|
||||
<tr>
|
||||
<td>{{label}}</td>
|
||||
<td>{{currency}} {{unitPrice}} / {{unit}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<h3>Charges</h3>
|
||||
<table class="schedule">
|
||||
<thead>
|
||||
@@ -56,6 +76,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{{/if}}
|
||||
<h3>Terms of payment</h3>
|
||||
<p>
|
||||
Unless otherwise agreed in writing, the Client shall settle the contract value in
|
||||
|
||||
@@ -93,20 +93,25 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter
|
||||
);
|
||||
|
||||
// Create indexes for service_types
|
||||
await queryRunner.createIndex(
|
||||
"freight.service_types",
|
||||
new TableIndex({
|
||||
name: "IDX_SERVICE_TYPES_IS_ACTIVE",
|
||||
columnNames: ["is_active"],
|
||||
}),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
"freight.service_types",
|
||||
new TableIndex({
|
||||
name: "IDX_SERVICE_TYPES_DISPLAY_ORDER",
|
||||
columnNames: ["display_order"],
|
||||
}),
|
||||
);
|
||||
const table = await queryRunner.getTable("freight.service_types");
|
||||
if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_IS_ACTIVE")) {
|
||||
await queryRunner.createIndex(
|
||||
"freight.service_types",
|
||||
new TableIndex({
|
||||
name: "IDX_SERVICE_TYPES_IS_ACTIVE",
|
||||
columnNames: ["is_active"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_DISPLAY_ORDER")) {
|
||||
await queryRunner.createIndex(
|
||||
"freight.service_types",
|
||||
new TableIndex({
|
||||
name: "IDX_SERVICE_TYPES_DISPLAY_ORDER",
|
||||
columnNames: ["display_order"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Create cargo_types table
|
||||
if (!(await queryRunner.hasTable("freight.cargo_types"))) await queryRunner.createTable(
|
||||
|
||||
@@ -78,6 +78,7 @@ export class CreateWarehouseModule1790000000000 implements MigrationInterface {
|
||||
weight NUMERIC(14,3) NOT NULL DEFAULT 0,
|
||||
volume NUMERIC(12,3) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'ARRIVED_AT_WAREHOUSE',
|
||||
inspection_status VARCHAR(20) NULL,
|
||||
arrived_at TIMESTAMPTZ NULL,
|
||||
inspected_at TIMESTAMPTZ NULL,
|
||||
ready_for_loading_at TIMESTAMPTZ NULL,
|
||||
|
||||
@@ -56,6 +56,7 @@ export class AddWarehouseAllocationAndFeeRules1791000000000 implements Migration
|
||||
{ name: 'zone_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'free_days', type: 'int', default: 0 },
|
||||
{ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 },
|
||||
{ name: 'tiers', type: 'jsonb', default: "'[]'" },
|
||||
{ name: 'currency', type: 'varchar', length: '8', default: "'USD'" },
|
||||
{ name: 'is_active', type: 'boolean', default: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Fix for fresh deployments: AddWarehouseInspection1750000000003 runs before
|
||||
* the warehouse_inventory table exists, so it cannot add inspection_status.
|
||||
*/
|
||||
export class AddWarehouseInventoryInspectionStatusFix1791000000005 implements MigrationInterface {
|
||||
private readonly table = 'freight.warehouse_inventory';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if ((await queryRunner.hasTable(this.table)) && !(await queryRunner.hasColumn(this.table, 'inspection_status'))) {
|
||||
await queryRunner.addColumn(
|
||||
this.table,
|
||||
new TableColumn({ name: 'inspection_status', type: 'varchar', length: '20', isNullable: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
if ((await queryRunner.hasTable(this.table)) && (await queryRunner.hasColumn(this.table, 'inspection_status'))) {
|
||||
await queryRunner.dropColumn(this.table, 'inspection_status');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Seeds the admin-configurable "contract validity periods" setting (days). Stored
|
||||
* as a dropdown_settings row whose options each hold a day count in `value`, so
|
||||
* backoffice manages them through the existing Dropdown Settings UI and the
|
||||
* contract staff-accept dialog only offers the configured durations.
|
||||
*/
|
||||
export class SeedContractValidityPeriods1792000000004
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'SeedContractValidityPeriods1792000000004';
|
||||
private readonly code = 'contract_validity_periods';
|
||||
private readonly options: Array<{ value: string; label: string }> = [
|
||||
{ value: '180', label: '6 months' },
|
||||
{ value: '365', label: '1 year' },
|
||||
{ value: '730', label: '2 years' },
|
||||
];
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const existing = await queryRunner.query(
|
||||
`SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`,
|
||||
[this.code],
|
||||
);
|
||||
if (existing.length > 0) return;
|
||||
|
||||
const inserted = await queryRunner.query(
|
||||
`INSERT INTO freight.dropdown_settings (code, label, description, multiple)
|
||||
VALUES ($1, $2, $3, false)
|
||||
RETURNING id;`,
|
||||
[
|
||||
this.code,
|
||||
'Contract Validity Periods (days)',
|
||||
'Validity durations (in days) a staff can choose when accepting a submitted contract.',
|
||||
],
|
||||
);
|
||||
const settingId = inserted[0].id;
|
||||
|
||||
for (let i = 0; i < this.options.length; i++) {
|
||||
const opt = this.options[i];
|
||||
await queryRunner.query(
|
||||
`INSERT INTO freight.dropdown_options (setting_id, value, label, display_order)
|
||||
VALUES ($1, $2, $3, $4);`,
|
||||
[settingId, opt.value, opt.label, i],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DELETE FROM freight.dropdown_settings WHERE code = $1;`,
|
||||
[this.code],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Create the freight.last_mile_container_allocations table — container allocation
|
||||
* records linking last-mile deliveries with containers and vehicles.
|
||||
*/
|
||||
export class CreateLastMileContainerAllocations1810000000002 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const exists = await queryRunner.hasTable('freight.last_mile_container_allocations');
|
||||
if (exists) return;
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'freight.last_mile_container_allocations',
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'uuid',
|
||||
isPrimary: true,
|
||||
default: 'gen_random_uuid()',
|
||||
},
|
||||
{ name: 'last_mile_id', type: 'uuid', isNullable: false },
|
||||
{ name: 'container_id', type: 'uuid', isNullable: false },
|
||||
{ name: 'vehicle_id', type: 'uuid', isNullable: true },
|
||||
{
|
||||
name: 'container_type',
|
||||
type: 'text',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'quantity',
|
||||
type: 'integer',
|
||||
default: 1,
|
||||
isNullable: false,
|
||||
},
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.last_mile_container_allocations',
|
||||
new TableForeignKey({
|
||||
columnNames: ['last_mile_id'],
|
||||
referencedTableName: 'freight.last_mile',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.last_mile_container_allocations',
|
||||
new TableForeignKey({
|
||||
columnNames: ['vehicle_id'],
|
||||
referencedTableName: 'freight.vehicles',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'SET NULL',
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_last_mile_container_allocations_last_mile_id" ON "freight"."last_mile_container_allocations" ("last_mile_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_last_mile_container_allocations_vehicle_id" ON "freight"."last_mile_container_allocations" ("vehicle_id")`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const exists = await queryRunner.hasTable('freight.last_mile_container_allocations');
|
||||
if (exists) {
|
||||
await queryRunner.dropTable('freight.last_mile_container_allocations');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||
|
||||
export class AddPostPaymentCompletedColumn1810000000004 implements MigrationInterface {
|
||||
name = 'AddPostPaymentCompletedColumn1810000000004';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries');
|
||||
if (firstMileTable) {
|
||||
const hasColumn = await queryRunner.hasColumn('freight.first_mile_deliveries', 'is_post_payment_completed');
|
||||
if (!hasColumn) {
|
||||
await queryRunner.addColumn(
|
||||
'freight.first_mile_deliveries',
|
||||
new TableColumn({
|
||||
name: 'is_post_payment_completed',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
isNullable: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const lastMileTable = await queryRunner.hasTable('freight.last_mile_deliveries');
|
||||
if (lastMileTable) {
|
||||
const hasColumn = await queryRunner.hasColumn('freight.last_mile_deliveries', 'is_post_payment_completed');
|
||||
if (!hasColumn) {
|
||||
await queryRunner.addColumn(
|
||||
'freight.last_mile_deliveries',
|
||||
new TableColumn({
|
||||
name: 'is_post_payment_completed',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
isNullable: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const lastMileTable = await queryRunner.hasTable('freight.last_mile_deliveries');
|
||||
if (lastMileTable) {
|
||||
await queryRunner.dropColumn('freight.last_mile_deliveries', 'is_post_payment_completed');
|
||||
}
|
||||
|
||||
const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries');
|
||||
if (firstMileTable) {
|
||||
await queryRunner.dropColumn('freight.first_mile_deliveries', 'is_post_payment_completed');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Catch-up for environments where AddWarehouseInspection ran before the
|
||||
* warehouse module table existed. Production needs this column for unload and
|
||||
* inspection flows because the WarehouseInventory entity maps inspectionStatus.
|
||||
*/
|
||||
export class EnsureWarehouseInventoryInspectionStatus1821000000001 implements MigrationInterface {
|
||||
private readonly table = 'freight.warehouse_inventory';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (!(await queryRunner.hasColumn(this.table, 'inspection_status'))) {
|
||||
await queryRunner.addColumn(
|
||||
this.table,
|
||||
new TableColumn({
|
||||
name: 'inspection_status',
|
||||
type: 'varchar',
|
||||
length: '20',
|
||||
isNullable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_inspection_status
|
||||
ON freight.warehouse_inventory(inspection_status)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_warehouse_inventory_inspection_status
|
||||
`);
|
||||
|
||||
if (await queryRunner.hasColumn(this.table, 'inspection_status')) {
|
||||
await queryRunner.dropColumn(this.table, 'inspection_status');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddDistanceColumnsToVehicles1821000000002 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD COLUMN IF NOT EXISTS estimated_distance_km NUMERIC,
|
||||
ADD COLUMN IF NOT EXISTS actual_distance_km NUMERIC;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
DROP COLUMN IF EXISTS estimated_distance_km,
|
||||
DROP COLUMN IF EXISTS actual_distance_km;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Freight billing — `invoices` + `invoice_lines` tables.
|
||||
*
|
||||
* Matches:
|
||||
* - billing/entities/invoice.entity.ts
|
||||
* - billing/entities/invoice-line.entity.ts
|
||||
*
|
||||
* The status enum mirrors `Freight.InvoiceStatus` and uses TypeORM's default
|
||||
* enum-type name (`<table>_<column>_enum`) so the entity's `type: "enum"`
|
||||
* column resolves to it without an explicit `enumName`.
|
||||
*/
|
||||
export class CreateInvoices1821000000002 implements MigrationInterface {
|
||||
name = "CreateInvoices1821000000002";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const typeExists = await queryRunner.query(
|
||||
`SELECT 1 FROM pg_type WHERE typname = 'invoices_status_enum' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'freight');`,
|
||||
);
|
||||
|
||||
if (!typeExists.length) {
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE freight.invoices_status_enum AS ENUM (
|
||||
'DRAFT',
|
||||
'PENDING',
|
||||
'PAID',
|
||||
'OVERDUE',
|
||||
'CANCELLED',
|
||||
'REFUNDED'
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.invoices (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
invoice_number varchar(64) NOT NULL,
|
||||
company_id uuid NOT NULL,
|
||||
company_profile_id uuid NOT NULL,
|
||||
total_amount numeric(14, 2) NOT NULL,
|
||||
currency varchar(8) NOT NULL DEFAULT 'ETB',
|
||||
status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT',
|
||||
source varchar(255) NOT NULL,
|
||||
source_id varchar(255) NOT NULL,
|
||||
type varchar(255) NOT NULL,
|
||||
issued_at timestamptz,
|
||||
payment_id uuid,
|
||||
due_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT pk_invoices PRIMARY KEY (id),
|
||||
CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number),
|
||||
CONSTRAINT fk_invoices_company FOREIGN KEY (company_id)
|
||||
REFERENCES freight.companies (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_invoices_company_profile FOREIGN KEY (company_profile_id)
|
||||
REFERENCES freight.company_profiles (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_invoices_payment FOREIGN KEY (payment_id)
|
||||
REFERENCES freight.payments (id) ON DELETE SET NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS id uuid DEFAULT uuid_generate_v4(),
|
||||
ADD COLUMN IF NOT EXISTS invoice_number varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS company_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS company_profile_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS total_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS currency varchar(8) NOT NULL DEFAULT 'ETB',
|
||||
ADD COLUMN IF NOT EXISTS status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT',
|
||||
ADD COLUMN IF NOT EXISTS source varchar(255),
|
||||
ADD COLUMN IF NOT EXISTS source_id varchar(255),
|
||||
ADD COLUMN IF NOT EXISTS type varchar(255),
|
||||
ADD COLUMN IF NOT EXISTS issued_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS payment_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS due_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(),
|
||||
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
|
||||
`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.invoices
|
||||
SET due_at = COALESCE(due_at, issued_at, created_at, now())
|
||||
WHERE due_at IS NULL;
|
||||
`);
|
||||
await queryRunner.query(`ALTER TABLE freight.invoices ALTER COLUMN due_at SET NOT NULL;`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE contype = 'p'
|
||||
AND conrelid = 'freight.invoices'::regclass
|
||||
) THEN
|
||||
ALTER TABLE freight.invoices ADD CONSTRAINT pk_invoices PRIMARY KEY (id);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'uq_invoices_invoice_number'
|
||||
AND conrelid = 'freight.invoices'::regclass
|
||||
) THEN
|
||||
ALTER TABLE freight.invoices ADD CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'fk_invoices_company'
|
||||
AND conrelid = 'freight.invoices'::regclass
|
||||
) THEN
|
||||
ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_company
|
||||
FOREIGN KEY (company_id) REFERENCES freight.companies (id) ON DELETE RESTRICT;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'fk_invoices_company_profile'
|
||||
AND conrelid = 'freight.invoices'::regclass
|
||||
) THEN
|
||||
ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_company_profile
|
||||
FOREIGN KEY (company_profile_id) REFERENCES freight.company_profiles (id) ON DELETE RESTRICT;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'fk_invoices_payment'
|
||||
AND conrelid = 'freight.invoices'::regclass
|
||||
) THEN
|
||||
ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_payment
|
||||
FOREIGN KEY (payment_id) REFERENCES freight.payments (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_invoices_company ON freight.invoices (company_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_invoices_company_profile ON freight.invoices (company_profile_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_invoices_source ON freight.invoices (source, source_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_invoices_status ON freight.invoices (status);`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.invoice_lines (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
invoice_id uuid NOT NULL,
|
||||
charge_type varchar NOT NULL,
|
||||
description varchar(255),
|
||||
quantity numeric(12, 2) NOT NULL DEFAULT 1,
|
||||
unit_rate numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
amount numeric(14, 2) NOT NULL,
|
||||
currency varchar(8) NOT NULL DEFAULT 'ETB',
|
||||
metadata jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT pk_invoice_lines PRIMARY KEY (id),
|
||||
CONSTRAINT fk_invoice_lines_invoice FOREIGN KEY (invoice_id)
|
||||
REFERENCES freight.invoices (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.invoice_lines;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.invoices;`);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS freight.invoices_status_enum;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Government bookings now bill to a real seeded government company + an explicit
|
||||
* importer/exporter profile, instead of carrying a null company + free-text
|
||||
* institution. This migration:
|
||||
*
|
||||
* 1. Adds `companies.kind` (commercial | government).
|
||||
* 2. Seeds the Ethiopian government entities + their importer/exporter
|
||||
* profiles (mirrors src/seed/data/gov-companies.data.ts — keep in sync).
|
||||
* 3. Backfills every booking with a NULL company_id / company_profile_id so
|
||||
* the NOT NULL constraints below can be applied:
|
||||
* - NULL company_id → the default government company.
|
||||
* - NULL company_profile_id → the company's profile matching the booking
|
||||
* trade direction; else any profile of the company; else the default
|
||||
* government importer profile.
|
||||
* 4. Enforces NOT NULL on bookings.company_id and bookings.company_profile_id.
|
||||
*/
|
||||
export class AddCompanyKindAndGovBookingLinks1821000000003
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddCompanyKindAndGovBookingLinks1821000000003";
|
||||
|
||||
// Mirrors src/seed/data/gov-companies.data.ts
|
||||
private readonly govCompanies = [
|
||||
{ id: "0a1b0001-0000-4000-8000-000000000001", name: "Federal Government of Ethiopia", tin: "0000000001", email: "procurement@gov.et", phone: "+251111000001", im: "0b1c0001-0000-4000-8000-000000000001", ex: "0b1c0001-0000-4000-8000-000000000002", imRef: "IM-90001", exRef: "EX-90001" },
|
||||
{ id: "0a1b0002-0000-4000-8000-000000000002", name: "Ministry of National Defense", tin: "0000000002", email: "logistics@mod.gov.et", phone: "+251111000002", im: "0b1c0002-0000-4000-8000-000000000001", ex: "0b1c0002-0000-4000-8000-000000000002", imRef: "IM-90002", exRef: "EX-90002" },
|
||||
{ id: "0a1b0003-0000-4000-8000-000000000003", name: "Ethiopian Roads Administration", tin: "0000000003", email: "supply@era.gov.et", phone: "+251111000003", im: "0b1c0003-0000-4000-8000-000000000001", ex: "0b1c0003-0000-4000-8000-000000000002", imRef: "IM-90003", exRef: "EX-90003" },
|
||||
{ id: "0a1b0004-0000-4000-8000-000000000004", name: "Ministry of Agriculture", tin: "0000000004", email: "imports@moa.gov.et", phone: "+251111000004", im: "0b1c0004-0000-4000-8000-000000000001", ex: "0b1c0004-0000-4000-8000-000000000002", imRef: "IM-90004", exRef: "EX-90004" },
|
||||
{ id: "0a1b0005-0000-4000-8000-000000000005", name: "Ministry of Trade and Regional Integration", tin: "0000000005", email: "trade@motri.gov.et", phone: "+251111000005", im: "0b1c0005-0000-4000-8000-000000000001", ex: "0b1c0005-0000-4000-8000-000000000002", imRef: "IM-90005", exRef: "EX-90005" },
|
||||
{ id: "0a1b0006-0000-4000-8000-000000000006", name: "Ethiopian Disaster Risk Management Commission", tin: "0000000006", email: "relief@edrmc.gov.et", phone: "+251111000006", im: "0b1c0006-0000-4000-8000-000000000001", ex: "0b1c0006-0000-4000-8000-000000000002", imRef: "IM-90006", exRef: "EX-90006" },
|
||||
];
|
||||
|
||||
private get defaultCompanyId(): string {
|
||||
return this.govCompanies[0].id;
|
||||
}
|
||||
private get defaultImporterProfileId(): string {
|
||||
return this.govCompanies[0].im;
|
||||
}
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 1. kind column
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."companies" ADD COLUMN IF NOT EXISTS "kind" varchar(20) NOT NULL DEFAULT 'commercial'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_companies_kind" ON "freight"."companies" ("kind")`,
|
||||
);
|
||||
|
||||
// 2. seed government companies + importer/exporter profiles (idempotent)
|
||||
for (const g of this.govCompanies) {
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "freight"."companies" ("id", "name", "type", "kind", "status", "tin", "country", "email", "phone")
|
||||
VALUES ($1, $2, 'customer', 'government', 'active', $3, 'Ethiopia', $4, $5)
|
||||
ON CONFLICT ("id") DO NOTHING`,
|
||||
[g.id, g.name, g.tin, g.email, g.phone],
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "freight"."company_profiles" ("id", "company_id", "type", "reference", "status")
|
||||
VALUES ($1, $2, 'importer', $3, 'active'), ($4, $2, 'exporter', $5, 'active')
|
||||
ON CONFLICT ("id") DO NOTHING`,
|
||||
[g.im, g.id, g.imRef, g.ex, g.exRef],
|
||||
);
|
||||
}
|
||||
|
||||
// 3a. backfill NULL company_id → default government company
|
||||
await queryRunner.query(
|
||||
`UPDATE "freight"."bookings" SET "company_id" = $1 WHERE "company_id" IS NULL`,
|
||||
[this.defaultCompanyId],
|
||||
);
|
||||
|
||||
// 3b. backfill NULL company_profile_id → profile matching trade direction
|
||||
await queryRunner.query(
|
||||
`UPDATE "freight"."bookings" b
|
||||
SET "company_profile_id" = cp."id"
|
||||
FROM "freight"."company_profiles" cp
|
||||
WHERE b."company_profile_id" IS NULL
|
||||
AND cp."company_id" = b."company_id"
|
||||
AND cp."deleted_at" IS NULL
|
||||
AND cp."type" = CASE b."trade_direction"
|
||||
WHEN 'IMPORT' THEN 'importer'
|
||||
WHEN 'EXPORT' THEN 'exporter'
|
||||
ELSE NULL END`,
|
||||
);
|
||||
|
||||
// 3c. fallback → any profile of the booking's company
|
||||
await queryRunner.query(
|
||||
`UPDATE "freight"."bookings" b
|
||||
SET "company_profile_id" = (
|
||||
SELECT cp."id" FROM "freight"."company_profiles" cp
|
||||
WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL
|
||||
ORDER BY cp."created_at" ASC LIMIT 1)
|
||||
WHERE b."company_profile_id" IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "freight"."company_profiles" cp
|
||||
WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL)`,
|
||||
);
|
||||
|
||||
// 3d. final fallback → default government importer profile
|
||||
await queryRunner.query(
|
||||
`UPDATE "freight"."bookings" SET "company_profile_id" = $1 WHERE "company_profile_id" IS NULL`,
|
||||
[this.defaultImporterProfileId],
|
||||
);
|
||||
|
||||
// 4. enforce NOT NULL
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" SET NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" SET NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" DROP NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" DROP NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS "freight"."IDX_companies_kind"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."companies" DROP COLUMN IF EXISTS "kind"`,
|
||||
);
|
||||
// Seeded government rows are intentionally left in place.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Make the payment projection source-agnostic so any domain (not just bookings)
|
||||
* can own a payment intent.
|
||||
*
|
||||
* - `payments.type` enum `('booking')` → `varchar(50)`. It now stores the
|
||||
* invoice SOURCE (e.g. 'booking', 'demurrage'), supplied by the caller, so a
|
||||
* new domain no longer needs an enum migration to write its intents.
|
||||
* - adds `payments.reference_type varchar(40)` — the gateway reference type
|
||||
* (`PaymentReferenceType`) the intent was opened with, so the reconcile/poll
|
||||
* path can query the provider without hardcoding it.
|
||||
*
|
||||
* Matches payment/entities/payment.entity.ts.
|
||||
*/
|
||||
export class MakePaymentsTypeGeneric1821000000004 implements MigrationInterface {
|
||||
name = "MakePaymentsTypeGeneric1821000000004";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.payments ALTER COLUMN type TYPE varchar(50) USING type::text;`,
|
||||
);
|
||||
await queryRunner.query(`DROP TYPE IF EXISTS freight.payments_type_enum;`);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.payments ADD COLUMN reference_type varchar(40);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.payments DROP COLUMN IF EXISTS reference_type;`,
|
||||
);
|
||||
|
||||
// Restore the single-value enum. Any non-'booking' rows would block the cast;
|
||||
// collapse them first so the down migration is safe.
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.payments SET type = 'booking' WHERE type <> 'booking';`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE freight.payments_type_enum AS ENUM ('booking');`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.payments ALTER COLUMN type TYPE freight.payments_type_enum USING type::freight.payments_type_enum;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Contract–Booking separation (additive phase). Introduces a first-class
|
||||
* `freight.contracts` aggregate that owns the legal/commercial agreement (scope
|
||||
* + unit rates, no quantities) and spawns shipment `bookings` via `contract_id`.
|
||||
*
|
||||
* Purely additive: no legacy columns are dropped here. The data backfill and
|
||||
* legacy-column removal happen in a later cutover migration.
|
||||
*
|
||||
* See docs/new-doc.md §5.
|
||||
*/
|
||||
export class CreateContracts1822000000000 implements MigrationInterface {
|
||||
name = 'CreateContracts1822000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ── contracts ───────────────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contracts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
reference VARCHAR(64) NOT NULL UNIQUE,
|
||||
|
||||
company_id UUID,
|
||||
company_profile_id UUID,
|
||||
is_government BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
government_institution VARCHAR(255),
|
||||
|
||||
contract_kind VARCHAR(20) NOT NULL,
|
||||
renewal_of_id UUID REFERENCES freight.contracts(id),
|
||||
trade_direction VARCHAR(10) NOT NULL,
|
||||
freight_type VARCHAR(20) NOT NULL,
|
||||
|
||||
service_type_id UUID NOT NULL,
|
||||
payment_currency VARCHAR(5) NOT NULL,
|
||||
customs_clearing_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
customs_clearing_agent VARCHAR(200),
|
||||
equipment_return VARCHAR(20),
|
||||
|
||||
first_mile_pickup_address TEXT,
|
||||
first_mile_pickup_lat NUMERIC(10,7),
|
||||
first_mile_pickup_lng NUMERIC(10,7),
|
||||
last_mile_delivery_address TEXT,
|
||||
last_mile_delivery_lat NUMERIC(10,7),
|
||||
last_mile_delivery_lng NUMERIC(10,7),
|
||||
|
||||
is_hazardous BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_reefer BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
estimated_shipment_date TIMESTAMPTZ,
|
||||
contract_validity_days INT,
|
||||
contract_valid_from TIMESTAMPTZ,
|
||||
contract_valid_until TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
|
||||
status VARCHAR(40) NOT NULL DEFAULT 'DRAFT',
|
||||
clearance_status VARCHAR(40) NOT NULL DEFAULT 'NOT_APPLICABLE',
|
||||
clearance_cycle_number INT NOT NULL DEFAULT 0,
|
||||
|
||||
pricing_breakdown JSONB,
|
||||
pricing_display_mode VARCHAR(20) DEFAULT 'UNIT_RATES',
|
||||
|
||||
contract_type VARCHAR(20),
|
||||
contract_template_key VARCHAR(128),
|
||||
contract_generated_at TIMESTAMPTZ,
|
||||
contract_summary TEXT,
|
||||
version_number INT NOT NULL DEFAULT 1,
|
||||
financial_terms JSONB,
|
||||
|
||||
approved_by_staff_id UUID,
|
||||
approved_by_staff_at TIMESTAMPTZ,
|
||||
signed_by_director_id UUID,
|
||||
signed_by_director_at TIMESTAMPTZ,
|
||||
signed_by_ceo_id UUID,
|
||||
signed_by_ceo_at TIMESTAMPTZ,
|
||||
customer_signed_at TIMESTAMPTZ,
|
||||
fully_executed_at TIMESTAMPTZ,
|
||||
locked_at TIMESTAMPTZ,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_company ON freight.contracts(company_id);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_status ON freight.contracts(status);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_kind ON freight.contracts(contract_kind);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_valid_until ON freight.contracts(contract_valid_until);`);
|
||||
|
||||
// ── contract_routes ──────────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_routes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
origin_yard_id UUID NOT NULL,
|
||||
destination_yard_id UUID NOT NULL,
|
||||
km NUMERIC(10,2),
|
||||
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT uq_contract_route UNIQUE (contract_id, origin_yard_id, destination_yard_id)
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_routes_contract ON freight.contract_routes(contract_id);`);
|
||||
|
||||
// ── contract_cargo_scope ─────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_cargo_scope (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
container_size VARCHAR(10),
|
||||
cargo_type_id UUID,
|
||||
cargo_free_text VARCHAR(200),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT uq_contract_container_size UNIQUE NULLS NOT DISTINCT (contract_id, container_size)
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_cargo_scope_contract ON freight.contract_cargo_scope(contract_id);`);
|
||||
|
||||
// ── contract_signatures ──────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_signatures (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) NOT NULL,
|
||||
signer_display_name VARCHAR(255) NOT NULL,
|
||||
signature_file_id UUID,
|
||||
consent_text TEXT,
|
||||
signed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_signatures_contract ON freight.contract_signatures(contract_id);`);
|
||||
|
||||
// ── contract_approval_steps ──────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_approval_steps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
step_order SMALLINT NOT NULL DEFAULT 0,
|
||||
required_role VARCHAR(40) NOT NULL,
|
||||
blocks_role VARCHAR(40),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||
acted_by_staff_id UUID,
|
||||
acted_at TIMESTAMPTZ,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_approval_steps_contract ON freight.contract_approval_steps(contract_id);`);
|
||||
|
||||
// ── contract_rate_snapshots ──────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_rate_snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
rate_id UUID,
|
||||
rate_code VARCHAR(64) NOT NULL,
|
||||
description VARCHAR(255),
|
||||
unit_price NUMERIC(14,2) NOT NULL,
|
||||
unit_of_measure VARCHAR(32) NOT NULL,
|
||||
currency VARCHAR(5) NOT NULL,
|
||||
container_size VARCHAR(10),
|
||||
is_surcharge BOOLEAN DEFAULT FALSE,
|
||||
conditional_on VARCHAR(32),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_rate_snapshots_contract ON freight.contract_rate_snapshots(contract_id);`);
|
||||
|
||||
// ── contract_review_notes ────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_review_notes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
note_type VARCHAR(40) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
author_role VARCHAR(20),
|
||||
author_user_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_review_notes_contract ON freight.contract_review_notes(contract_id);`);
|
||||
|
||||
// ── contract_clearance_cycles ────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_clearance_cycles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
cycle_number INT NOT NULL,
|
||||
status VARCHAR(40) NOT NULL DEFAULT 'AWAITING_DOCUMENTS',
|
||||
booking_id UUID,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
clearance_ready_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT uq_contract_clearance_cycle UNIQUE (contract_id, cycle_number)
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_clearance_cycles_contract ON freight.contract_clearance_cycles(contract_id);`);
|
||||
|
||||
// ── contract_document_review (pre-booking clearance, Path B) ─────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_document_review (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
clearance_cycle_id UUID REFERENCES freight.contract_clearance_cycles(id),
|
||||
setting_code VARCHAR(128) NOT NULL,
|
||||
file_key VARCHAR(128) NOT NULL,
|
||||
file_record_id UUID,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||
note TEXT,
|
||||
uploaded_by_role VARCHAR(20) NOT NULL DEFAULT 'CUSTOMER',
|
||||
reviewed_by_staff_id UUID,
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT uq_contract_document_review_doc
|
||||
UNIQUE NULLS NOT DISTINCT (contract_id, clearance_cycle_id, setting_code, file_key)
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_doc_review_contract ON freight.contract_document_review(contract_id);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_doc_review_status ON freight.contract_document_review(status);`);
|
||||
|
||||
// ── clearance_milestones (GL tracking) ───────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.clearance_milestones (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
booking_id UUID REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||
contract_id UUID REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
clearance_cycle_id UUID REFERENCES freight.contract_clearance_cycles(id),
|
||||
milestone_code VARCHAR(64) NOT NULL,
|
||||
milestone_label VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||
owner_region VARCHAR(5),
|
||||
triggered_by_doc BOOLEAN DEFAULT FALSE,
|
||||
triggered_at TIMESTAMPTZ,
|
||||
triggered_by_user_id UUID,
|
||||
note TEXT,
|
||||
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_clearance_milestones_booking ON freight.clearance_milestones(booking_id);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_clearance_milestones_contract ON freight.clearance_milestones(contract_id);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_clearance_milestones_region ON freight.clearance_milestones(owner_region, status);`);
|
||||
// booking-scoped and contract-cycle-scoped uniqueness for milestone codes
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_clearance_milestone_booking
|
||||
ON freight.clearance_milestones(booking_id, milestone_code) WHERE booking_id IS NOT NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_clearance_milestone_cycle
|
||||
ON freight.clearance_milestones(clearance_cycle_id, milestone_code) WHERE clearance_cycle_id IS NOT NULL;
|
||||
`);
|
||||
|
||||
// ── booking_container_units (per-unit container detail) ──────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.booking_container_units (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
booking_container_id UUID NOT NULL REFERENCES freight.booking_container(id) ON DELETE CASCADE,
|
||||
container_number VARCHAR(64) NOT NULL,
|
||||
seal_number VARCHAR(64),
|
||||
vgm_tons NUMERIC(10,3) NOT NULL,
|
||||
is_hazardous BOOLEAN DEFAULT FALSE,
|
||||
is_reefer BOOLEAN DEFAULT FALSE,
|
||||
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT uq_booking_container_unit_number UNIQUE (booking_container_id, container_number)
|
||||
);
|
||||
`);
|
||||
|
||||
// ── ALTER bookings ───────────────────────────────────────────────────────
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_id UUID REFERENCES freight.contracts(id);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_route_id UUID REFERENCES freight.contract_routes(id);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS created_by_role VARCHAR(20) DEFAULT 'CUSTOMER';`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS created_by_user_id UUID;`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_bookings_contract ON freight.bookings(contract_id);`);
|
||||
// One active booking per ONE_TIME contract. Postgres forbids a subquery in an
|
||||
// index predicate, so we denormalize the contract kind onto the booking and
|
||||
// predicate on that. The column is stamped at booking creation from the
|
||||
// contract; the app layer (ContractBookingService) is the primary guard and
|
||||
// this index is the backstop.
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_kind VARCHAR(20);`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_one_active_booking_per_one_time_contract
|
||||
ON freight.bookings (contract_id)
|
||||
WHERE status NOT IN ('EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED')
|
||||
AND contract_id IS NOT NULL
|
||||
AND contract_kind = 'ONE_TIME';
|
||||
`);
|
||||
|
||||
// ── ALTER booking_container ──────────────────────────────────────────────
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_container ADD COLUMN IF NOT EXISTS container_size VARCHAR(10);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_container ADD COLUMN IF NOT EXISTS hazardous_quantity SMALLINT DEFAULT 0;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_container ADD COLUMN IF NOT EXISTS reefer_quantity SMALLINT DEFAULT 0;`);
|
||||
|
||||
// ── ALTER booking_document_review (denormalized contract link) ───────────
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_document_review ADD COLUMN IF NOT EXISTS contract_id UUID REFERENCES freight.contracts(id);`);
|
||||
|
||||
// ── Extend file_upload_fields with phased GL metadata ────────────────────
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS phase VARCHAR(40);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS owner_region VARCHAR(5);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS trade_direction VARCHAR(10);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS triggers_milestone_code VARCHAR(64);`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS triggers_milestone_code;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS trade_direction;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS owner_region;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS phase;`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_document_review DROP COLUMN IF EXISTS contract_id;`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_container DROP COLUMN IF EXISTS reefer_quantity;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_container DROP COLUMN IF EXISTS hazardous_quantity;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_container DROP COLUMN IF EXISTS container_size;`);
|
||||
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_one_active_booking_per_one_time_contract;`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_contract;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_kind;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS created_by_user_id;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS created_by_role;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_route_id;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_id;`);
|
||||
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_container_units;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.clearance_milestones;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_document_review;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_clearance_cycles;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_review_notes;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_rate_snapshots;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_approval_steps;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_signatures;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_cargo_scope;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_routes;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contracts;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex } from 'typeorm';
|
||||
|
||||
export class CreateImportDjiboutiOperations1822000000000 implements MigrationInterface {
|
||||
name = 'CreateImportDjiboutiOperations1822000000000';
|
||||
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'import_djibouti_operations',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'train_schedule_id', type: 'uuid', isUnique: true },
|
||||
{ name: 'documents', type: 'jsonb', default: "'{}'::jsonb" },
|
||||
{ name: 'gatepass_granted_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'ready_for_loading_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'loaded_on_train_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'departed_from_djibouti_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'load_list_generated_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'performed_by', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'notes', type: 'text', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'freight.import_djibouti_operations',
|
||||
new TableIndex({
|
||||
name: 'idx_import_djibouti_operations_schedule',
|
||||
columnNames: ['train_schedule_id'],
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.import_djibouti_operations',
|
||||
new TableForeignKey({
|
||||
columnNames: ['train_schedule_id'],
|
||||
referencedTableName: 'train_schedules',
|
||||
referencedSchema: 'freight',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.import_djibouti_operations', true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Data backfill for the contract–booking separation (docs/new-doc.md §17).
|
||||
*
|
||||
* For every legacy `booking_type = 'GENERAL_CONTRACT'` booking we synthesise a
|
||||
* `freight.contracts` row from its contract-phase columns, copy its routes
|
||||
* (contract_route_lines → contract_routes, dropping quantity), and point the
|
||||
* contract + every child shipment booking (linked via booking_orders) at it.
|
||||
*
|
||||
* Per §19 item 1, historical ONE_TIME bookings that went through the full
|
||||
* contract flow get a contract parent inserted and `contract_id` set on the same
|
||||
* booking row (no row split).
|
||||
*
|
||||
* Idempotent: skips bookings that already have `contract_id` set, and matches a
|
||||
* synthesised contract by a deterministic `CTR-<bookingId>` reference.
|
||||
*/
|
||||
export class BackfillContractsFromBookings1823000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'BackfillContractsFromBookings1823000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 1. One contract per GENERAL_CONTRACT booking, carrying the contract-phase
|
||||
// columns. Reference is derived from the source booking id so re-runs are
|
||||
// idempotent (ON CONFLICT DO NOTHING on the unique reference).
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.contracts (
|
||||
reference, company_id, company_profile_id, is_government, government_institution,
|
||||
contract_kind, trade_direction, freight_type, service_type_id, payment_currency,
|
||||
customs_clearing_enabled, customs_clearing_agent, equipment_return,
|
||||
first_mile_pickup_address, first_mile_pickup_lat, first_mile_pickup_lng,
|
||||
last_mile_delivery_address, last_mile_delivery_lat, last_mile_delivery_lng,
|
||||
is_hazardous, is_reefer, estimated_shipment_date,
|
||||
contract_validity_days, contract_valid_from, contract_valid_until, expires_at,
|
||||
status, clearance_status, clearance_cycle_number,
|
||||
pricing_breakdown, contract_type, contract_template_key, contract_generated_at,
|
||||
contract_summary, version_number,
|
||||
approved_by_staff_id, approved_by_staff_at,
|
||||
signed_by_director_id, signed_by_director_at,
|
||||
signed_by_ceo_id, signed_by_ceo_at, customer_signed_at, fully_executed_at,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
'CTR-' || b.id::text, b.company_id, b.company_profile_id, b.is_government, b.government_institution,
|
||||
'GENERAL', b.trade_direction, b.freight_type, b.service_type_id, b.payment_currency,
|
||||
b.customs_clearing_enabled, b.customs_clearing_agent, b.equipment_return,
|
||||
b.first_mile_pickup_address, b.first_mile_pickup_lat, b.first_mile_pickup_lng,
|
||||
b.last_mile_delivery_address, b.last_mile_delivery_lat, b.last_mile_delivery_lng,
|
||||
b.is_hazardous, b.is_reefer, b.estimated_shipment_date,
|
||||
b.contract_validity_days, b.contract_valid_from, b.contract_valid_until, b.expires_at,
|
||||
CASE
|
||||
WHEN b.status IN ('CONTRACT_ACTIVE') THEN 'CONTRACT_ACTIVE'
|
||||
WHEN b.status IN ('CONTRACT_CLOSED') THEN 'CONTRACT_CLOSED'
|
||||
WHEN b.status IN ('EXPIRED') THEN 'EXPIRED'
|
||||
WHEN b.status IN ('CANCELLED') THEN 'CANCELLED'
|
||||
WHEN b.status IN ('REJECTED') THEN 'REJECTED'
|
||||
ELSE 'CONTRACT_ACTIVE'
|
||||
END,
|
||||
CASE WHEN b.customs_clearing_enabled THEN 'NOT_APPLICABLE' ELSE 'NOT_APPLICABLE' END,
|
||||
0,
|
||||
b.pricing_breakdown,
|
||||
b.contract_type, b.contract_template_key, b.contract_generated_at,
|
||||
b.contract_summary, COALESCE(b.version_number, 1),
|
||||
b.approved_by_staff_id, b.approved_by_staff_at,
|
||||
b.signed_by_director_id, b.signed_by_director_at,
|
||||
b.signed_by_ceo_id, b.signed_by_ceo_at, b.customer_signed_at, b.fully_executed_at,
|
||||
b.created_at, b.updated_at
|
||||
FROM freight.bookings b
|
||||
WHERE b.booking_type = 'GENERAL_CONTRACT'
|
||||
ON CONFLICT (reference) DO NOTHING;
|
||||
`);
|
||||
|
||||
// 2. Copy each general contract's route lines into contract_routes (no qty).
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.contract_routes (contract_id, origin_yard_id, destination_yard_id, km, sort_order, created_at, updated_at)
|
||||
SELECT c.id, crl.origin_yard_id, crl.destination_yard_id, crl.km, 0, now(), now()
|
||||
FROM freight.contract_route_lines crl
|
||||
JOIN freight.contracts c ON c.reference = 'CTR-' || crl.contract_booking_id::text
|
||||
ON CONFLICT (contract_id, origin_yard_id, destination_yard_id) DO NOTHING;
|
||||
`);
|
||||
|
||||
// 3. Point the general-contract booking itself at its new contract, and stamp
|
||||
// the denormalized contract_kind for the active-booking index.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET contract_id = c.id, contract_kind = 'GENERAL', created_by_role = 'CUSTOMER'
|
||||
FROM freight.contracts c
|
||||
WHERE c.reference = 'CTR-' || b.id::text
|
||||
AND b.booking_type = 'GENERAL_CONTRACT'
|
||||
AND b.contract_id IS NULL;
|
||||
`);
|
||||
|
||||
// 4. Point each child shipment booking (spawned via booking_orders) at the
|
||||
// same contract as its parent general contract.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings child
|
||||
SET contract_id = c.id, contract_kind = 'GENERAL', created_by_role = 'CUSTOMER'
|
||||
FROM freight.booking_orders bo
|
||||
JOIN freight.contracts c ON c.reference = 'CTR-' || bo.contract_booking_id::text
|
||||
WHERE child.id = bo.booking_id
|
||||
AND child.contract_id IS NULL;
|
||||
`);
|
||||
|
||||
// 5. Historical ONE_TIME bookings that completed the contract flow: synthesise
|
||||
// a contract parent and point the same booking row at it (no row split).
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.contracts (
|
||||
reference, company_id, company_profile_id, is_government, government_institution,
|
||||
contract_kind, trade_direction, freight_type, service_type_id, payment_currency,
|
||||
customs_clearing_enabled, customs_clearing_agent, equipment_return,
|
||||
first_mile_pickup_address, first_mile_pickup_lat, first_mile_pickup_lng,
|
||||
last_mile_delivery_address, last_mile_delivery_lat, last_mile_delivery_lng,
|
||||
is_hazardous, is_reefer, estimated_shipment_date,
|
||||
contract_validity_days, contract_valid_from, contract_valid_until,
|
||||
status, clearance_status, clearance_cycle_number,
|
||||
pricing_breakdown, contract_type, contract_template_key, contract_generated_at,
|
||||
contract_summary, version_number,
|
||||
approved_by_staff_id, approved_by_staff_at,
|
||||
signed_by_director_id, signed_by_director_at,
|
||||
signed_by_ceo_id, signed_by_ceo_at, customer_signed_at, fully_executed_at,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
'CTR-' || b.id::text, b.company_id, b.company_profile_id, b.is_government, b.government_institution,
|
||||
'ONE_TIME', b.trade_direction, b.freight_type, b.service_type_id, b.payment_currency,
|
||||
b.customs_clearing_enabled, b.customs_clearing_agent, b.equipment_return,
|
||||
b.first_mile_pickup_address, b.first_mile_pickup_lat, b.first_mile_pickup_lng,
|
||||
b.last_mile_delivery_address, b.last_mile_delivery_lat, b.last_mile_delivery_lng,
|
||||
b.is_hazardous, b.is_reefer, b.estimated_shipment_date,
|
||||
b.contract_validity_days, b.contract_valid_from, b.contract_valid_until,
|
||||
'FULLY_EXECUTED', 'NOT_APPLICABLE', 0,
|
||||
b.pricing_breakdown, b.contract_type, b.contract_template_key, b.contract_generated_at,
|
||||
b.contract_summary, COALESCE(b.version_number, 1),
|
||||
b.approved_by_staff_id, b.approved_by_staff_at,
|
||||
b.signed_by_director_id, b.signed_by_director_at,
|
||||
b.signed_by_ceo_id, b.signed_by_ceo_at, b.customer_signed_at, b.fully_executed_at,
|
||||
b.created_at, b.updated_at
|
||||
FROM freight.bookings b
|
||||
WHERE COALESCE(b.booking_type, 'ONE_TIME') = 'ONE_TIME'
|
||||
AND b.contract_id IS NULL
|
||||
AND b.contract_generated_at IS NOT NULL
|
||||
ON CONFLICT (reference) DO NOTHING;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET contract_id = c.id, contract_kind = 'ONE_TIME', created_by_role = 'CUSTOMER'
|
||||
FROM freight.contracts c
|
||||
WHERE c.reference = 'CTR-' || b.id::text
|
||||
AND COALESCE(b.booking_type, 'ONE_TIME') = 'ONE_TIME'
|
||||
AND b.contract_id IS NULL;
|
||||
`);
|
||||
|
||||
// 6. Build a single route per ONE_TIME contract from the booking's own
|
||||
// origin/destination (general contracts already got their routes in step 2).
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.contract_routes (contract_id, origin_yard_id, destination_yard_id, sort_order, created_at, updated_at)
|
||||
SELECT c.id, b.origin_yard_id, b.destination_yard_id, 0, now(), now()
|
||||
FROM freight.bookings b
|
||||
JOIN freight.contracts c ON c.id = b.contract_id AND c.contract_kind = 'ONE_TIME'
|
||||
WHERE b.origin_yard_id IS NOT NULL AND b.destination_yard_id IS NOT NULL
|
||||
ON CONFLICT (contract_id, origin_yard_id, destination_yard_id) DO NOTHING;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Unlink bookings and drop the synthesised contracts (and their cascaded routes).
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings SET contract_id = NULL, contract_route_id = NULL
|
||||
WHERE contract_id IN (SELECT id FROM freight.contracts WHERE reference LIKE 'CTR-%');
|
||||
`);
|
||||
await queryRunner.query(`DELETE FROM freight.contracts WHERE reference LIKE 'CTR-%';`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||
|
||||
export class CreateImportOperationsTables1823000000000 implements MigrationInterface {
|
||||
name = 'CreateImportOperationsTables1823000000000';
|
||||
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'djibouti_import_incidents',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'booking_id', type: 'uuid' },
|
||||
{ name: 'container_number', type: 'varchar', length: '80', isNullable: true },
|
||||
{ name: 'cargo_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'facility', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'station', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'incident_type', type: 'varchar', length: '40' },
|
||||
{ name: 'description', type: 'text' },
|
||||
{ name: 'photos', type: 'jsonb', default: "'[]'::jsonb" },
|
||||
{ name: 'reported_by', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'reported_at', type: 'timestamptz' },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_booking', columnNames: ['booking_id'] }));
|
||||
await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_container', columnNames: ['container_number'] }));
|
||||
await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_type', columnNames: ['incident_type'] }));
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'import_customs_finalizations',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'booking_id', type: 'uuid', isUnique: true },
|
||||
{ name: 'documents', type: 'jsonb', default: "'{}'::jsonb" },
|
||||
{ name: 'declaration_serial_number', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'duties_taxes_notified_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'duties_taxes_paid_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'customs_risk', type: 'varchar', length: '12', isNullable: true },
|
||||
{ name: 'import_release_permitted_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'completed_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'performed_by', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'notes', type: 'text', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createIndex('freight.import_customs_finalizations', new TableIndex({ name: 'idx_import_customs_booking', columnNames: ['booking_id'] }));
|
||||
await queryRunner.createIndex('freight.import_customs_finalizations', new TableIndex({ name: 'idx_import_customs_risk', columnNames: ['customs_risk'] }));
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'empty_container_returns',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'container_number', type: 'varchar', length: '80' },
|
||||
{ name: 'booking_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'customer_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'return_date', type: 'timestamptz' },
|
||||
{ name: 'facility', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'yard', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'zone', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'condition', type: 'text', isNullable: true },
|
||||
{ name: 'handover_note', type: 'text', isNullable: true },
|
||||
{ name: 'status', type: 'varchar', length: '40', default: "'RETURNED'" },
|
||||
{ name: 'wagon_allocation_reference', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'performed_by', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_container', columnNames: ['container_number'] }));
|
||||
await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_booking', columnNames: ['booking_id'] }));
|
||||
await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_status', columnNames: ['status'] }));
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.empty_container_returns', true);
|
||||
await queryRunner.dropTable('freight.import_customs_finalizations', true);
|
||||
await queryRunner.dropTable('freight.djibouti_import_incidents', true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Cutover cleanup (docs/new-doc.md §17 Phase 4). Runs AFTER the backfill
|
||||
* (1823…) so every legacy general contract + drawdown already lives in the
|
||||
* `contracts` aggregate.
|
||||
*
|
||||
* Drops the now-unused booking-as-contract artifacts:
|
||||
* - `bookings.booking_type` (every booking is a real shipment now)
|
||||
* - `bookings.previous_contract_id` (renewal lives on `contracts.renewal_of_id`)
|
||||
* - the `booking_orders` / `booking_order_lines` drawdown ledger
|
||||
* - `contract_route_lines` (superseded by `contract_routes`)
|
||||
*
|
||||
* The shipment/payment/scheduling/allocation columns on `bookings` are kept —
|
||||
* the operational pipeline is unchanged.
|
||||
*/
|
||||
export class DropLegacyContractTables1824000000000 implements MigrationInterface {
|
||||
name = 'DropLegacyContractTables1824000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// booking_order_lines references booking_orders → drop child first.
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_order_lines CASCADE;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_orders CASCADE;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_route_lines CASCADE;`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS booking_type;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS previous_contract_id;`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Re-add the dropped columns (data is not restored — this is a one-way cutover).
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS booking_type VARCHAR(20) DEFAULT 'ONE_TIME';`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS previous_contract_id UUID;`,
|
||||
);
|
||||
// The legacy ledger/route tables are intentionally NOT recreated here; restore
|
||||
// from a backup if a rollback past the cutover is ever required.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Global Logistics Phase-2 operational features (docs/new-doc.md §11–§13, gap
|
||||
* matrix #14/#16/#17/#18):
|
||||
* - `clearance_milestones.metadata` — structured payload for RISK_ASSIGNED
|
||||
* (risk level) and DUTY_TAXES_ADVISED (amount, currency, declaration serial)
|
||||
* - `bookings.gl_station_yard_id` / `gl_assigned_staff_id` / `gl_assigned_at`
|
||||
* — station routing + staff binding (GL US-02)
|
||||
* - `freight.clearance_incidents` — cargo exception/damage reports with photos
|
||||
* (GL Import US-07)
|
||||
*/
|
||||
export class AddGlOperations1825000000000 implements MigrationInterface {
|
||||
name = 'AddGlOperations1825000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.clearance_milestones ADD COLUMN IF NOT EXISTS metadata JSONB;`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS gl_station_yard_id UUID;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS gl_assigned_staff_id UUID;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS gl_assigned_at TIMESTAMPTZ;`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.clearance_incidents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||
incident_type VARCHAR(32) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
photo_file_ids JSONB NOT NULL DEFAULT '[]',
|
||||
reported_by_user_id UUID,
|
||||
reported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_clearance_incidents_booking ON freight.clearance_incidents(booking_id);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.clearance_incidents CASCADE;`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS gl_assigned_at;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS gl_assigned_staff_id;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS gl_station_yard_id;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.clearance_milestones DROP COLUMN IF EXISTS metadata;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Create the freight.booking_container_allocations table — container-to-vehicle
|
||||
* allocation mapping for flexible routing of containers across available vehicles.
|
||||
*/
|
||||
export class CreateBookingContainerAllocations1825000000000 implements MigrationInterface {
|
||||
name = 'CreateBookingContainerAllocations1825000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const exists = await queryRunner.hasTable('freight.booking_container_allocations');
|
||||
if (exists) return;
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'freight.booking_container_allocations',
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'uuid',
|
||||
isPrimary: true,
|
||||
default: 'gen_random_uuid()',
|
||||
},
|
||||
{ name: 'booking_id', type: 'uuid', isNullable: false },
|
||||
{ name: 'container_id', type: 'uuid', isNullable: false },
|
||||
{ name: 'vehicle_id', type: 'uuid', isNullable: true },
|
||||
{
|
||||
name: 'container_type',
|
||||
type: 'text',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'quantity',
|
||||
type: 'integer',
|
||||
default: 1,
|
||||
isNullable: false,
|
||||
},
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.booking_container_allocations',
|
||||
new TableForeignKey({
|
||||
columnNames: ['booking_id'],
|
||||
referencedTableName: 'freight.bookings',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.booking_container_allocations',
|
||||
new TableForeignKey({
|
||||
columnNames: ['vehicle_id'],
|
||||
referencedTableName: 'freight.vehicles',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'SET NULL',
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_booking_container_allocations_booking_id" ON "freight"."booking_container_allocations" ("booking_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_booking_container_allocations_vehicle_id" ON "freight"."booking_container_allocations" ("vehicle_id")`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const exists = await queryRunner.hasTable('freight.booking_container_allocations');
|
||||
if (exists) {
|
||||
await queryRunner.dropTable('freight.booking_container_allocations');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* GENERAL contracts can be booked repeatedly until a total cargo quantity cap is
|
||||
* reached (e.g. 100 containers across many shipments). `quantity_cap` on each
|
||||
* cargo-scope line holds that ceiling (containers per size, or tons/items for
|
||||
* bulk). NULL = uncapped; always NULL for ONE_TIME (single booking).
|
||||
*/
|
||||
export class AddCargoScopeQuantityCap1826000000000 implements MigrationInterface {
|
||||
name = 'AddCargoScopeQuantityCap1826000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_cargo_scope ADD COLUMN IF NOT EXISTS quantity_cap NUMERIC(12,2);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_cargo_scope DROP COLUMN IF EXISTS quantity_cap;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Customer shipment requests for GENERAL customs (Path B) contracts. The customer
|
||||
* submits date + quantities; Global Logistics reviews, then creates the booking
|
||||
* on their behalf and per-booking clearance begins. Additive — no change to
|
||||
* existing tables; ONE_TIME contracts are unaffected.
|
||||
*/
|
||||
export class CreateBookingRequests1827000000000 implements MigrationInterface {
|
||||
name = 'CreateBookingRequests1827000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'booking_requests',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||
{ name: 'reference', type: 'varchar', length: '40', default: "''" },
|
||||
{ name: 'contract_id', type: 'uuid' },
|
||||
{ name: 'requested_by_user_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'contract_route_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'scheduled_date', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'status', type: 'varchar', length: '16', default: "'PENDING'" },
|
||||
{ name: 'requested_lines', type: 'jsonb', default: "'{}'::jsonb" },
|
||||
{ name: 'notes', type: 'text', isNullable: true },
|
||||
{ name: 'created_booking_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'reviewed_by_staff_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'reviewed_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'review_note', type: 'text', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
columnNames: ['contract_id'],
|
||||
referencedSchema: 'freight',
|
||||
referencedTableName: 'contracts',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
{
|
||||
columnNames: ['created_booking_id'],
|
||||
referencedSchema: 'freight',
|
||||
referencedTableName: 'bookings',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'freight.booking_requests',
|
||||
new TableIndex({ name: 'idx_booking_requests_contract', columnNames: ['contract_id'] }),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.booking_requests',
|
||||
new TableIndex({ name: 'idx_booking_requests_status', columnNames: ['status'] }),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.booking_requests',
|
||||
new TableIndex({
|
||||
name: 'idx_booking_requests_contract_status',
|
||||
columnNames: ['contract_id', 'status'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.booking_requests', true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Bulk / break-bulk freight can now declare HOW MUCH of the cargo is hazardous
|
||||
* or refrigerated, in the cargo's own unit of measure (tons for PER_TON, item
|
||||
* count for PER_ITEM). These two columns hold that amount on the booking; they
|
||||
* stay 0 for container freight (which tracks it per line on booking_container)
|
||||
* and for bulk cargo with no hazardous/reefer portion. The existing
|
||||
* is_hazardous / is_reefer booleans remain the surcharge trigger.
|
||||
*/
|
||||
export class AddBulkHazmatReeferQuantity1828000000000 implements MigrationInterface {
|
||||
name = 'AddBulkHazmatReeferQuantity1828000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_hazardous_quantity NUMERIC(12,3) NOT NULL DEFAULT 0;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_reefer_quantity NUMERIC(12,3) NOT NULL DEFAULT 0;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_reefer_quantity;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_hazardous_quantity;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddGrnNumberToWarehouseInventory1828000000000 implements MigrationInterface {
|
||||
name = 'AddGrnNumberToWarehouseInventory1828000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_inventory
|
||||
ADD COLUMN IF NOT EXISTS grn_number VARCHAR(100) NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.warehouse_inventory
|
||||
SET grn_number = substring(notes FROM 'GRN Number: ([^\\n\\r]+)')
|
||||
WHERE grn_number IS NULL
|
||||
AND notes IS NOT NULL
|
||||
AND notes ~ 'GRN Number: '
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_grn_number
|
||||
ON freight.warehouse_inventory(grn_number)
|
||||
WHERE grn_number IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_grn_number`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_inventory
|
||||
DROP COLUMN IF EXISTS grn_number
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Extend `freight.invoices` into the billing record of record for every source
|
||||
* (booking, demurrage, warehouse fees, …) so warehouse fee invoices can be
|
||||
* centralized onto it instead of the parallel `warehouse_fee_invoices` table.
|
||||
*
|
||||
* Adds money tracking that supports partial payment (`subtotal/tax/paid/balance`),
|
||||
* a `paid_at` stamp, a `payments` jsonb ledger, and the `ISSUED` / `PARTIALLY_PAID`
|
||||
* statuses the warehouse flow uses.
|
||||
*
|
||||
* Matches billing/entities/invoice.entity.ts. All columns are additive with
|
||||
* defaults, so existing booking/demurrage rows are unaffected.
|
||||
*/
|
||||
export class ExtendInvoicesForPartialPayment1828000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "ExtendInvoicesForPartialPayment1828000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// New statuses. ADD VALUE is non-transactional-value-safe on PG 12+ as long
|
||||
// as the value is not referenced in the same transaction (it is not here).
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING';`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID';`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS paid_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]';
|
||||
`);
|
||||
|
||||
// Backfill existing rows: subtotal mirrors the total (no tax was modeled),
|
||||
// the outstanding balance is the full total for unpaid invoices.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.invoices
|
||||
SET subtotal_amount = total_amount,
|
||||
balance_amount = total_amount;
|
||||
`);
|
||||
|
||||
// Already-settled invoices: fully paid, zero balance, stamped from updated_at.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.invoices
|
||||
SET paid_amount = total_amount,
|
||||
balance_amount = 0,
|
||||
paid_at = updated_at
|
||||
WHERE status = 'PAID';
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP COLUMN IF EXISTS payments,
|
||||
DROP COLUMN IF EXISTS paid_at,
|
||||
DROP COLUMN IF EXISTS balance_amount,
|
||||
DROP COLUMN IF EXISTS paid_amount,
|
||||
DROP COLUMN IF EXISTS tax_amount,
|
||||
DROP COLUMN IF EXISTS subtotal_amount;
|
||||
`);
|
||||
// Postgres cannot drop individual enum values; ISSUED / PARTIALLY_PAID are
|
||||
// left on freight.invoices_status_enum (harmless, unused after down).
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Fold warehouse fee invoices into the central billing system.
|
||||
*
|
||||
* Warehouse fee invoices are no longer a standalone aggregate: each becomes a
|
||||
* global `freight.invoices` row (`source = 'warehouse'`, `source_id =
|
||||
* inventory_id`) with its items as `freight.invoice_lines`. The warehouse
|
||||
* service is now a thin layer over `BillingService`. This migration backfills the
|
||||
* existing rows (preserving ids, numbers, status, amounts and payment history),
|
||||
* then drops the two legacy tables.
|
||||
*
|
||||
* Rows that cannot be billed centrally — no company to bill (`company_id` /
|
||||
* `company_profile_id` underivable from the customer or the booking) — are not
|
||||
* migrated; they could never have been charged through the gateway and are
|
||||
* dropped with the table.
|
||||
*/
|
||||
export class CentralizeWarehouseInvoices1829000000000 implements MigrationInterface {
|
||||
name = 'CentralizeWarehouseInvoices1829000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'freight'
|
||||
AND table_name = 'invoices'
|
||||
AND column_name = 'booking_id'
|
||||
) THEN
|
||||
ALTER TABLE freight.invoices ALTER COLUMN booking_id DROP NOT NULL;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'freight'
|
||||
AND table_name = 'invoices'
|
||||
AND column_name = 'amount'
|
||||
) THEN
|
||||
ALTER TABLE freight.invoices ALTER COLUMN amount DROP NOT NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// 1. Invoice headers. Keep the same id so items still link, and so any
|
||||
// external reference to the invoice id stays valid.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.invoices (
|
||||
id, invoice_number, company_id, company_profile_id,
|
||||
subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount,
|
||||
currency, status, source, source_id, type,
|
||||
issued_at, paid_at, payments, payment_id, due_at,
|
||||
created_at, updated_at, deleted_at
|
||||
)
|
||||
SELECT
|
||||
fee.id,
|
||||
fee.invoice_number,
|
||||
COALESCE(fee.customer_id, b.company_id),
|
||||
COALESCE(
|
||||
b.company_profile_id,
|
||||
(SELECT cp.id
|
||||
FROM freight.company_profiles cp
|
||||
WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id)
|
||||
AND cp.deleted_at IS NULL
|
||||
ORDER BY cp.created_at ASC
|
||||
LIMIT 1)
|
||||
),
|
||||
fee.subtotal_amount, fee.tax_amount, fee.total_amount, fee.paid_amount, fee.balance_amount,
|
||||
fee.currency,
|
||||
fee.status::freight.invoices_status_enum,
|
||||
'warehouse',
|
||||
fee.inventory_id,
|
||||
fee.invoice_type,
|
||||
fee.issued_at,
|
||||
fee.paid_at,
|
||||
COALESCE(fee.payments, '[]'::jsonb),
|
||||
NULL,
|
||||
COALESCE(fee.due_date, fee.issued_at, fee.created_at),
|
||||
fee.created_at, fee.updated_at, fee.deleted_at
|
||||
FROM freight.warehouse_fee_invoices fee
|
||||
LEFT JOIN freight.bookings b ON b.id = fee.booking_id
|
||||
WHERE COALESCE(fee.customer_id, b.company_id) IS NOT NULL
|
||||
AND COALESCE(
|
||||
b.company_profile_id,
|
||||
(SELECT cp.id
|
||||
FROM freight.company_profiles cp
|
||||
WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id)
|
||||
AND cp.deleted_at IS NULL
|
||||
ORDER BY cp.created_at ASC
|
||||
LIMIT 1)
|
||||
) IS NOT NULL
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
`);
|
||||
|
||||
// 2. Invoice lines — only for items whose parent invoice migrated. Warehouse
|
||||
// fee fields (fee_rule_id / chargeable_days / free_days) move into the
|
||||
// line's jsonb metadata.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.invoice_lines (
|
||||
id, invoice_id, charge_type, description, quantity, unit_rate, amount,
|
||||
currency, metadata, created_at, updated_at, deleted_at
|
||||
)
|
||||
SELECT
|
||||
item.id,
|
||||
item.invoice_id,
|
||||
item.fee_type,
|
||||
item.description,
|
||||
item.quantity,
|
||||
item.unit_rate,
|
||||
item.amount,
|
||||
item.currency,
|
||||
jsonb_build_object(
|
||||
'feeRuleId', item.fee_rule_id,
|
||||
'chargeableDays', item.chargeable_days,
|
||||
'freeDays', item.free_days
|
||||
),
|
||||
item.created_at, item.updated_at, item.deleted_at
|
||||
FROM freight.warehouse_fee_invoice_items item
|
||||
JOIN freight.invoices i ON i.id = item.invoice_id AND i.source = 'warehouse'
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
`);
|
||||
|
||||
// 3. Drop the legacy tables (items first — FK to invoices).
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoice_items;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoices;`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Recreate the legacy tables …
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoices (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
invoice_number varchar(40) NOT NULL,
|
||||
booking_id uuid,
|
||||
customer_id uuid,
|
||||
inventory_id uuid NOT NULL,
|
||||
facility_id uuid,
|
||||
warehouse_id uuid,
|
||||
yard_id uuid,
|
||||
zone_id uuid,
|
||||
invoice_type varchar(32) NOT NULL DEFAULT 'MIXED_WAREHOUSE_FEES',
|
||||
status varchar(20) NOT NULL DEFAULT 'DRAFT',
|
||||
subtotal_amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||
tax_amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||
total_amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||
paid_amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||
balance_amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||
currency varchar(8) NOT NULL DEFAULT 'USD',
|
||||
period_start timestamptz,
|
||||
period_end timestamptz,
|
||||
issued_at timestamptz,
|
||||
due_date timestamptz,
|
||||
paid_at timestamptz,
|
||||
cancelled_at timestamptz,
|
||||
payments jsonb NOT NULL DEFAULT '[]',
|
||||
notes text,
|
||||
CONSTRAINT "PK_warehouse_fee_invoices" PRIMARY KEY (id),
|
||||
CONSTRAINT "UQ_warehouse_fee_invoices_invoice_number" UNIQUE (invoice_number)
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_booking_id" ON freight.warehouse_fee_invoices (booking_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_inventory_id" ON freight.warehouse_fee_invoices (inventory_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_status" ON freight.warehouse_fee_invoices (status);`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoice_items (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
invoice_id uuid NOT NULL,
|
||||
fee_rule_id uuid,
|
||||
fee_type varchar(32) NOT NULL,
|
||||
description varchar(255) NOT NULL,
|
||||
quantity numeric(12,2) NOT NULL DEFAULT 1,
|
||||
unit_rate numeric(14,2) NOT NULL DEFAULT 0,
|
||||
amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||
currency varchar(8) NOT NULL DEFAULT 'USD',
|
||||
chargeable_days int,
|
||||
free_days int,
|
||||
CONSTRAINT "PK_warehouse_fee_invoice_items" PRIMARY KEY (id),
|
||||
CONSTRAINT "FK_warehouse_fee_invoice_items_invoice"
|
||||
FOREIGN KEY (invoice_id) REFERENCES freight.warehouse_fee_invoices (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoice_items_invoice_id" ON freight.warehouse_fee_invoice_items (invoice_id);`,
|
||||
);
|
||||
|
||||
// … then copy the warehouse-source invoices back, deriving the typed FKs and
|
||||
// period from the linked inventory item.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.warehouse_fee_invoices (
|
||||
id, created_at, updated_at, deleted_at, invoice_number,
|
||||
booking_id, customer_id, inventory_id, facility_id, warehouse_id, yard_id, zone_id,
|
||||
invoice_type, status, subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount,
|
||||
currency, period_start, period_end, issued_at, due_date, paid_at, cancelled_at, payments, notes
|
||||
)
|
||||
SELECT
|
||||
i.id, i.created_at, i.updated_at, i.deleted_at, i.invoice_number,
|
||||
inv.booking_id, i.company_id, i.source_id, w.facility_id, inv.warehouse_id, inv.yard_id, inv.zone_id,
|
||||
i.type, i.status::text, i.subtotal_amount, i.tax_amount, i.total_amount, i.paid_amount, i.balance_amount,
|
||||
i.currency, inv.arrived_at, i.issued_at, i.issued_at, i.due_at, i.paid_at,
|
||||
CASE WHEN i.status::text = 'CANCELLED' THEN i.updated_at ELSE NULL END,
|
||||
i.payments, NULL
|
||||
FROM freight.invoices i
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
WHERE i.source = 'warehouse'
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.warehouse_fee_invoice_items (
|
||||
id, created_at, updated_at, deleted_at, invoice_id, fee_rule_id, fee_type,
|
||||
description, quantity, unit_rate, amount, currency, chargeable_days, free_days
|
||||
)
|
||||
SELECT
|
||||
l.id, l.created_at, l.updated_at, l.deleted_at, l.invoice_id,
|
||||
NULLIF(l.metadata->>'feeRuleId', '')::uuid,
|
||||
l.charge_type,
|
||||
COALESCE(l.description, ''),
|
||||
l.quantity, l.unit_rate, l.amount, l.currency,
|
||||
NULLIF(l.metadata->>'chargeableDays', '')::int,
|
||||
NULLIF(l.metadata->>'freeDays', '')::int
|
||||
FROM freight.invoice_lines l
|
||||
JOIN freight.invoices i ON i.id = l.invoice_id AND i.source = 'warehouse'
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
`);
|
||||
|
||||
// Remove the migrated rows from the central tables.
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.invoice_lines
|
||||
WHERE invoice_id IN (SELECT id FROM freight.invoices WHERE source = 'warehouse');
|
||||
`);
|
||||
await queryRunner.query(`DELETE FROM freight.invoices WHERE source = 'warehouse';`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class PhasedClearanceCycleMeta1829000000000 implements MigrationInterface {
|
||||
name = 'PhasedClearanceCycleMeta1829000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS duty_required BOOLEAN;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS vessel_departure_date DATE;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS ro_amendment_requested_at TIMESTAMPTZ;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS ro_hold_reason TEXT;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS current_phase VARCHAR(40);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS duty_required;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS vessel_departure_date;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS ro_amendment_requested_at;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS ro_hold_reason;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS current_phase;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/** Admin-configurable minimum days between today and export RO vessel departure. */
|
||||
export class SeedRoVesselMinDays1829000000001 implements MigrationInterface {
|
||||
name = 'SeedRoVesselMinDays1829000000001';
|
||||
private readonly code = 'ro_vessel_min_days';
|
||||
private readonly options: Array<{ value: string; label: string }> = [
|
||||
{ value: '2', label: '2 days' },
|
||||
{ value: '3', label: '3 days' },
|
||||
];
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const existing = await queryRunner.query(
|
||||
`SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`,
|
||||
[this.code],
|
||||
);
|
||||
if (existing.length > 0) return;
|
||||
|
||||
const inserted = await queryRunner.query(
|
||||
`INSERT INTO freight.dropdown_settings (code, label, description, multiple)
|
||||
VALUES ($1, $2, $3, false)
|
||||
RETURNING id;`,
|
||||
[
|
||||
this.code,
|
||||
'RO vessel minimum lead time (days)',
|
||||
'Minimum days between today and the vessel departure date on an export Release Order.',
|
||||
],
|
||||
);
|
||||
const settingId = inserted[0].id;
|
||||
|
||||
for (let i = 0; i < this.options.length; i++) {
|
||||
const opt = this.options[i];
|
||||
await queryRunner.query(
|
||||
`INSERT INTO freight.dropdown_options (setting_id, value, label, display_order)
|
||||
VALUES ($1, $2, $3, $4);`,
|
||||
[settingId, opt.value, opt.label, i],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DELETE FROM freight.dropdown_settings WHERE code = $1;`, [
|
||||
this.code,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class BookingClearanceMeta1829000000002 implements MigrationInterface {
|
||||
name = 'BookingClearanceMeta1829000000002';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS clearance_current_phase VARCHAR(40);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS duty_required BOOLEAN;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS vessel_departure_date DATE;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS ro_amendment_requested_at TIMESTAMPTZ;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS ro_hold_reason TEXT;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS clearance_current_phase;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS duty_required;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS vessel_departure_date;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS ro_amendment_requested_at;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS ro_hold_reason;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add the `EXPIRED` invoice status. An invoice expires when its source's pay
|
||||
* window closes before settlement (e.g. a booking whose `paymentDeadline`
|
||||
* lapses) — driven event-style from the domain via `BillingService.expirePayable`,
|
||||
* which emits `${source}.invoice.expired`. Terminal and not settle-able (kept out
|
||||
* of `OPEN_STATUSES`), so it is distinct from `CANCELLED` (manual void) and
|
||||
* `OVERDUE` (still payable).
|
||||
*
|
||||
* Matches Freight.InvoiceStatus in packages/types. ADD VALUE only — additive and
|
||||
* not referenced in this same transaction, so it is PG 12+ safe.
|
||||
*/
|
||||
export class AddExpiredInvoiceStatus1830000000000 implements MigrationInterface {
|
||||
name = "AddExpiredInvoiceStatus1830000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'EXPIRED' AFTER 'REFUNDED';`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// Postgres cannot drop individual enum values; EXPIRED is left on
|
||||
// freight.invoices_status_enum (harmless, unused after down).
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Create freight.first_mile_container_allocations table — tracks
|
||||
* container allocations per first-mile shipment with optional vehicle assignment.
|
||||
*/
|
||||
export class CreateFirstMileContainerAllocations1830000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const exists = await queryRunner.hasTable('freight.first_mile_container_allocations');
|
||||
if (exists) return;
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'freight.first_mile_container_allocations',
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'uuid',
|
||||
isPrimary: true,
|
||||
default: 'gen_random_uuid()',
|
||||
},
|
||||
{ name: 'first_mile_id', type: 'uuid', isNullable: false },
|
||||
{ name: 'container_id', type: 'uuid', isNullable: false },
|
||||
{ name: 'vehicle_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'container_type', type: 'text', isNullable: false },
|
||||
{
|
||||
name: 'quantity',
|
||||
type: 'int',
|
||||
default: 1,
|
||||
isNullable: false,
|
||||
},
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.first_mile_container_allocations',
|
||||
new TableForeignKey({
|
||||
columnNames: ['first_mile_id'],
|
||||
referencedTableName: 'freight.first_mile',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.first_mile_container_allocations',
|
||||
new TableForeignKey({
|
||||
columnNames: ['vehicle_id'],
|
||||
referencedTableName: 'freight.vehicles',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'SET NULL',
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_first_mile_container_allocations_first_mile_id" ON "freight"."first_mile_container_allocations" ("first_mile_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_first_mile_container_allocations_vehicle_id" ON "freight"."first_mile_container_allocations" ("vehicle_id")`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const exists = await queryRunner.hasTable('freight.first_mile_container_allocations');
|
||||
if (exists) {
|
||||
await queryRunner.dropTable('freight.first_mile_container_allocations');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class DropCargoTypeShowFreeTextBox1830000000000 implements MigrationInterface {
|
||||
name = 'DropCargoTypeShowFreeTextBox1830000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargo_types
|
||||
DROP COLUMN IF EXISTS show_free_text_box
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargo_types
|
||||
ADD COLUMN IF NOT EXISTS show_free_text_box boolean NOT NULL DEFAULT false
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class RouteStatusAndSegmentKm1830000000001 implements MigrationInterface {
|
||||
name = 'RouteStatusAndSegmentKm1830000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes
|
||||
ADD COLUMN IF NOT EXISTS status varchar(32) NOT NULL DEFAULT 'AVAILABLE'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.routes
|
||||
SET status = CASE WHEN is_active = true THEN 'AVAILABLE' ELSE 'STOP_WORKING' END
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight."IDX_routes_name"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes DROP COLUMN IF EXISTS name
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes DROP COLUMN IF EXISTS is_active
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_routes_status" ON freight.routes (status)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.route_milestones
|
||||
ADD COLUMN IF NOT EXISTS distance_km numeric(10,2)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.route_milestones DROP COLUMN IF EXISTS distance_km
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes
|
||||
ADD COLUMN IF NOT EXISTS name varchar(120)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.routes SET name = id::text WHERE name IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes ALTER COLUMN name SET NOT NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes
|
||||
ADD COLUMN IF NOT EXISTS is_active boolean NOT NULL DEFAULT true
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.routes
|
||||
SET is_active = CASE WHEN status = 'AVAILABLE' THEN true ELSE false END
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes DROP COLUMN IF EXISTS status
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight."IDX_routes_status"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_routes_name" ON freight.routes (name)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class PreClearanceFinalizedAt1830000000002 implements MigrationInterface {
|
||||
name = 'PreClearanceFinalizedAt1830000000002';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at TIMESTAMPTZ;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at TIMESTAMPTZ;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS pre_clearance_finalized_at;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS pre_clearance_finalized_at;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddWarehouseFeeRuleTiers1831000000000 implements MigrationInterface {
|
||||
name = 'AddWarehouseFeeRuleTiers1831000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_fee_rules
|
||||
ADD COLUMN IF NOT EXISTS tiers jsonb NOT NULL DEFAULT '[]';
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_fee_rules
|
||||
DROP COLUMN IF EXISTS tiers;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddCustomerTruckAssignmentToBookings1832000000000 implements MigrationInterface {
|
||||
name = 'AddCustomerTruckAssignmentToBookings1832000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_plate_number varchar(32),
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_driver_name varchar(120),
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_type varchar(60),
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_container_number varchar(16),
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_assigned_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS customer_truck_arrived_at timestamptz
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS customer_truck_arrived_at,
|
||||
DROP COLUMN IF EXISTS customer_truck_assigned_at,
|
||||
DROP COLUMN IF EXISTS customer_truck_container_number,
|
||||
DROP COLUMN IF EXISTS customer_truck_type,
|
||||
DROP COLUMN IF EXISTS customer_truck_driver_name,
|
||||
DROP COLUMN IF EXISTS customer_truck_plate_number
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class CreateFuelTables1840000000000 implements MigrationInterface {
|
||||
name = "CreateFuelTables1840000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const fuelPurchasesExists = await queryRunner.query(
|
||||
`SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_purchases';`,
|
||||
);
|
||||
|
||||
if (!fuelPurchasesExists.length) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.fuel_purchases (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
vehicle_id uuid NOT NULL,
|
||||
purchase_date timestamptz NOT NULL,
|
||||
liters numeric(10, 2) NOT NULL,
|
||||
cost_per_liter numeric(10, 2) NOT NULL,
|
||||
total_cost numeric(14, 2) NOT NULL,
|
||||
fuel_station varchar(255) NULL,
|
||||
payment_method varchar(50) DEFAULT 'CASH',
|
||||
odometer_reading numeric(10, 2) NULL,
|
||||
driver_id uuid NULL,
|
||||
receipt_number varchar(255) NULL,
|
||||
notes text NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL,
|
||||
CONSTRAINT pk_fuel_purchases PRIMARY KEY (id),
|
||||
CONSTRAINT fk_fuel_purchases_vehicle FOREIGN KEY (vehicle_id)
|
||||
REFERENCES freight.vehicles (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_fuel_purchases_vehicle ON freight.fuel_purchases (vehicle_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_fuel_purchases_date ON freight.fuel_purchases (purchase_date);`,
|
||||
);
|
||||
}
|
||||
|
||||
const fuelConsumptionExists = await queryRunner.query(
|
||||
`SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_consumption';`,
|
||||
);
|
||||
|
||||
if (!fuelConsumptionExists.length) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.fuel_consumption (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
vehicle_id uuid NOT NULL,
|
||||
month date NOT NULL,
|
||||
total_liters numeric(10, 2) NOT NULL,
|
||||
total_cost numeric(14, 2) NOT NULL,
|
||||
total_distance_km numeric(10, 2) NOT NULL,
|
||||
fuel_efficiency_km_per_l numeric(10, 2) NULL,
|
||||
number_of_purchases integer DEFAULT 0,
|
||||
average_cost_per_liter numeric(10, 2) NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL,
|
||||
CONSTRAINT pk_fuel_consumption PRIMARY KEY (id),
|
||||
CONSTRAINT fk_fuel_consumption_vehicle FOREIGN KEY (vehicle_id)
|
||||
REFERENCES freight.vehicles (id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_fuel_consumption_vehicle_month UNIQUE (vehicle_id, month)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_fuel_consumption_vehicle_month ON freight.fuel_consumption (vehicle_id, month);`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_consumption;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_purchases;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateMaintenanceTables1850000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Create maintenance_schedules table
|
||||
const scheduleTableExists = await queryRunner.query(`
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'freight' AND table_name = 'maintenance_schedules'
|
||||
)
|
||||
`);
|
||||
|
||||
if (!scheduleTableExists[0].exists) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "freight"."maintenance_schedules" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"vehicle_id" uuid NOT NULL,
|
||||
"maintenance_type" varchar NOT NULL,
|
||||
"description" varchar NOT NULL,
|
||||
"scheduled_date" timestamptz NOT NULL,
|
||||
"completed_date" timestamptz,
|
||||
"estimated_cost" numeric(14,2),
|
||||
"actual_cost" numeric(14,2),
|
||||
"status" varchar NOT NULL DEFAULT 'SCHEDULED',
|
||||
"odometer_reading" numeric,
|
||||
"service_provider" varchar,
|
||||
"notes" text,
|
||||
"next_due_km" numeric,
|
||||
"next_due_date" timestamptz,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"deleted_at" timestamptz,
|
||||
PRIMARY KEY ("id")
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "idx_maintenance_schedules_vehicle_date" ON "freight"."maintenance_schedules" ("vehicle_id", "scheduled_date")`
|
||||
);
|
||||
}
|
||||
|
||||
// Create maintenance_costs table
|
||||
const costsTableExists = await queryRunner.query(`
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'freight' AND table_name = 'maintenance_costs'
|
||||
)
|
||||
`);
|
||||
|
||||
if (!costsTableExists[0].exists) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "freight"."maintenance_costs" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"vehicle_id" uuid NOT NULL,
|
||||
"maintenance_schedule_id" uuid,
|
||||
"incurred_date" timestamptz NOT NULL,
|
||||
"cost_amount" numeric(14,2) NOT NULL,
|
||||
"cost_type" varchar NOT NULL,
|
||||
"description" varchar NOT NULL,
|
||||
"service_provider" varchar,
|
||||
"invoice_number" varchar,
|
||||
"notes" text,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"deleted_at" timestamptz,
|
||||
PRIMARY KEY ("id"),
|
||||
CONSTRAINT "fk_maintenance_schedule" FOREIGN KEY ("maintenance_schedule_id")
|
||||
REFERENCES "freight"."maintenance_schedules" ("id") ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "idx_maintenance_costs_vehicle_date" ON "freight"."maintenance_costs" ("vehicle_id", "incurred_date")`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_costs"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_schedules"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add paid column to first_mile and last_mile tables to track invoice payment status.
|
||||
*/
|
||||
export class AddPaidToFirstAndLastMile1860000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddPaidToFirstAndLastMile1860000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.first_mile
|
||||
ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile
|
||||
ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.first_mile
|
||||
DROP COLUMN IF EXISTS paid;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile
|
||||
DROP COLUMN IF EXISTS paid;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddBookingWindowGlobalRules1861000000000 implements MigrationInterface {
|
||||
name = "AddBookingWindowGlobalRules1861000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
ADD COLUMN import_window_lead_days integer NOT NULL DEFAULT 3,
|
||||
ADD COLUMN export_booking_lead_hours integer NOT NULL DEFAULT 24,
|
||||
ADD COLUMN window_open_hour integer NOT NULL DEFAULT 8,
|
||||
ADD COLUMN window_duration_hours numeric(4, 2) NOT NULL DEFAULT 3,
|
||||
ADD COLUMN doc_review_minutes integer NOT NULL DEFAULT 30,
|
||||
ADD COLUMN payment_window_minutes integer NOT NULL DEFAULT 60,
|
||||
ADD COLUMN reopen_delay_minutes integer NOT NULL DEFAULT 90;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
DROP COLUMN IF EXISTS import_window_lead_days,
|
||||
DROP COLUMN IF EXISTS export_booking_lead_hours,
|
||||
DROP COLUMN IF EXISTS window_open_hour,
|
||||
DROP COLUMN IF EXISTS window_duration_hours,
|
||||
DROP COLUMN IF EXISTS doc_review_minutes,
|
||||
DROP COLUMN IF EXISTS payment_window_minutes,
|
||||
DROP COLUMN IF EXISTS reopen_delay_minutes;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* arriveSchedule used to release only the primary locomotive of a train set, leaving
|
||||
* secondary locomotives ASSIGNED forever. Locomotives are now only ASSIGNED while out
|
||||
* on a dispatched train — release every ASSIGNED locomotive that is not attached to a
|
||||
* currently-DISPATCHED schedule.
|
||||
*/
|
||||
export class ReleaseStuckAssignedLocomotives1861000000001 implements MigrationInterface {
|
||||
name = "ReleaseStuckAssignedLocomotives1861000000001";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.locomotives l
|
||||
SET status = 'AVAILABLE'
|
||||
WHERE l.status = 'ASSIGNED'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
JOIN (
|
||||
SELECT tsl.train_set_id, tsl.locomotive_id
|
||||
FROM freight.train_set_locomotives tsl
|
||||
WHERE tsl.deleted_at IS NULL
|
||||
UNION
|
||||
SELECT t.id AS train_set_id, t.locomotive_id
|
||||
FROM freight.train_sets t
|
||||
WHERE t.locomotive_id IS NOT NULL
|
||||
) loco ON loco.train_set_id = tset.id
|
||||
WHERE ts.status = 'DISPATCHED'
|
||||
AND ts.deleted_at IS NULL
|
||||
AND loco.locomotive_id = l.id
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// Data fix — not reversible.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddScheduleWindowPhases1862000000000 implements MigrationInterface {
|
||||
name = "AddScheduleWindowPhases1862000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN window_phase varchar(20) NULL,
|
||||
ADD COLUMN window_opens_at timestamptz NULL,
|
||||
ADD COLUMN window_closes_at timestamptz NULL,
|
||||
ADD COLUMN doc_review_ends_at timestamptz NULL,
|
||||
ADD COLUMN doc_review_completed_at timestamptz NULL,
|
||||
ADD COLUMN payment_phase_ends_at timestamptz NULL,
|
||||
ADD COLUMN booking_cycle_no integer NOT NULL DEFAULT 0;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX idx_train_schedules_window_phase
|
||||
ON freight.train_schedules (window_phase)
|
||||
WHERE window_phase IS NOT NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_schedules_window_phase;`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP COLUMN IF EXISTS window_phase,
|
||||
DROP COLUMN IF EXISTS window_opens_at,
|
||||
DROP COLUMN IF EXISTS window_closes_at,
|
||||
DROP COLUMN IF EXISTS doc_review_ends_at,
|
||||
DROP COLUMN IF EXISTS doc_review_completed_at,
|
||||
DROP COLUMN IF EXISTS payment_phase_ends_at,
|
||||
DROP COLUMN IF EXISTS booking_cycle_no;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class CreateBookingBatchOffers1863000000000 implements MigrationInterface {
|
||||
name = "CreateBookingBatchOffers1863000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.booking_batch_offers (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||
train_schedule_id uuid NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
|
||||
offered_wagons integer NOT NULL,
|
||||
total_wagons integer NOT NULL,
|
||||
offered_lines jsonb NULL,
|
||||
offered_weight_tons numeric(12, 3) NOT NULL,
|
||||
offered_amount numeric(14, 2) NOT NULL,
|
||||
offered_pricing_breakdown jsonb NULL,
|
||||
invoice_id uuid NULL,
|
||||
payment_deadline timestamptz NOT NULL,
|
||||
status varchar(10) NOT NULL DEFAULT 'OFFERED',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_booking_batch_offers_booking ON freight.booking_batch_offers (booking_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_booking_batch_offers_schedule ON freight.booking_batch_offers (train_schedule_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_booking_batch_offers_status ON freight.booking_batch_offers (status);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_batch_offers;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add location_id column to vehicles table to track vehicle base location.
|
||||
*/
|
||||
export class AddLocationToVehicles1870000000000 implements MigrationInterface {
|
||||
name = "AddLocationToVehicles1870000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD COLUMN IF NOT EXISTS location_id uuid;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
DROP COLUMN IF EXISTS location_id;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add FREE and BUSY statuses to vehicle status enum.
|
||||
*/
|
||||
export class AddVehicleStatuses1880000000000 implements MigrationInterface {
|
||||
name = "AddVehicleStatuses1880000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Create enum type if it doesn't exist
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'vehicles_status_enum' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'freight')) THEN
|
||||
CREATE TYPE freight.vehicles_status_enum AS ENUM ('ACTIVE', 'FREE', 'BUSY', 'MAINTENANCE', 'RETIRED', 'OUT_OF_SERVICE');
|
||||
ELSE
|
||||
-- Add values if enum already exists but doesn't have them
|
||||
ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'FREE' BEFORE 'MAINTENANCE';
|
||||
ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'BUSY' AFTER 'FREE';
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// Note: Postgres cannot drop individual enum values, so the down migration is a no-op
|
||||
// The enum values FREE and BUSY will remain but will be unused after downgrade
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Split the mixed vehicle status into two fields:
|
||||
* - status: operational state (ACTIVE, MAINTENANCE, RETIRED, OUT_OF_SERVICE)
|
||||
* - availability: assignment state (FREE, BUSY)
|
||||
*
|
||||
* Existing FREE/BUSY statuses are moved to availability and the status is
|
||||
* normalized back to ACTIVE.
|
||||
*/
|
||||
export class SeparateVehicleAvailability1890000000000 implements MigrationInterface {
|
||||
name = "SeparateVehicleAvailability1890000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD COLUMN IF NOT EXISTS availability varchar DEFAULT 'FREE'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.vehicles SET availability = 'BUSY' WHERE status = 'BUSY'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.vehicles SET availability = 'FREE' WHERE availability IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.vehicles SET status = 'ACTIVE' WHERE status IN ('FREE', 'BUSY')
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Fold availability back into status before dropping the column
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.vehicles SET status = availability
|
||||
WHERE status = 'ACTIVE' AND availability IN ('FREE', 'BUSY')
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles DROP COLUMN IF EXISTS availability
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add code, power_plate_no and trailer_plate_no columns to vehicles.
|
||||
* These fields existed in the DTO and UI form but had no entity columns,
|
||||
* so submitted values were silently dropped.
|
||||
*/
|
||||
export class AddVehicleCodeAndPlates1890000000001 implements MigrationInterface {
|
||||
name = "AddVehicleCodeAndPlates1890000000001";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD COLUMN IF NOT EXISTS code varchar,
|
||||
ADD COLUMN IF NOT EXISTS power_plate_no varchar,
|
||||
ADD COLUMN IF NOT EXISTS trailer_plate_no varchar
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
DROP COLUMN IF EXISTS code,
|
||||
DROP COLUMN IF EXISTS power_plate_no,
|
||||
DROP COLUMN IF EXISTS trailer_plate_no
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { BillingService } from "./billing.service";
|
||||
@@ -7,8 +8,9 @@ import { BillingService } from "./billing.service";
|
||||
@ApiTags("billing")
|
||||
@Controller("billing")
|
||||
@FreightAdmin()
|
||||
@ApiBearerAuth()
|
||||
export class BillingController {
|
||||
constructor(private readonly billingService: BillingService) {}
|
||||
constructor(private readonly billingService: BillingService) { }
|
||||
|
||||
@Get("invoices")
|
||||
@ApiOperation({ summary: "List all invoices" })
|
||||
@@ -16,9 +18,31 @@ export class BillingController {
|
||||
return this.billingService.findAll();
|
||||
}
|
||||
|
||||
@Get("invoices/booking/:bookingId")
|
||||
@ApiOperation({ summary: "List invoices for a booking" })
|
||||
findByBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
|
||||
return this.billingService.findByBooking(bookingId);
|
||||
@Get("invoices/:id")
|
||||
@ApiOperation({ summary: "Get an invoice with its line items" })
|
||||
findById(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.billingService.findById(id);
|
||||
}
|
||||
|
||||
@Get("invoices/:id/document")
|
||||
@ApiOperation({ summary: "Download the sealed invoice PDF" })
|
||||
async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.billingService.document(id);
|
||||
sendPdf(res, filename, buffer);
|
||||
}
|
||||
|
||||
@Get("invoices/:id/receipt")
|
||||
@ApiOperation({ summary: "Download the sealed payment receipt PDF" })
|
||||
async receipt(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.billingService.receipt(id);
|
||||
sendPdf(res, filename, buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stream a generated PDF as a file download. */
|
||||
export function sendPdf(res: Response, filename: string, buffer: Buffer): void {
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
res.setHeader("Content-Length", buffer.length);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { BillingController } from "./billing.controller";
|
||||
import { PortalBillingController } from "./portal-billing.controller";
|
||||
import { PaymentController } from "./payment.controller";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { DocumentsModule } from "./documents/documents.module";
|
||||
import { Invoice } from "./entities/invoice.entity";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { InvoiceRepository } from "./invoice.repository";
|
||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||
import { PaymentModule } from "../payment/payment.module";
|
||||
import { CompaniesModule } from "../companies/companies.module";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Invoice])],
|
||||
controllers: [BillingController],
|
||||
providers: [BillingService],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
|
||||
forwardRef(() => PaymentModule),
|
||||
CompaniesModule,
|
||||
DocumentsModule,
|
||||
],
|
||||
controllers: [BillingController, PortalBillingController, PaymentController],
|
||||
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||
exports: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
|
||||
298
apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
Normal file
298
apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { BillingService } from "./billing.service";
|
||||
|
||||
/**
|
||||
* Minimal in-memory EntityManager stand-in covering the methods
|
||||
* `generateInvoice` / `markInvoiceAsPaid` call on the transaction manager.
|
||||
*/
|
||||
function makeManager(savedLines: unknown[]) {
|
||||
return {
|
||||
create: (_entity: unknown, data: Record<string, unknown>) => data,
|
||||
save: (data: Record<string, unknown>) => {
|
||||
const row = { id: data.id ?? "gen-1", ...data };
|
||||
if (data.invoiceId) savedLines.push(row);
|
||||
return Promise.resolve(row);
|
||||
},
|
||||
query: () => Promise.resolve([{ seq: 0 }]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
}
|
||||
|
||||
function makeEvents() {
|
||||
return { emit: jest.fn() };
|
||||
}
|
||||
|
||||
function generateInput(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: "booking-1",
|
||||
type: "prepaid",
|
||||
companyId: "company-1",
|
||||
companyProfileId: "profile-1",
|
||||
currency: "ETB",
|
||||
lines: [
|
||||
{
|
||||
chargeType: "RAIL_FREIGHT",
|
||||
description: "Rail freight",
|
||||
quantity: 2,
|
||||
unitRate: 500,
|
||||
amount: 1000,
|
||||
},
|
||||
{
|
||||
chargeType: "HAZARD_SURCHARGE",
|
||||
description: "Hazard surcharge",
|
||||
quantity: 2,
|
||||
unitRate: 250,
|
||||
amount: 500,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("BillingService.generateInvoice", () => {
|
||||
let savedLines: unknown[];
|
||||
let manager: ReturnType<typeof makeManager>;
|
||||
let events: ReturnType<typeof makeEvents>;
|
||||
let dataSource: { transaction: jest.Mock; manager: unknown };
|
||||
let service: BillingService;
|
||||
|
||||
beforeEach(() => {
|
||||
savedLines = [];
|
||||
manager = makeManager(savedLines);
|
||||
events = makeEvents();
|
||||
dataSource = {
|
||||
transaction: jest
|
||||
.fn()
|
||||
.mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
|
||||
manager,
|
||||
};
|
||||
service = new BillingService(
|
||||
dataSource as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a PENDING invoice with one line per input line", async () => {
|
||||
const invoice = await service.generateInvoice(generateInput());
|
||||
|
||||
expect(invoice.status).toBe(Freight.InvoiceStatus.Pending);
|
||||
expect(invoice.companyId).toBe("company-1");
|
||||
expect(invoice.source).toBe("booking");
|
||||
expect(invoice.sourceId).toBe("booking-1");
|
||||
expect(invoice.totalAmount).toBe(1500);
|
||||
expect(invoice.issuedAt).toBeInstanceOf(Date);
|
||||
expect(invoice.invoiceNumber).toMatch(/^INV-\d{8}-00001$/);
|
||||
expect(savedLines).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("sums line amounts when no explicit totalAmount is given", async () => {
|
||||
const invoice = await service.generateInvoice(
|
||||
generateInput({ totalAmount: undefined }),
|
||||
);
|
||||
expect(invoice.totalAmount).toBe(1500);
|
||||
});
|
||||
|
||||
it("leaves issuedAt null for a DRAFT invoice", async () => {
|
||||
const invoice = await service.generateInvoice(
|
||||
generateInput({ status: Freight.InvoiceStatus.Draft }),
|
||||
);
|
||||
expect(invoice.status).toBe(Freight.InvoiceStatus.Draft);
|
||||
expect(invoice.issuedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("enlists in a caller's transaction when a manager is passed", async () => {
|
||||
await service.generateInvoice(generateInput(), manager as never);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
expect(savedLines).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService.markInvoiceAsPaid", () => {
|
||||
it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => {
|
||||
const open = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
source: "booking",
|
||||
sourceId: "booking-1",
|
||||
totalAmount: 1500,
|
||||
paidAt: null,
|
||||
};
|
||||
const mg = {
|
||||
findOne: jest.fn().mockResolvedValue(open),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const events = makeEvents();
|
||||
const service = new BillingService(
|
||||
{ manager: mg } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
|
||||
expect(mg.update).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ id: "inv-1" },
|
||||
{
|
||||
status: Freight.InvoiceStatus.Paid,
|
||||
paymentId: "pay-1",
|
||||
paidAt: expect.any(Date),
|
||||
paidAmount: 1500,
|
||||
balanceAmount: 0,
|
||||
payments: [
|
||||
{
|
||||
amount: 1500,
|
||||
method: "GATEWAY",
|
||||
reference: "pay-1",
|
||||
paidAt: expect.any(String),
|
||||
metadata: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(events.emit).toHaveBeenCalledWith(
|
||||
"booking.invoice.paid",
|
||||
expect.objectContaining({
|
||||
invoiceId: "inv-1",
|
||||
status: Freight.InvoiceStatus.Paid,
|
||||
paymentId: "pay-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("is a no-op (no event) when the invoice is already paid", async () => {
|
||||
const paid = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Paid,
|
||||
source: "booking",
|
||||
};
|
||||
const mg = {
|
||||
findOne: jest.fn().mockResolvedValue(paid),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const events = makeEvents();
|
||||
const service = new BillingService(
|
||||
{ manager: mg } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
expect(events.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService.recordPayment", () => {
|
||||
function serviceFor(invoice: Record<string, unknown> | null) {
|
||||
const mg = {
|
||||
findOne: jest.fn().mockResolvedValue(invoice),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const events = makeEvents();
|
||||
const dataSource = {
|
||||
manager: mg,
|
||||
transaction: jest
|
||||
.fn()
|
||||
.mockImplementation((cb: (mg: unknown) => unknown) => cb(mg)),
|
||||
};
|
||||
const service = new BillingService(
|
||||
dataSource as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
);
|
||||
return { service, mg, events };
|
||||
}
|
||||
|
||||
const openInvoice = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
source: "warehouse",
|
||||
sourceId: "inv-item-1",
|
||||
totalAmount: 1000,
|
||||
paidAmount: 0,
|
||||
balanceAmount: 1000,
|
||||
payments: [],
|
||||
paidAt: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("moves to PARTIALLY_PAID and emits no event on a partial payment", async () => {
|
||||
const { service, mg, events } = serviceFor(openInvoice());
|
||||
|
||||
const updated = await service.recordPayment("inv-1", { amount: 400, method: "CASH" });
|
||||
|
||||
expect(updated.status).toBe(Freight.InvoiceStatus.PartiallyPaid);
|
||||
expect(updated.paidAmount).toBe(400);
|
||||
expect(updated.balanceAmount).toBe(600);
|
||||
expect(updated.payments).toHaveLength(1);
|
||||
expect(mg.update).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ id: "inv-1" },
|
||||
expect.objectContaining({
|
||||
status: Freight.InvoiceStatus.PartiallyPaid,
|
||||
paidAmount: 400,
|
||||
balanceAmount: 600,
|
||||
}),
|
||||
);
|
||||
expect(events.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => {
|
||||
const { service, mg, events } = serviceFor(openInvoice({ paidAmount: 400, balanceAmount: 600 }));
|
||||
|
||||
const updated = await service.recordPayment("inv-1", { amount: 600 });
|
||||
|
||||
expect(updated.status).toBe(Freight.InvoiceStatus.Paid);
|
||||
expect(updated.balanceAmount).toBe(0);
|
||||
expect(updated.paidAt).toBeInstanceOf(Date);
|
||||
expect(mg.update).toHaveBeenCalled();
|
||||
expect(events.emit).toHaveBeenCalledWith(
|
||||
"warehouse.invoice.paid",
|
||||
expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a non-positive amount", async () => {
|
||||
const { service, mg } = serviceFor(openInvoice());
|
||||
await expect(service.recordPayment("inv-1", { amount: 0 })).rejects.toThrow();
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a payment that exceeds the outstanding balance", async () => {
|
||||
const { service, mg } = serviceFor(openInvoice());
|
||||
await expect(
|
||||
service.recordPayment("inv-1", { amount: 1500 }),
|
||||
).rejects.toThrow();
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects payment against a cancelled invoice", async () => {
|
||||
const { service, mg } = serviceFor(
|
||||
openInvoice({ status: Freight.InvoiceStatus.Cancelled }),
|
||||
);
|
||||
await expect(service.recordPayment("inv-1", { amount: 100 })).rejects.toThrow();
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,943 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { Freight, PaymentReferenceType } from "@edr/types";
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { DataSource, EntityManager, In } from "typeorm";
|
||||
|
||||
import { Invoice } from "./entities/invoice.entity";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { PaymentService } from "../payment/payment.service";
|
||||
import { InitiateResponseDto } from "../payment/payments.dto";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
InvoiceDocumentService,
|
||||
} from "./documents/invoice-document.service";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
|
||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||
import { nextDailyInvoiceNumber } from "./invoice-numbering.util";
|
||||
import { applySettlement, round2 } from "./invoice-settlement.util";
|
||||
import { InvoiceRepository } from "./invoice.repository";
|
||||
|
||||
/** Options forwarded to the payment gateway when settling an invoice. */
|
||||
export interface PayInvoiceOptions {
|
||||
method?: string;
|
||||
platform?: "web" | "mobile";
|
||||
payerAccount?: string;
|
||||
returnUrl?: string;
|
||||
failureUrl?: string;
|
||||
}
|
||||
|
||||
/** A single manual/offline settlement to record against an invoice. */
|
||||
export interface RecordPaymentInput {
|
||||
/** Amount settled by this payment; must be > 0. */
|
||||
amount: number;
|
||||
method?: string | null;
|
||||
reference?: string | null;
|
||||
/** When the settlement occurred; defaults to now. */
|
||||
paidAt?: Date;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
|
||||
const DEFAULT_DUE_DAYS = 14;
|
||||
|
||||
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
|
||||
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
|
||||
Freight.InvoiceStatus.Issued,
|
||||
Freight.InvoiceStatus.Pending,
|
||||
Freight.InvoiceStatus.PartiallyPaid,
|
||||
Freight.InvoiceStatus.Overdue,
|
||||
];
|
||||
|
||||
/** A single line to bill on a generated invoice. */
|
||||
export interface InvoiceLineInput {
|
||||
chargeType: string;
|
||||
description?: string;
|
||||
/** Units this line bills for; defaults to 1. */
|
||||
quantity?: number;
|
||||
/** Price per unit; defaults to 0. */
|
||||
unitRate?: number;
|
||||
/** Line total; defaults to `quantity * unitRate`. */
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** Everything needed to generate an invoice for any source. */
|
||||
export interface GenerateInvoiceInput {
|
||||
/** Originating subsystem; namespaces events (`${source}.invoice.<event>`). */
|
||||
source: Freight.InvoiceSource;
|
||||
/** Identifier of the source record (e.g. booking id). */
|
||||
sourceId: string;
|
||||
/** What the invoice is for (e.g. "prepaid", "credit"). */
|
||||
type: string;
|
||||
companyId: string;
|
||||
companyProfileId: string;
|
||||
lines: InvoiceLineInput[];
|
||||
currency?: string;
|
||||
/** Explicit pre-tax subtotal; defaults to the sum of line amounts. */
|
||||
subtotalAmount?: number;
|
||||
/** Tax applied on top of the subtotal; defaults to 0. */
|
||||
taxAmount?: number;
|
||||
/** Explicit total; defaults to `subtotalAmount + taxAmount`. */
|
||||
totalAmount?: number;
|
||||
/** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */
|
||||
dueAt?: Date;
|
||||
dueInDays?: number;
|
||||
/**
|
||||
* Initial status. DRAFT leaves `issuedAt` null; any issued status
|
||||
* (default PENDING) stamps `issuedAt`.
|
||||
*/
|
||||
status?: Freight.InvoiceStatus;
|
||||
}
|
||||
|
||||
/** Payload broadcast on `${source}.invoice.<event>`. */
|
||||
export interface InvoiceEventPayload {
|
||||
invoiceId: string;
|
||||
invoiceNumber: string;
|
||||
source: Freight.InvoiceSource;
|
||||
sourceId: string;
|
||||
type: string;
|
||||
companyId: string;
|
||||
companyProfileId: string;
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
status: Freight.InvoiceStatus;
|
||||
paymentId?: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
private readonly logger = new Logger(BillingService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Invoice)
|
||||
private readonly invoicesRepository: Repository<Invoice>,
|
||||
) {}
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly invoices: InvoiceRepository,
|
||||
private readonly invoiceLines: InvoiceLineRepository,
|
||||
private readonly events: EventEmitter2,
|
||||
@Inject(forwardRef(() => PaymentService))
|
||||
private readonly payment: PaymentService,
|
||||
private readonly companies: CompaniesService,
|
||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||
) { }
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** List every invoice (most recent first). */
|
||||
findAll(): Promise<Invoice[]> {
|
||||
return this.invoicesRepository.find({ order: { issuedAt: "DESC" } });
|
||||
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
|
||||
}
|
||||
|
||||
/** List invoices for a given booking. */
|
||||
findByBooking(bookingId: string): Promise<Invoice[]> {
|
||||
return this.invoicesRepository.find({
|
||||
where: { bookingId },
|
||||
/** Invoice header plus its line items. */
|
||||
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const invoice = await this.invoices.findById(id);
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||
const lines = await this.invoiceLines.findAll({
|
||||
where: { invoiceId: id },
|
||||
order: { createdAt: "ASC" },
|
||||
});
|
||||
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
|
||||
}
|
||||
|
||||
// ── Documents (central PDF) ──────────────────────────────────────────────────
|
||||
|
||||
/** Sealed PDF invoice for any source, rendered by the shared document service. */
|
||||
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const invoice = await this.findById(id);
|
||||
return this.invoiceDocuments.render(
|
||||
this.toDocumentModel(invoice, "INVOICE"),
|
||||
);
|
||||
}
|
||||
|
||||
/** Sealed PDF receipt; available once any payment has been recorded. */
|
||||
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const invoice = await this.findById(id);
|
||||
if (Number(invoice.paidAmount) <= 0) {
|
||||
throw new BadRequestException(
|
||||
"A receipt is available only after payment is recorded.",
|
||||
);
|
||||
}
|
||||
return this.invoiceDocuments.render(
|
||||
this.toDocumentModel(invoice, "RECEIPT"),
|
||||
);
|
||||
}
|
||||
|
||||
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
|
||||
private toDocumentModel(
|
||||
invoice: Invoice & { lines: InvoiceLine[] },
|
||||
kind: "INVOICE" | "RECEIPT",
|
||||
): InvoiceDocumentModel {
|
||||
const title = invoice.source
|
||||
? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1)
|
||||
: "EDR";
|
||||
const totals: InvoiceDocumentModel["totals"] = [
|
||||
{ label: "Subtotal", amount: Number(invoice.subtotalAmount) },
|
||||
];
|
||||
if (Number(invoice.taxAmount) > 0) {
|
||||
totals.push({ label: "Tax", amount: Number(invoice.taxAmount) });
|
||||
}
|
||||
totals.push({
|
||||
label: "Total",
|
||||
amount: Number(invoice.totalAmount),
|
||||
grand: true,
|
||||
});
|
||||
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
|
||||
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
|
||||
|
||||
return {
|
||||
kind,
|
||||
title,
|
||||
documentNumber: invoice.invoiceNumber,
|
||||
issuedAt: invoice.issuedAt ?? invoice.createdAt,
|
||||
status: invoice.status,
|
||||
currency: invoice.currency,
|
||||
summary: [
|
||||
{ label: "Status", value: invoice.status },
|
||||
{ label: "Type", value: invoice.type },
|
||||
{ label: "Reference", value: invoice.sourceId },
|
||||
{ label: "Currency", value: invoice.currency },
|
||||
{
|
||||
label: "Issued",
|
||||
value: invoice.issuedAt
|
||||
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
|
||||
: null,
|
||||
},
|
||||
{
|
||||
label: "Due",
|
||||
value: invoice.dueAt
|
||||
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
|
||||
: null,
|
||||
},
|
||||
],
|
||||
categoryHeader: "Charge type",
|
||||
lines: invoice.lines.map((l) => ({
|
||||
description: l.description ?? l.chargeType,
|
||||
category: l.chargeType,
|
||||
quantity: l.quantity,
|
||||
unitRate: l.unitRate,
|
||||
amount: l.amount,
|
||||
currency: l.currency,
|
||||
})),
|
||||
totals,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Customer-scoped reads (portal) ───────────────────────────────────────────
|
||||
|
||||
/** Resolve the customer's company id from their IAM user id (null if none). */
|
||||
async resolveCompanyId(userId: string): Promise<string | null> {
|
||||
try {
|
||||
const { company } = await this.companies.getCompanyInfoByUserId(userId);
|
||||
return company?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every invoice billed to a company, newest first, with billing relations.
|
||||
* Optionally narrow to a single source record (e.g. a booking's invoices) via
|
||||
* `{ source, sourceId }`.
|
||||
*/
|
||||
findByCompany(
|
||||
companyId: string,
|
||||
filter: { source?: string; sourceId?: string } = {},
|
||||
): Promise<Invoice[]> {
|
||||
return this.invoices.findAll({
|
||||
where: {
|
||||
companyId,
|
||||
...(filter.source ? { source: filter.source } : {}),
|
||||
...(filter.sourceId ? { sourceId: filter.sourceId } : {}),
|
||||
},
|
||||
relations: { company: true, companyProfile: true },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Invoices for the signed-in customer; empty when they have no company. */
|
||||
async findForUser(
|
||||
userId: string,
|
||||
filter: { source?: string; sourceId?: string } = {},
|
||||
): Promise<Invoice[]> {
|
||||
const companyId = await this.resolveCompanyId(userId);
|
||||
return companyId ? this.findByCompany(companyId, filter) : [];
|
||||
}
|
||||
|
||||
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
|
||||
async findByIdForUser(
|
||||
id: string,
|
||||
userId: string,
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const companyId = await this.resolveCompanyId(userId);
|
||||
const invoice = await this.findById(id);
|
||||
if (!companyId || invoice.companyId !== companyId) {
|
||||
throw new NotFoundException(`Invoice ${id} not found`);
|
||||
}
|
||||
return invoice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate gateway payment for one of the customer's own invoices. Verifies
|
||||
* ownership, then charges the invoice directly by ID (see {@link payInvoice}).
|
||||
*/
|
||||
async payInvoiceForUser(
|
||||
id: string,
|
||||
userId: string,
|
||||
opts: PayInvoiceOptions = {},
|
||||
): Promise<InitiateResponseDto> {
|
||||
await this.findByIdForUser(id, userId);
|
||||
return this.payInvoice(id, opts);
|
||||
}
|
||||
|
||||
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
|
||||
async documentForUser(
|
||||
id: string,
|
||||
userId: string,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
await this.findByIdForUser(id, userId);
|
||||
return this.document(id);
|
||||
}
|
||||
|
||||
/** Sealed receipt PDF for one of the customer's own invoices (ownership-checked). */
|
||||
async receiptForUser(
|
||||
id: string,
|
||||
userId: string,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
await this.findByIdForUser(id, userId);
|
||||
return this.receipt(id);
|
||||
}
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */
|
||||
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
|
||||
return nextDailyInvoiceNumber(mg, {
|
||||
table: "freight.invoices",
|
||||
code: "INV",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an invoice for any source (booking, demurrage, manual, …).
|
||||
*
|
||||
* Persists the header plus its lines in one transaction and assigns the next
|
||||
* sequential `invoice_number`. The total defaults to the sum of line amounts
|
||||
* unless `totalAmount` is given. Issued invoices (default PENDING) stamp
|
||||
* `issuedAt`; pass `status: DRAFT` to leave it unissued.
|
||||
*
|
||||
* Pass `manager` to enlist in a caller's transaction (e.g. when generating an
|
||||
* invoice as part of a larger booking flow).
|
||||
*/
|
||||
async generateInvoice(
|
||||
input: GenerateInvoiceInput,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
console.log("oooooooooo", input);
|
||||
const run = (mg: EntityManager) => this.createInvoice(input, mg);
|
||||
return manager ? run(manager) : this.dataSource.transaction(run);
|
||||
}
|
||||
|
||||
private async createInvoice(
|
||||
input: GenerateInvoiceInput,
|
||||
mg: EntityManager,
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const currency = input.currency ?? "ETB";
|
||||
const status = input.status ?? Freight.InvoiceStatus.Pending;
|
||||
const issued = status !== Freight.InvoiceStatus.Draft;
|
||||
|
||||
const lines = input.lines.map((l) => {
|
||||
const quantity = l.quantity ?? 1;
|
||||
const unitRate = l.unitRate ?? 0;
|
||||
return {
|
||||
chargeType: l.chargeType,
|
||||
description: l.description,
|
||||
quantity,
|
||||
unitRate,
|
||||
amount: l.amount ?? quantity * unitRate,
|
||||
currency: l.currency ?? currency,
|
||||
metadata: l.metadata ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const subtotalAmount =
|
||||
input.subtotalAmount ??
|
||||
lines.reduce((sum, l) => sum + Number(l.amount), 0);
|
||||
const taxAmount = input.taxAmount ?? 0;
|
||||
const totalAmount = input.totalAmount ?? round2(subtotalAmount + taxAmount);
|
||||
|
||||
const dueAt =
|
||||
input.dueAt ??
|
||||
new Date(
|
||||
Date.now() +
|
||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const invoiceNumber = await this.nextInvoiceNumber(mg);
|
||||
|
||||
const invoice = await mg.save(
|
||||
mg.create(Invoice, {
|
||||
invoiceNumber,
|
||||
source: input.source,
|
||||
sourceId: input.sourceId,
|
||||
type: input.type,
|
||||
companyId: input.companyId,
|
||||
companyProfileId: input.companyProfileId,
|
||||
subtotalAmount: round2(subtotalAmount),
|
||||
taxAmount: round2(taxAmount),
|
||||
totalAmount: round2(totalAmount),
|
||||
paidAmount: 0,
|
||||
balanceAmount: round2(totalAmount),
|
||||
payments: [],
|
||||
currency,
|
||||
status,
|
||||
issuedAt: issued ? new Date() : null,
|
||||
dueAt,
|
||||
}),
|
||||
);
|
||||
|
||||
const savedLines = await Promise.all(
|
||||
lines.map((l) =>
|
||||
mg.save(mg.create(InvoiceLine, { ...l, invoiceId: invoice.id })),
|
||||
),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${input.source}:${input.sourceId}`,
|
||||
);
|
||||
|
||||
return { ...invoice, lines: savedLines };
|
||||
}
|
||||
|
||||
// ── State transitions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run `fn` inside a transaction and only emit its returned domain event
|
||||
* after commit. When the caller passes their own `manager`, they own commit
|
||||
* timing — `fn`'s event fires inline as soon as it resolves (the outer
|
||||
* transaction may still roll back afterwards; this is the caller's
|
||||
* documented tradeoff). When no `manager` is given, this opens its own
|
||||
* transaction and defers the emit until after that transaction commits, so
|
||||
* listeners (e.g. booking advancement) can never observe an invoice change
|
||||
* that then rolls back.
|
||||
*/
|
||||
private async runTransition<T>(
|
||||
manager: EntityManager | undefined,
|
||||
fn: (mg: EntityManager) => Promise<{ result: T; emit?: () => void }>,
|
||||
): Promise<T> {
|
||||
if (manager) {
|
||||
const { result, emit } = await fn(manager);
|
||||
emit?.();
|
||||
return result;
|
||||
}
|
||||
let pending: (() => void) | undefined;
|
||||
const result = await this.dataSource.transaction(async (mg) => {
|
||||
const out = await fn(mg);
|
||||
pending = out.emit;
|
||||
return out.result;
|
||||
});
|
||||
pending?.();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an invoice paid, stamp the paid timestamp, sync paid/balance amounts,
|
||||
* append the settlement to the `payments` ledger, link the gateway payment,
|
||||
* then emit `${source}.invoice.paid`. Full-payment only — no partial
|
||||
* settlement. No-op when the invoice is already paid. Pass `manager` to
|
||||
* enlist in a caller's transaction; otherwise locks the row for update and
|
||||
* emits only after commit (see {@link runTransition}).
|
||||
*/
|
||||
async markInvoiceAsPaid(
|
||||
invoiceId: string,
|
||||
paymentId: string | null = null,
|
||||
manager?: EntityManager,
|
||||
settlement: { providerTxnId?: string; paidAt?: Date } = {},
|
||||
): Promise<Invoice | null> {
|
||||
return this.runTransition(manager, async (mg) => {
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { id: invoiceId },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
return { result: invoice };
|
||||
}
|
||||
|
||||
const paidAt = invoice.paidAt ?? settlement.paidAt ?? new Date();
|
||||
const settledAmount = round2(
|
||||
Number(invoice.totalAmount) - Number(invoice.paidAmount ?? 0),
|
||||
);
|
||||
const entry: InvoicePayment = {
|
||||
amount: settledAmount,
|
||||
method: "GATEWAY",
|
||||
reference: settlement.providerTxnId ?? paymentId ?? null,
|
||||
paidAt: paidAt.toISOString(),
|
||||
metadata: null,
|
||||
};
|
||||
const payments = [...(invoice.payments ?? []), entry];
|
||||
|
||||
const patch = {
|
||||
status: Freight.InvoiceStatus.Paid,
|
||||
paymentId,
|
||||
paidAt,
|
||||
paidAmount: invoice.totalAmount,
|
||||
balanceAmount: 0,
|
||||
payments,
|
||||
};
|
||||
await mg.update(Invoice, { id: invoiceId }, patch as never);
|
||||
|
||||
const updated = { ...invoice, ...patch } as Invoice;
|
||||
return {
|
||||
result: updated,
|
||||
emit: () => this.emitInvoiceEvent("paid", updated),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a (possibly partial) settlement against an invoice and sync its
|
||||
* status. Appends to the `payments` ledger, recomputes `paidAmount` /
|
||||
* `balanceAmount`, and moves the invoice to PARTIALLY_PAID or — once the
|
||||
* balance reaches zero — PAID, stamping `paidAt` and emitting
|
||||
* `${source}.invoice.paid`. Use this for manual/offline settlement (e.g. cash
|
||||
* at the warehouse counter); gateway settlement goes through
|
||||
* {@link markInvoiceAsPaid}.
|
||||
*
|
||||
* Throws when the invoice is missing, cancelled, refunded, already fully
|
||||
* paid, `amount` is not positive, or `amount` exceeds the outstanding
|
||||
* balance. Pass `manager` to enlist in a caller's transaction; otherwise
|
||||
* locks the row for update and emits only after commit (see
|
||||
* {@link runTransition}).
|
||||
*/
|
||||
async recordPayment(
|
||||
invoiceId: string,
|
||||
input: RecordPaymentInput,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice> {
|
||||
if (!(input.amount > 0)) {
|
||||
throw new BadRequestException(
|
||||
"Payment amount must be greater than zero.",
|
||||
);
|
||||
}
|
||||
|
||||
return this.runTransition(manager, async (mg) => {
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { id: invoiceId },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Cancelled) {
|
||||
throw new BadRequestException("Cannot pay a cancelled invoice.");
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Refunded) {
|
||||
throw new BadRequestException("Cannot pay a refunded invoice.");
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
throw new BadRequestException("Invoice is already fully paid.");
|
||||
}
|
||||
if (round2(input.amount) > Number(invoice.balanceAmount)) {
|
||||
throw new BadRequestException(
|
||||
`Payment of ${round2(input.amount)} exceeds the outstanding balance of ${Number(invoice.balanceAmount)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const at = input.paidAt ?? new Date();
|
||||
const { paidAmount, balanceAmount, fullyPaid } = applySettlement(
|
||||
invoice.totalAmount,
|
||||
invoice.paidAmount,
|
||||
input.amount,
|
||||
);
|
||||
const status = fullyPaid
|
||||
? Freight.InvoiceStatus.Paid
|
||||
: Freight.InvoiceStatus.PartiallyPaid;
|
||||
|
||||
const entry: InvoicePayment = {
|
||||
amount: round2(input.amount),
|
||||
method: input.method ?? null,
|
||||
reference: input.reference ?? null,
|
||||
paidAt: at.toISOString(),
|
||||
metadata: input.metadata ?? null,
|
||||
};
|
||||
const payments = [...(invoice.payments ?? []), entry];
|
||||
|
||||
const patch = {
|
||||
paidAmount,
|
||||
balanceAmount,
|
||||
status,
|
||||
payments,
|
||||
paidAt: fullyPaid ? at : (invoice.paidAt ?? null),
|
||||
};
|
||||
await mg.update(Invoice, { id: invoice.id }, patch as never);
|
||||
|
||||
const updated = { ...invoice, ...patch } as Invoice;
|
||||
return {
|
||||
result: updated,
|
||||
emit: fullyPaid
|
||||
? () => this.emitInvoiceEvent("paid", updated)
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an invoice refunded and emit `${source}.invoice.refunded`.
|
||||
* No-op when already refunded. Throws when the invoice has no recorded
|
||||
* payment (nothing to refund).
|
||||
*/
|
||||
async markInvoiceAsRefunded(
|
||||
invoiceId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
return this.transition(
|
||||
invoiceId,
|
||||
Freight.InvoiceStatus.Refunded,
|
||||
"refunded",
|
||||
{},
|
||||
manager,
|
||||
(invoice) => {
|
||||
if (!(Number(invoice.paidAmount) > 0)) {
|
||||
throw new BadRequestException(
|
||||
"Cannot refund an invoice with no recorded payment.",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an invoice cancelled and emit `${source}.invoice.cancelled`.
|
||||
* No-op when already cancelled. Throws when the invoice has payments
|
||||
* recorded against it (refund it instead).
|
||||
*/
|
||||
async cancelInvoice(
|
||||
invoiceId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
return this.transition(
|
||||
invoiceId,
|
||||
Freight.InvoiceStatus.Cancelled,
|
||||
"cancelled",
|
||||
{},
|
||||
manager,
|
||||
(invoice) => {
|
||||
if (Number(invoice.paidAmount) > 0) {
|
||||
throw new BadRequestException(
|
||||
"Cannot cancel an invoice that has payments recorded against it.",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the invoice, apply the new status (+ extra columns), then emit
|
||||
* `${source}.invoice.<event>`. No-op (returns the invoice, skipping `guard`)
|
||||
* when it is already in the target status. Throws when the invoice does not
|
||||
* exist or `guard` rejects the current state. Pass `manager` to enlist in a
|
||||
* caller's transaction; otherwise locks the row for update and emits only
|
||||
* after commit (see {@link runTransition}).
|
||||
*/
|
||||
private async transition(
|
||||
invoiceId: string,
|
||||
status: Freight.InvoiceStatus,
|
||||
event: string,
|
||||
extra: { paymentId?: string },
|
||||
manager?: EntityManager,
|
||||
guard?: (invoice: Invoice) => void,
|
||||
): Promise<Invoice | null> {
|
||||
return this.runTransition(manager, async (mg) => {
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { id: invoiceId },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
}
|
||||
if (invoice.status === status) return { result: invoice };
|
||||
guard?.(invoice);
|
||||
|
||||
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
|
||||
|
||||
const updated = { ...invoice, ...extra, status } as Invoice;
|
||||
return {
|
||||
result: updated,
|
||||
emit: () => this.emitInvoiceEvent(event, updated),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Broadcast `${invoice.source}.invoice.<event>` to in-process listeners. */
|
||||
private emitInvoiceEvent(event: string, invoice: Invoice): void {
|
||||
const payload: InvoiceEventPayload = {
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
source: invoice.source as Freight.InvoiceSource,
|
||||
sourceId: invoice.sourceId,
|
||||
type: invoice.type,
|
||||
companyId: invoice.companyId,
|
||||
companyProfileId: invoice.companyProfileId,
|
||||
totalAmount: invoice.totalAmount,
|
||||
currency: invoice.currency,
|
||||
status: invoice.status,
|
||||
paymentId: invoice.paymentId ?? null,
|
||||
};
|
||||
this.events
|
||||
.emitAsync(`${invoice.source}.invoice.${event}`, payload)
|
||||
.catch((err) =>
|
||||
this.logger.error(
|
||||
`Listener for ${invoice.source}.invoice.${event} (invoice ${invoice.id}) failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Payment reconciliation (by source) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The invoice a source record already has open, or null if it needs a new
|
||||
* one. This is the idempotency check every `ensureInvoiceFor*` (booking,
|
||||
* first-mile, last-mile) runs before generating — it must see DRAFT
|
||||
* invoices too, not just issued ones, otherwise a source that already has
|
||||
* an unissued draft gets a second, duplicate invoice minted alongside it
|
||||
* instead of that draft being reused and then issued.
|
||||
*
|
||||
* Pass `type` to select a specific invoice when a source carries several (e.g.
|
||||
* a booking's up-front vs final charge); omit it to settle whichever single
|
||||
* invoice is currently open. Returns the most recent matching draft-or-open
|
||||
* (unpaid, non-cancelled) invoice.
|
||||
*/
|
||||
findPayable(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
type?: string,
|
||||
): Promise<Invoice | null> {
|
||||
return this.dataSource.getRepository(Invoice).findOne({
|
||||
where: {
|
||||
source,
|
||||
sourceId,
|
||||
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass `type` to select a specific invoice when a source carries several (e.g.
|
||||
* a booking's up-front vs final charge); omit it to settle whichever single
|
||||
* invoice is currently open. Returns the most recent matching open (unpaid,
|
||||
* non-cancelled) invoice.
|
||||
*/
|
||||
findInvoice(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
type?: string,
|
||||
): Promise<Invoice | null> {
|
||||
return this.dataSource.getRepository(Invoice).findOne({
|
||||
where: {
|
||||
source,
|
||||
sourceId,
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire a source's currently-open invoice (its pay window closed before
|
||||
* settlement), then emit `${source}.invoice.expired`. Resolves the open invoice
|
||||
* and transitions it to EXPIRED — a terminal, non-payable status (kept out of
|
||||
* `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice
|
||||
* (already paid/cancelled/expired).
|
||||
*
|
||||
* Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in
|
||||
* the batch engine) to enlist in its DB transaction.
|
||||
*/
|
||||
async expirePayable(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
type?: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: {
|
||||
source,
|
||||
sourceId,
|
||||
status: In(OPEN_STATUSES),
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
|
||||
return this.transition(
|
||||
invoice.id,
|
||||
Freight.InvoiceStatus.Expired,
|
||||
"expired",
|
||||
{},
|
||||
mg,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a source's open invoice `dueAt` to its real pay-window deadline. The
|
||||
* booking invoice is generated before the pay window opens (at booking
|
||||
* creation/approval), so its printed due date is refreshed when the batch engine
|
||||
* sets `paymentDeadline`. No-op when the source has no open invoice.
|
||||
*/
|
||||
async syncPayableDueDate(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
dueAt: Date,
|
||||
type?: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: {
|
||||
source,
|
||||
sourceId,
|
||||
status: In(OPEN_STATUSES),
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return;
|
||||
await mg.update(Invoice, { id: invoice.id }, { dueAt });
|
||||
}
|
||||
|
||||
/**
|
||||
* Force an invoice to `status`, including issuing a still-DRAFT invoice
|
||||
* (stamping `issuedAt`) — unlike the other transitions here, this is a
|
||||
* blunt admin/workflow override, not a settlement. No-op when the invoice
|
||||
* is missing or already terminal (paid/cancelled/refunded/expired).
|
||||
*/
|
||||
async updateStatus(
|
||||
invoiceId: string,
|
||||
status: Freight.InvoiceStatus,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: {
|
||||
id: invoiceId,
|
||||
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
|
||||
},
|
||||
});
|
||||
if (!invoice) return;
|
||||
await mg.update(
|
||||
Invoice,
|
||||
{ id: invoice.id },
|
||||
{ status, issuedAt: invoice.issuedAt ?? new Date() },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Payment initiation & settlement (the gateway boundary) ───────────────────
|
||||
|
||||
/**
|
||||
* Charge an invoice through the payment gateway. Billing is the single place
|
||||
* that turns "what is owed" (the invoice) into a payment intent — the domain
|
||||
* never talks to the payment service directly. Resolves the invoice by ID,
|
||||
* opens an intent for `invoice.balanceAmount` (so partial payments are honored),
|
||||
* records the intent id on the invoice (the settlement correlation key), and
|
||||
* returns the client action.
|
||||
*
|
||||
* When the provider settles synchronously, the invoice is settled inline here —
|
||||
* after the intent id is stored — so the `payment.succeeded` correlation can
|
||||
* never fire before the link exists. Throws when the invoice is not found or
|
||||
* not in an open/payable status.
|
||||
*/
|
||||
async payInvoice(
|
||||
invoiceId: string,
|
||||
opts: {
|
||||
method?: string;
|
||||
platform?: "web" | "mobile";
|
||||
payerAccount?: string;
|
||||
returnUrl?: string;
|
||||
failureUrl?: string;
|
||||
} = {},
|
||||
): Promise<InitiateResponseDto> {
|
||||
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
||||
where: { id: invoiceId, status: In(OPEN_STATUSES) },
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(
|
||||
`Invoice ${invoiceId} not found or not in a payable status`,
|
||||
);
|
||||
}
|
||||
|
||||
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
|
||||
if (!(amountDue > 0)) {
|
||||
throw new BadRequestException("Invoice has no outstanding balance.");
|
||||
}
|
||||
|
||||
const result = await this.payment.initiate({
|
||||
referenceId: invoice.sourceId,
|
||||
source: invoice.source,
|
||||
// Freight payments settle under the generic SHIPMENT reference — how the
|
||||
// payment service attributes them to the freight API. The payment ↔ invoice
|
||||
// link is the intent id (`paymentId`); per-source post-payment reactions live
|
||||
// in the domain via `${source}.invoice.paid`. Neither billing nor the payment
|
||||
// service branches on a domain-specific reference type.
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
orderRef: invoice.invoiceNumber.replace("-", "_"),
|
||||
amountMinor: Math.round(Number(invoice.balanceAmount)),
|
||||
currency: invoice.currency,
|
||||
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
||||
method: opts.method ?? "TELEBIRR",
|
||||
platform: opts.platform,
|
||||
payerAccount: opts.payerAccount,
|
||||
returnUrl: opts.returnUrl,
|
||||
failureUrl: opts.failureUrl,
|
||||
});
|
||||
|
||||
// Link the intent to the invoice BEFORE any settlement can correlate against it.
|
||||
await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.update({ id: invoice.id }, { paymentId: result.intentId });
|
||||
|
||||
if (result.immediateSuccess) {
|
||||
await this.settleByPaymentId(
|
||||
result.intentId,
|
||||
result.providerTxnId,
|
||||
result.paidAt,
|
||||
);
|
||||
}
|
||||
|
||||
return result.response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle the open invoice linked to a gateway intent id, if any. Called by the
|
||||
* payment service when an intent succeeds: finds the invoice linked by
|
||||
* `paymentId`, marks it paid, and emits `${source}.invoice.paid` for the domain
|
||||
* to advance on. Idempotent — no-op when no open invoice is linked (already
|
||||
* settled, or settled inline by {@link payInvoice}).
|
||||
*/
|
||||
async settleByPaymentId(
|
||||
paymentId: string,
|
||||
providerTxnId?: string,
|
||||
paidAt?: Date,
|
||||
): Promise<Invoice | null> {
|
||||
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
||||
where: { paymentId, status: In(OPEN_STATUSES) },
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
|
||||
return this.markInvoiceAsPaid(invoice.id, paymentId, undefined, {
|
||||
providerTxnId,
|
||||
paidAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { InvoiceDocumentService } from "./invoice-document.service";
|
||||
import { PdfRenderService } from "./pdf-render.service";
|
||||
|
||||
/**
|
||||
* Standalone document infrastructure — generic HTML→PDF plus the shared
|
||||
* invoice/receipt renderer. Has no domain dependencies, so any module (billing,
|
||||
* warehouses, …) can import it to print invoices without coupling to the
|
||||
* billing payment graph.
|
||||
*/
|
||||
@Module({
|
||||
providers: [PdfRenderService, InvoiceDocumentService],
|
||||
exports: [PdfRenderService, InvoiceDocumentService],
|
||||
})
|
||||
export class DocumentsModule {}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { PdfRenderService } from "./pdf-render.service";
|
||||
|
||||
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
|
||||
|
||||
/** One billed line on the document (charge type / fee type agnostic). */
|
||||
export interface InvoiceDocumentLine {
|
||||
description: string | null;
|
||||
/** Optional categorisation column (e.g. "Fee type" / "Charge type"). */
|
||||
category?: string | null;
|
||||
quantity?: number | null;
|
||||
unitRate?: number | null;
|
||||
amount?: number | null;
|
||||
currency?: string | null;
|
||||
}
|
||||
|
||||
/** A labelled total row in the totals box; mark `grand` for the headline total. */
|
||||
export interface InvoiceDocumentTotal {
|
||||
label: string;
|
||||
amount: number;
|
||||
grand?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-agnostic description of a printable invoice/receipt. Each billing
|
||||
* source maps its own entity onto this shape; the renderer owns the layout so
|
||||
* every EDR invoice document looks identical regardless of source.
|
||||
*/
|
||||
export interface InvoiceDocumentModel {
|
||||
kind: InvoiceDocumentKind;
|
||||
/** Document heading, e.g. "Warehouse Fee Invoice" / "Freight Invoice". */
|
||||
title: string;
|
||||
documentNumber: string;
|
||||
issuedAt?: Date | string | null;
|
||||
status: string;
|
||||
currency: string;
|
||||
/** Free-form summary grid (label/value pairs). */
|
||||
summary: Array<{ label: string; value: string | null }>;
|
||||
/** Header for the line-item category column; column hidden when omitted. */
|
||||
categoryHeader?: string;
|
||||
lines: InvoiceDocumentLine[];
|
||||
totals: InvoiceDocumentTotal[];
|
||||
/** Override the round seal text; defaults from kind/status. */
|
||||
sealText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central invoice/receipt PDF renderer shared by every billing source. Turns a
|
||||
* {@link InvoiceDocumentModel} into the sealed EDR document HTML and renders it
|
||||
* via {@link PdfRenderService}. Previously this layout lived (warehouse-only) in
|
||||
* `WarehouseInvoiceService`; it now serves all invoices.
|
||||
*/
|
||||
@Injectable()
|
||||
export class InvoiceDocumentService {
|
||||
constructor(private readonly pdf: PdfRenderService) {}
|
||||
|
||||
async render(
|
||||
model: InvoiceDocumentModel,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const html = this.buildHtml(model);
|
||||
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
|
||||
return {
|
||||
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
|
||||
buffer: await this.pdf.htmlToPdfBuffer(html, { label: `${model.title} ${kindLabel}` }),
|
||||
};
|
||||
}
|
||||
|
||||
buildHtml(model: InvoiceDocumentModel): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? "-")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
const money = (amount: unknown, currency = model.currency) =>
|
||||
`${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||
const date = (value: unknown) =>
|
||||
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
||||
|
||||
const showCategory = Boolean(model.categoryHeader);
|
||||
const sealText =
|
||||
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
|
||||
|
||||
const summaryRows = model.summary
|
||||
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
|
||||
.join("");
|
||||
|
||||
const itemRows = model.lines
|
||||
.map(
|
||||
(item) => `<tr>
|
||||
<td>${esc(item.description)}</td>
|
||||
${showCategory ? `<td>${esc((item.category ?? "").replace(/_/g, " "))}</td>` : ""}
|
||||
<td class="num">${esc(item.quantity ?? 0)}</td>
|
||||
<td class="num">${esc(money(item.unitRate, item.currency ?? model.currency))}</td>
|
||||
<td class="num">${esc(money(item.amount, item.currency ?? model.currency))}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const totalRows = model.totals
|
||||
.map(
|
||||
(total) =>
|
||||
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount))}</strong></div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
|
||||
.doc { padding: 16px 8px; position: relative; }
|
||||
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 16px; }
|
||||
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
|
||||
h1 { margin: 8px 0 0; font-size: 30px; }
|
||||
.meta { text-align: right; font-size: 12px; color: #475569; }
|
||||
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
|
||||
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
|
||||
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
|
||||
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
|
||||
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
|
||||
th { text-align: left; background: #f8fafc; color: #475569; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; font-size: 12px; }
|
||||
td.num, th.num { text-align: right; }
|
||||
.totals { margin-left: auto; width: 330px; margin-top: 18px; }
|
||||
.total-row { display: flex; justify-content: space-between; border-bottom: 1px solid #e2e8f0; padding: 8px 0; font-size: 13px; }
|
||||
.grand { font-size: 16px; font-weight: 800; }
|
||||
.footer { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</h1>
|
||||
</div>
|
||||
<div class="meta">
|
||||
Document no.
|
||||
<strong>${esc(model.documentNumber)}</strong>
|
||||
Issued: ${esc(date(model.issuedAt))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="seal">${esc(sealText)}</div>
|
||||
<div class="summary">${summaryRows}</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
${showCategory ? `<th>${esc(model.categoryHeader)}</th>` : ""}
|
||||
<th class="num">Qty</th>
|
||||
<th class="num">Rate</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${itemRows}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="totals">${totalRows}</div>
|
||||
<div class="footer">
|
||||
<div class="line">Prepared by EDR finance</div>
|
||||
<div class="line">Authorized seal / signature</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
safeFilename(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9_-]+/g, "-");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { existsSync } from "fs";
|
||||
|
||||
import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
|
||||
const MIN_VALID_PDF_BYTES = 2_000;
|
||||
|
||||
const PDF_PRINT_STYLES = `
|
||||
<style id="edr-pdf-print-fix">
|
||||
@media print {
|
||||
html, body {
|
||||
background: #fff !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
</style>`;
|
||||
|
||||
export interface PdfRenderOptions {
|
||||
/** Label used in logs to identify the document kind. */
|
||||
label?: string;
|
||||
/**
|
||||
* Degraded renderer used when Chromium is unavailable. Receives the
|
||||
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
|
||||
* header). When omitted, a generic single-page fallback is produced.
|
||||
*/
|
||||
fallback?: (preparedHtml: string) => Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic HTML → PDF renderer shared by every document producer (invoices,
|
||||
* receipts, warehouse release orders). Renders via headless Chromium when
|
||||
* available and degrades to a caller-supplied (or generic) hand-built PDF
|
||||
* otherwise. This is pure infrastructure — it knows nothing about invoices.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PdfRenderService {
|
||||
private readonly logger = new Logger(PdfRenderService.name);
|
||||
|
||||
async htmlToPdfBuffer(html: string, opts: PdfRenderOptions = {}): Promise<Buffer> {
|
||||
const label = opts.label ?? "document";
|
||||
const preparedHtml = this.injectPdfPrintStyles(html);
|
||||
const executablePath = this.resolveExecutablePath();
|
||||
|
||||
try {
|
||||
const puppeteer = await import("puppeteer");
|
||||
const launchOptions: import("puppeteer").LaunchOptions = {
|
||||
headless: true,
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
};
|
||||
|
||||
const browser = await puppeteer.default.launch(launchOptions);
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
|
||||
await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
|
||||
await page.emulateMediaType("print");
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
const pdf = await page.pdf({
|
||||
format: "A4",
|
||||
printBackground: true,
|
||||
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
|
||||
});
|
||||
|
||||
const buffer = Buffer.from(pdf);
|
||||
if (!this.isValidPdf(buffer)) {
|
||||
throw new Error(`Puppeteer produced invalid ${label} PDF (${buffer.length} bytes)`);
|
||||
}
|
||||
this.logger.log(
|
||||
`${label} PDF rendered (${buffer.length} bytes) via ${executablePath ?? "bundled Chromium"}`,
|
||||
);
|
||||
return buffer;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`);
|
||||
const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml);
|
||||
if (this.isValidPdf(fallback)) {
|
||||
this.logger.warn(
|
||||
`Using ${label} PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
throw new InternalServerErrorException(
|
||||
`${label} PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private injectPdfPrintStyles(html: string): string {
|
||||
if (html.includes("edr-pdf-print-fix")) return html;
|
||||
if (html.includes("</head>")) {
|
||||
return html.replace("</head>", `${PDF_PRINT_STYLES}</head>`);
|
||||
}
|
||||
return `${PDF_PRINT_STYLES}${html}`;
|
||||
}
|
||||
|
||||
private resolveExecutablePath(): string | undefined {
|
||||
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
|
||||
if (fromEnv && existsSync(fromEnv)) return fromEnv;
|
||||
|
||||
const candidates = [
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/google-chrome",
|
||||
];
|
||||
return candidates.find((path) => existsSync(path));
|
||||
}
|
||||
|
||||
isValidPdf(buffer: Buffer): boolean {
|
||||
return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString("ascii") === "%PDF-";
|
||||
}
|
||||
|
||||
/** Minimal valid one-page PDF carrying a plain-text rendering of the document. */
|
||||
private genericFallbackPdf(html: string): Buffer {
|
||||
const text = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/[^\x20-\x7e]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 900);
|
||||
|
||||
const escape = (value: string) => value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
|
||||
const lines = (text.match(/.{1,90}/g) ?? ["Document"]).slice(0, 40);
|
||||
const stream =
|
||||
"BT\n/F1 10 Tf\n36 800 Td\n12 TL\n" +
|
||||
lines.map((line, i) => `${i === 0 ? "" : "T*\n"}(${escape(line)}) Tj\n`).join("") +
|
||||
"ET";
|
||||
|
||||
const objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`,
|
||||
];
|
||||
|
||||
let pdf = "%PDF-1.4\n";
|
||||
const offsets: number[] = [];
|
||||
objects.forEach((object, index) => {
|
||||
offsets.push(Buffer.byteLength(pdf, "latin1"));
|
||||
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
||||
});
|
||||
while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) pdf += "% pad\n";
|
||||
const xrefOffset = Buffer.byteLength(pdf, "latin1");
|
||||
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
||||
for (const offset of offsets) pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
|
||||
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
||||
return Buffer.from(pdf, "latin1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsOptional, IsString } from "class-validator";
|
||||
|
||||
/** Gateway options for paying an invoice from the customer portal. */
|
||||
export class PayInvoiceDto {
|
||||
@ApiPropertyOptional({ description: "Payment method (defaults to TELEBIRR)." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
method?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" })
|
||||
@IsOptional()
|
||||
@IsIn(["web", "mobile"])
|
||||
platform?: "web" | "mobile";
|
||||
|
||||
@ApiPropertyOptional({ description: "Payer account / phone, for wallet methods." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
payerAccount?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Browser redirect URL on success." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
returnUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Browser redirect URL on failure." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
failureUrl?: string;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
import { Invoice } from "./invoice.entity";
|
||||
|
||||
@Entity({ schema: "freight", name: "invoice_lines" })
|
||||
export class InvoiceLine extends BaseEntity {
|
||||
@Column({ name: "invoice_id", type: "uuid", nullable: false })
|
||||
invoiceId!: string;
|
||||
|
||||
@ManyToOne(() => Invoice, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "invoice_id" })
|
||||
invoice!: Invoice;
|
||||
|
||||
@Column({ name: "charge_type", type: "varchar", nullable: false })
|
||||
chargeType!: string;
|
||||
|
||||
@Column({ name: "description", type: "varchar", length: 255, nullable: true })
|
||||
description?: string;
|
||||
|
||||
/** Units this line bills for (e.g. container count, wagon count, tons). */
|
||||
@Column({ name: "quantity", type: "numeric", precision: 12, scale: 2, default: 1 })
|
||||
quantity!: number;
|
||||
|
||||
/** Price per unit; `amount` is normally `quantity * unitRate`. */
|
||||
@Column({ name: "unit_rate", type: "numeric", precision: 14, scale: 2, default: 0 })
|
||||
unitRate!: number;
|
||||
|
||||
@Column({
|
||||
name: "amount",
|
||||
type: "numeric",
|
||||
precision: 14,
|
||||
scale: 2,
|
||||
nullable: false,
|
||||
})
|
||||
amount!: number;
|
||||
|
||||
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
|
||||
currency!: string;
|
||||
|
||||
@Column({ name: "metadata", type: "jsonb", nullable: true })
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
@@ -1,17 +1,60 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Freight } from "@edr/types";
|
||||
import { Column, Entity } from "typeorm";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
import { PaymentEntity } from "../../payment/entities/payment.entity";
|
||||
import { Company } from "../../companies/entities/company.entity";
|
||||
import { CompanyProfile } from "../../companies/entities/company-profile.entity";
|
||||
|
||||
@Entity({schema:"freight", name: "invoices" })
|
||||
/** A single recorded settlement against an invoice (payment ledger entry). */
|
||||
export interface InvoicePayment {
|
||||
amount: number;
|
||||
method?: string | null;
|
||||
reference?: string | null;
|
||||
/** ISO timestamp of when the settlement was recorded. */
|
||||
paidAt: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "invoices" })
|
||||
@Index(["companyId"])
|
||||
@Index(["companyProfileId"])
|
||||
export class Invoice extends BaseEntity {
|
||||
@Column({ name: "booking_id", type: "uuid" })
|
||||
bookingId!: string;
|
||||
|
||||
@Column({ name: "invoice_number", type: "varchar", length: 64, unique: true })
|
||||
invoiceNumber!: string;
|
||||
|
||||
@Column({ name: "amount", type: "numeric", precision: 14, scale: 2 })
|
||||
amount!: number;
|
||||
/** The customer (company) this invoice is billed to. */
|
||||
@Column({ name: "company_id", type: "uuid" })
|
||||
companyId!: string;
|
||||
|
||||
@ManyToOne(() => Company)
|
||||
@JoinColumn({ name: "company_id" })
|
||||
company?: Company;
|
||||
|
||||
/** The specific company profile (importer/exporter/forwarder/...) billed. */
|
||||
@Column({ name: "company_profile_id", type: "uuid" })
|
||||
companyProfileId!: string;
|
||||
|
||||
@ManyToOne(() => CompanyProfile)
|
||||
@JoinColumn({ name: "company_profile_id" })
|
||||
companyProfile?: CompanyProfile;
|
||||
|
||||
/** Sum of line amounts before tax; defaults to `totalAmount` for tax-free invoices. */
|
||||
@Column({ name: "subtotal_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
|
||||
subtotalAmount!: number;
|
||||
|
||||
@Column({ name: "tax_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
|
||||
taxAmount!: number;
|
||||
|
||||
@Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 })
|
||||
totalAmount!: number;
|
||||
|
||||
/** Cumulative amount settled so far (supports partial payment). */
|
||||
@Column({ name: "paid_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
|
||||
paidAmount!: number;
|
||||
|
||||
/** Outstanding balance = `totalAmount - paidAmount` (0 once fully paid). */
|
||||
@Column({ name: "balance_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
|
||||
balanceAmount!: number;
|
||||
|
||||
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
|
||||
currency!: string;
|
||||
@@ -19,13 +62,46 @@ export class Invoice extends BaseEntity {
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "enum",
|
||||
enum: Freight.PaymentStatus,
|
||||
default: Freight.PaymentStatus.Pending,
|
||||
enum: Freight.InvoiceStatus,
|
||||
default: Freight.InvoiceStatus.Draft,
|
||||
})
|
||||
status!: Freight.PaymentStatus;
|
||||
status!: Freight.InvoiceStatus;
|
||||
|
||||
@Column({ name: "issued_at", type: "timestamptz" })
|
||||
issuedAt!: Date;
|
||||
/** The source of the payment (e.g. booking, customer, etc.). */
|
||||
@Column({ name: "source", type: "varchar", length: 255, nullable: false })
|
||||
source!: string;
|
||||
|
||||
/** The ID of the source (e.g. booking ID, customer ID, etc.). */
|
||||
@Column({ name: "source_id", type: "varchar", length: 255, nullable: false })
|
||||
sourceId!: string;
|
||||
|
||||
/** The type of Invoice (e.g. prepaid, credit, etc.). it suppose to answer the question "what is the invoice for?" */
|
||||
@Column({
|
||||
type: "varchar",
|
||||
length: 255,
|
||||
nullable: false,
|
||||
})
|
||||
type!: string;
|
||||
|
||||
/** Set when the invoice is actually issued (DRAFT invoices leave this null). */
|
||||
@Column({ name: "issued_at", type: "timestamptz", nullable: true })
|
||||
issuedAt?: Date | null;
|
||||
|
||||
/** Set when the invoice is fully settled. */
|
||||
@Column({ name: "paid_at", type: "timestamptz", nullable: true })
|
||||
paidAt?: Date | null;
|
||||
|
||||
/** Ledger of individual settlements (manual or gateway), newest last. */
|
||||
@Column({ name: "payments", type: "jsonb", default: () => "'[]'" })
|
||||
payments!: InvoicePayment[];
|
||||
|
||||
/** The ID of the payment that generated this invoice. */
|
||||
@Column({ name: "payment_id", type: "uuid", nullable: true })
|
||||
paymentId?: string | null;
|
||||
|
||||
@ManyToOne(() => PaymentEntity)
|
||||
@JoinColumn({ name: "payment_id" })
|
||||
payment?: PaymentEntity;
|
||||
|
||||
@Column({ name: "due_at", type: "timestamptz" })
|
||||
dueAt!: Date;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
|
||||
@Injectable()
|
||||
export class InvoiceLineRepository extends BaseRepository<InvoiceLine> {
|
||||
constructor(
|
||||
@InjectRepository(InvoiceLine) repository: Repository<InvoiceLine>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Shared per-day sequential invoice numbering, used by every billing source
|
||||
* (freight `FRT-…`, warehouse fees `WHF-…`, …) so the format and the
|
||||
* `MAX(seq)+1` allocation live in one place instead of being copy-pasted per
|
||||
* service.
|
||||
*
|
||||
* Produces `<CODE>-YYYYMMDD-00001`: the sequence is the max existing suffix for
|
||||
* the day + 1. Run inside the caller's transaction (pass that transaction's
|
||||
* manager) so concurrent generation within a transaction stays consistent.
|
||||
*/
|
||||
|
||||
/** Anything exposing TypeORM's `.query` — an `EntityManager` or `DataSource`. */
|
||||
export interface SqlRunner {
|
||||
query(sql: string, params?: unknown[]): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface InvoiceNumberOptions {
|
||||
/** Schema-qualified table to scan, e.g. `freight.invoices`. */
|
||||
table: string;
|
||||
/** Document code prefix, e.g. `FRT` or `WHF`. */
|
||||
code: string;
|
||||
/** Column holding the number; defaults to `invoice_number`. */
|
||||
column?: string;
|
||||
/** Clock injection point (tests); defaults to now. */
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export async function nextDailyInvoiceNumber(
|
||||
runner: SqlRunner,
|
||||
opts: InvoiceNumberOptions,
|
||||
): Promise<string> {
|
||||
const now = opts.now ?? new Date();
|
||||
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
|
||||
const prefix = `${opts.code}-${ymd}-`;
|
||||
const column = opts.column ?? "invoice_number";
|
||||
|
||||
// Serialize concurrent allocation for this exact day+code prefix so two
|
||||
// simultaneous transactions can't both read the same MAX(seq) and mint a
|
||||
// duplicate number. Session-scoped to the caller's transaction — released
|
||||
// automatically on commit/rollback. Different prefixes hash to different
|
||||
// keys and never contend with each other.
|
||||
await runner.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [prefix]);
|
||||
|
||||
const rows = (await runner.query(
|
||||
`SELECT COALESCE(MAX(CAST(split_part(${column}, '-', 3) AS int)), 0) AS seq
|
||||
FROM ${opts.table} WHERE ${column} LIKE $1`,
|
||||
[`${prefix}%`],
|
||||
)) as Array<{ seq: number | string }>;
|
||||
const next = Number(rows[0]?.seq ?? 0) + 1;
|
||||
return `${prefix}${String(next).padStart(5, "0")}`;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Shared payment/settlement math for invoices. Both the global
|
||||
* `BillingService.recordPayment` and the warehouse fee invoice flow apply a
|
||||
* payment the same way — accumulate `paidAmount`, derive the outstanding
|
||||
* `balanceAmount`, and decide whether the invoice is now fully settled. Keeping
|
||||
* it here means the two flows can never drift on rounding or the
|
||||
* partial-vs-full threshold.
|
||||
*/
|
||||
|
||||
/** Round to 2 decimals, avoiding binary float drift. */
|
||||
export const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||
|
||||
export interface SettlementResult {
|
||||
/** New cumulative amount paid. */
|
||||
paidAmount: number;
|
||||
/** Remaining balance (0 once fully paid). */
|
||||
balanceAmount: number;
|
||||
/** True once the balance reaches zero. */
|
||||
fullyPaid: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single payment of `amount` to an invoice with `totalAmount` already
|
||||
* carrying `currentPaid`. Caller is responsible for validating `amount > 0` and
|
||||
* the invoice being in a payable state.
|
||||
*/
|
||||
export function applySettlement(
|
||||
totalAmount: number,
|
||||
currentPaid: number,
|
||||
amount: number,
|
||||
): SettlementResult {
|
||||
const total = Number(totalAmount);
|
||||
const paidAmount = round2(Number(currentPaid) + Number(amount));
|
||||
const balanceAmount = Math.max(0, round2(total - paidAmount));
|
||||
return { paidAmount, balanceAmount, fullyPaid: paidAmount >= total };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { Invoice } from "./entities/invoice.entity";
|
||||
|
||||
@Injectable()
|
||||
export class InvoiceRepository extends BaseRepository<Invoice> {
|
||||
constructor(
|
||||
@InjectRepository(Invoice) repository: Repository<Invoice>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
177
apps/edr-freight-api/src/modules/billing/payment.controller.ts
Normal file
177
apps/edr-freight-api/src/modules/billing/payment.controller.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiOkResponse,
|
||||
ApiProduces,
|
||||
} from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
import { Public } from "@edr/api-common";
|
||||
|
||||
import { BillingService } from "./billing.service";
|
||||
import {
|
||||
InitiatePaymentDto,
|
||||
InitiateResponseDto,
|
||||
PaymentMethodTypeEnum,
|
||||
PaymentPlatformDto,
|
||||
} from "../payment/payments.dto";
|
||||
|
||||
/**
|
||||
* Central payment entrypoints. Domain-agnostic — the caller supplies an
|
||||
* invoice ID and the billing service resolves the amount and drives the
|
||||
* gateway. The domain never talks to the payment service directly.
|
||||
* Routes are unchanged (`/payments/*`) so the portal is unaffected.
|
||||
*/
|
||||
@ApiTags("Payment")
|
||||
@Controller("payments")
|
||||
export class PaymentController {
|
||||
constructor(private readonly billing: BillingService) { }
|
||||
|
||||
@Post("initiate")
|
||||
@ApiOperation({
|
||||
summary: "Initiate payment for an invoice",
|
||||
description: "Charges the invoice through the payment gateway.",
|
||||
})
|
||||
@ApiOkResponse({ type: InitiateResponseDto })
|
||||
initiate(@Body() dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||||
return this.billing.payInvoice(dto.invoiceId, {
|
||||
method: dto.method,
|
||||
platform: dto.platform,
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl: dto.returnUrl,
|
||||
failureUrl: dto.failureUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("checkout")
|
||||
@Public()
|
||||
@ApiOperation({
|
||||
summary: "Browser checkout redirect",
|
||||
description:
|
||||
"Charges the invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
|
||||
})
|
||||
@ApiQuery({ name: "invoiceId", required: true })
|
||||
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
|
||||
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
|
||||
@ApiProduces("text/html")
|
||||
async checkout(
|
||||
@Query("invoiceId") invoiceId: string,
|
||||
@Query("method") method: PaymentMethodTypeEnum,
|
||||
@Query("platform") platform: PaymentPlatformDto = "web",
|
||||
@Res() res: Response,
|
||||
) {
|
||||
if (!invoiceId) {
|
||||
return res
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.type("html")
|
||||
.send(this.buildErrorHtml("Missing required query parameter: invoiceId"));
|
||||
}
|
||||
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
|
||||
return res
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.type("html")
|
||||
.send(this.buildErrorHtml("Missing or invalid query parameter: method"));
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.billing.payInvoice(
|
||||
invoiceId,
|
||||
{ method, platform },
|
||||
);
|
||||
const url =
|
||||
result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined;
|
||||
|
||||
if (url) {
|
||||
return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url));
|
||||
}
|
||||
return res
|
||||
.status(HttpStatus.OK)
|
||||
.type("html")
|
||||
.send(this.buildStatusHtml(result.status, result.intentId));
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "An unexpected error occurred";
|
||||
return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message));
|
||||
}
|
||||
}
|
||||
|
||||
private buildRedirectHtml(url: string): string {
|
||||
const escaped = url.replace(/\"/g, """);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="refresh" content="0;url=${escaped}">
|
||||
<title>Redirecting to payment…</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
p { color: #555; margin: 0 0 16px; }
|
||||
a { color: #1a73e8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="spinner"></div>
|
||||
<p>Redirecting to payment provider…</p>
|
||||
<p><a href="${escaped}">Click here if you are not redirected</a></p>
|
||||
</div>
|
||||
<script>window.location.href = "${escaped}";</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildStatusHtml(status: string, intentId: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Payment status</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
|
||||
small { color: #888; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="status">${status}</div>
|
||||
<small>Intent: ${intentId}</small>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildErrorHtml(message: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Payment error</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
|
||||
p { color: #555; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="error">Payment could not be initiated</div>
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
|
||||
import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from "../../common/resolve-auth-user-id";
|
||||
import { sendPdf } from "./billing.controller";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { PayInvoiceDto } from "./dto/pay-invoice.dto";
|
||||
|
||||
/**
|
||||
* Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
|
||||
* org-wide), every route here is force-scoped to the signed-in customer's
|
||||
* company — they only ever see and pay their own invoices.
|
||||
*/
|
||||
@ApiTags("billing")
|
||||
@ApiBearerAuth()
|
||||
@Controller("billing")
|
||||
export class PortalBillingController {
|
||||
constructor(private readonly billingService: BillingService) {}
|
||||
|
||||
@Get("my-invoices")
|
||||
@ApiOperation({ summary: "List the signed-in customer's invoices" })
|
||||
findMine(
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Query("source") source?: string,
|
||||
@Query("sourceId") sourceId?: string,
|
||||
) {
|
||||
return this.billingService.findForUser(resolveAuthUserId(user), {
|
||||
source,
|
||||
sourceId,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("my-invoices/:id")
|
||||
@ApiOperation({ summary: "Get one of the customer's invoices (+ line items)" })
|
||||
findMineById(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.billingService.findByIdForUser(id, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Get("my-invoices/:id/document")
|
||||
@ApiOperation({ summary: "Download one of the customer's invoice PDFs" })
|
||||
async document(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { filename, buffer } = await this.billingService.documentForUser(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
sendPdf(res, filename, buffer);
|
||||
}
|
||||
|
||||
@Get("my-invoices/:id/receipt")
|
||||
@ApiOperation({ summary: "Download one of the customer's payment receipt PDFs" })
|
||||
async receipt(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { filename, buffer } = await this.billingService.receiptForUser(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
sendPdf(res, filename, buffer);
|
||||
}
|
||||
|
||||
@Post("my-invoices/:id/pay")
|
||||
@ApiOperation({ summary: "Initiate payment for one of the customer's invoices" })
|
||||
pay(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto: PayInvoiceDto,
|
||||
) {
|
||||
return this.billingService.payInvoiceForUser(id, resolveAuthUserId(user), {
|
||||
method: dto.method,
|
||||
platform: dto.platform ?? "web",
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl: dto.returnUrl,
|
||||
failureUrl: dto.failureUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { BookingOrdersService } from './booking-orders.service';
|
||||
import { CreateBookingOrderDto } from './dto/create-booking-order.dto';
|
||||
import { GeneralContractService } from './general-contract.service';
|
||||
|
||||
@ApiTags('Booking Orders')
|
||||
@Controller('booking-orders')
|
||||
export class BookingOrdersController {
|
||||
constructor(
|
||||
private readonly ordersService: BookingOrdersService,
|
||||
private readonly generalContractService: GeneralContractService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Place a drawdown order against a general contract' })
|
||||
async create(
|
||||
@Body() dto: CreateBookingOrderDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.ordersService.create(dto, user?.id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List orders placed against a contract' })
|
||||
async list(@Query('contractBookingId', ParseUUIDPipe) contractBookingId: string) {
|
||||
return this.ordersService.listByContract(contractBookingId);
|
||||
}
|
||||
|
||||
@Get('contract/:id/pool')
|
||||
@ApiOperation({
|
||||
summary: 'Contracted / ordered / remaining quantities for a general contract',
|
||||
})
|
||||
async pool(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.generalContractService.getQuantityLines(id);
|
||||
}
|
||||
|
||||
@Get('contract/:id/routes')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Per-route contracted / ordered / remaining quantities (multi-route contracts). Empty for single-route.',
|
||||
})
|
||||
async routes(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.generalContractService.getRouteLines(id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a single booking order' })
|
||||
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.ordersService.findById(id);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { CompaniesModule } from '../companies/companies.module';
|
||||
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
import { BookingOrdersController } from './booking-orders.controller';
|
||||
import { BookingOrdersRepository } from './booking-orders.repository';
|
||||
import { BookingOrdersService } from './booking-orders.service';
|
||||
import { BookingOrder } from './entities/booking-order.entity';
|
||||
import { BookingOrderLine } from './entities/booking-order-line.entity';
|
||||
import { ContractRouteLine } from './entities/contract-route-line.entity';
|
||||
import { GeneralContractService } from './general-contract.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([BookingOrder, BookingOrderLine, ContractRouteLine]),
|
||||
BookingsModule,
|
||||
CompaniesModule,
|
||||
DropdownSettingsModule,
|
||||
RuleEngineModule,
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
],
|
||||
controllers: [BookingOrdersController],
|
||||
providers: [
|
||||
BookingOrdersService,
|
||||
BookingOrdersRepository,
|
||||
GeneralContractService,
|
||||
],
|
||||
exports: [BookingOrdersService, GeneralContractService],
|
||||
})
|
||||
export class BookingOrdersModule {}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BookingOrder } from './entities/booking-order.entity';
|
||||
|
||||
@Injectable()
|
||||
export class BookingOrdersRepository extends BaseRepository<BookingOrder> {
|
||||
constructor(
|
||||
@InjectRepository(BookingOrder)
|
||||
repository: Repository<BookingOrder>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** Orders placed against a given contract, newest first, with their lines. */
|
||||
findByContract(contractBookingId: string): Promise<BookingOrder[]> {
|
||||
return this.repository.find({
|
||||
where: { contractBookingId },
|
||||
relations: { lines: { containerType: true }, booking: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
override findById(id: string): Promise<BookingOrder | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id },
|
||||
relations: { lines: { containerType: true }, booking: true, contractBooking: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** Count this calendar year's orders, for reference generation. */
|
||||
async countByYear(year: number): Promise<number> {
|
||||
const start = new Date(Date.UTC(year, 0, 1));
|
||||
const end = new Date(Date.UTC(year + 1, 0, 1));
|
||||
return this.repository
|
||||
.createQueryBuilder('o')
|
||||
.where('o.createdAt >= :start AND o.createdAt < :end', { start, end })
|
||||
.getCount();
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { BookingOrdersService } from './booking-orders.service';
|
||||
|
||||
/**
|
||||
* Phase-0 spine: a drawdown order spawns a PRICED, UNPAID child booking that
|
||||
* waits for Marketing review (or the customs clearance gate first) — it does
|
||||
* NOT auto-enter the train batch pool, and the contract is not charged.
|
||||
*/
|
||||
describe('BookingOrdersService — child spawn on order create', () => {
|
||||
function makeService(opts: { includesCustoms: boolean; roadKm?: number | null }) {
|
||||
const contract = {
|
||||
id: 'c-1',
|
||||
bookingType: 'GENERAL_CONTRACT',
|
||||
status: 'CONTRACT_ACTIVE',
|
||||
expiresAt: new Date('2030-01-01T00:00:00.000Z'),
|
||||
freightType: 'BULK',
|
||||
originYardId: 'o-1',
|
||||
destinationYardId: 'd-1',
|
||||
companyId: null,
|
||||
paymentCurrency: 'ETB',
|
||||
serviceType: { includesCustoms: opts.includesCustoms, code: 'RAIL_BULK' },
|
||||
bookingContainers: [],
|
||||
};
|
||||
|
||||
// Capture what status the child is created with.
|
||||
const created: Record<string, unknown>[] = [];
|
||||
const managerUpdates: Record<string, unknown>[] = [];
|
||||
const fakeManager = {
|
||||
create: (_entity: unknown, data: Record<string, unknown>) => {
|
||||
created.push(data);
|
||||
return { id: 'child-1', ...data };
|
||||
},
|
||||
save: async (row: Record<string, unknown>) => ({ id: 'child-1', ...row }),
|
||||
getRepository: () => ({
|
||||
findOne: async () => ({ id: 'child-1', paymentCurrency: 'ETB', bookingContainers: [] }),
|
||||
update: async (_id: string, data: Record<string, unknown>) => {
|
||||
managerUpdates.push(data);
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const dataSource = {
|
||||
transaction: async (cb: (m: unknown) => Promise<unknown>) => cb(fakeManager),
|
||||
getRepository: () => ({ update: jest.fn() }),
|
||||
};
|
||||
const ordersRepository = {
|
||||
countByYear: jest.fn().mockResolvedValue(0),
|
||||
findById: jest.fn().mockResolvedValue({ id: 'order-1', lines: [] }),
|
||||
};
|
||||
const bookingsRepository = {
|
||||
findById: jest.fn().mockResolvedValue(contract),
|
||||
countByYear: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
const generalContractService = {
|
||||
isGeneralContract: () => true,
|
||||
getRouteLines: jest.fn().mockResolvedValue([]),
|
||||
getQuantityLines: jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ containerTypeId: null, remainingQuantity: 100, containerTypeName: null },
|
||||
]),
|
||||
isExhausted: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
const pricingService = {
|
||||
computePriceForBooking: jest.fn().mockResolvedValue({
|
||||
totalAmount: 500,
|
||||
priorityScore: 10,
|
||||
lineItems: [],
|
||||
currency: 'ETB',
|
||||
}),
|
||||
};
|
||||
const ratesService = { findLiveRates: jest.fn().mockResolvedValue([]) };
|
||||
const trainSchedulingService = {
|
||||
existsOpenScheduleOnRouteDay: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const companiesService = {};
|
||||
|
||||
const service = new BookingOrdersService(
|
||||
dataSource as never,
|
||||
ordersRepository as never,
|
||||
bookingsRepository as never,
|
||||
companiesService as never,
|
||||
generalContractService as never,
|
||||
pricingService as never,
|
||||
ratesService as never,
|
||||
trainSchedulingService as never,
|
||||
);
|
||||
return { service, created, managerUpdates, pricingService };
|
||||
}
|
||||
|
||||
const dto = {
|
||||
contractBookingId: 'c-1',
|
||||
scheduledDate: '2026-07-01T00:00:00.000Z',
|
||||
lines: [{ quantity: 10, hazardousQuantity: 4, reeferQuantity: 0 }],
|
||||
};
|
||||
|
||||
it('spawns the child at OPERATION_REQUEST_PENDING (no customs), priced + unpaid', async () => {
|
||||
const { service, created, managerUpdates, pricingService } = makeService({
|
||||
includesCustoms: false,
|
||||
});
|
||||
await service.create(dto as never);
|
||||
|
||||
const child = created.find((c) => c.bookingType === 'ONE_TIME')!;
|
||||
expect(child.status).toBe('OPERATION_REQUEST_PENDING');
|
||||
expect(child.paymentStatus).toBe('PENDING');
|
||||
expect(child.isHazardous).toBe(true); // line has hazardousQuantity > 0
|
||||
expect(pricingService.computePriceForBooking).toHaveBeenCalled();
|
||||
// The computed price is persisted onto the child.
|
||||
expect(managerUpdates.some((u) => u.totalAmount === 500)).toBe(true);
|
||||
});
|
||||
|
||||
it('spawns the child at AWAITING_DOCUMENTS when the service includes customs', async () => {
|
||||
const { service, created } = makeService({ includesCustoms: true });
|
||||
await service.create(dto as never);
|
||||
const child = created.find((c) => c.bookingType === 'ONE_TIME')!;
|
||||
expect(child.status).toBe('AWAITING_DOCUMENTS');
|
||||
});
|
||||
|
||||
it('rejects when hazardous quantity exceeds the line quantity', async () => {
|
||||
const { service } = makeService({ includesCustoms: false });
|
||||
await expect(
|
||||
service.create({
|
||||
...dto,
|
||||
lines: [{ quantity: 5, hazardousQuantity: 9, reeferQuantity: 0 }],
|
||||
} as never),
|
||||
).rejects.toThrow(/exceed the line quantity/);
|
||||
});
|
||||
});
|
||||
@@ -1,469 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { BookingOrdersRepository } from './booking-orders.repository';
|
||||
import { CreateBookingOrderDto } from './dto/create-booking-order.dto';
|
||||
import { BookingOrder } from './entities/booking-order.entity';
|
||||
import { BookingOrderLine } from './entities/booking-order-line.entity';
|
||||
import { GeneralContractService } from './general-contract.service';
|
||||
import { isRoadService, roadKmPrice } from './road.util';
|
||||
|
||||
@Injectable()
|
||||
export class BookingOrdersService {
|
||||
private readonly logger = new Logger(BookingOrdersService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly ordersRepository: BookingOrdersRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly generalContractService: GeneralContractService,
|
||||
private readonly pricingService: BookingPricingService,
|
||||
private readonly ratesService: RatesService,
|
||||
@Inject(forwardRef(() => TrainSchedulingService))
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
) {}
|
||||
|
||||
/** Orders placed against a contract, with their lines and child booking. */
|
||||
async listByContract(contractBookingId: string): Promise<BookingOrder[]> {
|
||||
const orders = await this.ordersRepository.findByContract(contractBookingId);
|
||||
await Promise.all(orders.map((o) => this.syncOrderFromChild(o)));
|
||||
return orders;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<BookingOrder | null> {
|
||||
const order = await this.ordersRepository.findById(id);
|
||||
if (order) await this.syncOrderFromChild(order);
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* The order is a ledger row; the spawned child ONE_TIME booking is what
|
||||
* actually moves through the workflow (clearance → marketing/ops accept →
|
||||
* pay → allocate), exactly like a one-time booking. Nothing writes the order
|
||||
* row after creation, so its stored status would stay 'PENDING' forever.
|
||||
*
|
||||
* Mirror the child onto the order whenever it is read: copy the child's
|
||||
* status, schedulingStatus and trainScheduleId onto the order (mutating the
|
||||
* in-memory instance the caller gets back), and persist that snapshot when it
|
||||
* has drifted so list/detail views and any stored reporting stay in sync.
|
||||
*/
|
||||
private async syncOrderFromChild(order: BookingOrder): Promise<void> {
|
||||
const child = order.booking;
|
||||
if (!child) return;
|
||||
|
||||
const nextStatus = child.status;
|
||||
const nextScheduling = child.schedulingStatus;
|
||||
const nextTrainScheduleId = child.trainScheduleId ?? null;
|
||||
|
||||
const drifted =
|
||||
order.status !== nextStatus ||
|
||||
order.schedulingStatus !== nextScheduling ||
|
||||
(order.trainScheduleId ?? null) !== nextTrainScheduleId;
|
||||
|
||||
// Reflect the child onto the instance returned to the caller.
|
||||
order.status = nextStatus;
|
||||
order.schedulingStatus = nextScheduling;
|
||||
order.trainScheduleId = nextTrainScheduleId;
|
||||
|
||||
if (drifted) {
|
||||
await this.ordersRepository.update(order.id, {
|
||||
status: nextStatus,
|
||||
schedulingStatus: nextScheduling,
|
||||
trainScheduleId: nextTrainScheduleId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a drawdown order against an ACTIVE general contract.
|
||||
*
|
||||
* Validates the requested quantities against the remaining pool, then spawns a
|
||||
* ONE_TIME child Booking (PAID + FULLY_EXECUTED, inheriting the contract's
|
||||
* route/cargo/service) so it flows through the existing train-scheduling
|
||||
* pipeline. The order row is the ledger entry linking contract → child booking.
|
||||
*/
|
||||
async create(
|
||||
dto: CreateBookingOrderDto,
|
||||
userId?: string,
|
||||
): Promise<BookingOrder> {
|
||||
const contract = await this.bookingsRepository.findById(dto.contractBookingId);
|
||||
if (!contract) {
|
||||
throw new NotFoundException(`Contract ${dto.contractBookingId} not found`);
|
||||
}
|
||||
if (!this.generalContractService.isGeneralContract(contract)) {
|
||||
throw new BadRequestException('Booking is not a general contract');
|
||||
}
|
||||
if (contract.status !== 'CONTRACT_ACTIVE') {
|
||||
throw new BadRequestException(
|
||||
`Contract is ${contract.status} — orders can only be placed against an ACTIVE contract`,
|
||||
);
|
||||
}
|
||||
if (contract.expiresAt && contract.expiresAt.getTime() <= Date.now()) {
|
||||
throw new BadRequestException('Contract ordering window has expired');
|
||||
}
|
||||
|
||||
// The customer placing the order must own the contract.
|
||||
if (userId && !(await this.userOwnsContract(userId, contract))) {
|
||||
throw new BadRequestException('You do not have access to this contract');
|
||||
}
|
||||
|
||||
// Resolve the route the order ships on: a chosen contract route line for a
|
||||
// multi-route contract, else the contract's own origin/destination.
|
||||
const routeLines = await this.generalContractService.getRouteLines(
|
||||
contract.id,
|
||||
);
|
||||
let originYardId = contract.originYardId;
|
||||
let destinationYardId = contract.destinationYardId;
|
||||
let routeLineId: string | null = null;
|
||||
let routeKm: number | null = null;
|
||||
|
||||
if (routeLines.length > 0) {
|
||||
if (!dto.routeLineId) {
|
||||
throw new BadRequestException(
|
||||
'This contract has multiple routes — select a route to draw from',
|
||||
);
|
||||
}
|
||||
const chosen = routeLines.find((r) => r.routeLineId === dto.routeLineId);
|
||||
if (!chosen) {
|
||||
throw new BadRequestException(
|
||||
'Selected route is not part of this contract',
|
||||
);
|
||||
}
|
||||
originYardId = chosen.originYardId;
|
||||
destinationYardId = chosen.destinationYardId;
|
||||
routeLineId = chosen.routeLineId;
|
||||
routeKm = chosen.km ?? null;
|
||||
}
|
||||
|
||||
// Validate the route has a departure on the chosen day.
|
||||
const day = eatDay(new Date(dto.scheduledDate));
|
||||
const hasDeparture =
|
||||
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
day,
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
'No departures available on the selected day for this route',
|
||||
);
|
||||
}
|
||||
|
||||
const isContainer = contract.freightType === 'CONTAINER';
|
||||
|
||||
// Hazardous/reefer counts the customer entered cannot exceed the line they
|
||||
// belong to. Validated for every order regardless of routing.
|
||||
for (const line of dto.lines) {
|
||||
const haz = line.hazardousQuantity ?? 0;
|
||||
const reefer = line.reeferQuantity ?? 0;
|
||||
if (haz < 0 || reefer < 0) {
|
||||
throw new BadRequestException('Hazardous/reefer quantities cannot be negative');
|
||||
}
|
||||
if (haz > line.quantity || reefer > line.quantity) {
|
||||
throw new BadRequestException(
|
||||
'Hazardous/reefer quantity cannot exceed the line quantity',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The contract has a single shared drawdown pool (per container type for
|
||||
// CONTAINER, or one bulk bucket). Routes are pure lanes — the chosen route
|
||||
// only fixed origin/destination/km above — so every order, routed or not,
|
||||
// validates each line against the same shared pool.
|
||||
const poolLines = await this.generalContractService.getQuantityLines(
|
||||
contract.id,
|
||||
);
|
||||
for (const line of dto.lines) {
|
||||
if (line.quantity <= 0) {
|
||||
throw new BadRequestException('Order quantities must be greater than zero');
|
||||
}
|
||||
const key = isContainer ? (line.containerTypeId ?? '') : '';
|
||||
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
|
||||
if (!poolLine) {
|
||||
throw new BadRequestException(
|
||||
isContainer
|
||||
? `Container type ${line.containerTypeId} is not part of this contract`
|
||||
: 'This contract has no matching quantity pool',
|
||||
);
|
||||
}
|
||||
if (line.quantity > poolLine.remainingQuantity) {
|
||||
throw new BadRequestException(
|
||||
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
|
||||
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the order + its child shipment booking atomically.
|
||||
const order = await this.dataSource.transaction(async (manager) => {
|
||||
const childBooking = await this.spawnChildBooking(
|
||||
contract,
|
||||
dto,
|
||||
{ originYardId, destinationYardId, km: routeKm },
|
||||
manager,
|
||||
);
|
||||
|
||||
const reference = await this.generateReference();
|
||||
const orderRow = manager.create(BookingOrder, {
|
||||
reference,
|
||||
contractBookingId: contract.id,
|
||||
bookingId: childBooking.id,
|
||||
routeLineId,
|
||||
companyId: contract.companyId ?? null,
|
||||
scheduledDate: new Date(dto.scheduledDate),
|
||||
// The order is a ledger row; the child booking drives the workflow
|
||||
// (review → pay → allocate), so the order tracks PENDING until done.
|
||||
status: 'PENDING',
|
||||
schedulingStatus: 'NOT_SCHEDULED',
|
||||
});
|
||||
const savedOrder = await manager.save(orderRow);
|
||||
|
||||
const lines = dto.lines.map((l) =>
|
||||
manager.create(BookingOrderLine, {
|
||||
orderId: savedOrder.id,
|
||||
containerTypeId: isContainer ? (l.containerTypeId ?? null) : null,
|
||||
quantity: l.quantity,
|
||||
hazardousQuantity: l.hazardousQuantity ?? 0,
|
||||
reeferQuantity: l.reeferQuantity ?? 0,
|
||||
}),
|
||||
);
|
||||
await manager.save(lines);
|
||||
savedOrder.lines = lines;
|
||||
return savedOrder;
|
||||
});
|
||||
|
||||
// The child does NOT enter the train batch pool here. It is priced and
|
||||
// unpaid, awaiting Marketing review (OPERATION_REQUEST_PENDING) or customs
|
||||
// clearance first; the batch enqueue happens only on accept.
|
||||
|
||||
// Close the contract once its pool is exhausted (pending orders count, so
|
||||
// the pool reserves quantity as soon as an order is placed).
|
||||
if (await this.generalContractService.isExhausted(contract.id)) {
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(contract.id, { status: 'CONTRACT_CLOSED' });
|
||||
this.logger.log(
|
||||
`Contract ${contract.reference} CLOSED — quantity exhausted`,
|
||||
);
|
||||
}
|
||||
|
||||
return (await this.ordersRepository.findById(order.id)) ?? order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the ONE_TIME child booking for an order, inheriting the contract's
|
||||
* shipment context. Unlike the contract (which is no longer paid up front),
|
||||
* the child is PRICED and UNPAID and waits for Marketing review — going
|
||||
* through the customs clearance gate first when the service includes customs,
|
||||
* mirroring a one-time booking. It only enters the train pool on accept.
|
||||
*/
|
||||
private async spawnChildBooking(
|
||||
contract: Booking,
|
||||
dto: CreateBookingOrderDto,
|
||||
route: { originYardId: string; destinationYardId: string; km: number | null },
|
||||
manager: import('typeorm').EntityManager,
|
||||
): Promise<Booking> {
|
||||
const reference = await this.generateChildBookingReference();
|
||||
const isContainer = contract.freightType === 'CONTAINER';
|
||||
|
||||
// Sum line quantities × the contract's per-unit weight for the child total.
|
||||
const containerByType = new Map(
|
||||
(contract.bookingContainers ?? []).map((c) => [c.containerTypeId, c]),
|
||||
);
|
||||
let totalWeight = 0;
|
||||
if (isContainer) {
|
||||
for (const line of dto.lines) {
|
||||
const src = containerByType.get(line.containerTypeId ?? '');
|
||||
const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0;
|
||||
totalWeight += vgmPerUnit * line.quantity;
|
||||
}
|
||||
} else {
|
||||
totalWeight = dto.lines.reduce((sum, l) => sum + l.quantity, 0);
|
||||
}
|
||||
|
||||
// Per-order hazardous/reefer: set the child flags from the order's line
|
||||
// counts so the HAZARD_SURCHARGE / REEFER_SURCHARGE rates apply.
|
||||
const hasHazardous = dto.lines.some((l) => (l.hazardousQuantity ?? 0) > 0);
|
||||
const hasReefer = dto.lines.some((l) => (l.reeferQuantity ?? 0) > 0);
|
||||
|
||||
// Customs orders flow through the one-time clearance gate first; others go
|
||||
// straight to operations review with the chosen shipment day.
|
||||
const { includesCustoms } = clearanceCodesForBooking(contract);
|
||||
const spawnStatus = includesCustoms
|
||||
? 'AWAITING_DOCUMENTS'
|
||||
: 'OPERATION_REQUEST_PENDING';
|
||||
|
||||
const child = manager.create(Booking, {
|
||||
reference,
|
||||
companyId: contract.companyId ?? null,
|
||||
companyProfileId: contract.companyProfileId ?? null,
|
||||
isGovernment: contract.isGovernment,
|
||||
governmentInstitution: contract.governmentInstitution ?? null,
|
||||
contractType: contract.contractType,
|
||||
previousContractId: contract.id,
|
||||
serviceTypeId: contract.serviceTypeId,
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||
equipmentReturn: contract.equipmentReturn,
|
||||
originYardId: route.originYardId,
|
||||
destinationYardId: route.destinationYardId,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
freightType: contract.freightType,
|
||||
cargoTypeId: contract.cargoTypeId ?? null,
|
||||
cargoFreeText: contract.cargoFreeText ?? null,
|
||||
shippingLineId: contract.shippingLineId ?? null,
|
||||
cargoTotalWeightVgm: totalWeight,
|
||||
isHazardous: hasHazardous,
|
||||
isReefer: hasReefer,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
bookingType: 'ONE_TIME',
|
||||
scheduledDate: new Date(dto.scheduledDate),
|
||||
// Priced + unpaid: the customer pays this order on its own.
|
||||
status: spawnStatus,
|
||||
paymentStatus: 'PENDING',
|
||||
priorityScore: contract.priorityScore,
|
||||
totalAmount: 0,
|
||||
schedulingStatus: 'NOT_SCHEDULED',
|
||||
});
|
||||
const savedChild = await manager.save(child);
|
||||
|
||||
if (isContainer) {
|
||||
for (const line of dto.lines) {
|
||||
const src = containerByType.get(line.containerTypeId ?? '');
|
||||
const ct = line.containerTypeId
|
||||
? await manager.getRepository(ContainerType).findOne({
|
||||
where: { id: line.containerTypeId },
|
||||
})
|
||||
: null;
|
||||
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
|
||||
const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0;
|
||||
const row = manager.create(BookingContainer, {
|
||||
bookingId: savedChild.id,
|
||||
containerTypeId: line.containerTypeId ?? null,
|
||||
quantity: line.quantity,
|
||||
vgmPerUnitTons: vgmPerUnit,
|
||||
totalVgmTons: vgmPerUnit * line.quantity,
|
||||
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnit),
|
||||
isOverweight: false,
|
||||
});
|
||||
await manager.save(row);
|
||||
}
|
||||
}
|
||||
|
||||
// Price the order: base freight for the drawn quantity + haz/reefer
|
||||
// surcharges, plus a road KM charge when the service ships by road.
|
||||
const roadKm = isRoadService(contract.serviceType) ? route.km : null;
|
||||
await this.priceChildBooking(savedChild.id, roadKm, manager);
|
||||
|
||||
return savedChild;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute and persist the child order's price (base + surcharges) inside the
|
||||
* order transaction. The contract is no longer paid up front, so each order
|
||||
* carries its own total that the customer pays.
|
||||
*/
|
||||
private async priceChildBooking(
|
||||
childId: string,
|
||||
roadKm: number | null,
|
||||
manager: import('typeorm').EntityManager,
|
||||
): Promise<void> {
|
||||
const child = await manager.getRepository(Booking).findOne({
|
||||
where: { id: childId },
|
||||
relations: { bookingContainers: true },
|
||||
});
|
||||
if (!child) return;
|
||||
|
||||
try {
|
||||
const computed = await this.pricingService.computePriceForBooking(child);
|
||||
const lineItems = [...computed.lineItems];
|
||||
let total = computed.totalAmount;
|
||||
|
||||
// Road KM charge: distance × the live PER_KM rate, added as its own line.
|
||||
if (roadKm && roadKm > 0) {
|
||||
const perKmRate = await this.findPerKmRate(child.paymentCurrency);
|
||||
const kmAmount = roadKmPrice(roadKm, perKmRate);
|
||||
if (kmAmount > 0) {
|
||||
lineItems.push({
|
||||
code: 'ROAD_KM',
|
||||
description: `Road transport (${roadKm} km)`,
|
||||
amount: kmAmount,
|
||||
unitAmount: perKmRate!,
|
||||
unit: 'PER_KM',
|
||||
quantity: roadKm,
|
||||
currency: child.paymentCurrency,
|
||||
});
|
||||
total += kmAmount;
|
||||
}
|
||||
}
|
||||
|
||||
await manager.getRepository(Booking).update(childId, {
|
||||
totalAmount: total,
|
||||
priorityScore: computed.priorityScore,
|
||||
pricingBreakdown: {
|
||||
lineItems,
|
||||
totalAmount: total,
|
||||
currency: computed.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
} as never);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Pricing child order ${childId} failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The live PER_KM rate value for road billing, in the given currency. */
|
||||
private async findPerKmRate(currency: string): Promise<number | null> {
|
||||
const rates = await this.ratesService.findLiveRates();
|
||||
const rate = rates.find(
|
||||
(r) => r.rateUnit === 'PER_KM' && r.currency === currency,
|
||||
);
|
||||
return rate ? Number(rate.rateValue) : null;
|
||||
}
|
||||
|
||||
private async userOwnsContract(
|
||||
userId: string,
|
||||
contract: Booking,
|
||||
): Promise<boolean> {
|
||||
if (!contract.companyId) return true; // government / staff-created
|
||||
try {
|
||||
const { company } = await this.companiesService.getCompanyInfoByUserId(
|
||||
userId,
|
||||
);
|
||||
return company.id === contract.companyId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async generateReference(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const count = await this.ordersRepository.countByYear(year);
|
||||
return `ORD-${year}-${String(count + 1).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
private async generateChildBookingReference(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const count = await this.bookingsRepository.countByYear(year);
|
||||
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { CargoUnitOfMeasure } from '@edr/types';
|
||||
|
||||
/** A single contracted/ordered/remaining pool line for a general contract. */
|
||||
export class ContractQuantityLineView {
|
||||
@ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' })
|
||||
containerTypeId!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
containerTypeName!: string | null;
|
||||
|
||||
@ApiProperty({ enum: CargoUnitOfMeasure, nullable: true })
|
||||
unitOfMeasure!: CargoUnitOfMeasure | null;
|
||||
|
||||
@ApiProperty()
|
||||
contractedQuantity!: number;
|
||||
|
||||
@ApiProperty()
|
||||
orderedQuantity!: number;
|
||||
|
||||
@ApiProperty()
|
||||
remainingQuantity!: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A contracted route (lane) of a general contract. Routes are pure
|
||||
* origin→destination lanes the contract covers; they carry NO quantity. The
|
||||
* contract has a single shared drawdown pool (see {@link ContractQuantityLineView}),
|
||||
* and an order picks one lane (for scheduling/billing) while drawing from that
|
||||
* shared pool.
|
||||
*/
|
||||
export class ContractRouteLineView {
|
||||
@ApiProperty({ description: 'Contract route line id' })
|
||||
routeLineId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
originYardId!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
originYardName!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
destinationYardId!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
destinationYardName!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true, description: 'Road distance (km); used to bill road orders' })
|
||||
km!: number | null;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import 'reflect-metadata';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { CreateBookingOrderLineDto } from './create-booking-order.dto';
|
||||
|
||||
/**
|
||||
* Order line haz/reefer quantities arrive as JSON numbers but must default to 0
|
||||
* when omitted and coerce string inputs (defensive) to numbers.
|
||||
*/
|
||||
describe('CreateBookingOrderLineDto — haz/reefer coercion', () => {
|
||||
const toDto = (plain: Record<string, unknown>) =>
|
||||
plainToInstance(CreateBookingOrderLineDto, plain, {
|
||||
enableImplicitConversion: false,
|
||||
exposeDefaultValues: true,
|
||||
}) as unknown as CreateBookingOrderLineDto;
|
||||
|
||||
it('defaults hazardous/reefer quantities to 0 when omitted', () => {
|
||||
const dto = toDto({ quantity: 5 });
|
||||
expect(dto.hazardousQuantity).toBe(0);
|
||||
expect(dto.reeferQuantity).toBe(0);
|
||||
});
|
||||
|
||||
it('coerces provided string quantities to numbers', () => {
|
||||
const dto = toDto({ quantity: '5', hazardousQuantity: '2', reeferQuantity: '3' });
|
||||
expect(dto.quantity).toBe(5);
|
||||
expect(dto.hazardousQuantity).toBe(2);
|
||||
expect(dto.reeferQuantity).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateBookingOrderLineDto {
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Container type for this line (CONTAINER contracts). Omit for bulk/break-bulk.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
containerTypeId?: string;
|
||||
|
||||
@ApiProperty({ description: 'Quantity to draw down (containers, tons, or items)', minimum: 0 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'How much of this line is hazardous (≤ quantity). Defaults to 0.',
|
||||
minimum: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
hazardousQuantity?: number = 0;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'How much of this line is refrigerated (≤ quantity). Defaults to 0.',
|
||||
minimum: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
reeferQuantity?: number = 0;
|
||||
}
|
||||
|
||||
export class CreateBookingOrderDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'The general contract to draw down from' })
|
||||
@IsUUID()
|
||||
contractBookingId!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'For multi-route contracts: the contract route line being drawn from. ' +
|
||||
'Determines the shipment origin/destination. Omit for single-route contracts.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
routeLineId?: string;
|
||||
|
||||
@ApiProperty({ example: '2026-07-01T00:00:00.000Z', description: 'Shipment day for this order' })
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
|
||||
@ApiProperty({ type: [CreateBookingOrderLineDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateBookingOrderLineDto)
|
||||
lines!: CreateBookingOrderLineDto[];
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
|
||||
import { BookingOrder } from './booking-order.entity';
|
||||
|
||||
/**
|
||||
* Postgres `numeric` columns are serialized to JS strings by the driver. This
|
||||
* transformer hydrates them back into real numbers so consumers (and the
|
||||
* `quantity: number` API type) don't have to coerce on every read.
|
||||
*/
|
||||
const numericColumn = {
|
||||
to: (value: number) => value,
|
||||
from: (value: string | null) => (value == null ? value : Number(value)),
|
||||
};
|
||||
|
||||
/**
|
||||
* One drawn-down quantity line of an order. For CONTAINER contracts there is one
|
||||
* line per container type (matching the contract's pools); for BULK/BREAK_BULK a
|
||||
* single line with a null containerTypeId carries the tons/items.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_order_lines' })
|
||||
export class BookingOrderLine extends BaseEntity {
|
||||
@Column({ name: 'order_id', type: 'uuid' })
|
||||
orderId!: string;
|
||||
|
||||
@ManyToOne(() => BookingOrder, (order) => order.lines, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'order_id' })
|
||||
order?: BookingOrder;
|
||||
|
||||
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
|
||||
containerTypeId?: string | null;
|
||||
|
||||
@ManyToOne(() => ContainerType, { nullable: true })
|
||||
@JoinColumn({ name: 'container_type_id' })
|
||||
containerType?: ContainerType | null;
|
||||
|
||||
/** Containers (count), tons, or items depending on the contract's freight/UoM. */
|
||||
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, transformer: numericColumn })
|
||||
quantity!: number;
|
||||
|
||||
/**
|
||||
* How much of this line is hazardous / refrigerated, entered per order by the
|
||||
* customer when they toggle the flag. Drives the HAZARD_SURCHARGE /
|
||||
* REEFER_SURCHARGE rates on the spawned child booking. Both ≤ quantity.
|
||||
*/
|
||||
@Column({
|
||||
name: 'hazardous_quantity',
|
||||
type: 'numeric',
|
||||
precision: 12,
|
||||
scale: 3,
|
||||
default: 0,
|
||||
transformer: numericColumn,
|
||||
})
|
||||
hazardousQuantity!: number;
|
||||
|
||||
@Column({
|
||||
name: 'reefer_quantity',
|
||||
type: 'numeric',
|
||||
precision: 12,
|
||||
scale: 3,
|
||||
default: 0,
|
||||
transformer: numericColumn,
|
||||
})
|
||||
reeferQuantity!: number;
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { SchedulingStatus } from '@edr/types';
|
||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { BookingOrderLine } from './booking-order-line.entity';
|
||||
|
||||
/**
|
||||
* A single drawdown against a general contract. Each order spawns its own
|
||||
* ONE_TIME child Booking (the shipment that enters the train scheduling
|
||||
* pipeline); this row is the ledger entry linking the contract to that
|
||||
* shipment and recording the drawn-down quantities.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_orders' })
|
||||
export class BookingOrder extends BaseEntity {
|
||||
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
|
||||
reference!: string;
|
||||
|
||||
/** The general contract (a Booking with bookingType = GENERAL_CONTRACT). */
|
||||
@Column({ name: 'contract_booking_id', type: 'uuid' })
|
||||
contractBookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking)
|
||||
@JoinColumn({ name: 'contract_booking_id' })
|
||||
contractBooking?: Booking;
|
||||
|
||||
/** The ONE_TIME child shipment booking spawned for this order. */
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId?: string | null;
|
||||
|
||||
@ManyToOne(() => Booking, { nullable: true })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking | null;
|
||||
|
||||
/** Denormalized from the contract for fast company-scoped filtering. */
|
||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
||||
companyId?: string | null;
|
||||
|
||||
@ManyToOne(() => Company, { nullable: true })
|
||||
@JoinColumn({ name: 'company_id' })
|
||||
company?: Company | null;
|
||||
|
||||
/**
|
||||
* The contract route line this order drew down (multi-route general contracts).
|
||||
* Null for legacy/single-route contracts that have no route lines — the order
|
||||
* then uses the contract's own origin/destination.
|
||||
*/
|
||||
@Column({ name: 'route_line_id', type: 'uuid', nullable: true })
|
||||
routeLineId?: string | null;
|
||||
|
||||
@Column({ name: 'scheduled_date', type: 'timestamptz' })
|
||||
scheduledDate!: Date;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 40, default: 'PAID' })
|
||||
status!: string;
|
||||
|
||||
@Column({
|
||||
name: 'scheduling_status',
|
||||
type: 'varchar',
|
||||
length: 30,
|
||||
default: SchedulingStatus.NotScheduled,
|
||||
})
|
||||
schedulingStatus!: string;
|
||||
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||
trainScheduleId?: string | null;
|
||||
|
||||
@OneToMany(() => BookingOrderLine, (line) => line.order, { cascade: true })
|
||||
lines?: BookingOrderLine[];
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
|
||||
/**
|
||||
* One contracted route+quantity line of a GENERAL contract. A general contract
|
||||
* may span several routes (e.g. Addis→Dire Dawa: 10, Modjo→Djibouti: 5); each
|
||||
* route reserves its own quantity pool. Drawdown orders pick one of these routes
|
||||
* and decrement that route's pool. One-time bookings do not use this — they keep
|
||||
* the single origin/destination on the booking itself.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'contract_route_lines' })
|
||||
@Index(['contractBookingId'])
|
||||
export class ContractRouteLine extends BaseEntity {
|
||||
/** The general contract (a Booking with bookingType = GENERAL_CONTRACT). */
|
||||
@Column({ name: 'contract_booking_id', type: 'uuid' })
|
||||
contractBookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking)
|
||||
@JoinColumn({ name: 'contract_booking_id' })
|
||||
contractBooking?: Booking;
|
||||
|
||||
@Column({ name: 'origin_yard_id', type: 'uuid' })
|
||||
originYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'origin_yard_id' })
|
||||
originYard?: Yard;
|
||||
|
||||
@Column({ name: 'destination_yard_id', type: 'uuid' })
|
||||
destinationYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'destination_yard_id' })
|
||||
destinationYard?: Yard;
|
||||
|
||||
/**
|
||||
* Container type this route line reserves (CONTAINER contracts); null for
|
||||
* BULK/BREAK_BULK, where the quantity is tons/items.
|
||||
*/
|
||||
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
|
||||
containerTypeId?: string | null;
|
||||
|
||||
@ManyToOne(() => ContainerType, { nullable: true })
|
||||
@JoinColumn({ name: 'container_type_id' })
|
||||
containerType?: ContainerType | null;
|
||||
|
||||
/** Contracted quantity for this (route, container type): containers, tons, or items. */
|
||||
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
|
||||
quantity!: number;
|
||||
|
||||
/**
|
||||
* Road distance for this route, configured with the route. Road (truck)
|
||||
* drawdown orders bill KM × the PER_KM rate from this value. Null for
|
||||
* rail-only routes where KM is not billed.
|
||||
*/
|
||||
@Column({ name: 'km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
km?: number | null;
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { BookingType, CargoUnitOfMeasure } from '@edr/types';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingOrder } from './entities/booking-order.entity';
|
||||
import { ContractRouteLine } from './entities/contract-route-line.entity';
|
||||
import {
|
||||
ContractQuantityLineView,
|
||||
ContractRouteLineView,
|
||||
} from './dto/contract-view.dto';
|
||||
|
||||
/** Setting code holding the global ordering window (in months) for general contracts. */
|
||||
export const CONTRACT_PERIOD_SETTING_CODE = 'general_contract_period';
|
||||
/** Fallback when the setting is missing or unparseable. */
|
||||
export const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
|
||||
|
||||
/**
|
||||
* Owns general-contract concerns that sit alongside the generic booking flow:
|
||||
* the configurable ordering period, post-payment activation, and computing the
|
||||
* remaining drawdown pool per contract.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GeneralContractService {
|
||||
private readonly logger = new Logger(GeneralContractService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly dropdownSettings: DropdownSettingsService,
|
||||
) {}
|
||||
|
||||
isGeneralContract(booking: Pick<Booking, 'bookingType'>): boolean {
|
||||
return booking.bookingType === BookingType.GeneralContract;
|
||||
}
|
||||
|
||||
/** The configured ordering window in months (defaults to 3). */
|
||||
async getPeriodMonths(): Promise<number> {
|
||||
try {
|
||||
const setting = await this.dropdownSettings.getByCode(
|
||||
CONTRACT_PERIOD_SETTING_CODE,
|
||||
);
|
||||
const raw = setting.children?.[0]?.value;
|
||||
const months = Number(raw);
|
||||
if (Number.isFinite(months) && months > 0) return months;
|
||||
} catch {
|
||||
// Setting not seeded yet — fall back to the default.
|
||||
}
|
||||
return DEFAULT_CONTRACT_PERIOD_MONTHS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a general contract's payment succeeds: mark it ACTIVE (instead of
|
||||
* entering the train queue like a one-time booking) and stamp the ordering
|
||||
* window. Idempotent.
|
||||
*/
|
||||
async activateAfterPayment(bookingId: string): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(Booking);
|
||||
const booking = await repo.findOne({ where: { id: bookingId } });
|
||||
if (!booking || !this.isGeneralContract(booking)) return;
|
||||
if (booking.status === 'CONTRACT_ACTIVE' || booking.status === 'CONTRACT_CLOSED') {
|
||||
return;
|
||||
}
|
||||
|
||||
const months = await this.getPeriodMonths();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setMonth(expiresAt.getMonth() + months);
|
||||
|
||||
await repo.update(bookingId, {
|
||||
status: 'CONTRACT_ACTIVE',
|
||||
paymentStatus: 'PAID',
|
||||
expiresAt,
|
||||
});
|
||||
this.logger.log(
|
||||
`General contract ${booking.reference} ACTIVE — ordering window ${months} month(s) (expires ${expiresAt.toISOString()})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The drawdown pool for a contract: contracted vs. ordered vs. remaining,
|
||||
* per container type for CONTAINER contracts, or a single total line for
|
||||
* BULK/BREAK_BULK (keyed on a null container type).
|
||||
*/
|
||||
async getQuantityLines(
|
||||
contractBookingId: string,
|
||||
): Promise<ContractQuantityLineView[]> {
|
||||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: contractBookingId },
|
||||
relations: { bookingContainers: { containerType: true }, cargoType: true },
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Contract ${contractBookingId} not found`);
|
||||
|
||||
const ordered = await this.orderedByContainerType(contractBookingId);
|
||||
|
||||
if (booking.freightType === 'CONTAINER') {
|
||||
return (booking.bookingContainers ?? []).map((c) => {
|
||||
const orderedQty = ordered.get(c.containerTypeId ?? '') ?? 0;
|
||||
const contracted = Number(c.quantity);
|
||||
return {
|
||||
containerTypeId: c.containerTypeId ?? null,
|
||||
containerTypeName: c.containerType?.label ?? null,
|
||||
unitOfMeasure: null,
|
||||
contractedQuantity: contracted,
|
||||
orderedQuantity: orderedQty,
|
||||
remainingQuantity: Math.max(0, contracted - orderedQty),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// BULK / BREAK_BULK — a single pool keyed on the contracted total weight/items.
|
||||
const orderedQty = ordered.get('') ?? 0;
|
||||
const contracted = Number(booking.cargoTotalWeightVgm);
|
||||
const uom: CargoUnitOfMeasure | null =
|
||||
(booking.cargoType?.unitOfMeasure as CargoUnitOfMeasure | undefined) ??
|
||||
CargoUnitOfMeasure.PerTon;
|
||||
return [
|
||||
{
|
||||
containerTypeId: null,
|
||||
containerTypeName: null,
|
||||
unitOfMeasure: uom,
|
||||
contractedQuantity: contracted,
|
||||
orderedQuantity: orderedQty,
|
||||
remainingQuantity: Math.max(0, contracted - orderedQty),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The contracted routes (lanes) of a multi-route general contract — pure
|
||||
* origin→destination pairs the contract covers. Routes carry NO quantity; the
|
||||
* contract draws from a single shared pool ({@link getQuantityLines}). An order
|
||||
* picks one lane (for scheduling + road billing) and draws from that pool.
|
||||
* Returns [] for single-route contracts (no route lines) — callers then use the
|
||||
* contract's own origin/destination.
|
||||
*/
|
||||
async getRouteLines(
|
||||
contractBookingId: string,
|
||||
): Promise<ContractRouteLineView[]> {
|
||||
const routeLines = await this.dataSource
|
||||
.getRepository(ContractRouteLine)
|
||||
.find({
|
||||
where: { contractBookingId },
|
||||
relations: {
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
},
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
return routeLines.map((rl) => ({
|
||||
routeLineId: rl.id,
|
||||
originYardId: rl.originYardId,
|
||||
originYardName: rl.originYard?.label ?? null,
|
||||
destinationYardId: rl.destinationYardId,
|
||||
destinationYardName: rl.destinationYard?.label ?? null,
|
||||
km: rl.km != null ? Number(rl.km) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */
|
||||
private async orderedByContainerType(
|
||||
contractBookingId: string,
|
||||
): Promise<Map<string, number>> {
|
||||
const rows = await this.dataSource
|
||||
.getRepository(BookingOrder)
|
||||
.createQueryBuilder('o')
|
||||
.innerJoin('o.lines', 'line')
|
||||
.select('COALESCE(line.container_type_id::text, :empty)', 'key')
|
||||
.addSelect('SUM(line.quantity)', 'total')
|
||||
.where('o.contract_booking_id = :contractBookingId', { contractBookingId })
|
||||
.andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`)
|
||||
.setParameter('empty', '')
|
||||
.groupBy('key')
|
||||
.getRawMany<{ key: string; total: string }>();
|
||||
|
||||
const map = new Map<string, number>();
|
||||
for (const row of rows) map.set(row.key ?? '', Number(row.total));
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Convenience: how many units remain for a given container type ('' = bulk). */
|
||||
async remainingFor(
|
||||
contractBookingId: string,
|
||||
containerTypeKey: string,
|
||||
): Promise<number> {
|
||||
const lines = await this.getQuantityLines(contractBookingId);
|
||||
const line = lines.find(
|
||||
(l) => (l.containerTypeId ?? '') === containerTypeKey,
|
||||
);
|
||||
return line?.remainingQuantity ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* True once the contract's shared pool is fully drawn down. Routes are pure
|
||||
* lanes with no quantity, so exhaustion is purely a function of the shared
|
||||
* per-container-type (or bulk) pool, regardless of how many routes exist.
|
||||
*/
|
||||
async isExhausted(contractBookingId: string): Promise<boolean> {
|
||||
const lines = await this.getQuantityLines(contractBookingId);
|
||||
return lines.every((l) => l.remainingQuantity <= 0);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { isRoadService, roadKmPrice } from './road.util';
|
||||
|
||||
describe('road.util', () => {
|
||||
describe('isRoadService', () => {
|
||||
it('treats ROAD/TRUCK codes (and prefixes) as road', () => {
|
||||
expect(isRoadService({ code: 'ROAD' })).toBe(true);
|
||||
expect(isRoadService({ code: 'TRUCK' })).toBe(true);
|
||||
expect(isRoadService({ code: 'ROAD_CONTAINER' })).toBe(true);
|
||||
expect(isRoadService({ code: 'truck_forwarding' })).toBe(true);
|
||||
});
|
||||
|
||||
it('treats rail / unknown / missing services as not road', () => {
|
||||
expect(isRoadService({ code: 'RAIL_CONTAINER' })).toBe(false);
|
||||
expect(isRoadService({ code: 'OFFROADING' })).toBe(false);
|
||||
expect(isRoadService(null)).toBe(false);
|
||||
expect(isRoadService(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roadKmPrice', () => {
|
||||
it('multiplies distance by the per-km rate', () => {
|
||||
expect(roadKmPrice(120, 5)).toBe(600);
|
||||
});
|
||||
|
||||
it('returns 0 when km or rate is missing/non-positive', () => {
|
||||
expect(roadKmPrice(null, 5)).toBe(0);
|
||||
expect(roadKmPrice(120, null)).toBe(0);
|
||||
expect(roadKmPrice(0, 5)).toBe(0);
|
||||
expect(roadKmPrice(120, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { AllocateContainersDto } from './dto/allocate-containers.dto';
|
||||
|
||||
@ApiTags('bookings')
|
||||
@Controller('bookings')
|
||||
@ApiBearerAuth()
|
||||
export class BookingAllocationController {
|
||||
constructor(private readonly bookingsService: BookingsService) {}
|
||||
|
||||
@Post(':bookingId/allocate-containers')
|
||||
@ApiOperation({ summary: 'Allocate containers to vehicles' })
|
||||
async allocateContainers(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: AllocateContainersDto,
|
||||
) {
|
||||
return this.bookingsService.allocateContainers(bookingId, dto.allocations);
|
||||
}
|
||||
}
|
||||
@@ -24,12 +24,6 @@ import { ContractViewDto } from './dto/contract-view.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
|
||||
|
||||
/**
|
||||
* Default ordering window (months) for a general contract activated on
|
||||
* counter-sign. Mirrors GeneralContractService.DEFAULT_CONTRACT_PERIOD_MONTHS;
|
||||
* defined locally to avoid a circular module dependency on booking-orders.
|
||||
*/
|
||||
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
|
||||
@@ -240,23 +234,9 @@ export class BookingContractService {
|
||||
includesCustoms,
|
||||
);
|
||||
|
||||
const isGeneralContract = booking.bookingType === 'GENERAL_CONTRACT';
|
||||
|
||||
if (role === 'CUSTOMER') {
|
||||
updates.status = 'SIGNED_CUSTOMER';
|
||||
updates.customerSignedAt = now;
|
||||
} else if (isGeneralContract) {
|
||||
// A general contract is NOT paid up front — each drawdown order is priced
|
||||
// and paid on its own. So on counter-sign it becomes ACTIVE directly and
|
||||
// opens its ordering window; orders spawn their own priced child bookings.
|
||||
const expiresAt = new Date(now);
|
||||
expiresAt.setMonth(expiresAt.getMonth() + DEFAULT_CONTRACT_PERIOD_MONTHS);
|
||||
updates.fullyExecutedAt = now;
|
||||
updates.marketingApprovedAt = now;
|
||||
updates.marketingApprovedById = options.signerUserId ?? null;
|
||||
updates.lockedAt = now;
|
||||
updates.status = 'CONTRACT_ACTIVE';
|
||||
updates.expiresAt = expiresAt;
|
||||
} else {
|
||||
updates.fullyExecutedAt = now;
|
||||
updates.marketingApprovedAt = now;
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { OnEvent } from "@nestjs/event-emitter";
|
||||
import { Freight } from "@edr/types";
|
||||
import { DataSource, EntityManager } from "typeorm";
|
||||
|
||||
import {
|
||||
BillingService,
|
||||
GenerateInvoiceInput,
|
||||
InvoiceEventPayload,
|
||||
InvoiceLineInput,
|
||||
} from "../billing/billing.service";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
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 { Booking } from "./entities/booking.entity";
|
||||
|
||||
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
|
||||
interface StoredPricingBreakdown {
|
||||
lineItems?: PriceLineItemDto[];
|
||||
totalAmount?: number;
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export interface InvoiceOptions {
|
||||
dueDate?: Date;
|
||||
invoiceType?: string;
|
||||
invoiceStatus?: Freight.InvoiceStatus;
|
||||
}
|
||||
|
||||
/** Round to 2 decimals, avoiding binary float drift. */
|
||||
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||
|
||||
/**
|
||||
* Owns the booking ⇄ invoice mapping — the one place that knows how a booking
|
||||
* turns into invoices, which type to use, and how it advances when paid. Bookings
|
||||
* are the billable business entity, so they generate their own invoices directly
|
||||
* via {@link BillingService} (billing stays source-agnostic). All booking-specific
|
||||
* type branching lives here, at the two points it belongs: invoice creation and
|
||||
* settlement (the paid handler).
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingInvoiceService {
|
||||
private readonly logger = new Logger(BookingInvoiceService.name);
|
||||
|
||||
constructor(
|
||||
private readonly billing: BillingService,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly dataSource: DataSource,
|
||||
@Inject(forwardRef(() => FirstMileService))
|
||||
private readonly firstMile: FirstMileService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatch: BookingBatchService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Ensure the booking has its invoice, generating one from the snapshotted
|
||||
* pricing breakdown if absent. Called when a booking reaches a billable state.
|
||||
* Idempotent — returns the existing open invoice instead of a duplicate.
|
||||
* Throws `BadRequestException` when the booking is not billable: no company
|
||||
* to bill (e.g. government bookings whose `companyId` is null, which the
|
||||
* invoices FK requires), or no priced amount.
|
||||
*/
|
||||
async ensureInvoiceForBooking(
|
||||
booking: Booking,
|
||||
invoiceOptions: InvoiceOptions = {},
|
||||
): Promise<Invoice> {
|
||||
const existing = await this.billing.findPayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
booking.id,
|
||||
"PREPAID",
|
||||
);
|
||||
if (existing) return existing;
|
||||
|
||||
if (!booking.companyId) {
|
||||
throw new BadRequestException(
|
||||
`Cannot generate invoice for booking ${booking.reference} (${booking.id}): no company to bill.`,
|
||||
);
|
||||
}
|
||||
|
||||
const input = this.buildInput(booking, invoiceOptions);
|
||||
|
||||
return this.billing.generateInvoice(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* the booking its own way. Only PREPAID exists today.
|
||||
*/
|
||||
@OnEvent("booking.invoice.paid")
|
||||
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
this.logger.log(
|
||||
`onBookingInvoicePaid trigger for ${payload.sourceId} from ${payload.invoiceId}`,
|
||||
);
|
||||
switch (payload.type) {
|
||||
case "PREPAID":
|
||||
await this.advanceBookingOnPayment(payload.sourceId);
|
||||
break;
|
||||
default:
|
||||
this.logger.warn(
|
||||
`Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus(
|
||||
invoiceId: string,
|
||||
status: Freight.InvoiceStatus,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
return this.billing.updateStatus(invoiceId, status, manager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance a booking once its prepaid invoice settles — the domain side-effect
|
||||
* of payment, relocated out of the payment service: the booking becomes PAID
|
||||
* and is allocated into its batch. Idempotent — no-op when already PAID.
|
||||
*
|
||||
* General contracts are a separate aggregate now: their CONTRACT_ACTIVE
|
||||
* lifecycle and ordering window live in the contracts module, advanced by the
|
||||
* contract transition/clearance services — not by booking payment. Every
|
||||
* booking that settles here is a ONE_TIME shipment, so there is no contract
|
||||
* branch (legacy GENERAL_CONTRACT booking creation now 410s).
|
||||
*/
|
||||
private async advanceBookingOnPayment(bookingId: string): Promise<void> {
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
if (!booking) {
|
||||
this.logger.warn(
|
||||
`Cannot advance unknown booking ${bookingId} on payment.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// if (booking.paymentStatus === "PAID") return;
|
||||
|
||||
await this.dataSource.transaction(async (mg) => {
|
||||
await mg.update(
|
||||
Booking,
|
||||
{ id: bookingId },
|
||||
{ paymentStatus: "PAID", status: "PAID" },
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.firstMile.acceptBooking(bookingId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error accepting first-mile after payment: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.bookingBatch.ensurePaidBookingAllocated(bookingId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a booking's pricing snapshot into a generic invoice request. */
|
||||
private buildInput(
|
||||
booking: Booking,
|
||||
invoiceOptions: InvoiceOptions = {},
|
||||
): GenerateInvoiceInput {
|
||||
const breakdown = (booking.pricingBreakdown ??
|
||||
{}) as StoredPricingBreakdown;
|
||||
const currency = breakdown.currency ?? booking.paymentCurrency ?? "ETB";
|
||||
|
||||
const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({
|
||||
chargeType: l.code,
|
||||
description: l.description,
|
||||
quantity: l.quantity,
|
||||
unitRate: l.unitAmount,
|
||||
amount: l.amount,
|
||||
currency: l.currency ?? currency,
|
||||
metadata: l.unit ? { unit: l.unit } : null,
|
||||
}));
|
||||
|
||||
// Fall back to a single freight line when no breakdown was snapshotted.
|
||||
if (lines.length === 0) {
|
||||
const amount = Number(booking.totalAmount);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot generate invoice for booking ${booking.reference} (${booking.id}): no priced amount.`,
|
||||
);
|
||||
}
|
||||
lines.push({
|
||||
chargeType: "FREIGHT",
|
||||
description: "Rail freight",
|
||||
quantity: 1,
|
||||
unitRate: amount,
|
||||
amount,
|
||||
currency,
|
||||
});
|
||||
}
|
||||
|
||||
const subtotal = round2(
|
||||
lines.reduce((sum, l) => sum + Number(l.amount), 0),
|
||||
);
|
||||
let totalAmount = subtotal;
|
||||
|
||||
// Honor a staff price override: bill the adjusted total, recording the delta
|
||||
// as an ADJUSTMENT line so the lines still sum to the invoice total.
|
||||
const adjusted = booking.adjustedTotalAmount;
|
||||
if (adjusted != null && Number.isFinite(Number(adjusted))) {
|
||||
const delta = round2(Number(adjusted) - subtotal);
|
||||
if (delta !== 0) {
|
||||
lines.push({
|
||||
chargeType: "ADJUSTMENT",
|
||||
description: "Staff price adjustment",
|
||||
quantity: 1,
|
||||
unitRate: delta,
|
||||
amount: delta,
|
||||
currency,
|
||||
});
|
||||
}
|
||||
totalAmount = round2(Number(adjusted));
|
||||
}
|
||||
|
||||
return {
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: booking.id,
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId,
|
||||
currency,
|
||||
lines,
|
||||
totalAmount,
|
||||
dueAt: invoiceOptions.dueDate,
|
||||
type: invoiceOptions.invoiceType ?? "PREPAID",
|
||||
status: invoiceOptions.invoiceStatus ?? Freight.InvoiceStatus.Draft,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
||||
import { PaymentService } from '../payment/payment.service';
|
||||
import { PaymentStatus } from '../payment/entities/payment.entity';
|
||||
import { PaymentMethodTypeEnum } from '../payment/payments.dto';
|
||||
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
|
||||
|
||||
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
|
||||
"action-required",
|
||||
"processing",
|
||||
"success",
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class BookingPaymentService {
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly paymentService: PaymentService,
|
||||
) { }
|
||||
|
||||
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
|
||||
|
||||
const existing = await this.paymentService.findBookingById(bookingId);
|
||||
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
||||
if (existing.clientAction) {
|
||||
const action = existing.clientAction as { type?: string; url?: string };
|
||||
if (action.type === "REDIRECT" && action.url) {
|
||||
return { redirectUrl: action.url };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await this.paymentService.initiatePayment({
|
||||
bookingId,
|
||||
method: PaymentMethodTypeEnum.TELEBIRR,
|
||||
platform: "web",
|
||||
});
|
||||
|
||||
const action = resp.clientAction as { type?: string; url?: string } | undefined;
|
||||
return {
|
||||
redirectUrl: action?.type === "REDIRECT" ? (action.url ?? "") : "",
|
||||
};
|
||||
}
|
||||
|
||||
private async requireBooking(id: string): Promise<Booking> {
|
||||
const booking = await this.bookingsRepository.findById(id);
|
||||
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||
return booking;
|
||||
}
|
||||
}
|
||||
@@ -121,9 +121,18 @@ export class BookingPricingService {
|
||||
total += line.amount;
|
||||
}
|
||||
|
||||
// First / last mile trucking — billed per the rate's unit (km / container /
|
||||
// ton / flat), only for legs the booking actually carries.
|
||||
const { lineItems: mileLines, usedRates: mileRates } =
|
||||
await this.computeFirstLastMileLines(booking, evalInput);
|
||||
for (const line of mileLines) {
|
||||
lineItems.push(line);
|
||||
total += line.amount;
|
||||
}
|
||||
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const rateById = new Map(liveRates.map((r) => [r.id, r]));
|
||||
const usedRatesMap = new Map(baseRates.map((r) => [r.id, r]));
|
||||
const usedRatesMap = new Map([...baseRates, ...mileRates].map((r) => [r.id, r]));
|
||||
|
||||
for (const mod of ruleResult.appliedModifiers) {
|
||||
const usdAmount = mod.calculatedAmount;
|
||||
@@ -424,6 +433,97 @@ export class BookingPricingService {
|
||||
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
||||
}
|
||||
|
||||
/**
|
||||
* First-mile (pick-up) and last-mile (delivery) trucking lines. Each leg is
|
||||
* billed only when the booking carries that leg (an address is set) and a LIVE
|
||||
* rate exists, scaled by the rate's own unit:
|
||||
* PER_KM → contract-route road distance (km)
|
||||
* PER_CONTAINER → total container count
|
||||
* PER_TON → total bulk tonnage
|
||||
* FLAT → once
|
||||
* A leg whose rate value (or computed amount) is 0 contributes nothing.
|
||||
*/
|
||||
private async computeFirstLastMileLines(
|
||||
booking: Booking,
|
||||
evalInput: BookingEvaluationInput,
|
||||
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
|
||||
const legs: Array<{ rateType: 'FIRST_MILE' | 'LAST_MILE'; label: string; active: boolean }> = [
|
||||
{
|
||||
rateType: 'FIRST_MILE',
|
||||
label: 'First mile (pick-up)',
|
||||
active: Boolean(booking.firstMilePickupAddress),
|
||||
},
|
||||
{
|
||||
rateType: 'LAST_MILE',
|
||||
label: 'Last mile (delivery)',
|
||||
active: Boolean(booking.lastMileDeliveryAddress),
|
||||
},
|
||||
];
|
||||
if (!legs.some((l) => l.active)) {
|
||||
return { lineItems: [], usedRates: [] };
|
||||
}
|
||||
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
|
||||
const containerCount = evalInput.containers.reduce(
|
||||
(sum, c) => sum + Number(c.quantity || 0),
|
||||
0,
|
||||
);
|
||||
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const routeKm = await this.bookingsRepository.getContractRouteKm(booking.contractRouteId);
|
||||
|
||||
const lines: PriceLineItemDto[] = [];
|
||||
const usedRatesMap = new Map<string, Rate>();
|
||||
|
||||
for (const leg of legs) {
|
||||
if (!leg.active) continue;
|
||||
const rate = liveRates.find(
|
||||
(r) => r.rateType === leg.rateType && r.currency === 'USD' && r.status === 'LIVE',
|
||||
);
|
||||
if (!rate) continue;
|
||||
|
||||
const value = Number(rate.rateValue);
|
||||
let quantity: number;
|
||||
switch (rate.rateUnit) {
|
||||
case 'PER_KM':
|
||||
quantity = routeKm;
|
||||
break;
|
||||
case 'PER_CONTAINER':
|
||||
quantity = containerCount;
|
||||
break;
|
||||
case 'PER_TON':
|
||||
quantity = bulkTons;
|
||||
break;
|
||||
case 'FLAT':
|
||||
default:
|
||||
quantity = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
const usdAmount = value * quantity;
|
||||
// Skip legs that resolve to nothing (zero rate, or zero km / count / tons).
|
||||
if (!(usdAmount > 0)) continue;
|
||||
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
const unitUsd = value;
|
||||
usedRatesMap.set(rate.id, rate);
|
||||
lines.push({
|
||||
code: leg.rateType,
|
||||
description: leg.label,
|
||||
amount,
|
||||
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
|
||||
unit: rate.rateUnit,
|
||||
quantity,
|
||||
currency: paymentCurrency,
|
||||
});
|
||||
}
|
||||
|
||||
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
||||
}
|
||||
|
||||
/** Friendly container-type label for the per-unit card; degrades to "Container". */
|
||||
private async containerTypeLabel(containerTypeId: string): Promise<string> {
|
||||
try {
|
||||
|
||||
@@ -58,7 +58,6 @@ export function buildCargoTypeTree(
|
||||
id: child.id,
|
||||
name: child.cargoTypeName,
|
||||
code: child.code,
|
||||
show_free_text_box: child.showFreeTextBox,
|
||||
unit_of_measure: child.unitOfMeasure ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -30,10 +30,13 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
||||
ruleEngineService as never,
|
||||
{} as never, // pricingService
|
||||
{} as never, // contractService
|
||||
{} as never, // invoiceService
|
||||
{} as never, // filesService
|
||||
{} as never, // fileUploadSettingsService
|
||||
{} as never, // bookingBatchService
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository, ruleEngineService };
|
||||
}
|
||||
|
||||
@@ -41,10 +41,13 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
||||
{} as never, // ruleEngineService
|
||||
{} as never, // pricingService
|
||||
{} as never, // contractService
|
||||
{} as never, // invoiceService
|
||||
filesService as never,
|
||||
fileUploadSettingsService as never,
|
||||
{} as never, // bookingBatchService
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository };
|
||||
}
|
||||
@@ -122,10 +125,13 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // invoiceService
|
||||
filesService as never,
|
||||
fileUploadSettingsService as never,
|
||||
{} as never,
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository };
|
||||
}
|
||||
@@ -189,10 +195,13 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // invoiceService
|
||||
filesService as never,
|
||||
fileUploadSettingsService as never,
|
||||
{} as never,
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository, filesService };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { BookingTransitionService } from './booking-transition.service';
|
||||
* - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool.
|
||||
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued.
|
||||
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
|
||||
* - ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM.
|
||||
*/
|
||||
describe('BookingTransitionService — operation review', () => {
|
||||
function makeService(serviceTypeCode: string) {
|
||||
@@ -34,10 +33,13 @@ describe('BookingTransitionService — operation review', () => {
|
||||
{} as never, // ruleEngineService
|
||||
{} as never, // pricingService
|
||||
{} as never, // contractService
|
||||
{} as never, // invoiceService
|
||||
{} as never, // filesService
|
||||
{} as never, // fileUploadSettingsService
|
||||
bookingBatchService as never,
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository, bookingBatchService };
|
||||
}
|
||||
@@ -78,18 +80,4 @@ describe('BookingTransitionService — operation review', () => {
|
||||
expect.objectContaining({ status: 'OPERATION_CHANGES_REQUESTED' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM', async () => {
|
||||
const { service, bookingsRepository } = makeService('RAIL_CONTAINER');
|
||||
await service.reviewOperationRequest('b-1', 'ADJUST_PRICE', 'staff-1', {
|
||||
amount: 1500,
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({
|
||||
adjustedTotalAmount: 1500,
|
||||
status: 'OPERATION_PRICE_PENDING_CONFIRM',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import {
|
||||
Request,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UploadedFile,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
@@ -19,7 +20,7 @@ import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
@@ -27,12 +28,17 @@ import {
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
} from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
import { BookingClearanceService } from '../contracts/booking-clearance.service';
|
||||
import {
|
||||
AdviseContractDutyDto,
|
||||
RoAmendmentDto,
|
||||
} from '../contracts/dto/phased-clearance.dto';
|
||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
||||
@@ -43,7 +49,6 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||
import {
|
||||
AcceptIntakeDto,
|
||||
AdjustPriceDto,
|
||||
ApproveStepDto,
|
||||
CancelBookingDto,
|
||||
RejectBookingDto,
|
||||
@@ -52,20 +57,23 @@ import {
|
||||
ReviewDocumentDto,
|
||||
RequestOperationDto,
|
||||
OperationReviewDto,
|
||||
ConfirmOperationPriceDto,
|
||||
StaffRejectDto,
|
||||
} from './dto/request-changes.dto';
|
||||
import { ContractViewDto } from './dto/contract-view.dto';
|
||||
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||
import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from '../../common/resolve-auth-user-id';
|
||||
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
|
||||
} from "../../common/resolve-auth-user-id";
|
||||
import {
|
||||
assertFreightPermission,
|
||||
hasFreightPermission,
|
||||
} from "../../common/freight-permission.util";
|
||||
|
||||
@ApiTags('bookings')
|
||||
@Controller('bookings')
|
||||
@ApiTags("bookings")
|
||||
@Controller("bookings")
|
||||
@ApiBearerAuth()
|
||||
export class BookingsController {
|
||||
constructor(
|
||||
@@ -74,12 +82,13 @@ export class BookingsController {
|
||||
private readonly pricingService: BookingPricingService,
|
||||
private readonly transitionService: BookingTransitionService,
|
||||
private readonly contractService: BookingContractService,
|
||||
private readonly bookingClearanceService: BookingClearanceService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "Create a new freight booking (DRAFT)" })
|
||||
@ApiBody({ type: CreateBookingDto })
|
||||
async create(
|
||||
@Body() dto: CreateBookingDto,
|
||||
@@ -89,15 +98,24 @@ export class BookingsController {
|
||||
if (dto.isGovernment) {
|
||||
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
|
||||
}
|
||||
const result = await this.bookingsService.create(dto, files ?? [], user?.id);
|
||||
const result = await this.bookingsService.create(
|
||||
dto,
|
||||
files ?? [],
|
||||
user?.id,
|
||||
);
|
||||
|
||||
// Staff-created commercial bookings skip the draft stage: auto generate-price + submit.
|
||||
const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
|
||||
const isStaff = hasFreightPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.bookings.staffAccept,
|
||||
);
|
||||
if (isStaff && !dto.isGovernment) {
|
||||
try {
|
||||
await this.pricingService.generatePrice(result.booking.id);
|
||||
await this.transitionService.submit(result.booking.id);
|
||||
const submitted = await this.bookingsService.findById(result.booking.id);
|
||||
const submitted = await this.bookingsService.findById(
|
||||
result.booking.id,
|
||||
);
|
||||
return { booking: submitted, warnings: result.warnings };
|
||||
} catch {
|
||||
// If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually.
|
||||
@@ -107,16 +125,16 @@ export class BookingsController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Patch(":id")
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: 'Update booking',
|
||||
description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.',
|
||||
summary: "Update booking",
|
||||
description: "Allowed when status is DRAFT or CHANGES_REQUESTED.",
|
||||
})
|
||||
@ApiBody({ type: UpdateBookingDto })
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateBookingDto,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
@@ -124,7 +142,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List freight bookings (paginated)' })
|
||||
@ApiOperation({ summary: "List freight bookings (paginated)" })
|
||||
async findAll(
|
||||
@Query() filter: FilterBookingDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@@ -141,7 +159,7 @@ export class BookingsController {
|
||||
return this.bookingsService.findClearanceQueue(filter);
|
||||
}
|
||||
const userId = user?.id;
|
||||
if (!userId) throw new UnauthorizedException('Authentication required');
|
||||
if (!userId) throw new UnauthorizedException("Authentication required");
|
||||
const companyId =
|
||||
await this.bookingsService.resolveCustomerCompanyId(userId);
|
||||
// No linked company yet → no bookings to show (avoids leaking all bookings).
|
||||
@@ -167,27 +185,29 @@ export class BookingsController {
|
||||
return this.bookingsService.findAll(filter, companyId);
|
||||
}
|
||||
|
||||
@Get('by-company/:companyId/customer-view')
|
||||
@ApiOperation({ summary: 'List bookings for a company (customer-view shape, backoffice)' })
|
||||
@Get("by-company/:companyId/customer-view")
|
||||
@ApiOperation({
|
||||
summary: "List bookings for a company (customer-view shape, backoffice)",
|
||||
})
|
||||
findByCompanyCustomerView(
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
) {
|
||||
return this.bookingsService.findCustomerBookings(companyId);
|
||||
}
|
||||
|
||||
@Get('list-summary')
|
||||
@ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' })
|
||||
@Get("list-summary")
|
||||
@ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" })
|
||||
@ApiOkResponse({ type: BookingListSummaryDto })
|
||||
findListSummary(@Query() filter: FilterBookingDto) {
|
||||
return this.bookingsService.getListSummary(filter);
|
||||
}
|
||||
|
||||
@Get('my')
|
||||
@Get("my")
|
||||
@ApiOperation({
|
||||
summary: "List the current customer's bookings ready for payment",
|
||||
description:
|
||||
'Bookings owned by the authenticated user\'s company that are payable ' +
|
||||
'(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.',
|
||||
"Bookings owned by the authenticated user's company that are payable " +
|
||||
"(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.",
|
||||
})
|
||||
findMyPayable(
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@@ -196,32 +216,32 @@ export class BookingsController {
|
||||
return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter);
|
||||
}
|
||||
|
||||
@Get('queues/:queue')
|
||||
@Get("queues/:queue")
|
||||
@ApiOperation({
|
||||
summary: 'List bookings for a dashboard queue',
|
||||
description: 'Queues: intake, approval, signatures, marketing, finance',
|
||||
summary: "List bookings for a dashboard queue",
|
||||
description: "Queues: intake, approval, signatures, marketing, finance",
|
||||
})
|
||||
findQueue(
|
||||
@Param('queue') queue: string,
|
||||
@Param("queue") queue: string,
|
||||
@Query() filter: FilterBookingDto,
|
||||
@Query('excludeBulk') excludeBulk?: string,
|
||||
@Query("excludeBulk") excludeBulk?: string,
|
||||
) {
|
||||
return this.bookingsService.findQueue(queue, filter, {
|
||||
excludeBulk: excludeBulk === 'true',
|
||||
excludeBulk: excludeBulk === "true",
|
||||
});
|
||||
}
|
||||
|
||||
@Get('reference-data')
|
||||
@ApiOperation({ summary: 'Booking form catalog' })
|
||||
@Get("reference-data")
|
||||
@ApiOperation({ summary: "Booking form catalog" })
|
||||
@ApiOkResponse({ type: BookingReferenceDataDto })
|
||||
getReferenceData(): Promise<BookingReferenceDataDto> {
|
||||
return this.bookingReferenceDataService.getReferenceData();
|
||||
}
|
||||
|
||||
@Get('by-reference/:reference')
|
||||
@ApiOperation({ summary: 'Get booking by reference' })
|
||||
@Get("by-reference/:reference")
|
||||
@ApiOperation({ summary: "Get booking by reference" })
|
||||
async findByReference(
|
||||
@Param('reference') reference: string,
|
||||
@Param("reference") reference: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findByReference(reference);
|
||||
@@ -235,10 +255,10 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get booking by ID' })
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get booking by ID" })
|
||||
async findOne(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
@@ -256,15 +276,48 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/customer-truck-assignment')
|
||||
@ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' })
|
||||
async assignCustomerTruck(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CustomerTruckAssignmentDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
const assigned = await this.bookingsService.assignCustomerTruck(id, dto);
|
||||
return this.transitionService.enrichBookingResponse(assigned);
|
||||
}
|
||||
|
||||
@Get(':id/customer-truck-assignment/freight-order')
|
||||
@ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' })
|
||||
async customerTruckFreightOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
const { filename, buffer } =
|
||||
await this.bookingsService.customerTruckFreightOrderCopies(id);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Get(':id/tracking')
|
||||
@ApiOperation({
|
||||
summary: 'Shipment tracking timeline for a booking',
|
||||
summary: "Shipment tracking timeline for a booking",
|
||||
description:
|
||||
"Returns the booking's consignment (once dispatched) and its ordered " +
|
||||
'tracking events. Scoped to the customer\'s own company.',
|
||||
"tracking events. Scoped to the customer's own company.",
|
||||
})
|
||||
async findTracking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
@@ -278,66 +331,66 @@ export class BookingsController {
|
||||
return this.bookingsService.getBookingTracking(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Delete(":id")
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: "Soft-delete DRAFT booking" })
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/documents')
|
||||
@Post(":id/documents")
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" })
|
||||
async uploadDocuments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
const booking = await this.bookingsService.uploadDocuments(id, files ?? []);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/generate-price')
|
||||
@Post(":id/generate-price")
|
||||
@ApiOperation({
|
||||
summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)',
|
||||
summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)",
|
||||
description:
|
||||
'Computes and stores a price preview on the booking. Does not create rate snapshots.',
|
||||
"Computes and stores a price preview on the booking. Does not create rate snapshots.",
|
||||
})
|
||||
@ApiOkResponse({ type: GeneratePriceResponseDto })
|
||||
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
|
||||
generatePrice(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.pricingService.generatePrice(id);
|
||||
}
|
||||
|
||||
@Post(':id/submit')
|
||||
@Post(":id/submit")
|
||||
@ApiOperation({
|
||||
summary: 'Customer submit booking',
|
||||
summary: "Customer submit booking",
|
||||
description:
|
||||
'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.',
|
||||
"Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.",
|
||||
})
|
||||
@ApiOkResponse({ type: SubmitBookingResponseDto })
|
||||
submit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
submit(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.submit(id);
|
||||
}
|
||||
|
||||
@Post(':id/confirm-submit')
|
||||
@Post(":id/confirm-submit")
|
||||
@ApiOperation({
|
||||
summary: 'Confirm submit after price change',
|
||||
summary: "Confirm submit after price change",
|
||||
description:
|
||||
'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.',
|
||||
"Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.",
|
||||
})
|
||||
@ApiOkResponse({ type: SubmitBookingResponseDto })
|
||||
confirmSubmit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
confirmSubmit(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.confirmSubmit(id);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
@Post(":id/reject")
|
||||
@ApiOperation({
|
||||
summary: 'Customer reject price estimate',
|
||||
summary: "Customer reject price estimate",
|
||||
description:
|
||||
'Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.',
|
||||
"Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.",
|
||||
})
|
||||
async reject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectBookingDto,
|
||||
) {
|
||||
const booking = await this.transitionService.reject(id, dto.reason);
|
||||
@@ -346,22 +399,37 @@ export class BookingsController {
|
||||
|
||||
// ── Document clearance (post counter-sign) ────────────────────────────────
|
||||
|
||||
@Get('clearance/et-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({ summary: 'GL ET queue — general customs bookings awaiting ET action' })
|
||||
getBookingEtClearanceQueue() {
|
||||
return this.bookingClearanceService.etQueue();
|
||||
}
|
||||
|
||||
@Get('clearance/dj-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({ summary: 'GL DJ queue — general customs bookings awaiting DJ action' })
|
||||
getBookingDjClearanceQueue() {
|
||||
return this.bookingClearanceService.djQueue();
|
||||
}
|
||||
|
||||
@Get(':id/clearance')
|
||||
@ApiOperation({
|
||||
summary: 'Document-clearance grid (required docs + upload + GL review status)',
|
||||
summary:
|
||||
"Document-clearance grid (required docs + upload + GL review status)",
|
||||
})
|
||||
getClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
getClearance(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.getClearanceView(id);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/documents')
|
||||
@Post(":id/clearance/documents")
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: 'Customer uploads clearance documents (fieldname = document key)',
|
||||
summary: "Customer uploads clearance documents (fieldname = document key)",
|
||||
})
|
||||
async submitClearanceDocuments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
const booking = await this.transitionService.submitClearanceDocuments(
|
||||
@@ -371,14 +439,14 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/proceed')
|
||||
@Post(":id/clearance/proceed")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Customer requests operation with a schedule day ' +
|
||||
'(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)',
|
||||
"Customer requests operation with a schedule day " +
|
||||
"(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)",
|
||||
})
|
||||
async proceedToOperation(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RequestOperationDto,
|
||||
) {
|
||||
const booking = await this.transitionService.requestOperation(
|
||||
@@ -388,15 +456,15 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/operation/review')
|
||||
@Post(":id/operation/review")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Operations reviews an operation request: ACCEPT (→ batch pool), ' +
|
||||
'REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)',
|
||||
"Operations reviews an operation request: ACCEPT (→ batch pool), " +
|
||||
"REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)",
|
||||
})
|
||||
async reviewOperationRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: OperationReviewDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
@@ -404,33 +472,18 @@ export class BookingsController {
|
||||
id,
|
||||
dto.decision,
|
||||
resolveAuthUserId(user),
|
||||
{ note: dto.note, amount: dto.amount },
|
||||
{ note: dto.note },
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/operation/confirm-price')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Customer confirms or rejects an operations price adjustment ' +
|
||||
'(OPERATION_PRICE_PENDING_CONFIRM → batch pool | OPERATION_CHANGES_REQUESTED)',
|
||||
})
|
||||
async confirmOperationPrice(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ConfirmOperationPriceDto,
|
||||
) {
|
||||
const booking = await this.transitionService.confirmOperationPrice(
|
||||
id,
|
||||
dto.accept,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/review')
|
||||
@Post(":id/clearance/review")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
|
||||
@ApiOperation({ summary: 'GL reviews a clearance document (Approve | Query)' })
|
||||
@ApiOperation({
|
||||
summary: "GL reviews a clearance document (Approve | Query)",
|
||||
})
|
||||
async reviewClearanceDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: ReviewDocumentDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
@@ -444,13 +497,13 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/output-documents')
|
||||
@Post(":id/clearance/output-documents")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…)' })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "GL uploads customs output documents (IM4/EX3/…)" })
|
||||
async uploadClearanceOutput(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
const booking = await this.transitionService.uploadClearanceOutputDocuments(
|
||||
@@ -460,21 +513,178 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/finalize')
|
||||
@Post(":id/clearance/finalize")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance)
|
||||
@ApiOperation({
|
||||
summary: 'GL finalizes clearance (requires 100% approved) → CLEARANCE_READY',
|
||||
summary:
|
||||
"GL finalizes clearance (requires 100% approved) → CLEARANCE_READY",
|
||||
})
|
||||
async finalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) {
|
||||
const booking = await this.transitionService.finalizeClearance(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/declaration')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL ET uploads customs declaration on booking (GENERAL customs)' })
|
||||
async uploadBookingDeclaration(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadDeclaration(
|
||||
id,
|
||||
files ?? [],
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise)
|
||||
@UseInterceptors(FileInterceptor('attachment'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL ET sets duty/tax on booking with notice attachment' })
|
||||
async adviseBookingDuty(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('dutyRequired') dutyRequiredRaw: string,
|
||||
@Body('amount') amountRaw: string | undefined,
|
||||
@Body('currency') currency: string | undefined,
|
||||
@Body('declarationSerial') declarationSerial: string | undefined,
|
||||
@UploadedFile() attachment: Express.Multer.File | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1';
|
||||
const dto: AdviseContractDutyDto = {
|
||||
dutyRequired,
|
||||
amount:
|
||||
amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined,
|
||||
currency: currency ?? 'ETB',
|
||||
declarationSerial,
|
||||
};
|
||||
const booking = await this.bookingClearanceService.adviseDuty(
|
||||
id,
|
||||
dto,
|
||||
resolveAuthUserId(user),
|
||||
attachment,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/finalize-pre-clearance')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })
|
||||
async finalizeBookingPreClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const booking = await this.bookingClearanceService.finalizePreClearance(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty-slip')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer uploads duty/tax payment slip on booking' })
|
||||
async uploadBookingDutySlip(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadDutySlip(id, file);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/transit-permit')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
async uploadBookingTransitPermit(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadTransitPermit(
|
||||
id,
|
||||
files ?? [],
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/delivery-order')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
async uploadBookingDeliveryOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
||||
id,
|
||||
file,
|
||||
resolveAuthUserId(user),
|
||||
vesselDepartureDate,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/release-order')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
async uploadBookingReleaseOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('vesselDepartureDate') vesselDepartureDate: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const result = await this.bookingClearanceService.uploadReleaseOrder(
|
||||
id,
|
||||
file,
|
||||
vesselDepartureDate,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return {
|
||||
...this.transitionService.enrichBookingResponse(result.booking),
|
||||
hold: result.hold,
|
||||
holdReason: result.holdReason,
|
||||
};
|
||||
}
|
||||
|
||||
@Post(':id/clearance/ro-amendment')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
async requestBookingRoAmendment(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RoAmendmentDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.requestRoAmendment(
|
||||
id,
|
||||
dto.note,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/export-release')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
async confirmBookingExportRelease(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.confirmExportRelease(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/staff/request-changes')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
|
||||
@ApiOperation({ summary: 'Staff return booking for customer updates' })
|
||||
@ApiOperation({ summary: "Staff return booking for customer updates" })
|
||||
async requestChanges(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RequestChangesDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
@@ -486,14 +696,14 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/staff/accept')
|
||||
@Post(":id/staff/accept")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Staff accept intake → set contract validity window + start approval chain',
|
||||
"Staff accept intake → set contract validity window + start approval chain",
|
||||
})
|
||||
async acceptIntake(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: AcceptIntakeDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
@@ -505,11 +715,11 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/staff/reject')
|
||||
@Post(":id/staff/reject")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.reject)
|
||||
@ApiOperation({ summary: 'Staff final reject' })
|
||||
@ApiOperation({ summary: "Staff final reject" })
|
||||
async staffReject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: StaffRejectDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
@@ -521,30 +731,13 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/adjust-price')
|
||||
@Post(":id/government-expedite")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||
@ApiOperation({
|
||||
summary: 'Staff adjust booking total price (override; null clears it)',
|
||||
summary: "Expedite government booking to PAID / ELIGIBLE for scheduling",
|
||||
})
|
||||
async adjustPrice(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AdjustPriceDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.transitionService.adjustPrice(
|
||||
id,
|
||||
dto.amount ?? null,
|
||||
resolveAuthUserId(user),
|
||||
dto.reason,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/government-expedite')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })
|
||||
async governmentExpedite(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.bookingsService.governmentExpedite(
|
||||
@@ -554,16 +747,16 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/approve')
|
||||
@Post(":id/approval-steps/:stepId/approve")
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||
FREIGHT_PERMS.bookings.approveDirector,
|
||||
FREIGHT_PERMS.bookings.approveCeo,
|
||||
])
|
||||
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
||||
@ApiOperation({ summary: "Approve one approval step in sequence" })
|
||||
async approveStep(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('stepId', ParseUUIDPipe) stepId: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("stepId", ParseUUIDPipe) stepId: string,
|
||||
@Body() dto: ApproveStepDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
@@ -577,12 +770,12 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/reject')
|
||||
@Post(":id/approval-steps/:stepId/reject")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
|
||||
@ApiOperation({ summary: 'Reject at approval step' })
|
||||
@ApiOperation({ summary: "Reject at approval step" })
|
||||
async rejectStep(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('stepId', ParseUUIDPipe) stepId: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("stepId", ParseUUIDPipe) stepId: string,
|
||||
@Body() dto: RejectStepDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
@@ -595,53 +788,53 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/contract/generate')
|
||||
@Post(":id/contract/generate")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
|
||||
@ApiOperation({ summary: 'Generate contract PDF from template' })
|
||||
async generateContract(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: "Generate contract PDF from template" })
|
||||
async generateContract(@Param("id", ParseUUIDPipe) id: string) {
|
||||
const booking = await this.contractService.generateContract(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id/contract/view')
|
||||
@Get(":id/contract/view")
|
||||
@ApiOkResponse({ type: ContractViewDto })
|
||||
@ApiOperation({ summary: 'Contract HTML view for portal and backoffice' })
|
||||
@ApiOperation({ summary: "Contract HTML view for portal and backoffice" })
|
||||
getContractView(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Request() req: { user?: { id?: string; sub?: string } },
|
||||
) {
|
||||
const userId = req.user?.id ?? req.user?.sub;
|
||||
return this.contractService.getContractView(id, userId);
|
||||
}
|
||||
|
||||
@Get(':id/contract/document')
|
||||
@ApiOperation({ summary: 'Download contract PDF' })
|
||||
@Get(":id/contract/document")
|
||||
@ApiOperation({ summary: "Download contract PDF" })
|
||||
async downloadContractDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const { stream, record } = await this.contractService.streamContract(id);
|
||||
res.setHeader('Content-Type', record.mimeType ?? 'application/pdf');
|
||||
res.setHeader("Content-Type", record.mimeType ?? "application/pdf");
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${record.name}"`,
|
||||
);
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
@Get(':id/contract')
|
||||
@ApiOperation({ summary: 'Download contract file (alias)' })
|
||||
@Get(":id/contract")
|
||||
@ApiOperation({ summary: "Download contract file (alias)" })
|
||||
async downloadContract(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
return this.downloadContractDocument(id, res);
|
||||
}
|
||||
|
||||
@Post(':id/contract/sign')
|
||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff)' })
|
||||
@Post(":id/contract/sign")
|
||||
@ApiOperation({ summary: "Apply digital signature (customer or staff)" })
|
||||
async signContract(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: SignContractDto,
|
||||
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
|
||||
) {
|
||||
@@ -653,28 +846,28 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id/contract/signatures')
|
||||
@ApiOperation({ summary: 'List contract signatures' })
|
||||
getContractSignatures(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@Get(":id/contract/signatures")
|
||||
@ApiOperation({ summary: "List contract signatures" })
|
||||
getContractSignatures(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.contractService.getSignatures(id);
|
||||
}
|
||||
|
||||
@Get(':id/summary')
|
||||
@ApiOperation({ summary: 'Contract summary string for dashboard' })
|
||||
getSummary(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@Get(":id/summary")
|
||||
@ApiOperation({ summary: "Contract summary string for dashboard" })
|
||||
getSummary(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.contractService.getSummary(id);
|
||||
}
|
||||
|
||||
@Post(':id/customer/sign')
|
||||
@Post(":id/customer/sign")
|
||||
@ApiOperation({
|
||||
summary: 'Customer digital signature (deprecated — use POST contract/sign)',
|
||||
summary: "Customer digital signature (deprecated — use POST contract/sign)",
|
||||
})
|
||||
async customerSign(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: SignContractDto,
|
||||
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
|
||||
) {
|
||||
const payload: SignContractDto = { ...dto, role: 'CUSTOMER' };
|
||||
const payload: SignContractDto = { ...dto, role: "CUSTOMER" };
|
||||
const booking = await this.contractService.signContract(id, payload, {
|
||||
signerUserId: req.user?.id ?? req.user?.sub,
|
||||
ipAddress: req.ip,
|
||||
@@ -682,20 +875,21 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/marketing/approve')
|
||||
@Post(":id/marketing/approve")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.signStaff)
|
||||
@ApiOperation({
|
||||
summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)',
|
||||
summary:
|
||||
"Staff contract signature and fully execute (use contract/sign STAFF preferred)",
|
||||
})
|
||||
async marketingApprove(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: SignContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Request() req: { ip?: string },
|
||||
) {
|
||||
const payload: SignContractDto = {
|
||||
...dto,
|
||||
role: 'STAFF',
|
||||
role: "STAFF",
|
||||
};
|
||||
const booking = await this.contractService.signContract(id, payload, {
|
||||
signerUserId: resolveAuthUserId(user),
|
||||
@@ -704,48 +898,48 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/operations/start-transit')
|
||||
@Post(":id/operations/start-transit")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Mark in transit' })
|
||||
async startTransit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: "Mark in transit" })
|
||||
async startTransit(@Param("id", ParseUUIDPipe) id: string) {
|
||||
const booking = await this.transitionService.startTransit(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/operations/complete')
|
||||
@Post(":id/operations/complete")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Mark completed' })
|
||||
async complete(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: "Mark completed" })
|
||||
async complete(@Param("id", ParseUUIDPipe) id: string) {
|
||||
const booking = await this.transitionService.complete(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@Post(":id/cancel")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
|
||||
@ApiOperation({ summary: 'Cancel booking' })
|
||||
@ApiOperation({ summary: "Cancel booking" })
|
||||
async cancel(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: CancelBookingDto,
|
||||
) {
|
||||
const booking = await this.transitionService.cancel(id, dto.reason);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/consolidation')
|
||||
@ApiOperation({ summary: 'Request freight consolidation' })
|
||||
requestConsolidation(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@Post(":id/consolidation")
|
||||
@ApiOperation({ summary: "Request freight consolidation" })
|
||||
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.requestConsolidation(id);
|
||||
}
|
||||
|
||||
@Delete(':id/consolidation')
|
||||
@ApiOperation({ summary: 'Remove consolidation pairing' })
|
||||
removeConsolidation(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@Delete(":id/consolidation")
|
||||
@ApiOperation({ summary: "Remove consolidation pairing" })
|
||||
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.removeConsolidation(id);
|
||||
}
|
||||
|
||||
@Get(':id/consolidation')
|
||||
@ApiOperation({ summary: 'Get consolidation details' })
|
||||
getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@Get(":id/consolidation")
|
||||
@ApiOperation({ summary: "Get consolidation details" })
|
||||
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.getConsolidationDetails(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||
import { Module, forwardRef } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { ExchangeModule, ExchangeOptions } from "@edr/api-common";
|
||||
|
||||
// import { CustomersModule } from '../customers/customers.module';
|
||||
import { CompaniesModule } from '../companies/companies.module';
|
||||
@@ -10,13 +10,17 @@ import { MinioModule } from '../minio/minio.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { FirstMileModule } from '../first-mile/first-mile.module';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPaymentService } from './booking-payment.service';
|
||||
import { BookingInvoiceService } from './booking-invoice.service';
|
||||
// import { BookingPaymentController } from './booking-payment.controller';
|
||||
// import { BookingPaymentService } from './booking-payment.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
import { BookingsController } from './bookings.controller';
|
||||
import { PayController } from './pay.controller';
|
||||
// import { PayController } from './pay.controller';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
@@ -29,12 +33,14 @@ import { BookingContractSignature } from './entities/booking-contract-signature.
|
||||
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
|
||||
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||||
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||
import { PaymentModule } from '../payment/payment.module';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
import { ContractsModule } from '../contracts/contracts.module';
|
||||
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
|
||||
import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder";
|
||||
import { ContractRendererService } from "../../contracts/contract-renderer.service";
|
||||
import { ContractTemplateResolver } from "../../contracts/contract-template.resolver";
|
||||
import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder";
|
||||
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
||||
import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -47,11 +53,16 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
BookingRateSnapshot,
|
||||
BookingReviewNote,
|
||||
BookingContractSignature,
|
||||
BookingContainerAllocation,
|
||||
]),
|
||||
PaymentModule,
|
||||
BillingModule,
|
||||
forwardRef(() => FirstMileModule),
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
forwardRef(() => ContractsModule),
|
||||
forwardRef(() => ContractsModule),
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
VehiclesModule,
|
||||
CompaniesModule,
|
||||
// CustomersModule,
|
||||
RuleEngineModule,
|
||||
@@ -60,10 +71,10 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||
config.get<ExchangeOptions>("app.cbeExchange") ?? {},
|
||||
}),
|
||||
],
|
||||
controllers: [BookingsController, PayController],
|
||||
controllers: [BookingsController],
|
||||
providers: [
|
||||
BookingsService,
|
||||
BookingsRepository,
|
||||
@@ -72,13 +83,18 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
BookingPricingService,
|
||||
BookingTransitionService,
|
||||
BookingContractService,
|
||||
BookingPaymentService,
|
||||
BookingInvoiceService,
|
||||
ContractTemplateResolver,
|
||||
ContractViewModelBuilder,
|
||||
ContractPricingScheduleBuilder,
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
],
|
||||
exports: [BookingsService, BookingsRepository, BookingPricingService],
|
||||
exports: [
|
||||
BookingsService,
|
||||
BookingsRepository,
|
||||
BookingPricingService,
|
||||
BookingInvoiceService,
|
||||
],
|
||||
})
|
||||
export class BookingsModule {}
|
||||
export class BookingsModule { }
|
||||
|
||||
@@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { ContractRoute } from '../contracts/entities/contract-route.entity';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import {
|
||||
@@ -34,7 +35,6 @@ export interface BookingListFilterOptions {
|
||||
serviceTypeId?: string;
|
||||
cargoTypeId?: string;
|
||||
freightType?: string;
|
||||
bookingType?: string;
|
||||
tradeDirection?: string;
|
||||
paymentCurrency?: string;
|
||||
paymentStatus?: string;
|
||||
@@ -121,6 +121,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
containerTypeId: string;
|
||||
quantity: number;
|
||||
vgmPerUnitTons: number;
|
||||
hazardousQuantity?: number;
|
||||
reeferQuantity?: number;
|
||||
weightResult: ContainerWeightResult;
|
||||
}>,
|
||||
): Promise<BookingContainer[]> {
|
||||
@@ -133,11 +135,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
|
||||
const totalVgm = item.quantity * item.vgmPerUnitTons;
|
||||
const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit);
|
||||
// A per-line breakdown can never exceed the line's own quantity.
|
||||
const clamp = (v?: number) =>
|
||||
Math.max(0, Math.min(item.quantity, Math.floor(Number(v ?? 0)) || 0));
|
||||
|
||||
const row = containerRepo.create({
|
||||
bookingId,
|
||||
containerTypeId: item.containerTypeId,
|
||||
quantity: item.quantity,
|
||||
hazardousQuantity: clamp(item.hazardousQuantity),
|
||||
reeferQuantity: clamp(item.reeferQuantity),
|
||||
vgmPerUnitTons: item.vgmPerUnitTons,
|
||||
totalVgmTons: totalVgm,
|
||||
wagonsRequired,
|
||||
@@ -164,6 +171,19 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
return Number(result?.total ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The road billing distance (km) of a booking's contract route, used to price
|
||||
* per-km first/last-mile trucking. Returns 0 when there is no route or no km
|
||||
* recorded (rail-only lanes) so a PER_KM rate bills nothing.
|
||||
*/
|
||||
async getContractRouteKm(contractRouteId: string | null | undefined): Promise<number> {
|
||||
if (!contractRouteId) return 0;
|
||||
const route = await this.dataSource
|
||||
.getRepository(ContractRoute)
|
||||
.findOne({ where: { id: contractRouteId }, select: { id: true, km: true } });
|
||||
return Number(route?.km ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
||||
* (same route, same container type, partial wagon on both sides).
|
||||
@@ -470,6 +490,15 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
} as never);
|
||||
}
|
||||
|
||||
/** Bookings in any of the given statuses (clearance queue helpers). */
|
||||
async findByStatuses(statuses: string[]): Promise<Booking[]> {
|
||||
if (!statuses.length) return [];
|
||||
return this.repository.find({
|
||||
where: { status: In(statuses) },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/** Queue listing with optional bulk exclusion for LINE_STAFF. */
|
||||
async findQueue(options: {
|
||||
status: string | string[];
|
||||
@@ -693,11 +722,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
freightType: options.freightType,
|
||||
});
|
||||
}
|
||||
if (options.bookingType) {
|
||||
qb.andWhere('booking.booking_type = :bookingType', {
|
||||
bookingType: options.bookingType,
|
||||
});
|
||||
}
|
||||
if (options.createdFrom) {
|
||||
qb.andWhere('booking.created_at >= :createdFrom', {
|
||||
createdFrom: options.createdFrom,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
forwardRef,
|
||||
GoneException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
@@ -11,7 +12,7 @@ import { Freight, SchedulingStatus } from '@edr/types';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
||||
import { CompanyStatus } from '../companies/entities/company.entity';
|
||||
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { FilesService } from '../files/files.service';
|
||||
@@ -27,10 +28,11 @@ import { DataSource, In } from 'typeorm';
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { ContractRouteLine } from '../booking-orders/entities/contract-route-line.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { VehiclesService } from '../vehicles/vehicles.service';
|
||||
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
||||
import { assertFreightShape } from './booking-freight.util';
|
||||
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
|
||||
import { mapStatusCountsToTabs } from './booking-list-tabs.config';
|
||||
@@ -43,7 +45,10 @@ import {
|
||||
FreightType,
|
||||
} from './entities/booking.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
|
||||
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
|
||||
export interface PaginatedBookings {
|
||||
@@ -66,6 +71,17 @@ const NEEDS_ACTION_STATUSES = [
|
||||
'APPROVED_PENDING_SIGNATURE',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Clamp a bulk hazardous/reefer amount into 0..cargoAmount: it can never exceed
|
||||
* the total cargo it's a portion of, and is never negative.
|
||||
*/
|
||||
function clampToCargo(value: number | undefined, cargoAmount: number): number {
|
||||
const v = Number(value ?? 0);
|
||||
if (!Number.isFinite(v) || v <= 0) return 0;
|
||||
const cap = Number.isFinite(cargoAmount) && cargoAmount > 0 ? cargoAmount : 0;
|
||||
return Math.min(v, cap);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BookingsService {
|
||||
constructor(
|
||||
@@ -80,8 +96,63 @@ export class BookingsService {
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
private readonly contractPdfService: ContractPdfService,
|
||||
) {}
|
||||
|
||||
async assignCustomerTruck(
|
||||
bookingId: string,
|
||||
dto: CustomerTruckAssignmentDto,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.findById(bookingId);
|
||||
const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim());
|
||||
const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim());
|
||||
const usesMileService =
|
||||
booking.tradeDirection === 'IMPORT'
|
||||
? hasLastMile
|
||||
: booking.tradeDirection === 'EXPORT'
|
||||
? hasFirstMile
|
||||
: hasFirstMile || hasLastMile;
|
||||
if (usesMileService) {
|
||||
throw new BadRequestException(
|
||||
'Customer truck assignment is only allowed when first/last mile delivery is not selected',
|
||||
);
|
||||
}
|
||||
if (booking.customerTruckAssignedAt) {
|
||||
throw new ConflictException('Customer truck assignment is already submitted and locked');
|
||||
}
|
||||
if (booking.paymentStatus !== 'PAID') {
|
||||
throw new BadRequestException('Booking must be paid before assigning an external customer truck');
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'TRUCK_ASSIGNED',
|
||||
customerTruckPlateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
||||
customerTruckDriverName: dto.driverName.trim(),
|
||||
customerTruckType: dto.truckType.trim(),
|
||||
customerTruckContainerNumber: dto.containerNumberToLoad.trim().toUpperCase(),
|
||||
customerTruckAssignedAt: new Date(),
|
||||
});
|
||||
|
||||
return this.findById(bookingId);
|
||||
}
|
||||
|
||||
async customerTruckFreightOrderCopies(
|
||||
bookingId: string,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const booking = await this.findById(bookingId);
|
||||
if (!booking.customerTruckAssignedAt) {
|
||||
throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated');
|
||||
}
|
||||
|
||||
const html = this.buildCustomerTruckFreightOrderHtml(booking);
|
||||
const buffer = await this.contractPdfService.htmlToPdfBuffer(html);
|
||||
return {
|
||||
filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||
buffer,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve trade direction from yard countries; reject client mismatch. */
|
||||
private async resolveTradeDirectionForBooking(
|
||||
originYardId: string,
|
||||
@@ -119,6 +190,79 @@ export class BookingsService {
|
||||
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
private buildCustomerTruckFreightOrderHtml(booking: Booking): string {
|
||||
const assignedAt = booking.customerTruckAssignedAt
|
||||
? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB')
|
||||
: '-';
|
||||
const rows: Array<[string, string | null | undefined]> = [
|
||||
['Booking Reference', booking.reference],
|
||||
['Client Name', booking.company?.name],
|
||||
['Client ID', booking.companyId],
|
||||
['Trade Direction', booking.tradeDirection],
|
||||
['Freight Type', booking.freightType],
|
||||
['Truck Plate Number', booking.customerTruckPlateNumber],
|
||||
['Driver Name', booking.customerTruckDriverName],
|
||||
['Truck Type', booking.customerTruckType],
|
||||
['Container Number to Load', booking.customerTruckContainerNumber],
|
||||
['Assigned At', assignedAt],
|
||||
['Booking Status', booking.status],
|
||||
];
|
||||
const rowHtml = rows
|
||||
.map(([label, value]) => `<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`)
|
||||
.join('');
|
||||
const copy = (watermark: string) => `
|
||||
<section class="copy">
|
||||
<div class="watermark">${this.escapeHtml(watermark)}</div>
|
||||
<header>
|
||||
<div>
|
||||
<h1>Freight Order</h1>
|
||||
<p>Customer external truck assignment</p>
|
||||
</div>
|
||||
<strong>${this.escapeHtml(booking.reference)}</strong>
|
||||
</header>
|
||||
<table>${rowHtml}</table>
|
||||
<div class="signatures">
|
||||
<div>Customer / Carrier Signature</div>
|
||||
<div>Port Operations Verification</div>
|
||||
<div>Gate Security Verification</div>
|
||||
</div>
|
||||
</section>`;
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; color: #10202f; margin: 0; }
|
||||
.copy { position: relative; min-height: 46vh; padding: 28px 32px; page-break-inside: avoid; border-bottom: 1px dashed #94a3b8; }
|
||||
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; font-weight: 800; color: rgba(16, 32, 47, 0.08); transform: rotate(-18deg); pointer-events: none; }
|
||||
header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; }
|
||||
h1 { margin: 0; font-size: 28px; letter-spacing: 0; }
|
||||
p { margin: 4px 0 0; color: #64748b; }
|
||||
strong { font-size: 16px; color: #0a9f6a; }
|
||||
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; }
|
||||
th { width: 34%; background: #f1f5f9; }
|
||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; }
|
||||
.signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${copy('Copy 1: Port Operations Copy')}
|
||||
${copy('Copy 2: Gate Security & Carrier Copy')}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** Build evaluation input from booking freight shape. */
|
||||
/**
|
||||
* Whether a service type bundles customs clearance. This is the single source
|
||||
@@ -283,6 +427,15 @@ export class BookingsService {
|
||||
): Promise<{ booking: Booking; warnings: string[] }> {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Contract–booking separation: contracts are no longer created through the
|
||||
// booking endpoint. Legacy GENERAL_CONTRACT creation is deprecated — clients
|
||||
// must use POST /contracts (and create shipments via POST /contracts/:id/bookings).
|
||||
if (dto.bookingType === 'GENERAL_CONTRACT') {
|
||||
throw new GoneException(
|
||||
'General contracts are no longer created here. Use POST /contracts instead.',
|
||||
);
|
||||
}
|
||||
|
||||
// let customerId = dto.customerId;
|
||||
// if (!customerId) {
|
||||
// if (!userId) {
|
||||
@@ -295,14 +448,26 @@ export class BookingsService {
|
||||
// }
|
||||
|
||||
const isGovernment = dto.isGovernment === true;
|
||||
const isGeneralContract = dto.bookingType === 'GENERAL_CONTRACT';
|
||||
|
||||
let companyId: string | null | undefined = dto.companyId;
|
||||
if (isGovernment) {
|
||||
if (!dto.governmentInstitution?.trim()) {
|
||||
throw new BadRequestException('governmentInstitution is required for government bookings');
|
||||
// Government bookings bill to a real seeded government company + an
|
||||
// explicitly-chosen importer/exporter profile (no more null company +
|
||||
// free-text institution).
|
||||
if (!dto.companyId) {
|
||||
throw new BadRequestException('A government company is required for government bookings');
|
||||
}
|
||||
companyId = dto.companyId ?? null;
|
||||
const govCompany = await this.companiesService.findCompanyById(dto.companyId);
|
||||
if (govCompany.kind !== CompanyKind.Government) {
|
||||
throw new BadRequestException('Selected company is not a government entity');
|
||||
}
|
||||
if (govCompany.status !== CompanyStatus.Active) {
|
||||
throw new BadRequestException('Selected government company is not active');
|
||||
}
|
||||
if (!dto.companyProfileId) {
|
||||
throw new BadRequestException('A government company profile is required for government bookings');
|
||||
}
|
||||
companyId = govCompany.id;
|
||||
} else if (!companyId) {
|
||||
if (!userId) {
|
||||
throw new BadRequestException(
|
||||
@@ -375,7 +540,16 @@ export class BookingsService {
|
||||
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
|
||||
// for non-government bookings with a resolved company; never blocks creation.
|
||||
let companyProfileId: string | null = null;
|
||||
if (!isGovernment && companyId) {
|
||||
if (dto.companyProfileId && companyId) {
|
||||
// Explicit profile pin (government booking, or staff booking on behalf):
|
||||
// must belong to the chosen company and be active.
|
||||
const profile =
|
||||
await this.companiesService.getActiveCompanyProfileForBooking(
|
||||
companyId,
|
||||
dto.companyProfileId,
|
||||
);
|
||||
companyProfileId = profile.id;
|
||||
} else if (companyId) {
|
||||
let fallbackType: ProfileType | null = null;
|
||||
if (userId) {
|
||||
try {
|
||||
@@ -405,6 +579,16 @@ export class BookingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Every booking must link to a company and a company profile.
|
||||
if (!companyId) {
|
||||
throw new BadRequestException('A company is required to create a booking');
|
||||
}
|
||||
if (!companyProfileId) {
|
||||
throw new BadRequestException(
|
||||
'A company profile is required to create a booking — none could be resolved for this company',
|
||||
);
|
||||
}
|
||||
|
||||
const needsConsolidation =
|
||||
dto.freightType === 'CONTAINER'
|
||||
? await this.needsConsolidation(containers)
|
||||
@@ -435,14 +619,13 @@ export class BookingsService {
|
||||
|
||||
const booking = await this.bookingsRepository.create({
|
||||
reference,
|
||||
companyId: companyId ?? null,
|
||||
companyId,
|
||||
companyProfileId,
|
||||
isGovernment,
|
||||
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
|
||||
governmentInstitution: dto.governmentInstitution?.trim() || null,
|
||||
trainId: dto.trainId,
|
||||
trainScheduleId: dto.trainScheduleId ?? null,
|
||||
contractType: dto.contractType,
|
||||
previousContractId: dto.previousContractId,
|
||||
serviceTypeId: dto.serviceTypeId,
|
||||
firstMilePickupAddress: dto.firstMilePickupAddress,
|
||||
firstMilePickupLat: dto.firstMilePickupLat ?? null,
|
||||
@@ -466,10 +649,19 @@ export class BookingsService {
|
||||
// the container type at pricing time, so the booking-level flag stays off
|
||||
// for container freight to avoid double-counting.
|
||||
isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false,
|
||||
// Bulk-only hazardous/reefer amount, clamped to the cargo amount. Container
|
||||
// freight tracks this per line, so these are 0 for CONTAINER.
|
||||
bulkHazardousQuantity:
|
||||
dto.freightType === 'BULK'
|
||||
? clampToCargo(dto.bulkHazardousQuantity, dto.cargoTotalWeightVgm)
|
||||
: 0,
|
||||
bulkReeferQuantity:
|
||||
dto.freightType === 'BULK'
|
||||
? clampToCargo(dto.bulkReeferQuantity, dto.cargoTotalWeightVgm)
|
||||
: 0,
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
pnrCode: dto.pnrCode,
|
||||
financialTerms: dto.financialTerms,
|
||||
bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME',
|
||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||
estimatedShipmentDate: dto.estimatedShipmentDate
|
||||
? new Date(dto.estimatedShipmentDate)
|
||||
@@ -489,6 +681,8 @@ export class BookingsService {
|
||||
containerTypeId: c.containerTypeId,
|
||||
quantity: c.quantity,
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
hazardousQuantity: c.hazardousQuantity,
|
||||
reeferQuantity: c.reeferQuantity,
|
||||
weightResult: ruleResult.containerWeightResults[i],
|
||||
})),
|
||||
);
|
||||
@@ -496,27 +690,6 @@ export class BookingsService {
|
||||
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
||||
}
|
||||
|
||||
// Multi-route general contracts: persist the contracted routes (lanes). Routes
|
||||
// carry NO quantity — the contract has a single shared pool (the cargo-step
|
||||
// total / container quantities). Each drawdown order picks one lane for
|
||||
// scheduling + road billing and draws from that shared pool. `quantity` on the
|
||||
// route line is retained for legacy rows but is no longer meaningful (0).
|
||||
if (isGeneralContract && dto.routes?.length) {
|
||||
const routeRepo = this.dataSource.getRepository(ContractRouteLine);
|
||||
await routeRepo.save(
|
||||
dto.routes.map((r) =>
|
||||
routeRepo.create({
|
||||
contractBookingId: booking.id,
|
||||
originYardId: r.originYardId,
|
||||
destinationYardId: r.destinationYardId,
|
||||
containerTypeId: null,
|
||||
quantity: 0,
|
||||
km: r.km ?? null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
await this.filesService.uploadMany(booking.id, 'bookings', files);
|
||||
@@ -647,6 +820,8 @@ export class BookingsService {
|
||||
containers,
|
||||
);
|
||||
|
||||
const cargoAmount =
|
||||
dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0);
|
||||
const updates: Record<string, unknown> = {
|
||||
...dto,
|
||||
freightType,
|
||||
@@ -657,6 +832,22 @@ export class BookingsService {
|
||||
freightType === 'BULK'
|
||||
? (dto.isReefer ?? existing.isReefer ?? false)
|
||||
: false,
|
||||
// Bulk-only hazardous/reefer amount, clamped to the cargo amount; 0 for
|
||||
// container freight (per-line on the containers instead).
|
||||
bulkHazardousQuantity:
|
||||
freightType === 'BULK'
|
||||
? clampToCargo(
|
||||
dto.bulkHazardousQuantity ?? Number(existing.bulkHazardousQuantity ?? 0),
|
||||
cargoAmount,
|
||||
)
|
||||
: 0,
|
||||
bulkReeferQuantity:
|
||||
freightType === 'BULK'
|
||||
? clampToCargo(
|
||||
dto.bulkReeferQuantity ?? Number(existing.bulkReeferQuantity ?? 0),
|
||||
cargoAmount,
|
||||
)
|
||||
: 0,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
tradeDirection,
|
||||
};
|
||||
@@ -703,6 +894,8 @@ export class BookingsService {
|
||||
containerTypeId: c.containerTypeId,
|
||||
quantity: c.quantity,
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
hazardousQuantity: c.hazardousQuantity,
|
||||
reeferQuantity: c.reeferQuantity,
|
||||
weightResult: ruleResult.containerWeightResults[i],
|
||||
})),
|
||||
);
|
||||
@@ -808,7 +1001,6 @@ export class BookingsService {
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
freightType: filter.freightType,
|
||||
bookingType: filter.bookingType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
paymentStatus: filter.paymentStatus,
|
||||
@@ -1017,7 +1209,6 @@ export class BookingsService {
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
freightType: filter.freightType,
|
||||
bookingType: filter.bookingType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
paymentStatus: filter.paymentStatus,
|
||||
@@ -1322,4 +1513,55 @@ export class BookingsService {
|
||||
createdAt: b.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async allocateContainers(
|
||||
bookingId: string,
|
||||
allocations: Array<{ containerId: string; vehicleId: string }>,
|
||||
) {
|
||||
const booking = await this.findById(bookingId);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
|
||||
const previousAllocations = await this.dataSource.manager.find(BookingContainerAllocation, {
|
||||
where: {
|
||||
bookingId,
|
||||
containerId: In(allocations.map((a) => a.containerId)),
|
||||
},
|
||||
});
|
||||
const previousVehicleIds = previousAllocations
|
||||
.map((a) => a.vehicleId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
for (const allocation of allocations) {
|
||||
await manager.delete(BookingContainerAllocation, {
|
||||
bookingId,
|
||||
containerId: allocation.containerId,
|
||||
});
|
||||
await manager.insert(BookingContainerAllocation, {
|
||||
bookingId,
|
||||
containerId: allocation.containerId,
|
||||
vehicleId: allocation.vehicleId,
|
||||
containerType: 'CONTAINER',
|
||||
quantity: 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const vehicleIds = new Set(allocations.map((a) => a.vehicleId));
|
||||
await Promise.all(
|
||||
[...vehicleIds].map((vehicleId) =>
|
||||
this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY),
|
||||
),
|
||||
);
|
||||
await this.vehiclesService.releaseIfUnused(
|
||||
previousVehicleIds.filter((id) => !vehicleIds.has(id)),
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
allocated: allocations.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,14 @@ export function clearanceCodesForBooking(booking: Booking): {
|
||||
outputCode: string | null;
|
||||
includesCustoms: boolean;
|
||||
} {
|
||||
const includesCustoms = booking.serviceType?.includesCustoms ?? false;
|
||||
// Customs applies when EITHER the service type bundles it OR the booking was
|
||||
// created with customsClearingEnabled (copied from the contract). Contract
|
||||
// bookings carry customsClearingEnabled even when the serviceType relation
|
||||
// isn't loaded / has includesCustoms=false — without this the per-booking
|
||||
// clearance grid would resolve empty.
|
||||
const includesCustoms =
|
||||
Boolean(booking.serviceType?.includesCustoms) ||
|
||||
Boolean(booking.customsClearingEnabled);
|
||||
return {
|
||||
inputCode: clearanceSettingCode(
|
||||
booking.tradeDirection,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user