mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -50,7 +50,7 @@ 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$"
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -18,18 +18,21 @@
|
||||
"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",
|
||||
"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: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"
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
|
||||
@@ -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";
|
||||
@@ -56,6 +57,7 @@ import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-inte
|
||||
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";
|
||||
@@ -79,7 +81,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
|
||||
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
// EventEmitterModule.forRoot(),
|
||||
EventEmitterModule.forRoot(),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
||||
@@ -144,6 +146,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
|
||||
FileUploadSettingsSeeder,
|
||||
FreightPermissionKeyMigrationSeeder,
|
||||
DemoFreightDataSeeder,
|
||||
GovCompaniesSeeder,
|
||||
IndodeFacilitySeeder,
|
||||
Batch14TestDataSeeder,
|
||||
Batch5TestDataSeeder,
|
||||
@@ -173,6 +176,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
private readonly govCompaniesSeeder: GovCompaniesSeeder,
|
||||
) { }
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
@@ -199,5 +203,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddPostPaymentCompletedColumn1719667261000 implements MigrationInterface {
|
||||
name = 'AddPostPaymentCompletedColumn1719667261000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE freight.first_mile_deliveries ADD COLUMN IF NOT EXISTS is_post_payment_completed BOOLEAN NOT NULL DEFAULT false;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.last_mile_deliveries ADD COLUMN IF NOT EXISTS is_post_payment_completed BOOLEAN NOT NULL DEFAULT false;`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE freight.last_mile_deliveries DROP COLUMN IF EXISTS is_post_payment_completed;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.first_mile_deliveries DROP COLUMN IF EXISTS is_post_payment_completed;`);
|
||||
}
|
||||
}
|
||||
@@ -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,109 @@
|
||||
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(
|
||||
`CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_invoices_status ON freight.invoices (status);`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE 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 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,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,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,222 @@
|
||||
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> {
|
||||
// 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,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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { BillingService } from "./billing.service";
|
||||
@Controller("billing")
|
||||
@FreightAdmin()
|
||||
export class BillingController {
|
||||
constructor(private readonly billingService: BillingService) {}
|
||||
constructor(private readonly billingService: BillingService) { }
|
||||
|
||||
@Get("invoices")
|
||||
@ApiOperation({ summary: "List all invoices" })
|
||||
@@ -16,9 +16,9 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
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 { 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],
|
||||
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||
exports: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
|
||||
338
apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
Normal file
338
apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
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, links the payment, and emits ${source}.invoice.paid", async () => {
|
||||
const open = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
source: "booking",
|
||||
sourceId: "booking-1",
|
||||
};
|
||||
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" },
|
||||
);
|
||||
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 service = new BillingService(
|
||||
{ manager: mg } 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 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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService.settlePayable", () => {
|
||||
it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => {
|
||||
const open = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: "booking-1",
|
||||
};
|
||||
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
|
||||
);
|
||||
|
||||
const settled = await service.settlePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
"booking-1",
|
||||
"pay-1",
|
||||
mg as never,
|
||||
);
|
||||
|
||||
expect(settled?.status).toBe(Freight.InvoiceStatus.Paid);
|
||||
expect(mg.update).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ id: "inv-1" },
|
||||
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
|
||||
);
|
||||
expect(events.emit).toHaveBeenCalledWith(
|
||||
"booking.invoice.paid",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("is a no-op (returns null) when the source has no open invoice", async () => {
|
||||
const mg = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
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
|
||||
);
|
||||
|
||||
const settled = await service.settlePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
"booking-1",
|
||||
"pay-1",
|
||||
mg as never,
|
||||
);
|
||||
|
||||
expect(settled).toBeNull();
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
expect(events.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,716 @@
|
||||
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.Draft,
|
||||
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. */
|
||||
findByCompany(companyId: string): Promise<Invoice[]> {
|
||||
return this.invoices.findAll({
|
||||
where: { companyId },
|
||||
relations: { company: true, companyProfile: true },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Invoices for the signed-in customer; empty when they have no company. */
|
||||
async findForUser(userId: string): Promise<Invoice[]> {
|
||||
const companyId = await this.resolveCompanyId(userId);
|
||||
return companyId ? this.findByCompany(companyId) : [];
|
||||
}
|
||||
|
||||
/** 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 whichever open invoice the source currently has
|
||||
* (see {@link payInvoice}).
|
||||
*/
|
||||
async payInvoiceForUser(
|
||||
id: string,
|
||||
userId: string,
|
||||
opts: PayInvoiceOptions = {},
|
||||
): Promise<InitiateResponseDto> {
|
||||
const invoice = await this.findByIdForUser(id, userId);
|
||||
return this.payInvoice(
|
||||
invoice.source as Freight.InvoiceSource,
|
||||
invoice.sourceId,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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[] }> {
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mark an invoice paid and 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.
|
||||
*/
|
||||
async markInvoiceAsPaid(
|
||||
invoiceId: string,
|
||||
paymentId: string | null = null,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
return this.transition(
|
||||
invoiceId,
|
||||
Freight.InvoiceStatus.Paid,
|
||||
"paid",
|
||||
{ paymentId: paymentId ?? undefined },
|
||||
manager,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
* or when `amount` is not positive. Pass `manager` to enlist in a caller's
|
||||
* transaction.
|
||||
*/
|
||||
async recordPayment(
|
||||
invoiceId: string,
|
||||
input: RecordPaymentInput,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice> {
|
||||
if (!(input.amount > 0)) {
|
||||
throw new BadRequestException("Payment amount must be greater than zero.");
|
||||
}
|
||||
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } });
|
||||
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.");
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
await mg.update(
|
||||
Invoice,
|
||||
{ id: invoice.id },
|
||||
{
|
||||
paidAmount,
|
||||
balanceAmount,
|
||||
status,
|
||||
payments,
|
||||
paidAt: fullyPaid ? at : invoice.paidAt ?? null,
|
||||
} as never,
|
||||
);
|
||||
|
||||
const updated = {
|
||||
...invoice,
|
||||
paidAmount,
|
||||
balanceAmount,
|
||||
status,
|
||||
payments,
|
||||
paidAt: fullyPaid ? at : invoice.paidAt ?? null,
|
||||
} as Invoice;
|
||||
|
||||
if (fullyPaid) this.emitInvoiceEvent("paid", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an invoice refunded and emit `${source}.invoice.refunded`.
|
||||
* No-op when already refunded.
|
||||
*/
|
||||
async markInvoiceAsRefunded(
|
||||
invoiceId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
return this.transition(
|
||||
invoiceId,
|
||||
Freight.InvoiceStatus.Refunded,
|
||||
"refunded",
|
||||
{},
|
||||
manager,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an invoice cancelled and emit `${source}.invoice.cancelled`.
|
||||
* No-op when already cancelled.
|
||||
*/
|
||||
async cancelInvoice(
|
||||
invoiceId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
return this.transition(
|
||||
invoiceId,
|
||||
Freight.InvoiceStatus.Cancelled,
|
||||
"cancelled",
|
||||
{},
|
||||
manager,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the invoice, apply the new status (+ extra columns), then emit
|
||||
* `${source}.invoice.<event>`. No-op (returns the invoice) when it is already
|
||||
* in the target status. Throws when the invoice does not exist.
|
||||
*
|
||||
* Note: the event fires in-process synchronously. When a `manager` from an
|
||||
* outer transaction is passed, listeners run before that transaction commits.
|
||||
*/
|
||||
private async transition(
|
||||
invoiceId: string,
|
||||
status: Freight.InvoiceStatus,
|
||||
event: string,
|
||||
extra: { paymentId?: string },
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } });
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
if (invoice.status === status) return invoice;
|
||||
|
||||
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
|
||||
|
||||
const updated = { ...invoice, ...extra, status } as Invoice;
|
||||
this.emitInvoiceEvent(event, updated);
|
||||
return 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.emit(`${invoice.source}.invoice.${event}`, payload);
|
||||
}
|
||||
|
||||
// ── Payment reconciliation (by source) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The invoice a gateway payment should settle for a source record, or null if
|
||||
* none. This is the billing document of record for "what is owed" — callers
|
||||
* (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than
|
||||
* recomputing from the source's own total, so discounts/penalties/adjustments
|
||||
* carried on the invoice are honored.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
findPayable(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
type?: string,
|
||||
): Promise<Invoice | null> {
|
||||
return this.dataSource.getRepository(Invoice).findOne({
|
||||
where: {
|
||||
source,
|
||||
sourceId,
|
||||
status: In(OPEN_STATUSES),
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle a source's currently-open invoice as paid and link the gateway
|
||||
* payment, then emit `${source}.invoice.paid`. Resolves the open invoice then
|
||||
* delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial
|
||||
* settlement. No-op (returns null) when the source has no open invoice.
|
||||
*
|
||||
* Type-blind by design: settles whichever invoice is due; any per-type reaction
|
||||
* belongs in the `${source}.invoice.paid` handler, which reads `invoice.type`.
|
||||
* Pass the caller's transaction `manager` to enlist in its DB transaction.
|
||||
*
|
||||
* NOTE: the booking flow settles via {@link payInvoice} + the `payment.succeeded`
|
||||
* event ({@link settleByPaymentId}); this source-keyed settle is a generic helper
|
||||
* for callers that settle by source rather than by gateway intent id.
|
||||
*/
|
||||
async settlePayable(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
paymentId: string | null,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { source, sourceId, status: In(OPEN_STATUSES) },
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
|
||||
return this.markInvoiceAsPaid(invoice.id, paymentId, mg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refund a source's paid invoice, then emit `${source}.invoice.refunded`.
|
||||
* Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}.
|
||||
* No-op (returns null) when the source has no paid invoice.
|
||||
*
|
||||
* Pass the caller's transaction `manager` (e.g. from `payment.service.refund`)
|
||||
* to enlist in its DB transaction.
|
||||
*/
|
||||
async refundPayable(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { source, sourceId, status: Freight.InvoiceStatus.Paid },
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
|
||||
return this.markInvoiceAsRefunded(invoice.id, mg);
|
||||
}
|
||||
|
||||
// ── Payment initiation & settlement (the gateway boundary) ───────────────────
|
||||
|
||||
/**
|
||||
* Charge a source's open 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 open
|
||||
* invoice, opens an intent for `invoice.totalAmount`, 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 source has no open invoice.
|
||||
*/
|
||||
async payInvoice(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
opts: {
|
||||
method?: string;
|
||||
platform?: "web" | "mobile";
|
||||
payerAccount?: string;
|
||||
returnUrl?: string;
|
||||
failureUrl?: string;
|
||||
} = {},
|
||||
): Promise<InitiateResponseDto> {
|
||||
const invoice = await this.findPayable(source, sourceId);
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`);
|
||||
}
|
||||
|
||||
const result = await this.payment.initiate({
|
||||
referenceId: 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,
|
||||
amountMinor: Math.round(Number(invoice.totalAmount)),
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,44 @@
|
||||
/**
|
||||
* 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<Array<{ seq: number | string }>>;
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
const [row] = await runner.query(
|
||||
`SELECT COALESCE(MAX(CAST(split_part(${column}, '-', 3) AS int)), 0) AS seq
|
||||
FROM ${opts.table} WHERE ${column} LIKE $1`,
|
||||
[`${prefix}%`],
|
||||
);
|
||||
const next = Number(row?.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
|
||||
import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from "../../common/resolve-auth-user-id";
|
||||
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) {
|
||||
return this.billingService.findForUser(resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@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));
|
||||
}
|
||||
|
||||
@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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Freight } from '@edr/types';
|
||||
import { DataSource } 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;
|
||||
}
|
||||
|
||||
/** 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.
|
||||
* Returns `null` (and logs) 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): Promise<Invoice | null> {
|
||||
const existing = await this.billing.findPayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
booking.id,
|
||||
Freight.InvoiceType.Prepaid,
|
||||
);
|
||||
if (existing) return existing;
|
||||
|
||||
if (!booking.companyId) {
|
||||
this.logger.warn(
|
||||
`Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const input = this.buildInput(booking);
|
||||
if (!input) {
|
||||
this.logger.warn(
|
||||
`Skipping invoice for booking ${booking.reference} (${booking.id}): no priced amount.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
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> {
|
||||
switch (payload.type) {
|
||||
case Freight.InvoiceType.Prepaid:
|
||||
await this.advanceBookingOnPayment(payload.sourceId);
|
||||
break;
|
||||
default:
|
||||
this.logger.warn(
|
||||
`Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' },
|
||||
);
|
||||
await this.firstMile.acceptBooking(bookingId);
|
||||
});
|
||||
|
||||
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): GenerateInvoiceInput | null {
|
||||
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) return null;
|
||||
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,
|
||||
type: Freight.InvoiceType.Prepaid,
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId,
|
||||
currency,
|
||||
lines,
|
||||
totalAmount,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
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 { Freight } from "@edr/types";
|
||||
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
import {
|
||||
InitiatePaymentDto,
|
||||
InitiateResponseDto,
|
||||
PaymentMethodTypeEnum,
|
||||
PaymentPlatformDto,
|
||||
} from "../payment/payments.dto";
|
||||
|
||||
/**
|
||||
* Booking-payment entrypoints. This is the ONE place that knows a payment is for a
|
||||
* booking — it maps the request to {@link Freight.InvoiceSource.Booking} and hands
|
||||
* off to billing, which resolves the invoice/amount and drives the gateway. Billing
|
||||
* and payment stay source-agnostic; the booking knowledge lives here, in the domain.
|
||||
* Routes are unchanged (`/payments/*`) so the portal is unaffected.
|
||||
*/
|
||||
@ApiTags("Payment")
|
||||
@Controller("payments")
|
||||
export class BookingPaymentController {
|
||||
constructor(private readonly billing: BillingService) { }
|
||||
|
||||
@Post("initiate")
|
||||
@ApiOperation({
|
||||
summary: "Initiate payment for a freight booking",
|
||||
description: "Charges the booking's open invoice through the payment gateway.",
|
||||
})
|
||||
@ApiOkResponse({ type: InitiateResponseDto })
|
||||
initiate(@Body() dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||||
return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, {
|
||||
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 booking's invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
|
||||
})
|
||||
@ApiQuery({ name: "bookingId", required: true })
|
||||
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
|
||||
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
|
||||
@ApiProduces("text/html")
|
||||
async checkout(
|
||||
@Query("bookingId") bookingId: string,
|
||||
@Query("method") method: PaymentMethodTypeEnum,
|
||||
@Query("platform") platform: PaymentPlatformDto = "web",
|
||||
@Res() res: Response,
|
||||
) {
|
||||
if (!bookingId) {
|
||||
return res
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.type("html")
|
||||
.send(this.buildErrorHtml("Missing required query parameter: bookingId"));
|
||||
}
|
||||
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(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
{ 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>`;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,37 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Freight } from '@edr/types';
|
||||
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 { BillingService } from '../billing/billing.service';
|
||||
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,
|
||||
private readonly billing: BillingService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Start payment for a booking. The booking never touches the payment gateway
|
||||
* directly — it charges its invoice through billing, which resolves the amount
|
||||
* and drives the provider. Returns the provider redirect URL (empty when none).
|
||||
*/
|
||||
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,
|
||||
const resp = await this.billing.payInvoice(Freight.InvoiceSource.Booking, bookingId, {
|
||||
method: PaymentMethodTypeEnum.TELEBIRR,
|
||||
platform: "web",
|
||||
platform: 'web',
|
||||
});
|
||||
|
||||
const action = resp.clientAction as { type?: string; url?: string } | undefined;
|
||||
return {
|
||||
redirectUrl: action?.type === "REDIRECT" ? (action.url ?? "") : "",
|
||||
redirectUrl: action?.type === 'REDIRECT' ? (action.url ?? '') : '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ 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
|
||||
|
||||
@@ -41,6 +41,7 @@ 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
|
||||
@@ -122,6 +123,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // invoiceService
|
||||
filesService as never,
|
||||
fileUploadSettingsService as never,
|
||||
{} as never,
|
||||
@@ -189,6 +191,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // invoiceService
|
||||
filesService as never,
|
||||
fileUploadSettingsService as never,
|
||||
{} as never,
|
||||
|
||||
@@ -33,6 +33,7 @@ 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,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
@@ -14,6 +15,7 @@ import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingInvoiceService } from './booking-invoice.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
@@ -26,11 +28,14 @@ import { BookingsService } from './bookings.service';
|
||||
|
||||
@Injectable()
|
||||
export class BookingTransitionService {
|
||||
private readonly logger = new Logger(BookingTransitionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly pricingService: BookingPricingService,
|
||||
private readonly contractService: BookingContractService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
@@ -404,7 +409,22 @@ export class BookingTransitionService {
|
||||
marketingApprovedAt: new Date(),
|
||||
lockedAt: new Date(),
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
|
||||
const executed = await this.bookingsService.findById(updated!.id);
|
||||
|
||||
// Billable state reached — generate the invoice payment will settle.
|
||||
// Non-blocking: a billing hiccup must not undo the execution.
|
||||
await this.invoiceService
|
||||
.ensureInvoiceForBooking(executed)
|
||||
.catch((err) =>
|
||||
this.logger.error(
|
||||
`Failed to generate invoice for booking ${executed.reference}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
),
|
||||
);
|
||||
|
||||
return executed;
|
||||
}
|
||||
|
||||
async startTransit(bookingId: string): Promise<Booking> {
|
||||
|
||||
@@ -10,7 +10,11 @@ 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 { 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';
|
||||
@@ -28,12 +32,12 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { BookingContainerAllocation } from './entities/booking-container-allocation.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';
|
||||
|
||||
@Module({
|
||||
@@ -47,8 +51,10 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
BookingRateSnapshot,
|
||||
BookingReviewNote,
|
||||
BookingContractSignature,
|
||||
BookingContainerAllocation,
|
||||
]),
|
||||
PaymentModule,
|
||||
BillingModule,
|
||||
forwardRef(() => FirstMileModule),
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
@@ -63,7 +69,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||
}),
|
||||
],
|
||||
controllers: [BookingsController, PayController],
|
||||
controllers: [BookingsController, PayController, BookingPaymentController],
|
||||
providers: [
|
||||
BookingsService,
|
||||
BookingsRepository,
|
||||
@@ -72,6 +78,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
BookingPricingService,
|
||||
BookingTransitionService,
|
||||
BookingContractService,
|
||||
BookingInvoiceService,
|
||||
BookingPaymentService,
|
||||
ContractTemplateResolver,
|
||||
ContractViewModelBuilder,
|
||||
@@ -79,6 +86,6 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
],
|
||||
exports: [BookingsService, BookingsRepository, BookingPricingService],
|
||||
exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService],
|
||||
})
|
||||
export class BookingsModule {}
|
||||
|
||||
@@ -12,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';
|
||||
@@ -43,6 +43,7 @@ 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';
|
||||
|
||||
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
|
||||
@@ -307,10 +308,23 @@ export class BookingsService {
|
||||
|
||||
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(
|
||||
@@ -383,7 +397,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 {
|
||||
@@ -413,6 +436,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)
|
||||
@@ -443,10 +476,10 @@ 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,
|
||||
@@ -1305,4 +1338,35 @@ 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`);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
allocated: allocations.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export class ContainerAllocationDto {
|
||||
containerId!: string;
|
||||
vehicleId!: string;
|
||||
}
|
||||
|
||||
export class AllocateContainersDto {
|
||||
allocations!: ContainerAllocationDto[];
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
Validate,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
@@ -104,19 +103,31 @@ export class CreateBookingDto {
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isGovernment?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required when isGovernment is true' })
|
||||
@ValidateIf((o) => o.isGovernment === true)
|
||||
/** @deprecated Government bookings now bill to a real government company. */
|
||||
@ApiPropertyOptional({ description: 'Deprecated: free-text institution (superseded by companyId)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
governmentInstitution?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
|
||||
@ValidateIf((o) => o.isGovernment !== true)
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Target company. Required for staff/government bookings; resolved from the auth token for customer self-bookings.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Explicit company profile (importer/exporter). Required for government bookings; commercial bookings auto-resolve from trade direction.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyProfileId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Booking } from './booking.entity';
|
||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'booking_container_allocations' })
|
||||
@Index(['bookingId'])
|
||||
@Index(['vehicleId'])
|
||||
export class BookingContainerAllocation extends BaseEntity {
|
||||
@ManyToOne(() => Booking, (b) => b.containerAllocations)
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking!: Booking;
|
||||
|
||||
@Column('uuid', { name: 'booking_id' })
|
||||
bookingId!: string;
|
||||
|
||||
@Column('uuid', { name: 'container_id' })
|
||||
containerId!: string;
|
||||
|
||||
@ManyToOne(() => Vehicle)
|
||||
@JoinColumn({ name: 'vehicle_id' })
|
||||
vehicle!: Vehicle;
|
||||
|
||||
@Column('uuid', { name: 'vehicle_id', nullable: true })
|
||||
vehicleId?: string;
|
||||
|
||||
@Column('text')
|
||||
containerType!: string; // CONTAINER, BULK_DRY, etc
|
||||
|
||||
@Column('integer', { default: 1 })
|
||||
quantity!: number;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { FileRecord } from '../../files/entities/file.entity';
|
||||
import { BookingApprovalStep } from './booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
|
||||
import { BookingContainer } from './booking-container.entity';
|
||||
import { BookingContainerAllocation } from './booking-container-allocation.entity';
|
||||
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
|
||||
import { BookingReviewNote } from './booking-review-note.entity';
|
||||
|
||||
@@ -105,8 +106,10 @@ export class Booking extends BaseEntity {
|
||||
// @JoinColumn({ name: 'customer_id' })
|
||||
// customer?: Customer;
|
||||
|
||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
||||
companyId?: string | null;
|
||||
// Every booking is billed to a company — government bookings bill to a seeded
|
||||
// government company (companies.kind = 'government'). Enforced NOT NULL.
|
||||
@Column({ name: 'company_id', type: 'uuid' })
|
||||
companyId!: string;
|
||||
|
||||
@ManyToOne(() => Company, { nullable: true })
|
||||
@JoinColumn({ name: 'company_id' })
|
||||
@@ -116,11 +119,12 @@ export class Booking extends BaseEntity {
|
||||
* The operational profile (importer/exporter/forwarder) this booking belongs
|
||||
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
|
||||
* EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder.
|
||||
* Customer portal lists and dashboard KPIs are scoped by this. Nullable for
|
||||
* legacy/government/staff-created bookings.
|
||||
* Customer portal lists and dashboard KPIs are scoped by this. Required:
|
||||
* commercial bookings resolve it from trade direction / active mode;
|
||||
* government bookings carry the explicitly-picked government profile.
|
||||
*/
|
||||
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
|
||||
companyProfileId?: string | null;
|
||||
@Column({ name: 'company_profile_id', type: 'uuid' })
|
||||
companyProfileId!: string;
|
||||
|
||||
@ManyToOne(() => CompanyProfile, { nullable: true })
|
||||
@JoinColumn({ name: 'company_profile_id' })
|
||||
@@ -441,6 +445,9 @@ export class Booking extends BaseEntity {
|
||||
@OneToMany(() => BookingContainer, (bc) => bc.booking)
|
||||
bookingContainers?: BookingContainer[];
|
||||
|
||||
@OneToMany(() => BookingContainerAllocation, (ca) => ca.booking)
|
||||
containerAllocations?: BookingContainerAllocation[];
|
||||
|
||||
@OneToMany(() => BookingCargoModifier, (m) => m.booking)
|
||||
cargoModifiers?: BookingCargoModifier[];
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
async findPaginated(
|
||||
query: ListCompaniesQueryDto,
|
||||
): Promise<{ items: Company[]; total: number }> {
|
||||
const { page = 1, pageSize = 20, search, type, status } = query;
|
||||
const { page = 1, pageSize = 20, search, type, kind, status } = query;
|
||||
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('company')
|
||||
@@ -49,6 +49,10 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
qb.andWhere('company.type = :type', { type });
|
||||
}
|
||||
|
||||
if (kind) {
|
||||
qb.andWhere('company.kind = :kind', { kind });
|
||||
}
|
||||
|
||||
if (status) {
|
||||
qb.andWhere('company.status = :status', { status });
|
||||
}
|
||||
|
||||
@@ -337,6 +337,29 @@ export class CompaniesService {
|
||||
return company;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an explicitly-chosen company profile for a booking: it must belong
|
||||
* to the booking's company and be Active. Used for government bookings (staff
|
||||
* pick the profile) and any staff booking that pins a profile directly.
|
||||
*/
|
||||
async getActiveCompanyProfileForBooking(
|
||||
companyId: string,
|
||||
profileId: string,
|
||||
): Promise<CompanyProfile> {
|
||||
const profile = await this.companyProfilesRepo.findById(profileId);
|
||||
if (!profile || profile.companyId !== companyId) {
|
||||
throw new BadRequestException(
|
||||
"Selected company profile does not belong to the chosen company",
|
||||
);
|
||||
}
|
||||
if (profile.status !== ProfileStatus.Active) {
|
||||
throw new BadRequestException(
|
||||
"Selected company profile is not active",
|
||||
);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
async getCompanyInfoByUserId(
|
||||
userId: string,
|
||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
|
||||
import { Transform } from "class-transformer";
|
||||
import { CompanyStatus, CompanyType } from "../entities/company.entity";
|
||||
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity";
|
||||
|
||||
export class ListCompaniesQueryDto {
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@@ -28,6 +28,11 @@ export class ListCompaniesQueryDto {
|
||||
@IsIn(Object.values(CompanyType))
|
||||
type?: CompanyType;
|
||||
|
||||
@ApiPropertyOptional({ enum: CompanyKind })
|
||||
@IsOptional()
|
||||
@IsIn(Object.values(CompanyKind))
|
||||
kind?: CompanyKind;
|
||||
|
||||
@ApiPropertyOptional({ enum: CompanyStatus })
|
||||
@IsOptional()
|
||||
@IsIn(Object.values(CompanyStatus))
|
||||
|
||||
@@ -10,6 +10,16 @@ export enum CompanyType {
|
||||
Transporter = "transporter",
|
||||
}
|
||||
|
||||
/**
|
||||
* Sector of the company — orthogonal to {@link CompanyType} (the trade role).
|
||||
* Government bookings are billed to a single seeded `GOVERNMENT` company instead
|
||||
* of carrying a null company + free-text institution.
|
||||
*/
|
||||
export enum CompanyKind {
|
||||
Commercial = "commercial",
|
||||
Government = "government",
|
||||
}
|
||||
|
||||
export enum CompanyStatus {
|
||||
Active = "active",
|
||||
Pending = "pending",
|
||||
@@ -25,6 +35,7 @@ export enum CompanyNationality {
|
||||
@Entity({ schema: "freight", name: "companies" })
|
||||
@Index(["tin"])
|
||||
@Index(["type"])
|
||||
@Index(["kind"])
|
||||
export class Company extends BaseEntity {
|
||||
@Column({ name: "name", type: "varchar", length: 200 })
|
||||
name!: string;
|
||||
@@ -32,6 +43,16 @@ export class Company extends BaseEntity {
|
||||
@Column({ name: "type", type: "varchar", length: 32, enum: CompanyType })
|
||||
type!: CompanyType;
|
||||
|
||||
/** Commercial customer vs. the seeded government entity. */
|
||||
@Column({
|
||||
name: "kind",
|
||||
type: "varchar",
|
||||
length: 20,
|
||||
default: CompanyKind.Commercial,
|
||||
enum: CompanyKind,
|
||||
})
|
||||
kind!: CompanyKind;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "varchar",
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
@@ -11,6 +12,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
@@ -45,6 +47,8 @@ export interface CreateBookingUnderContractResult {
|
||||
*/
|
||||
@Injectable()
|
||||
export class ContractBookingService {
|
||||
private readonly logger = new Logger(ContractBookingService.name);
|
||||
|
||||
constructor(
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
@@ -52,6 +56,7 @@ export class ContractBookingService {
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
@@ -199,6 +204,22 @@ export class ContractBookingService {
|
||||
}
|
||||
|
||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
|
||||
// Contract bookings are born past the billable gate (the contract is already
|
||||
// executed), so the invoice is generated here — they never pass through the
|
||||
// legacy marketingApprove → FULLY_EXECUTED path that invoices direct bookings.
|
||||
// Idempotent and non-blocking: a billing hiccup must not undo the booking.
|
||||
// Skips silently when unbillable (no company / no priced amount).
|
||||
await this.invoiceService
|
||||
.ensureInvoiceForBooking(result ?? booking)
|
||||
.catch((err) =>
|
||||
this.logger.error(
|
||||
`Failed to generate invoice for contract booking ${booking.reference}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
),
|
||||
);
|
||||
|
||||
return { booking: result ?? booking, warnings };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export class FirstMileContainerAllocationDto {
|
||||
containerId!: string;
|
||||
vehicleId!: string;
|
||||
}
|
||||
|
||||
export class AllocateFirstMileContainersDto {
|
||||
allocations!: FirstMileContainerAllocationDto[];
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { FirstMile } from './first-mile.entity';
|
||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||
|
||||
@Entity({ name: 'first_mile_container_allocations', schema: 'freight' })
|
||||
@Index(['firstMileId'])
|
||||
@Index(['vehicleId'])
|
||||
export class FirstMileContainerAllocation extends BaseEntity {
|
||||
@Column({ name: 'first_mile_id', type: 'uuid' })
|
||||
firstMileId!: string;
|
||||
|
||||
@ManyToOne(() => FirstMile, (firstMile) => firstMile.containerAllocations, {
|
||||
nullable: false,
|
||||
eager: false,
|
||||
})
|
||||
@JoinColumn({ name: 'first_mile_id' })
|
||||
firstMile?: FirstMile;
|
||||
|
||||
@Column({ name: 'container_id', type: 'uuid' })
|
||||
containerId!: string;
|
||||
|
||||
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
|
||||
vehicleId?: string | null;
|
||||
|
||||
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
|
||||
@JoinColumn({ name: 'vehicle_id' })
|
||||
vehicle?: Vehicle | null;
|
||||
|
||||
@Column({ name: 'container_type', type: 'text' })
|
||||
containerType!: string;
|
||||
|
||||
@Column({ name: 'quantity', type: 'int', default: 1 })
|
||||
quantity!: number;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||
import { FirstMileContainerAllocation } from './first-mile-container-allocation.entity';
|
||||
|
||||
export const FIRST_MILE_STATUSES = [
|
||||
'PAYMENT_PENDING',
|
||||
@@ -34,8 +35,9 @@ export class FirstMile extends BaseEntity {
|
||||
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
remainingPayment!: number;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isPostPaymentCompleted!: boolean;
|
||||
// TODO: uncomment after migration creates column
|
||||
// @Column({ type: 'boolean', default: false })
|
||||
// isPostPaymentCompleted!: boolean;
|
||||
|
||||
@Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
estimatedKm?: number | null;
|
||||
@@ -49,4 +51,11 @@ export class FirstMile extends BaseEntity {
|
||||
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
|
||||
@JoinColumn({ name: 'vehicle_id' })
|
||||
vehicle?: Vehicle | null;
|
||||
|
||||
@OneToMany(
|
||||
() => FirstMileContainerAllocation,
|
||||
(containerAllocation) => containerAllocation.firstMile,
|
||||
{ eager: false },
|
||||
)
|
||||
containerAllocations!: FirstMileContainerAllocation[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Freight } from '@edr/types';
|
||||
|
||||
import {
|
||||
BillingService,
|
||||
InvoiceEventPayload,
|
||||
} from '../billing/billing.service';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { FirstMileRepository } from './first-mile.repository';
|
||||
import { FirstMile } from './entities/first-mile.entity';
|
||||
|
||||
/**
|
||||
* Owns the first-mile ⇄ invoice mapping — the one place that knows how a
|
||||
* first-mile record turns into invoices, which type to use, and how it
|
||||
* advances when paid. First-mile records are billable entities, so they
|
||||
* generate their own invoices directly via {@link BillingService}.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FirstMileInvoiceService {
|
||||
private readonly logger = new Logger(FirstMileInvoiceService.name);
|
||||
|
||||
constructor(
|
||||
private readonly billing: BillingService,
|
||||
private readonly firstMileRepo: FirstMileRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Ensure the first-mile record has its invoice, generating one from the
|
||||
* remaining payment if absent. Called when a first-mile record reaches a
|
||||
* billable state. Idempotent — returns the existing open invoice instead
|
||||
* of a duplicate. Returns `null` (and logs) when the record is not billable:
|
||||
* no company to bill.
|
||||
*/
|
||||
async ensureInvoiceFor(record: FirstMile): Promise<Invoice | null> {
|
||||
const existing = await this.billing.findPayable(
|
||||
'first_mile' as Freight.InvoiceSource,
|
||||
record.id,
|
||||
'DELIVERY_FEE',
|
||||
);
|
||||
if (existing) return existing;
|
||||
|
||||
if (!record.bookingId) {
|
||||
this.logger.warn(
|
||||
`Skipping invoice for first-mile record ${record.id}: no booking to reference.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch the booking to get the companyId and companyProfileId
|
||||
const fm = record.booking ? record : (await this.firstMileRepo.findById(record.bookingId, { relations: { booking: true } }));
|
||||
if (!fm) return null;
|
||||
if (!fm.booking?.companyId) {
|
||||
this.logger.warn(
|
||||
`Skipping invoice for first-mile record ${record.id}: no company to bill.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalAmount = record.remainingPayment || 0;
|
||||
if (!Number.isFinite(totalAmount) || totalAmount <= 0) {
|
||||
this.logger.warn(
|
||||
`Skipping invoice for first-mile record ${record.id}: no remaining payment.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.billing.generateInvoice({
|
||||
source: 'first_mile' as Freight.InvoiceSource,
|
||||
sourceId: record.id,
|
||||
type: 'DELIVERY_FEE',
|
||||
companyId: fm.booking!.companyId,
|
||||
companyProfileId: fm.booking!.companyProfileId || '',
|
||||
currency: 'ETB',
|
||||
lines: [
|
||||
{
|
||||
chargeType: 'DELIVERY',
|
||||
description: 'First-mile delivery',
|
||||
quantity: 1,
|
||||
unitRate: totalAmount,
|
||||
amount: totalAmount,
|
||||
},
|
||||
],
|
||||
totalAmount,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* React to a first-mile invoice being paid — the settlement branch point.
|
||||
* Mark the first-mile record as having completed post-payment processing.
|
||||
*/
|
||||
@OnEvent('first_mile.invoice.paid')
|
||||
async onPaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
if (payload.type === 'DELIVERY_FEE') {
|
||||
const record = await this.firstMileRepo.findById(payload.sourceId);
|
||||
if (!record) {
|
||||
this.logger.warn(
|
||||
`Cannot mark unknown first-mile record ${payload.sourceId} as paid.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`First-mile invoice paid for record ${payload.sourceId}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,15 +17,20 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
|
||||
|
||||
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
||||
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
||||
import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto';
|
||||
import { FirstMileStatus } from './entities/first-mile.entity';
|
||||
import { FirstMileService } from './first-mile.service';
|
||||
import { FirstMileInvoiceService } from './first-mile-invoice.service';
|
||||
|
||||
@ApiTags('first-mile')
|
||||
@ApiBearerAuth()
|
||||
@Controller('first-mile')
|
||||
@TrainSchedulingView()
|
||||
export class FirstMileController {
|
||||
constructor(private readonly firstMileService: FirstMileService) {}
|
||||
constructor(
|
||||
private readonly firstMileService: FirstMileService,
|
||||
private readonly firstMileInvoiceService: FirstMileInvoiceService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List first-mile legs' })
|
||||
@@ -72,8 +77,13 @@ export class FirstMileController {
|
||||
@Patch(':id')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Update a first-mile leg' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
|
||||
return this.firstMileService.update(id, dto);
|
||||
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
|
||||
const record = await this.firstMileService.update(id, dto);
|
||||
// Auto-generate invoice if distance or payment was updated
|
||||
if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) {
|
||||
await this.firstMileInvoiceService.ensureInvoiceFor(record);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@@ -83,4 +93,14 @@ export class FirstMileController {
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.firstMileService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':firstMileId/allocate-containers')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Allocate containers to vehicles for a first-mile leg' })
|
||||
allocateContainers(
|
||||
@Param('firstMileId', ParseUUIDPipe) firstMileId: string,
|
||||
@Body() dto: AllocateFirstMileContainersDto,
|
||||
) {
|
||||
return this.firstMileService.allocateContainers(firstMileId, dto.allocations);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { DriversModule } from '../drivers/drivers.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { VehiclesModule } from '../vehicles/vehicles.module';
|
||||
import { FirstMile } from './entities/first-mile.entity';
|
||||
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
|
||||
import { FirstMileController } from './first-mile.controller';
|
||||
import { FirstMileInvoiceService } from './first-mile-invoice.service';
|
||||
import { FirstMileRepository } from './first-mile.repository';
|
||||
import { FirstMileService } from './first-mile.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([FirstMile]),
|
||||
TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]),
|
||||
BillingModule,
|
||||
forwardRef(() => BookingsModule),
|
||||
VehiclesModule,
|
||||
DriversModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [FirstMileController],
|
||||
providers: [FirstMileRepository, FirstMileService],
|
||||
exports: [FirstMileRepository, FirstMileService],
|
||||
providers: [FirstMileRepository, FirstMileService, FirstMileInvoiceService],
|
||||
exports: [FirstMileRepository, FirstMileService, FirstMileInvoiceService],
|
||||
})
|
||||
export class FirstMileModule {}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { FindOptionsWhere } from 'typeorm';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { DriversService } from '../drivers/drivers.service';
|
||||
@@ -8,6 +10,7 @@ import { VehiclesService } from '../vehicles/vehicles.service';
|
||||
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
||||
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
||||
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
|
||||
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
|
||||
import { FirstMileRepository } from './first-mile.repository';
|
||||
|
||||
type FirstMileListFilter = {
|
||||
@@ -32,6 +35,7 @@ export class FirstMileService {
|
||||
private readonly logger = new Logger(FirstMileService.name);
|
||||
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly firstMileRepository: FirstMileRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
@@ -273,4 +277,35 @@ export class FirstMileService {
|
||||
await this.findById(id);
|
||||
await this.firstMileRepository.softDelete(id);
|
||||
}
|
||||
|
||||
async allocateContainers(
|
||||
firstMileId: string,
|
||||
allocations: Array<{ containerId: string; vehicleId: string }>,
|
||||
) {
|
||||
const firstMile = await this.findById(firstMileId);
|
||||
if (!firstMile) {
|
||||
throw new NotFoundException(`First-mile record ${firstMileId} not found`);
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
for (const allocation of allocations) {
|
||||
await manager.delete(FirstMileContainerAllocation, {
|
||||
firstMileId,
|
||||
containerId: allocation.containerId,
|
||||
});
|
||||
await manager.insert(FirstMileContainerAllocation, {
|
||||
firstMileId,
|
||||
containerId: allocation.containerId,
|
||||
vehicleId: allocation.vehicleId,
|
||||
containerType: 'CONTAINER',
|
||||
quantity: 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
allocated: allocations.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export class LastMileContainerAllocationDto {
|
||||
containerId!: string;
|
||||
vehicleId!: string;
|
||||
}
|
||||
|
||||
export class AllocateLastMileContainersDto {
|
||||
allocations!: LastMileContainerAllocationDto[];
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { LastMile } from './last-mile.entity';
|
||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'last_mile_container_allocations' })
|
||||
@Index(['lastMileId'])
|
||||
@Index(['vehicleId'])
|
||||
export class LastMileContainerAllocation extends BaseEntity {
|
||||
@ManyToOne(() => LastMile, (lm) => lm.containerAllocations)
|
||||
@JoinColumn({ name: 'last_mile_id' })
|
||||
lastMile!: LastMile;
|
||||
|
||||
@Column('uuid', { name: 'last_mile_id' })
|
||||
lastMileId!: string;
|
||||
|
||||
@Column('uuid', { name: 'container_id' })
|
||||
containerId!: string;
|
||||
|
||||
@ManyToOne(() => Vehicle)
|
||||
@JoinColumn({ name: 'vehicle_id' })
|
||||
vehicle?: Vehicle | null;
|
||||
|
||||
@Column('uuid', { name: 'vehicle_id', nullable: true })
|
||||
vehicleId?: string | null;
|
||||
|
||||
@Column('text')
|
||||
containerType!: string;
|
||||
|
||||
@Column('integer', { default: 1 })
|
||||
quantity!: number;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||
import { LastMileContainerAllocation } from './last-mile-container-allocation.entity';
|
||||
|
||||
export const LAST_MILE_STATUSES = [
|
||||
'PAYMENT_PENDING',
|
||||
@@ -34,8 +35,9 @@ export class LastMile extends BaseEntity {
|
||||
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
remainingPayment!: number;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isPostPaymentCompleted!: boolean;
|
||||
// TODO: uncomment after migration creates column
|
||||
// @Column({ type: 'boolean', default: false })
|
||||
// isPostPaymentCompleted!: boolean;
|
||||
|
||||
@Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
estimatedKm?: number | null;
|
||||
@@ -49,4 +51,7 @@ export class LastMile extends BaseEntity {
|
||||
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
|
||||
@JoinColumn({ name: 'vehicle_id' })
|
||||
vehicle?: Vehicle | null;
|
||||
|
||||
@OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile)
|
||||
containerAllocations?: LastMileContainerAllocation[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Freight } from '@edr/types';
|
||||
|
||||
import {
|
||||
BillingService,
|
||||
GenerateInvoiceInput,
|
||||
InvoiceEventPayload,
|
||||
} from '../billing/billing.service';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { LastMileRepository } from './last-mile.repository';
|
||||
import { LastMile } from './entities/last-mile.entity';
|
||||
|
||||
/**
|
||||
* Owns the last-mile ⇄ invoice mapping — the one place that knows how a last-mile
|
||||
* record turns into invoices, which type to use, and how it advances when paid.
|
||||
* Last-mile records are billable business entities for delivery fees, so they
|
||||
* generate their own invoices directly via {@link BillingService}. All last-mile-specific
|
||||
* type branching lives here, at the two points it belongs: invoice creation and
|
||||
* settlement (the paid handler).
|
||||
*/
|
||||
@Injectable()
|
||||
export class LastMileInvoiceService {
|
||||
private readonly logger = new Logger(LastMileInvoiceService.name);
|
||||
|
||||
constructor(
|
||||
private readonly billing: BillingService,
|
||||
private readonly lastMileRepo: LastMileRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Ensure the last-mile record has its invoice, generating one from the
|
||||
* remainingPayment if absent. Called when a last-mile record reaches a
|
||||
* billable state. Idempotent — returns the existing open invoice instead
|
||||
* of a duplicate. Returns `null` (and logs) when the record is not billable:
|
||||
* no company to bill (invoices FK requires a companyId).
|
||||
*/
|
||||
async ensureInvoiceFor(record: LastMile): Promise<Invoice | null> {
|
||||
// Check if invoice already exists
|
||||
const existing = await this.billing.findPayable(
|
||||
'last_mile' as Freight.InvoiceSource,
|
||||
record.id,
|
||||
'DELIVERY_FEE',
|
||||
);
|
||||
if (existing) return existing;
|
||||
|
||||
// Can't bill without company
|
||||
const lm = record.booking ? record : (await this.lastMileRepo.findById(record.id, { relations: { booking: true } }));
|
||||
if (!lm) return null;
|
||||
if (!lm.booking?.companyId) {
|
||||
this.logger.warn(
|
||||
`Skipping invoice for last-mile record ${record.id}: no company to bill.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate invoice with remainingPayment as totalAmount
|
||||
const input: GenerateInvoiceInput = {
|
||||
source: 'last_mile' as Freight.InvoiceSource,
|
||||
sourceId: record.id,
|
||||
type: 'DELIVERY_FEE',
|
||||
companyId: lm.booking!.companyId,
|
||||
companyProfileId: lm.booking!.companyProfileId || '',
|
||||
currency: 'ETB',
|
||||
lines: [
|
||||
{
|
||||
chargeType: 'DELIVERY',
|
||||
description: 'Last-mile delivery',
|
||||
quantity: 1,
|
||||
unitRate: record.remainingPayment || 0,
|
||||
amount: record.remainingPayment || 0,
|
||||
},
|
||||
],
|
||||
totalAmount: record.remainingPayment || 0,
|
||||
};
|
||||
|
||||
return this.billing.generateInvoice(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* React to a last-mile invoice being paid — the settlement branch point.
|
||||
* Advances the last-mile record to mark post-payment as completed.
|
||||
*/
|
||||
@OnEvent('last_mile.invoice.paid')
|
||||
async onPaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
if (payload.type === 'DELIVERY_FEE') {
|
||||
const record = await this.lastMileRepo.findById(payload.sourceId);
|
||||
if (record) {
|
||||
this.logger.log(`Last-mile invoice paid for record ${payload.sourceId}.`);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Cannot mark last-mile record ${payload.sourceId} as paid: not found.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,15 +17,20 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
|
||||
|
||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||
import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto';
|
||||
import { LastMileStatus } from './entities/last-mile.entity';
|
||||
import { LastMileService } from './last-mile.service';
|
||||
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
||||
|
||||
@ApiTags('last-mile')
|
||||
@ApiBearerAuth()
|
||||
@Controller('last-mile')
|
||||
@TrainSchedulingView()
|
||||
export class LastMileController {
|
||||
constructor(private readonly lastMileService: LastMileService) {}
|
||||
constructor(
|
||||
private readonly lastMileService: LastMileService,
|
||||
private readonly lastMileInvoiceService: LastMileInvoiceService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List last-mile legs' })
|
||||
@@ -72,8 +77,13 @@ export class LastMileController {
|
||||
@Patch(':id')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Update a last-mile leg' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
|
||||
return this.lastMileService.update(id, dto);
|
||||
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
|
||||
const record = await this.lastMileService.update(id, dto);
|
||||
// Auto-generate invoice if distance or payment was updated
|
||||
if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) {
|
||||
await this.lastMileInvoiceService.ensureInvoiceFor(record);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@@ -83,4 +93,14 @@ export class LastMileController {
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.lastMileService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/allocate-containers')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Allocate containers to vehicles' })
|
||||
async allocateContainers(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AllocateLastMileContainersDto,
|
||||
) {
|
||||
return this.lastMileService.allocateContainers(id, dto.allocations);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { DriversModule } from '../drivers/drivers.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { VehiclesModule } from '../vehicles/vehicles.module';
|
||||
import { LastMile } from './entities/last-mile.entity';
|
||||
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
||||
import { LastMileController } from './last-mile.controller';
|
||||
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
||||
import { LastMileRepository } from './last-mile.repository';
|
||||
import { LastMileService } from './last-mile.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([LastMile]),
|
||||
TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation]),
|
||||
BillingModule,
|
||||
forwardRef(() => BookingsModule),
|
||||
VehiclesModule,
|
||||
DriversModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [LastMileController],
|
||||
providers: [LastMileRepository, LastMileService],
|
||||
exports: [LastMileRepository, LastMileService],
|
||||
providers: [LastMileRepository, LastMileService, LastMileInvoiceService],
|
||||
exports: [LastMileRepository, LastMileService, LastMileInvoiceService],
|
||||
})
|
||||
export class LastMileModule {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { FindOptionsWhere } from 'typeorm';
|
||||
import { DataSource, FindOptionsWhere } from 'typeorm';
|
||||
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { DriversService } from '../drivers/drivers.service';
|
||||
@@ -8,6 +8,7 @@ import { VehiclesService } from '../vehicles/vehicles.service';
|
||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
||||
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
||||
import { LastMileRepository } from './last-mile.repository';
|
||||
|
||||
type LastMileListFilter = {
|
||||
@@ -37,6 +38,7 @@ export class LastMileService {
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
private readonly driversService: DriversService,
|
||||
private readonly smsClient: SmsClientService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
|
||||
@@ -206,4 +208,35 @@ export class LastMileService {
|
||||
await this.findById(id);
|
||||
await this.lastMileRepository.softDelete(id);
|
||||
}
|
||||
|
||||
async allocateContainers(
|
||||
lastMileId: string,
|
||||
allocations: Array<{ containerId: string; vehicleId: string }>,
|
||||
) {
|
||||
const lastMile = await this.findById(lastMileId);
|
||||
if (!lastMile) {
|
||||
throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
for (const allocation of allocations) {
|
||||
await manager.delete(LastMileContainerAllocation, {
|
||||
lastMileId,
|
||||
containerId: allocation.containerId,
|
||||
});
|
||||
await manager.insert(LastMileContainerAllocation, {
|
||||
lastMileId,
|
||||
containerId: allocation.containerId,
|
||||
vehicleId: allocation.vehicleId,
|
||||
containerType: 'CONTAINER',
|
||||
quantity: 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
allocated: allocations.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { BaseEntity, Column, CreateDateColumn, Entity, OneToMany, PrimaryGenerat
|
||||
import { PaymentRefundEntity } from "./payment-refund.entity";
|
||||
|
||||
|
||||
type PaymentType = "booking"
|
||||
/** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */
|
||||
type PaymentType = string
|
||||
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank"
|
||||
type Currency = "ETB" | "USD"
|
||||
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
||||
@@ -15,9 +16,12 @@ export class PaymentEntity extends BaseEntity {
|
||||
@Column({ type: 'varchar', length: 255, name: "ref_id" })
|
||||
refId!: string
|
||||
|
||||
@Column({ type: "enum", enum: ["booking"] })
|
||||
@Column({ type: "varchar", length: 50 })
|
||||
type!: PaymentType;
|
||||
|
||||
@Column({ type: "varchar", length: 40, nullable: true, name: "reference_type" })
|
||||
referenceType?: string;
|
||||
|
||||
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] })
|
||||
method!: PaymentMethod
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
@@ -18,16 +16,9 @@ import {
|
||||
} from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { BookingView, FreightAdmin } from "../../common/booking-guards";
|
||||
import { BookingView } from "../../common/booking-guards";
|
||||
import { PaymentService } from "./payment.service";
|
||||
import {
|
||||
InitiatePaymentDto,
|
||||
InitiateResponseDto,
|
||||
IntentStatusDto,
|
||||
PaymentMethodTypeEnum,
|
||||
PaymentPlatformDto,
|
||||
RefundDto,
|
||||
} from "./payments.dto";
|
||||
import { IntentStatusDto } from "./payments.dto";
|
||||
|
||||
@ApiTags("Payment")
|
||||
@Controller("payments")
|
||||
@@ -73,16 +64,6 @@ export class PaymentController {
|
||||
});
|
||||
}
|
||||
|
||||
@Post("initiate")
|
||||
@ApiOperation({
|
||||
summary: "Initiate payment for a freight booking",
|
||||
description: `Initiates payment via the central payment microservice.\n\n**Supported methods:**\n- TELEBIRR — Ethiopian mobile money\n- CBE_BIRR — Commercial Bank of Ethiopia\n- EBIRR — Electronic payment gateway\n- WAAFI — Djibouti mobile money\n- CARD — Visa/Mastercard\n- DMONEY — Djibouti D-money\n- CAC_BANK — CAC Int Bank (OTP)`,
|
||||
})
|
||||
@ApiOkResponse({ type: InitiateResponseDto })
|
||||
initiatePayment(@Body() dto: InitiatePaymentDto) {
|
||||
return this.paymentService.initiatePayment(dto);
|
||||
}
|
||||
|
||||
@Get("intents/:bookingId")
|
||||
@ApiOperation({ summary: "Get payment intent status for a booking" })
|
||||
@ApiOkResponse({ type: IntentStatusDto })
|
||||
@@ -90,61 +71,6 @@ export class PaymentController {
|
||||
return this.paymentService.getIntentByBookingId(bookingId);
|
||||
}
|
||||
|
||||
@Post("refund")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Refund a paid booking (staff/admin only)" })
|
||||
refund(@Body() dto: RefundDto) {
|
||||
return this.paymentService.refund(dto);
|
||||
}
|
||||
|
||||
@Get("checkout")
|
||||
@Public()
|
||||
@ApiOperation({
|
||||
summary: "Browser checkout redirect",
|
||||
description:
|
||||
"Initiates payment and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
|
||||
})
|
||||
@ApiQuery({ name: "bookingId", required: true })
|
||||
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
|
||||
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
|
||||
@ApiProduces("text/html")
|
||||
async checkout(
|
||||
@Query("bookingId") bookingId: string,
|
||||
@Query("method") method: PaymentMethodTypeEnum,
|
||||
@Query("platform") platform: PaymentPlatformDto = "web",
|
||||
@Res() res: Response,
|
||||
) {
|
||||
if (!bookingId) {
|
||||
return res
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.type("html")
|
||||
.send(this.buildErrorHtml("Missing required query parameter: bookingId"));
|
||||
}
|
||||
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.paymentService.initiatePayment({ bookingId, 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));
|
||||
}
|
||||
}
|
||||
|
||||
@Get("receipt/:orderId")
|
||||
@Public()
|
||||
@ApiOperation({ summary: "Generate a payment receipt HTML page" })
|
||||
@@ -153,76 +79,4 @@ export class PaymentController {
|
||||
const html = await this.paymentService.genReceiptHtml(orderId);
|
||||
return res.status(HttpStatus.OK).type("html").send(html);
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DynamicModule, Module, forwardRef } from "@nestjs/common";
|
||||
import { DynamicModule, forwardRef, Module } from "@nestjs/common";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
|
||||
@@ -12,9 +12,7 @@ import {
|
||||
} from "@edr/types";
|
||||
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module";
|
||||
import { FirstMileModule } from "../first-mile/first-mile.module";
|
||||
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
||||
import { BillingModule } from "../billing/billing.module";
|
||||
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
|
||||
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
|
||||
import { PaymentEntity } from "./entities/payment.entity";
|
||||
@@ -58,9 +56,7 @@ function rabbitMQImport(): DynamicModule[] {
|
||||
imports: [
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
ConfigModule,
|
||||
DropdownSettingsModule,
|
||||
forwardRef(() => FirstMileModule),
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
forwardRef(() => BillingModule),
|
||||
TypeOrmModule.forFeature([
|
||||
PaymentEntity,
|
||||
PaymentWebhookEventEntity,
|
||||
|
||||
@@ -1,459 +1,549 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
import { PaymentEntity } from "./entities/payment.entity";
|
||||
import { PaymentRepository } from "./payment.repository";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as Handlebars from "handlebars";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
|
||||
import { ClientAction, ProviderPaymentStatus } from "@edr/payment-providers";
|
||||
import {
|
||||
ClientAction,
|
||||
ProviderPaymentStatus,
|
||||
} from "@edr/payment-providers";
|
||||
import {
|
||||
PaymentService as PaymentServiceEnum,
|
||||
PaymentReferenceType,
|
||||
PaymentIntentSnapshot,
|
||||
ProviderMethod,
|
||||
PaymentService as PaymentServiceEnum,
|
||||
PaymentReferenceType,
|
||||
PaymentIntentSnapshot,
|
||||
ProviderMethod,
|
||||
} from "@edr/types";
|
||||
import {
|
||||
InitiatePaymentDto,
|
||||
InitiateResponseDto,
|
||||
IntentStatusDto,
|
||||
RefundDto,
|
||||
InitiateResponseDto,
|
||||
IntentStatusDto,
|
||||
PaymentPlatformDto,
|
||||
} from "./payments.dto";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
import { FirstMileService } from "../first-mile/first-mile.service";
|
||||
|
||||
/** Everything the gateway needs to open an intent. Amount/currency are supplied by
|
||||
* the caller (billing) — this service never derives them from a domain record. */
|
||||
export interface InitiateIntentInput {
|
||||
/** Opaque domain reference (booking id, …). */
|
||||
referenceId: string;
|
||||
/** Invoice source that owns the intent ('booking', …) — stored on the projection. */
|
||||
source: string;
|
||||
/** Gateway reference type the intent is opened with (caller's domain decides it). */
|
||||
referenceType: PaymentReferenceType;
|
||||
/** Human-readable order ref shown on provider pages. */
|
||||
orderRef: string;
|
||||
/** Authoritative amount in minor units, computed by the caller. */
|
||||
amountMinor: number;
|
||||
currency: string;
|
||||
/** Stored on the intent projection for receipts/dashboards. */
|
||||
reason?: string;
|
||||
/** Provider/method selector. */
|
||||
method: ProviderMethod | string;
|
||||
platform?: PaymentPlatformDto;
|
||||
payerAccount?: string;
|
||||
returnUrl?: string;
|
||||
failureUrl?: string;
|
||||
}
|
||||
|
||||
export interface InitiateIntentResult {
|
||||
intentId: string;
|
||||
response: InitiateResponseDto;
|
||||
/** True when the provider settled the charge synchronously during initiate. */
|
||||
immediateSuccess: boolean;
|
||||
providerTxnId?: string;
|
||||
paidAt?: Date;
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
|
||||
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
"processing": ProviderPaymentStatus.PROCESSING,
|
||||
"success": ProviderPaymentStatus.SUCCEEDED,
|
||||
"failed": ProviderPaymentStatus.FAILED,
|
||||
"canceled": ProviderPaymentStatus.CANCELLED,
|
||||
"refunded": ProviderPaymentStatus.CANCELLED,
|
||||
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
processing: ProviderPaymentStatus.PROCESSING,
|
||||
success: ProviderPaymentStatus.SUCCEEDED,
|
||||
failed: ProviderPaymentStatus.FAILED,
|
||||
canceled: ProviderPaymentStatus.CANCELLED,
|
||||
refunded: ProviderPaymentStatus.CANCELLED,
|
||||
};
|
||||
|
||||
const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
|
||||
TELEBIRR: "telebirr",
|
||||
CBE_BIRR: "cbe-birr",
|
||||
EBIRR: "ebirr",
|
||||
WAAFI: "waafi",
|
||||
CARD: "card",
|
||||
DMONEY: "dmoney",
|
||||
CAC_BANK: "cac-bank",
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure payment-gateway adapter. Owns intents, provider calls and webhooks — and
|
||||
* NOTHING domain-specific: it never loads a booking, computes an amount, or
|
||||
* advances a domain record. On settlement it notifies billing directly
|
||||
* ({@link BillingService.settleByPaymentId}); billing (and through it, the domain)
|
||||
* reacts. The billing↔payment pair is a deliberate forwardRef cycle.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PaymentService {
|
||||
private readonly logger = new Logger(PaymentService.name);
|
||||
private readonly logger = new Logger(PaymentService.name);
|
||||
|
||||
constructor(
|
||||
private readonly datasource: DataSource,
|
||||
private readonly paymentRepo: PaymentRepository,
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly firstMileService: FirstMileService,
|
||||
) { }
|
||||
constructor(
|
||||
private readonly paymentRepo: PaymentRepository,
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
@Inject(forwardRef(() => BillingService))
|
||||
private readonly billing: BillingService,
|
||||
) { }
|
||||
|
||||
async getAll(filters: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
method?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
const { search, status, method, page = 1, pageSize = 10 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
async getAll(filters: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
method?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
const { search, status, method, page = 1, pageSize = 10 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const qb = this.paymentRepo.createQueryBuilder("payment");
|
||||
const qb = this.paymentRepo.createQueryBuilder("payment");
|
||||
|
||||
if (search) {
|
||||
qb.andWhere(
|
||||
"(payment.merchantOrderId ILIKE :search OR payment.refId ILIKE :search OR payment.transactionId ILIKE :search)",
|
||||
{ search: `%${search}%` },
|
||||
);
|
||||
}
|
||||
if (status) {
|
||||
qb.andWhere("payment.status = :status", { status });
|
||||
}
|
||||
if (method) {
|
||||
qb.andWhere("payment.method = :method", { method });
|
||||
}
|
||||
if (search) {
|
||||
qb.andWhere(
|
||||
"(payment.merchantOrderId ILIKE :search OR payment.refId ILIKE :search OR payment.transactionId ILIKE :search)",
|
||||
{ search: `%${search}%` },
|
||||
);
|
||||
}
|
||||
if (status) {
|
||||
qb.andWhere("payment.status = :status", { status });
|
||||
}
|
||||
if (method) {
|
||||
qb.andWhere("payment.method = :method", { method });
|
||||
}
|
||||
|
||||
const [items, total] = await qb
|
||||
.orderBy("payment.createdAt", "DESC")
|
||||
.skip(skip)
|
||||
.take(pageSize)
|
||||
.getManyAndCount();
|
||||
const [items, total] = await qb
|
||||
.orderBy("payment.createdAt", "DESC")
|
||||
.skip(skip)
|
||||
.take(pageSize)
|
||||
.getManyAndCount();
|
||||
|
||||
return {
|
||||
items: items.map((p) => ({
|
||||
id: p.id,
|
||||
bookingId: p.refId,
|
||||
amount: p.amount,
|
||||
currency: p.currency,
|
||||
method: p.method,
|
||||
status: p.status,
|
||||
merchantOrderId: p.merchantOrderId,
|
||||
paidAt: p.paidAt,
|
||||
createdAt: p.createdAt,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
/** Aggregate counts across ALL payments for the dashboard summary cards. */
|
||||
async getSummary() {
|
||||
const rows = await this.paymentRepo
|
||||
.createQueryBuilder("payment")
|
||||
.select("payment.status", "status")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.groupBy("payment.status")
|
||||
.getRawMany<{ status: string; count: number }>();
|
||||
|
||||
const byStatus: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const row of rows) {
|
||||
byStatus[row.status] = row.count;
|
||||
total += row.count;
|
||||
}
|
||||
|
||||
const paidAgg = await this.paymentRepo
|
||||
.createQueryBuilder("payment")
|
||||
.select("COALESCE(SUM(payment.amount), 0)", "sum")
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.getRawOne<{ sum: string }>();
|
||||
|
||||
return {
|
||||
total,
|
||||
success: byStatus["success"] ?? 0,
|
||||
processing:
|
||||
(byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0),
|
||||
failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0),
|
||||
refunded: byStatus["refunded"] ?? 0,
|
||||
paidAmount: Number(paidAgg?.sum ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a gateway intent for a caller-supplied amount/reference and project it
|
||||
* locally. Returns the intent id (so billing can correlate the invoice) plus
|
||||
* the client action. When the provider settles synchronously, the intent is
|
||||
* marked paid WITHOUT emitting — the caller (billing) settles inline after it
|
||||
* has stored the intent id, avoiding a settle-before-correlation race.
|
||||
*/
|
||||
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
referenceId: input.referenceId,
|
||||
orderRef: input.orderRef,
|
||||
amountMinor: input.amountMinor,
|
||||
currency: input.currency,
|
||||
provider: input.method as ProviderMethod,
|
||||
platform: input.platform,
|
||||
payerAccount: input.payerAccount,
|
||||
returnUrl:
|
||||
input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
|
||||
failureUrl:
|
||||
input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
|
||||
});
|
||||
|
||||
const immediateSuccess =
|
||||
snapshot.status === ProviderPaymentStatus.SUCCEEDED;
|
||||
const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined;
|
||||
|
||||
const intent = await this.upsertIntent(input, snapshot);
|
||||
|
||||
if (immediateSuccess) {
|
||||
// Settle the projection but DO NOT notify billing — billing settles
|
||||
// inline once it has stored intentId on the invoice (see payInvoice),
|
||||
// avoiding a settle-before-correlation race.
|
||||
await this.markIntentSucceeded(intent.id, {
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt,
|
||||
notify: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
intentId: intent.id,
|
||||
// `intent` still reflects the projection status ("processing" on immediate
|
||||
// success — settlement is applied by the caller, not shown synchronously).
|
||||
response: this.formatIntentResponse(intent),
|
||||
immediateSuccess,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create or update the local intent projection from a provider snapshot. */
|
||||
private async upsertIntent(
|
||||
input: InitiateIntentInput,
|
||||
snapshot: PaymentIntentSnapshot,
|
||||
): Promise<PaymentEntity> {
|
||||
const existing = await this.paymentRepo.findOneBy({
|
||||
refId: input.referenceId,
|
||||
});
|
||||
|
||||
const method: PaymentEntity["method"] =
|
||||
PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr";
|
||||
const status =
|
||||
snapshot.status === ProviderPaymentStatus.SUCCEEDED
|
||||
? "processing"
|
||||
: this.toLocalStatus(snapshot.status);
|
||||
|
||||
const clientAction = (snapshot.clientAction ?? undefined) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const data = {
|
||||
status,
|
||||
method,
|
||||
merchantOrderId:
|
||||
snapshot.merchantOrderId ?? existing?.merchantOrderId ?? "",
|
||||
transactionId: snapshot.providerTxnId ?? existing?.transactionId,
|
||||
expiresAt: snapshot.expiresAt
|
||||
? new Date(snapshot.expiresAt)
|
||||
: existing?.expiresAt,
|
||||
failerCode: snapshot.failureCode ?? undefined,
|
||||
failureMessage: snapshot.failureMessage ?? undefined,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await this.paymentRepo.update({ id: existing.id }, {
|
||||
...data,
|
||||
clientAction,
|
||||
} as any);
|
||||
return { ...existing, ...data, clientAction } as PaymentEntity;
|
||||
}
|
||||
|
||||
return this.paymentRepo.create({
|
||||
refId: input.referenceId,
|
||||
type: input.source,
|
||||
referenceType: input.referenceType,
|
||||
amount: input.amountMinor,
|
||||
currency: input.currency as PaymentEntity["currency"],
|
||||
reason: input.reason ?? `Payment for ${input.orderRef}`,
|
||||
rawInitiation: snapshot as unknown as Record<string, unknown>,
|
||||
clientAction: clientAction ?? {},
|
||||
...data,
|
||||
} as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile an intent's status with the gateway by reference. Read-only on the
|
||||
* domain side: it syncs the local projection and, when the provider reports a
|
||||
* newly-observed success, notifies billing to settle. `referenceId` is opaque
|
||||
* (the booking id, but this service does not load it).
|
||||
*/
|
||||
async getIntentByBookingId(referenceId: string): Promise<IntentStatusDto> {
|
||||
const local = await this.paymentRepo.findOneBy({ refId: referenceId });
|
||||
|
||||
let snapshot: PaymentIntentSnapshot | null = null;
|
||||
try {
|
||||
snapshot = await this.paymentClient.getIntentByReference(
|
||||
(local?.referenceType as PaymentReferenceType) ??
|
||||
PaymentReferenceType.SHIPMENT,
|
||||
referenceId,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`payment service lookup failed for reference ${referenceId}: ${message}; using local intent`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
if (!local) throw new NotFoundException("PaymentIntent not found");
|
||||
return this.formatIntentStatus(local);
|
||||
}
|
||||
if (!local) throw new NotFoundException("PaymentIntent not found");
|
||||
|
||||
// Sync local projection with provider-reported status.
|
||||
const becameSuccess =
|
||||
snapshot.status === ProviderPaymentStatus.SUCCEEDED &&
|
||||
local.status !== "success";
|
||||
|
||||
if (becameSuccess) {
|
||||
await this.markIntentSucceeded(local.id, {
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
notify: true,
|
||||
});
|
||||
} else if (snapshot.status !== ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.paymentRepo.update(
|
||||
{ id: local.id },
|
||||
{
|
||||
status: this.toLocalStatus(snapshot.status),
|
||||
failerCode: snapshot.failureCode ?? undefined,
|
||||
failureMessage: snapshot.failureMessage ?? undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const refreshed = await this.paymentRepo.findOneBy({ id: local.id });
|
||||
return this.formatIntentStatus(refreshed ?? local);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a gateway intent paid and (by default) notify billing to settle the
|
||||
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`
|
||||
* when the caller settles inline and will trigger settlement itself.
|
||||
*/
|
||||
async markIntentSucceeded(
|
||||
intentId: string,
|
||||
opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {},
|
||||
): Promise<{ alreadyFinalized: boolean }> {
|
||||
const intent = await this.paymentRepo.findOneBy({ id: intentId });
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
if (intent.status === "success") return { alreadyFinalized: true };
|
||||
|
||||
const paidAt = opts.paidAt ?? new Date();
|
||||
await this.paymentRepo.update(
|
||||
{ id: intent.id },
|
||||
{
|
||||
status: "success",
|
||||
paidAt,
|
||||
transactionId: opts.providerTxnId ?? intent.transactionId,
|
||||
},
|
||||
);
|
||||
|
||||
if (opts.notify !== false) {
|
||||
await this.billing.settleByPaymentId(
|
||||
intent.id,
|
||||
opts.providerTxnId,
|
||||
paidAt,
|
||||
);
|
||||
}
|
||||
|
||||
return { alreadyFinalized: false };
|
||||
}
|
||||
|
||||
async markPaymentFailed(input: {
|
||||
intentId: string;
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
}): Promise<void> {
|
||||
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
if (intent.status === "success" || intent.status === "canceled") return;
|
||||
|
||||
await this.paymentRepo.update(
|
||||
{ id: intent.id },
|
||||
{
|
||||
status: "failed",
|
||||
failerCode: input.failureCode,
|
||||
failureMessage: input.failureMessage,
|
||||
},
|
||||
);
|
||||
|
||||
// Invoice stays open for retry — nothing to settle. Logged only.
|
||||
this.logger.warn(
|
||||
`Payment ${intent.id} failed for ${intent.refId}` +
|
||||
(input.failureMessage ? `: ${input.failureMessage}` : ""),
|
||||
);
|
||||
}
|
||||
|
||||
async getActivePaymentByOrderIdAndMethod(
|
||||
orderId: string,
|
||||
method: PaymentEntity["method"],
|
||||
): Promise<PaymentEntity | null> {
|
||||
return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method);
|
||||
}
|
||||
|
||||
async genReceiptHtml(orderId: string) {
|
||||
const payment = await this.paymentRepo.findOneBy({
|
||||
merchantOrderId: orderId,
|
||||
status: "success",
|
||||
});
|
||||
if (!payment)
|
||||
throw new BadRequestException(
|
||||
"No successful payment found for this order",
|
||||
);
|
||||
|
||||
const filePath = path.join(__dirname, "templates", "receipt.hbs");
|
||||
if (!fs.existsSync(filePath)) throw new InternalServerErrorException();
|
||||
|
||||
const source = fs.readFileSync(filePath, "utf8");
|
||||
const template = Handlebars.compile(source);
|
||||
return template({
|
||||
vendorName: "Ethio Djibouti Railway Freight Booking",
|
||||
vendorAddress: "Addis Ababa",
|
||||
receiptDate: payment.paidAt,
|
||||
paymentMethod: payment.method,
|
||||
subtotal: payment.amount.toString(),
|
||||
total: payment.amount.toString(),
|
||||
currency: payment.currency,
|
||||
reason: payment.reason,
|
||||
});
|
||||
}
|
||||
|
||||
findBookingById(id: string) {
|
||||
return this.paymentRepo.findOneBy({ refId: id });
|
||||
}
|
||||
|
||||
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
|
||||
const clientAction =
|
||||
intent.clientAction && typeof intent.clientAction === "object"
|
||||
? (intent.clientAction as unknown as ClientAction)
|
||||
: undefined;
|
||||
return {
|
||||
intentId: intent.id,
|
||||
status: STATUS_MAP[intent.status] ?? ProviderPaymentStatus.PROCESSING,
|
||||
clientAction,
|
||||
merchantOrderId: intent.merchantOrderId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private formatIntentStatus(intent: PaymentEntity): IntentStatusDto {
|
||||
return {
|
||||
...this.formatIntentResponse(intent),
|
||||
paidAt: intent.paidAt?.toISOString(),
|
||||
failureCode: intent.failerCode ?? undefined,
|
||||
failureMessage: intent.failureMessage ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async handlePaymentEvent(event: {
|
||||
eventType: string;
|
||||
eventId: string;
|
||||
referenceId: string;
|
||||
intentId: string;
|
||||
providerTxnId?: string;
|
||||
paidAt?: string;
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
}): Promise<{
|
||||
processed: boolean;
|
||||
alreadyFinalized?: boolean;
|
||||
reason?: string;
|
||||
}> {
|
||||
console.log(`Received payment event: ${JSON.stringify(event)}`);
|
||||
if (event.eventType === "payment.succeeded") {
|
||||
const intent = await this.paymentRepo.findOneBy({
|
||||
refId: event.referenceId,
|
||||
});
|
||||
if (!intent) {
|
||||
return {
|
||||
items: items.map((p) => ({
|
||||
id: p.id,
|
||||
bookingId: p.refId,
|
||||
amount: p.amount,
|
||||
currency: p.currency,
|
||||
method: p.method,
|
||||
status: p.status,
|
||||
merchantOrderId: p.merchantOrderId,
|
||||
paidAt: p.paidAt,
|
||||
createdAt: p.createdAt,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
processed: false,
|
||||
reason: `No local intent for reference ${event.referenceId}`,
|
||||
};
|
||||
}
|
||||
console.log(`Processing payment succeeded event for intent: }`, intent);
|
||||
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
|
||||
providerTxnId: event.providerTxnId,
|
||||
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
||||
notify: true,
|
||||
});
|
||||
console.log(
|
||||
`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`,
|
||||
);
|
||||
|
||||
// The payment service stays domain-agnostic: it settles the intent and
|
||||
// lets billing settle the invoice (markIntentSucceeded → settleByPaymentId),
|
||||
// which emits `${source}.invoice.paid`. Per-source advances (booking → PAID,
|
||||
// warehouse → release, …) live in the domain services that listen for it.
|
||||
return { processed: true, alreadyFinalized };
|
||||
}
|
||||
|
||||
/** Aggregate counts across ALL payments for the dashboard summary cards. */
|
||||
async getSummary() {
|
||||
const rows = await this.paymentRepo
|
||||
.createQueryBuilder("payment")
|
||||
.select("payment.status", "status")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.groupBy("payment.status")
|
||||
.getRawMany<{ status: string; count: number }>();
|
||||
|
||||
const byStatus: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const row of rows) {
|
||||
byStatus[row.status] = row.count;
|
||||
total += row.count;
|
||||
}
|
||||
|
||||
// Sum of successfully collected amounts.
|
||||
const paidAgg = await this.paymentRepo
|
||||
.createQueryBuilder("payment")
|
||||
.select("COALESCE(SUM(payment.amount), 0)", "sum")
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.getRawOne<{ sum: string }>();
|
||||
|
||||
if (event.eventType === "payment.failed") {
|
||||
const intent = await this.paymentRepo.findOneBy({
|
||||
refId: event.referenceId,
|
||||
});
|
||||
if (!intent) {
|
||||
return {
|
||||
total,
|
||||
success: byStatus["success"] ?? 0,
|
||||
processing:
|
||||
(byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0),
|
||||
failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0),
|
||||
refunded: byStatus["refunded"] ?? 0,
|
||||
paidAmount: Number(paidAgg?.sum ?? 0),
|
||||
processed: false,
|
||||
reason: `No local intent for reference ${event.referenceId}`,
|
||||
};
|
||||
}
|
||||
await this.markPaymentFailed({
|
||||
intentId: intent.id,
|
||||
failureCode: event.failureCode,
|
||||
failureMessage: event.failureMessage,
|
||||
});
|
||||
return { processed: true };
|
||||
}
|
||||
|
||||
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||||
const booking = await this.datasource
|
||||
.getRepository(Booking)
|
||||
.findOneBy({ id: dto.bookingId });
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
return {
|
||||
processed: false,
|
||||
reason: `Unknown event type: ${event.eventType}`,
|
||||
};
|
||||
}
|
||||
|
||||
const amountMinor = Math.round(Number(booking.totalAmount));
|
||||
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
referenceId: booking.id,
|
||||
orderRef: booking.reference,
|
||||
amountMinor,
|
||||
currency: booking.paymentCurrency,
|
||||
provider: dto.method as unknown as ProviderMethod,
|
||||
platform: dto.platform,
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl:'https://edrfreight.triaplc.com/payment/success',
|
||||
failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
|
||||
});
|
||||
|
||||
await this.datasource.getRepository(Booking).update(
|
||||
{ id: dto.bookingId },
|
||||
{ paymentStatus: "PAID", status: "PAID" },
|
||||
);
|
||||
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);
|
||||
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
bookingId: booking.id,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return this.formatIntentResponse(intent);
|
||||
private toLocalStatus(
|
||||
status: ProviderPaymentStatus,
|
||||
): PaymentEntity["status"] {
|
||||
switch (status) {
|
||||
case ProviderPaymentStatus.SUCCEEDED:
|
||||
return "success";
|
||||
case ProviderPaymentStatus.FAILED:
|
||||
return "failed";
|
||||
case ProviderPaymentStatus.CANCELLED:
|
||||
return "canceled";
|
||||
case ProviderPaymentStatus.PROCESSING:
|
||||
return "processing";
|
||||
default:
|
||||
return "action-required";
|
||||
}
|
||||
}
|
||||
|
||||
private async syncIntentProjection(
|
||||
bookingId: string,
|
||||
booking: Booking,
|
||||
snapshot: PaymentIntentSnapshot,
|
||||
): Promise<PaymentEntity> {
|
||||
const existing = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
|
||||
|
||||
const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
|
||||
TELEBIRR: "telebirr",
|
||||
CBE_BIRR: "cbe-birr",
|
||||
EBIRR: "ebirr",
|
||||
WAAFI: "waafi",
|
||||
CARD: "card",
|
||||
DMONEY: "dmoney",
|
||||
CAC_BANK: "cac-bank",
|
||||
};
|
||||
const method: PaymentEntity["method"] =
|
||||
PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr";
|
||||
const status = snapshot.status === ProviderPaymentStatus.SUCCEEDED
|
||||
? "processing"
|
||||
: this.toLocalStatus(snapshot.status);
|
||||
|
||||
const clientAction = (snapshot.clientAction ?? undefined) as Record<string, unknown> | undefined;
|
||||
const data = {
|
||||
status,
|
||||
method,
|
||||
merchantOrderId: snapshot.merchantOrderId ?? existing?.merchantOrderId ?? "",
|
||||
transactionId: snapshot.providerTxnId ?? existing?.transactionId,
|
||||
expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : existing?.expiresAt,
|
||||
failerCode: snapshot.failureCode ?? undefined,
|
||||
failureMessage: snapshot.failureMessage ?? undefined,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await this.paymentRepo.update({ id: existing.id }, { ...data, clientAction } as any);
|
||||
return { ...existing, ...data, clientAction } as PaymentEntity;
|
||||
}
|
||||
|
||||
return this.paymentRepo.create({
|
||||
refId: bookingId,
|
||||
type: "booking",
|
||||
amount: booking.totalAmount,
|
||||
currency: booking.paymentCurrency,
|
||||
reason: `Payment for booking ${booking.reference}`,
|
||||
rawInitiation: snapshot as unknown as Record<string, unknown>,
|
||||
clientAction: clientAction ?? {},
|
||||
...data,
|
||||
} as any);
|
||||
}
|
||||
|
||||
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
|
||||
const local = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
|
||||
|
||||
let snapshot: PaymentIntentSnapshot | null = null;
|
||||
try {
|
||||
snapshot = await this.paymentClient.getIntentByReference(
|
||||
PaymentReferenceType.SHIPMENT,
|
||||
bookingId,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`payment service lookup failed for booking ${bookingId}: ${message}; using local intent`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
if (!local) throw new NotFoundException("PaymentIntent not found");
|
||||
return this.formatIntentStatus(local);
|
||||
}
|
||||
|
||||
const booking = await this.datasource
|
||||
.getRepository(Booking)
|
||||
.findOneBy({ id: bookingId });
|
||||
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
const intent = await this.syncIntentProjection(bookingId, booking, snapshot);
|
||||
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
bookingId: booking.id,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const refreshed = await this.paymentRepo.findOneBy({ id: intent.id });
|
||||
return this.formatIntentStatus(refreshed ?? intent);
|
||||
}
|
||||
|
||||
async refund(dto: RefundDto) {
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" });
|
||||
if (!intent || intent.status !== "success") {
|
||||
throw new BadRequestException("No successful payment to refund");
|
||||
}
|
||||
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() });
|
||||
await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" });
|
||||
});
|
||||
|
||||
return { refunded: true, bookingId: dto.bookingId };
|
||||
}
|
||||
|
||||
async finalizePaymentSuccess(input: {
|
||||
intentId: string;
|
||||
bookingId: string;
|
||||
providerTxnId?: string;
|
||||
paidAt?: Date;
|
||||
}): Promise<{ alreadyFinalized: boolean }> {
|
||||
// const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
|
||||
// if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
// if (intent.status === "success") return { alreadyFinalized: true };
|
||||
|
||||
// Every booking is a real shipment now (contracts are a separate aggregate),
|
||||
// so payment always settles the booking to PAID and enters allocation.
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
// await mg.update(
|
||||
// PaymentEntity,
|
||||
// // { id: intent.id },
|
||||
// {id:input.intentId},
|
||||
// { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
|
||||
// );
|
||||
await mg.update(
|
||||
Booking,
|
||||
{ id: input.bookingId },
|
||||
{ paymentStatus: "PAID", status: "PAID" },
|
||||
);
|
||||
await this.firstMileService.acceptBooking(input.bookingId);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { alreadyFinalized: false };
|
||||
}
|
||||
|
||||
async markPaymentFailed(input: {
|
||||
intentId: string;
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
}): Promise<void> {
|
||||
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
if (intent.status === "success" || intent.status === "canceled") return;
|
||||
|
||||
await this.paymentRepo.update(
|
||||
{ id: intent.id },
|
||||
{ status: "failed", failerCode: input.failureCode, failureMessage: input.failureMessage },
|
||||
);
|
||||
}
|
||||
|
||||
async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
|
||||
return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method);
|
||||
}
|
||||
|
||||
async genReceiptHtml(orderId: string) {
|
||||
const payment = await this.paymentRepo.findOneBy({ merchantOrderId: orderId, status: "success" });
|
||||
if (!payment) throw new BadRequestException("No successful payment found for this order");
|
||||
|
||||
const filePath = path.join(__dirname, "templates", "receipt.hbs");
|
||||
if (!fs.existsSync(filePath)) throw new InternalServerErrorException();
|
||||
|
||||
const source = fs.readFileSync(filePath, "utf8");
|
||||
const template = Handlebars.compile(source);
|
||||
return template({
|
||||
vendorName: "Ethio Djibouti Railway Freight Booking",
|
||||
vendorAddress: "Addis Ababa",
|
||||
receiptDate: payment.paidAt,
|
||||
paymentMethod: payment.method,
|
||||
subtotal: payment.amount.toString(),
|
||||
total: payment.amount.toString(),
|
||||
currency: payment.currency,
|
||||
reason: payment.reason,
|
||||
});
|
||||
}
|
||||
|
||||
findBookingById(id: string) {
|
||||
return this.paymentRepo.findOneBy({ refId: id, type: "booking" });
|
||||
}
|
||||
|
||||
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
|
||||
const clientAction =
|
||||
intent.clientAction && typeof intent.clientAction === "object"
|
||||
? (intent.clientAction as unknown as ClientAction)
|
||||
: undefined;
|
||||
return {
|
||||
intentId: intent.id,
|
||||
status: STATUS_MAP[intent.status] ?? ProviderPaymentStatus.PROCESSING,
|
||||
clientAction,
|
||||
merchantOrderId: intent.merchantOrderId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private formatIntentStatus(intent: PaymentEntity): IntentStatusDto {
|
||||
return {
|
||||
...this.formatIntentResponse(intent),
|
||||
paidAt: intent.paidAt?.toISOString(),
|
||||
failureCode: intent.failerCode ?? undefined,
|
||||
failureMessage: intent.failureMessage ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async handlePaymentEvent(event: {
|
||||
eventType: string;
|
||||
eventId: string;
|
||||
referenceId: string;
|
||||
intentId: string;
|
||||
providerTxnId?: string;
|
||||
paidAt?: string;
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
}): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> {
|
||||
const { alreadyFinalized } = await this.finalizePaymentSuccess({
|
||||
intentId:event.intentId,
|
||||
bookingId: event.referenceId,
|
||||
providerTxnId: event.providerTxnId,
|
||||
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
||||
});
|
||||
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
|
||||
return { processed: true, alreadyFinalized };
|
||||
// console.log(`Received payment event: ${JSON.stringify(event)}`);
|
||||
// if (event.eventType === "payment.succeeded") {
|
||||
// console.log(`Received payment.succeeded event for booking ${event.referenceId}, intent ${event.intentId}`);
|
||||
// const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
|
||||
// if (!intent) {
|
||||
// return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
|
||||
// }
|
||||
// console.log(`Processing payment.succeeded event for booking ${event.referenceId}, intent ${intent.id}`);
|
||||
// const { alreadyFinalized } = await this.finalizePaymentSuccess({
|
||||
// intentId: intent.id,
|
||||
// bookingId: event.referenceId,
|
||||
// providerTxnId: event.providerTxnId,
|
||||
// paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
||||
// });
|
||||
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
|
||||
// return { processed: true, alreadyFinalized };
|
||||
// }
|
||||
|
||||
// if (event.eventType === "payment.failed") {
|
||||
// const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
|
||||
// if (!intent) {
|
||||
// return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
|
||||
// }
|
||||
// await this.markPaymentFailed({
|
||||
// intentId: intent.id,
|
||||
// failureCode: event.failureCode,
|
||||
// failureMessage: event.failureMessage,
|
||||
// });
|
||||
// return { processed: true };
|
||||
// }
|
||||
|
||||
// return { processed: false, reason: `Unknown event type: ${event.eventType}` };
|
||||
}
|
||||
|
||||
private toLocalStatus(status: ProviderPaymentStatus): PaymentEntity["status"] {
|
||||
switch (status) {
|
||||
case ProviderPaymentStatus.SUCCEEDED: return "success";
|
||||
case ProviderPaymentStatus.FAILED: return "failed";
|
||||
case ProviderPaymentStatus.CANCELLED: return "canceled";
|
||||
case ProviderPaymentStatus.PROCESSING: return "processing";
|
||||
default: return "action-required";
|
||||
}
|
||||
}
|
||||
|
||||
async findByCompanyId(companyId: string) {
|
||||
return this.paymentRepo.findByCompanyId(companyId);
|
||||
}
|
||||
async findByCompanyId(companyId: string) {
|
||||
return this.paymentRepo.findByCompanyId(companyId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { WarehouseFeeInvoice } from './warehouse-fee-invoice.entity';
|
||||
|
||||
export const WAREHOUSE_FEE_TYPES = [
|
||||
'CONTAINER_DEMURRAGE',
|
||||
'BULK_DEMURRAGE',
|
||||
'STORAGE_FEE',
|
||||
'HANDLING_FEE',
|
||||
] as const;
|
||||
export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_fee_invoice_items' })
|
||||
@Index(['invoiceId'])
|
||||
export class WarehouseFeeInvoiceItem extends BaseEntity {
|
||||
@Column({ name: 'invoice_id', type: 'uuid' })
|
||||
invoiceId!: string;
|
||||
|
||||
@ManyToOne(() => WarehouseFeeInvoice, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'invoice_id' })
|
||||
invoice?: WarehouseFeeInvoice;
|
||||
|
||||
@Column({ name: 'fee_rule_id', type: 'uuid', nullable: true })
|
||||
feeRuleId?: string | null;
|
||||
|
||||
@Column({ name: 'fee_type', type: 'varchar', length: 32 })
|
||||
feeType!: WarehouseFeeType;
|
||||
|
||||
@Column({ name: 'description', type: 'varchar', length: 255 })
|
||||
description!: string;
|
||||
|
||||
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 2, default: 1 })
|
||||
quantity!: number;
|
||||
|
||||
@Column({ name: 'unit_rate', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
unitRate!: number;
|
||||
|
||||
@Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
amount!: number;
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' })
|
||||
currency!: string;
|
||||
|
||||
@Column({ name: 'chargeable_days', type: 'int', nullable: true })
|
||||
chargeableDays?: number | null;
|
||||
|
||||
@Column({ name: 'free_days', type: 'int', nullable: true })
|
||||
freeDays?: number | null;
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const;
|
||||
export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number];
|
||||
|
||||
export const WAREHOUSE_INVOICE_STATUSES = [
|
||||
'DRAFT',
|
||||
'ISSUED',
|
||||
'PARTIALLY_PAID',
|
||||
'PAID',
|
||||
'CANCELLED',
|
||||
] as const;
|
||||
export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number];
|
||||
|
||||
/** A single recorded payment against a warehouse fee invoice (history). */
|
||||
export interface WarehouseInvoicePayment {
|
||||
amount: number;
|
||||
method?: string | null;
|
||||
reference?: string | null;
|
||||
paidAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch 6 — invoice generated from Batch 5 demurrage/storage fee calculation.
|
||||
* Owns warehouse fees; links to booking/customer/inventory/location so it can
|
||||
* connect to the existing payment module without duplicating it.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'warehouse_fee_invoices' })
|
||||
@Index(['invoiceNumber'], { unique: true })
|
||||
@Index(['bookingId'])
|
||||
@Index(['inventoryId'])
|
||||
@Index(['status'])
|
||||
export class WarehouseFeeInvoice extends BaseEntity {
|
||||
@Column({ name: 'invoice_number', type: 'varchar', length: 40, unique: true })
|
||||
invoiceNumber!: string;
|
||||
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId?: string | null;
|
||||
|
||||
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
|
||||
customerId?: string | null;
|
||||
|
||||
@Column({ name: 'inventory_id', type: 'uuid' })
|
||||
inventoryId!: string;
|
||||
|
||||
@Column({ name: 'facility_id', type: 'uuid', nullable: true })
|
||||
facilityId?: string | null;
|
||||
|
||||
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
|
||||
warehouseId?: string | null;
|
||||
|
||||
@Column({ name: 'yard_id', type: 'uuid', nullable: true })
|
||||
yardId?: string | null;
|
||||
|
||||
@Column({ name: 'zone_id', type: 'uuid', nullable: true })
|
||||
zoneId?: string | null;
|
||||
|
||||
@Column({ name: 'invoice_type', type: 'varchar', length: 32, default: 'MIXED_WAREHOUSE_FEES' })
|
||||
invoiceType!: WarehouseInvoiceType;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
|
||||
status!: WarehouseInvoiceStatus;
|
||||
|
||||
@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, default: 0 })
|
||||
totalAmount!: number;
|
||||
|
||||
@Column({ name: 'paid_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
paidAmount!: number;
|
||||
|
||||
@Column({ name: 'balance_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
balanceAmount!: number;
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' })
|
||||
currency!: string;
|
||||
|
||||
/** Charge window covered by this invoice — used to allow a later invoice for a new period. */
|
||||
@Column({ name: 'period_start', type: 'timestamptz', nullable: true })
|
||||
periodStart?: Date | null;
|
||||
|
||||
@Column({ name: 'period_end', type: 'timestamptz', nullable: true })
|
||||
periodEnd?: Date | null;
|
||||
|
||||
@Column({ name: 'issued_at', type: 'timestamptz', nullable: true })
|
||||
issuedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'due_date', type: 'timestamptz', nullable: true })
|
||||
dueDate?: Date | null;
|
||||
|
||||
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
|
||||
paidAt?: Date | null;
|
||||
|
||||
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
|
||||
cancelledAt?: Date | null;
|
||||
|
||||
@Column({ name: 'payments', type: 'jsonb', default: () => "'[]'" })
|
||||
payments!: WarehouseInvoicePayment[];
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -110,6 +110,9 @@ export class WarehouseInventory extends BaseEntity {
|
||||
@Column({ name: 'volume', type: 'numeric', precision: 12, scale: 3, nullable: true })
|
||||
volume?: number | null;
|
||||
|
||||
@Column({ name: 'grn_number', type: 'varchar', length: 100, nullable: true })
|
||||
grnNumber?: string | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 32, default: 'RECEIVED' })
|
||||
status!: WarehouseInventoryStatus;
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseFeeInvoiceItemRepository extends BaseRepository<WarehouseFeeInvoiceItem> {
|
||||
constructor(@InjectRepository(WarehouseFeeInvoiceItem) repository: Repository<WarehouseFeeInvoiceItem>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseFeeInvoiceRepository extends BaseRepository<WarehouseFeeInvoice> {
|
||||
constructor(@InjectRepository(WarehouseFeeInvoice) repository: Repository<WarehouseFeeInvoice>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -273,6 +273,16 @@ export class WarehouseInventoryController {
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get(':id/grn-document')
|
||||
@ApiOperation({ summary: 'View goods received note PDF' })
|
||||
async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.grnDocument(id);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get(':id/handover-document')
|
||||
@ApiOperation({ summary: 'View import goods handover document PDF' })
|
||||
async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
|
||||
@@ -52,6 +52,7 @@ const isLoadableWagonStatus = (status: string | null | undefined) =>
|
||||
LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status));
|
||||
|
||||
const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
|
||||
const HANDOVER_DOCUMENT_MARKER = '[Handover Document]';
|
||||
|
||||
export interface InventoryInquiryResult {
|
||||
id: string;
|
||||
@@ -249,6 +250,7 @@ export interface ReadyToLoadRow {
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
weight: number | null;
|
||||
grnNumber: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
inspectionStatus: string | null;
|
||||
@@ -295,6 +297,7 @@ export interface ImportUnloadedRow {
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
weight: number | null;
|
||||
grnNumber: string | null;
|
||||
trainSchedule: string | null;
|
||||
inspectionStatus: string | null;
|
||||
pickupOption: string;
|
||||
@@ -302,6 +305,8 @@ export interface ImportUnloadedRow {
|
||||
currentStatus: string;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
handoverDocumentReference: string | null;
|
||||
handoverDocumentDate: string | null;
|
||||
deliveredAt: string | null;
|
||||
}
|
||||
|
||||
@@ -415,7 +420,10 @@ export class WarehouseInventoryService {
|
||||
|
||||
const search = filter.search?.trim();
|
||||
const where: FindManyOptions<WarehouseInventory>['where'] = search
|
||||
? { ...base, notes: ILike(`%${search}%`) }
|
||||
? [
|
||||
{ ...base, notes: ILike(`%${search}%`) },
|
||||
{ ...base, grnNumber: ILike(`%${search}%`) },
|
||||
]
|
||||
: base;
|
||||
|
||||
const items = await this.inventoryRepository.findAll({
|
||||
@@ -766,6 +774,7 @@ export class WarehouseInventoryService {
|
||||
const [booking] = await manager.query(
|
||||
`SELECT b.reference AS "reference",
|
||||
b.payment_status AS "paymentStatus",
|
||||
b.freight_type AS "freightType",
|
||||
b.cargo_total_weight_vgm AS "weight",
|
||||
company.name AS "customer",
|
||||
company.tin AS "customerTin",
|
||||
@@ -847,6 +856,12 @@ export class WarehouseInventoryService {
|
||||
const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } });
|
||||
if (existing) { skip('Already received'); continue; }
|
||||
|
||||
const containerQuantity = Number(booking.containerQuantity ?? 0);
|
||||
if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) {
|
||||
skip('Container booking has no container quantity');
|
||||
continue;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
|
||||
const truckEntrance = dto.truckEntrance
|
||||
@@ -867,8 +882,9 @@ export class WarehouseInventoryService {
|
||||
yardId: dto.yardId,
|
||||
zoneId: dto.zoneId,
|
||||
bookingId,
|
||||
quantity: Number(booking.containerQuantity) || 1,
|
||||
quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1,
|
||||
weight: Number(booking.weight) || 0,
|
||||
grnNumber,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt: now,
|
||||
notes: receiveNote,
|
||||
@@ -962,6 +978,7 @@ export class WarehouseInventoryService {
|
||||
ct.container_number AS "containerNumber",
|
||||
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
||||
inv.weight AS "weight",
|
||||
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
|
||||
oy.code AS "origin",
|
||||
dy.code AS "destination",
|
||||
oy.country AS "originCountry",
|
||||
@@ -1021,6 +1038,7 @@ export class WarehouseInventoryService {
|
||||
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
|
||||
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
||||
inv.weight AS "weight",
|
||||
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
|
||||
ts.train_number AS "trainSchedule",
|
||||
inv.inspection_status AS "inspectionStatus",
|
||||
CASE WHEN b.last_mile_delivery_address IS NOT NULL
|
||||
@@ -1029,6 +1047,8 @@ export class WarehouseInventoryService {
|
||||
inv.status AS "currentStatus",
|
||||
inv.release_date AS "releaseDate",
|
||||
inv.release_order_reference AS "releaseOrderReference",
|
||||
substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference",
|
||||
substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate",
|
||||
inv.delivered_at AS "deliveredAt",
|
||||
oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry"
|
||||
@@ -1669,6 +1689,7 @@ export class WarehouseInventoryService {
|
||||
quantity,
|
||||
weight,
|
||||
volume: dto.volume ?? null,
|
||||
grnNumber,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt: now,
|
||||
notes: receiveNote,
|
||||
@@ -1933,24 +1954,31 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
}
|
||||
|
||||
const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date();
|
||||
const reference = dto.reference?.trim() || null;
|
||||
const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
|
||||
const releaseDate = isTruckLeaving
|
||||
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
|
||||
: item.releaseDate ?? null;
|
||||
const reference = dto.reference?.trim() || (await this.generateReleaseReference(item));
|
||||
const exitInspectionNote = this.buildExitInspectionNote(dto);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
releaseDate,
|
||||
releaseOrderReference: reference,
|
||||
notes: [item.notes?.trim(), exitInspectionNote].filter(Boolean).join('\n\n'),
|
||||
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
|
||||
});
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_RELEASED',
|
||||
inventoryId: id,
|
||||
warehouseId: item.warehouseId,
|
||||
description: reference
|
||||
? `Release order ${reference} sent to customer`
|
||||
: 'Release order sent to customer',
|
||||
description: isTruckLeaving
|
||||
? reference
|
||||
? `Exit paper ${reference} generated`
|
||||
: 'Exit paper generated'
|
||||
: reference
|
||||
? `Truck arrival ${reference} registered`
|
||||
: 'Truck arrival registered',
|
||||
performedBy: dto.performedBy,
|
||||
},
|
||||
manager,
|
||||
@@ -2039,6 +2067,106 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
|
||||
async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
|
||||
COALESCE(inv.arrived_at, inv.created_at) AS "receivedAt",
|
||||
inv.quantity,
|
||||
inv.weight,
|
||||
inv.volume,
|
||||
inv.status,
|
||||
inv.notes,
|
||||
b.id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
b.status AS "bookingStatus",
|
||||
b.freight_type AS "freightType",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
b.cargo_total_weight_vgm AS "bookingDeclaredWeight",
|
||||
company.name AS "customerName",
|
||||
company.tin AS "customerTin",
|
||||
service_type.service_name AS "serviceType",
|
||||
origin_yard.label AS "originYardLabel",
|
||||
origin_yard.code AS "originYardCode",
|
||||
destination_yard.label AS "destinationYardLabel",
|
||||
destination_yard.code AS "destinationYardCode",
|
||||
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
|
||||
booking_container."containerSummary" AS "bookingContainerSummary",
|
||||
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
|
||||
wh.name AS "warehouseName",
|
||||
wh.code AS "warehouseCode",
|
||||
yard.name AS "yardName",
|
||||
yard.code AS "yardCode",
|
||||
zone.name AS "zoneName",
|
||||
zone.code AS "zoneCode"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.service_types service_type ON service_type.id = b.service_type_id
|
||||
LEFT JOIN freight.yards origin_yard ON origin_yard.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards destination_yard ON destination_yard.id = b.destination_yard_id
|
||||
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
|
||||
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
|
||||
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
|
||||
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(bc.container_number) AS container_number,
|
||||
STRING_AGG(
|
||||
CONCAT_WS(' ', bc.quantity::text, COALESCE(ct.label, ct.code, 'container')),
|
||||
', '
|
||||
ORDER BY COALESCE(ct.label, ct.code, bc.container_type_id::text)
|
||||
) AS "containerSummary"
|
||||
FROM freight.booking_container bc
|
||||
LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id
|
||||
WHERE bc.booking_id = b.id
|
||||
AND bc.deleted_at IS NULL
|
||||
) booking_container ON true
|
||||
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
|
||||
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
|
||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[id],
|
||||
);
|
||||
if (!row) {
|
||||
throw new NotFoundException(`Inventory item ${id} not found`);
|
||||
}
|
||||
if (!row.grnNumber) {
|
||||
throw new BadRequestException('GRN number is missing for this inventory item');
|
||||
}
|
||||
|
||||
const html = this.buildGrnDocumentHtml({
|
||||
grnNumber: row.grnNumber,
|
||||
receivedAt: row.receivedAt ? new Date(row.receivedAt) : new Date(),
|
||||
bookingReference: row.bookingReference ?? row.bookingId ?? 'N/A',
|
||||
bookingStatus: row.bookingStatus ?? null,
|
||||
customerName: row.customerName ?? null,
|
||||
customerTin: row.customerTin ?? null,
|
||||
serviceType: row.serviceType ?? null,
|
||||
freightType: row.freightType ?? null,
|
||||
tradeDirection: row.tradeDirection ?? null,
|
||||
route: [row.originYardLabel ?? row.originYardCode, row.destinationYardLabel ?? row.destinationYardCode]
|
||||
.filter(Boolean)
|
||||
.join(' to ') || null,
|
||||
containerNumber: row.containerNumber ?? null,
|
||||
bookingContainerSummary: row.bookingContainerSummary ?? null,
|
||||
cargoDescription: row.cargoDescription ?? null,
|
||||
quantity: Number(row.quantity ?? 0),
|
||||
weight: Number(row.weight ?? 0),
|
||||
volume: row.volume == null ? null : Number(row.volume),
|
||||
bookingDeclaredWeight: Number(row.bookingDeclaredWeight ?? 0),
|
||||
warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null,
|
||||
yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null,
|
||||
zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null,
|
||||
inventoryStatus: row.status ?? null,
|
||||
receiveSummary: this.extractReceiveSummary(row.notes),
|
||||
});
|
||||
|
||||
return {
|
||||
filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
|
||||
};
|
||||
}
|
||||
|
||||
async approveDeliveryForBooking(
|
||||
bookingId: string,
|
||||
userId?: string,
|
||||
@@ -2121,8 +2249,17 @@ export class WarehouseInventoryService {
|
||||
b.status AS "bookingStatus",
|
||||
b.freight_type AS "freightType",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
b.scheduled_date AS "scheduledDate",
|
||||
b.cargo_total_weight_vgm AS "bookingDeclaredWeight",
|
||||
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
|
||||
company.name AS "customerName",
|
||||
service_type.service_name AS "serviceType",
|
||||
origin_yard.label AS "originYardLabel",
|
||||
origin_yard.code AS "originYardCode",
|
||||
destination_yard.label AS "destinationYardLabel",
|
||||
destination_yard.code AS "destinationYardCode",
|
||||
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
|
||||
booking_container."containerSummary" AS "bookingContainerSummary",
|
||||
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
|
||||
wh.name AS "warehouseName",
|
||||
wh.code AS "warehouseCode",
|
||||
@@ -2134,14 +2271,25 @@ export class WarehouseInventoryService {
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.service_types service_type ON service_type.id = b.service_type_id
|
||||
LEFT JOIN freight.yards origin_yard ON origin_yard.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards destination_yard ON destination_yard.id = b.destination_yard_id
|
||||
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
|
||||
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
|
||||
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
|
||||
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
|
||||
LEFT JOIN freight.booking_container booking_container ON (
|
||||
booking_container.booking_id = b.id
|
||||
AND booking_container.deleted_at IS NULL
|
||||
)
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(bc.container_number) AS container_number,
|
||||
STRING_AGG(
|
||||
CONCAT_WS(' ', bc.quantity::text, COALESCE(ct.label, ct.code, 'container')),
|
||||
', '
|
||||
ORDER BY COALESCE(ct.label, ct.code, bc.container_type_id::text)
|
||||
) AS "containerSummary"
|
||||
FROM freight.booking_container bc
|
||||
LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id
|
||||
WHERE bc.booking_id = b.id
|
||||
AND bc.deleted_at IS NULL
|
||||
) booking_container ON true
|
||||
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
|
||||
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
|
||||
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
|
||||
@@ -2158,18 +2306,37 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
const bookingReference = row.bookingReference || row.bookingId || 'N/A';
|
||||
const reference =
|
||||
this.extractHandoverDocumentLine(row.notes, 'Handover Reference') ||
|
||||
`HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`;
|
||||
const generatedAtValue = this.extractHandoverDocumentLine(row.notes, 'Generated At');
|
||||
const generatedAt = generatedAtValue ? new Date(generatedAtValue) : new Date();
|
||||
const handedOverAt = Number.isNaN(generatedAt.getTime()) ? new Date() : generatedAt;
|
||||
if (!generatedAtValue) {
|
||||
await this.inventoryRepository.update(id, {
|
||||
notes: this.replaceHandoverDocumentNote(row.notes, this.buildHandoverDocumentNote(reference, handedOverAt)),
|
||||
});
|
||||
}
|
||||
|
||||
const html = this.buildHandoverDocumentHtml({
|
||||
reference: `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`,
|
||||
handedOverAt: new Date(row.handoverDate ?? Date.now()),
|
||||
reference,
|
||||
handedOverAt,
|
||||
bookingReference,
|
||||
bookingStatus: row.bookingStatus ?? null,
|
||||
customerName: row.customerName ?? null,
|
||||
serviceType: row.serviceType ?? null,
|
||||
freightType: row.freightType ?? null,
|
||||
tradeDirection: row.tradeDirection ?? null,
|
||||
route: [row.originYardLabel ?? row.originYardCode, row.destinationYardLabel ?? row.destinationYardCode]
|
||||
.filter(Boolean)
|
||||
.join(' to ') || null,
|
||||
scheduledDate: row.scheduledDate ? new Date(row.scheduledDate) : null,
|
||||
containerNumber: row.containerNumber ?? null,
|
||||
bookingContainerSummary: row.bookingContainerSummary ?? null,
|
||||
cargoDescription: row.cargoDescription ?? null,
|
||||
quantity: Number(row.quantity ?? 0),
|
||||
weight: Number(row.weight ?? 0),
|
||||
bookingDeclaredWeight: Number(row.bookingDeclaredWeight ?? 0),
|
||||
warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null,
|
||||
yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null,
|
||||
zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null,
|
||||
@@ -2178,11 +2345,12 @@ export class WarehouseInventoryService {
|
||||
releaseOrderReference: row.releaseOrderReference ?? null,
|
||||
releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
|
||||
trainSchedule: row.trainSchedule ?? null,
|
||||
lastMileDeliveryAddress: row.lastMileDeliveryAddress ?? null,
|
||||
customerApproval: this.extractCustomerDeliveryApproval(row.notes),
|
||||
});
|
||||
|
||||
return {
|
||||
filename: `handover-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||
filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
|
||||
};
|
||||
}
|
||||
@@ -2666,6 +2834,128 @@ export class WarehouseInventoryService {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
private buildGrnDocumentHtml(data: {
|
||||
grnNumber: string;
|
||||
receivedAt: Date;
|
||||
bookingReference: string;
|
||||
bookingStatus: string | null;
|
||||
customerName: string | null;
|
||||
customerTin: string | null;
|
||||
serviceType: string | null;
|
||||
freightType: string | null;
|
||||
tradeDirection: string | null;
|
||||
route: string | null;
|
||||
containerNumber: string | null;
|
||||
bookingContainerSummary: string | null;
|
||||
cargoDescription: string | null;
|
||||
quantity: number;
|
||||
weight: number;
|
||||
volume: number | null;
|
||||
bookingDeclaredWeight: number;
|
||||
warehouse: string | null;
|
||||
yard: string | null;
|
||||
zone: string | null;
|
||||
inventoryStatus: string | null;
|
||||
receiveSummary: string | null;
|
||||
}): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
const receivedAt = data.receivedAt.toLocaleString('en-GB', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const rows: Array<[string, unknown]> = [
|
||||
['Booking Reference', data.bookingReference],
|
||||
['Customer / Consignee', data.customerName],
|
||||
['Customer TIN', data.customerTin],
|
||||
['Booking Status', data.bookingStatus],
|
||||
['Service Type', data.serviceType],
|
||||
['Freight Type', data.freightType],
|
||||
['Trade Direction', data.tradeDirection],
|
||||
['Route', data.route],
|
||||
['Container Number', data.containerNumber],
|
||||
['Booking Containers', data.bookingContainerSummary],
|
||||
['Cargo / Goods Description', data.cargoDescription],
|
||||
['Quantity', data.quantity],
|
||||
['Received Weight', `${data.weight.toLocaleString()} kg`],
|
||||
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null],
|
||||
['Volume', data.volume == null ? null : data.volume.toLocaleString()],
|
||||
['Warehouse', data.warehouse],
|
||||
['Yard', data.yard],
|
||||
['Zone', data.zone],
|
||||
['Inventory Status', data.inventoryStatus],
|
||||
...(data.receiveSummary ? [['Receive Details', data.receiveSummary] as [string, string]] : []),
|
||||
];
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Goods Received Note</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
@page { size: A4; margin: 12mm 15mm 14mm; }
|
||||
body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 0; background: #fff; }
|
||||
.top { display: grid; grid-template-columns: 1fr 210px; gap: 24px; border-top: 5px solid #0f766e; padding-top: 18px; }
|
||||
.brand { font-size: 12px; color: #064c27; text-transform: uppercase; letter-spacing: .13em; font-weight: 800; }
|
||||
h1 { margin: 8px 0 0; font-size: 31px; line-height: .98; text-transform: uppercase; letter-spacing: .02em; }
|
||||
.subtitle { margin-top: 12px; font-size: 11px; color: #3d516a; text-transform: uppercase; letter-spacing: .14em; }
|
||||
.ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; }
|
||||
.ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; }
|
||||
.rule { height: 3px; background: #0f766e; margin: 16px 0 22px; }
|
||||
.notice { width: 76%; margin: 0 0 18px; padding: 13px 18px; background: #f0fdfa; border: 1px solid #5eead4; border-left: 5px solid #0f766e; font-size: 13px; line-height: 1.45; }
|
||||
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #0f766e; text-transform: uppercase; letter-spacing: .12em; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { width: 31%; text-align: left; color: #0f2744; background: #f8fafc; font-weight: 800; }
|
||||
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12.2px; vertical-align: top; white-space: pre-line; }
|
||||
.clause { margin-top: 14px; border: 1px solid #b9c7d8; padding: 12px 15px; font-size: 12.2px; line-height: 1.45; }
|
||||
.signatures { display: grid; grid-template-columns: 1fr 1fr; gap: 34px; align-items: start; margin-top: 42px; }
|
||||
.line { border-top: 1.4px solid #061323; padding-top: 7px; font-size: 10.8px; color: #24384f; min-height: 42px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Goods Received Note</h1>
|
||||
<div class="subtitle">Warehouse receiving confirmation</div>
|
||||
</div>
|
||||
<div class="ref">
|
||||
GRN Number
|
||||
<strong>${esc(data.grnNumber)}</strong>
|
||||
Received: ${esc(receivedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rule"></div>
|
||||
<div class="notice">
|
||||
This Goods Received Note confirms that the listed goods were received into EDR warehouse custody at the stated location.
|
||||
</div>
|
||||
<div class="section-title">Receiving Particulars</div>
|
||||
<table>
|
||||
<tbody>
|
||||
${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="section-title">Receipt Clause</div>
|
||||
<div class="clause">
|
||||
This document records warehouse receipt only. Loading, dispatch, release, delivery, customs, and fee clearance remain subject to their respective operational approvals.
|
||||
</div>
|
||||
<div class="signatures">
|
||||
<div class="line">Warehouse receiver name / signature / date</div>
|
||||
<div class="line">Driver or customer representative name / signature / date</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildReleaseDocumentHtml(data: {
|
||||
reference: string;
|
||||
issuedAt: Date;
|
||||
@@ -2721,7 +3011,7 @@ export class WarehouseInventoryService {
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Warehouse Gate Clearance / Release Order</title>
|
||||
<title>Warehouse Release / Exit Paper</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
@page { size: A4; margin: 12mm 15mm 14mm; }
|
||||
@@ -2752,8 +3042,8 @@ export class WarehouseInventoryService {
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Warehouse Gate Clearance / Release Order</h1>
|
||||
<div class="subtitle">Official warehouse release and exit authorization</div>
|
||||
<h1>Warehouse Release / Exit Paper</h1>
|
||||
<div class="subtitle">Official gate clearance and warehouse exit authorization</div>
|
||||
</div>
|
||||
<div class="ref">
|
||||
Document / Release No.
|
||||
@@ -2763,7 +3053,7 @@ export class WarehouseInventoryService {
|
||||
</div>
|
||||
<div class="rule"></div>
|
||||
<div class="notice">
|
||||
This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
|
||||
This Exit Paper confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
|
||||
</div>
|
||||
<div class="section-title">Release Particulars</div>
|
||||
<table>
|
||||
@@ -2792,12 +3082,17 @@ export class WarehouseInventoryService {
|
||||
bookingReference: string;
|
||||
bookingStatus: string | null;
|
||||
customerName: string | null;
|
||||
serviceType: string | null;
|
||||
freightType: string | null;
|
||||
tradeDirection: string | null;
|
||||
route: string | null;
|
||||
scheduledDate: Date | null;
|
||||
containerNumber: string | null;
|
||||
bookingContainerSummary: string | null;
|
||||
cargoDescription: string | null;
|
||||
quantity: number;
|
||||
weight: number;
|
||||
bookingDeclaredWeight: number;
|
||||
warehouse: string | null;
|
||||
yard: string | null;
|
||||
zone: string | null;
|
||||
@@ -2806,6 +3101,7 @@ export class WarehouseInventoryService {
|
||||
releaseOrderReference: string | null;
|
||||
releaseDate: Date | null;
|
||||
trainSchedule: string | null;
|
||||
lastMileDeliveryAddress: string | null;
|
||||
customerApproval: {
|
||||
approvedAt: string;
|
||||
signerDisplayName: string;
|
||||
@@ -2835,13 +3131,18 @@ export class WarehouseInventoryService {
|
||||
['Booking Reference', data.bookingReference],
|
||||
['Customer / Consignee', data.customerName],
|
||||
['Booking Status', data.bookingStatus],
|
||||
['Service Type', data.serviceType],
|
||||
['Freight Type', data.freightType],
|
||||
['Trade Direction', data.tradeDirection],
|
||||
['Route', data.route],
|
||||
['Scheduled Date', fmt(data.scheduledDate)],
|
||||
['Train Schedule', data.trainSchedule],
|
||||
['Container Number', data.containerNumber],
|
||||
['Booking Containers', data.bookingContainerSummary],
|
||||
['Cargo / Goods Description', data.cargoDescription],
|
||||
['Quantity', data.quantity],
|
||||
['Declared Weight', `${data.weight.toLocaleString()} kg`],
|
||||
['Inventory Weight', `${data.weight.toLocaleString()} kg`],
|
||||
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null],
|
||||
['Warehouse', data.warehouse],
|
||||
['Yard', data.yard],
|
||||
['Zone', data.zone],
|
||||
@@ -2849,6 +3150,7 @@ export class WarehouseInventoryService {
|
||||
['Inspection Status', data.inspectionStatus],
|
||||
['Release Order', data.releaseOrderReference],
|
||||
['Release Date', fmt(data.releaseDate)],
|
||||
['Last-mile Delivery Address', data.lastMileDeliveryAddress],
|
||||
];
|
||||
const approval = data.customerApproval;
|
||||
|
||||
@@ -2898,7 +3200,8 @@ export class WarehouseInventoryService {
|
||||
</div>
|
||||
<div class="rule"></div>
|
||||
<div class="notice">
|
||||
This document confirms EDR handed over the listed import goods to the customer after warehouse inspection passed.
|
||||
This handover document is separate from the warehouse Exit Paper. It records the booking, route, cargo, container,
|
||||
inspection, release, and customer approval details for the goods being handed to the customer.
|
||||
</div>
|
||||
<div class="section-title">Handover Particulars</div>
|
||||
<table>
|
||||
@@ -2911,7 +3214,9 @@ export class WarehouseInventoryService {
|
||||
<tbody>
|
||||
<tr><th>1. Goods</th><td>${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}</td></tr>
|
||||
<tr><th>Container</th><td>${esc(data.containerNumber)}</td></tr>
|
||||
<tr><th>Weight</th><td>${esc(`${data.weight.toLocaleString()} kg`)}</td></tr>
|
||||
<tr><th>Booking Containers</th><td>${esc(data.bookingContainerSummary)}</td></tr>
|
||||
<tr><th>Inventory Weight</th><td>${esc(`${data.weight.toLocaleString()} kg`)}</td></tr>
|
||||
<tr><th>Booking Declared Weight</th><td>${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null)}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="section-title">Handover Clause</div>
|
||||
@@ -3156,6 +3461,21 @@ export class WarehouseInventoryService {
|
||||
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
|
||||
}
|
||||
|
||||
private async generateReleaseReference(item: WarehouseInventory): Promise<string> {
|
||||
let bookingReference = item.booking?.reference;
|
||||
if (!bookingReference && item.bookingId) {
|
||||
const [booking]: Array<{ reference: string | null }> = await this.dataSource.query(
|
||||
`SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1`,
|
||||
[item.bookingId],
|
||||
);
|
||||
bookingReference = booking?.reference ?? undefined;
|
||||
}
|
||||
if (bookingReference) {
|
||||
return `REL-${String(bookingReference).replace(/^BK-?/i, '')}`;
|
||||
}
|
||||
return `REL-${new Date().toISOString().slice(0, 10).replace(/-/g, '')}-${item.id.replace(/-/g, '').slice(0, 8).toUpperCase()}`;
|
||||
}
|
||||
|
||||
private buildExitInspectionNote(dto: ReleaseOrderDto): string | null {
|
||||
const hasExitInspection =
|
||||
Boolean(dto.truckPlateNumber?.trim()) ||
|
||||
@@ -3179,17 +3499,27 @@ export class WarehouseInventoryService {
|
||||
if (!dto.driverName?.trim()) {
|
||||
throw new BadRequestException('Driver name is required for exit inspection');
|
||||
}
|
||||
if (dto.tareWeight === undefined || dto.grossWeight === undefined) {
|
||||
throw new BadRequestException('Tare weight and gross weight are required for exit inspection');
|
||||
if (dto.tareWeight === undefined) {
|
||||
throw new BadRequestException('Tare weight is required for truck arrival');
|
||||
}
|
||||
|
||||
const tareWeight = Number(dto.tareWeight);
|
||||
const grossWeight = Number(dto.grossWeight);
|
||||
const computedNetWeight = Number((grossWeight - tareWeight).toFixed(3));
|
||||
const submittedNetWeight = dto.netWeight === undefined ? computedNetWeight : Number(dto.netWeight);
|
||||
const grossWeight = dto.grossWeight === undefined ? null : Number(dto.grossWeight);
|
||||
const computedNetWeight =
|
||||
grossWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
|
||||
const submittedNetWeight =
|
||||
dto.netWeight === undefined || computedNetWeight == null ? computedNetWeight : Number(dto.netWeight);
|
||||
|
||||
if (Math.abs(submittedNetWeight - computedNetWeight) > 0.001) {
|
||||
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
|
||||
if (grossWeight != null && !dto.gateOutTime) {
|
||||
throw new BadRequestException('Gate out time is required for truck exit');
|
||||
}
|
||||
if (grossWeight != null && computedNetWeight != null && submittedNetWeight != null) {
|
||||
if (Math.abs(submittedNetWeight - computedNetWeight) > 0.001) {
|
||||
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
|
||||
}
|
||||
}
|
||||
if ((dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) && grossWeight == null) {
|
||||
throw new BadRequestException('Gross weight is required for truck exit');
|
||||
}
|
||||
|
||||
const rows = [
|
||||
@@ -3205,14 +3535,27 @@ export class WarehouseInventoryService {
|
||||
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
|
||||
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
|
||||
`Tare Weight: ${tareWeight} kg`,
|
||||
`Gross Weight: ${grossWeight} kg`,
|
||||
`Net Weight: ${computedNetWeight} kg`,
|
||||
grossWeight == null ? null : `Gross Weight: ${grossWeight} kg`,
|
||||
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} kg`,
|
||||
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
|
||||
];
|
||||
|
||||
return rows.filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null {
|
||||
const trimmed = notes?.trim();
|
||||
if (!exitInspectionNote) return trimmed || null;
|
||||
if (!trimmed) return exitInspectionNote;
|
||||
|
||||
const marker = '[Exit Inspection]';
|
||||
const index = trimmed.lastIndexOf(marker);
|
||||
if (index < 0) {
|
||||
return `${trimmed}\n\n${exitInspectionNote}`;
|
||||
}
|
||||
return [trimmed.slice(0, index).trim(), exitInspectionNote].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
private extractExitInspectionNote(notes?: string | null): string | null {
|
||||
if (!notes) return null;
|
||||
const marker = '[Exit Inspection]';
|
||||
@@ -3221,6 +3564,40 @@ export class WarehouseInventoryService {
|
||||
return notes.slice(index + marker.length).trim() || null;
|
||||
}
|
||||
|
||||
private extractReceiveSummary(notes?: string | null): string | null {
|
||||
if (!notes?.trim()) return null;
|
||||
const withoutExit = notes.split('\n\n[Exit Inspection]')[0] ?? notes;
|
||||
const withoutHandover = withoutExit.split(`\n\n${HANDOVER_DOCUMENT_MARKER}`)[0] ?? withoutExit;
|
||||
return this.stripCustomerDeliveryApproval(withoutHandover)?.trim() || withoutHandover.trim() || null;
|
||||
}
|
||||
|
||||
private buildHandoverDocumentNote(reference: string, generatedAt: Date): string {
|
||||
return [
|
||||
HANDOVER_DOCUMENT_MARKER,
|
||||
`Handover Reference: ${reference}`,
|
||||
`Generated At: ${generatedAt.toISOString()}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private replaceHandoverDocumentNote(notes: string | null | undefined, handoverDocumentNote: string): string {
|
||||
const trimmed = notes?.trim();
|
||||
if (!trimmed) return handoverDocumentNote;
|
||||
const index = trimmed.lastIndexOf(HANDOVER_DOCUMENT_MARKER);
|
||||
if (index < 0) {
|
||||
return `${trimmed}\n\n${handoverDocumentNote}`;
|
||||
}
|
||||
return [trimmed.slice(0, index).trim(), handoverDocumentNote].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
private extractHandoverDocumentLine(notes: string | null | undefined, label: string): string | null {
|
||||
if (!notes) return null;
|
||||
const index = notes.lastIndexOf(HANDOVER_DOCUMENT_MARKER);
|
||||
if (index < 0) return null;
|
||||
const section = notes.slice(index + HANDOVER_DOCUMENT_MARKER.length);
|
||||
const match = section.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
private buildReceiveNote(input: {
|
||||
grnNumber: string;
|
||||
direction?: string | null;
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Freight } from '@edr/types';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
|
||||
import {
|
||||
WarehouseFeeInvoice,
|
||||
InvoiceDocumentModel,
|
||||
InvoiceDocumentService,
|
||||
} from '../billing/documents/invoice-document.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||
import {
|
||||
WarehouseFeeInvoiceView,
|
||||
WarehouseFeeType,
|
||||
WarehouseInvoiceItemView,
|
||||
WarehouseInvoiceStatus,
|
||||
WarehouseInvoiceType,
|
||||
} from './entities/warehouse-fee-invoice.entity';
|
||||
import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity';
|
||||
import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository';
|
||||
import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository';
|
||||
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
|
||||
} from './warehouse-invoice.types';
|
||||
|
||||
interface GenerateOptions {
|
||||
confirmZero?: boolean;
|
||||
@@ -27,9 +34,18 @@ export interface PayInvoiceDto {
|
||||
driverPhone?: string;
|
||||
}
|
||||
|
||||
/** Invoices that still owe money and therefore block terminal release. */
|
||||
const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID'];
|
||||
const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', 'PAID'];
|
||||
/** Warehouse fee invoices live in the global billing system under this source. */
|
||||
const SOURCE = Freight.InvoiceSource.Warehouse;
|
||||
|
||||
/** Global statuses that still owe money and therefore block terminal release. */
|
||||
const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [
|
||||
Freight.InvoiceStatus.Issued,
|
||||
Freight.InvoiceStatus.Pending,
|
||||
Freight.InvoiceStatus.PartiallyPaid,
|
||||
Freight.InvoiceStatus.Overdue,
|
||||
];
|
||||
/** Global statuses considered an "active" invoice for per-inventory dedup. */
|
||||
const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [...BLOCKING_STATUSES, Freight.InvoiceStatus.Paid];
|
||||
|
||||
export interface InvoiceDocumentDetails {
|
||||
bookingReference: string | null;
|
||||
@@ -45,28 +61,75 @@ export interface InvoiceDocumentDetails {
|
||||
zoneName: string | null;
|
||||
}
|
||||
|
||||
export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial<InvoiceDocumentDetails>;
|
||||
export type WarehouseFeeInvoiceDetail = WarehouseFeeInvoiceView &
|
||||
Partial<InvoiceDocumentDetails> & { items: WarehouseInvoiceItemView[] };
|
||||
|
||||
/** The warehouse-specific columns derived from the linked inventory item. */
|
||||
interface InventoryContext {
|
||||
bookingId: string | null;
|
||||
facilityId: string | null;
|
||||
warehouseId: string | null;
|
||||
yardId: string | null;
|
||||
zoneId: string | null;
|
||||
periodStart: Date | null;
|
||||
}
|
||||
|
||||
/** Source fields a view is projected from — satisfied by the global {@link Invoice}. */
|
||||
interface ViewSource {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
companyId: string;
|
||||
sourceId: string;
|
||||
type: string;
|
||||
status: Freight.InvoiceStatus | string;
|
||||
subtotalAmount: number | string;
|
||||
taxAmount: number | string;
|
||||
totalAmount: number | string;
|
||||
paidAmount: number | string;
|
||||
balanceAmount: number | string;
|
||||
currency: string;
|
||||
issuedAt?: Date | null;
|
||||
dueAt?: Date | null;
|
||||
paidAt?: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
payments?: Array<{
|
||||
amount: number | string;
|
||||
method?: string | null;
|
||||
reference?: string | null;
|
||||
paidAt: string;
|
||||
}> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin warehouse layer over the central {@link BillingService}. Warehouse fee
|
||||
* invoices are global `Invoice` rows (`source = warehouse`, `sourceId =
|
||||
* inventoryId`); this service owns only the warehouse-specific concerns —
|
||||
* computing fees, per-inventory dedup, release-blocking, SMS notifications, the
|
||||
* sealed PDF, and reshaping the global invoice back into the historical
|
||||
* `WarehouseFeeInvoice` JSON the portal/backoffice expect. All money, numbering,
|
||||
* status, and payment math live in billing.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WarehouseInvoiceService {
|
||||
private readonly logger = new Logger(WarehouseInvoiceService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly invoiceRepository: WarehouseFeeInvoiceRepository,
|
||||
private readonly itemRepository: WarehouseFeeInvoiceItemRepository,
|
||||
private readonly billing: BillingService,
|
||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||
private readonly feeService: WarehouseFeeService,
|
||||
private readonly documents: WarehouseReleaseDocumentService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────
|
||||
async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise<WarehouseFeeInvoice> {
|
||||
async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise<WarehouseFeeInvoiceDetail> {
|
||||
const [item] = await this.dataSource.query(
|
||||
`SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
|
||||
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt",
|
||||
w.facility_id AS "facilityId",
|
||||
b.company_id AS "customerId", b.freight_type AS "freightType"
|
||||
b.company_id AS "companyId", b.company_profile_id AS "companyProfileId",
|
||||
b.freight_type AS "freightType"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
@@ -75,9 +138,16 @@ export class WarehouseInvoiceService {
|
||||
);
|
||||
if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`);
|
||||
|
||||
// Routing through the global invoice requires a billable company + profile,
|
||||
// both of which come from the inventory's booking.
|
||||
if (!item.companyId || !item.companyProfileId) {
|
||||
throw new BadRequestException(
|
||||
'Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).',
|
||||
);
|
||||
}
|
||||
|
||||
// Dedup: only one active (non-cancelled) invoice per inventory item.
|
||||
const active = await this.invoiceRepository.findAll({ where: { inventoryId } });
|
||||
if (active.some((inv) => ACTIVE_STATUSES.includes(inv.status))) {
|
||||
if (await this.hasActiveInvoice(inventoryId)) {
|
||||
throw new ConflictException(
|
||||
'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.',
|
||||
);
|
||||
@@ -112,9 +182,7 @@ export class WarehouseInvoiceService {
|
||||
};
|
||||
});
|
||||
|
||||
const subtotal = items.reduce((s, i) => s + i.amount, 0);
|
||||
const total = subtotal; // tax model can be layered on later
|
||||
|
||||
const total = items.reduce((s, i) => s + i.amount, 0);
|
||||
if (total <= 0 && !opts.confirmZero) {
|
||||
throw new BadRequestException('No payable warehouse fee found for this item.');
|
||||
}
|
||||
@@ -124,74 +192,81 @@ export class WarehouseInvoiceService {
|
||||
const invoiceType: WarehouseInvoiceType =
|
||||
hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE';
|
||||
|
||||
const currency = billingCurrency;
|
||||
const now = new Date();
|
||||
const periodEnd = previews[0] ? new Date(previews[0].endDate) : now;
|
||||
const lines: InvoiceLineInput[] = items.map((it) => ({
|
||||
chargeType: it.feeType,
|
||||
description: it.description,
|
||||
quantity: it.quantity,
|
||||
unitRate: it.unitRate,
|
||||
amount: it.amount,
|
||||
currency: it.currency,
|
||||
metadata: {
|
||||
feeRuleId: it.feeRuleId ?? null,
|
||||
chargeableDays: it.chargeableDays ?? null,
|
||||
freeDays: it.freeDays ?? null,
|
||||
},
|
||||
}));
|
||||
|
||||
const invoice = await this.invoiceRepository.create({
|
||||
invoiceNumber: await this.nextInvoiceNumber(),
|
||||
bookingId: item.bookingId ?? null,
|
||||
customerId: item.customerId ?? null,
|
||||
inventoryId,
|
||||
facilityId: item.facilityId ?? null,
|
||||
warehouseId: item.warehouseId ?? null,
|
||||
yardId: item.yardId ?? null,
|
||||
zoneId: item.zoneId ?? null,
|
||||
invoiceType,
|
||||
status: 'ISSUED',
|
||||
subtotalAmount: subtotal,
|
||||
taxAmount: 0,
|
||||
totalAmount: total,
|
||||
paidAmount: 0,
|
||||
balanceAmount: total,
|
||||
currency,
|
||||
periodStart: item.arrivedAt ?? null,
|
||||
periodEnd,
|
||||
issuedAt: now,
|
||||
payments: [],
|
||||
notes: opts.performedBy ? `Generated by ${opts.performedBy}` : null,
|
||||
const invoice = await this.billing.generateInvoice({
|
||||
source: SOURCE,
|
||||
sourceId: inventoryId,
|
||||
type: invoiceType,
|
||||
companyId: item.companyId,
|
||||
companyProfileId: item.companyProfileId,
|
||||
currency: billingCurrency,
|
||||
lines,
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
});
|
||||
|
||||
for (const it of items) {
|
||||
await this.itemRepository.create({ invoiceId: invoice.id, ...it });
|
||||
}
|
||||
|
||||
const saved = await this.findById(invoice.id);
|
||||
await this.notifyWarehouseFeeIssued(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** WHF-YYYYMMDD-00001 — sequential per day. */
|
||||
private async nextInvoiceNumber(): Promise<string> {
|
||||
const now = new Date();
|
||||
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
|
||||
const prefix = `WHF-${ymd}-`;
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
|
||||
FROM freight.warehouse_fee_invoices WHERE invoice_number LIKE $1`,
|
||||
[`${prefix}%`],
|
||||
);
|
||||
const next = Number(row?.seq ?? 0) + 1;
|
||||
return `${prefix}${String(next).padStart(5, '0')}`;
|
||||
const detail = await this.findById(invoice.id);
|
||||
await this.notifyWarehouseFeeIssued(detail);
|
||||
return detail;
|
||||
}
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────
|
||||
async findById(id: string): Promise<WarehouseFeeInvoiceWithDisplay & { items: unknown[] }> {
|
||||
const invoice = await this.invoiceRepository.findById(id);
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||
const items = await this.itemRepository.findAll({ where: { invoiceId: id } });
|
||||
async findById(id: string): Promise<WarehouseFeeInvoiceDetail> {
|
||||
const invoice = await this.loadWarehouseInvoice(id);
|
||||
const ctx = await this.getInventoryContext(invoice.sourceId);
|
||||
const details = await this.getInvoiceDocumentDetails(invoice);
|
||||
return { ...invoice, ...details, items } as WarehouseFeeInvoiceWithDisplay & { items: unknown[] };
|
||||
const items = invoice.lines.map((l) => this.lineToItem(l));
|
||||
return { ...this.buildView(invoice, ctx), ...details, items };
|
||||
}
|
||||
|
||||
listForInventory(inventoryId: string): Promise<WarehouseFeeInvoiceView[]> {
|
||||
return this.queryViews('AND i.source_id = $1', [inventoryId]);
|
||||
}
|
||||
|
||||
listForBooking(bookingId: string): Promise<WarehouseFeeInvoiceView[]> {
|
||||
return this.queryViews('AND inv.booking_id = $1', [bookingId]);
|
||||
}
|
||||
|
||||
async findAll(
|
||||
filter: Partial<
|
||||
Pick<
|
||||
WarehouseFeeInvoiceView,
|
||||
'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId'
|
||||
>
|
||||
>,
|
||||
): Promise<WarehouseFeeInvoiceView[]> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
const add = (sql: (p: string) => string, value: unknown) => {
|
||||
params.push(value);
|
||||
conditions.push(sql(`$${params.length}`));
|
||||
};
|
||||
|
||||
if (filter.status) add((p) => `i.status::text = ${p}`, this.toGlobalStatus(filter.status as WarehouseInvoiceStatus));
|
||||
if (filter.invoiceType) add((p) => `i.type = ${p}`, filter.invoiceType);
|
||||
if (filter.customerId) add((p) => `i.company_id = ${p}`, filter.customerId);
|
||||
if (filter.warehouseId) add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId);
|
||||
if (filter.facilityId) add((p) => `w.facility_id = ${p}`, filter.facilityId);
|
||||
if (filter.bookingId) add((p) => `inv.booking_id = ${p}`, filter.bookingId);
|
||||
|
||||
return this.queryViews(conditions.map((c) => `AND ${c}`).join(' '), params);
|
||||
}
|
||||
|
||||
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const invoice = await this.findById(id);
|
||||
const details = await this.getInvoiceDocumentDetails(invoice);
|
||||
const html = this.buildInvoiceDocumentHtml(invoice, 'INVOICE', details);
|
||||
return {
|
||||
filename: `warehouse-invoice-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
|
||||
buffer: await this.documents.htmlToPdfBuffer(html),
|
||||
};
|
||||
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'INVOICE'));
|
||||
}
|
||||
|
||||
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
@@ -199,76 +274,65 @@ export class WarehouseInvoiceService {
|
||||
if (Number(invoice.paidAmount) <= 0) {
|
||||
throw new BadRequestException('A receipt is available only after payment is recorded.');
|
||||
}
|
||||
const details = await this.getInvoiceDocumentDetails(invoice);
|
||||
const html = this.buildInvoiceDocumentHtml(invoice, 'RECEIPT', details);
|
||||
return {
|
||||
filename: `warehouse-receipt-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
|
||||
buffer: await this.documents.htmlToPdfBuffer(html),
|
||||
};
|
||||
}
|
||||
|
||||
listForInventory(inventoryId: string): Promise<WarehouseFeeInvoice[]> {
|
||||
return this.invoiceRepository.findAll({ where: { inventoryId }, order: { createdAt: 'DESC' } });
|
||||
}
|
||||
|
||||
listForBooking(bookingId: string): Promise<WarehouseFeeInvoice[]> {
|
||||
return this.invoiceRepository.findAll({ where: { bookingId }, order: { createdAt: 'DESC' } });
|
||||
}
|
||||
|
||||
findAll(filter: Partial<Pick<WarehouseFeeInvoice, 'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId'>>): Promise<WarehouseFeeInvoice[]> {
|
||||
const where = Object.fromEntries(Object.entries(filter).filter(([, v]) => v != null));
|
||||
return this.invoiceRepository.findAll({ where, order: { createdAt: 'DESC' } });
|
||||
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT'));
|
||||
}
|
||||
|
||||
// ── State changes ────────────────────────────────────────────────────────
|
||||
async cancel(id: string): Promise<WarehouseFeeInvoice> {
|
||||
const invoice = await this.invoiceRepository.findById(id);
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||
if (invoice.status === 'PAID') throw new BadRequestException('A paid invoice cannot be cancelled.');
|
||||
const updated = await this.invoiceRepository.update(id, { status: 'CANCELLED', cancelledAt: new Date() });
|
||||
return updated as WarehouseFeeInvoice;
|
||||
async cancel(id: string): Promise<WarehouseFeeInvoiceDetail> {
|
||||
const invoice = await this.loadWarehouseInvoice(id);
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
throw new BadRequestException('A paid invoice cannot be cancelled.');
|
||||
}
|
||||
await this.billing.cancelInvoice(id);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/** Record a payment against the invoice and sync status (links to existing payment flow). */
|
||||
async pay(id: string, dto: PayInvoiceDto): Promise<WarehouseFeeInvoice> {
|
||||
const invoice = await this.invoiceRepository.findById(id);
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||
if (invoice.status === 'CANCELLED') throw new BadRequestException('Cannot pay a cancelled invoice.');
|
||||
if (invoice.status === 'PAID') throw new BadRequestException('Invoice is already fully paid.');
|
||||
if (!(dto.amount > 0)) throw new BadRequestException('Payment amount must be greater than zero.');
|
||||
|
||||
const paidAmount = Number(invoice.paidAmount) + dto.amount;
|
||||
const total = Number(invoice.totalAmount);
|
||||
const balance = Math.max(0, Math.round((total - paidAmount) * 100) / 100);
|
||||
const fullyPaid = paidAmount >= total;
|
||||
|
||||
const payments = [
|
||||
...(invoice.payments ?? []),
|
||||
{ amount: dto.amount, method: dto.method ?? null, reference: dto.reference ?? null, paidAt: new Date().toISOString() },
|
||||
];
|
||||
|
||||
const updated = await this.invoiceRepository.update(id, {
|
||||
paidAmount: Math.round(paidAmount * 100) / 100,
|
||||
balanceAmount: balance,
|
||||
status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID',
|
||||
paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null,
|
||||
payments,
|
||||
/** Record a payment against the invoice (delegates settlement to billing). */
|
||||
async pay(id: string, dto: PayInvoiceDto): Promise<WarehouseFeeInvoiceDetail> {
|
||||
// Guard that this is a warehouse invoice before recording (404 otherwise).
|
||||
await this.loadWarehouseInvoice(id);
|
||||
await this.billing.recordPayment(id, {
|
||||
amount: dto.amount,
|
||||
method: dto.method ?? null,
|
||||
reference: dto.reference ?? null,
|
||||
metadata:
|
||||
dto.driverName || dto.driverPhone
|
||||
? { driverName: dto.driverName ?? null, driverPhone: dto.driverPhone ?? null }
|
||||
: null,
|
||||
});
|
||||
const paidInvoice = updated as WarehouseFeeInvoice;
|
||||
await this.notifyWarehouseFeePayment(paidInvoice, dto);
|
||||
return paidInvoice;
|
||||
const detail = await this.findById(id);
|
||||
await this.notifyWarehouseFeePayment(detail, dto);
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify on online (gateway) settlement — the domain side-effect of a warehouse
|
||||
* fee being paid through billing's payment flow. The counter {@link pay} path
|
||||
* notifies inline (and carries driver details from the request), so this only
|
||||
* handles gateway payments: those stamp the invoice `paymentId`, whereas a
|
||||
* counter settlement leaves it null. Skipping null-`paymentId` events avoids
|
||||
* double-notifying a counter payment that already sent its SMS.
|
||||
*/
|
||||
@OnEvent('warehouse.invoice.paid')
|
||||
async onWarehouseInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
if (!payload.paymentId) return;
|
||||
const detail = await this.findById(payload.invoiceId);
|
||||
await this.notifyWarehouseFeePayment(detail, { amount: Number(detail.totalAmount) });
|
||||
}
|
||||
|
||||
// ── Release blocking ──────────────────────────────────────────────────────
|
||||
/** Returns the first unpaid invoice that blocks terminal release, or null. */
|
||||
async findBlockingInvoice(inventoryId: string): Promise<WarehouseFeeInvoice | null> {
|
||||
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
|
||||
return invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)) ?? null;
|
||||
async findBlockingInvoice(inventoryId: string): Promise<WarehouseFeeInvoiceView | null> {
|
||||
const blocking = await this.queryViews(
|
||||
`AND i.source_id = $1 AND i.status::text = ANY($2::text[])`,
|
||||
[inventoryId, BLOCKING_STATUSES],
|
||||
);
|
||||
return blocking[0] ?? null;
|
||||
}
|
||||
|
||||
async assertClearanceAllowed(inventoryId: string): Promise<void> {
|
||||
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
|
||||
const blocking = invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status));
|
||||
const invoices = await this.queryViews('AND i.source_id = $1', [inventoryId]);
|
||||
const blocking = invoices.find((inv) => inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
|
||||
if (blocking) {
|
||||
throw new BadRequestException(
|
||||
`Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`,
|
||||
@@ -286,12 +350,218 @@ export class WarehouseInvoiceService {
|
||||
}
|
||||
}
|
||||
|
||||
private async getInvoiceDocumentDetails(invoice: WarehouseFeeInvoice): Promise<InvoiceDocumentDetails> {
|
||||
// ── Internal: loading & projection ─────────────────────────────────────────
|
||||
|
||||
/** Load a global invoice (+lines) and assert it is a warehouse fee invoice. */
|
||||
private async loadWarehouseInvoice(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const invoice = await this.billing.findById(id);
|
||||
if (invoice.source !== SOURCE) {
|
||||
throw new NotFoundException(`Invoice ${id} not found`);
|
||||
}
|
||||
return invoice;
|
||||
}
|
||||
|
||||
private async hasActiveInvoice(inventoryId: string): Promise<boolean> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT 1
|
||||
FROM freight.invoices
|
||||
WHERE source = $1 AND source_id = $2 AND status::text = ANY($3::text[]) AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[SOURCE, inventoryId, ACTIVE_STATUSES],
|
||||
);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project warehouse-source global invoices into the historical view, joined to
|
||||
* their inventory item for the typed FKs. Powers every list/filter read.
|
||||
*/
|
||||
private async queryViews(extraWhere: string, params: unknown[]): Promise<WarehouseFeeInvoiceView[]> {
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT i.id, i.invoice_number AS "invoiceNumber", i.company_id AS "companyId",
|
||||
i.source_id AS "sourceId", i.type, i.status,
|
||||
i.subtotal_amount AS "subtotalAmount", i.tax_amount AS "taxAmount",
|
||||
i.total_amount AS "totalAmount", i.paid_amount AS "paidAmount",
|
||||
i.balance_amount AS "balanceAmount", i.currency, i.payments,
|
||||
i.issued_at AS "issuedAt", i.due_at AS "dueAt", i.paid_at AS "paidAt",
|
||||
i.created_at AS "createdAt", i.updated_at AS "updatedAt",
|
||||
inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
|
||||
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart",
|
||||
w.facility_id AS "facilityId"
|
||||
FROM freight.invoices i
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
WHERE i.source = $${params.length + 1} AND i.deleted_at IS NULL ${extraWhere}
|
||||
ORDER BY i.created_at DESC`,
|
||||
[...params, SOURCE],
|
||||
);
|
||||
|
||||
return (rows as Array<ViewSource & InventoryContext>).map((row) =>
|
||||
this.buildView(row, {
|
||||
bookingId: row.bookingId ?? null,
|
||||
facilityId: row.facilityId ?? null,
|
||||
warehouseId: row.warehouseId ?? null,
|
||||
yardId: row.yardId ?? null,
|
||||
zoneId: row.zoneId ?? null,
|
||||
periodStart: row.periodStart ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Reshape a global invoice (+ derived inventory context) into the warehouse view. */
|
||||
private buildView(inv: ViewSource, ctx: InventoryContext): WarehouseFeeInvoiceView {
|
||||
const status = this.toWarehouseStatus(inv.status);
|
||||
return {
|
||||
id: inv.id,
|
||||
invoiceNumber: inv.invoiceNumber,
|
||||
bookingId: ctx.bookingId,
|
||||
customerId: inv.companyId ?? null,
|
||||
inventoryId: inv.sourceId,
|
||||
facilityId: ctx.facilityId,
|
||||
warehouseId: ctx.warehouseId,
|
||||
yardId: ctx.yardId,
|
||||
zoneId: ctx.zoneId,
|
||||
invoiceType: inv.type as WarehouseInvoiceType,
|
||||
status,
|
||||
subtotalAmount: Number(inv.subtotalAmount),
|
||||
taxAmount: Number(inv.taxAmount),
|
||||
totalAmount: Number(inv.totalAmount),
|
||||
paidAmount: Number(inv.paidAmount),
|
||||
balanceAmount: Number(inv.balanceAmount),
|
||||
currency: inv.currency,
|
||||
periodStart: ctx.periodStart,
|
||||
// No standalone period column once centralized: the charge window ends at
|
||||
// issuance, so `issuedAt` is the period end.
|
||||
periodEnd: inv.issuedAt ?? null,
|
||||
issuedAt: inv.issuedAt ?? null,
|
||||
dueDate: inv.dueAt ?? null,
|
||||
paidAt: inv.paidAt ?? null,
|
||||
cancelledAt: status === 'CANCELLED' ? inv.updatedAt : null,
|
||||
payments: (inv.payments ?? []).map((p) => ({
|
||||
amount: Number(p.amount),
|
||||
method: p.method ?? null,
|
||||
reference: p.reference ?? null,
|
||||
paidAt: p.paidAt,
|
||||
})),
|
||||
notes: null,
|
||||
createdAt: inv.createdAt,
|
||||
updatedAt: inv.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private lineToItem(line: InvoiceLine): WarehouseInvoiceItemView {
|
||||
const meta = (line.metadata ?? {}) as {
|
||||
feeRuleId?: string | null;
|
||||
chargeableDays?: number | null;
|
||||
freeDays?: number | null;
|
||||
};
|
||||
return {
|
||||
feeRuleId: meta.feeRuleId ?? null,
|
||||
feeType: line.chargeType as WarehouseFeeType,
|
||||
description: line.description ?? '',
|
||||
quantity: Number(line.quantity),
|
||||
unitRate: Number(line.unitRate),
|
||||
amount: Number(line.amount),
|
||||
currency: line.currency,
|
||||
chargeableDays: meta.chargeableDays ?? null,
|
||||
freeDays: meta.freeDays ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private toWarehouseStatus(status: Freight.InvoiceStatus | string): WarehouseInvoiceStatus {
|
||||
switch (status) {
|
||||
case Freight.InvoiceStatus.Draft:
|
||||
return 'DRAFT';
|
||||
case Freight.InvoiceStatus.PartiallyPaid:
|
||||
return 'PARTIALLY_PAID';
|
||||
case Freight.InvoiceStatus.Paid:
|
||||
return 'PAID';
|
||||
case Freight.InvoiceStatus.Cancelled:
|
||||
case Freight.InvoiceStatus.Refunded:
|
||||
return 'CANCELLED';
|
||||
default:
|
||||
// Issued / Pending / Overdue → an issued, still-owed invoice.
|
||||
return 'ISSUED';
|
||||
}
|
||||
}
|
||||
|
||||
private toGlobalStatus(status: WarehouseInvoiceStatus): Freight.InvoiceStatus {
|
||||
switch (status) {
|
||||
case 'DRAFT':
|
||||
return Freight.InvoiceStatus.Draft;
|
||||
case 'PARTIALLY_PAID':
|
||||
return Freight.InvoiceStatus.PartiallyPaid;
|
||||
case 'PAID':
|
||||
return Freight.InvoiceStatus.Paid;
|
||||
case 'CANCELLED':
|
||||
return Freight.InvoiceStatus.Cancelled;
|
||||
default:
|
||||
return Freight.InvoiceStatus.Issued;
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a warehouse fee invoice view onto the shared document model. */
|
||||
private toDocumentModel(
|
||||
invoice: WarehouseFeeInvoiceDetail,
|
||||
kind: 'INVOICE' | 'RECEIPT',
|
||||
): InvoiceDocumentModel {
|
||||
const lastPayment = [...(invoice.payments ?? [])].pop();
|
||||
const date = (value: unknown) =>
|
||||
value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null;
|
||||
|
||||
return {
|
||||
kind,
|
||||
title: 'Warehouse Fee',
|
||||
documentNumber: invoice.invoiceNumber,
|
||||
issuedAt: invoice.issuedAt ?? invoice.createdAt,
|
||||
status: invoice.status,
|
||||
currency: invoice.currency,
|
||||
summary: [
|
||||
{ label: 'Status', value: invoice.status.replace(/_/g, ' ') },
|
||||
{ label: 'Invoice type', value: invoice.invoiceType.replace(/_/g, ' ') },
|
||||
{ label: 'Booking reference', value: invoice.bookingReference ?? null },
|
||||
{ label: 'Customer', value: invoice.customerName ?? null },
|
||||
{ label: 'Inventory reference', value: invoice.inventoryReference ?? null },
|
||||
{ label: 'Inventory info', value: invoice.inventoryInfo ?? null },
|
||||
{ label: 'Clearance', value: invoice.clearanceStatus ?? null },
|
||||
{ label: 'Warehouse', value: invoice.warehouseName ?? null },
|
||||
{
|
||||
label: 'Yard / Zone',
|
||||
value: [invoice.yardName, invoice.zoneName].filter(Boolean).join(' / ') || null,
|
||||
},
|
||||
{ label: 'Period', value: `${date(invoice.periodStart) ?? '-'} - ${date(invoice.periodEnd) ?? '-'}` },
|
||||
{
|
||||
label: 'Payment',
|
||||
value: lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt) ?? '-'}` : null,
|
||||
},
|
||||
],
|
||||
categoryHeader: 'Fee type',
|
||||
lines: invoice.items.map((item) => ({
|
||||
description: item.description ?? null,
|
||||
category: item.feeType ?? null,
|
||||
quantity: item.quantity ?? item.chargeableDays ?? 0,
|
||||
unitRate: item.unitRate,
|
||||
amount: item.amount,
|
||||
currency: item.currency ?? invoice.currency,
|
||||
})),
|
||||
totals: [
|
||||
{ label: 'Subtotal', amount: Number(invoice.subtotalAmount) },
|
||||
{ label: 'Tax', amount: Number(invoice.taxAmount) },
|
||||
{ label: 'Total', amount: Number(invoice.totalAmount), grand: true },
|
||||
{ label: 'Paid', amount: Number(invoice.paidAmount) },
|
||||
{ label: 'Balance', amount: Number(invoice.balanceAmount) },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Warehouse-specific display details, derived from the linked inventory item. */
|
||||
private async getInvoiceDocumentDetails(invoice: ViewSource): Promise<InvoiceDocumentDetails> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT b.reference AS "bookingReference",
|
||||
company.name AS "customerName",
|
||||
COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference",
|
||||
inv.status AS "inventoryStatus",
|
||||
inv.release_date AS "releaseDate",
|
||||
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
|
||||
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
|
||||
CONCAT_WS(
|
||||
@@ -302,16 +572,10 @@ export class WarehouseInvoiceService {
|
||||
) AS "inventoryInfo",
|
||||
wh.name AS "warehouseName",
|
||||
yard.name AS "yardName",
|
||||
zone.name AS "zoneName",
|
||||
CASE
|
||||
WHEN inv.release_date IS NOT NULL THEN 'RELEASE ISSUED'
|
||||
WHEN $2 = 'PAID' THEN 'FEE PAID - READY FOR RELEASE'
|
||||
ELSE 'PENDING PAYMENT'
|
||||
END AS "clearanceStatus"
|
||||
FROM freight.warehouse_fee_invoices fee
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id)
|
||||
zone.name AS "zoneName"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
|
||||
LEFT JOIN freight.booking_container booking_container ON (
|
||||
booking_container.booking_id = b.id
|
||||
@@ -319,14 +583,21 @@ export class WarehouseInvoiceService {
|
||||
)
|
||||
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
|
||||
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
|
||||
LEFT JOIN freight.warehouses wh ON wh.id = fee.warehouse_id
|
||||
LEFT JOIN freight.warehouse_yards yard ON yard.id = fee.yard_id
|
||||
LEFT JOIN freight.warehouse_zones zone ON zone.id = fee.zone_id
|
||||
WHERE fee.id = $1
|
||||
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
|
||||
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
|
||||
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
|
||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[invoice.id, invoice.status],
|
||||
[invoice.sourceId],
|
||||
);
|
||||
|
||||
const fullyPaid = this.toWarehouseStatus(invoice.status) === 'PAID';
|
||||
const clearanceStatus = row?.releaseDate
|
||||
? 'RELEASE ISSUED'
|
||||
: fullyPaid
|
||||
? 'FEE PAID - READY FOR RELEASE'
|
||||
: 'PENDING PAYMENT';
|
||||
|
||||
return {
|
||||
bookingReference: row?.bookingReference ?? null,
|
||||
customerName: row?.customerName ?? null,
|
||||
@@ -338,11 +609,33 @@ export class WarehouseInvoiceService {
|
||||
warehouseName: row?.warehouseName ?? null,
|
||||
yardName: row?.yardName ?? null,
|
||||
zoneName: row?.zoneName ?? null,
|
||||
clearanceStatus: row?.clearanceStatus ?? (invoice.status === 'PAID' ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT'),
|
||||
clearanceStatus,
|
||||
};
|
||||
}
|
||||
|
||||
private async getInvoiceNotificationContacts(invoice: WarehouseFeeInvoice): Promise<{
|
||||
private async getInventoryContext(inventoryId: string): Promise<InventoryContext> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
|
||||
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart",
|
||||
w.facility_id AS "facilityId"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[inventoryId],
|
||||
);
|
||||
return {
|
||||
bookingId: row?.bookingId ?? null,
|
||||
facilityId: row?.facilityId ?? null,
|
||||
warehouseId: row?.warehouseId ?? null,
|
||||
yardId: row?.yardId ?? null,
|
||||
zoneId: row?.zoneId ?? null,
|
||||
periodStart: row?.periodStart ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Notifications ──────────────────────────────────────────────────────────
|
||||
private async getInvoiceNotificationContacts(inventoryId: string): Promise<{
|
||||
bookingReference: string | null;
|
||||
customerName: string | null;
|
||||
customerPhone: string | null;
|
||||
@@ -364,10 +657,9 @@ export class WarehouseInvoiceService {
|
||||
COALESCE(last_driver.phone_number, first_driver.phone_number) AS "driverPhone",
|
||||
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
|
||||
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription"
|
||||
FROM freight.warehouse_fee_invoices fee
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id)
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
|
||||
LEFT JOIN freight.booking_container booking_container ON (
|
||||
booking_container.booking_id = b.id
|
||||
@@ -393,9 +685,9 @@ export class WarehouseInvoiceService {
|
||||
) latest_first_mile ON true
|
||||
LEFT JOIN freight.vehicles first_vehicle ON first_vehicle.id = latest_first_mile.vehicle_id
|
||||
LEFT JOIN freight.drivers first_driver ON first_driver.id = first_vehicle.assigned_driver_id
|
||||
WHERE fee.id = $1
|
||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[invoice.id],
|
||||
[inventoryId],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -419,8 +711,8 @@ export class WarehouseInvoiceService {
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoice): Promise<void> {
|
||||
const contacts = await this.getInvoiceNotificationContacts(invoice);
|
||||
private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoiceView): Promise<void> {
|
||||
const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId);
|
||||
const customerName = contacts.customerName?.trim() || 'Customer';
|
||||
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
|
||||
const cargo = contacts.containerNumber || contacts.cargoDescription;
|
||||
@@ -433,8 +725,8 @@ export class WarehouseInvoiceService {
|
||||
await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`);
|
||||
}
|
||||
|
||||
private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoice, dto: PayInvoiceDto): Promise<void> {
|
||||
const contacts = await this.getInvoiceNotificationContacts(invoice);
|
||||
private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoiceView, dto: PayInvoiceDto): Promise<void> {
|
||||
const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId);
|
||||
const customerName = contacts.customerName?.trim() || 'Customer';
|
||||
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
|
||||
const statusText =
|
||||
@@ -460,131 +752,4 @@ export class WarehouseInvoiceService {
|
||||
|
||||
await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`);
|
||||
}
|
||||
|
||||
private buildInvoiceDocumentHtml(
|
||||
invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] },
|
||||
kind: 'INVOICE' | 'RECEIPT',
|
||||
details: InvoiceDocumentDetails,
|
||||
): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
const money = (amount: unknown, currency = invoice.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 items = invoice.items as Array<{
|
||||
id?: string;
|
||||
description?: string;
|
||||
feeType?: string;
|
||||
quantity?: number;
|
||||
unitRate?: number;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
chargeableDays?: number | null;
|
||||
}>;
|
||||
const lastPayment = [...(invoice.payments ?? [])].pop();
|
||||
const sealText = kind === 'RECEIPT' || invoice.status === 'PAID' ? 'EDR PAID' : 'EDR';
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Warehouse Fee ${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>Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}</h1>
|
||||
</div>
|
||||
<div class="meta">
|
||||
Document no.
|
||||
<strong>${esc(invoice.invoiceNumber)}</strong>
|
||||
Issued: ${esc(date(invoice.issuedAt ?? invoice.createdAt))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="seal">${esc(sealText)}</div>
|
||||
<div class="summary">
|
||||
<div><span>Status</span>${esc(invoice.status.replace(/_/g, ' '))}</div>
|
||||
<div><span>Invoice type</span>${esc(invoice.invoiceType.replace(/_/g, ' '))}</div>
|
||||
<div><span>Booking reference</span>${esc(details.bookingReference)}</div>
|
||||
<div><span>Customer</span>${esc(details.customerName)}</div>
|
||||
<div><span>Inventory reference</span>${esc(details.inventoryReference)}</div>
|
||||
<div><span>Inventory info</span>${esc(details.inventoryInfo)}</div>
|
||||
<div><span>Clearance</span>${esc(details.clearanceStatus)}</div>
|
||||
<div><span>Warehouse</span>${esc(details.warehouseName)}</div>
|
||||
<div><span>Yard / Zone</span>${esc([details.yardName, details.zoneName].filter(Boolean).join(' / ') || null)}</div>
|
||||
<div><span>Period</span>${esc(date(invoice.periodStart))} - ${esc(date(invoice.periodEnd))}</div>
|
||||
<div><span>Payment</span>${esc(lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt)}` : '-')}</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Fee type</th>
|
||||
<th class="num">Qty</th>
|
||||
<th class="num">Rate</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${items
|
||||
.map(
|
||||
(item) => `<tr>
|
||||
<td>${esc(item.description)}</td>
|
||||
<td>${esc((item.feeType ?? '').replace(/_/g, ' '))}</td>
|
||||
<td class="num">${esc(item.quantity ?? item.chargeableDays ?? 0)}</td>
|
||||
<td class="num">${esc(money(item.unitRate, item.currency ?? invoice.currency))}</td>
|
||||
<td class="num">${esc(money(item.amount, item.currency ?? invoice.currency))}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="totals">
|
||||
<div class="total-row"><span>Subtotal</span><strong>${esc(money(invoice.subtotalAmount))}</strong></div>
|
||||
<div class="total-row"><span>Tax</span><strong>${esc(money(invoice.taxAmount))}</strong></div>
|
||||
<div class="total-row grand"><span>Total</span><strong>${esc(money(invoice.totalAmount))}</strong></div>
|
||||
<div class="total-row"><span>Paid</span><strong>${esc(money(invoice.paidAmount))}</strong></div>
|
||||
<div class="total-row"><span>Balance</span><strong>${esc(money(invoice.balanceAmount))}</strong></div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="line">Prepared by EDR warehouse finance</div>
|
||||
<div class="line">Authorized seal / signature</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private safeFilename(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9_-]+/g, '-');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Public shapes for warehouse fee invoices.
|
||||
*
|
||||
* Warehouse fee invoices are no longer a standalone table — they are global
|
||||
* `Invoice` rows (`source = "warehouse"`, `sourceId = inventoryId`) owned by the
|
||||
* central {@link BillingService}. These types preserve the warehouse-facing API
|
||||
* contract: `WarehouseInvoiceService` reshapes the global invoice (+ lines +
|
||||
* inventory context) back into the historical `WarehouseFeeInvoice` JSON so the
|
||||
* portal/backoffice stay untouched.
|
||||
*/
|
||||
|
||||
export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const;
|
||||
export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number];
|
||||
|
||||
export const WAREHOUSE_INVOICE_STATUSES = [
|
||||
'DRAFT',
|
||||
'ISSUED',
|
||||
'PARTIALLY_PAID',
|
||||
'PAID',
|
||||
'CANCELLED',
|
||||
] as const;
|
||||
export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number];
|
||||
|
||||
export const WAREHOUSE_FEE_TYPES = [
|
||||
'CONTAINER_DEMURRAGE',
|
||||
'BULK_DEMURRAGE',
|
||||
'STORAGE_FEE',
|
||||
'HANDLING_FEE',
|
||||
] as const;
|
||||
export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number];
|
||||
|
||||
/** A single recorded payment against a warehouse fee invoice (history). */
|
||||
export interface WarehouseInvoicePayment {
|
||||
amount: number;
|
||||
method?: string | null;
|
||||
reference?: string | null;
|
||||
paidAt: string;
|
||||
}
|
||||
|
||||
/** A billed warehouse fee line, projected from a global `InvoiceLine`. */
|
||||
export interface WarehouseInvoiceItemView {
|
||||
feeRuleId: string | null;
|
||||
feeType: WarehouseFeeType;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitRate: number;
|
||||
amount: number;
|
||||
currency: string;
|
||||
chargeableDays: number | null;
|
||||
freeDays: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The warehouse-facing invoice header — same field set the old
|
||||
* `WarehouseFeeInvoice` entity exposed, projected from a global `Invoice`. The
|
||||
* typed FKs (`bookingId`/`facilityId`/`warehouseId`/`yardId`/`zoneId`) and the
|
||||
* charge `period` are derived from the linked inventory item; `customerId` is the
|
||||
* billed company; `invoiceType` is the invoice `type`.
|
||||
*/
|
||||
export interface WarehouseFeeInvoiceView {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
bookingId: string | null;
|
||||
customerId: string | null;
|
||||
inventoryId: string;
|
||||
facilityId: string | null;
|
||||
warehouseId: string | null;
|
||||
yardId: string | null;
|
||||
zoneId: string | null;
|
||||
invoiceType: WarehouseInvoiceType;
|
||||
status: WarehouseInvoiceStatus;
|
||||
subtotalAmount: number;
|
||||
taxAmount: number;
|
||||
totalAmount: number;
|
||||
paidAmount: number;
|
||||
balanceAmount: number;
|
||||
currency: string;
|
||||
periodStart: Date | null;
|
||||
periodEnd: Date | null;
|
||||
issuedAt: Date | null;
|
||||
dueDate: Date | null;
|
||||
paidAt: Date | null;
|
||||
cancelledAt: Date | null;
|
||||
payments: WarehouseInvoicePayment[];
|
||||
notes: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -1,101 +1,23 @@
|
||||
import { existsSync } from 'fs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { PdfRenderService } from '../billing/documents/pdf-render.service';
|
||||
|
||||
const MIN_VALID_PDF_BYTES = 2_000;
|
||||
|
||||
const RELEASE_DOCUMENT_PRINT_STYLES = `
|
||||
<style id="warehouse-release-document-print-fix">
|
||||
@media print {
|
||||
html, body {
|
||||
background: #fff !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
</style>`;
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseReleaseDocumentService {
|
||||
private readonly logger = new Logger(WarehouseReleaseDocumentService.name);
|
||||
constructor(private readonly pdf: PdfRenderService) {}
|
||||
|
||||
async htmlToPdfBuffer(html: string): Promise<Buffer> {
|
||||
const preparedHtml = this.injectPdfPrintStyles(html);
|
||||
const executablePath = this.resolveExecutablePath();
|
||||
|
||||
try {
|
||||
const puppeteer = await import('puppeteer');
|
||||
const launchOptions: import('puppeteer').LaunchOptions = {
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
};
|
||||
|
||||
const browser = await puppeteer.default.launch(launchOptions);
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
|
||||
await page.setContent(preparedHtml, { waitUntil: 'load', timeout: 60_000 });
|
||||
await page.emulateMediaType('print');
|
||||
await new Promise((resolve) => setTimeout(resolve, 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 release PDF (${buffer.length} bytes)`);
|
||||
}
|
||||
this.logger.log(
|
||||
`Warehouse release PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`,
|
||||
);
|
||||
return buffer;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Warehouse release PDF failed (executable=${executablePath ?? 'default'}): ${error}`,
|
||||
);
|
||||
const fallback = this.htmlToBasicPdfBuffer(preparedHtml);
|
||||
if (this.isValidPdf(fallback)) {
|
||||
this.logger.warn(
|
||||
`Using basic warehouse release PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
throw new InternalServerErrorException(
|
||||
'Warehouse release PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private injectPdfPrintStyles(html: string): string {
|
||||
if (html.includes('warehouse-release-document-print-fix')) return html;
|
||||
if (html.includes('</head>')) {
|
||||
return html.replace('</head>', `${RELEASE_DOCUMENT_PRINT_STYLES}</head>`);
|
||||
}
|
||||
return `${RELEASE_DOCUMENT_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));
|
||||
}
|
||||
|
||||
private isValidPdf(buffer: Buffer): boolean {
|
||||
return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString('ascii') === '%PDF-';
|
||||
/**
|
||||
* Render the gate-clearance release document to PDF via the shared renderer,
|
||||
* falling back to the release-specific hand-built layout when Chromium is
|
||||
* unavailable.
|
||||
*/
|
||||
htmlToPdfBuffer(html: string): Promise<Buffer> {
|
||||
return this.pdf.htmlToPdfBuffer(html, {
|
||||
label: 'Warehouse release',
|
||||
fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml),
|
||||
});
|
||||
}
|
||||
|
||||
private htmlToBasicPdfBuffer(html: string): Buffer {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { DocumentsModule } from '../billing/documents/documents.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||
@@ -10,8 +12,6 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
|
||||
import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity';
|
||||
import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
|
||||
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||
@@ -38,8 +38,6 @@ import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.r
|
||||
import { WarehouseAllocationService } from './warehouse-allocation.service';
|
||||
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
|
||||
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||
import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository';
|
||||
import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository';
|
||||
import { WarehouseInvoiceController } from './warehouse-invoice.controller';
|
||||
import { WarehouseInvoiceService } from './warehouse-invoice.service';
|
||||
import { WarehouseRulesController } from './warehouse-rules.controller';
|
||||
@@ -67,9 +65,9 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseInspectionReport,
|
||||
WarehouseAllocationRule,
|
||||
WarehouseFeeRule,
|
||||
WarehouseFeeInvoice,
|
||||
WarehouseFeeInvoiceItem,
|
||||
]),
|
||||
BillingModule,
|
||||
DocumentsModule,
|
||||
FilesModule,
|
||||
InterchangeDocumentsModule,
|
||||
forwardRef(() => LastMileModule),
|
||||
@@ -102,8 +100,6 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseInspectionRepository,
|
||||
WarehouseAllocationRuleRepository,
|
||||
WarehouseFeeRuleRepository,
|
||||
WarehouseFeeInvoiceRepository,
|
||||
WarehouseFeeInvoiceItemRepository,
|
||||
WarehousesService,
|
||||
WarehouseYardsService,
|
||||
WarehouseZonesService,
|
||||
|
||||
28
apps/edr-freight-api/src/scripts/seed-gov-companies.ts
Normal file
28
apps/edr-freight-api/src/scripts/seed-gov-companies.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import "reflect-metadata";
|
||||
import { config } from "dotenv";
|
||||
import { resolve } from "path";
|
||||
|
||||
config({ path: resolve(__dirname, "../../.env") });
|
||||
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { AppModule } from "../app.module";
|
||||
import { GovCompaniesSeeder } from "../seed/gov-companies.seeder";
|
||||
|
||||
async function main() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: ["error", "warn", "log"],
|
||||
});
|
||||
|
||||
try {
|
||||
const seeder = app.get(GovCompaniesSeeder);
|
||||
await seeder.run();
|
||||
console.log("Government companies seeded.");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Government companies seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import 'reflect-metadata';
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { AppModule } from '../app.module';
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
|
||||
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
|
||||
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
|
||||
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
|
||||
const BOOKING_REFS = [
|
||||
'WH-EXP-RCV-001',
|
||||
'WH-EXP-RCV-002',
|
||||
'WH-EXP-RCV-003',
|
||||
'WH-EXP-RCV-004',
|
||||
'WH-EXP-RCV-005',
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
|
||||
try {
|
||||
const dataSource = app.get(DataSource);
|
||||
const yardRepo = dataSource.getRepository(Yard);
|
||||
const serviceTypeRepo = dataSource.getRepository(ServiceType);
|
||||
const cargoTypeRepo = dataSource.getRepository(CargoType);
|
||||
const containerTypeRepo = dataSource.getRepository(ContainerType);
|
||||
const bookingRepo = dataSource.getRepository(Booking);
|
||||
const bookingContainerRepo = dataSource.getRepository(BookingContainer);
|
||||
const inventoryRepo = dataSource.getRepository(WarehouseInventory);
|
||||
|
||||
const originYard =
|
||||
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
|
||||
const destinationYard =
|
||||
(await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
|
||||
const serviceType =
|
||||
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER', includesFirstMile: false, isActive: true } })) ??
|
||||
(await serviceTypeRepo.findOne({ where: { includesFirstMile: false, isActive: true } }));
|
||||
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
|
||||
const containerType =
|
||||
(await containerTypeRepo.findOne({ where: { code: '40FT', isActive: true } })) ??
|
||||
(await containerTypeRepo.findOne({ where: { code: '40', isActive: true } })) ??
|
||||
(await containerTypeRepo.findOne({ where: { sizeFt: 40, isActive: true } })) ??
|
||||
(await containerTypeRepo.findOne({ where: { isActive: true } }));
|
||||
|
||||
const missing = [
|
||||
!originYard ? 'MOJO/Ethiopia origin yard' : '',
|
||||
!destinationYard ? 'DJIB_PORT/Djibouti destination yard' : '',
|
||||
!serviceType ? 'active service type without first mile' : '',
|
||||
!containerType ? 'active container type' : '',
|
||||
].filter(Boolean);
|
||||
|
||||
if (missing.length) {
|
||||
throw new Error(`Cannot seed warehouse export receive-ready bookings, missing: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
const now = Date.now();
|
||||
|
||||
for (const [index, reference] of BOOKING_REFS.entries()) {
|
||||
const existing = await bookingRepo.findOne({ where: { reference } });
|
||||
if (existing) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const containerQuantity = index === 4 ? 2 : 1;
|
||||
const weightKg = 18_000 + index * 1_250 + (containerQuantity - 1) * 9_000;
|
||||
const scheduledDate = new Date(now + index * 60 * 60_000);
|
||||
|
||||
const booking = await bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
reference,
|
||||
originYardId: originYard!.id,
|
||||
destinationYardId: destinationYard!.id,
|
||||
serviceTypeId: serviceType!.id,
|
||||
status: 'PAID',
|
||||
paymentStatus: 'PAID',
|
||||
scheduledDate,
|
||||
contractType: 'SPOT',
|
||||
equipmentReturn: 'TERMINAL',
|
||||
paymentCurrency: 'ETB',
|
||||
totalAmount: 0,
|
||||
isGovernment: false,
|
||||
tradeDirection: 'EXPORT',
|
||||
freightType: 'CONTAINER',
|
||||
cargoTypeId: cargoType?.id ?? null,
|
||||
cargoFreeText: cargoType ? null : `Warehouse export receive-ready cargo ${index + 1}`,
|
||||
cargoTotalWeightVgm: weightKg,
|
||||
schedulingStatus: 'NOT_SCHEDULED',
|
||||
}),
|
||||
);
|
||||
|
||||
await bookingContainerRepo.save(
|
||||
bookingContainerRepo.create({
|
||||
bookingId: booking.id,
|
||||
containerTypeId: containerType!.id,
|
||||
containerNumber: `EDRU${String(730100 + index).padStart(6, '0')}`,
|
||||
containerSize: containerType!.sizeFt ? `${containerType!.sizeFt}ft` : containerType!.code,
|
||||
quantity: containerQuantity,
|
||||
hazardousQuantity: 0,
|
||||
reeferQuantity: 0,
|
||||
vgmPerUnitTons: Number((weightKg / containerQuantity / 1000).toFixed(3)),
|
||||
totalVgmTons: Number((weightKg / 1000).toFixed(3)),
|
||||
wagonsRequired: Math.max(1, containerQuantity * Number(containerType!.wagonsPerUnit ?? 1)),
|
||||
isOverweight: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const inventory = await inventoryRepo.findOne({ where: { bookingId: booking.id } });
|
||||
if (inventory) {
|
||||
throw new Error(`Seed invariant failed: booking ${reference} unexpectedly has warehouse inventory`);
|
||||
}
|
||||
|
||||
created += 1;
|
||||
}
|
||||
|
||||
console.log(`Warehouse export receive-ready seed complete. Created ${created}, skipped ${skipped}.`);
|
||||
console.log(`Booking refs: ${BOOKING_REFS.join(', ')}`);
|
||||
console.log('Open Backoffice Warehouse > Receive for loading > Export / Receive to Warehouse.');
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Warehouse export receive-ready seed failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
136
apps/edr-freight-api/src/seed/data/gov-companies.data.ts
Normal file
136
apps/edr-freight-api/src/seed/data/gov-companies.data.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
CompanyKind,
|
||||
CompanyStatus,
|
||||
CompanyType,
|
||||
} from "../../modules/companies/entities/company.entity";
|
||||
import {
|
||||
ProfileStatus,
|
||||
ProfileType,
|
||||
} from "../../modules/companies/entities/company-profile.entity";
|
||||
|
||||
/**
|
||||
* Canonical list of seeded Ethiopian government entities. Government bookings
|
||||
* are billed to one of these (with an explicit importer/exporter profile)
|
||||
* instead of carrying a null company + free-text institution.
|
||||
*
|
||||
* IDs are fixed so the seeder is idempotent and the matching migration
|
||||
* (1821000000003-AddCompanyKindAndGovBookingLinks) can backfill legacy rows to
|
||||
* the same companies. The migration mirrors these rows in raw SQL — keep both
|
||||
* in sync when adding new entities.
|
||||
*/
|
||||
|
||||
export const GOV_COMPANY_TYPE = CompanyType.Customer;
|
||||
export const GOV_COMPANY_KIND = CompanyKind.Government;
|
||||
export const GOV_COMPANY_STATUS = CompanyStatus.Active;
|
||||
export const GOV_PROFILE_STATUS = ProfileStatus.Active;
|
||||
|
||||
export interface GovProfileSeed {
|
||||
id: string;
|
||||
type: ProfileType;
|
||||
reference: string;
|
||||
}
|
||||
|
||||
export interface GovCompanySeed {
|
||||
id: string;
|
||||
name: string;
|
||||
tin: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
profiles: GovProfileSeed[];
|
||||
}
|
||||
|
||||
const importExport = (
|
||||
index: number,
|
||||
importerId: string,
|
||||
exporterId: string,
|
||||
): GovProfileSeed[] => [
|
||||
{
|
||||
id: importerId,
|
||||
type: ProfileType.importer,
|
||||
reference: `IM-9000${index}`,
|
||||
},
|
||||
{
|
||||
id: exporterId,
|
||||
type: ProfileType.exporter,
|
||||
reference: `EX-9000${index}`,
|
||||
},
|
||||
];
|
||||
|
||||
export const GOV_COMPANIES: GovCompanySeed[] = [
|
||||
{
|
||||
id: "0a1b0001-0000-4000-8000-000000000001",
|
||||
name: "Federal Government of Ethiopia",
|
||||
tin: "0000000001",
|
||||
email: "procurement@gov.et",
|
||||
phone: "+251111000001",
|
||||
profiles: importExport(
|
||||
1,
|
||||
"0b1c0001-0000-4000-8000-000000000001",
|
||||
"0b1c0001-0000-4000-8000-000000000002",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "0a1b0002-0000-4000-8000-000000000002",
|
||||
name: "Ministry of National Defense",
|
||||
tin: "0000000002",
|
||||
email: "logistics@mod.gov.et",
|
||||
phone: "+251111000002",
|
||||
profiles: importExport(
|
||||
2,
|
||||
"0b1c0002-0000-4000-8000-000000000001",
|
||||
"0b1c0002-0000-4000-8000-000000000002",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "0a1b0003-0000-4000-8000-000000000003",
|
||||
name: "Ethiopian Roads Administration",
|
||||
tin: "0000000003",
|
||||
email: "supply@era.gov.et",
|
||||
phone: "+251111000003",
|
||||
profiles: importExport(
|
||||
3,
|
||||
"0b1c0003-0000-4000-8000-000000000001",
|
||||
"0b1c0003-0000-4000-8000-000000000002",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "0a1b0004-0000-4000-8000-000000000004",
|
||||
name: "Ministry of Agriculture",
|
||||
tin: "0000000004",
|
||||
email: "imports@moa.gov.et",
|
||||
phone: "+251111000004",
|
||||
profiles: importExport(
|
||||
4,
|
||||
"0b1c0004-0000-4000-8000-000000000001",
|
||||
"0b1c0004-0000-4000-8000-000000000002",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "0a1b0005-0000-4000-8000-000000000005",
|
||||
name: "Ministry of Trade and Regional Integration",
|
||||
tin: "0000000005",
|
||||
email: "trade@motri.gov.et",
|
||||
phone: "+251111000005",
|
||||
profiles: importExport(
|
||||
5,
|
||||
"0b1c0005-0000-4000-8000-000000000001",
|
||||
"0b1c0005-0000-4000-8000-000000000002",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "0a1b0006-0000-4000-8000-000000000006",
|
||||
name: "Ethiopian Disaster Risk Management Commission",
|
||||
tin: "0000000006",
|
||||
email: "relief@edrmc.gov.et",
|
||||
phone: "+251111000006",
|
||||
profiles: importExport(
|
||||
6,
|
||||
"0b1c0006-0000-4000-8000-000000000001",
|
||||
"0b1c0006-0000-4000-8000-000000000002",
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/** Fallback entity used to backfill legacy government / null-company bookings. */
|
||||
export const DEFAULT_GOV_COMPANY = GOV_COMPANIES[0];
|
||||
export const DEFAULT_GOV_IMPORTER_PROFILE = GOV_COMPANIES[0].profiles[0];
|
||||
72
apps/edr-freight-api/src/seed/gov-companies.seeder.ts
Normal file
72
apps/edr-freight-api/src/seed/gov-companies.seeder.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { Company } from "../modules/companies/entities/company.entity";
|
||||
import { CompanyProfile } from "../modules/companies/entities/company-profile.entity";
|
||||
import {
|
||||
GOV_COMPANIES,
|
||||
GOV_COMPANY_KIND,
|
||||
GOV_COMPANY_STATUS,
|
||||
GOV_COMPANY_TYPE,
|
||||
GOV_PROFILE_STATUS,
|
||||
} from "./data/gov-companies.data";
|
||||
|
||||
/**
|
||||
* Idempotently seeds the Ethiopian government entities (with importer + exporter
|
||||
* profiles) that government bookings bill to. Safe to re-run — rows are keyed by
|
||||
* the fixed IDs in {@link GOV_COMPANIES}; existing rows are left untouched.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GovCompaniesSeeder {
|
||||
private readonly logger = new Logger(GovCompaniesSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const companyRepo = manager.getRepository(Company);
|
||||
const profileRepo = manager.getRepository(CompanyProfile);
|
||||
|
||||
for (const gov of GOV_COMPANIES) {
|
||||
const existing = await companyRepo.findOne({ where: { id: gov.id } });
|
||||
if (!existing) {
|
||||
await companyRepo.save(
|
||||
companyRepo.create({
|
||||
id: gov.id,
|
||||
name: gov.name,
|
||||
type: GOV_COMPANY_TYPE,
|
||||
kind: GOV_COMPANY_KIND,
|
||||
status: GOV_COMPANY_STATUS,
|
||||
tin: gov.tin,
|
||||
country: "Ethiopia",
|
||||
email: gov.email,
|
||||
phone: gov.phone,
|
||||
}),
|
||||
);
|
||||
this.logger.log(`Created government company: ${gov.name}`);
|
||||
}
|
||||
|
||||
for (const profile of gov.profiles) {
|
||||
const existingProfile = await profileRepo.findOne({
|
||||
where: { id: profile.id },
|
||||
});
|
||||
if (existingProfile) continue;
|
||||
await profileRepo.save(
|
||||
profileRepo.create({
|
||||
id: profile.id,
|
||||
companyId: gov.id,
|
||||
type: profile.type,
|
||||
reference: profile.reference,
|
||||
status: GOV_PROFILE_STATUS,
|
||||
}),
|
||||
);
|
||||
this.logger.log(
|
||||
`Created ${profile.type} profile ${profile.reference} for ${gov.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.log("Government companies seeded.");
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
"noEmit": false,
|
||||
"incremental": true,
|
||||
"tsBuildInfoFile": "./.tsbuildinfo",
|
||||
"preserveWatchOutput": true,
|
||||
"module": "node16",
|
||||
"moduleResolution": "node16"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5183",
|
||||
"dev": "vite --port 5183 --clearScreen false",
|
||||
"prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5183",
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
|
||||
export interface ContainerAllocationRow {
|
||||
id: string;
|
||||
type: string;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
export interface ContainerAllocationTableProps {
|
||||
bookingId: string;
|
||||
containers: ContainerAllocationRow[];
|
||||
onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual container-to-vehicle allocation table for freight bookings.
|
||||
* Displays containers with type/qty, vehicle dropdown per row, and save action.
|
||||
*/
|
||||
export function ContainerAllocationTable({
|
||||
bookingId,
|
||||
containers,
|
||||
onSave,
|
||||
}: ContainerAllocationTableProps) {
|
||||
const [allocations, setAllocations] = useState<Record<string, string | null>>(
|
||||
() => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
|
||||
);
|
||||
|
||||
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
|
||||
queryKey: ["vehicles", "active"],
|
||||
queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }),
|
||||
});
|
||||
|
||||
const vehicleOptions = useMemo(
|
||||
() =>
|
||||
vehicles.map((v) => ({
|
||||
value: v.id,
|
||||
label: `${v.plateNumber} (${v.vehicleType})`,
|
||||
description: `${v.model} · ${v.manufacturer}`,
|
||||
})),
|
||||
[vehicles],
|
||||
);
|
||||
|
||||
const saveAllocation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const mappings = containers
|
||||
.filter((c) => allocations[c.id])
|
||||
.map((c) => ({
|
||||
containerId: c.id,
|
||||
vehicleId: allocations[c.id]!,
|
||||
}));
|
||||
|
||||
if (mappings.length === 0) {
|
||||
throw new Error("No containers allocated to vehicles");
|
||||
}
|
||||
|
||||
await onSave(mappings);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Container allocations saved");
|
||||
setAllocations(
|
||||
containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save allocations",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const allocatedCount = Object.values(allocations).filter(Boolean).length;
|
||||
const allAllocated = allocatedCount === containers.length;
|
||||
|
||||
if (vehiclesLoading) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" p="xl">
|
||||
<Loader size="sm" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{vehicles.length === 0 && (
|
||||
<Alert icon={<AlertCircle size={16} />} color="yellow">
|
||||
No active vehicles available. Add vehicles before allocating containers.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Container ID</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Qty</Table.Th>
|
||||
<Table.Th>Assigned Vehicle</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{containers.map((container) => (
|
||||
<Table.Tr key={container.id}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{container.id}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{container.type}</Table.Td>
|
||||
<Table.Td>{container.qty}</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder="Select vehicle"
|
||||
data={vehicleOptions}
|
||||
value={allocations[container.id] ?? null}
|
||||
onChange={(value) =>
|
||||
setAllocations((prev) => ({
|
||||
...prev,
|
||||
[container.id]: value,
|
||||
}))
|
||||
}
|
||||
searchable
|
||||
clearable
|
||||
disabled={vehicles.length === 0}
|
||||
style={{ minWidth: 200 }}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{allocatedCount} of {containers.length} containers allocated
|
||||
</Text>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={saveAllocation.isPending}
|
||||
disabled={allocatedCount === 0 || vehicles.length === 0}
|
||||
onClick={() => saveAllocation.mutate()}
|
||||
>
|
||||
Save Allocations
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
|
||||
export interface ContainerAllocationRow {
|
||||
id: string;
|
||||
type: string;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
export interface FirstMileContainerAllocationTableProps {
|
||||
firstMileId: string;
|
||||
containers: ContainerAllocationRow[];
|
||||
onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual container-to-vehicle allocation table for first-mile pickups.
|
||||
* Displays containers with type/qty, vehicle dropdown per row, and save action.
|
||||
*/
|
||||
export function FirstMileContainerAllocationTable({
|
||||
firstMileId,
|
||||
containers,
|
||||
onSave,
|
||||
}: FirstMileContainerAllocationTableProps) {
|
||||
const [allocations, setAllocations] = useState<Record<string, string | null>>(
|
||||
() => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
|
||||
);
|
||||
|
||||
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
|
||||
queryKey: ["vehicles", "active"],
|
||||
queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }),
|
||||
});
|
||||
|
||||
const vehicleOptions = useMemo(
|
||||
() =>
|
||||
vehicles.map((v) => ({
|
||||
value: v.id,
|
||||
label: `${v.plateNumber} (${v.vehicleType})`,
|
||||
description: `${v.model} · ${v.manufacturer}`,
|
||||
})),
|
||||
[vehicles],
|
||||
);
|
||||
|
||||
const saveAllocation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const mappings = containers
|
||||
.filter((c) => allocations[c.id])
|
||||
.map((c) => ({
|
||||
containerId: c.id,
|
||||
vehicleId: allocations[c.id]!,
|
||||
}));
|
||||
|
||||
if (mappings.length === 0) {
|
||||
throw new Error("No containers allocated to vehicles");
|
||||
}
|
||||
|
||||
await onSave(mappings);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Container allocations saved");
|
||||
setAllocations(
|
||||
containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save allocations",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const allocatedCount = Object.values(allocations).filter(Boolean).length;
|
||||
const allAllocated = allocatedCount === containers.length;
|
||||
|
||||
if (vehiclesLoading) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" p="xl">
|
||||
<Loader size="sm" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{vehicles.length === 0 && (
|
||||
<Alert icon={<AlertCircle size={16} />} color="yellow">
|
||||
No active vehicles available. Add vehicles before allocating containers.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Container ID</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Qty</Table.Th>
|
||||
<Table.Th>Assigned Vehicle</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{containers.map((container) => (
|
||||
<Table.Tr key={container.id}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{container.id}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{container.type}</Table.Td>
|
||||
<Table.Td>{container.qty}</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder="Select vehicle"
|
||||
data={vehicleOptions}
|
||||
value={allocations[container.id] ?? null}
|
||||
onChange={(value) =>
|
||||
setAllocations((prev) => ({
|
||||
...prev,
|
||||
[container.id]: value,
|
||||
}))
|
||||
}
|
||||
searchable
|
||||
clearable
|
||||
disabled={vehicles.length === 0}
|
||||
style={{ minWidth: 200 }}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{allocatedCount} of {containers.length} containers allocated
|
||||
</Text>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={saveAllocation.isPending}
|
||||
disabled={allocatedCount === 0 || vehicles.length === 0}
|
||||
onClick={() => saveAllocation.mutate()}
|
||||
>
|
||||
Save Allocations
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
|
||||
export interface LastMileContainerRow {
|
||||
id: string;
|
||||
type: string;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
export interface LastMileContainerAllocationTableProps {
|
||||
lastMileId: string;
|
||||
containers: LastMileContainerRow[];
|
||||
onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual container-to-vehicle allocation table for last-mile deliveries.
|
||||
* Displays containers with type/qty, vehicle dropdown per row, and save action.
|
||||
*/
|
||||
export function LastMileContainerAllocationTable({
|
||||
lastMileId,
|
||||
containers,
|
||||
onSave,
|
||||
}: LastMileContainerAllocationTableProps) {
|
||||
const [allocations, setAllocations] = useState<Record<string, string | null>>(
|
||||
() => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
|
||||
);
|
||||
|
||||
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
|
||||
queryKey: ["vehicles", "active"],
|
||||
queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }),
|
||||
});
|
||||
|
||||
const vehicleOptions = useMemo(
|
||||
() =>
|
||||
vehicles.map((v) => ({
|
||||
value: v.id,
|
||||
label: `${v.plateNumber} (${v.vehicleType})`,
|
||||
description: `${v.model} · ${v.manufacturer}`,
|
||||
})),
|
||||
[vehicles],
|
||||
);
|
||||
|
||||
const saveAllocation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const mappings = containers
|
||||
.filter((c) => allocations[c.id])
|
||||
.map((c) => ({
|
||||
containerId: c.id,
|
||||
vehicleId: allocations[c.id]!,
|
||||
}));
|
||||
|
||||
if (mappings.length === 0) {
|
||||
throw new Error("No containers allocated to vehicles");
|
||||
}
|
||||
|
||||
await onSave(mappings);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Container allocations saved");
|
||||
setAllocations(
|
||||
containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save allocations",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const allocatedCount = Object.values(allocations).filter(Boolean).length;
|
||||
const allAllocated = allocatedCount === containers.length;
|
||||
|
||||
if (vehiclesLoading) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" p="xl">
|
||||
<Loader size="sm" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{vehicles.length === 0 && (
|
||||
<Alert icon={<AlertCircle size={16} />} color="yellow">
|
||||
No active vehicles available. Add vehicles before allocating containers.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Container ID</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Qty</Table.Th>
|
||||
<Table.Th>Assigned Vehicle</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{containers.map((container) => (
|
||||
<Table.Tr key={container.id}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{container.id}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{container.type}</Table.Td>
|
||||
<Table.Td>{container.qty}</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder="Select vehicle"
|
||||
data={vehicleOptions}
|
||||
value={allocations[container.id] ?? null}
|
||||
onChange={(value) =>
|
||||
setAllocations((prev) => ({
|
||||
...prev,
|
||||
[container.id]: value,
|
||||
}))
|
||||
}
|
||||
searchable
|
||||
clearable
|
||||
disabled={vehicles.length === 0}
|
||||
style={{ minWidth: 200 }}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{allocatedCount} of {containers.length} containers allocated
|
||||
</Text>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={saveAllocation.isPending}
|
||||
disabled={allocatedCount === 0 || vehicles.length === 0}
|
||||
onClick={() => saveAllocation.mutate()}
|
||||
>
|
||||
Save Allocations
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -23,11 +23,20 @@ function DetailRow({ label, value }: { label: string; value: React.ReactNode })
|
||||
);
|
||||
}
|
||||
|
||||
const noteLineValue = (notes: string | null | undefined, label: string) => {
|
||||
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
||||
return match?.[1]?.trim() ?? '';
|
||||
};
|
||||
|
||||
export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) {
|
||||
const bookingReference = item?.booking?.reference ?? '-';
|
||||
const handoverReference = item?.handoverDocumentReference ?? noteLineValue(item?.notes, 'Handover Reference');
|
||||
const handoverDate = item?.handoverDocumentDate ?? noteLineValue(item?.notes, 'Generated At');
|
||||
const inventorySummary = [
|
||||
item?.status?.replace(/_/g, ' '),
|
||||
item?.grnNumber ? `GRN ${item.grnNumber}` : null,
|
||||
item?.releaseOrderReference ? `Release ${item.releaseOrderReference}` : null,
|
||||
handoverReference ? `Handover ${handoverReference}` : null,
|
||||
item?.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -61,11 +70,13 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
|
||||
<Divider label="Booking & item" labelPosition="left" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<DetailRow label="Booking reference" value={bookingReference} />
|
||||
<DetailRow label="GRN" value={item.grnNumber ?? '-'} />
|
||||
<DetailRow label="Booking status" value={item.booking?.status ?? '-'} />
|
||||
<DetailRow label="Payment status" value={item.booking?.paymentStatus ?? '-'} />
|
||||
<DetailRow label="Trade direction" value={item.booking?.tradeDirection ?? '-'} />
|
||||
<DetailRow label="Inventory status" value={item.status.replace(/_/g, ' ')} />
|
||||
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
|
||||
<DetailRow label="Handover reference" value={handoverReference || '-'} />
|
||||
<DetailRow label="Quantity" value={formatNumber(item.quantity)} />
|
||||
<DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} />
|
||||
<DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} />
|
||||
@@ -83,6 +94,7 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
|
||||
<DetailRow label="Dispatched" value={formatDate(item.dispatchedAt)} />
|
||||
<DetailRow label="Ready for pickup" value={formatDate(item.readyForPickupAt)} />
|
||||
<DetailRow label="Released" value={formatDate(item.releaseDate)} />
|
||||
<DetailRow label="Handover generated" value={formatDate(handoverDate)} />
|
||||
<DetailRow label="Delivered" value={formatDate(item.deliveredAt)} />
|
||||
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import { Fragment, useEffect, useMemo, useState, type MouseEvent } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
@@ -79,6 +79,45 @@ interface ReceiveInventoryModalProps {
|
||||
onReceived?: () => void;
|
||||
}
|
||||
|
||||
function GrnDocumentButton({ inventoryId, grnNumber }: { inventoryId: string; grnNumber?: string | null }) {
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const openDocument = async (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
if (!grnNumber) {
|
||||
toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const response = await warehouseService.downloadGrnDocument(inventoryId);
|
||||
const opened = openPdfBlob(response.data, `grn-${grnNumber}.pdf`, pdfWindow);
|
||||
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="teal"
|
||||
leftSection={<FileText size={12} />}
|
||||
disabled={!grnNumber}
|
||||
loading={loading}
|
||||
onClick={openDocument}
|
||||
>
|
||||
{grnNumber ?? 'No GRN'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface Location {
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
@@ -620,11 +659,13 @@ function EligibleTab({
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { data: allRows = [], isLoading } = useQuery(
|
||||
api.warehouses.eligibleBookings.queryOptions({ enabled }),
|
||||
api.warehouses.eligibleBookings.queryOptions({
|
||||
input: { direction },
|
||||
enabled,
|
||||
}),
|
||||
);
|
||||
const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]);
|
||||
const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions());
|
||||
const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions());
|
||||
const requestFirstMile = useMutation({
|
||||
mutationFn: (reference: string) => firstMileService.accept(reference),
|
||||
onSuccess: () => {
|
||||
@@ -782,10 +823,24 @@ function EligibleTab({
|
||||
return;
|
||||
}
|
||||
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
|
||||
const totalContainerQuantity = selectedRows.reduce(
|
||||
(sum, row) => sum + Number(row.containerQuantity ?? 0),
|
||||
0,
|
||||
);
|
||||
const normalizedForm =
|
||||
nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0
|
||||
? {
|
||||
...form,
|
||||
unitCount: totalContainerQuantity,
|
||||
}
|
||||
: form;
|
||||
setPendingReceiveIds(filteredIds);
|
||||
setReceivedAt(new Date().toISOString());
|
||||
setTruckForm(form);
|
||||
setLockedTruckFields(lockedFields);
|
||||
setTruckForm(normalizedForm);
|
||||
setLockedTruckFields({
|
||||
...lockedFields,
|
||||
unitCount: nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0,
|
||||
});
|
||||
setPackagingFreightType(nextPackagingFreightType);
|
||||
setTruckOpen(true);
|
||||
};
|
||||
@@ -798,18 +853,6 @@ function EligibleTab({
|
||||
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
|
||||
};
|
||||
|
||||
const loadPassedExport = async () => {
|
||||
try {
|
||||
const r = await loadPassed.mutateAsync(undefined);
|
||||
toast({
|
||||
title: `${r.loadedCount} loaded`,
|
||||
description: r.skippedCount ? `${r.skippedCount} skipped — inspection not passed` : undefined,
|
||||
});
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm" mt="sm">
|
||||
@@ -828,18 +871,6 @@ function EligibleTab({
|
||||
Selected: <b>{selected.size}</b> / {statusFilteredRows.length} eligible
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{direction === 'EXPORT' && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<Truck size={14} />}
|
||||
loading={loadPassed.isPending}
|
||||
onClick={loadPassedExport}
|
||||
>
|
||||
Load Passed Export Items
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color={direction === 'EXPORT' ? 'edr-green' : undefined}
|
||||
@@ -849,7 +880,7 @@ function EligibleTab({
|
||||
loading={bulkReceive.isPending}
|
||||
onClick={() => openTruckReceive(selected.size > 0 ? [...selected] : selectableRows.map((r) => r.id))}
|
||||
>
|
||||
{direction === 'EXPORT' ? 'Receive to Warehouse' : 'Receive All to Warehouse'}
|
||||
{direction === 'EXPORT' ? 'Receive All for Loading' : 'Receive All to Warehouse'}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
@@ -858,7 +889,7 @@ function EligibleTab({
|
||||
loading={bulkReceive.isPending}
|
||||
onClick={() => openTruckReceive([...selected])}
|
||||
>
|
||||
Receive Selected
|
||||
{direction === 'EXPORT' ? 'Receive Selected for Loading' : 'Receive Selected'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -1000,7 +1031,7 @@ function EligibleTab({
|
||||
loading={bulkReceive.isPending}
|
||||
onClick={() => openTruckReceive([r.id])}
|
||||
>
|
||||
{canReceive ? 'Receive to Warehouse' : 'Await First Mile'}
|
||||
{canReceive ? (direction === 'EXPORT' ? 'Receive for Loading' : 'Receive to Warehouse') : 'Await First Mile'}
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
@@ -1015,7 +1046,7 @@ function EligibleTab({
|
||||
<Modal
|
||||
opened={truckOpen}
|
||||
onClose={() => setTruckOpen(false)}
|
||||
title="Receive to Warehouse"
|
||||
title={direction === 'EXPORT' ? 'Receive for Loading' : 'Receive to Warehouse'}
|
||||
centered
|
||||
size="lg"
|
||||
>
|
||||
@@ -1175,6 +1206,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
/>
|
||||
</Table.Th>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>GRN</Table.Th>
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
<Table.Th>Customer Name</Table.Th>
|
||||
@@ -1201,7 +1233,13 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
||||
@@ -1322,6 +1360,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
/>
|
||||
</Table.Th>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>GRN</Table.Th>
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
<Table.Th>Customer Name</Table.Th>
|
||||
@@ -1344,7 +1383,13 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
||||
@@ -1492,6 +1537,7 @@ function LoadedExportTab({
|
||||
</Table.Th>
|
||||
)}
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>GRN</Table.Th>
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
<Table.Th>Customer Name</Table.Th>
|
||||
@@ -1516,7 +1562,13 @@ function LoadedExportTab({
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
||||
@@ -1866,12 +1918,15 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
bookingId: row.bookingId,
|
||||
quantity: 1,
|
||||
weight: Number(row.weight) || 0,
|
||||
grnNumber: row.grnNumber,
|
||||
status: row.currentStatus,
|
||||
arrivedAt: row.arrivalTime,
|
||||
unloadedAt: row.arrivalTime,
|
||||
inspectionStatus: row.inspectionStatus,
|
||||
releaseDate: row.releaseDate,
|
||||
releaseOrderReference: row.releaseOrderReference,
|
||||
handoverDocumentReference: row.handoverDocumentReference,
|
||||
handoverDocumentDate: row.handoverDocumentDate,
|
||||
deliveredAt: row.deliveredAt,
|
||||
booking: row.bookingId
|
||||
? {
|
||||
@@ -1902,6 +1957,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
try {
|
||||
const response = await warehouseService.downloadHandoverDocument(row.id);
|
||||
openPdfBlob(response.data, `handover-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
|
||||
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) });
|
||||
@@ -1910,6 +1966,20 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
}
|
||||
};
|
||||
|
||||
const openReleaseDocument = async (row: ImportUnloadedItem) => {
|
||||
setBusyId(row.id);
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const response = await warehouseService.downloadReleaseDocument(row.id);
|
||||
openPdfBlob(response.data, `release-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'Exit paper failed', description: extractErrorMessage(error) });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm" mt="sm">
|
||||
<Group justify="space-between">
|
||||
@@ -1959,6 +2029,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
</Table.Th>
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>GRN</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
<Table.Th>Customer Name</Table.Th>
|
||||
<Table.Th>Arrival Time</Table.Th>
|
||||
@@ -1987,7 +2058,13 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'}</Text>
|
||||
@@ -2049,7 +2126,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
color="yellow"
|
||||
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||
>
|
||||
Truck Arrival
|
||||
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -2064,6 +2141,18 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
Dispatch
|
||||
</Button>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<FileText size={14} />}
|
||||
loading={busyId === r.id}
|
||||
onClick={() => openReleaseDocument(r)}
|
||||
>
|
||||
Exit Paper
|
||||
</Button>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
@@ -2082,7 +2171,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
leftSection={<FileText size={14} />}
|
||||
onClick={() => openHandoverDocument(r)}
|
||||
>
|
||||
Handover
|
||||
{r.handoverDocumentReference ? 'View Handover' : 'Handover'}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
||||
@@ -2398,7 +2487,12 @@ function ExportWarehouseTabs({
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<ExportWarehouseTab>('receive-queue');
|
||||
const { data: eligibleRows = [] } = useQuery(api.warehouses.eligibleBookings.queryOptions({ enabled }));
|
||||
const { data: eligibleRows = [] } = useQuery(
|
||||
api.warehouses.eligibleBookings.queryOptions({
|
||||
input: { direction: 'EXPORT' },
|
||||
enabled,
|
||||
}),
|
||||
);
|
||||
const { data: receivedRows = [] } = useQuery(api.warehouses.receivedExport.queryOptions({ enabled }));
|
||||
const { data: readyRows = [] } = useQuery(api.warehouses.readyToLoadExport.queryOptions({ enabled }));
|
||||
const { data: loadedRows = [] } = useQuery(api.warehouses.loadedExport.queryOptions({ enabled }));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, NumberInput, Select, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Info, Scale } from 'lucide-react';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
@@ -46,6 +46,77 @@ const toIsoDateTime = (value: string) => {
|
||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
||||
};
|
||||
|
||||
const toLocalDateTimeInput = (value?: string | null) => {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const offsetMs = date.getTimezoneOffset() * 60_000;
|
||||
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
const generateReleaseReference = (item: WarehouseInventoryItem | null) => {
|
||||
const bookingReference = item?.booking?.reference;
|
||||
if (bookingReference) return `REL-${bookingReference.replace(/^BK-?/i, '')}`;
|
||||
if (item?.bookingId) return `REL-${item.bookingId.replace(/-/g, '').slice(0, 8).toUpperCase()}`;
|
||||
return '';
|
||||
};
|
||||
|
||||
const lineValue = (notes: string | null | undefined, label: string) => {
|
||||
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
||||
return match?.[1]?.trim() ?? '';
|
||||
};
|
||||
|
||||
const lineNumber = (notes: string | null | undefined, label: string): number | '' => {
|
||||
const value = lineValue(notes, label).replace(/\s*kg$/i, '');
|
||||
if (!value) return '';
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : '';
|
||||
};
|
||||
|
||||
const splitContainerNumbers = (value: string | null | undefined) =>
|
||||
(value ?? '')
|
||||
.split(/[,;\n]+/)
|
||||
.map((number) => number.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
|
||||
(item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? '';
|
||||
|
||||
const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => {
|
||||
const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
|
||||
?.booking?.freightType;
|
||||
return Boolean(item?.containerId || containerCount > 0 || freightType === 'CONTAINER');
|
||||
};
|
||||
|
||||
const initialContainerNumbers = (item: WarehouseInventoryItem | null, savedContainerNumber: string) => {
|
||||
const savedNumbers = splitContainerNumbers(savedContainerNumber);
|
||||
const itemNumbers = splitContainerNumbers(getItemContainerNumber(item));
|
||||
const sourceNumbers = savedNumbers.length ? savedNumbers : itemNumbers;
|
||||
const quantityCount = isContainerInventory(item, sourceNumbers.length) ? Number(item?.quantity ?? 0) : 0;
|
||||
const expectedCount = Math.max(1, sourceNumbers.length, quantityCount);
|
||||
return Array.from({ length: expectedCount }, (_, index) => sourceNumbers[index] ?? '');
|
||||
};
|
||||
|
||||
const parseInspectionNote = (notes: string | null | undefined) => {
|
||||
const marker = '[Exit Inspection]';
|
||||
const index = notes?.lastIndexOf(marker) ?? -1;
|
||||
const note = index >= 0 ? notes?.slice(index + marker.length) : notes;
|
||||
return {
|
||||
truckPlateNumber: lineValue(note, 'Truck Plate'),
|
||||
trailerPlateNumber: lineValue(note, 'Trailer Plate'),
|
||||
driverName: lineValue(note, 'Driver'),
|
||||
driverLicense: lineValue(note, 'Driver License'),
|
||||
driverPhone: lineValue(note, 'Driver Phone'),
|
||||
truckType: lineValue(note, 'Truck Type'),
|
||||
containerNumber: lineValue(note, 'Container Number'),
|
||||
gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')),
|
||||
tareWeight: lineNumber(note, 'Tare Weight'),
|
||||
grossWeight: lineNumber(note, 'Gross Weight'),
|
||||
netWeight: lineNumber(note, 'Net Weight'),
|
||||
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
|
||||
};
|
||||
};
|
||||
|
||||
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
|
||||
const { toast } = useToast();
|
||||
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
||||
@@ -56,7 +127,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
const [driverLicense, setDriverLicense] = useState('');
|
||||
const [driverPhone, setDriverPhone] = useState('');
|
||||
const [truckType, setTruckType] = useState('');
|
||||
const [containerNumber, setContainerNumber] = useState('');
|
||||
const [containerNumbers, setContainerNumbers] = useState<string[]>(['']);
|
||||
const [gateInTime, setGateInTime] = useState('');
|
||||
const [tareWeight, setTareWeight] = useState<number | ''>('');
|
||||
const [grossWeight, setGrossWeight] = useState<number | ''>('');
|
||||
@@ -66,26 +137,32 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setReference(item?.releaseOrderReference ?? '');
|
||||
setTruckPlateNumber('');
|
||||
setTrailerPlateNumber('');
|
||||
setDriverName('');
|
||||
setDriverLicense('');
|
||||
setDriverPhone('');
|
||||
setTruckType('');
|
||||
setContainerNumber('');
|
||||
setGateInTime('');
|
||||
setTareWeight('');
|
||||
setGrossWeight('');
|
||||
setNetWeight(item?.weight != null ? Number(item.weight) : '');
|
||||
setGateOutTime('');
|
||||
const inspection = parseInspectionNote(item?.notes);
|
||||
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
|
||||
setTruckPlateNumber(inspection.truckPlateNumber);
|
||||
setTrailerPlateNumber(inspection.trailerPlateNumber);
|
||||
setDriverName(inspection.driverName);
|
||||
setDriverLicense(inspection.driverLicense);
|
||||
setDriverPhone(inspection.driverPhone);
|
||||
setTruckType(inspection.truckType);
|
||||
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber));
|
||||
setGateInTime(inspection.gateInTime);
|
||||
setTareWeight(inspection.tareWeight);
|
||||
setGrossWeight(inspection.grossWeight);
|
||||
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
|
||||
setGateOutTime(inspection.gateOutTime);
|
||||
}
|
||||
}, [opened, item]);
|
||||
|
||||
const savedInspection = parseInspectionNote(item?.notes);
|
||||
const isExitStep = savedInspection.tareWeight !== '';
|
||||
const isEntranceLocked = isExitStep;
|
||||
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
|
||||
const computedNetWeight =
|
||||
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
||||
const weightMismatch =
|
||||
computedNetWeight != null && netWeight !== '' && Math.abs(Number(netWeight) - computedNetWeight) > 0.001;
|
||||
computedNetWeight != null && systemNetWeight !== '' && Math.abs(Number(systemNetWeight) - computedNetWeight) > 0.001;
|
||||
const title = isExitStep ? 'Customer truck leaving and exit weighing' : 'Customer truck arrival weighing';
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item) return;
|
||||
@@ -93,11 +170,19 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
|
||||
return;
|
||||
}
|
||||
if (tareWeight === '' || grossWeight === '') {
|
||||
toast({ variant: 'destructive', title: 'Tare and gross weight are required' });
|
||||
if (!gateInTime || tareWeight === '') {
|
||||
toast({ variant: 'destructive', title: 'Gate in time and tare weight are required' });
|
||||
return;
|
||||
}
|
||||
if (weightMismatch) {
|
||||
if (isExitStep && (!gateOutTime || grossWeight === '')) {
|
||||
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
|
||||
return;
|
||||
}
|
||||
if (isExitStep && systemNetWeight === '') {
|
||||
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
|
||||
return;
|
||||
}
|
||||
if (isExitStep && weightMismatch) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Weight mismatch',
|
||||
@@ -105,7 +190,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
});
|
||||
return;
|
||||
}
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
const pdfWindow = isExitStep ? window.open('', '_blank') : null;
|
||||
try {
|
||||
const released = await releaseMutation.mutateAsync({
|
||||
id: item.id,
|
||||
@@ -119,14 +204,22 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
driverLicense: driverLicense.trim() || undefined,
|
||||
driverPhone: driverPhone.trim() || undefined,
|
||||
truckType: truckType.trim() || undefined,
|
||||
containerNumber: containerNumber.trim() || undefined,
|
||||
containerNumber: containerNumbers.map((number) => number.trim()).filter(Boolean).join(', ') || undefined,
|
||||
gateInTime: toIsoDateTime(gateInTime),
|
||||
tareWeight: Number(tareWeight),
|
||||
grossWeight: Number(grossWeight),
|
||||
netWeight: netWeight === '' ? computedNetWeight ?? undefined : Number(netWeight),
|
||||
gateOutTime: toIsoDateTime(gateOutTime),
|
||||
grossWeight: grossWeight === '' ? undefined : Number(grossWeight),
|
||||
netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
|
||||
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
|
||||
},
|
||||
});
|
||||
if (!isExitStep) {
|
||||
toast({
|
||||
title: 'Truck arrival saved',
|
||||
description: `${released.releaseOrderReference ?? reference} is ready for exit weighing.`,
|
||||
});
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setDownloading(true);
|
||||
const response = await warehouseService.downloadReleaseDocument(item.id);
|
||||
const blob = response.data;
|
||||
@@ -148,20 +241,27 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Customer truck arrival and exit weighing" centered size="lg">
|
||||
<Modal opened={opened} onClose={onClose} title={title} centered size="lg">
|
||||
<Stack gap="md">
|
||||
<Alert icon={<Info size={16} />} color="orange" variant="light">
|
||||
<Text size="sm">
|
||||
Register the customer truck and driver at arrival, record tare weight, then record gross
|
||||
weight at exit after loading. Gate clearance is blocked when recorded net weight does not
|
||||
equal gross weight minus tare weight.
|
||||
</Text>
|
||||
{isExitStep ? (
|
||||
<Text size="sm">
|
||||
Record the truck leaving time and gross weight. The system recorded net weight is locked,
|
||||
and the exit paper is generated only when it equals gross weight minus tare weight.
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="sm">
|
||||
Register the customer truck and driver at arrival, then save gate in time and tare weight.
|
||||
Reopen this form when the truck is leaving to complete the exit weighing.
|
||||
</Text>
|
||||
)}
|
||||
</Alert>
|
||||
<TextInput
|
||||
label="Release document reference"
|
||||
placeholder="e.g. REL-2026-001"
|
||||
value={reference}
|
||||
onChange={(e) => setReference(e.currentTarget.value)}
|
||||
readOnly={isEntranceLocked}
|
||||
/>
|
||||
<Select
|
||||
label="Registered first / last-mile truck"
|
||||
@@ -169,6 +269,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
searchable
|
||||
clearable
|
||||
data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
|
||||
disabled={isEntranceLocked}
|
||||
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||||
onChange={(value) => {
|
||||
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value);
|
||||
@@ -182,35 +283,53 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
required
|
||||
value={truckPlateNumber}
|
||||
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
|
||||
readOnly={isEntranceLocked}
|
||||
/>
|
||||
<TextInput
|
||||
label="Trailer plate number"
|
||||
value={trailerPlateNumber}
|
||||
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
|
||||
readOnly={isEntranceLocked}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} />
|
||||
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} />
|
||||
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} />
|
||||
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} />
|
||||
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Container number" value={containerNumber} onChange={(e) => setContainerNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} />
|
||||
<Stack gap={6}>
|
||||
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
|
||||
{containerNumbers.map((containerNumber, index) => (
|
||||
<TextInput
|
||||
key={index}
|
||||
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
|
||||
value={containerNumber}
|
||||
onChange={(e) =>
|
||||
setContainerNumbers((numbers) =>
|
||||
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
|
||||
)
|
||||
}
|
||||
readOnly={isEntranceLocked}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} />
|
||||
<NumberInput label="Gross weight (kg)" required min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} />
|
||||
<NumberInput label="Recorded net weight (kg)" min={0} value={netWeight} onChange={(v) => setNetWeight(v === '' ? '' : Number(v))} />
|
||||
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
|
||||
<NumberInput label="Gross weight (kg)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
|
||||
<NumberInput label="Recorded net weight (system kg)" min={0} value={systemNetWeight} readOnly />
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
||||
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} kg`}</b>
|
||||
</Text>
|
||||
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} />
|
||||
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep} />
|
||||
</Group>
|
||||
{weightMismatch && (
|
||||
<Alert icon={<Scale size={16} />} color="red" variant="light">
|
||||
@@ -225,7 +344,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
|
||||
Save Truck Arrival & View Exit Paper
|
||||
{isExitStep ? 'Save Truck Leaving & View Exit Paper' : 'Save Truck Arrival'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useState, type MouseEvent } from 'react';
|
||||
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
|
||||
import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import {
|
||||
getNextInventoryAction,
|
||||
type InventoryAction,
|
||||
type WarehouseInventoryItem,
|
||||
} from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { formatDate, formatNumber, humanizeEnum } from './options';
|
||||
import { extractErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
interface WarehouseInventoryTableProps {
|
||||
items: WarehouseInventoryItem[];
|
||||
@@ -46,6 +50,56 @@ const actionColor: Record<InventoryAction, string> = {
|
||||
deliver: 'green',
|
||||
};
|
||||
|
||||
const releaseActionLabel = (item: WarehouseInventoryItem) =>
|
||||
item.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival';
|
||||
|
||||
const noteLineValue = (notes: string | null | undefined, label: string) => {
|
||||
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
||||
return match?.[1]?.trim() ?? '';
|
||||
};
|
||||
|
||||
const handoverDocumentReference = (item: WarehouseInventoryItem) =>
|
||||
item.handoverDocumentReference ?? noteLineValue(item.notes, 'Handover Reference');
|
||||
|
||||
function GrnDocumentButton({ item }: { item: WarehouseInventoryItem }) {
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const openDocument = async (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
if (!item.grnNumber) {
|
||||
toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const response = await warehouseService.downloadGrnDocument(item.id);
|
||||
const opened = openPdfBlob(response.data, `grn-${item.grnNumber}.pdf`, pdfWindow);
|
||||
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="teal"
|
||||
leftSection={<FileText size={12} />}
|
||||
disabled={!item.grnNumber}
|
||||
loading={loading}
|
||||
onClick={openDocument}
|
||||
>
|
||||
{item.grnNumber ?? 'No GRN'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function WarehouseInventoryTable({
|
||||
items,
|
||||
busyId,
|
||||
@@ -90,6 +144,7 @@ export function WarehouseInventoryTable({
|
||||
</Table.Th>
|
||||
)}
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>GRN</Table.Th>
|
||||
<Table.Th>Facility</Table.Th>
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
@@ -111,6 +166,7 @@ export function WarehouseInventoryTable({
|
||||
item.inspectionStatus === 'PASSED' &&
|
||||
Boolean(item.bookingId) &&
|
||||
(!item.booking?.tradeDirection || item.booking.tradeDirection === 'IMPORT');
|
||||
const handoverReference = handoverDocumentReference(item);
|
||||
|
||||
return (
|
||||
<Table.Tr key={item.id}>
|
||||
@@ -136,6 +192,9 @@ export function WarehouseInventoryTable({
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<GrnDocumentButton item={item} />
|
||||
</Table.Td>
|
||||
<Table.Td>{item.warehouse?.facility?.name ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.warehouse?.code ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.yard?.code ?? '-'}</Table.Td>
|
||||
@@ -170,7 +229,7 @@ export function WarehouseInventoryTable({
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, nextAction)}
|
||||
>
|
||||
{nextAction === 'release' ? 'Truck Arrival' : humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||
{nextAction === 'release' ? releaseActionLabel(item) : humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||
</Button>
|
||||
)}
|
||||
{item.status === 'READY_FOR_PICKUP' && (
|
||||
@@ -224,7 +283,10 @@ export function WarehouseInventoryTable({
|
||||
</Tooltip>
|
||||
)}
|
||||
{onHandoverDocument && canGenerateHandover && (
|
||||
<Tooltip label="Generate customer handover document" withArrow>
|
||||
<Tooltip
|
||||
label={handoverReference ? `View handover document ${handoverReference}` : 'Generate customer handover document'}
|
||||
withArrow
|
||||
>
|
||||
<ActionIcon variant="subtle" color="teal" onClick={() => onHandoverDocument(item)}>
|
||||
<FileText size={16} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -389,6 +389,7 @@ export const URL_CONSTANTS = {
|
||||
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
|
||||
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
|
||||
RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`,
|
||||
GRN_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/grn-document`,
|
||||
HANDOVER_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/handover-document`,
|
||||
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
|
||||
// Receive (Import/Export bulk)
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
|
||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
//export const API_BASE_URL = 'http://localhost:3001';
|
||||
|
||||
|
||||
|
||||
// export const API_BASE_URL = 'http://localhost:3001';
|
||||
|
||||
|
||||
/**
|
||||
* URL that streams an uploaded file through the API by its UUID. Routes the
|
||||
* bytes through `GET /api/files/:id` (served from MinIO with backend
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Container, Grid, Stack } from "@mantine/core";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
BookingApprovalCard,
|
||||
@@ -16,10 +18,26 @@ import {
|
||||
type BookingDetailView,
|
||||
} from "@/components/bookings/detail";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import ContainerAllocationTable from "@/components/ContainerAllocationTable";
|
||||
import { api } from "@/services/api";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
|
||||
const BookingDetailPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const allocateMutation = useMutation({
|
||||
mutationFn: (data: any) =>
|
||||
api.post(`/bookings/${id}/allocate-containers`, data),
|
||||
onSuccess: () => {
|
||||
toast.success("Containers allocated");
|
||||
qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.byId(id ?? "") });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to allocate containers");
|
||||
},
|
||||
});
|
||||
|
||||
// Mock data - replace with actual API call
|
||||
const booking: BookingDetailView = {
|
||||
@@ -134,6 +152,17 @@ const BookingDetailPage = () => {
|
||||
<BookingContainersCard
|
||||
containers={booking.bookingContainers ?? []}
|
||||
/>
|
||||
<ContainerAllocationTable
|
||||
bookingId={booking.id}
|
||||
containers={(booking.bookingContainers ?? []).map((c) => ({
|
||||
id: c.id,
|
||||
type: c.containerType?.label ?? "Unknown",
|
||||
qty: c.quantity,
|
||||
}))}
|
||||
onSave={(allocations) =>
|
||||
allocateMutation.mutateAsync({ allocations })
|
||||
}
|
||||
/>
|
||||
<BookingApprovalCard
|
||||
steps={approvalSteps}
|
||||
approvedCount={approvedCount}
|
||||
|
||||
@@ -180,8 +180,10 @@ export default function NewBookingPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [isGovernment, setIsGovernment] = useState(false);
|
||||
const [governmentInstitution, setGovernmentInstitution] = useState("");
|
||||
const [companyId, setCompanyId] = useState<string | null>(null);
|
||||
// Government bookings bill to a real government company + an explicit profile.
|
||||
const [govCompanyId, setGovCompanyId] = useState<string | null>(null);
|
||||
const [govProfileId, setGovProfileId] = useState<string | null>(null);
|
||||
const [freightType, setFreightType] = useState<FreightType>("CONTAINER");
|
||||
const [originYardId, setOriginYardId] = useState<string | null>(null);
|
||||
const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
|
||||
@@ -220,6 +222,42 @@ export default function NewBookingPage() {
|
||||
label: c.name || c.email || c.tin || c.id,
|
||||
}));
|
||||
|
||||
// Active government companies (kind=government) the booking can bill to.
|
||||
const { data: govCompaniesPage, isLoading: govCompaniesLoading } = useQuery({
|
||||
queryKey: ["companies", "government", "active"],
|
||||
queryFn: () =>
|
||||
customersService.list({
|
||||
page: 1,
|
||||
pageSize: 1000,
|
||||
kind: "government",
|
||||
status: "active",
|
||||
}),
|
||||
enabled: isGovernment,
|
||||
});
|
||||
|
||||
const govCompanies = govCompaniesPage?.items ?? [];
|
||||
const govCompanyOptions = govCompanies.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.name || c.tin || c.id,
|
||||
}));
|
||||
|
||||
// Profiles (importer/exporter) of the chosen government company — the booking
|
||||
// must link to one explicitly.
|
||||
const selectedGovCompany = govCompanies.find((c) => c.id === govCompanyId);
|
||||
const govProfileOptions = (selectedGovCompany?.companyProfiles ?? [])
|
||||
.filter((p) => p.status === "active")
|
||||
.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.type === "importer" ? "Import" : p.type === "exporter" ? "Export" : p.type}${
|
||||
p.reference ? ` — ${p.reference}` : ""
|
||||
}`,
|
||||
}));
|
||||
|
||||
// Reset the chosen profile when the government company changes.
|
||||
useEffect(() => {
|
||||
setGovProfileId(null);
|
||||
}, [govCompanyId]);
|
||||
|
||||
// Day-level pool: fetch only the days that have a departure on the route (no
|
||||
// train, no capacity). The batch engine assigns the train after booking.
|
||||
const { data: availableDays, isLoading: daysLoading } = useQuery(
|
||||
@@ -306,7 +344,7 @@ export default function NewBookingPage() {
|
||||
Boolean(tradeDirection) &&
|
||||
Boolean(serviceTypeId) &&
|
||||
departureSatisfied &&
|
||||
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
|
||||
(isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) &&
|
||||
(freightType === "BULK"
|
||||
? Boolean(cargoTypeId) && bulkWeight > 0
|
||||
: allLinesValid);
|
||||
@@ -320,8 +358,8 @@ export default function NewBookingPage() {
|
||||
mutationFn: () =>
|
||||
bookingsService.create({
|
||||
isGovernment,
|
||||
governmentInstitution: isGovernment ? governmentInstitution : undefined,
|
||||
companyId: isGovernment ? undefined : companyId || undefined,
|
||||
companyId: isGovernment ? govCompanyId || undefined : companyId || undefined,
|
||||
companyProfileId: isGovernment ? govProfileId || undefined : undefined,
|
||||
freightType,
|
||||
contractType: "NEW",
|
||||
equipmentReturn,
|
||||
@@ -390,18 +428,37 @@ export default function NewBookingPage() {
|
||||
<Stack gap="md">
|
||||
<Switch
|
||||
label="Government booking"
|
||||
description="No company required — institution name instead. Expedited to the scheduling queue."
|
||||
description="Bills to a government entity + profile. Expedited to the scheduling queue."
|
||||
checked={isGovernment}
|
||||
onChange={(e) => setIsGovernment(e.currentTarget.checked)}
|
||||
/>
|
||||
{isGovernment ? (
|
||||
<TextInput
|
||||
label="Government institution"
|
||||
placeholder="e.g. Ministry of Transport"
|
||||
value={governmentInstitution}
|
||||
onChange={(e) => setGovernmentInstitution(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label="Government entity"
|
||||
placeholder="Select government company"
|
||||
data={govCompanyOptions}
|
||||
value={govCompanyId}
|
||||
onChange={setGovCompanyId}
|
||||
searchable
|
||||
required
|
||||
disabled={govCompaniesLoading}
|
||||
nothingFoundMessage="No active government companies"
|
||||
/>
|
||||
<Select
|
||||
label="Profile"
|
||||
placeholder={
|
||||
govCompanyId ? "Select import/export profile" : "Pick an entity first"
|
||||
}
|
||||
data={govProfileOptions}
|
||||
value={govProfileId}
|
||||
onChange={setGovProfileId}
|
||||
searchable
|
||||
required
|
||||
disabled={!govCompanyId}
|
||||
nothingFoundMessage="No active profiles for this entity"
|
||||
/>
|
||||
</Group>
|
||||
) : (
|
||||
<Select
|
||||
label="Customer"
|
||||
|
||||
@@ -30,9 +30,11 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
UnstyledButton,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { FirstMileContainerAllocationTable } from "@/components/FirstMileContainerAllocationTable";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
@@ -44,6 +46,7 @@ import {
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { ratesService } from "@/services/rates.service";
|
||||
import { api } from "@/auth/http";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
|
||||
const formatPrice = (amount: number) =>
|
||||
@@ -336,6 +339,9 @@ const FirstMilePage = () => {
|
||||
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
||||
const [invoiceRecord, setInvoiceRecord] = useState<FirstMileRecord | null>(null);
|
||||
|
||||
const [containerAllocationOpen, setContainerAllocationOpen] = useState(false);
|
||||
const [containerAllocationFirstMileId, setContainerAllocationFirstMileId] = useState<string | null>(null);
|
||||
|
||||
const { data: listData, isLoading } = useQuery({
|
||||
queryKey: QUERY_KEYS.FIRST_MILE.list(),
|
||||
queryFn: async () => {
|
||||
@@ -434,6 +440,19 @@ const FirstMilePage = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const allocateMutation = useMutation({
|
||||
mutationFn: (data) => apiClient.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data),
|
||||
onSuccess: () => {
|
||||
toast({ title: "Containers allocated" });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.detail(containerAllocationFirstMileId ?? "") });
|
||||
setContainerAllocationOpen(false);
|
||||
setContainerAllocationFirstMileId(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Allocation failed", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const activeRecord = useMemo(
|
||||
() => records.find((r) => r.id === activeId) ?? null,
|
||||
[records, activeId],
|
||||
@@ -508,6 +527,16 @@ const FirstMilePage = () => {
|
||||
setInvoiceRecord(null);
|
||||
};
|
||||
|
||||
const openContainerAllocation = (firstMileId: string) => {
|
||||
setContainerAllocationFirstMileId(firstMileId);
|
||||
setContainerAllocationOpen(true);
|
||||
};
|
||||
|
||||
const closeContainerAllocation = () => {
|
||||
setContainerAllocationOpen(false);
|
||||
setContainerAllocationFirstMileId(null);
|
||||
};
|
||||
|
||||
const handleSaveDistance = () => {
|
||||
const distance = parseFloat(distanceValue);
|
||||
if (!activeId || isNaN(distance) || distance < 0) {
|
||||
@@ -530,7 +559,6 @@ const FirstMilePage = () => {
|
||||
};
|
||||
|
||||
const matchesFilter = (r: FirstMileRecord) => {
|
||||
if (filterPostPaymentPending && r.isPostPaymentCompleted) return false;
|
||||
switch (statusFilter) {
|
||||
case "ALL": return true;
|
||||
case "ASSIGNED": return isAssigned(r);
|
||||
@@ -1272,6 +1300,56 @@ const FirstMilePage = () => {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Container Allocation modal */}
|
||||
<Modal
|
||||
opened={containerAllocationOpen}
|
||||
onClose={closeContainerAllocation}
|
||||
title={<Text fw={600}>Allocate Containers to Vehicles</Text>}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
{activeRecord && (
|
||||
<>
|
||||
{/* Capacity guidance */}
|
||||
{activeRecord.booking?.cargoType?.label === "BULK" ? (
|
||||
<Alert color="blue" title="Bulk Cargo Allocation">
|
||||
<Text size="sm">
|
||||
Select multiple containers per vehicle based on capacity. Each vehicle can carry multiple containers if capacity allows.
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
Capacity: TBD — TODO: add vehicle capacity_tons to vehicle API if missing
|
||||
</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert color="blue">
|
||||
<Text size="sm">
|
||||
One vehicle per container. Each container will be assigned to a single vehicle.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<Divider />
|
||||
|
||||
{/* Container table */}
|
||||
<FirstMileContainerAllocationTable
|
||||
firstMileId={activeRecord.id}
|
||||
containers={[
|
||||
// TODO: Get containers from booking/first-mile data
|
||||
// For now placeholder with TODO comment
|
||||
]}
|
||||
onSave={async (allocations) => {
|
||||
await allocateMutation.mutateAsync(allocations);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={closeContainerAllocation}>Close</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -45,6 +45,8 @@ import {
|
||||
} from "@/services/last-mile.service";
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { ratesService } from "@/services/rates.service";
|
||||
import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable";
|
||||
import { api } from "@/auth/http";
|
||||
|
||||
const formatPrice = (amount: number) =>
|
||||
`ETB ${amount.toLocaleString("en-US", {
|
||||
@@ -321,6 +323,9 @@ const LastMilePage = () => {
|
||||
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
||||
const [invoiceRecord, setInvoiceRecord] = useState<LastMileRecord | null>(null);
|
||||
|
||||
const [allocationOpen, setAllocationOpen] = useState(false);
|
||||
const [allocationContainers, setAllocationContainers] = useState<LastMileContainerRow[]>([]);
|
||||
|
||||
const { data: listData, isLoading } = useQuery({
|
||||
queryKey: QUERY_KEYS.LAST_MILE.list(),
|
||||
queryFn: async () => {
|
||||
@@ -385,6 +390,19 @@ const LastMilePage = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const allocateMutation = useMutation({
|
||||
mutationFn: (data: Array<{ containerId: string; vehicleId: string }>) =>
|
||||
api.post(`/last-mile/${activeId}/allocate-containers`, data),
|
||||
onSuccess: () => {
|
||||
toast({ title: "Containers allocated", variant: "default" });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.detail(activeId ?? "") });
|
||||
closeAllocation();
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Allocation failed", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({
|
||||
queryKey: ["warehouse-inventory", "arrival-queue"],
|
||||
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
|
||||
@@ -477,6 +495,18 @@ const LastMilePage = () => {
|
||||
setInvoiceRecord(null);
|
||||
};
|
||||
|
||||
const openAllocation = (id: string, containers?: LastMileContainerRow[]) => {
|
||||
setActiveId(id);
|
||||
setAllocationContainers(containers ?? []);
|
||||
setAllocationOpen(true);
|
||||
};
|
||||
|
||||
const closeAllocation = () => {
|
||||
setAllocationOpen(false);
|
||||
setActiveId(null);
|
||||
setAllocationContainers([]);
|
||||
};
|
||||
|
||||
const handleSaveDistance = () => {
|
||||
const distance = parseFloat(distanceValue);
|
||||
if (!activeId || isNaN(distance) || distance < 0) {
|
||||
@@ -509,7 +539,6 @@ const LastMilePage = () => {
|
||||
);
|
||||
|
||||
const matchesFilter = (r: LastMileRecord) => {
|
||||
if (filterPostPaymentPending && r.isPostPaymentCompleted) return false;
|
||||
switch (statusFilter) {
|
||||
case "ALL": return true;
|
||||
case "ASSIGNED": return isAssigned(r);
|
||||
@@ -1243,6 +1272,76 @@ const LastMilePage = () => {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Container Allocation modal */}
|
||||
<Modal
|
||||
opened={allocationOpen}
|
||||
onClose={closeAllocation}
|
||||
title={<Text fw={600}>Allocate Containers to Vehicles</Text>}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
{activeRecord && (
|
||||
<>
|
||||
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Stack gap={0}>
|
||||
<Text fw={600} size="sm">{bookingRef(activeRecord)}</Text>
|
||||
<Text size="xs" c="dimmed">{customerName(activeRecord)}</Text>
|
||||
</Stack>
|
||||
<Stack gap={0} align="flex-end">
|
||||
<Text size="xs" c="dimmed" tt="uppercase">Cargo Type</Text>
|
||||
<Text size="sm" fw={600}>{activeRecord.booking?.cargoType?.label ?? activeRecord.booking?.cargoType?.name ?? "—"}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Capacity logic based on cargo type */}
|
||||
{activeRecord.booking?.cargoType?.name === "BULK" ? (
|
||||
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-blue-0)" style={{ borderColor: "var(--mantine-color-blue-3)" }}>
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs">
|
||||
<Text fw={600} size="sm">Smart Capacity Allocation</Text>
|
||||
</Group>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm">Capacity: TBD</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
TODO: add vehicle capacity_tons to vehicle API if missing
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
TODO: add container weight to booking if missing
|
||||
</Text>
|
||||
</Stack>
|
||||
<Text size="sm" fw={500} mt="xs">
|
||||
Select multiple containers per vehicle based on capacity
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
|
||||
<Text size="sm" fw={500}>One vehicle per container</Text>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<LastMileContainerAllocationTable
|
||||
lastMileId={activeId ?? ""}
|
||||
containers={allocationContainers}
|
||||
onSave={async (mappings) => {
|
||||
await allocateMutation.mutateAsync(mappings);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={closeAllocation}>Close</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button, Card } from '@mantine/core';
|
||||
import { PackageSearch } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Button, Card, Group, Modal, Stack } from '@mantine/core';
|
||||
import { PackageSearch, Truck } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
@@ -7,6 +8,7 @@ import { WarehouseFlowWorkbench } from '@/components/warehouses';
|
||||
|
||||
export default function ExportWarehouseFlowPage() {
|
||||
const navigate = useNavigate();
|
||||
const [receiveOpen, setReceiveOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -14,15 +16,41 @@ export default function ExportWarehouseFlowPage() {
|
||||
title="Export Operations"
|
||||
subtitle="Manage export receive, terminal inventory, loading readiness, loaded items, and dispatch flow."
|
||||
action={
|
||||
<Button variant="light" leftSection={<PackageSearch size={16} />} onClick={() => navigate('/dashboard/import-warehouse')}>
|
||||
Import Operations
|
||||
</Button>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
fw={700}
|
||||
leftSection={<Truck size={16} />}
|
||||
onClick={() => setReceiveOpen(true)}
|
||||
>
|
||||
Receive for Loading
|
||||
</Button>
|
||||
<Button variant="light" leftSection={<PackageSearch size={16} />} onClick={() => navigate('/dashboard/import-warehouse')}>
|
||||
Import Operations
|
||||
</Button>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<WarehouseFlowWorkbench direction="EXPORT" />
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={receiveOpen}
|
||||
onClose={() => setReceiveOpen(false)}
|
||||
title="Receive for loading"
|
||||
centered
|
||||
size="80rem"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<WarehouseFlowWorkbench enabled={receiveOpen} direction="EXPORT" />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setReceiveOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -656,11 +656,11 @@ export const api = {
|
||||
({ filter }) => ["warehouse-inventory", "inquiry", filter],
|
||||
),
|
||||
|
||||
eligibleBookings: endpoint<void, EligibleBooking[]>(
|
||||
eligibleBookings: endpoint<{ direction?: 'IMPORT' | 'EXPORT' } | void, EligibleBooking[]>(
|
||||
"warehouse-inventory",
|
||||
"eligible-bookings",
|
||||
() => warehouseService.eligibleBookings().then((r) => r.data),
|
||||
() => ["warehouse-inventory", "eligible-bookings"],
|
||||
(input) => warehouseService.eligibleBookings(input?.direction).then((r) => r.data),
|
||||
(input) => ["warehouse-inventory", "eligible-bookings", input?.direction ?? "ALL"],
|
||||
),
|
||||
|
||||
readyToLoadExport: endpoint<void, ReadyToLoadRow[]>(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user