mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
Merge branch 'dev' into freight/feat/invoice
This commit is contained in:
@@ -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
|
||||
@@ -34,4 +40,4 @@ RUN addgroup --system --gid 1001 nodejs \
|
||||
COPY --from=deployer --chown=nestjs:nodejs /deploy .
|
||||
USER nestjs
|
||||
EXPOSE 3001
|
||||
CMD ["sh", "-c", "pnpm run migrate && node dist/main.js"]
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
||||
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>
|
||||
@@ -18,6 +18,7 @@
|
||||
"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",
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||
|
||||
export class AddPostPaymentCompletedColumn1719667261000 implements MigrationInterface {
|
||||
name = 'AddPostPaymentCompletedColumn1719667261000';
|
||||
export class AddPostPaymentCompletedColumn1810000000004 implements MigrationInterface {
|
||||
name = 'AddPostPaymentCompletedColumn1810000000004';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries');
|
||||
@@ -15,19 +15,25 @@ export class CreateInvoices1821000000002 implements MigrationInterface {
|
||||
name = "CreateInvoices1821000000002";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE freight.invoices_status_enum AS ENUM (
|
||||
'DRAFT',
|
||||
'PENDING',
|
||||
'PAID',
|
||||
'OVERDUE',
|
||||
'CANCELLED',
|
||||
'REFUNDED'
|
||||
);
|
||||
`);
|
||||
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 freight.invoices (
|
||||
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,
|
||||
@@ -96,6 +102,8 @@ export class CreateInvoices1821000000002 implements MigrationInterface {
|
||||
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;`);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS freight.invoices_status_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,74 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Create freight.first_mile_container_allocations table — tracks
|
||||
* container allocations per first-mile shipment with optional vehicle assignment.
|
||||
*/
|
||||
export class CreateFirstMileContainerAllocations1830000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const exists = await queryRunner.hasTable('freight.first_mile_container_allocations');
|
||||
if (exists) return;
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'freight.first_mile_container_allocations',
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'uuid',
|
||||
isPrimary: true,
|
||||
default: 'gen_random_uuid()',
|
||||
},
|
||||
{ name: 'first_mile_id', type: 'uuid', isNullable: false },
|
||||
{ name: 'container_id', type: 'uuid', isNullable: false },
|
||||
{ name: 'vehicle_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'container_type', type: 'text', isNullable: false },
|
||||
{
|
||||
name: 'quantity',
|
||||
type: 'int',
|
||||
default: 1,
|
||||
isNullable: false,
|
||||
},
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.first_mile_container_allocations',
|
||||
new TableForeignKey({
|
||||
columnNames: ['first_mile_id'],
|
||||
referencedTableName: 'freight.first_mile',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.first_mile_container_allocations',
|
||||
new TableForeignKey({
|
||||
columnNames: ['vehicle_id'],
|
||||
referencedTableName: 'freight.vehicles',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'SET NULL',
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_first_mile_container_allocations_first_mile_id" ON "freight"."first_mile_container_allocations" ("first_mile_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_first_mile_container_allocations_vehicle_id" ON "freight"."first_mile_container_allocations" ("vehicle_id")`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const exists = await queryRunner.hasTable('freight.first_mile_container_allocations');
|
||||
if (exists) {
|
||||
await queryRunner.dropTable('freight.first_mile_container_allocations');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ 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';
|
||||
@@ -50,6 +51,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
BookingRateSnapshot,
|
||||
BookingReviewNote,
|
||||
BookingContractSignature,
|
||||
BookingContainerAllocation,
|
||||
]),
|
||||
BillingModule,
|
||||
forwardRef(() => FirstMileModule),
|
||||
|
||||
@@ -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). */
|
||||
@@ -1337,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[];
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -444,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[];
|
||||
|
||||
|
||||
@@ -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,6 +35,7 @@ export class FirstMile extends BaseEntity {
|
||||
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
remainingPayment!: number;
|
||||
|
||||
// TODO: uncomment after migration creates column
|
||||
// @Column({ type: 'boolean', default: false })
|
||||
// isPostPaymentCompleted!: boolean;
|
||||
|
||||
@@ -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,6 +35,7 @@ export class LastMile extends BaseEntity {
|
||||
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
remainingPayment!: number;
|
||||
|
||||
// TODO: uncomment after migration creates column
|
||||
// @Column({ type: 'boolean', default: false })
|
||||
// isPostPaymentCompleted!: boolean;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,16 +462,29 @@ export class PaymentService {
|
||||
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 { 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}`);
|
||||
|
||||
// When the intent references a booking, flip the booking itself paid.
|
||||
// refId holds the booking id (the domain reference the intent opened with).
|
||||
if (intent.referenceType === PaymentReferenceType.BOOKING) {
|
||||
await this.datasource.manager.update(
|
||||
Booking,
|
||||
{ id: intent.refId },
|
||||
{ status: "PAID", paymentStatus: "PAID" },
|
||||
);
|
||||
}
|
||||
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
|
||||
return { processed: true, alreadyFinalized };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user