mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
29
.github/workflows/deploy.yml
vendored
29
.github/workflows/deploy.yml
vendored
@@ -50,10 +50,10 @@ jobs:
|
|||||||
|
|
||||||
SERVICES=()
|
SERVICES=()
|
||||||
|
|
||||||
NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$"
|
NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$|^scripts/deploy/sync-env-from-server-jenkins[.]sh$"
|
||||||
|
|
||||||
GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$"
|
GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$"
|
||||||
|
|
||||||
DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true)
|
DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true)
|
||||||
if [ -z "$DEPLOYABLE" ]; then
|
if [ -z "$DEPLOYABLE" ]; then
|
||||||
echo "Only non-deployable files changed. Skipping deploy."
|
echo "Only non-deployable files changed. Skipping deploy."
|
||||||
@@ -142,6 +142,31 @@ jobs:
|
|||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
run: ./scripts/deploy/create-npmrc.sh
|
run: ./scripts/deploy/create-npmrc.sh
|
||||||
|
|
||||||
|
- name: Resolve env file path for ${{ matrix.service }}
|
||||||
|
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
|
||||||
|
run: |
|
||||||
|
case "${{ matrix.service }}" in
|
||||||
|
passenger-api) echo "SERVICE_ENV_FILE=apps/edr-passenger-api/.env" >> "$GITHUB_ENV" ;;
|
||||||
|
payment-api) echo "SERVICE_ENV_FILE=apps/edr-payment-api/.env" >> "$GITHUB_ENV" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
- name: Build migration image for ${{ matrix.service }}
|
||||||
|
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker build \
|
||||||
|
--secret id=npmrc,src=.npmrc \
|
||||||
|
--target migration \
|
||||||
|
-f "apps/edr-${{ matrix.service }}/Dockerfile" \
|
||||||
|
-t "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Run migrations for ${{ matrix.service }}
|
||||||
|
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker run --rm --env-file "${SERVICE_ENV_FILE}" "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration"
|
||||||
|
|
||||||
- name: Build ${{ matrix.service }}
|
- name: Build ${{ matrix.service }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ DEFAULT_PASSWORD=password@tria
|
|||||||
# Freight org + staff (bookings / rule-engine IAM)
|
# Freight org + staff (bookings / rule-engine IAM)
|
||||||
SEED_EDR_ORG=true
|
SEED_EDR_ORG=true
|
||||||
SEED_FREIGHT_STAFF=true
|
SEED_FREIGHT_STAFF=true
|
||||||
|
SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false
|
||||||
|
|
||||||
# MinIO (used by @tria-plc/iamapi-common for file storage)
|
# MinIO (used by @tria-plc/iamapi-common for file storage)
|
||||||
MINIO_ENDPOINT=localhost
|
MINIO_ENDPOINT=localhost
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
|
|
||||||
FROM node:24.15.0-alpine AS base
|
FROM node:24.15.0-alpine AS base
|
||||||
RUN apk add --no-cache libc6-compat
|
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
|
RUN corepack enable
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -14,6 +18,7 @@ FROM base AS installer
|
|||||||
COPY --from=pruner /app/out/json/ .
|
COPY --from=pruner /app/out/json/ .
|
||||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
||||||
|
--mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
@@ -23,7 +28,8 @@ RUN pnpm turbo build --filter="@edr/freight-api..."
|
|||||||
|
|
||||||
FROM base AS deployer
|
FROM base AS deployer
|
||||||
COPY --from=builder /app/ .
|
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
|
FROM node:24.15.0-alpine AS runner
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
|
|||||||
452
apps/edr-freight-api/html/payment-tester.html
Normal file
452
apps/edr-freight-api/html/payment-tester.html
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>EDR Freight — Payment Tester (Telebirr ETB + Card USD)</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1419;
|
||||||
|
--panel: #1a2129;
|
||||||
|
--panel-2: #232d38;
|
||||||
|
--border: #2e3b48;
|
||||||
|
--txt: #e6edf3;
|
||||||
|
--muted: #8b98a5;
|
||||||
|
--accent: #1a73e8;
|
||||||
|
--etb: #00a651; /* telebirr green */
|
||||||
|
--usd: #2563eb; /* card blue */
|
||||||
|
--ok: #2ea043;
|
||||||
|
--warn: #d29922;
|
||||||
|
--err: #f85149;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: var(--bg); color: var(--txt); line-height: 1.5;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
padding: 20px 24px; border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--panel); display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
header h1 { font-size: 1.05rem; margin: 0; font-weight: 600; }
|
||||||
|
header .badge {
|
||||||
|
font-size: .7rem; padding: 2px 8px; border-radius: 999px;
|
||||||
|
background: var(--panel-2); color: var(--muted); border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
main { max-width: 920px; margin: 0 auto; padding: 24px; display: grid; gap: 20px; }
|
||||||
|
.card {
|
||||||
|
background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 20px;
|
||||||
|
}
|
||||||
|
.card h2 { margin: 0 0 14px; font-size: .8rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
|
||||||
|
label { display: block; font-size: .78rem; color: var(--muted); margin: 0 0 6px; }
|
||||||
|
input, select {
|
||||||
|
width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border);
|
||||||
|
background: var(--panel-2); color: var(--txt); font-size: .9rem; font-family: inherit;
|
||||||
|
}
|
||||||
|
input:focus, select:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.row { display: grid; grid-template-columns: 1fr auto; gap: 10px; align-items: end; }
|
||||||
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||||
|
button {
|
||||||
|
cursor: pointer; border: none; border-radius: 8px; padding: 10px 16px;
|
||||||
|
font-size: .85rem; font-weight: 600; color: #fff; font-family: inherit;
|
||||||
|
background: var(--panel-2); border: 1px solid var(--border); color: var(--txt);
|
||||||
|
transition: filter .15s, opacity .15s;
|
||||||
|
}
|
||||||
|
button:hover:not(:disabled) { filter: brightness(1.15); }
|
||||||
|
button:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
button.pay-etb { background: var(--etb); border-color: var(--etb); color: #fff; }
|
||||||
|
button.pay-usd { background: var(--usd); border-color: var(--usd); color: #fff; }
|
||||||
|
button.ghost { background: transparent; }
|
||||||
|
.pay-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 4px; }
|
||||||
|
.pay-buttons button { padding: 16px; font-size: .95rem; display: flex; flex-direction: column; gap: 4px; align-items: center; }
|
||||||
|
.pay-buttons button small { font-weight: 400; opacity: .85; font-size: .72rem; }
|
||||||
|
.booking-meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-top: 14px; }
|
||||||
|
.meta-item { background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
|
||||||
|
.meta-item .k { font-size: .68rem; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
.meta-item .v { font-size: 1rem; font-weight: 600; margin-top: 2px; word-break: break-all; }
|
||||||
|
.pill { display: inline-block; font-size: .72rem; font-weight: 600; padding: 2px 9px; border-radius: 999px; }
|
||||||
|
.pill.etb { background: rgba(0,166,81,.15); color: #4ade80; }
|
||||||
|
.pill.usd { background: rgba(37,99,235,.18); color: #60a5fa; }
|
||||||
|
.status-line { display: flex; align-items: center; gap: 8px; font-size: .9rem; }
|
||||||
|
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); }
|
||||||
|
.dot.ok { background: var(--ok); } .dot.warn { background: var(--warn); } .dot.err { background: var(--err); }
|
||||||
|
.dot.pulse { animation: pulse 1s ease-in-out infinite; }
|
||||||
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .3; } }
|
||||||
|
pre {
|
||||||
|
background: #0b0f14; border: 1px solid var(--border); border-radius: 8px; padding: 14px;
|
||||||
|
font-size: .76rem; overflow: auto; max-height: 320px; margin: 10px 0 0; color: #c9d1d9;
|
||||||
|
}
|
||||||
|
.log { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: .74rem; }
|
||||||
|
.log-entry { padding: 4px 0; border-bottom: 1px solid var(--border); }
|
||||||
|
.log-entry .ts { color: var(--muted); margin-right: 8px; }
|
||||||
|
.log-entry.req { color: #79c0ff; } .log-entry.res { color: #7ee787; } .log-entry.err { color: var(--err); }
|
||||||
|
.actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 14px; }
|
||||||
|
.hint { font-size: .76rem; color: var(--muted); margin-top: 8px; }
|
||||||
|
a { color: #58a6ff; }
|
||||||
|
.table { width: 100%; border-collapse: collapse; font-size: .8rem; margin-top: 10px; }
|
||||||
|
.table th, .table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
||||||
|
.table th { color: var(--muted); font-weight: 500; font-size: .72rem; text-transform: uppercase; }
|
||||||
|
.table tr.clickable { cursor: pointer; }
|
||||||
|
.table tr.clickable:hover { background: var(--panel-2); }
|
||||||
|
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
|
@media (max-width: 720px) { .split, .grid-2, .pay-buttons { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>🚂 EDR Freight — Payment Tester</h1>
|
||||||
|
<span class="badge">Telebirr (ETB)</span>
|
||||||
|
<span class="badge">Card (USD)</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- ── Config ───────────────────────────────────────────── -->
|
||||||
|
<section class="card">
|
||||||
|
<h2>API connection</h2>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div>
|
||||||
|
<label>API base URL</label>
|
||||||
|
<input id="apiBase" value="http://localhost:3001/api" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Service / Bearer token (optional)</label>
|
||||||
|
<input id="token" placeholder="Bearer token if guards enabled" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="ghost" id="pingBtn">Test connection</button>
|
||||||
|
<span class="status-line"><span class="dot" id="pingDot"></span><span id="pingTxt">not checked</span></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Pick booking ─────────────────────────────────────── -->
|
||||||
|
<section class="card">
|
||||||
|
<h2>1 · Choose a booking</h2>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label>Booking ID (UUID) — paste directly, or load the list below</label>
|
||||||
|
<input id="bookingId" placeholder="e.g. 9f8c…-uuid" />
|
||||||
|
</div>
|
||||||
|
<button class="ghost" id="loadBookingBtn">Load this booking</button>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="ghost" id="listBtn">List recent bookings</button>
|
||||||
|
<span class="hint">Currency (ETB vs USD) is set per-booking via <code>paymentCurrency</code>. Pick an ETB booking to test Telebirr, a USD booking to test Card.</span>
|
||||||
|
</div>
|
||||||
|
<div id="bookingList"></div>
|
||||||
|
|
||||||
|
<div id="bookingMeta" style="display:none;">
|
||||||
|
<div class="booking-meta">
|
||||||
|
<div class="meta-item"><div class="k">Reference</div><div class="v" id="mRef">—</div></div>
|
||||||
|
<div class="meta-item"><div class="k">Amount</div><div class="v" id="mAmt">—</div></div>
|
||||||
|
<div class="meta-item"><div class="k">Currency</div><div class="v" id="mCur">—</div></div>
|
||||||
|
<div class="meta-item"><div class="k">Status</div><div class="v" id="mStatus">—</div></div>
|
||||||
|
<div class="meta-item"><div class="k">Pay status</div><div class="v" id="mPay">—</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Pay ──────────────────────────────────────────────── -->
|
||||||
|
<section class="card">
|
||||||
|
<h2>2 · Initiate payment</h2>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div>
|
||||||
|
<label>Platform</label>
|
||||||
|
<select id="platform"><option value="web">web (browser redirect)</option><option value="mobile">mobile (launch app)</option></select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Payer account (optional — Waafi MWALLET / phone)</label>
|
||||||
|
<input id="payerAccount" placeholder="2519…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pay-buttons">
|
||||||
|
<button class="pay-etb" id="payTelebirr" disabled>Pay with Telebirr<small>ETB · Ethiopian mobile money</small></button>
|
||||||
|
<button class="pay-usd" id="payCard" disabled>Pay with Card<small>USD · Visa / Mastercard</small></button>
|
||||||
|
</div>
|
||||||
|
<div class="actions" style="margin-top:14px;">
|
||||||
|
<label style="margin:0;display:flex;align-items:center;gap:6px;font-size:.8rem;">
|
||||||
|
<input type="checkbox" id="openTab" checked style="width:auto;" /> open provider checkout in new tab
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="hint">
|
||||||
|
Telebirr → forces method <code>TELEBIRR</code>. Card → forces method <code>CARD</code>.
|
||||||
|
Each calls <code>POST {base}/payments/initiate</code> and follows the returned <code>clientAction</code> (REDIRECT url for web).
|
||||||
|
</div>
|
||||||
|
<p class="hint" id="initiateResult"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Status / receipt ─────────────────────────────────── -->
|
||||||
|
<section class="card">
|
||||||
|
<h2>3 · Track intent & receipt</h2>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="ghost" id="pollBtn" disabled>Refresh intent status</button>
|
||||||
|
<label style="margin:0;display:flex;align-items:center;gap:6px;font-size:.8rem;">
|
||||||
|
<input type="checkbox" id="autoPoll" style="width:auto;" /> auto-poll every 3s
|
||||||
|
</label>
|
||||||
|
<span class="status-line"><span class="dot" id="intentDot"></span><span id="intentTxt">no intent yet</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="actions" style="margin-top:6px;">
|
||||||
|
<button class="ghost" id="receiptBtn" disabled>Open receipt (success only)</button>
|
||||||
|
<span class="hint">Receipt = <code>GET {base}/payments/receipt/{merchantOrderId}</code></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Raw / log ────────────────────────────────────────── -->
|
||||||
|
<section class="split">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Last response</h2>
|
||||||
|
<pre id="rawOut">—</pre>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Request log</h2>
|
||||||
|
<div class="log" id="log"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const state = { booking: null, intent: null, merchantOrderId: null, pollTimer: null };
|
||||||
|
|
||||||
|
// ── helpers ─────────────────────────────────────────────
|
||||||
|
const base = () => $("apiBase").value.replace(/\/$/, "");
|
||||||
|
const headers = () => {
|
||||||
|
const h = { "Content-Type": "application/json", Accept: "application/json" };
|
||||||
|
const t = $("token").value.trim();
|
||||||
|
if (t) h["Authorization"] = t.startsWith("Bearer ") ? t : "Bearer " + t;
|
||||||
|
return h;
|
||||||
|
};
|
||||||
|
|
||||||
|
function now() {
|
||||||
|
const d = new Date();
|
||||||
|
return d.toTimeString().slice(0, 8) + "." + String(d.getMilliseconds()).padStart(3, "0");
|
||||||
|
}
|
||||||
|
function log(kind, msg) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "log-entry " + kind;
|
||||||
|
el.innerHTML = '<span class="ts">' + now() + "</span>" + msg;
|
||||||
|
$("log").prepend(el);
|
||||||
|
}
|
||||||
|
function showRaw(obj) {
|
||||||
|
$("rawOut").textContent = typeof obj === "string" ? obj : JSON.stringify(obj, null, 2);
|
||||||
|
}
|
||||||
|
function setDot(id, cls) {
|
||||||
|
const d = $(id);
|
||||||
|
d.className = "dot" + (cls ? " " + cls : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap the @edr/api-common ResponseTransformInterceptor envelope if present.
|
||||||
|
function unwrap(json) {
|
||||||
|
if (json && typeof json === "object" && "data" in json && ("statusCode" in json || "success" in json)) {
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(method, path, body) {
|
||||||
|
const url = base() + path;
|
||||||
|
log("req", method + " " + path);
|
||||||
|
let res, text, json;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: headers(),
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
log("err", "network error: " + e.message + " — is the API running at " + base() + "?");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
text = await res.text();
|
||||||
|
try { json = text ? JSON.parse(text) : null; } catch { json = text; }
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = json && json.message ? (Array.isArray(json.message) ? json.message.join(", ") : json.message) : text;
|
||||||
|
log("err", method + " " + path + " → " + res.status + ": " + detail);
|
||||||
|
showRaw(json || text);
|
||||||
|
throw new Error(detail || ("HTTP " + res.status));
|
||||||
|
}
|
||||||
|
log("res", method + " " + path + " → " + res.status);
|
||||||
|
return unwrap(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ping ────────────────────────────────────────────────
|
||||||
|
$("pingBtn").onclick = async () => {
|
||||||
|
setDot("pingDot", "pulse warn"); $("pingTxt").textContent = "checking…";
|
||||||
|
try {
|
||||||
|
// payment summary is a light, low-side-effect GET
|
||||||
|
await api("GET", "/payments/summary");
|
||||||
|
setDot("pingDot", "ok"); $("pingTxt").textContent = "connected ✓";
|
||||||
|
} catch (e) {
|
||||||
|
setDot("pingDot", "err"); $("pingTxt").textContent = "failed — " + e.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── list bookings ───────────────────────────────────────
|
||||||
|
$("listBtn").onclick = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api("GET", "/bookings");
|
||||||
|
const items = Array.isArray(data) ? data : (data && data.items) || [];
|
||||||
|
renderBookingList(items.slice(0, 25));
|
||||||
|
} catch (e) { /* logged */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderBookingList(items) {
|
||||||
|
const wrap = $("bookingList");
|
||||||
|
if (!items.length) { wrap.innerHTML = '<p class="hint">No bookings returned.</p>'; return; }
|
||||||
|
let html = '<table class="table"><thead><tr><th>Reference</th><th>Amount</th><th>Cur</th><th>Status</th><th>ID</th></tr></thead><tbody>';
|
||||||
|
for (const b of items) {
|
||||||
|
const cur = b.paymentCurrency || b.currency || "—";
|
||||||
|
const curClass = cur === "ETB" ? "etb" : cur === "USD" ? "usd" : "";
|
||||||
|
const amt = b.totalAmount != null ? b.totalAmount : (b.amount != null ? b.amount : "—");
|
||||||
|
html += '<tr class="clickable" data-id="' + (b.id || "") + '">' +
|
||||||
|
"<td>" + (b.reference || "—") + "</td>" +
|
||||||
|
"<td>" + amt + "</td>" +
|
||||||
|
'<td><span class="pill ' + curClass + '">' + cur + "</span></td>" +
|
||||||
|
"<td>" + (b.status || "—") + "</td>" +
|
||||||
|
'<td style="font-size:.7rem;color:var(--muted);">' + (b.id ? b.id.slice(0, 8) + "…" : "—") + "</td></tr>";
|
||||||
|
}
|
||||||
|
html += "</tbody></table>";
|
||||||
|
wrap.innerHTML = html;
|
||||||
|
wrap.querySelectorAll("tr.clickable").forEach((tr) => {
|
||||||
|
tr.onclick = () => { $("bookingId").value = tr.dataset.id; loadBooking(); };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── load one booking ────────────────────────────────────
|
||||||
|
$("loadBookingBtn").onclick = loadBooking;
|
||||||
|
async function loadBooking() {
|
||||||
|
const id = $("bookingId").value.trim();
|
||||||
|
if (!id) { log("err", "enter a booking ID first"); return; }
|
||||||
|
try {
|
||||||
|
const b = await api("GET", "/bookings/" + id);
|
||||||
|
state.booking = b;
|
||||||
|
showRaw(b);
|
||||||
|
renderMeta(b);
|
||||||
|
enablePayButtons(b);
|
||||||
|
$("pollBtn").disabled = false;
|
||||||
|
// pre-load any existing intent
|
||||||
|
refreshIntent(true).catch(() => {});
|
||||||
|
} catch (e) { /* logged */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMeta(b) {
|
||||||
|
const cur = b.paymentCurrency || b.currency || "—";
|
||||||
|
$("bookingMeta").style.display = "block";
|
||||||
|
$("mRef").textContent = b.reference || "—";
|
||||||
|
$("mAmt").textContent = (b.totalAmount != null ? b.totalAmount : (b.amount != null ? b.amount : "—"));
|
||||||
|
$("mCur").innerHTML = '<span class="pill ' + (cur === "ETB" ? "etb" : cur === "USD" ? "usd" : "") + '">' + cur + "</span>";
|
||||||
|
$("mStatus").textContent = b.status || "—";
|
||||||
|
$("mPay").textContent = b.paymentStatus || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function enablePayButtons(b) {
|
||||||
|
const cur = b.paymentCurrency || b.currency || "";
|
||||||
|
const etbBtn = $("payTelebirr"), usdBtn = $("payCard");
|
||||||
|
etbBtn.disabled = false; usdBtn.disabled = false;
|
||||||
|
// soft hint via title; both stay enabled so you can deliberately test mismatch
|
||||||
|
etbBtn.title = cur && cur !== "ETB" ? "Booking currency is " + cur + ", not ETB — Telebirr expects ETB" : "";
|
||||||
|
usdBtn.title = cur && cur !== "USD" ? "Booking currency is " + cur + ", not USD — Card expects USD" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── initiate payment ────────────────────────────────────
|
||||||
|
async function pay(method, label) {
|
||||||
|
const id = $("bookingId").value.trim();
|
||||||
|
if (!id) { log("err", "pick a booking first"); return; }
|
||||||
|
$("initiateResult").textContent = "Initiating " + label + "…";
|
||||||
|
const body = {
|
||||||
|
bookingId: id,
|
||||||
|
method: method,
|
||||||
|
platform: $("platform").value,
|
||||||
|
};
|
||||||
|
const payer = $("payerAccount").value.trim();
|
||||||
|
if (payer) body.payerAccount = payer;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await api("POST", "/payments/initiate", body);
|
||||||
|
state.intent = resp;
|
||||||
|
state.merchantOrderId = resp.merchantOrderId || null;
|
||||||
|
showRaw(resp);
|
||||||
|
renderIntent(resp);
|
||||||
|
$("receiptBtn").disabled = !state.merchantOrderId;
|
||||||
|
|
||||||
|
const action = resp.clientAction;
|
||||||
|
if (action && action.type === "REDIRECT" && action.url) {
|
||||||
|
$("initiateResult").innerHTML =
|
||||||
|
label + " intent created (status: " + resp.status + "). " +
|
||||||
|
'Redirect → <a href="' + action.url + '" target="_blank" rel="noopener">' + action.url + "</a>";
|
||||||
|
if ($("openTab").checked) window.open(action.url, "_blank", "noopener");
|
||||||
|
} else if (action && action.type === "LAUNCH_APP") {
|
||||||
|
$("initiateResult").textContent =
|
||||||
|
label + " → LAUNCH_APP (mobile). appId=" + (action.appId || "") + " shortCode=" + (action.shortCode || "");
|
||||||
|
} else if (action && action.type === "COLLECT_OTP") {
|
||||||
|
$("initiateResult").textContent =
|
||||||
|
label + " → COLLECT_OTP. providerOrderId=" + (action.providerOrderId || "") + " — " + (action.message || "");
|
||||||
|
} else {
|
||||||
|
$("initiateResult").textContent = label + " intent created. Status: " + resp.status + " (no redirect action).";
|
||||||
|
}
|
||||||
|
// begin watching
|
||||||
|
if ($("autoPoll").checked) startAutoPoll();
|
||||||
|
} catch (e) {
|
||||||
|
$("initiateResult").textContent = "Failed: " + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$("payTelebirr").onclick = () => pay("TELEBIRR", "Telebirr (ETB)");
|
||||||
|
$("payCard").onclick = () => pay("CARD", "Card (USD)");
|
||||||
|
|
||||||
|
// ── intent status ───────────────────────────────────────
|
||||||
|
$("pollBtn").onclick = () => refreshIntent(false);
|
||||||
|
async function refreshIntent(silent) {
|
||||||
|
const id = $("bookingId").value.trim();
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
const resp = await api("GET", "/payments/intents/" + id);
|
||||||
|
state.intent = resp;
|
||||||
|
if (resp.merchantOrderId) state.merchantOrderId = resp.merchantOrderId;
|
||||||
|
$("receiptBtn").disabled = !state.merchantOrderId;
|
||||||
|
showRaw(resp);
|
||||||
|
renderIntent(resp);
|
||||||
|
return resp;
|
||||||
|
} catch (e) {
|
||||||
|
if (!silent) { /* already logged */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIntent(resp) {
|
||||||
|
const s = (resp.status || "").toUpperCase();
|
||||||
|
let cls = "warn";
|
||||||
|
if (s === "SUCCEEDED") cls = "ok";
|
||||||
|
else if (s === "FAILED" || s === "CANCELLED") cls = "err";
|
||||||
|
setDot("intentDot", cls);
|
||||||
|
let txt = "status: " + (resp.status || "—");
|
||||||
|
if (resp.merchantOrderId) txt += " · order " + resp.merchantOrderId;
|
||||||
|
if (resp.paidAt) txt += " · paid " + resp.paidAt;
|
||||||
|
if (resp.failureMessage) txt += " · " + resp.failureMessage;
|
||||||
|
$("intentTxt").textContent = txt;
|
||||||
|
if (s === "SUCCEEDED" || s === "FAILED" || s === "CANCELLED") stopAutoPoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAutoPoll() {
|
||||||
|
stopAutoPoll();
|
||||||
|
state.pollTimer = setInterval(() => refreshIntent(true), 3000);
|
||||||
|
}
|
||||||
|
function stopAutoPoll() {
|
||||||
|
if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }
|
||||||
|
}
|
||||||
|
$("autoPoll").onchange = (e) => { if (e.target.checked) startAutoPoll(); else stopAutoPoll(); };
|
||||||
|
|
||||||
|
// ── receipt ─────────────────────────────────────────────
|
||||||
|
$("receiptBtn").onclick = () => {
|
||||||
|
if (!state.merchantOrderId) { log("err", "no merchantOrderId yet — pay first"); return; }
|
||||||
|
const url = base() + "/payments/receipt/" + encodeURIComponent(state.merchantOrderId);
|
||||||
|
log("req", "GET /payments/receipt/" + state.merchantOrderId + " (new tab)");
|
||||||
|
window.open(url, "_blank", "noopener");
|
||||||
|
};
|
||||||
|
|
||||||
|
log("res", "ready — set API base, pick a booking, pay with Telebirr (ETB) or Card (USD)");
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -18,14 +18,21 @@
|
|||||||
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
|
"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: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-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:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
|
||||||
|
"seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts",
|
||||||
|
"seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",
|
||||||
|
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
|
||||||
|
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
|
||||||
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
||||||
|
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
|
||||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
|
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
|
||||||
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
|
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
|
||||||
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
|
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
|
||||||
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
||||||
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
||||||
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js"
|
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js",
|
||||||
|
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edr/api-common": "workspace:*",
|
"@edr/api-common": "workspace:*",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module, OnApplicationBootstrap } from "@nestjs/common";
|
|||||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||||
import { ScheduleModule } from "@nestjs/schedule";
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
|
import { EventEmitterModule } from "@nestjs/event-emitter";
|
||||||
import { DataSource, DataSourceOptions } from "typeorm";
|
import { DataSource, DataSourceOptions } from "typeorm";
|
||||||
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
|
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
|
||||||
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
|
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
|
||||||
@@ -13,7 +14,7 @@ import telebirrConfig from "./config/telebirr.config";
|
|||||||
import rabbitmqConfig from "./config/rabbitmq.config";
|
import rabbitmqConfig from "./config/rabbitmq.config";
|
||||||
|
|
||||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||||
import { BookingOrdersModule } from "./modules/booking-orders/booking-orders.module";
|
import { ContractsModule } from "./modules/contracts/contracts.module";
|
||||||
import { SignaturesModule } from "./modules/signatures/signatures.module";
|
import { SignaturesModule } from "./modules/signatures/signatures.module";
|
||||||
import { FilesModule } from "./modules/files/files.module";
|
import { FilesModule } from "./modules/files/files.module";
|
||||||
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||||
@@ -52,8 +53,12 @@ import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
|
|||||||
import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
|
import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
|
||||||
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
|
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
|
||||||
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
|
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
|
||||||
|
import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
|
||||||
|
import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
|
||||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||||
|
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
||||||
|
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||||
//New Trains, Wagons, Container and Cargo management modules
|
//New Trains, Wagons, Container and Cargo management modules
|
||||||
import { TrainsModule } from "./modules/trains/trains.module";
|
import { TrainsModule } from "./modules/trains/trains.module";
|
||||||
import { WagonsModule } from './modules/wagons/wagons.module';
|
import { WagonsModule } from './modules/wagons/wagons.module';
|
||||||
@@ -67,6 +72,7 @@ import { DriversModule } from './modules/drivers/drivers.module';
|
|||||||
import { FirstMileModule } from './modules/first-mile/first-mile.module';
|
import { FirstMileModule } from './modules/first-mile/first-mile.module';
|
||||||
import { LastMileModule } from './modules/last-mile/last-mile.module';
|
import { LastMileModule } from './modules/last-mile/last-mile.module';
|
||||||
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
|
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
|
||||||
|
import { ImportOperationsModule } from './modules/import-operations/import-operations.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -75,7 +81,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
|
|||||||
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
|
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
|
||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
// EventEmitterModule.forRoot(),
|
EventEmitterModule.forRoot(),
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
||||||
@@ -95,7 +101,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
|
|||||||
permissions: EDR_FREIGHT_PERMISSIONS,
|
permissions: EDR_FREIGHT_PERMISSIONS,
|
||||||
}),
|
}),
|
||||||
BookingsModule,
|
BookingsModule,
|
||||||
BookingOrdersModule,
|
ContractsModule,
|
||||||
SignaturesModule,
|
SignaturesModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
ConsignmentsModule,
|
ConsignmentsModule,
|
||||||
@@ -130,6 +136,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
|
|||||||
FirstMileModule,
|
FirstMileModule,
|
||||||
LastMileModule,
|
LastMileModule,
|
||||||
InterchangeDocumentsModule,
|
InterchangeDocumentsModule,
|
||||||
|
ImportOperationsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
EdrOrgSeeder,
|
EdrOrgSeeder,
|
||||||
@@ -139,12 +146,16 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
|
|||||||
FileUploadSettingsSeeder,
|
FileUploadSettingsSeeder,
|
||||||
FreightPermissionKeyMigrationSeeder,
|
FreightPermissionKeyMigrationSeeder,
|
||||||
DemoFreightDataSeeder,
|
DemoFreightDataSeeder,
|
||||||
|
GovCompaniesSeeder,
|
||||||
IndodeFacilitySeeder,
|
IndodeFacilitySeeder,
|
||||||
Batch14TestDataSeeder,
|
Batch14TestDataSeeder,
|
||||||
Batch5TestDataSeeder,
|
Batch5TestDataSeeder,
|
||||||
Batch7TestDataSeeder,
|
Batch7TestDataSeeder,
|
||||||
Batch8TestDataSeeder,
|
Batch8TestDataSeeder,
|
||||||
WarehouseDemoSeeder,
|
WarehouseDemoSeeder,
|
||||||
|
ExportDjiboutiInterchangeDemoSeeder,
|
||||||
|
MarshallingDemoTrainsSeeder,
|
||||||
|
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
@@ -161,8 +172,11 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
|
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
|
||||||
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
|
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
|
||||||
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
||||||
|
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
|
||||||
|
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
|
||||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||||
|
private readonly govCompaniesSeeder: GovCompaniesSeeder,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async onApplicationBootstrap() {
|
async onApplicationBootstrap() {
|
||||||
@@ -179,6 +193,8 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
await this.batch7TestDataSeeder.run();
|
await this.batch7TestDataSeeder.run();
|
||||||
await this.batch8TestDataSeeder.run();
|
await this.batch8TestDataSeeder.run();
|
||||||
await this.warehouseDemoSeeder.run();
|
await this.warehouseDemoSeeder.run();
|
||||||
|
await this.exportDjiboutiInterchangeDemoSeeder.run();
|
||||||
|
await this.marshallingDemoTrainsSeeder.run();
|
||||||
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
|
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
|
||||||
// Each block self-guards on an empty-table check, so this is safe every boot.
|
// Each block self-guards on an empty-table check, so this is safe every boot.
|
||||||
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
|
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
|
||||||
@@ -187,5 +203,8 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
|
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
|
||||||
// rules are disabled inside the seeder). Kept running for the staff users.
|
// rules are disabled inside the seeder). Kept running for the staff users.
|
||||||
await this.demoFreightDataSeeder.run();
|
await this.demoFreightDataSeeder.run();
|
||||||
|
// Government entities (with importer/exporter profiles) that government
|
||||||
|
// bookings bill to. Idempotent — keyed by fixed IDs.
|
||||||
|
await this.govCompaniesSeeder.run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ContractsRepository } from '../modules/contracts/contracts.repository';
|
||||||
|
import { Contract } from '../modules/contracts/entities/contract.entity';
|
||||||
|
import { ContractRoute } from '../modules/contracts/entities/contract-route.entity';
|
||||||
|
import {
|
||||||
|
ContractSignature,
|
||||||
|
ContractSignerRole,
|
||||||
|
} from '../modules/contracts/entities/contract-signature.entity';
|
||||||
|
import { ContractPricingBreakdown } from '../modules/contracts/contract-pricing.service';
|
||||||
|
import { ContractTemplateResolver } from './contract-template.resolver';
|
||||||
|
import { getTemplateMeta } from './contract-template.registry';
|
||||||
|
import { ContractViewModel } from './contract-view-model.builder';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signature row for the contract PDF. Mirrors the booking builder's
|
||||||
|
* `ContractSignatureView` but widens `role` to the contract's signer roles
|
||||||
|
* (CUSTOMER | STAFF | DIRECTOR | CEO).
|
||||||
|
*/
|
||||||
|
export interface ContractDocumentSignatureView {
|
||||||
|
role: ContractSignerRole;
|
||||||
|
signerDisplayName: string;
|
||||||
|
signedAt: string;
|
||||||
|
signatureImageUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single unit-rate row on the contract PDF — price per unit, NO total. */
|
||||||
|
export interface ContractUnitRateRow {
|
||||||
|
label: string;
|
||||||
|
unitPrice: number;
|
||||||
|
unit: string;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pricing schedule for a Contract document: a unit-rate schedule (one price per
|
||||||
|
* unit, e.g. "X ETB / container") with NO quantities and NO grand total. Shaped
|
||||||
|
* to stay structurally compatible with the renderer's expectations of
|
||||||
|
* {@link ContractViewModel.pricing} (it reads `currency`).
|
||||||
|
*/
|
||||||
|
export interface ContractUnitRateSchedule {
|
||||||
|
displayMode: 'UNIT_RATES';
|
||||||
|
unitRates: ContractUnitRateRow[];
|
||||||
|
currency: string;
|
||||||
|
equipmentReturn?: string;
|
||||||
|
originLabel: string;
|
||||||
|
destinationLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a stored contract unit to a human PDF suffix ("/ container", "/ ton", …). */
|
||||||
|
function unitLabel(unit: string): string {
|
||||||
|
switch (unit) {
|
||||||
|
case 'per_container':
|
||||||
|
return 'container';
|
||||||
|
case 'per_ton':
|
||||||
|
return 'ton';
|
||||||
|
case 'per_item':
|
||||||
|
return 'item';
|
||||||
|
case 'per_km':
|
||||||
|
return 'km';
|
||||||
|
default:
|
||||||
|
return 'unit';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the contract PDF view-model from the {@link Contract} aggregate (the new
|
||||||
|
* source of truth) — mirrors {@link ContractViewModelBuilder} but every field is
|
||||||
|
* sourced from the contract, its routes, cargo scope and unit-rate breakdown.
|
||||||
|
* The legacy booking-based builder remains untouched for the migration window.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ContractDocumentViewModelBuilder {
|
||||||
|
constructor(
|
||||||
|
private readonly contractsRepository: ContractsRepository,
|
||||||
|
private readonly templateResolver: ContractTemplateResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async build(
|
||||||
|
contractId: string,
|
||||||
|
): Promise<{ contract: Contract; view: ContractViewModel }> {
|
||||||
|
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||||
|
if (!contract) {
|
||||||
|
throw new NotFoundException(`Contract ${contractId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateKey =
|
||||||
|
contract.contractTemplateKey ?? this.templateResolver.resolve(this.toResolverInput(contract));
|
||||||
|
const template = getTemplateMeta(templateKey);
|
||||||
|
const pricing = this.buildPricing(contract);
|
||||||
|
const signatures = await this.loadSignatures(contractId);
|
||||||
|
|
||||||
|
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
|
||||||
|
const hasStaff = signatures.some((s) => s.role === 'STAFF');
|
||||||
|
const hasContractFile = Boolean(
|
||||||
|
contract.files?.some((f) => f.code === 'contract'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const view: ContractViewModel = {
|
||||||
|
bookingId: contract.id,
|
||||||
|
reference: contract.reference,
|
||||||
|
status: contract.status,
|
||||||
|
templateKey,
|
||||||
|
template,
|
||||||
|
contractDate: new Date().toLocaleDateString('en-GB', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
}),
|
||||||
|
contractYear: new Date().getFullYear(),
|
||||||
|
client: {
|
||||||
|
companyName: contract.company?.name ?? 'Client',
|
||||||
|
companyAddress: this.valueOrDash(contract.company?.address),
|
||||||
|
companyLocation: this.valueOrDash(contract.company?.country),
|
||||||
|
phone: this.valueOrDash(contract.company?.phone),
|
||||||
|
email: this.valueOrDash(contract.company?.email),
|
||||||
|
tinNumber: this.valueOrDash(contract.company?.tin),
|
||||||
|
vatNumber: this.valueOrDash(contract.company?.vatNumber),
|
||||||
|
fanNumber: this.valueOrDash(contract.company?.fanNumber),
|
||||||
|
businessLicense: this.valueOrDash(
|
||||||
|
contract.company?.companyProfiles?.[0]?.businessLicense,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
|
||||||
|
address: 'Addis Ababa, Ethiopia',
|
||||||
|
phone: '+251 11 872 0000',
|
||||||
|
email: 'info@edr.gov.et',
|
||||||
|
tinNumber: '—',
|
||||||
|
},
|
||||||
|
schedule: this.buildSchedule(contract),
|
||||||
|
pricing: pricing as unknown as ContractViewModel['pricing'],
|
||||||
|
// Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking
|
||||||
|
// view-model's narrower CUSTOMER|STAFF role union.
|
||||||
|
signatures: signatures as unknown as ContractViewModel['signatures'],
|
||||||
|
canSignCustomer: contract.status === 'CONTRACT_READY' && !hasCustomer,
|
||||||
|
canSignStaff:
|
||||||
|
contract.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
|
||||||
|
hasContractDocument: hasContractFile,
|
||||||
|
hasCustomerSignature: hasCustomer,
|
||||||
|
hasStaffSignature: hasStaff,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { contract, view };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadSignatures(
|
||||||
|
contractId: string,
|
||||||
|
): Promise<ContractDocumentSignatureView[]> {
|
||||||
|
const rows = await this.contractsRepository.findSignatures(contractId);
|
||||||
|
return rows.map((s) => this.toSignatureView(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
toSignatureView(row: ContractSignature): ContractDocumentSignatureView {
|
||||||
|
return {
|
||||||
|
role: row.role,
|
||||||
|
signerDisplayName: row.signerDisplayName,
|
||||||
|
signedAt: this.formatDate(row.signedAt),
|
||||||
|
signatureImageUrl: row.signatureFile?.url ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unit-rate schedule from the contract's frozen pricing breakdown — NO totals. */
|
||||||
|
private buildPricing(contract: Contract): ContractUnitRateSchedule {
|
||||||
|
const breakdown = contract.pricingBreakdown as ContractPricingBreakdown | null;
|
||||||
|
const currency = breakdown?.currency ?? contract.paymentCurrency;
|
||||||
|
const lineItems = breakdown?.lineItems ?? [];
|
||||||
|
const firstRoute = this.firstRoute(contract);
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayMode: 'UNIT_RATES',
|
||||||
|
unitRates: lineItems.map((line) => ({
|
||||||
|
label: line.label,
|
||||||
|
unitPrice: line.unitPrice,
|
||||||
|
unit: unitLabel(line.unit),
|
||||||
|
currency,
|
||||||
|
})),
|
||||||
|
currency,
|
||||||
|
equipmentReturn: contract.equipmentReturn ?? '—',
|
||||||
|
originLabel: this.yardLabel(firstRoute?.originYard),
|
||||||
|
destinationLabel: this.yardLabel(firstRoute?.destinationYard),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSchedule(contract: Contract): ContractViewModel['schedule'] {
|
||||||
|
const firstRoute = this.firstRoute(contract);
|
||||||
|
const cargoScope = (contract.cargoScope ?? [])[0];
|
||||||
|
const cargoName =
|
||||||
|
cargoScope?.cargoType?.cargoTypeName ||
|
||||||
|
cargoScope?.cargoFreeText ||
|
||||||
|
(cargoScope?.containerSize
|
||||||
|
? `${cargoScope.containerSize} container`
|
||||||
|
: 'Container cargo');
|
||||||
|
|
||||||
|
return {
|
||||||
|
originLabel: this.yardLabel(firstRoute?.originYard),
|
||||||
|
destinationLabel: this.yardLabel(firstRoute?.destinationYard),
|
||||||
|
tradeDirection: this.valueOrDash(contract.tradeDirection),
|
||||||
|
freightType: this.valueOrDash(contract.freightType),
|
||||||
|
serviceType: this.valueOrDash(
|
||||||
|
contract.serviceType?.serviceName ?? contract.serviceType?.code,
|
||||||
|
),
|
||||||
|
scheduledDate: this.formatDate(contract.estimatedShipmentDate),
|
||||||
|
contractType: this.valueOrDash(contract.contractType),
|
||||||
|
cargoDescription: this.valueOrDash(cargoName),
|
||||||
|
totalWeightVgm: '—',
|
||||||
|
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
|
||||||
|
hazardousLabel: contract.isHazardous ? 'Yes' : 'No',
|
||||||
|
firstMilePickupAddress: this.valueOrDash(contract.firstMilePickupAddress),
|
||||||
|
lastMileDeliveryAddress: this.valueOrDash(contract.lastMileDeliveryAddress),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The contract's primary route (lowest sortOrder), used for origin/destination labels. */
|
||||||
|
private firstRoute(contract: Contract): ContractRoute | undefined {
|
||||||
|
const routes = [...(contract.routes ?? [])].sort(
|
||||||
|
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
||||||
|
);
|
||||||
|
return routes[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The template resolver reads a Booking; a contract carries equivalent fields
|
||||||
|
* under a different shape (cargoType lives on cargoScope). Build a minimal,
|
||||||
|
* structurally-compatible adapter rather than widening the resolver signature.
|
||||||
|
*/
|
||||||
|
private toResolverInput(
|
||||||
|
contract: Contract,
|
||||||
|
): Parameters<ContractTemplateResolver['resolve']>[0] {
|
||||||
|
const cargoType = (contract.cargoScope ?? []).find((c) => c.cargoType)?.cargoType;
|
||||||
|
return {
|
||||||
|
tradeDirection: contract.tradeDirection,
|
||||||
|
freightType: contract.freightType,
|
||||||
|
paymentCurrency: contract.paymentCurrency,
|
||||||
|
cargoType: cargoType ?? undefined,
|
||||||
|
serviceType: contract.serviceType,
|
||||||
|
} as Parameters<ContractTemplateResolver['resolve']>[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private yardLabel(yard?: { label?: string; code?: string } | null): string {
|
||||||
|
return this.valueOrDash(yard?.label ?? yard?.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDate(value?: Date | string | null): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '—';
|
||||||
|
return date.toLocaleDateString('en-GB', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private valueOrDash(value?: string | number | null): string {
|
||||||
|
if (value === undefined || value === null || value === '') return '—';
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export for callers that want the role union without importing the entity.
|
||||||
|
export type { ContractSignerRole };
|
||||||
@@ -25,6 +25,26 @@
|
|||||||
<p><strong>Equipment return:</strong> {{pricing.equipmentReturn}}</p>
|
<p><strong>Equipment return:</strong> {{pricing.equipmentReturn}}</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if pricing.unitRates}}
|
||||||
|
<h3>Unit Rate Schedule</h3>
|
||||||
|
<p>
|
||||||
|
The rates below are the frozen unit prices applicable to this contract. Quantities and the resulting
|
||||||
|
totals are determined per shipment at booking time; no total contract value is fixed at this stage.
|
||||||
|
</p>
|
||||||
|
<table class="schedule">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Item</th><th>Unit price</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{#each pricing.unitRates}}
|
||||||
|
<tr>
|
||||||
|
<td>{{label}}</td>
|
||||||
|
<td>{{currency}} {{unitPrice}} / {{unit}}</td>
|
||||||
|
</tr>
|
||||||
|
{{/each}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
<h3>Charges</h3>
|
<h3>Charges</h3>
|
||||||
<table class="schedule">
|
<table class="schedule">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -56,6 +76,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{{/if}}
|
||||||
<h3>Terms of payment</h3>
|
<h3>Terms of payment</h3>
|
||||||
<p>
|
<p>
|
||||||
Unless otherwise agreed in writing, the Client shall settle the contract value in
|
Unless otherwise agreed in writing, the Client shall settle the contract value in
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds the admin-configurable "contract validity periods" setting (days). Stored
|
||||||
|
* as a dropdown_settings row whose options each hold a day count in `value`, so
|
||||||
|
* backoffice manages them through the existing Dropdown Settings UI and the
|
||||||
|
* contract staff-accept dialog only offers the configured durations.
|
||||||
|
*/
|
||||||
|
export class SeedContractValidityPeriods1792000000004
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'SeedContractValidityPeriods1792000000004';
|
||||||
|
private readonly code = 'contract_validity_periods';
|
||||||
|
private readonly options: Array<{ value: string; label: string }> = [
|
||||||
|
{ value: '180', label: '6 months' },
|
||||||
|
{ value: '365', label: '1 year' },
|
||||||
|
{ value: '730', label: '2 years' },
|
||||||
|
];
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const existing = await queryRunner.query(
|
||||||
|
`SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`,
|
||||||
|
[this.code],
|
||||||
|
);
|
||||||
|
if (existing.length > 0) return;
|
||||||
|
|
||||||
|
const inserted = await queryRunner.query(
|
||||||
|
`INSERT INTO freight.dropdown_settings (code, label, description, multiple)
|
||||||
|
VALUES ($1, $2, $3, false)
|
||||||
|
RETURNING id;`,
|
||||||
|
[
|
||||||
|
this.code,
|
||||||
|
'Contract Validity Periods (days)',
|
||||||
|
'Validity durations (in days) a staff can choose when accepting a submitted contract.',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const settingId = inserted[0].id;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.options.length; i++) {
|
||||||
|
const opt = this.options[i];
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO freight.dropdown_options (setting_id, value, label, display_order)
|
||||||
|
VALUES ($1, $2, $3, $4);`,
|
||||||
|
[settingId, opt.value, opt.label, i],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DELETE FROM freight.dropdown_settings WHERE code = $1;`,
|
||||||
|
[this.code],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the freight.last_mile_container_allocations table — container allocation
|
||||||
|
* records linking last-mile deliveries with containers and vehicles.
|
||||||
|
*/
|
||||||
|
export class CreateLastMileContainerAllocations1810000000002 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const exists = await queryRunner.hasTable('freight.last_mile_container_allocations');
|
||||||
|
if (exists) return;
|
||||||
|
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'freight.last_mile_container_allocations',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'uuid',
|
||||||
|
isPrimary: true,
|
||||||
|
default: 'gen_random_uuid()',
|
||||||
|
},
|
||||||
|
{ name: 'last_mile_id', type: 'uuid', isNullable: false },
|
||||||
|
{ name: 'container_id', type: 'uuid', isNullable: false },
|
||||||
|
{ name: 'vehicle_id', type: 'uuid', isNullable: true },
|
||||||
|
{
|
||||||
|
name: 'container_type',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'quantity',
|
||||||
|
type: 'integer',
|
||||||
|
default: 1,
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'freight.last_mile_container_allocations',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['last_mile_id'],
|
||||||
|
referencedTableName: 'freight.last_mile',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'freight.last_mile_container_allocations',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['vehicle_id'],
|
||||||
|
referencedTableName: 'freight.vehicles',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_last_mile_container_allocations_last_mile_id" ON "freight"."last_mile_container_allocations" ("last_mile_id")`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_last_mile_container_allocations_vehicle_id" ON "freight"."last_mile_container_allocations" ("vehicle_id")`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const exists = await queryRunner.hasTable('freight.last_mile_container_allocations');
|
||||||
|
if (exists) {
|
||||||
|
await queryRunner.dropTable('freight.last_mile_container_allocations');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddPostPaymentCompletedColumn1810000000004 implements MigrationInterface {
|
||||||
|
name = 'AddPostPaymentCompletedColumn1810000000004';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries');
|
||||||
|
if (firstMileTable) {
|
||||||
|
const hasColumn = await queryRunner.hasColumn('freight.first_mile_deliveries', 'is_post_payment_completed');
|
||||||
|
if (!hasColumn) {
|
||||||
|
await queryRunner.addColumn(
|
||||||
|
'freight.first_mile_deliveries',
|
||||||
|
new TableColumn({
|
||||||
|
name: 'is_post_payment_completed',
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
isNullable: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastMileTable = await queryRunner.hasTable('freight.last_mile_deliveries');
|
||||||
|
if (lastMileTable) {
|
||||||
|
const hasColumn = await queryRunner.hasColumn('freight.last_mile_deliveries', 'is_post_payment_completed');
|
||||||
|
if (!hasColumn) {
|
||||||
|
await queryRunner.addColumn(
|
||||||
|
'freight.last_mile_deliveries',
|
||||||
|
new TableColumn({
|
||||||
|
name: 'is_post_payment_completed',
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
isNullable: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const lastMileTable = await queryRunner.hasTable('freight.last_mile_deliveries');
|
||||||
|
if (lastMileTable) {
|
||||||
|
await queryRunner.dropColumn('freight.last_mile_deliveries', 'is_post_payment_completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries');
|
||||||
|
if (firstMileTable) {
|
||||||
|
await queryRunner.dropColumn('freight.first_mile_deliveries', 'is_post_payment_completed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddDistanceColumnsToVehicles1821000000002 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.vehicles
|
||||||
|
ADD COLUMN IF NOT EXISTS estimated_distance_km NUMERIC,
|
||||||
|
ADD COLUMN IF NOT EXISTS actual_distance_km NUMERIC;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.vehicles
|
||||||
|
DROP COLUMN IF EXISTS estimated_distance_km,
|
||||||
|
DROP COLUMN IF EXISTS actual_distance_km;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freight billing — `invoices` + `invoice_lines` tables.
|
||||||
|
*
|
||||||
|
* Matches:
|
||||||
|
* - billing/entities/invoice.entity.ts
|
||||||
|
* - billing/entities/invoice-line.entity.ts
|
||||||
|
*
|
||||||
|
* The status enum mirrors `Freight.InvoiceStatus` and uses TypeORM's default
|
||||||
|
* enum-type name (`<table>_<column>_enum`) so the entity's `type: "enum"`
|
||||||
|
* column resolves to it without an explicit `enumName`.
|
||||||
|
*/
|
||||||
|
export class CreateInvoices1821000000002 implements MigrationInterface {
|
||||||
|
name = "CreateInvoices1821000000002";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const typeExists = await queryRunner.query(
|
||||||
|
`SELECT 1 FROM pg_type WHERE typname = 'invoices_status_enum' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'freight');`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!typeExists.length) {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TYPE freight.invoices_status_enum AS ENUM (
|
||||||
|
'DRAFT',
|
||||||
|
'PENDING',
|
||||||
|
'PAID',
|
||||||
|
'OVERDUE',
|
||||||
|
'CANCELLED',
|
||||||
|
'REFUNDED'
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.invoices (
|
||||||
|
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||||
|
invoice_number varchar(64) NOT NULL,
|
||||||
|
company_id uuid NOT NULL,
|
||||||
|
company_profile_id uuid NOT NULL,
|
||||||
|
total_amount numeric(14, 2) NOT NULL,
|
||||||
|
currency varchar(8) NOT NULL DEFAULT 'ETB',
|
||||||
|
status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT',
|
||||||
|
source varchar(255) NOT NULL,
|
||||||
|
source_id varchar(255) NOT NULL,
|
||||||
|
type varchar(255) NOT NULL,
|
||||||
|
issued_at timestamptz,
|
||||||
|
payment_id uuid,
|
||||||
|
due_at timestamptz NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
CONSTRAINT pk_invoices PRIMARY KEY (id),
|
||||||
|
CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number),
|
||||||
|
CONSTRAINT fk_invoices_company FOREIGN KEY (company_id)
|
||||||
|
REFERENCES freight.companies (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_invoices_company_profile FOREIGN KEY (company_profile_id)
|
||||||
|
REFERENCES freight.company_profiles (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_invoices_payment FOREIGN KEY (payment_id)
|
||||||
|
REFERENCES freight.payments (id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX idx_invoices_status ON freight.invoices (status);`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE freight.invoice_lines (
|
||||||
|
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||||
|
invoice_id uuid NOT NULL,
|
||||||
|
charge_type varchar NOT NULL,
|
||||||
|
description varchar(255),
|
||||||
|
quantity numeric(12, 2) NOT NULL DEFAULT 1,
|
||||||
|
unit_rate numeric(14, 2) NOT NULL DEFAULT 0,
|
||||||
|
amount numeric(14, 2) NOT NULL,
|
||||||
|
currency varchar(8) NOT NULL DEFAULT 'ETB',
|
||||||
|
metadata jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
CONSTRAINT pk_invoice_lines PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_invoice_lines_invoice FOREIGN KEY (invoice_id)
|
||||||
|
REFERENCES freight.invoices (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.invoice_lines;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.invoices;`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP TYPE IF EXISTS freight.invoices_status_enum;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Government bookings now bill to a real seeded government company + an explicit
|
||||||
|
* importer/exporter profile, instead of carrying a null company + free-text
|
||||||
|
* institution. This migration:
|
||||||
|
*
|
||||||
|
* 1. Adds `companies.kind` (commercial | government).
|
||||||
|
* 2. Seeds the Ethiopian government entities + their importer/exporter
|
||||||
|
* profiles (mirrors src/seed/data/gov-companies.data.ts — keep in sync).
|
||||||
|
* 3. Backfills every booking with a NULL company_id / company_profile_id so
|
||||||
|
* the NOT NULL constraints below can be applied:
|
||||||
|
* - NULL company_id → the default government company.
|
||||||
|
* - NULL company_profile_id → the company's profile matching the booking
|
||||||
|
* trade direction; else any profile of the company; else the default
|
||||||
|
* government importer profile.
|
||||||
|
* 4. Enforces NOT NULL on bookings.company_id and bookings.company_profile_id.
|
||||||
|
*/
|
||||||
|
export class AddCompanyKindAndGovBookingLinks1821000000003
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "AddCompanyKindAndGovBookingLinks1821000000003";
|
||||||
|
|
||||||
|
// Mirrors src/seed/data/gov-companies.data.ts
|
||||||
|
private readonly govCompanies = [
|
||||||
|
{ id: "0a1b0001-0000-4000-8000-000000000001", name: "Federal Government of Ethiopia", tin: "0000000001", email: "procurement@gov.et", phone: "+251111000001", im: "0b1c0001-0000-4000-8000-000000000001", ex: "0b1c0001-0000-4000-8000-000000000002", imRef: "IM-90001", exRef: "EX-90001" },
|
||||||
|
{ id: "0a1b0002-0000-4000-8000-000000000002", name: "Ministry of National Defense", tin: "0000000002", email: "logistics@mod.gov.et", phone: "+251111000002", im: "0b1c0002-0000-4000-8000-000000000001", ex: "0b1c0002-0000-4000-8000-000000000002", imRef: "IM-90002", exRef: "EX-90002" },
|
||||||
|
{ id: "0a1b0003-0000-4000-8000-000000000003", name: "Ethiopian Roads Administration", tin: "0000000003", email: "supply@era.gov.et", phone: "+251111000003", im: "0b1c0003-0000-4000-8000-000000000001", ex: "0b1c0003-0000-4000-8000-000000000002", imRef: "IM-90003", exRef: "EX-90003" },
|
||||||
|
{ id: "0a1b0004-0000-4000-8000-000000000004", name: "Ministry of Agriculture", tin: "0000000004", email: "imports@moa.gov.et", phone: "+251111000004", im: "0b1c0004-0000-4000-8000-000000000001", ex: "0b1c0004-0000-4000-8000-000000000002", imRef: "IM-90004", exRef: "EX-90004" },
|
||||||
|
{ id: "0a1b0005-0000-4000-8000-000000000005", name: "Ministry of Trade and Regional Integration", tin: "0000000005", email: "trade@motri.gov.et", phone: "+251111000005", im: "0b1c0005-0000-4000-8000-000000000001", ex: "0b1c0005-0000-4000-8000-000000000002", imRef: "IM-90005", exRef: "EX-90005" },
|
||||||
|
{ id: "0a1b0006-0000-4000-8000-000000000006", name: "Ethiopian Disaster Risk Management Commission", tin: "0000000006", email: "relief@edrmc.gov.et", phone: "+251111000006", im: "0b1c0006-0000-4000-8000-000000000001", ex: "0b1c0006-0000-4000-8000-000000000002", imRef: "IM-90006", exRef: "EX-90006" },
|
||||||
|
];
|
||||||
|
|
||||||
|
private get defaultCompanyId(): string {
|
||||||
|
return this.govCompanies[0].id;
|
||||||
|
}
|
||||||
|
private get defaultImporterProfileId(): string {
|
||||||
|
return this.govCompanies[0].im;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// 1. kind column
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."companies" ADD COLUMN IF NOT EXISTS "kind" varchar(20) NOT NULL DEFAULT 'commercial'`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_companies_kind" ON "freight"."companies" ("kind")`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. seed government companies + importer/exporter profiles (idempotent)
|
||||||
|
for (const g of this.govCompanies) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "freight"."companies" ("id", "name", "type", "kind", "status", "tin", "country", "email", "phone")
|
||||||
|
VALUES ($1, $2, 'customer', 'government', 'active', $3, 'Ethiopia', $4, $5)
|
||||||
|
ON CONFLICT ("id") DO NOTHING`,
|
||||||
|
[g.id, g.name, g.tin, g.email, g.phone],
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "freight"."company_profiles" ("id", "company_id", "type", "reference", "status")
|
||||||
|
VALUES ($1, $2, 'importer', $3, 'active'), ($4, $2, 'exporter', $5, 'active')
|
||||||
|
ON CONFLICT ("id") DO NOTHING`,
|
||||||
|
[g.im, g.id, g.imRef, g.ex, g.exRef],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3a. backfill NULL company_id → default government company
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE "freight"."bookings" SET "company_id" = $1 WHERE "company_id" IS NULL`,
|
||||||
|
[this.defaultCompanyId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3b. backfill NULL company_profile_id → profile matching trade direction
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE "freight"."bookings" b
|
||||||
|
SET "company_profile_id" = cp."id"
|
||||||
|
FROM "freight"."company_profiles" cp
|
||||||
|
WHERE b."company_profile_id" IS NULL
|
||||||
|
AND cp."company_id" = b."company_id"
|
||||||
|
AND cp."deleted_at" IS NULL
|
||||||
|
AND cp."type" = CASE b."trade_direction"
|
||||||
|
WHEN 'IMPORT' THEN 'importer'
|
||||||
|
WHEN 'EXPORT' THEN 'exporter'
|
||||||
|
ELSE NULL END`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3c. fallback → any profile of the booking's company
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE "freight"."bookings" b
|
||||||
|
SET "company_profile_id" = (
|
||||||
|
SELECT cp."id" FROM "freight"."company_profiles" cp
|
||||||
|
WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL
|
||||||
|
ORDER BY cp."created_at" ASC LIMIT 1)
|
||||||
|
WHERE b."company_profile_id" IS NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM "freight"."company_profiles" cp
|
||||||
|
WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3d. final fallback → default government importer profile
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE "freight"."bookings" SET "company_profile_id" = $1 WHERE "company_profile_id" IS NULL`,
|
||||||
|
[this.defaultImporterProfileId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. enforce NOT NULL
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" SET NOT NULL`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" SET NOT NULL`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" DROP NOT NULL`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" DROP NOT NULL`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS "freight"."IDX_companies_kind"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."companies" DROP COLUMN IF EXISTS "kind"`,
|
||||||
|
);
|
||||||
|
// Seeded government rows are intentionally left in place.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the payment projection source-agnostic so any domain (not just bookings)
|
||||||
|
* can own a payment intent.
|
||||||
|
*
|
||||||
|
* - `payments.type` enum `('booking')` → `varchar(50)`. It now stores the
|
||||||
|
* invoice SOURCE (e.g. 'booking', 'demurrage'), supplied by the caller, so a
|
||||||
|
* new domain no longer needs an enum migration to write its intents.
|
||||||
|
* - adds `payments.reference_type varchar(40)` — the gateway reference type
|
||||||
|
* (`PaymentReferenceType`) the intent was opened with, so the reconcile/poll
|
||||||
|
* path can query the provider without hardcoding it.
|
||||||
|
*
|
||||||
|
* Matches payment/entities/payment.entity.ts.
|
||||||
|
*/
|
||||||
|
export class MakePaymentsTypeGeneric1821000000004 implements MigrationInterface {
|
||||||
|
name = "MakePaymentsTypeGeneric1821000000004";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.payments ALTER COLUMN type TYPE varchar(50) USING type::text;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TYPE IF EXISTS freight.payments_type_enum;`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.payments ADD COLUMN reference_type varchar(40);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.payments DROP COLUMN IF EXISTS reference_type;`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Restore the single-value enum. Any non-'booking' rows would block the cast;
|
||||||
|
// collapse them first so the down migration is safe.
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE freight.payments SET type = 'booking' WHERE type <> 'booking';`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TYPE freight.payments_type_enum AS ENUM ('booking');`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.payments ALTER COLUMN type TYPE freight.payments_type_enum USING type::freight.payments_type_enum;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract–Booking separation (additive phase). Introduces a first-class
|
||||||
|
* `freight.contracts` aggregate that owns the legal/commercial agreement (scope
|
||||||
|
* + unit rates, no quantities) and spawns shipment `bookings` via `contract_id`.
|
||||||
|
*
|
||||||
|
* Purely additive: no legacy columns are dropped here. The data backfill and
|
||||||
|
* legacy-column removal happen in a later cutover migration.
|
||||||
|
*
|
||||||
|
* See docs/new-doc.md §5.
|
||||||
|
*/
|
||||||
|
export class CreateContracts1822000000000 implements MigrationInterface {
|
||||||
|
name = 'CreateContracts1822000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// ── contracts ───────────────────────────────────────────────────────────
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.contracts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
reference VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
|
||||||
|
company_id UUID,
|
||||||
|
company_profile_id UUID,
|
||||||
|
is_government BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
government_institution VARCHAR(255),
|
||||||
|
|
||||||
|
contract_kind VARCHAR(20) NOT NULL,
|
||||||
|
renewal_of_id UUID REFERENCES freight.contracts(id),
|
||||||
|
trade_direction VARCHAR(10) NOT NULL,
|
||||||
|
freight_type VARCHAR(20) NOT NULL,
|
||||||
|
|
||||||
|
service_type_id UUID NOT NULL,
|
||||||
|
payment_currency VARCHAR(5) NOT NULL,
|
||||||
|
customs_clearing_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
customs_clearing_agent VARCHAR(200),
|
||||||
|
equipment_return VARCHAR(20),
|
||||||
|
|
||||||
|
first_mile_pickup_address TEXT,
|
||||||
|
first_mile_pickup_lat NUMERIC(10,7),
|
||||||
|
first_mile_pickup_lng NUMERIC(10,7),
|
||||||
|
last_mile_delivery_address TEXT,
|
||||||
|
last_mile_delivery_lat NUMERIC(10,7),
|
||||||
|
last_mile_delivery_lng NUMERIC(10,7),
|
||||||
|
|
||||||
|
is_hazardous BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_reefer BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
|
||||||
|
estimated_shipment_date TIMESTAMPTZ,
|
||||||
|
contract_validity_days INT,
|
||||||
|
contract_valid_from TIMESTAMPTZ,
|
||||||
|
contract_valid_until TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
status VARCHAR(40) NOT NULL DEFAULT 'DRAFT',
|
||||||
|
clearance_status VARCHAR(40) NOT NULL DEFAULT 'NOT_APPLICABLE',
|
||||||
|
clearance_cycle_number INT NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
pricing_breakdown JSONB,
|
||||||
|
pricing_display_mode VARCHAR(20) DEFAULT 'UNIT_RATES',
|
||||||
|
|
||||||
|
contract_type VARCHAR(20),
|
||||||
|
contract_template_key VARCHAR(128),
|
||||||
|
contract_generated_at TIMESTAMPTZ,
|
||||||
|
contract_summary TEXT,
|
||||||
|
version_number INT NOT NULL DEFAULT 1,
|
||||||
|
financial_terms JSONB,
|
||||||
|
|
||||||
|
approved_by_staff_id UUID,
|
||||||
|
approved_by_staff_at TIMESTAMPTZ,
|
||||||
|
signed_by_director_id UUID,
|
||||||
|
signed_by_director_at TIMESTAMPTZ,
|
||||||
|
signed_by_ceo_id UUID,
|
||||||
|
signed_by_ceo_at TIMESTAMPTZ,
|
||||||
|
customer_signed_at TIMESTAMPTZ,
|
||||||
|
fully_executed_at TIMESTAMPTZ,
|
||||||
|
locked_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_company ON freight.contracts(company_id);`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_status ON freight.contracts(status);`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_kind ON freight.contracts(contract_kind);`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_valid_until ON freight.contracts(contract_valid_until);`);
|
||||||
|
|
||||||
|
// ── contract_routes ──────────────────────────────────────────────────────
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.contract_routes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||||
|
origin_yard_id UUID NOT NULL,
|
||||||
|
destination_yard_id UUID NOT NULL,
|
||||||
|
km NUMERIC(10,2),
|
||||||
|
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT uq_contract_route UNIQUE (contract_id, origin_yard_id, destination_yard_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_routes_contract ON freight.contract_routes(contract_id);`);
|
||||||
|
|
||||||
|
// ── contract_cargo_scope ─────────────────────────────────────────────────
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.contract_cargo_scope (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||||
|
container_size VARCHAR(10),
|
||||||
|
cargo_type_id UUID,
|
||||||
|
cargo_free_text VARCHAR(200),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT uq_contract_container_size UNIQUE NULLS NOT DISTINCT (contract_id, container_size)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_cargo_scope_contract ON freight.contract_cargo_scope(contract_id);`);
|
||||||
|
|
||||||
|
// ── contract_signatures ──────────────────────────────────────────────────
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.contract_signatures (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(20) NOT NULL,
|
||||||
|
signer_display_name VARCHAR(255) NOT NULL,
|
||||||
|
signature_file_id UUID,
|
||||||
|
consent_text TEXT,
|
||||||
|
signed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_signatures_contract ON freight.contract_signatures(contract_id);`);
|
||||||
|
|
||||||
|
// ── contract_approval_steps ──────────────────────────────────────────────
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.contract_approval_steps (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||||
|
step_order SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
required_role VARCHAR(40) NOT NULL,
|
||||||
|
blocks_role VARCHAR(40),
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||||
|
acted_by_staff_id UUID,
|
||||||
|
acted_at TIMESTAMPTZ,
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_approval_steps_contract ON freight.contract_approval_steps(contract_id);`);
|
||||||
|
|
||||||
|
// ── contract_rate_snapshots ──────────────────────────────────────────────
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.contract_rate_snapshots (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||||
|
rate_id UUID,
|
||||||
|
rate_code VARCHAR(64) NOT NULL,
|
||||||
|
description VARCHAR(255),
|
||||||
|
unit_price NUMERIC(14,2) NOT NULL,
|
||||||
|
unit_of_measure VARCHAR(32) NOT NULL,
|
||||||
|
currency VARCHAR(5) NOT NULL,
|
||||||
|
container_size VARCHAR(10),
|
||||||
|
is_surcharge BOOLEAN DEFAULT FALSE,
|
||||||
|
conditional_on VARCHAR(32),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_rate_snapshots_contract ON freight.contract_rate_snapshots(contract_id);`);
|
||||||
|
|
||||||
|
// ── contract_review_notes ────────────────────────────────────────────────
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.contract_review_notes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||||
|
note_type VARCHAR(40) NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
author_role VARCHAR(20),
|
||||||
|
author_user_id UUID,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_review_notes_contract ON freight.contract_review_notes(contract_id);`);
|
||||||
|
|
||||||
|
// ── contract_clearance_cycles ────────────────────────────────────────────
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.contract_clearance_cycles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||||
|
cycle_number INT NOT NULL,
|
||||||
|
status VARCHAR(40) NOT NULL DEFAULT 'AWAITING_DOCUMENTS',
|
||||||
|
booking_id UUID,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
clearance_ready_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT uq_contract_clearance_cycle UNIQUE (contract_id, cycle_number)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_clearance_cycles_contract ON freight.contract_clearance_cycles(contract_id);`);
|
||||||
|
|
||||||
|
// ── contract_document_review (pre-booking clearance, Path B) ─────────────
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.contract_document_review (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||||
|
clearance_cycle_id UUID REFERENCES freight.contract_clearance_cycles(id),
|
||||||
|
setting_code VARCHAR(128) NOT NULL,
|
||||||
|
file_key VARCHAR(128) NOT NULL,
|
||||||
|
file_record_id UUID,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||||
|
note TEXT,
|
||||||
|
uploaded_by_role VARCHAR(20) NOT NULL DEFAULT 'CUSTOMER',
|
||||||
|
reviewed_by_staff_id UUID,
|
||||||
|
reviewed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT uq_contract_document_review_doc
|
||||||
|
UNIQUE NULLS NOT DISTINCT (contract_id, clearance_cycle_id, setting_code, file_key)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_doc_review_contract ON freight.contract_document_review(contract_id);`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_doc_review_status ON freight.contract_document_review(status);`);
|
||||||
|
|
||||||
|
// ── clearance_milestones (GL tracking) ───────────────────────────────────
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.clearance_milestones (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
booking_id UUID REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||||
|
contract_id UUID REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||||
|
clearance_cycle_id UUID REFERENCES freight.contract_clearance_cycles(id),
|
||||||
|
milestone_code VARCHAR(64) NOT NULL,
|
||||||
|
milestone_label VARCHAR(255) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||||
|
owner_region VARCHAR(5),
|
||||||
|
triggered_by_doc BOOLEAN DEFAULT FALSE,
|
||||||
|
triggered_at TIMESTAMPTZ,
|
||||||
|
triggered_by_user_id UUID,
|
||||||
|
note TEXT,
|
||||||
|
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_clearance_milestones_booking ON freight.clearance_milestones(booking_id);`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_clearance_milestones_contract ON freight.clearance_milestones(contract_id);`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_clearance_milestones_region ON freight.clearance_milestones(owner_region, status);`);
|
||||||
|
// booking-scoped and contract-cycle-scoped uniqueness for milestone codes
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_clearance_milestone_booking
|
||||||
|
ON freight.clearance_milestones(booking_id, milestone_code) WHERE booking_id IS NOT NULL;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_clearance_milestone_cycle
|
||||||
|
ON freight.clearance_milestones(clearance_cycle_id, milestone_code) WHERE clearance_cycle_id IS NOT NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── booking_container_units (per-unit container detail) ──────────────────
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.booking_container_units (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
booking_container_id UUID NOT NULL REFERENCES freight.booking_container(id) ON DELETE CASCADE,
|
||||||
|
container_number VARCHAR(64) NOT NULL,
|
||||||
|
seal_number VARCHAR(64),
|
||||||
|
vgm_tons NUMERIC(10,3) NOT NULL,
|
||||||
|
is_hazardous BOOLEAN DEFAULT FALSE,
|
||||||
|
is_reefer BOOLEAN DEFAULT FALSE,
|
||||||
|
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT uq_booking_container_unit_number UNIQUE (booking_container_id, container_number)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── ALTER bookings ───────────────────────────────────────────────────────
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_id UUID REFERENCES freight.contracts(id);`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_route_id UUID REFERENCES freight.contract_routes(id);`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS created_by_role VARCHAR(20) DEFAULT 'CUSTOMER';`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS created_by_user_id UUID;`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_bookings_contract ON freight.bookings(contract_id);`);
|
||||||
|
// One active booking per ONE_TIME contract. Postgres forbids a subquery in an
|
||||||
|
// index predicate, so we denormalize the contract kind onto the booking and
|
||||||
|
// predicate on that. The column is stamped at booking creation from the
|
||||||
|
// contract; the app layer (ContractBookingService) is the primary guard and
|
||||||
|
// this index is the backstop.
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_kind VARCHAR(20);`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_one_active_booking_per_one_time_contract
|
||||||
|
ON freight.bookings (contract_id)
|
||||||
|
WHERE status NOT IN ('EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED')
|
||||||
|
AND contract_id IS NOT NULL
|
||||||
|
AND contract_kind = 'ONE_TIME';
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── ALTER booking_container ──────────────────────────────────────────────
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.booking_container ADD COLUMN IF NOT EXISTS container_size VARCHAR(10);`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.booking_container ADD COLUMN IF NOT EXISTS hazardous_quantity SMALLINT DEFAULT 0;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.booking_container ADD COLUMN IF NOT EXISTS reefer_quantity SMALLINT DEFAULT 0;`);
|
||||||
|
|
||||||
|
// ── ALTER booking_document_review (denormalized contract link) ───────────
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.booking_document_review ADD COLUMN IF NOT EXISTS contract_id UUID REFERENCES freight.contracts(id);`);
|
||||||
|
|
||||||
|
// ── Extend file_upload_fields with phased GL metadata ────────────────────
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS phase VARCHAR(40);`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS owner_region VARCHAR(5);`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS trade_direction VARCHAR(10);`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS triggers_milestone_code VARCHAR(64);`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS triggers_milestone_code;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS trade_direction;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS owner_region;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS phase;`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.booking_document_review DROP COLUMN IF EXISTS contract_id;`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.booking_container DROP COLUMN IF EXISTS reefer_quantity;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.booking_container DROP COLUMN IF EXISTS hazardous_quantity;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.booking_container DROP COLUMN IF EXISTS container_size;`);
|
||||||
|
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_one_active_booking_per_one_time_contract;`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_contract;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_kind;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS created_by_user_id;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS created_by_role;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_route_id;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_id;`);
|
||||||
|
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_container_units;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.clearance_milestones;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_document_review;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_clearance_cycles;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_review_notes;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_rate_snapshots;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_approval_steps;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_signatures;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_cargo_scope;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_routes;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.contracts;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateImportDjiboutiOperations1822000000000 implements MigrationInterface {
|
||||||
|
name = 'CreateImportDjiboutiOperations1822000000000';
|
||||||
|
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
schema: 'freight',
|
||||||
|
name: 'import_djibouti_operations',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||||
|
{ name: 'train_schedule_id', type: 'uuid', isUnique: true },
|
||||||
|
{ name: 'documents', type: 'jsonb', default: "'{}'::jsonb" },
|
||||||
|
{ name: 'gatepass_granted_at', type: 'timestamptz', isNullable: true },
|
||||||
|
{ name: 'ready_for_loading_at', type: 'timestamptz', isNullable: true },
|
||||||
|
{ name: 'loaded_on_train_at', type: 'timestamptz', isNullable: true },
|
||||||
|
{ name: 'departed_from_djibouti_at', type: 'timestamptz', isNullable: true },
|
||||||
|
{ name: 'load_list_generated_at', type: 'timestamptz', isNullable: true },
|
||||||
|
{ name: 'performed_by', type: 'varchar', length: '120', isNullable: true },
|
||||||
|
{ name: 'notes', type: 'text', isNullable: true },
|
||||||
|
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'freight.import_djibouti_operations',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'idx_import_djibouti_operations_schedule',
|
||||||
|
columnNames: ['train_schedule_id'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'freight.import_djibouti_operations',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['train_schedule_id'],
|
||||||
|
referencedTableName: 'train_schedules',
|
||||||
|
referencedSchema: 'freight',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('freight.import_djibouti_operations', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data backfill for the contract–booking separation (docs/new-doc.md §17).
|
||||||
|
*
|
||||||
|
* For every legacy `booking_type = 'GENERAL_CONTRACT'` booking we synthesise a
|
||||||
|
* `freight.contracts` row from its contract-phase columns, copy its routes
|
||||||
|
* (contract_route_lines → contract_routes, dropping quantity), and point the
|
||||||
|
* contract + every child shipment booking (linked via booking_orders) at it.
|
||||||
|
*
|
||||||
|
* Per §19 item 1, historical ONE_TIME bookings that went through the full
|
||||||
|
* contract flow get a contract parent inserted and `contract_id` set on the same
|
||||||
|
* booking row (no row split).
|
||||||
|
*
|
||||||
|
* Idempotent: skips bookings that already have `contract_id` set, and matches a
|
||||||
|
* synthesised contract by a deterministic `CTR-<bookingId>` reference.
|
||||||
|
*/
|
||||||
|
export class BackfillContractsFromBookings1823000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'BackfillContractsFromBookings1823000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// 1. One contract per GENERAL_CONTRACT booking, carrying the contract-phase
|
||||||
|
// columns. Reference is derived from the source booking id so re-runs are
|
||||||
|
// idempotent (ON CONFLICT DO NOTHING on the unique reference).
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO freight.contracts (
|
||||||
|
reference, company_id, company_profile_id, is_government, government_institution,
|
||||||
|
contract_kind, trade_direction, freight_type, service_type_id, payment_currency,
|
||||||
|
customs_clearing_enabled, customs_clearing_agent, equipment_return,
|
||||||
|
first_mile_pickup_address, first_mile_pickup_lat, first_mile_pickup_lng,
|
||||||
|
last_mile_delivery_address, last_mile_delivery_lat, last_mile_delivery_lng,
|
||||||
|
is_hazardous, is_reefer, estimated_shipment_date,
|
||||||
|
contract_validity_days, contract_valid_from, contract_valid_until, expires_at,
|
||||||
|
status, clearance_status, clearance_cycle_number,
|
||||||
|
pricing_breakdown, contract_type, contract_template_key, contract_generated_at,
|
||||||
|
contract_summary, version_number,
|
||||||
|
approved_by_staff_id, approved_by_staff_at,
|
||||||
|
signed_by_director_id, signed_by_director_at,
|
||||||
|
signed_by_ceo_id, signed_by_ceo_at, customer_signed_at, fully_executed_at,
|
||||||
|
created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'CTR-' || b.id::text, b.company_id, b.company_profile_id, b.is_government, b.government_institution,
|
||||||
|
'GENERAL', b.trade_direction, b.freight_type, b.service_type_id, b.payment_currency,
|
||||||
|
b.customs_clearing_enabled, b.customs_clearing_agent, b.equipment_return,
|
||||||
|
b.first_mile_pickup_address, b.first_mile_pickup_lat, b.first_mile_pickup_lng,
|
||||||
|
b.last_mile_delivery_address, b.last_mile_delivery_lat, b.last_mile_delivery_lng,
|
||||||
|
b.is_hazardous, b.is_reefer, b.estimated_shipment_date,
|
||||||
|
b.contract_validity_days, b.contract_valid_from, b.contract_valid_until, b.expires_at,
|
||||||
|
CASE
|
||||||
|
WHEN b.status IN ('CONTRACT_ACTIVE') THEN 'CONTRACT_ACTIVE'
|
||||||
|
WHEN b.status IN ('CONTRACT_CLOSED') THEN 'CONTRACT_CLOSED'
|
||||||
|
WHEN b.status IN ('EXPIRED') THEN 'EXPIRED'
|
||||||
|
WHEN b.status IN ('CANCELLED') THEN 'CANCELLED'
|
||||||
|
WHEN b.status IN ('REJECTED') THEN 'REJECTED'
|
||||||
|
ELSE 'CONTRACT_ACTIVE'
|
||||||
|
END,
|
||||||
|
CASE WHEN b.customs_clearing_enabled THEN 'NOT_APPLICABLE' ELSE 'NOT_APPLICABLE' END,
|
||||||
|
0,
|
||||||
|
b.pricing_breakdown,
|
||||||
|
b.contract_type, b.contract_template_key, b.contract_generated_at,
|
||||||
|
b.contract_summary, COALESCE(b.version_number, 1),
|
||||||
|
b.approved_by_staff_id, b.approved_by_staff_at,
|
||||||
|
b.signed_by_director_id, b.signed_by_director_at,
|
||||||
|
b.signed_by_ceo_id, b.signed_by_ceo_at, b.customer_signed_at, b.fully_executed_at,
|
||||||
|
b.created_at, b.updated_at
|
||||||
|
FROM freight.bookings b
|
||||||
|
WHERE b.booking_type = 'GENERAL_CONTRACT'
|
||||||
|
ON CONFLICT (reference) DO NOTHING;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 2. Copy each general contract's route lines into contract_routes (no qty).
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO freight.contract_routes (contract_id, origin_yard_id, destination_yard_id, km, sort_order, created_at, updated_at)
|
||||||
|
SELECT c.id, crl.origin_yard_id, crl.destination_yard_id, crl.km, 0, now(), now()
|
||||||
|
FROM freight.contract_route_lines crl
|
||||||
|
JOIN freight.contracts c ON c.reference = 'CTR-' || crl.contract_booking_id::text
|
||||||
|
ON CONFLICT (contract_id, origin_yard_id, destination_yard_id) DO NOTHING;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 3. Point the general-contract booking itself at its new contract, and stamp
|
||||||
|
// the denormalized contract_kind for the active-booking index.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.bookings b
|
||||||
|
SET contract_id = c.id, contract_kind = 'GENERAL', created_by_role = 'CUSTOMER'
|
||||||
|
FROM freight.contracts c
|
||||||
|
WHERE c.reference = 'CTR-' || b.id::text
|
||||||
|
AND b.booking_type = 'GENERAL_CONTRACT'
|
||||||
|
AND b.contract_id IS NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 4. Point each child shipment booking (spawned via booking_orders) at the
|
||||||
|
// same contract as its parent general contract.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.bookings child
|
||||||
|
SET contract_id = c.id, contract_kind = 'GENERAL', created_by_role = 'CUSTOMER'
|
||||||
|
FROM freight.booking_orders bo
|
||||||
|
JOIN freight.contracts c ON c.reference = 'CTR-' || bo.contract_booking_id::text
|
||||||
|
WHERE child.id = bo.booking_id
|
||||||
|
AND child.contract_id IS NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 5. Historical ONE_TIME bookings that completed the contract flow: synthesise
|
||||||
|
// a contract parent and point the same booking row at it (no row split).
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO freight.contracts (
|
||||||
|
reference, company_id, company_profile_id, is_government, government_institution,
|
||||||
|
contract_kind, trade_direction, freight_type, service_type_id, payment_currency,
|
||||||
|
customs_clearing_enabled, customs_clearing_agent, equipment_return,
|
||||||
|
first_mile_pickup_address, first_mile_pickup_lat, first_mile_pickup_lng,
|
||||||
|
last_mile_delivery_address, last_mile_delivery_lat, last_mile_delivery_lng,
|
||||||
|
is_hazardous, is_reefer, estimated_shipment_date,
|
||||||
|
contract_validity_days, contract_valid_from, contract_valid_until,
|
||||||
|
status, clearance_status, clearance_cycle_number,
|
||||||
|
pricing_breakdown, contract_type, contract_template_key, contract_generated_at,
|
||||||
|
contract_summary, version_number,
|
||||||
|
approved_by_staff_id, approved_by_staff_at,
|
||||||
|
signed_by_director_id, signed_by_director_at,
|
||||||
|
signed_by_ceo_id, signed_by_ceo_at, customer_signed_at, fully_executed_at,
|
||||||
|
created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'CTR-' || b.id::text, b.company_id, b.company_profile_id, b.is_government, b.government_institution,
|
||||||
|
'ONE_TIME', b.trade_direction, b.freight_type, b.service_type_id, b.payment_currency,
|
||||||
|
b.customs_clearing_enabled, b.customs_clearing_agent, b.equipment_return,
|
||||||
|
b.first_mile_pickup_address, b.first_mile_pickup_lat, b.first_mile_pickup_lng,
|
||||||
|
b.last_mile_delivery_address, b.last_mile_delivery_lat, b.last_mile_delivery_lng,
|
||||||
|
b.is_hazardous, b.is_reefer, b.estimated_shipment_date,
|
||||||
|
b.contract_validity_days, b.contract_valid_from, b.contract_valid_until,
|
||||||
|
'FULLY_EXECUTED', 'NOT_APPLICABLE', 0,
|
||||||
|
b.pricing_breakdown, b.contract_type, b.contract_template_key, b.contract_generated_at,
|
||||||
|
b.contract_summary, COALESCE(b.version_number, 1),
|
||||||
|
b.approved_by_staff_id, b.approved_by_staff_at,
|
||||||
|
b.signed_by_director_id, b.signed_by_director_at,
|
||||||
|
b.signed_by_ceo_id, b.signed_by_ceo_at, b.customer_signed_at, b.fully_executed_at,
|
||||||
|
b.created_at, b.updated_at
|
||||||
|
FROM freight.bookings b
|
||||||
|
WHERE COALESCE(b.booking_type, 'ONE_TIME') = 'ONE_TIME'
|
||||||
|
AND b.contract_id IS NULL
|
||||||
|
AND b.contract_generated_at IS NOT NULL
|
||||||
|
ON CONFLICT (reference) DO NOTHING;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.bookings b
|
||||||
|
SET contract_id = c.id, contract_kind = 'ONE_TIME', created_by_role = 'CUSTOMER'
|
||||||
|
FROM freight.contracts c
|
||||||
|
WHERE c.reference = 'CTR-' || b.id::text
|
||||||
|
AND COALESCE(b.booking_type, 'ONE_TIME') = 'ONE_TIME'
|
||||||
|
AND b.contract_id IS NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 6. Build a single route per ONE_TIME contract from the booking's own
|
||||||
|
// origin/destination (general contracts already got their routes in step 2).
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO freight.contract_routes (contract_id, origin_yard_id, destination_yard_id, sort_order, created_at, updated_at)
|
||||||
|
SELECT c.id, b.origin_yard_id, b.destination_yard_id, 0, now(), now()
|
||||||
|
FROM freight.bookings b
|
||||||
|
JOIN freight.contracts c ON c.id = b.contract_id AND c.contract_kind = 'ONE_TIME'
|
||||||
|
WHERE b.origin_yard_id IS NOT NULL AND b.destination_yard_id IS NOT NULL
|
||||||
|
ON CONFLICT (contract_id, origin_yard_id, destination_yard_id) DO NOTHING;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Unlink bookings and drop the synthesised contracts (and their cascaded routes).
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.bookings SET contract_id = NULL, contract_route_id = NULL
|
||||||
|
WHERE contract_id IN (SELECT id FROM freight.contracts WHERE reference LIKE 'CTR-%');
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`DELETE FROM freight.contracts WHERE reference LIKE 'CTR-%';`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateImportOperationsTables1823000000000 implements MigrationInterface {
|
||||||
|
name = 'CreateImportOperationsTables1823000000000';
|
||||||
|
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
schema: 'freight',
|
||||||
|
name: 'djibouti_import_incidents',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||||
|
{ name: 'booking_id', type: 'uuid' },
|
||||||
|
{ name: 'container_number', type: 'varchar', length: '80', isNullable: true },
|
||||||
|
{ name: 'cargo_id', type: 'uuid', isNullable: true },
|
||||||
|
{ name: 'facility', type: 'varchar', length: '120', isNullable: true },
|
||||||
|
{ name: 'station', type: 'varchar', length: '120', isNullable: true },
|
||||||
|
{ name: 'incident_type', type: 'varchar', length: '40' },
|
||||||
|
{ name: 'description', type: 'text' },
|
||||||
|
{ name: 'photos', type: 'jsonb', default: "'[]'::jsonb" },
|
||||||
|
{ name: 'reported_by', type: 'varchar', length: '120', isNullable: true },
|
||||||
|
{ name: 'reported_at', type: 'timestamptz' },
|
||||||
|
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_booking', columnNames: ['booking_id'] }));
|
||||||
|
await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_container', columnNames: ['container_number'] }));
|
||||||
|
await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_type', columnNames: ['incident_type'] }));
|
||||||
|
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
schema: 'freight',
|
||||||
|
name: 'import_customs_finalizations',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||||
|
{ name: 'booking_id', type: 'uuid', isUnique: true },
|
||||||
|
{ name: 'documents', type: 'jsonb', default: "'{}'::jsonb" },
|
||||||
|
{ name: 'declaration_serial_number', type: 'varchar', length: '120', isNullable: true },
|
||||||
|
{ name: 'duties_taxes_notified_at', type: 'timestamptz', isNullable: true },
|
||||||
|
{ name: 'duties_taxes_paid_at', type: 'timestamptz', isNullable: true },
|
||||||
|
{ name: 'customs_risk', type: 'varchar', length: '12', isNullable: true },
|
||||||
|
{ name: 'import_release_permitted_at', type: 'timestamptz', isNullable: true },
|
||||||
|
{ name: 'completed_at', type: 'timestamptz', isNullable: true },
|
||||||
|
{ name: 'performed_by', type: 'varchar', length: '120', isNullable: true },
|
||||||
|
{ name: 'notes', type: 'text', isNullable: true },
|
||||||
|
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await queryRunner.createIndex('freight.import_customs_finalizations', new TableIndex({ name: 'idx_import_customs_booking', columnNames: ['booking_id'] }));
|
||||||
|
await queryRunner.createIndex('freight.import_customs_finalizations', new TableIndex({ name: 'idx_import_customs_risk', columnNames: ['customs_risk'] }));
|
||||||
|
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
schema: 'freight',
|
||||||
|
name: 'empty_container_returns',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||||
|
{ name: 'container_number', type: 'varchar', length: '80' },
|
||||||
|
{ name: 'booking_id', type: 'uuid', isNullable: true },
|
||||||
|
{ name: 'customer_id', type: 'uuid', isNullable: true },
|
||||||
|
{ name: 'return_date', type: 'timestamptz' },
|
||||||
|
{ name: 'facility', type: 'varchar', length: '120', isNullable: true },
|
||||||
|
{ name: 'yard', type: 'varchar', length: '120', isNullable: true },
|
||||||
|
{ name: 'zone', type: 'varchar', length: '120', isNullable: true },
|
||||||
|
{ name: 'condition', type: 'text', isNullable: true },
|
||||||
|
{ name: 'handover_note', type: 'text', isNullable: true },
|
||||||
|
{ name: 'status', type: 'varchar', length: '40', default: "'RETURNED'" },
|
||||||
|
{ name: 'wagon_allocation_reference', type: 'varchar', length: '120', isNullable: true },
|
||||||
|
{ name: 'performed_by', type: 'varchar', length: '120', isNullable: true },
|
||||||
|
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_container', columnNames: ['container_number'] }));
|
||||||
|
await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_booking', columnNames: ['booking_id'] }));
|
||||||
|
await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_status', columnNames: ['status'] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('freight.empty_container_returns', true);
|
||||||
|
await queryRunner.dropTable('freight.import_customs_finalizations', true);
|
||||||
|
await queryRunner.dropTable('freight.djibouti_import_incidents', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cutover cleanup (docs/new-doc.md §17 Phase 4). Runs AFTER the backfill
|
||||||
|
* (1823…) so every legacy general contract + drawdown already lives in the
|
||||||
|
* `contracts` aggregate.
|
||||||
|
*
|
||||||
|
* Drops the now-unused booking-as-contract artifacts:
|
||||||
|
* - `bookings.booking_type` (every booking is a real shipment now)
|
||||||
|
* - `bookings.previous_contract_id` (renewal lives on `contracts.renewal_of_id`)
|
||||||
|
* - the `booking_orders` / `booking_order_lines` drawdown ledger
|
||||||
|
* - `contract_route_lines` (superseded by `contract_routes`)
|
||||||
|
*
|
||||||
|
* The shipment/payment/scheduling/allocation columns on `bookings` are kept —
|
||||||
|
* the operational pipeline is unchanged.
|
||||||
|
*/
|
||||||
|
export class DropLegacyContractTables1824000000000 implements MigrationInterface {
|
||||||
|
name = 'DropLegacyContractTables1824000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// booking_order_lines references booking_orders → drop child first.
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_order_lines CASCADE;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_orders CASCADE;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_route_lines CASCADE;`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS booking_type;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS previous_contract_id;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Re-add the dropped columns (data is not restored — this is a one-way cutover).
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS booking_type VARCHAR(20) DEFAULT 'ONE_TIME';`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS previous_contract_id UUID;`,
|
||||||
|
);
|
||||||
|
// The legacy ledger/route tables are intentionally NOT recreated here; restore
|
||||||
|
// from a backup if a rollback past the cutover is ever required.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global Logistics Phase-2 operational features (docs/new-doc.md §11–§13, gap
|
||||||
|
* matrix #14/#16/#17/#18):
|
||||||
|
* - `clearance_milestones.metadata` — structured payload for RISK_ASSIGNED
|
||||||
|
* (risk level) and DUTY_TAXES_ADVISED (amount, currency, declaration serial)
|
||||||
|
* - `bookings.gl_station_yard_id` / `gl_assigned_staff_id` / `gl_assigned_at`
|
||||||
|
* — station routing + staff binding (GL US-02)
|
||||||
|
* - `freight.clearance_incidents` — cargo exception/damage reports with photos
|
||||||
|
* (GL Import US-07)
|
||||||
|
*/
|
||||||
|
export class AddGlOperations1825000000000 implements MigrationInterface {
|
||||||
|
name = 'AddGlOperations1825000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.clearance_milestones ADD COLUMN IF NOT EXISTS metadata JSONB;`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS gl_station_yard_id UUID;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS gl_assigned_staff_id UUID;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS gl_assigned_at TIMESTAMPTZ;`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.clearance_incidents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||||
|
incident_type VARCHAR(32) NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
photo_file_ids JSONB NOT NULL DEFAULT '[]',
|
||||||
|
reported_by_user_id UUID,
|
||||||
|
reported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_clearance_incidents_booking ON freight.clearance_incidents(booking_id);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.clearance_incidents CASCADE;`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS gl_assigned_at;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS gl_assigned_staff_id;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS gl_station_yard_id;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.clearance_milestones DROP COLUMN IF EXISTS metadata;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the freight.booking_container_allocations table — container-to-vehicle
|
||||||
|
* allocation mapping for flexible routing of containers across available vehicles.
|
||||||
|
*/
|
||||||
|
export class CreateBookingContainerAllocations1825000000000 implements MigrationInterface {
|
||||||
|
name = 'CreateBookingContainerAllocations1825000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const exists = await queryRunner.hasTable('freight.booking_container_allocations');
|
||||||
|
if (exists) return;
|
||||||
|
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'freight.booking_container_allocations',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'uuid',
|
||||||
|
isPrimary: true,
|
||||||
|
default: 'gen_random_uuid()',
|
||||||
|
},
|
||||||
|
{ name: 'booking_id', type: 'uuid', isNullable: false },
|
||||||
|
{ name: 'container_id', type: 'uuid', isNullable: false },
|
||||||
|
{ name: 'vehicle_id', type: 'uuid', isNullable: true },
|
||||||
|
{
|
||||||
|
name: 'container_type',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'quantity',
|
||||||
|
type: 'integer',
|
||||||
|
default: 1,
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'freight.booking_container_allocations',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['booking_id'],
|
||||||
|
referencedTableName: 'freight.bookings',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'freight.booking_container_allocations',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['vehicle_id'],
|
||||||
|
referencedTableName: 'freight.vehicles',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_booking_container_allocations_booking_id" ON "freight"."booking_container_allocations" ("booking_id")`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_booking_container_allocations_vehicle_id" ON "freight"."booking_container_allocations" ("vehicle_id")`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const exists = await queryRunner.hasTable('freight.booking_container_allocations');
|
||||||
|
if (exists) {
|
||||||
|
await queryRunner.dropTable('freight.booking_container_allocations');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GENERAL contracts can be booked repeatedly until a total cargo quantity cap is
|
||||||
|
* reached (e.g. 100 containers across many shipments). `quantity_cap` on each
|
||||||
|
* cargo-scope line holds that ceiling (containers per size, or tons/items for
|
||||||
|
* bulk). NULL = uncapped; always NULL for ONE_TIME (single booking).
|
||||||
|
*/
|
||||||
|
export class AddCargoScopeQuantityCap1826000000000 implements MigrationInterface {
|
||||||
|
name = 'AddCargoScopeQuantityCap1826000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contract_cargo_scope ADD COLUMN IF NOT EXISTS quantity_cap NUMERIC(12,2);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contract_cargo_scope DROP COLUMN IF EXISTS quantity_cap;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer shipment requests for GENERAL customs (Path B) contracts. The customer
|
||||||
|
* submits date + quantities; Global Logistics reviews, then creates the booking
|
||||||
|
* on their behalf and per-booking clearance begins. Additive — no change to
|
||||||
|
* existing tables; ONE_TIME contracts are unaffected.
|
||||||
|
*/
|
||||||
|
export class CreateBookingRequests1827000000000 implements MigrationInterface {
|
||||||
|
name = 'CreateBookingRequests1827000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
schema: 'freight',
|
||||||
|
name: 'booking_requests',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||||
|
{ name: 'reference', type: 'varchar', length: '40', default: "''" },
|
||||||
|
{ name: 'contract_id', type: 'uuid' },
|
||||||
|
{ name: 'requested_by_user_id', type: 'uuid', isNullable: true },
|
||||||
|
{ name: 'contract_route_id', type: 'uuid', isNullable: true },
|
||||||
|
{ name: 'scheduled_date', type: 'timestamptz', isNullable: true },
|
||||||
|
{ name: 'status', type: 'varchar', length: '16', default: "'PENDING'" },
|
||||||
|
{ name: 'requested_lines', type: 'jsonb', default: "'{}'::jsonb" },
|
||||||
|
{ name: 'notes', type: 'text', isNullable: true },
|
||||||
|
{ name: 'created_booking_id', type: 'uuid', isNullable: true },
|
||||||
|
{ name: 'reviewed_by_staff_id', type: 'uuid', isNullable: true },
|
||||||
|
{ name: 'reviewed_at', type: 'timestamptz', isNullable: true },
|
||||||
|
{ name: 'review_note', type: 'text', isNullable: true },
|
||||||
|
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||||
|
],
|
||||||
|
foreignKeys: [
|
||||||
|
{
|
||||||
|
columnNames: ['contract_id'],
|
||||||
|
referencedSchema: 'freight',
|
||||||
|
referencedTableName: 'contracts',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
columnNames: ['created_booking_id'],
|
||||||
|
referencedSchema: 'freight',
|
||||||
|
referencedTableName: 'bookings',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'freight.booking_requests',
|
||||||
|
new TableIndex({ name: 'idx_booking_requests_contract', columnNames: ['contract_id'] }),
|
||||||
|
);
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'freight.booking_requests',
|
||||||
|
new TableIndex({ name: 'idx_booking_requests_status', columnNames: ['status'] }),
|
||||||
|
);
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'freight.booking_requests',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'idx_booking_requests_contract_status',
|
||||||
|
columnNames: ['contract_id', 'status'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('freight.booking_requests', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { BillingService } from "./billing.service";
|
|||||||
@Controller("billing")
|
@Controller("billing")
|
||||||
@FreightAdmin()
|
@FreightAdmin()
|
||||||
export class BillingController {
|
export class BillingController {
|
||||||
constructor(private readonly billingService: BillingService) {}
|
constructor(private readonly billingService: BillingService) { }
|
||||||
|
|
||||||
@Get("invoices")
|
@Get("invoices")
|
||||||
@ApiOperation({ summary: "List all invoices" })
|
@ApiOperation({ summary: "List all invoices" })
|
||||||
@@ -16,9 +16,9 @@ export class BillingController {
|
|||||||
return this.billingService.findAll();
|
return this.billingService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("invoices/booking/:bookingId")
|
@Get("invoices/:id")
|
||||||
@ApiOperation({ summary: "List invoices for a booking" })
|
@ApiOperation({ summary: "Get an invoice with its line items" })
|
||||||
findByBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
|
findById(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.billingService.findByBooking(bookingId);
|
return this.billingService.findById(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
|
||||||
import { BillingController } from "./billing.controller";
|
import { BillingController } from "./billing.controller";
|
||||||
|
import { PortalBillingController } from "./portal-billing.controller";
|
||||||
import { BillingService } from "./billing.service";
|
import { BillingService } from "./billing.service";
|
||||||
import { Invoice } from "./entities/invoice.entity";
|
import { Invoice } from "./entities/invoice.entity";
|
||||||
|
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||||
|
import { InvoiceRepository } from "./invoice.repository";
|
||||||
|
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||||
|
import { PaymentModule } from "../payment/payment.module";
|
||||||
|
import { CompaniesModule } from "../companies/companies.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Invoice])],
|
imports: [
|
||||||
controllers: [BillingController],
|
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
|
||||||
providers: [BillingService],
|
forwardRef(() => PaymentModule),
|
||||||
|
CompaniesModule,
|
||||||
|
],
|
||||||
|
controllers: [BillingController, PortalBillingController],
|
||||||
|
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||||
exports: [BillingService],
|
exports: [BillingService],
|
||||||
})
|
})
|
||||||
export class BillingModule {}
|
export class BillingModule {}
|
||||||
|
|||||||
250
apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
Normal file
250
apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { BillingService } from "./billing.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal in-memory EntityManager stand-in covering the methods
|
||||||
|
* `generateInvoice` / `markInvoiceAsPaid` call on the transaction manager.
|
||||||
|
*/
|
||||||
|
function makeManager(savedLines: unknown[]) {
|
||||||
|
return {
|
||||||
|
create: (_entity: unknown, data: Record<string, unknown>) => data,
|
||||||
|
save: (data: Record<string, unknown>) => {
|
||||||
|
const row = { id: data.id ?? "gen-1", ...data };
|
||||||
|
if (data.invoiceId) savedLines.push(row);
|
||||||
|
return Promise.resolve(row);
|
||||||
|
},
|
||||||
|
query: () => Promise.resolve([{ seq: 0 }]),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEvents() {
|
||||||
|
return { emit: jest.fn() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateInput(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
source: Freight.InvoiceSource.Booking,
|
||||||
|
sourceId: "booking-1",
|
||||||
|
type: "prepaid",
|
||||||
|
companyId: "company-1",
|
||||||
|
companyProfileId: "profile-1",
|
||||||
|
currency: "ETB",
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
chargeType: "RAIL_FREIGHT",
|
||||||
|
description: "Rail freight",
|
||||||
|
quantity: 2,
|
||||||
|
unitRate: 500,
|
||||||
|
amount: 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chargeType: "HAZARD_SURCHARGE",
|
||||||
|
description: "Hazard surcharge",
|
||||||
|
quantity: 2,
|
||||||
|
unitRate: 250,
|
||||||
|
amount: 500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BillingService.generateInvoice", () => {
|
||||||
|
let savedLines: unknown[];
|
||||||
|
let manager: ReturnType<typeof makeManager>;
|
||||||
|
let events: ReturnType<typeof makeEvents>;
|
||||||
|
let dataSource: { transaction: jest.Mock; manager: unknown };
|
||||||
|
let service: BillingService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
savedLines = [];
|
||||||
|
manager = makeManager(savedLines);
|
||||||
|
events = makeEvents();
|
||||||
|
dataSource = {
|
||||||
|
transaction: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
|
||||||
|
manager,
|
||||||
|
};
|
||||||
|
service = new BillingService(
|
||||||
|
dataSource as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
events as never,
|
||||||
|
{} as never, // payment
|
||||||
|
{} as never, // companies
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a PENDING invoice with one line per input line", async () => {
|
||||||
|
const invoice = await service.generateInvoice(generateInput());
|
||||||
|
|
||||||
|
expect(invoice.status).toBe(Freight.InvoiceStatus.Pending);
|
||||||
|
expect(invoice.companyId).toBe("company-1");
|
||||||
|
expect(invoice.source).toBe("booking");
|
||||||
|
expect(invoice.sourceId).toBe("booking-1");
|
||||||
|
expect(invoice.totalAmount).toBe(1500);
|
||||||
|
expect(invoice.issuedAt).toBeInstanceOf(Date);
|
||||||
|
expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/);
|
||||||
|
expect(savedLines).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sums line amounts when no explicit totalAmount is given", async () => {
|
||||||
|
const invoice = await service.generateInvoice(
|
||||||
|
generateInput({ totalAmount: undefined }),
|
||||||
|
);
|
||||||
|
expect(invoice.totalAmount).toBe(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves issuedAt null for a DRAFT invoice", async () => {
|
||||||
|
const invoice = await service.generateInvoice(
|
||||||
|
generateInput({ status: Freight.InvoiceStatus.Draft }),
|
||||||
|
);
|
||||||
|
expect(invoice.status).toBe(Freight.InvoiceStatus.Draft);
|
||||||
|
expect(invoice.issuedAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enlists in a caller's transaction when a manager is passed", async () => {
|
||||||
|
await service.generateInvoice(generateInput(), manager as never);
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
expect(savedLines).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BillingService.markInvoiceAsPaid", () => {
|
||||||
|
it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => {
|
||||||
|
const open = {
|
||||||
|
id: "inv-1",
|
||||||
|
status: Freight.InvoiceStatus.Pending,
|
||||||
|
source: "booking",
|
||||||
|
sourceId: "booking-1",
|
||||||
|
};
|
||||||
|
const mg = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(open),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const events = makeEvents();
|
||||||
|
const service = new BillingService(
|
||||||
|
{ manager: mg } as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
events as never,
|
||||||
|
{} as never, // payment
|
||||||
|
{} as never, // companies
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
|
|
||||||
|
expect(mg.update).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
{ id: "inv-1" },
|
||||||
|
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
|
||||||
|
);
|
||||||
|
expect(events.emit).toHaveBeenCalledWith(
|
||||||
|
"booking.invoice.paid",
|
||||||
|
expect.objectContaining({
|
||||||
|
invoiceId: "inv-1",
|
||||||
|
status: Freight.InvoiceStatus.Paid,
|
||||||
|
paymentId: "pay-1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op (no event) when the invoice is already paid", async () => {
|
||||||
|
const paid = {
|
||||||
|
id: "inv-1",
|
||||||
|
status: Freight.InvoiceStatus.Paid,
|
||||||
|
source: "booking",
|
||||||
|
};
|
||||||
|
const mg = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(paid),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const events = makeEvents();
|
||||||
|
const service = new BillingService(
|
||||||
|
{ manager: mg } as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
events as never,
|
||||||
|
{} as never, // payment
|
||||||
|
{} as never, // companies
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
|
|
||||||
|
expect(mg.update).not.toHaveBeenCalled();
|
||||||
|
expect(events.emit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BillingService.settlePayable", () => {
|
||||||
|
it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => {
|
||||||
|
const open = {
|
||||||
|
id: "inv-1",
|
||||||
|
status: Freight.InvoiceStatus.Pending,
|
||||||
|
source: Freight.InvoiceSource.Booking,
|
||||||
|
sourceId: "booking-1",
|
||||||
|
};
|
||||||
|
const mg = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(open),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const events = makeEvents();
|
||||||
|
const service = new BillingService(
|
||||||
|
{ manager: mg } as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
events as never,
|
||||||
|
{} as never, // payment
|
||||||
|
{} as never, // companies
|
||||||
|
);
|
||||||
|
|
||||||
|
const settled = await service.settlePayable(
|
||||||
|
Freight.InvoiceSource.Booking,
|
||||||
|
"booking-1",
|
||||||
|
"pay-1",
|
||||||
|
mg as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(settled?.status).toBe(Freight.InvoiceStatus.Paid);
|
||||||
|
expect(mg.update).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
{ id: "inv-1" },
|
||||||
|
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
|
||||||
|
);
|
||||||
|
expect(events.emit).toHaveBeenCalledWith(
|
||||||
|
"booking.invoice.paid",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op (returns null) when the source has no open invoice", async () => {
|
||||||
|
const mg = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const events = makeEvents();
|
||||||
|
const service = new BillingService(
|
||||||
|
{ manager: mg } as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
events as never,
|
||||||
|
{} as never, // payment
|
||||||
|
{} as never, // companies
|
||||||
|
);
|
||||||
|
|
||||||
|
const settled = await service.settlePayable(
|
||||||
|
Freight.InvoiceSource.Booking,
|
||||||
|
"booking-1",
|
||||||
|
"pay-1",
|
||||||
|
mg as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(settled).toBeNull();
|
||||||
|
expect(mg.update).not.toHaveBeenCalled();
|
||||||
|
expect(events.emit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,26 +1,542 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||||
import { Repository } from "typeorm";
|
import { Freight, PaymentReferenceType } from "@edr/types";
|
||||||
|
import { DataSource, EntityManager, In } from "typeorm";
|
||||||
|
|
||||||
import { Invoice } from "./entities/invoice.entity";
|
import { Invoice } from "./entities/invoice.entity";
|
||||||
|
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||||
|
import { InvoiceRepository } from "./invoice.repository";
|
||||||
|
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||||
|
import { PaymentService } from "../payment/payment.service";
|
||||||
|
import { InitiateResponseDto } from "../payment/payments.dto";
|
||||||
|
import { CompaniesService } from "../companies/companies.service";
|
||||||
|
|
||||||
|
/** Options forwarded to the payment gateway when settling an invoice. */
|
||||||
|
export interface PayInvoiceOptions {
|
||||||
|
method?: string;
|
||||||
|
platform?: "web" | "mobile";
|
||||||
|
payerAccount?: string;
|
||||||
|
returnUrl?: string;
|
||||||
|
failureUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
|
||||||
|
const DEFAULT_DUE_DAYS = 14;
|
||||||
|
|
||||||
|
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
|
||||||
|
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
|
||||||
|
Freight.InvoiceStatus.Draft,
|
||||||
|
Freight.InvoiceStatus.Pending,
|
||||||
|
Freight.InvoiceStatus.Overdue,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** A single line to bill on a generated invoice. */
|
||||||
|
export interface InvoiceLineInput {
|
||||||
|
chargeType: string;
|
||||||
|
description?: string;
|
||||||
|
/** Units this line bills for; defaults to 1. */
|
||||||
|
quantity?: number;
|
||||||
|
/** Price per unit; defaults to 0. */
|
||||||
|
unitRate?: number;
|
||||||
|
/** Line total; defaults to `quantity * unitRate`. */
|
||||||
|
amount?: number;
|
||||||
|
currency?: string;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything needed to generate an invoice for any source. */
|
||||||
|
export interface GenerateInvoiceInput {
|
||||||
|
/** Originating subsystem; namespaces events (`${source}.invoice.<event>`). */
|
||||||
|
source: Freight.InvoiceSource;
|
||||||
|
/** Identifier of the source record (e.g. booking id). */
|
||||||
|
sourceId: string;
|
||||||
|
/** What the invoice is for (e.g. "prepaid", "credit"). */
|
||||||
|
type: string;
|
||||||
|
companyId: string;
|
||||||
|
companyProfileId: string;
|
||||||
|
lines: InvoiceLineInput[];
|
||||||
|
currency?: string;
|
||||||
|
/** Explicit total; defaults to the sum of line amounts. */
|
||||||
|
totalAmount?: number;
|
||||||
|
/** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */
|
||||||
|
dueAt?: Date;
|
||||||
|
dueInDays?: number;
|
||||||
|
/**
|
||||||
|
* Initial status. DRAFT leaves `issuedAt` null; any issued status
|
||||||
|
* (default PENDING) stamps `issuedAt`.
|
||||||
|
*/
|
||||||
|
status?: Freight.InvoiceStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload broadcast on `${source}.invoice.<event>`. */
|
||||||
|
export interface InvoiceEventPayload {
|
||||||
|
invoiceId: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
source: Freight.InvoiceSource;
|
||||||
|
sourceId: string;
|
||||||
|
type: string;
|
||||||
|
companyId: string;
|
||||||
|
companyProfileId: string;
|
||||||
|
totalAmount: number;
|
||||||
|
currency: string;
|
||||||
|
status: Freight.InvoiceStatus;
|
||||||
|
paymentId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillingService {
|
export class BillingService {
|
||||||
|
private readonly logger = new Logger(BillingService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Invoice)
|
private readonly dataSource: DataSource,
|
||||||
private readonly invoicesRepository: Repository<Invoice>,
|
private readonly invoices: InvoiceRepository,
|
||||||
) {}
|
private readonly invoiceLines: InvoiceLineRepository,
|
||||||
|
private readonly events: EventEmitter2,
|
||||||
|
@Inject(forwardRef(() => PaymentService))
|
||||||
|
private readonly payment: PaymentService,
|
||||||
|
private readonly companies: CompaniesService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** List every invoice (most recent first). */
|
/** List every invoice (most recent first). */
|
||||||
findAll(): Promise<Invoice[]> {
|
findAll(): Promise<Invoice[]> {
|
||||||
return this.invoicesRepository.find({ order: { issuedAt: "DESC" } });
|
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List invoices for a given booking. */
|
/** Invoice header plus its line items. */
|
||||||
findByBooking(bookingId: string): Promise<Invoice[]> {
|
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||||
return this.invoicesRepository.find({
|
const invoice = await this.invoices.findById(id);
|
||||||
where: { bookingId },
|
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||||
|
const lines = await this.invoiceLines.findAll({
|
||||||
|
where: { invoiceId: id },
|
||||||
|
order: { createdAt: "ASC" },
|
||||||
|
});
|
||||||
|
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Customer-scoped reads (portal) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Resolve the customer's company id from their IAM user id (null if none). */
|
||||||
|
async resolveCompanyId(userId: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { company } = await this.companies.getCompanyInfoByUserId(userId);
|
||||||
|
return company?.id ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every invoice billed to a company, newest first, with billing relations. */
|
||||||
|
findByCompany(companyId: string): Promise<Invoice[]> {
|
||||||
|
return this.invoices.findAll({
|
||||||
|
where: { companyId },
|
||||||
|
relations: { company: true, companyProfile: true },
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invoices for the signed-in customer; empty when they have no company. */
|
||||||
|
async findForUser(userId: string): Promise<Invoice[]> {
|
||||||
|
const companyId = await this.resolveCompanyId(userId);
|
||||||
|
return companyId ? this.findByCompany(companyId) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
|
||||||
|
async findByIdForUser(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||||
|
const companyId = await this.resolveCompanyId(userId);
|
||||||
|
const invoice = await this.findById(id);
|
||||||
|
if (!companyId || invoice.companyId !== companyId) {
|
||||||
|
throw new NotFoundException(`Invoice ${id} not found`);
|
||||||
|
}
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate gateway payment for one of the customer's own invoices. Verifies
|
||||||
|
* ownership, then charges whichever open invoice the source currently has
|
||||||
|
* (see {@link payInvoice}).
|
||||||
|
*/
|
||||||
|
async payInvoiceForUser(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
opts: PayInvoiceOptions = {},
|
||||||
|
): Promise<InitiateResponseDto> {
|
||||||
|
const invoice = await this.findByIdForUser(id, userId);
|
||||||
|
return this.payInvoice(
|
||||||
|
invoice.source as Freight.InvoiceSource,
|
||||||
|
invoice.sourceId,
|
||||||
|
opts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generation ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */
|
||||||
|
private async nextInvoiceNumber(mg: EntityManager): Promise<string> {
|
||||||
|
const now = new Date();
|
||||||
|
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
|
||||||
|
const prefix = `FRT-${ymd}-`;
|
||||||
|
const [row] = await mg.query(
|
||||||
|
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
|
||||||
|
FROM freight.invoices WHERE invoice_number LIKE $1`,
|
||||||
|
[`${prefix}%`],
|
||||||
|
);
|
||||||
|
const next = Number(row?.seq ?? 0) + 1;
|
||||||
|
return `${prefix}${String(next).padStart(5, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an invoice for any source (booking, demurrage, manual, …).
|
||||||
|
*
|
||||||
|
* Persists the header plus its lines in one transaction and assigns the next
|
||||||
|
* sequential `invoice_number`. The total defaults to the sum of line amounts
|
||||||
|
* unless `totalAmount` is given. Issued invoices (default PENDING) stamp
|
||||||
|
* `issuedAt`; pass `status: DRAFT` to leave it unissued.
|
||||||
|
*
|
||||||
|
* Pass `manager` to enlist in a caller's transaction (e.g. when generating an
|
||||||
|
* invoice as part of a larger booking flow).
|
||||||
|
*/
|
||||||
|
async generateInvoice(
|
||||||
|
input: GenerateInvoiceInput,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||||
|
const run = (mg: EntityManager) => this.createInvoice(input, mg);
|
||||||
|
return manager ? run(manager) : this.dataSource.transaction(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createInvoice(
|
||||||
|
input: GenerateInvoiceInput,
|
||||||
|
mg: EntityManager,
|
||||||
|
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||||
|
const currency = input.currency ?? "ETB";
|
||||||
|
const status = input.status ?? Freight.InvoiceStatus.Pending;
|
||||||
|
const issued = status !== Freight.InvoiceStatus.Draft;
|
||||||
|
|
||||||
|
const lines = input.lines.map((l) => {
|
||||||
|
const quantity = l.quantity ?? 1;
|
||||||
|
const unitRate = l.unitRate ?? 0;
|
||||||
|
return {
|
||||||
|
chargeType: l.chargeType,
|
||||||
|
description: l.description,
|
||||||
|
quantity,
|
||||||
|
unitRate,
|
||||||
|
amount: l.amount ?? quantity * unitRate,
|
||||||
|
currency: l.currency ?? currency,
|
||||||
|
metadata: l.metadata ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalAmount =
|
||||||
|
input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0);
|
||||||
|
|
||||||
|
const dueAt =
|
||||||
|
input.dueAt ??
|
||||||
|
new Date(
|
||||||
|
Date.now() +
|
||||||
|
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const invoiceNumber = await this.nextInvoiceNumber(mg);
|
||||||
|
|
||||||
|
const invoice = await mg.save(
|
||||||
|
mg.create(Invoice, {
|
||||||
|
invoiceNumber,
|
||||||
|
source: input.source,
|
||||||
|
sourceId: input.sourceId,
|
||||||
|
type: input.type,
|
||||||
|
companyId: input.companyId,
|
||||||
|
companyProfileId: input.companyProfileId,
|
||||||
|
totalAmount,
|
||||||
|
currency,
|
||||||
|
status,
|
||||||
|
issuedAt: issued ? new Date() : null,
|
||||||
|
dueAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const savedLines = await Promise.all(
|
||||||
|
lines.map((l) =>
|
||||||
|
mg.save(mg.create(InvoiceLine, { ...l, invoiceId: invoice.id })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${input.source}:${input.sourceId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...invoice, lines: savedLines };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State transitions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an invoice paid and link the gateway payment, then emit
|
||||||
|
* `${source}.invoice.paid`. Full-payment only — no partial settlement.
|
||||||
|
* No-op when the invoice is already paid. Pass `manager` to enlist in a
|
||||||
|
* caller's transaction.
|
||||||
|
*/
|
||||||
|
async markInvoiceAsPaid(
|
||||||
|
invoiceId: string,
|
||||||
|
paymentId: string | null = null,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<Invoice | null> {
|
||||||
|
return this.transition(
|
||||||
|
invoiceId,
|
||||||
|
Freight.InvoiceStatus.Paid,
|
||||||
|
"paid",
|
||||||
|
{ paymentId: paymentId ?? undefined },
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an invoice refunded and emit `${source}.invoice.refunded`.
|
||||||
|
* No-op when already refunded.
|
||||||
|
*/
|
||||||
|
async markInvoiceAsRefunded(
|
||||||
|
invoiceId: string,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<Invoice | null> {
|
||||||
|
return this.transition(
|
||||||
|
invoiceId,
|
||||||
|
Freight.InvoiceStatus.Refunded,
|
||||||
|
"refunded",
|
||||||
|
{},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an invoice cancelled and emit `${source}.invoice.cancelled`.
|
||||||
|
* No-op when already cancelled.
|
||||||
|
*/
|
||||||
|
async cancelInvoice(
|
||||||
|
invoiceId: string,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<Invoice | null> {
|
||||||
|
return this.transition(
|
||||||
|
invoiceId,
|
||||||
|
Freight.InvoiceStatus.Cancelled,
|
||||||
|
"cancelled",
|
||||||
|
{},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the invoice, apply the new status (+ extra columns), then emit
|
||||||
|
* `${source}.invoice.<event>`. No-op (returns the invoice) when it is already
|
||||||
|
* in the target status. Throws when the invoice does not exist.
|
||||||
|
*
|
||||||
|
* Note: the event fires in-process synchronously. When a `manager` from an
|
||||||
|
* outer transaction is passed, listeners run before that transaction commits.
|
||||||
|
*/
|
||||||
|
private async transition(
|
||||||
|
invoiceId: string,
|
||||||
|
status: Freight.InvoiceStatus,
|
||||||
|
event: string,
|
||||||
|
extra: { paymentId?: string },
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<Invoice | null> {
|
||||||
|
const mg = manager ?? this.dataSource.manager;
|
||||||
|
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } });
|
||||||
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||||
|
if (invoice.status === status) return invoice;
|
||||||
|
|
||||||
|
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
|
||||||
|
|
||||||
|
const updated = { ...invoice, ...extra, status } as Invoice;
|
||||||
|
this.emitInvoiceEvent(event, updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broadcast `${invoice.source}.invoice.<event>` to in-process listeners. */
|
||||||
|
private emitInvoiceEvent(event: string, invoice: Invoice): void {
|
||||||
|
const payload: InvoiceEventPayload = {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
source: invoice.source as Freight.InvoiceSource,
|
||||||
|
sourceId: invoice.sourceId,
|
||||||
|
type: invoice.type,
|
||||||
|
companyId: invoice.companyId,
|
||||||
|
companyProfileId: invoice.companyProfileId,
|
||||||
|
totalAmount: invoice.totalAmount,
|
||||||
|
currency: invoice.currency,
|
||||||
|
status: invoice.status,
|
||||||
|
paymentId: invoice.paymentId ?? null,
|
||||||
|
};
|
||||||
|
this.events.emit(`${invoice.source}.invoice.${event}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payment reconciliation (by source) ───────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The invoice a gateway payment should settle for a source record, or null if
|
||||||
|
* none. This is the billing document of record for "what is owed" — callers
|
||||||
|
* (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than
|
||||||
|
* recomputing from the source's own total, so discounts/penalties/adjustments
|
||||||
|
* carried on the invoice are honored.
|
||||||
|
*
|
||||||
|
* Pass `type` to select a specific invoice when a source carries several (e.g.
|
||||||
|
* a booking's up-front vs final charge); omit it to settle whichever single
|
||||||
|
* invoice is currently open. Returns the most recent matching open (unpaid,
|
||||||
|
* non-cancelled) invoice.
|
||||||
|
*/
|
||||||
|
findPayable(
|
||||||
|
source: Freight.InvoiceSource,
|
||||||
|
sourceId: string,
|
||||||
|
type?: string,
|
||||||
|
): Promise<Invoice | null> {
|
||||||
|
return this.dataSource.getRepository(Invoice).findOne({
|
||||||
|
where: {
|
||||||
|
source,
|
||||||
|
sourceId,
|
||||||
|
status: In(OPEN_STATUSES),
|
||||||
|
...(type ? { type } : {}),
|
||||||
|
},
|
||||||
order: { issuedAt: "DESC" },
|
order: { issuedAt: "DESC" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settle a source's currently-open invoice as paid and link the gateway
|
||||||
|
* payment, then emit `${source}.invoice.paid`. Resolves the open invoice then
|
||||||
|
* delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial
|
||||||
|
* settlement. No-op (returns null) when the source has no open invoice.
|
||||||
|
*
|
||||||
|
* Type-blind by design: settles whichever invoice is due; any per-type reaction
|
||||||
|
* belongs in the `${source}.invoice.paid` handler, which reads `invoice.type`.
|
||||||
|
* Pass the caller's transaction `manager` to enlist in its DB transaction.
|
||||||
|
*
|
||||||
|
* NOTE: the booking flow settles via {@link payInvoice} + the `payment.succeeded`
|
||||||
|
* event ({@link settleByPaymentId}); this source-keyed settle is a generic helper
|
||||||
|
* for callers that settle by source rather than by gateway intent id.
|
||||||
|
*/
|
||||||
|
async settlePayable(
|
||||||
|
source: Freight.InvoiceSource,
|
||||||
|
sourceId: string,
|
||||||
|
paymentId: string | null,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<Invoice | null> {
|
||||||
|
const mg = manager ?? this.dataSource.manager;
|
||||||
|
const invoice = await mg.findOne(Invoice, {
|
||||||
|
where: { source, sourceId, status: In(OPEN_STATUSES) },
|
||||||
|
order: { issuedAt: "DESC" },
|
||||||
|
});
|
||||||
|
if (!invoice) return null;
|
||||||
|
|
||||||
|
return this.markInvoiceAsPaid(invoice.id, paymentId, mg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refund a source's paid invoice, then emit `${source}.invoice.refunded`.
|
||||||
|
* Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}.
|
||||||
|
* No-op (returns null) when the source has no paid invoice.
|
||||||
|
*
|
||||||
|
* Pass the caller's transaction `manager` (e.g. from `payment.service.refund`)
|
||||||
|
* to enlist in its DB transaction.
|
||||||
|
*/
|
||||||
|
async refundPayable(
|
||||||
|
source: Freight.InvoiceSource,
|
||||||
|
sourceId: string,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<Invoice | null> {
|
||||||
|
const mg = manager ?? this.dataSource.manager;
|
||||||
|
const invoice = await mg.findOne(Invoice, {
|
||||||
|
where: { source, sourceId, status: Freight.InvoiceStatus.Paid },
|
||||||
|
order: { issuedAt: "DESC" },
|
||||||
|
});
|
||||||
|
if (!invoice) return null;
|
||||||
|
|
||||||
|
return this.markInvoiceAsRefunded(invoice.id, mg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payment initiation & settlement (the gateway boundary) ───────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge a source's open invoice through the payment gateway. Billing is the
|
||||||
|
* single place that turns "what is owed" (the invoice) into a payment intent —
|
||||||
|
* the domain never talks to the payment service directly. Resolves the open
|
||||||
|
* invoice, opens an intent for `invoice.totalAmount`, records the intent id on
|
||||||
|
* the invoice (the settlement correlation key), and returns the client action.
|
||||||
|
*
|
||||||
|
* When the provider settles synchronously, the invoice is settled inline here —
|
||||||
|
* after the intent id is stored — so the `payment.succeeded` correlation can
|
||||||
|
* never fire before the link exists. Throws when the source has no open invoice.
|
||||||
|
*/
|
||||||
|
async payInvoice(
|
||||||
|
source: Freight.InvoiceSource,
|
||||||
|
sourceId: string,
|
||||||
|
opts: {
|
||||||
|
method?: string;
|
||||||
|
platform?: "web" | "mobile";
|
||||||
|
payerAccount?: string;
|
||||||
|
returnUrl?: string;
|
||||||
|
failureUrl?: string;
|
||||||
|
} = {},
|
||||||
|
): Promise<InitiateResponseDto> {
|
||||||
|
const invoice = await this.findPayable(source, sourceId);
|
||||||
|
if (!invoice) {
|
||||||
|
throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.payment.initiate({
|
||||||
|
referenceId: sourceId,
|
||||||
|
source: invoice.source,
|
||||||
|
// Gateway reference type derives from the invoice source by convention
|
||||||
|
// (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and
|
||||||
|
// the domain never supplies it. New sources add their uppercased value to
|
||||||
|
// the PaymentReferenceType enum.
|
||||||
|
referenceType: invoice.source.toUpperCase() as PaymentReferenceType,
|
||||||
|
orderRef: invoice.invoiceNumber,
|
||||||
|
amountMinor: Math.round(Number(invoice.totalAmount)),
|
||||||
|
currency: invoice.currency,
|
||||||
|
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
||||||
|
method: opts.method ?? "TELEBIRR",
|
||||||
|
platform: opts.platform,
|
||||||
|
payerAccount: opts.payerAccount,
|
||||||
|
returnUrl: opts.returnUrl,
|
||||||
|
failureUrl: opts.failureUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Link the intent to the invoice BEFORE any settlement can correlate against it.
|
||||||
|
await this.dataSource
|
||||||
|
.getRepository(Invoice)
|
||||||
|
.update({ id: invoice.id }, { paymentId: result.intentId });
|
||||||
|
|
||||||
|
if (result.immediateSuccess) {
|
||||||
|
await this.settleByPaymentId(
|
||||||
|
result.intentId,
|
||||||
|
result.providerTxnId,
|
||||||
|
result.paidAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settle the open invoice linked to a gateway intent id, if any. Called by the
|
||||||
|
* payment service when an intent succeeds: finds the invoice linked by
|
||||||
|
* `paymentId`, marks it paid, and emits `${source}.invoice.paid` for the domain
|
||||||
|
* to advance on. Idempotent — no-op when no open invoice is linked (already
|
||||||
|
* settled, or settled inline by {@link payInvoice}).
|
||||||
|
*/
|
||||||
|
async settleByPaymentId(
|
||||||
|
paymentId: string,
|
||||||
|
_providerTxnId?: string,
|
||||||
|
_paidAt?: Date,
|
||||||
|
): Promise<Invoice | null> {
|
||||||
|
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
||||||
|
where: { paymentId, status: In(OPEN_STATUSES) },
|
||||||
|
order: { issuedAt: "DESC" },
|
||||||
|
});
|
||||||
|
if (!invoice) return null;
|
||||||
|
|
||||||
|
return this.markInvoiceAsPaid(invoice.id, paymentId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsIn, IsOptional, IsString } from "class-validator";
|
||||||
|
|
||||||
|
/** Gateway options for paying an invoice from the customer portal. */
|
||||||
|
export class PayInvoiceDto {
|
||||||
|
@ApiPropertyOptional({ description: "Payment method (defaults to TELEBIRR)." })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
method?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(["web", "mobile"])
|
||||||
|
platform?: "web" | "mobile";
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Payer account / phone, for wallet methods." })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
payerAccount?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Browser redirect URL on success." })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
returnUrl?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Browser redirect URL on failure." })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
failureUrl?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
|
||||||
|
|
||||||
|
import { Invoice } from "./invoice.entity";
|
||||||
|
|
||||||
|
@Entity({ schema: "freight", name: "invoice_lines" })
|
||||||
|
export class InvoiceLine extends BaseEntity {
|
||||||
|
@Column({ name: "invoice_id", type: "uuid", nullable: false })
|
||||||
|
invoiceId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Invoice, { onDelete: "CASCADE" })
|
||||||
|
@JoinColumn({ name: "invoice_id" })
|
||||||
|
invoice!: Invoice;
|
||||||
|
|
||||||
|
@Column({ name: "charge_type", type: "varchar", nullable: false })
|
||||||
|
chargeType!: string;
|
||||||
|
|
||||||
|
@Column({ name: "description", type: "varchar", length: 255, nullable: true })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
/** Units this line bills for (e.g. container count, wagon count, tons). */
|
||||||
|
@Column({ name: "quantity", type: "numeric", precision: 12, scale: 2, default: 1 })
|
||||||
|
quantity!: number;
|
||||||
|
|
||||||
|
/** Price per unit; `amount` is normally `quantity * unitRate`. */
|
||||||
|
@Column({ name: "unit_rate", type: "numeric", precision: 14, scale: 2, default: 0 })
|
||||||
|
unitRate!: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "amount",
|
||||||
|
type: "numeric",
|
||||||
|
precision: 14,
|
||||||
|
scale: 2,
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
@Column({ name: "metadata", type: "jsonb", nullable: true })
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
@@ -1,17 +1,35 @@
|
|||||||
import { BaseEntity } from "@edr/api-common";
|
import { BaseEntity } from "@edr/api-common";
|
||||||
import { Freight } from "@edr/types";
|
import { Freight } from "@edr/types";
|
||||||
import { Column, Entity } from "typeorm";
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||||
|
import { PaymentEntity } from "../../payment/entities/payment.entity";
|
||||||
|
import { Company } from "../../companies/entities/company.entity";
|
||||||
|
import { CompanyProfile } from "../../companies/entities/company-profile.entity";
|
||||||
|
|
||||||
@Entity({schema:"freight", name: "invoices" })
|
@Entity({ schema: "freight", name: "invoices" })
|
||||||
|
@Index(["companyId"])
|
||||||
|
@Index(["companyProfileId"])
|
||||||
export class Invoice extends BaseEntity {
|
export class Invoice extends BaseEntity {
|
||||||
@Column({ name: "booking_id", type: "uuid" })
|
|
||||||
bookingId!: string;
|
|
||||||
|
|
||||||
@Column({ name: "invoice_number", type: "varchar", length: 64, unique: true })
|
@Column({ name: "invoice_number", type: "varchar", length: 64, unique: true })
|
||||||
invoiceNumber!: string;
|
invoiceNumber!: string;
|
||||||
|
|
||||||
@Column({ name: "amount", type: "numeric", precision: 14, scale: 2 })
|
/** The customer (company) this invoice is billed to. */
|
||||||
amount!: number;
|
@Column({ name: "company_id", type: "uuid" })
|
||||||
|
companyId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Company)
|
||||||
|
@JoinColumn({ name: "company_id" })
|
||||||
|
company?: Company;
|
||||||
|
|
||||||
|
/** The specific company profile (importer/exporter/forwarder/...) billed. */
|
||||||
|
@Column({ name: "company_profile_id", type: "uuid" })
|
||||||
|
companyProfileId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => CompanyProfile)
|
||||||
|
@JoinColumn({ name: "company_profile_id" })
|
||||||
|
companyProfile?: CompanyProfile;
|
||||||
|
|
||||||
|
@Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 })
|
||||||
|
totalAmount!: number;
|
||||||
|
|
||||||
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
|
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
|
||||||
currency!: string;
|
currency!: string;
|
||||||
@@ -19,13 +37,38 @@ export class Invoice extends BaseEntity {
|
|||||||
@Column({
|
@Column({
|
||||||
name: "status",
|
name: "status",
|
||||||
type: "enum",
|
type: "enum",
|
||||||
enum: Freight.PaymentStatus,
|
enum: Freight.InvoiceStatus,
|
||||||
default: Freight.PaymentStatus.Pending,
|
default: Freight.InvoiceStatus.Draft,
|
||||||
})
|
})
|
||||||
status!: Freight.PaymentStatus;
|
status!: Freight.InvoiceStatus;
|
||||||
|
|
||||||
@Column({ name: "issued_at", type: "timestamptz" })
|
/** The source of the payment (e.g. booking, customer, etc.). */
|
||||||
issuedAt!: Date;
|
@Column({ name: "source", type: "varchar", length: 255, nullable: false })
|
||||||
|
source!: string;
|
||||||
|
|
||||||
|
/** The ID of the source (e.g. booking ID, customer ID, etc.). */
|
||||||
|
@Column({ name: "source_id", type: "varchar", length: 255, nullable: false })
|
||||||
|
sourceId!: string;
|
||||||
|
|
||||||
|
/** The type of Invoice (e.g. prepaid, credit, etc.). it suppose to answer the question "what is the invoice for?" */
|
||||||
|
@Column({
|
||||||
|
type: "varchar",
|
||||||
|
length: 255,
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
type!: string;
|
||||||
|
|
||||||
|
/** Set when the invoice is actually issued (DRAFT invoices leave this null). */
|
||||||
|
@Column({ name: "issued_at", type: "timestamptz", nullable: true })
|
||||||
|
issuedAt?: Date | null;
|
||||||
|
|
||||||
|
/** The ID of the payment that generated this invoice. */
|
||||||
|
@Column({ name: "payment_id", type: "uuid", nullable: true })
|
||||||
|
paymentId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => PaymentEntity)
|
||||||
|
@JoinColumn({ name: "payment_id" })
|
||||||
|
payment?: PaymentEntity;
|
||||||
|
|
||||||
@Column({ name: "due_at", type: "timestamptz" })
|
@Column({ name: "due_at", type: "timestamptz" })
|
||||||
dueAt!: Date;
|
dueAt!: Date;
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { BaseRepository } from "@edr/api-common";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
|
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InvoiceLineRepository extends BaseRepository<InvoiceLine> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(InvoiceLine) repository: Repository<InvoiceLine>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { BaseRepository } from "@edr/api-common";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
|
import { Invoice } from "./entities/invoice.entity";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InvoiceRepository extends BaseRepository<Invoice> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Invoice) repository: Repository<Invoice>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { CurrentUser } from "@edr/api-common";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AuthUserPayload,
|
||||||
|
resolveAuthUserId,
|
||||||
|
} from "../../common/resolve-auth-user-id";
|
||||||
|
import { BillingService } from "./billing.service";
|
||||||
|
import { PayInvoiceDto } from "./dto/pay-invoice.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
|
||||||
|
* org-wide), every route here is force-scoped to the signed-in customer's
|
||||||
|
* company — they only ever see and pay their own invoices.
|
||||||
|
*/
|
||||||
|
@ApiTags("billing")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller("billing")
|
||||||
|
export class PortalBillingController {
|
||||||
|
constructor(private readonly billingService: BillingService) {}
|
||||||
|
|
||||||
|
@Get("my-invoices")
|
||||||
|
@ApiOperation({ summary: "List the signed-in customer's invoices" })
|
||||||
|
findMine(@CurrentUser() user: AuthUserPayload) {
|
||||||
|
return this.billingService.findForUser(resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("my-invoices/:id")
|
||||||
|
@ApiOperation({ summary: "Get one of the customer's invoices (+ line items)" })
|
||||||
|
findMineById(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.billingService.findByIdForUser(id, resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("my-invoices/:id/pay")
|
||||||
|
@ApiOperation({ summary: "Initiate payment for one of the customer's invoices" })
|
||||||
|
pay(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
@Body() dto: PayInvoiceDto,
|
||||||
|
) {
|
||||||
|
return this.billingService.payInvoiceForUser(id, resolveAuthUserId(user), {
|
||||||
|
method: dto.method,
|
||||||
|
platform: dto.platform ?? "web",
|
||||||
|
payerAccount: dto.payerAccount,
|
||||||
|
returnUrl: dto.returnUrl,
|
||||||
|
failureUrl: dto.failureUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Param,
|
|
||||||
ParseUUIDPipe,
|
|
||||||
Post,
|
|
||||||
Query,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { CurrentUser } from '@edr/api-common';
|
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
|
||||||
import { BookingOrdersService } from './booking-orders.service';
|
|
||||||
import { CreateBookingOrderDto } from './dto/create-booking-order.dto';
|
|
||||||
import { GeneralContractService } from './general-contract.service';
|
|
||||||
|
|
||||||
@ApiTags('Booking Orders')
|
|
||||||
@Controller('booking-orders')
|
|
||||||
export class BookingOrdersController {
|
|
||||||
constructor(
|
|
||||||
private readonly ordersService: BookingOrdersService,
|
|
||||||
private readonly generalContractService: GeneralContractService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Post()
|
|
||||||
@ApiOperation({ summary: 'Place a drawdown order against a general contract' })
|
|
||||||
async create(
|
|
||||||
@Body() dto: CreateBookingOrderDto,
|
|
||||||
@CurrentUser() user: TCurrentUser,
|
|
||||||
) {
|
|
||||||
return this.ordersService.create(dto, user?.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
@ApiOperation({ summary: 'List orders placed against a contract' })
|
|
||||||
async list(@Query('contractBookingId', ParseUUIDPipe) contractBookingId: string) {
|
|
||||||
return this.ordersService.listByContract(contractBookingId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('contract/:id/pool')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Contracted / ordered / remaining quantities for a general contract',
|
|
||||||
})
|
|
||||||
async pool(@Param('id', ParseUUIDPipe) id: string) {
|
|
||||||
return this.generalContractService.getQuantityLines(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('contract/:id/routes')
|
|
||||||
@ApiOperation({
|
|
||||||
summary:
|
|
||||||
'Per-route contracted / ordered / remaining quantities (multi-route contracts). Empty for single-route.',
|
|
||||||
})
|
|
||||||
async routes(@Param('id', ParseUUIDPipe) id: string) {
|
|
||||||
return this.generalContractService.getRouteLines(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':id')
|
|
||||||
@ApiOperation({ summary: 'Get a single booking order' })
|
|
||||||
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
|
||||||
return this.ordersService.findById(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { forwardRef, Module } from '@nestjs/common';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
|
||||||
import { CompaniesModule } from '../companies/companies.module';
|
|
||||||
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
|
|
||||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
|
||||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
|
||||||
import { BookingOrdersController } from './booking-orders.controller';
|
|
||||||
import { BookingOrdersRepository } from './booking-orders.repository';
|
|
||||||
import { BookingOrdersService } from './booking-orders.service';
|
|
||||||
import { BookingOrder } from './entities/booking-order.entity';
|
|
||||||
import { BookingOrderLine } from './entities/booking-order-line.entity';
|
|
||||||
import { ContractRouteLine } from './entities/contract-route-line.entity';
|
|
||||||
import { GeneralContractService } from './general-contract.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
TypeOrmModule.forFeature([BookingOrder, BookingOrderLine, ContractRouteLine]),
|
|
||||||
BookingsModule,
|
|
||||||
CompaniesModule,
|
|
||||||
DropdownSettingsModule,
|
|
||||||
RuleEngineModule,
|
|
||||||
forwardRef(() => TrainSchedulingModule),
|
|
||||||
],
|
|
||||||
controllers: [BookingOrdersController],
|
|
||||||
providers: [
|
|
||||||
BookingOrdersService,
|
|
||||||
BookingOrdersRepository,
|
|
||||||
GeneralContractService,
|
|
||||||
],
|
|
||||||
exports: [BookingOrdersService, GeneralContractService],
|
|
||||||
})
|
|
||||||
export class BookingOrdersModule {}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { BaseRepository } from '@edr/api-common';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { BookingOrder } from './entities/booking-order.entity';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class BookingOrdersRepository extends BaseRepository<BookingOrder> {
|
|
||||||
constructor(
|
|
||||||
@InjectRepository(BookingOrder)
|
|
||||||
repository: Repository<BookingOrder>,
|
|
||||||
) {
|
|
||||||
super(repository);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Orders placed against a given contract, newest first, with their lines. */
|
|
||||||
findByContract(contractBookingId: string): Promise<BookingOrder[]> {
|
|
||||||
return this.repository.find({
|
|
||||||
where: { contractBookingId },
|
|
||||||
relations: { lines: { containerType: true }, booking: true },
|
|
||||||
order: { createdAt: 'DESC' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
override findById(id: string): Promise<BookingOrder | null> {
|
|
||||||
return this.repository.findOne({
|
|
||||||
where: { id },
|
|
||||||
relations: { lines: { containerType: true }, booking: true, contractBooking: true },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Count this calendar year's orders, for reference generation. */
|
|
||||||
async countByYear(year: number): Promise<number> {
|
|
||||||
const start = new Date(Date.UTC(year, 0, 1));
|
|
||||||
const end = new Date(Date.UTC(year + 1, 0, 1));
|
|
||||||
return this.repository
|
|
||||||
.createQueryBuilder('o')
|
|
||||||
.where('o.createdAt >= :start AND o.createdAt < :end', { start, end })
|
|
||||||
.getCount();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { BookingOrdersService } from './booking-orders.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Phase-0 spine: a drawdown order spawns a PRICED, UNPAID child booking that
|
|
||||||
* waits for Marketing review (or the customs clearance gate first) — it does
|
|
||||||
* NOT auto-enter the train batch pool, and the contract is not charged.
|
|
||||||
*/
|
|
||||||
describe('BookingOrdersService — child spawn on order create', () => {
|
|
||||||
function makeService(opts: { includesCustoms: boolean; roadKm?: number | null }) {
|
|
||||||
const contract = {
|
|
||||||
id: 'c-1',
|
|
||||||
bookingType: 'GENERAL_CONTRACT',
|
|
||||||
status: 'CONTRACT_ACTIVE',
|
|
||||||
expiresAt: new Date('2030-01-01T00:00:00.000Z'),
|
|
||||||
freightType: 'BULK',
|
|
||||||
originYardId: 'o-1',
|
|
||||||
destinationYardId: 'd-1',
|
|
||||||
companyId: null,
|
|
||||||
paymentCurrency: 'ETB',
|
|
||||||
serviceType: { includesCustoms: opts.includesCustoms, code: 'RAIL_BULK' },
|
|
||||||
bookingContainers: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Capture what status the child is created with.
|
|
||||||
const created: Record<string, unknown>[] = [];
|
|
||||||
const managerUpdates: Record<string, unknown>[] = [];
|
|
||||||
const fakeManager = {
|
|
||||||
create: (_entity: unknown, data: Record<string, unknown>) => {
|
|
||||||
created.push(data);
|
|
||||||
return { id: 'child-1', ...data };
|
|
||||||
},
|
|
||||||
save: async (row: Record<string, unknown>) => ({ id: 'child-1', ...row }),
|
|
||||||
getRepository: () => ({
|
|
||||||
findOne: async () => ({ id: 'child-1', paymentCurrency: 'ETB', bookingContainers: [] }),
|
|
||||||
update: async (_id: string, data: Record<string, unknown>) => {
|
|
||||||
managerUpdates.push(data);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const dataSource = {
|
|
||||||
transaction: async (cb: (m: unknown) => Promise<unknown>) => cb(fakeManager),
|
|
||||||
getRepository: () => ({ update: jest.fn() }),
|
|
||||||
};
|
|
||||||
const ordersRepository = {
|
|
||||||
countByYear: jest.fn().mockResolvedValue(0),
|
|
||||||
findById: jest.fn().mockResolvedValue({ id: 'order-1', lines: [] }),
|
|
||||||
};
|
|
||||||
const bookingsRepository = {
|
|
||||||
findById: jest.fn().mockResolvedValue(contract),
|
|
||||||
countByYear: jest.fn().mockResolvedValue(0),
|
|
||||||
};
|
|
||||||
const generalContractService = {
|
|
||||||
isGeneralContract: () => true,
|
|
||||||
getRouteLines: jest.fn().mockResolvedValue([]),
|
|
||||||
getQuantityLines: jest
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue([
|
|
||||||
{ containerTypeId: null, remainingQuantity: 100, containerTypeName: null },
|
|
||||||
]),
|
|
||||||
isExhausted: jest.fn().mockResolvedValue(false),
|
|
||||||
};
|
|
||||||
const pricingService = {
|
|
||||||
computePriceForBooking: jest.fn().mockResolvedValue({
|
|
||||||
totalAmount: 500,
|
|
||||||
priorityScore: 10,
|
|
||||||
lineItems: [],
|
|
||||||
currency: 'ETB',
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const ratesService = { findLiveRates: jest.fn().mockResolvedValue([]) };
|
|
||||||
const trainSchedulingService = {
|
|
||||||
existsOpenScheduleOnRouteDay: jest.fn().mockResolvedValue(true),
|
|
||||||
};
|
|
||||||
const companiesService = {};
|
|
||||||
|
|
||||||
const service = new BookingOrdersService(
|
|
||||||
dataSource as never,
|
|
||||||
ordersRepository as never,
|
|
||||||
bookingsRepository as never,
|
|
||||||
companiesService as never,
|
|
||||||
generalContractService as never,
|
|
||||||
pricingService as never,
|
|
||||||
ratesService as never,
|
|
||||||
trainSchedulingService as never,
|
|
||||||
);
|
|
||||||
return { service, created, managerUpdates, pricingService };
|
|
||||||
}
|
|
||||||
|
|
||||||
const dto = {
|
|
||||||
contractBookingId: 'c-1',
|
|
||||||
scheduledDate: '2026-07-01T00:00:00.000Z',
|
|
||||||
lines: [{ quantity: 10, hazardousQuantity: 4, reeferQuantity: 0 }],
|
|
||||||
};
|
|
||||||
|
|
||||||
it('spawns the child at OPERATION_REQUEST_PENDING (no customs), priced + unpaid', async () => {
|
|
||||||
const { service, created, managerUpdates, pricingService } = makeService({
|
|
||||||
includesCustoms: false,
|
|
||||||
});
|
|
||||||
await service.create(dto as never);
|
|
||||||
|
|
||||||
const child = created.find((c) => c.bookingType === 'ONE_TIME')!;
|
|
||||||
expect(child.status).toBe('OPERATION_REQUEST_PENDING');
|
|
||||||
expect(child.paymentStatus).toBe('PENDING');
|
|
||||||
expect(child.isHazardous).toBe(true); // line has hazardousQuantity > 0
|
|
||||||
expect(pricingService.computePriceForBooking).toHaveBeenCalled();
|
|
||||||
// The computed price is persisted onto the child.
|
|
||||||
expect(managerUpdates.some((u) => u.totalAmount === 500)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('spawns the child at AWAITING_DOCUMENTS when the service includes customs', async () => {
|
|
||||||
const { service, created } = makeService({ includesCustoms: true });
|
|
||||||
await service.create(dto as never);
|
|
||||||
const child = created.find((c) => c.bookingType === 'ONE_TIME')!;
|
|
||||||
expect(child.status).toBe('AWAITING_DOCUMENTS');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects when hazardous quantity exceeds the line quantity', async () => {
|
|
||||||
const { service } = makeService({ includesCustoms: false });
|
|
||||||
await expect(
|
|
||||||
service.create({
|
|
||||||
...dto,
|
|
||||||
lines: [{ quantity: 5, hazardousQuantity: 9, reeferQuantity: 0 }],
|
|
||||||
} as never),
|
|
||||||
).rejects.toThrow(/exceed the line quantity/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,469 +0,0 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
forwardRef,
|
|
||||||
Inject,
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { DataSource } from 'typeorm';
|
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
|
||||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
|
||||||
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
|
||||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
|
||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
|
||||||
import { RatesService } from '../rule-engine/services/rates.service';
|
|
||||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
|
||||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
|
||||||
import { BookingOrdersRepository } from './booking-orders.repository';
|
|
||||||
import { CreateBookingOrderDto } from './dto/create-booking-order.dto';
|
|
||||||
import { BookingOrder } from './entities/booking-order.entity';
|
|
||||||
import { BookingOrderLine } from './entities/booking-order-line.entity';
|
|
||||||
import { GeneralContractService } from './general-contract.service';
|
|
||||||
import { isRoadService, roadKmPrice } from './road.util';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class BookingOrdersService {
|
|
||||||
private readonly logger = new Logger(BookingOrdersService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly dataSource: DataSource,
|
|
||||||
private readonly ordersRepository: BookingOrdersRepository,
|
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
|
||||||
private readonly companiesService: CompaniesService,
|
|
||||||
private readonly generalContractService: GeneralContractService,
|
|
||||||
private readonly pricingService: BookingPricingService,
|
|
||||||
private readonly ratesService: RatesService,
|
|
||||||
@Inject(forwardRef(() => TrainSchedulingService))
|
|
||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** Orders placed against a contract, with their lines and child booking. */
|
|
||||||
async listByContract(contractBookingId: string): Promise<BookingOrder[]> {
|
|
||||||
const orders = await this.ordersRepository.findByContract(contractBookingId);
|
|
||||||
await Promise.all(orders.map((o) => this.syncOrderFromChild(o)));
|
|
||||||
return orders;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findById(id: string): Promise<BookingOrder | null> {
|
|
||||||
const order = await this.ordersRepository.findById(id);
|
|
||||||
if (order) await this.syncOrderFromChild(order);
|
|
||||||
return order;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The order is a ledger row; the spawned child ONE_TIME booking is what
|
|
||||||
* actually moves through the workflow (clearance → marketing/ops accept →
|
|
||||||
* pay → allocate), exactly like a one-time booking. Nothing writes the order
|
|
||||||
* row after creation, so its stored status would stay 'PENDING' forever.
|
|
||||||
*
|
|
||||||
* Mirror the child onto the order whenever it is read: copy the child's
|
|
||||||
* status, schedulingStatus and trainScheduleId onto the order (mutating the
|
|
||||||
* in-memory instance the caller gets back), and persist that snapshot when it
|
|
||||||
* has drifted so list/detail views and any stored reporting stay in sync.
|
|
||||||
*/
|
|
||||||
private async syncOrderFromChild(order: BookingOrder): Promise<void> {
|
|
||||||
const child = order.booking;
|
|
||||||
if (!child) return;
|
|
||||||
|
|
||||||
const nextStatus = child.status;
|
|
||||||
const nextScheduling = child.schedulingStatus;
|
|
||||||
const nextTrainScheduleId = child.trainScheduleId ?? null;
|
|
||||||
|
|
||||||
const drifted =
|
|
||||||
order.status !== nextStatus ||
|
|
||||||
order.schedulingStatus !== nextScheduling ||
|
|
||||||
(order.trainScheduleId ?? null) !== nextTrainScheduleId;
|
|
||||||
|
|
||||||
// Reflect the child onto the instance returned to the caller.
|
|
||||||
order.status = nextStatus;
|
|
||||||
order.schedulingStatus = nextScheduling;
|
|
||||||
order.trainScheduleId = nextTrainScheduleId;
|
|
||||||
|
|
||||||
if (drifted) {
|
|
||||||
await this.ordersRepository.update(order.id, {
|
|
||||||
status: nextStatus,
|
|
||||||
schedulingStatus: nextScheduling,
|
|
||||||
trainScheduleId: nextTrainScheduleId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Place a drawdown order against an ACTIVE general contract.
|
|
||||||
*
|
|
||||||
* Validates the requested quantities against the remaining pool, then spawns a
|
|
||||||
* ONE_TIME child Booking (PAID + FULLY_EXECUTED, inheriting the contract's
|
|
||||||
* route/cargo/service) so it flows through the existing train-scheduling
|
|
||||||
* pipeline. The order row is the ledger entry linking contract → child booking.
|
|
||||||
*/
|
|
||||||
async create(
|
|
||||||
dto: CreateBookingOrderDto,
|
|
||||||
userId?: string,
|
|
||||||
): Promise<BookingOrder> {
|
|
||||||
const contract = await this.bookingsRepository.findById(dto.contractBookingId);
|
|
||||||
if (!contract) {
|
|
||||||
throw new NotFoundException(`Contract ${dto.contractBookingId} not found`);
|
|
||||||
}
|
|
||||||
if (!this.generalContractService.isGeneralContract(contract)) {
|
|
||||||
throw new BadRequestException('Booking is not a general contract');
|
|
||||||
}
|
|
||||||
if (contract.status !== 'CONTRACT_ACTIVE') {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Contract is ${contract.status} — orders can only be placed against an ACTIVE contract`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (contract.expiresAt && contract.expiresAt.getTime() <= Date.now()) {
|
|
||||||
throw new BadRequestException('Contract ordering window has expired');
|
|
||||||
}
|
|
||||||
|
|
||||||
// The customer placing the order must own the contract.
|
|
||||||
if (userId && !(await this.userOwnsContract(userId, contract))) {
|
|
||||||
throw new BadRequestException('You do not have access to this contract');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the route the order ships on: a chosen contract route line for a
|
|
||||||
// multi-route contract, else the contract's own origin/destination.
|
|
||||||
const routeLines = await this.generalContractService.getRouteLines(
|
|
||||||
contract.id,
|
|
||||||
);
|
|
||||||
let originYardId = contract.originYardId;
|
|
||||||
let destinationYardId = contract.destinationYardId;
|
|
||||||
let routeLineId: string | null = null;
|
|
||||||
let routeKm: number | null = null;
|
|
||||||
|
|
||||||
if (routeLines.length > 0) {
|
|
||||||
if (!dto.routeLineId) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'This contract has multiple routes — select a route to draw from',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const chosen = routeLines.find((r) => r.routeLineId === dto.routeLineId);
|
|
||||||
if (!chosen) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'Selected route is not part of this contract',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
originYardId = chosen.originYardId;
|
|
||||||
destinationYardId = chosen.destinationYardId;
|
|
||||||
routeLineId = chosen.routeLineId;
|
|
||||||
routeKm = chosen.km ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate the route has a departure on the chosen day.
|
|
||||||
const day = eatDay(new Date(dto.scheduledDate));
|
|
||||||
const hasDeparture =
|
|
||||||
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
|
||||||
originYardId,
|
|
||||||
destinationYardId,
|
|
||||||
day,
|
|
||||||
);
|
|
||||||
if (!hasDeparture) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'No departures available on the selected day for this route',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isContainer = contract.freightType === 'CONTAINER';
|
|
||||||
|
|
||||||
// Hazardous/reefer counts the customer entered cannot exceed the line they
|
|
||||||
// belong to. Validated for every order regardless of routing.
|
|
||||||
for (const line of dto.lines) {
|
|
||||||
const haz = line.hazardousQuantity ?? 0;
|
|
||||||
const reefer = line.reeferQuantity ?? 0;
|
|
||||||
if (haz < 0 || reefer < 0) {
|
|
||||||
throw new BadRequestException('Hazardous/reefer quantities cannot be negative');
|
|
||||||
}
|
|
||||||
if (haz > line.quantity || reefer > line.quantity) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'Hazardous/reefer quantity cannot exceed the line quantity',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The contract has a single shared drawdown pool (per container type for
|
|
||||||
// CONTAINER, or one bulk bucket). Routes are pure lanes — the chosen route
|
|
||||||
// only fixed origin/destination/km above — so every order, routed or not,
|
|
||||||
// validates each line against the same shared pool.
|
|
||||||
const poolLines = await this.generalContractService.getQuantityLines(
|
|
||||||
contract.id,
|
|
||||||
);
|
|
||||||
for (const line of dto.lines) {
|
|
||||||
if (line.quantity <= 0) {
|
|
||||||
throw new BadRequestException('Order quantities must be greater than zero');
|
|
||||||
}
|
|
||||||
const key = isContainer ? (line.containerTypeId ?? '') : '';
|
|
||||||
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
|
|
||||||
if (!poolLine) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
isContainer
|
|
||||||
? `Container type ${line.containerTypeId} is not part of this contract`
|
|
||||||
: 'This contract has no matching quantity pool',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (line.quantity > poolLine.remainingQuantity) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
|
|
||||||
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist the order + its child shipment booking atomically.
|
|
||||||
const order = await this.dataSource.transaction(async (manager) => {
|
|
||||||
const childBooking = await this.spawnChildBooking(
|
|
||||||
contract,
|
|
||||||
dto,
|
|
||||||
{ originYardId, destinationYardId, km: routeKm },
|
|
||||||
manager,
|
|
||||||
);
|
|
||||||
|
|
||||||
const reference = await this.generateReference();
|
|
||||||
const orderRow = manager.create(BookingOrder, {
|
|
||||||
reference,
|
|
||||||
contractBookingId: contract.id,
|
|
||||||
bookingId: childBooking.id,
|
|
||||||
routeLineId,
|
|
||||||
companyId: contract.companyId ?? null,
|
|
||||||
scheduledDate: new Date(dto.scheduledDate),
|
|
||||||
// The order is a ledger row; the child booking drives the workflow
|
|
||||||
// (review → pay → allocate), so the order tracks PENDING until done.
|
|
||||||
status: 'PENDING',
|
|
||||||
schedulingStatus: 'NOT_SCHEDULED',
|
|
||||||
});
|
|
||||||
const savedOrder = await manager.save(orderRow);
|
|
||||||
|
|
||||||
const lines = dto.lines.map((l) =>
|
|
||||||
manager.create(BookingOrderLine, {
|
|
||||||
orderId: savedOrder.id,
|
|
||||||
containerTypeId: isContainer ? (l.containerTypeId ?? null) : null,
|
|
||||||
quantity: l.quantity,
|
|
||||||
hazardousQuantity: l.hazardousQuantity ?? 0,
|
|
||||||
reeferQuantity: l.reeferQuantity ?? 0,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await manager.save(lines);
|
|
||||||
savedOrder.lines = lines;
|
|
||||||
return savedOrder;
|
|
||||||
});
|
|
||||||
|
|
||||||
// The child does NOT enter the train batch pool here. It is priced and
|
|
||||||
// unpaid, awaiting Marketing review (OPERATION_REQUEST_PENDING) or customs
|
|
||||||
// clearance first; the batch enqueue happens only on accept.
|
|
||||||
|
|
||||||
// Close the contract once its pool is exhausted (pending orders count, so
|
|
||||||
// the pool reserves quantity as soon as an order is placed).
|
|
||||||
if (await this.generalContractService.isExhausted(contract.id)) {
|
|
||||||
await this.dataSource
|
|
||||||
.getRepository(Booking)
|
|
||||||
.update(contract.id, { status: 'CONTRACT_CLOSED' });
|
|
||||||
this.logger.log(
|
|
||||||
`Contract ${contract.reference} CLOSED — quantity exhausted`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (await this.ordersRepository.findById(order.id)) ?? order;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the ONE_TIME child booking for an order, inheriting the contract's
|
|
||||||
* shipment context. Unlike the contract (which is no longer paid up front),
|
|
||||||
* the child is PRICED and UNPAID and waits for Marketing review — going
|
|
||||||
* through the customs clearance gate first when the service includes customs,
|
|
||||||
* mirroring a one-time booking. It only enters the train pool on accept.
|
|
||||||
*/
|
|
||||||
private async spawnChildBooking(
|
|
||||||
contract: Booking,
|
|
||||||
dto: CreateBookingOrderDto,
|
|
||||||
route: { originYardId: string; destinationYardId: string; km: number | null },
|
|
||||||
manager: import('typeorm').EntityManager,
|
|
||||||
): Promise<Booking> {
|
|
||||||
const reference = await this.generateChildBookingReference();
|
|
||||||
const isContainer = contract.freightType === 'CONTAINER';
|
|
||||||
|
|
||||||
// Sum line quantities × the contract's per-unit weight for the child total.
|
|
||||||
const containerByType = new Map(
|
|
||||||
(contract.bookingContainers ?? []).map((c) => [c.containerTypeId, c]),
|
|
||||||
);
|
|
||||||
let totalWeight = 0;
|
|
||||||
if (isContainer) {
|
|
||||||
for (const line of dto.lines) {
|
|
||||||
const src = containerByType.get(line.containerTypeId ?? '');
|
|
||||||
const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0;
|
|
||||||
totalWeight += vgmPerUnit * line.quantity;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
totalWeight = dto.lines.reduce((sum, l) => sum + l.quantity, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-order hazardous/reefer: set the child flags from the order's line
|
|
||||||
// counts so the HAZARD_SURCHARGE / REEFER_SURCHARGE rates apply.
|
|
||||||
const hasHazardous = dto.lines.some((l) => (l.hazardousQuantity ?? 0) > 0);
|
|
||||||
const hasReefer = dto.lines.some((l) => (l.reeferQuantity ?? 0) > 0);
|
|
||||||
|
|
||||||
// Customs orders flow through the one-time clearance gate first; others go
|
|
||||||
// straight to operations review with the chosen shipment day.
|
|
||||||
const { includesCustoms } = clearanceCodesForBooking(contract);
|
|
||||||
const spawnStatus = includesCustoms
|
|
||||||
? 'AWAITING_DOCUMENTS'
|
|
||||||
: 'OPERATION_REQUEST_PENDING';
|
|
||||||
|
|
||||||
const child = manager.create(Booking, {
|
|
||||||
reference,
|
|
||||||
companyId: contract.companyId ?? null,
|
|
||||||
companyProfileId: contract.companyProfileId ?? null,
|
|
||||||
isGovernment: contract.isGovernment,
|
|
||||||
governmentInstitution: contract.governmentInstitution ?? null,
|
|
||||||
contractType: contract.contractType,
|
|
||||||
previousContractId: contract.id,
|
|
||||||
serviceTypeId: contract.serviceTypeId,
|
|
||||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
|
||||||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
|
||||||
equipmentReturn: contract.equipmentReturn,
|
|
||||||
originYardId: route.originYardId,
|
|
||||||
destinationYardId: route.destinationYardId,
|
|
||||||
tradeDirection: contract.tradeDirection,
|
|
||||||
freightType: contract.freightType,
|
|
||||||
cargoTypeId: contract.cargoTypeId ?? null,
|
|
||||||
cargoFreeText: contract.cargoFreeText ?? null,
|
|
||||||
shippingLineId: contract.shippingLineId ?? null,
|
|
||||||
cargoTotalWeightVgm: totalWeight,
|
|
||||||
isHazardous: hasHazardous,
|
|
||||||
isReefer: hasReefer,
|
|
||||||
paymentCurrency: contract.paymentCurrency,
|
|
||||||
bookingType: 'ONE_TIME',
|
|
||||||
scheduledDate: new Date(dto.scheduledDate),
|
|
||||||
// Priced + unpaid: the customer pays this order on its own.
|
|
||||||
status: spawnStatus,
|
|
||||||
paymentStatus: 'PENDING',
|
|
||||||
priorityScore: contract.priorityScore,
|
|
||||||
totalAmount: 0,
|
|
||||||
schedulingStatus: 'NOT_SCHEDULED',
|
|
||||||
});
|
|
||||||
const savedChild = await manager.save(child);
|
|
||||||
|
|
||||||
if (isContainer) {
|
|
||||||
for (const line of dto.lines) {
|
|
||||||
const src = containerByType.get(line.containerTypeId ?? '');
|
|
||||||
const ct = line.containerTypeId
|
|
||||||
? await manager.getRepository(ContainerType).findOne({
|
|
||||||
where: { id: line.containerTypeId },
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
|
|
||||||
const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0;
|
|
||||||
const row = manager.create(BookingContainer, {
|
|
||||||
bookingId: savedChild.id,
|
|
||||||
containerTypeId: line.containerTypeId ?? null,
|
|
||||||
quantity: line.quantity,
|
|
||||||
vgmPerUnitTons: vgmPerUnit,
|
|
||||||
totalVgmTons: vgmPerUnit * line.quantity,
|
|
||||||
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnit),
|
|
||||||
isOverweight: false,
|
|
||||||
});
|
|
||||||
await manager.save(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Price the order: base freight for the drawn quantity + haz/reefer
|
|
||||||
// surcharges, plus a road KM charge when the service ships by road.
|
|
||||||
const roadKm = isRoadService(contract.serviceType) ? route.km : null;
|
|
||||||
await this.priceChildBooking(savedChild.id, roadKm, manager);
|
|
||||||
|
|
||||||
return savedChild;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute and persist the child order's price (base + surcharges) inside the
|
|
||||||
* order transaction. The contract is no longer paid up front, so each order
|
|
||||||
* carries its own total that the customer pays.
|
|
||||||
*/
|
|
||||||
private async priceChildBooking(
|
|
||||||
childId: string,
|
|
||||||
roadKm: number | null,
|
|
||||||
manager: import('typeorm').EntityManager,
|
|
||||||
): Promise<void> {
|
|
||||||
const child = await manager.getRepository(Booking).findOne({
|
|
||||||
where: { id: childId },
|
|
||||||
relations: { bookingContainers: true },
|
|
||||||
});
|
|
||||||
if (!child) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const computed = await this.pricingService.computePriceForBooking(child);
|
|
||||||
const lineItems = [...computed.lineItems];
|
|
||||||
let total = computed.totalAmount;
|
|
||||||
|
|
||||||
// Road KM charge: distance × the live PER_KM rate, added as its own line.
|
|
||||||
if (roadKm && roadKm > 0) {
|
|
||||||
const perKmRate = await this.findPerKmRate(child.paymentCurrency);
|
|
||||||
const kmAmount = roadKmPrice(roadKm, perKmRate);
|
|
||||||
if (kmAmount > 0) {
|
|
||||||
lineItems.push({
|
|
||||||
code: 'ROAD_KM',
|
|
||||||
description: `Road transport (${roadKm} km)`,
|
|
||||||
amount: kmAmount,
|
|
||||||
unitAmount: perKmRate!,
|
|
||||||
unit: 'PER_KM',
|
|
||||||
quantity: roadKm,
|
|
||||||
currency: child.paymentCurrency,
|
|
||||||
});
|
|
||||||
total += kmAmount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await manager.getRepository(Booking).update(childId, {
|
|
||||||
totalAmount: total,
|
|
||||||
priorityScore: computed.priorityScore,
|
|
||||||
pricingBreakdown: {
|
|
||||||
lineItems,
|
|
||||||
totalAmount: total,
|
|
||||||
currency: computed.currency,
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
} as never);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(
|
|
||||||
`Pricing child order ${childId} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The live PER_KM rate value for road billing, in the given currency. */
|
|
||||||
private async findPerKmRate(currency: string): Promise<number | null> {
|
|
||||||
const rates = await this.ratesService.findLiveRates();
|
|
||||||
const rate = rates.find(
|
|
||||||
(r) => r.rateUnit === 'PER_KM' && r.currency === currency,
|
|
||||||
);
|
|
||||||
return rate ? Number(rate.rateValue) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async userOwnsContract(
|
|
||||||
userId: string,
|
|
||||||
contract: Booking,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!contract.companyId) return true; // government / staff-created
|
|
||||||
try {
|
|
||||||
const { company } = await this.companiesService.getCompanyInfoByUserId(
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
return company.id === contract.companyId;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async generateReference(): Promise<string> {
|
|
||||||
const year = new Date().getFullYear();
|
|
||||||
const count = await this.ordersRepository.countByYear(year);
|
|
||||||
return `ORD-${year}-${String(count + 1).padStart(6, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async generateChildBookingReference(): Promise<string> {
|
|
||||||
const year = new Date().getFullYear();
|
|
||||||
const count = await this.bookingsRepository.countByYear(year);
|
|
||||||
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { CargoUnitOfMeasure } from '@edr/types';
|
|
||||||
|
|
||||||
/** A single contracted/ordered/remaining pool line for a general contract. */
|
|
||||||
export class ContractQuantityLineView {
|
|
||||||
@ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' })
|
|
||||||
containerTypeId!: string | null;
|
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
|
||||||
containerTypeName!: string | null;
|
|
||||||
|
|
||||||
@ApiProperty({ enum: CargoUnitOfMeasure, nullable: true })
|
|
||||||
unitOfMeasure!: CargoUnitOfMeasure | null;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
contractedQuantity!: number;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
orderedQuantity!: number;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
remainingQuantity!: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A contracted route (lane) of a general contract. Routes are pure
|
|
||||||
* origin→destination lanes the contract covers; they carry NO quantity. The
|
|
||||||
* contract has a single shared drawdown pool (see {@link ContractQuantityLineView}),
|
|
||||||
* and an order picks one lane (for scheduling/billing) while drawing from that
|
|
||||||
* shared pool.
|
|
||||||
*/
|
|
||||||
export class ContractRouteLineView {
|
|
||||||
@ApiProperty({ description: 'Contract route line id' })
|
|
||||||
routeLineId!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
originYardId!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
|
||||||
originYardName!: string | null;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
destinationYardId!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
|
||||||
destinationYardName!: string | null;
|
|
||||||
|
|
||||||
@ApiProperty({ nullable: true, description: 'Road distance (km); used to bill road orders' })
|
|
||||||
km!: number | null;
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { plainToInstance } from 'class-transformer';
|
|
||||||
import { CreateBookingOrderLineDto } from './create-booking-order.dto';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Order line haz/reefer quantities arrive as JSON numbers but must default to 0
|
|
||||||
* when omitted and coerce string inputs (defensive) to numbers.
|
|
||||||
*/
|
|
||||||
describe('CreateBookingOrderLineDto — haz/reefer coercion', () => {
|
|
||||||
const toDto = (plain: Record<string, unknown>) =>
|
|
||||||
plainToInstance(CreateBookingOrderLineDto, plain, {
|
|
||||||
enableImplicitConversion: false,
|
|
||||||
exposeDefaultValues: true,
|
|
||||||
}) as unknown as CreateBookingOrderLineDto;
|
|
||||||
|
|
||||||
it('defaults hazardous/reefer quantities to 0 when omitted', () => {
|
|
||||||
const dto = toDto({ quantity: 5 });
|
|
||||||
expect(dto.hazardousQuantity).toBe(0);
|
|
||||||
expect(dto.reeferQuantity).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('coerces provided string quantities to numbers', () => {
|
|
||||||
const dto = toDto({ quantity: '5', hazardousQuantity: '2', reeferQuantity: '3' });
|
|
||||||
expect(dto.quantity).toBe(5);
|
|
||||||
expect(dto.hazardousQuantity).toBe(2);
|
|
||||||
expect(dto.reeferQuantity).toBe(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
import { Transform, Type } from 'class-transformer';
|
|
||||||
import {
|
|
||||||
ArrayMinSize,
|
|
||||||
IsArray,
|
|
||||||
IsDateString,
|
|
||||||
IsNumber,
|
|
||||||
IsOptional,
|
|
||||||
IsUUID,
|
|
||||||
Min,
|
|
||||||
ValidateNested,
|
|
||||||
} from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateBookingOrderLineDto {
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
format: 'uuid',
|
|
||||||
description: 'Container type for this line (CONTAINER contracts). Omit for bulk/break-bulk.',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID()
|
|
||||||
containerTypeId?: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Quantity to draw down (containers, tons, or items)', minimum: 0 })
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
@Transform(({ value }) => Number(value))
|
|
||||||
quantity!: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'How much of this line is hazardous (≤ quantity). Defaults to 0.',
|
|
||||||
minimum: 0,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
@Transform(({ value }) => Number(value ?? 0))
|
|
||||||
hazardousQuantity?: number = 0;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'How much of this line is refrigerated (≤ quantity). Defaults to 0.',
|
|
||||||
minimum: 0,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
@Transform(({ value }) => Number(value ?? 0))
|
|
||||||
reeferQuantity?: number = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CreateBookingOrderDto {
|
|
||||||
@ApiProperty({ format: 'uuid', description: 'The general contract to draw down from' })
|
|
||||||
@IsUUID()
|
|
||||||
contractBookingId!: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
format: 'uuid',
|
|
||||||
description:
|
|
||||||
'For multi-route contracts: the contract route line being drawn from. ' +
|
|
||||||
'Determines the shipment origin/destination. Omit for single-route contracts.',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID()
|
|
||||||
routeLineId?: string;
|
|
||||||
|
|
||||||
@ApiProperty({ example: '2026-07-01T00:00:00.000Z', description: 'Shipment day for this order' })
|
|
||||||
@IsDateString()
|
|
||||||
scheduledDate!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ type: [CreateBookingOrderLineDto] })
|
|
||||||
@IsArray()
|
|
||||||
@ArrayMinSize(1)
|
|
||||||
@ValidateNested({ each: true })
|
|
||||||
@Type(() => CreateBookingOrderLineDto)
|
|
||||||
lines!: CreateBookingOrderLineDto[];
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
|
||||||
import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
|
|
||||||
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
|
|
||||||
import { BookingOrder } from './booking-order.entity';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Postgres `numeric` columns are serialized to JS strings by the driver. This
|
|
||||||
* transformer hydrates them back into real numbers so consumers (and the
|
|
||||||
* `quantity: number` API type) don't have to coerce on every read.
|
|
||||||
*/
|
|
||||||
const numericColumn = {
|
|
||||||
to: (value: number) => value,
|
|
||||||
from: (value: string | null) => (value == null ? value : Number(value)),
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One drawn-down quantity line of an order. For CONTAINER contracts there is one
|
|
||||||
* line per container type (matching the contract's pools); for BULK/BREAK_BULK a
|
|
||||||
* single line with a null containerTypeId carries the tons/items.
|
|
||||||
*/
|
|
||||||
@Entity({ schema: 'freight', name: 'booking_order_lines' })
|
|
||||||
export class BookingOrderLine extends BaseEntity {
|
|
||||||
@Column({ name: 'order_id', type: 'uuid' })
|
|
||||||
orderId!: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => BookingOrder, (order) => order.lines, { onDelete: 'CASCADE' })
|
|
||||||
@JoinColumn({ name: 'order_id' })
|
|
||||||
order?: BookingOrder;
|
|
||||||
|
|
||||||
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
|
|
||||||
containerTypeId?: string | null;
|
|
||||||
|
|
||||||
@ManyToOne(() => ContainerType, { nullable: true })
|
|
||||||
@JoinColumn({ name: 'container_type_id' })
|
|
||||||
containerType?: ContainerType | null;
|
|
||||||
|
|
||||||
/** Containers (count), tons, or items depending on the contract's freight/UoM. */
|
|
||||||
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, transformer: numericColumn })
|
|
||||||
quantity!: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How much of this line is hazardous / refrigerated, entered per order by the
|
|
||||||
* customer when they toggle the flag. Drives the HAZARD_SURCHARGE /
|
|
||||||
* REEFER_SURCHARGE rates on the spawned child booking. Both ≤ quantity.
|
|
||||||
*/
|
|
||||||
@Column({
|
|
||||||
name: 'hazardous_quantity',
|
|
||||||
type: 'numeric',
|
|
||||||
precision: 12,
|
|
||||||
scale: 3,
|
|
||||||
default: 0,
|
|
||||||
transformer: numericColumn,
|
|
||||||
})
|
|
||||||
hazardousQuantity!: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'reefer_quantity',
|
|
||||||
type: 'numeric',
|
|
||||||
precision: 12,
|
|
||||||
scale: 3,
|
|
||||||
default: 0,
|
|
||||||
transformer: numericColumn,
|
|
||||||
})
|
|
||||||
reeferQuantity!: number;
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
|
||||||
import { SchedulingStatus } from '@edr/types';
|
|
||||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
|
||||||
import { Booking } from '../../bookings/entities/booking.entity';
|
|
||||||
import { Company } from '../../companies/entities/company.entity';
|
|
||||||
import { BookingOrderLine } from './booking-order-line.entity';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single drawdown against a general contract. Each order spawns its own
|
|
||||||
* ONE_TIME child Booking (the shipment that enters the train scheduling
|
|
||||||
* pipeline); this row is the ledger entry linking the contract to that
|
|
||||||
* shipment and recording the drawn-down quantities.
|
|
||||||
*/
|
|
||||||
@Entity({ schema: 'freight', name: 'booking_orders' })
|
|
||||||
export class BookingOrder extends BaseEntity {
|
|
||||||
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
|
|
||||||
reference!: string;
|
|
||||||
|
|
||||||
/** The general contract (a Booking with bookingType = GENERAL_CONTRACT). */
|
|
||||||
@Column({ name: 'contract_booking_id', type: 'uuid' })
|
|
||||||
contractBookingId!: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => Booking)
|
|
||||||
@JoinColumn({ name: 'contract_booking_id' })
|
|
||||||
contractBooking?: Booking;
|
|
||||||
|
|
||||||
/** The ONE_TIME child shipment booking spawned for this order. */
|
|
||||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
|
||||||
bookingId?: string | null;
|
|
||||||
|
|
||||||
@ManyToOne(() => Booking, { nullable: true })
|
|
||||||
@JoinColumn({ name: 'booking_id' })
|
|
||||||
booking?: Booking | null;
|
|
||||||
|
|
||||||
/** Denormalized from the contract for fast company-scoped filtering. */
|
|
||||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
|
||||||
companyId?: string | null;
|
|
||||||
|
|
||||||
@ManyToOne(() => Company, { nullable: true })
|
|
||||||
@JoinColumn({ name: 'company_id' })
|
|
||||||
company?: Company | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The contract route line this order drew down (multi-route general contracts).
|
|
||||||
* Null for legacy/single-route contracts that have no route lines — the order
|
|
||||||
* then uses the contract's own origin/destination.
|
|
||||||
*/
|
|
||||||
@Column({ name: 'route_line_id', type: 'uuid', nullable: true })
|
|
||||||
routeLineId?: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'scheduled_date', type: 'timestamptz' })
|
|
||||||
scheduledDate!: Date;
|
|
||||||
|
|
||||||
@Column({ name: 'status', type: 'varchar', length: 40, default: 'PAID' })
|
|
||||||
status!: string;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'scheduling_status',
|
|
||||||
type: 'varchar',
|
|
||||||
length: 30,
|
|
||||||
default: SchedulingStatus.NotScheduled,
|
|
||||||
})
|
|
||||||
schedulingStatus!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
|
||||||
trainScheduleId?: string | null;
|
|
||||||
|
|
||||||
@OneToMany(() => BookingOrderLine, (line) => line.order, { cascade: true })
|
|
||||||
lines?: BookingOrderLine[];
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
|
||||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
|
||||||
import { Booking } from '../../bookings/entities/booking.entity';
|
|
||||||
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
|
|
||||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One contracted route+quantity line of a GENERAL contract. A general contract
|
|
||||||
* may span several routes (e.g. Addis→Dire Dawa: 10, Modjo→Djibouti: 5); each
|
|
||||||
* route reserves its own quantity pool. Drawdown orders pick one of these routes
|
|
||||||
* and decrement that route's pool. One-time bookings do not use this — they keep
|
|
||||||
* the single origin/destination on the booking itself.
|
|
||||||
*/
|
|
||||||
@Entity({ schema: 'freight', name: 'contract_route_lines' })
|
|
||||||
@Index(['contractBookingId'])
|
|
||||||
export class ContractRouteLine extends BaseEntity {
|
|
||||||
/** The general contract (a Booking with bookingType = GENERAL_CONTRACT). */
|
|
||||||
@Column({ name: 'contract_booking_id', type: 'uuid' })
|
|
||||||
contractBookingId!: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => Booking)
|
|
||||||
@JoinColumn({ name: 'contract_booking_id' })
|
|
||||||
contractBooking?: Booking;
|
|
||||||
|
|
||||||
@Column({ name: 'origin_yard_id', type: 'uuid' })
|
|
||||||
originYardId!: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => Yard)
|
|
||||||
@JoinColumn({ name: 'origin_yard_id' })
|
|
||||||
originYard?: Yard;
|
|
||||||
|
|
||||||
@Column({ name: 'destination_yard_id', type: 'uuid' })
|
|
||||||
destinationYardId!: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => Yard)
|
|
||||||
@JoinColumn({ name: 'destination_yard_id' })
|
|
||||||
destinationYard?: Yard;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Container type this route line reserves (CONTAINER contracts); null for
|
|
||||||
* BULK/BREAK_BULK, where the quantity is tons/items.
|
|
||||||
*/
|
|
||||||
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
|
|
||||||
containerTypeId?: string | null;
|
|
||||||
|
|
||||||
@ManyToOne(() => ContainerType, { nullable: true })
|
|
||||||
@JoinColumn({ name: 'container_type_id' })
|
|
||||||
containerType?: ContainerType | null;
|
|
||||||
|
|
||||||
/** Contracted quantity for this (route, container type): containers, tons, or items. */
|
|
||||||
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
|
|
||||||
quantity!: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Road distance for this route, configured with the route. Road (truck)
|
|
||||||
* drawdown orders bill KM × the PER_KM rate from this value. Null for
|
|
||||||
* rail-only routes where KM is not billed.
|
|
||||||
*/
|
|
||||||
@Column({ name: 'km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
|
||||||
km?: number | null;
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
|
||||||
import { BookingType, CargoUnitOfMeasure } from '@edr/types';
|
|
||||||
import { DataSource } from 'typeorm';
|
|
||||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
|
||||||
import { BookingOrder } from './entities/booking-order.entity';
|
|
||||||
import { ContractRouteLine } from './entities/contract-route-line.entity';
|
|
||||||
import {
|
|
||||||
ContractQuantityLineView,
|
|
||||||
ContractRouteLineView,
|
|
||||||
} from './dto/contract-view.dto';
|
|
||||||
|
|
||||||
/** Setting code holding the global ordering window (in months) for general contracts. */
|
|
||||||
export const CONTRACT_PERIOD_SETTING_CODE = 'general_contract_period';
|
|
||||||
/** Fallback when the setting is missing or unparseable. */
|
|
||||||
export const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owns general-contract concerns that sit alongside the generic booking flow:
|
|
||||||
* the configurable ordering period, post-payment activation, and computing the
|
|
||||||
* remaining drawdown pool per contract.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class GeneralContractService {
|
|
||||||
private readonly logger = new Logger(GeneralContractService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly dataSource: DataSource,
|
|
||||||
private readonly dropdownSettings: DropdownSettingsService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
isGeneralContract(booking: Pick<Booking, 'bookingType'>): boolean {
|
|
||||||
return booking.bookingType === BookingType.GeneralContract;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The configured ordering window in months (defaults to 3). */
|
|
||||||
async getPeriodMonths(): Promise<number> {
|
|
||||||
try {
|
|
||||||
const setting = await this.dropdownSettings.getByCode(
|
|
||||||
CONTRACT_PERIOD_SETTING_CODE,
|
|
||||||
);
|
|
||||||
const raw = setting.children?.[0]?.value;
|
|
||||||
const months = Number(raw);
|
|
||||||
if (Number.isFinite(months) && months > 0) return months;
|
|
||||||
} catch {
|
|
||||||
// Setting not seeded yet — fall back to the default.
|
|
||||||
}
|
|
||||||
return DEFAULT_CONTRACT_PERIOD_MONTHS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when a general contract's payment succeeds: mark it ACTIVE (instead of
|
|
||||||
* entering the train queue like a one-time booking) and stamp the ordering
|
|
||||||
* window. Idempotent.
|
|
||||||
*/
|
|
||||||
async activateAfterPayment(bookingId: string): Promise<void> {
|
|
||||||
const repo = this.dataSource.getRepository(Booking);
|
|
||||||
const booking = await repo.findOne({ where: { id: bookingId } });
|
|
||||||
if (!booking || !this.isGeneralContract(booking)) return;
|
|
||||||
if (booking.status === 'CONTRACT_ACTIVE' || booking.status === 'CONTRACT_CLOSED') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const months = await this.getPeriodMonths();
|
|
||||||
const expiresAt = new Date();
|
|
||||||
expiresAt.setMonth(expiresAt.getMonth() + months);
|
|
||||||
|
|
||||||
await repo.update(bookingId, {
|
|
||||||
status: 'CONTRACT_ACTIVE',
|
|
||||||
paymentStatus: 'PAID',
|
|
||||||
expiresAt,
|
|
||||||
});
|
|
||||||
this.logger.log(
|
|
||||||
`General contract ${booking.reference} ACTIVE — ordering window ${months} month(s) (expires ${expiresAt.toISOString()})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The drawdown pool for a contract: contracted vs. ordered vs. remaining,
|
|
||||||
* per container type for CONTAINER contracts, or a single total line for
|
|
||||||
* BULK/BREAK_BULK (keyed on a null container type).
|
|
||||||
*/
|
|
||||||
async getQuantityLines(
|
|
||||||
contractBookingId: string,
|
|
||||||
): Promise<ContractQuantityLineView[]> {
|
|
||||||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
|
||||||
where: { id: contractBookingId },
|
|
||||||
relations: { bookingContainers: { containerType: true }, cargoType: true },
|
|
||||||
});
|
|
||||||
if (!booking) throw new NotFoundException(`Contract ${contractBookingId} not found`);
|
|
||||||
|
|
||||||
const ordered = await this.orderedByContainerType(contractBookingId);
|
|
||||||
|
|
||||||
if (booking.freightType === 'CONTAINER') {
|
|
||||||
return (booking.bookingContainers ?? []).map((c) => {
|
|
||||||
const orderedQty = ordered.get(c.containerTypeId ?? '') ?? 0;
|
|
||||||
const contracted = Number(c.quantity);
|
|
||||||
return {
|
|
||||||
containerTypeId: c.containerTypeId ?? null,
|
|
||||||
containerTypeName: c.containerType?.label ?? null,
|
|
||||||
unitOfMeasure: null,
|
|
||||||
contractedQuantity: contracted,
|
|
||||||
orderedQuantity: orderedQty,
|
|
||||||
remainingQuantity: Math.max(0, contracted - orderedQty),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// BULK / BREAK_BULK — a single pool keyed on the contracted total weight/items.
|
|
||||||
const orderedQty = ordered.get('') ?? 0;
|
|
||||||
const contracted = Number(booking.cargoTotalWeightVgm);
|
|
||||||
const uom: CargoUnitOfMeasure | null =
|
|
||||||
(booking.cargoType?.unitOfMeasure as CargoUnitOfMeasure | undefined) ??
|
|
||||||
CargoUnitOfMeasure.PerTon;
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
containerTypeId: null,
|
|
||||||
containerTypeName: null,
|
|
||||||
unitOfMeasure: uom,
|
|
||||||
contractedQuantity: contracted,
|
|
||||||
orderedQuantity: orderedQty,
|
|
||||||
remainingQuantity: Math.max(0, contracted - orderedQty),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The contracted routes (lanes) of a multi-route general contract — pure
|
|
||||||
* origin→destination pairs the contract covers. Routes carry NO quantity; the
|
|
||||||
* contract draws from a single shared pool ({@link getQuantityLines}). An order
|
|
||||||
* picks one lane (for scheduling + road billing) and draws from that pool.
|
|
||||||
* Returns [] for single-route contracts (no route lines) — callers then use the
|
|
||||||
* contract's own origin/destination.
|
|
||||||
*/
|
|
||||||
async getRouteLines(
|
|
||||||
contractBookingId: string,
|
|
||||||
): Promise<ContractRouteLineView[]> {
|
|
||||||
const routeLines = await this.dataSource
|
|
||||||
.getRepository(ContractRouteLine)
|
|
||||||
.find({
|
|
||||||
where: { contractBookingId },
|
|
||||||
relations: {
|
|
||||||
originYard: true,
|
|
||||||
destinationYard: true,
|
|
||||||
},
|
|
||||||
order: { createdAt: 'ASC' },
|
|
||||||
});
|
|
||||||
|
|
||||||
return routeLines.map((rl) => ({
|
|
||||||
routeLineId: rl.id,
|
|
||||||
originYardId: rl.originYardId,
|
|
||||||
originYardName: rl.originYard?.label ?? null,
|
|
||||||
destinationYardId: rl.destinationYardId,
|
|
||||||
destinationYardName: rl.destinationYard?.label ?? null,
|
|
||||||
km: rl.km != null ? Number(rl.km) : null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */
|
|
||||||
private async orderedByContainerType(
|
|
||||||
contractBookingId: string,
|
|
||||||
): Promise<Map<string, number>> {
|
|
||||||
const rows = await this.dataSource
|
|
||||||
.getRepository(BookingOrder)
|
|
||||||
.createQueryBuilder('o')
|
|
||||||
.innerJoin('o.lines', 'line')
|
|
||||||
.select('COALESCE(line.container_type_id::text, :empty)', 'key')
|
|
||||||
.addSelect('SUM(line.quantity)', 'total')
|
|
||||||
.where('o.contract_booking_id = :contractBookingId', { contractBookingId })
|
|
||||||
.andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`)
|
|
||||||
.setParameter('empty', '')
|
|
||||||
.groupBy('key')
|
|
||||||
.getRawMany<{ key: string; total: string }>();
|
|
||||||
|
|
||||||
const map = new Map<string, number>();
|
|
||||||
for (const row of rows) map.set(row.key ?? '', Number(row.total));
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convenience: how many units remain for a given container type ('' = bulk). */
|
|
||||||
async remainingFor(
|
|
||||||
contractBookingId: string,
|
|
||||||
containerTypeKey: string,
|
|
||||||
): Promise<number> {
|
|
||||||
const lines = await this.getQuantityLines(contractBookingId);
|
|
||||||
const line = lines.find(
|
|
||||||
(l) => (l.containerTypeId ?? '') === containerTypeKey,
|
|
||||||
);
|
|
||||||
return line?.remainingQuantity ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True once the contract's shared pool is fully drawn down. Routes are pure
|
|
||||||
* lanes with no quantity, so exhaustion is purely a function of the shared
|
|
||||||
* per-container-type (or bulk) pool, regardless of how many routes exist.
|
|
||||||
*/
|
|
||||||
async isExhausted(contractBookingId: string): Promise<boolean> {
|
|
||||||
const lines = await this.getQuantityLines(contractBookingId);
|
|
||||||
return lines.every((l) => l.remainingQuantity <= 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { isRoadService, roadKmPrice } from './road.util';
|
|
||||||
|
|
||||||
describe('road.util', () => {
|
|
||||||
describe('isRoadService', () => {
|
|
||||||
it('treats ROAD/TRUCK codes (and prefixes) as road', () => {
|
|
||||||
expect(isRoadService({ code: 'ROAD' })).toBe(true);
|
|
||||||
expect(isRoadService({ code: 'TRUCK' })).toBe(true);
|
|
||||||
expect(isRoadService({ code: 'ROAD_CONTAINER' })).toBe(true);
|
|
||||||
expect(isRoadService({ code: 'truck_forwarding' })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats rail / unknown / missing services as not road', () => {
|
|
||||||
expect(isRoadService({ code: 'RAIL_CONTAINER' })).toBe(false);
|
|
||||||
expect(isRoadService({ code: 'OFFROADING' })).toBe(false);
|
|
||||||
expect(isRoadService(null)).toBe(false);
|
|
||||||
expect(isRoadService(undefined)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('roadKmPrice', () => {
|
|
||||||
it('multiplies distance by the per-km rate', () => {
|
|
||||||
expect(roadKmPrice(120, 5)).toBe(600);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 0 when km or rate is missing/non-positive', () => {
|
|
||||||
expect(roadKmPrice(null, 5)).toBe(0);
|
|
||||||
expect(roadKmPrice(120, null)).toBe(0);
|
|
||||||
expect(roadKmPrice(0, 5)).toBe(0);
|
|
||||||
expect(roadKmPrice(120, 0)).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { BookingsService } from './bookings.service';
|
||||||
|
import { AllocateContainersDto } from './dto/allocate-containers.dto';
|
||||||
|
|
||||||
|
@ApiTags('bookings')
|
||||||
|
@Controller('bookings')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class BookingAllocationController {
|
||||||
|
constructor(private readonly bookingsService: BookingsService) {}
|
||||||
|
|
||||||
|
@Post(':bookingId/allocate-containers')
|
||||||
|
@ApiOperation({ summary: 'Allocate containers to vehicles' })
|
||||||
|
async allocateContainers(
|
||||||
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
@Body() dto: AllocateContainersDto,
|
||||||
|
) {
|
||||||
|
return this.bookingsService.allocateContainers(bookingId, dto.allocations);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,12 +24,6 @@ import { ContractViewDto } from './dto/contract-view.dto';
|
|||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
|
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
|
||||||
|
|
||||||
/**
|
|
||||||
* Default ordering window (months) for a general contract activated on
|
|
||||||
* counter-sign. Mirrors GeneralContractService.DEFAULT_CONTRACT_PERIOD_MONTHS;
|
|
||||||
* defined locally to avoid a circular module dependency on booking-orders.
|
|
||||||
*/
|
|
||||||
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
|
|
||||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||||
import { SignaturesService } from '../signatures/signatures.service';
|
import { SignaturesService } from '../signatures/signatures.service';
|
||||||
|
|
||||||
@@ -240,23 +234,9 @@ export class BookingContractService {
|
|||||||
includesCustoms,
|
includesCustoms,
|
||||||
);
|
);
|
||||||
|
|
||||||
const isGeneralContract = booking.bookingType === 'GENERAL_CONTRACT';
|
|
||||||
|
|
||||||
if (role === 'CUSTOMER') {
|
if (role === 'CUSTOMER') {
|
||||||
updates.status = 'SIGNED_CUSTOMER';
|
updates.status = 'SIGNED_CUSTOMER';
|
||||||
updates.customerSignedAt = now;
|
updates.customerSignedAt = now;
|
||||||
} else if (isGeneralContract) {
|
|
||||||
// A general contract is NOT paid up front — each drawdown order is priced
|
|
||||||
// and paid on its own. So on counter-sign it becomes ACTIVE directly and
|
|
||||||
// opens its ordering window; orders spawn their own priced child bookings.
|
|
||||||
const expiresAt = new Date(now);
|
|
||||||
expiresAt.setMonth(expiresAt.getMonth() + DEFAULT_CONTRACT_PERIOD_MONTHS);
|
|
||||||
updates.fullyExecutedAt = now;
|
|
||||||
updates.marketingApprovedAt = now;
|
|
||||||
updates.marketingApprovedById = options.signerUserId ?? null;
|
|
||||||
updates.lockedAt = now;
|
|
||||||
updates.status = 'CONTRACT_ACTIVE';
|
|
||||||
updates.expiresAt = expiresAt;
|
|
||||||
} else {
|
} else {
|
||||||
updates.fullyExecutedAt = now;
|
updates.fullyExecutedAt = now;
|
||||||
updates.marketingApprovedAt = now;
|
updates.marketingApprovedAt = now;
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { Freight } from '@edr/types';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BillingService,
|
||||||
|
GenerateInvoiceInput,
|
||||||
|
InvoiceEventPayload,
|
||||||
|
InvoiceLineInput,
|
||||||
|
} from '../billing/billing.service';
|
||||||
|
import { Invoice } from '../billing/entities/invoice.entity';
|
||||||
|
import { FirstMileService } from '../first-mile/first-mile.service';
|
||||||
|
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||||
|
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||||
|
import { BookingsRepository } from './bookings.repository';
|
||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
|
||||||
|
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
|
||||||
|
interface StoredPricingBreakdown {
|
||||||
|
lineItems?: PriceLineItemDto[];
|
||||||
|
totalAmount?: number;
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Round to 2 decimals, avoiding binary float drift. */
|
||||||
|
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the booking ⇄ invoice mapping — the one place that knows how a booking
|
||||||
|
* turns into invoices, which type to use, and how it advances when paid. Bookings
|
||||||
|
* are the billable business entity, so they generate their own invoices directly
|
||||||
|
* via {@link BillingService} (billing stays source-agnostic). All booking-specific
|
||||||
|
* type branching lives here, at the two points it belongs: invoice creation and
|
||||||
|
* settlement (the paid handler).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class BookingInvoiceService {
|
||||||
|
private readonly logger = new Logger(BookingInvoiceService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly billing: BillingService,
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
@Inject(forwardRef(() => FirstMileService))
|
||||||
|
private readonly firstMile: FirstMileService,
|
||||||
|
@Inject(forwardRef(() => BookingBatchService))
|
||||||
|
private readonly bookingBatch: BookingBatchService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the booking has its invoice, generating one from the snapshotted
|
||||||
|
* pricing breakdown if absent. Called when a booking reaches a billable state.
|
||||||
|
* Idempotent — returns the existing open invoice instead of a duplicate.
|
||||||
|
* Returns `null` (and logs) when the booking is not billable: no company to
|
||||||
|
* bill (e.g. government bookings whose `companyId` is null, which the invoices
|
||||||
|
* FK requires), or no priced amount.
|
||||||
|
*/
|
||||||
|
async ensureInvoiceForBooking(booking: Booking): Promise<Invoice | null> {
|
||||||
|
const existing = await this.billing.findPayable(
|
||||||
|
Freight.InvoiceSource.Booking,
|
||||||
|
booking.id,
|
||||||
|
Freight.InvoiceType.Prepaid,
|
||||||
|
);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
if (!booking.companyId) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = this.buildInput(booking);
|
||||||
|
if (!input) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Skipping invoice for booking ${booking.reference} (${booking.id}): no priced amount.`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.billing.generateInvoice(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React to a booking invoice being paid — the settlement branch point. Per-type
|
||||||
|
* reactions live here (not in the payment process): each invoice type advances
|
||||||
|
* the booking its own way. Only PREPAID exists today.
|
||||||
|
*/
|
||||||
|
@OnEvent('booking.invoice.paid')
|
||||||
|
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||||
|
switch (payload.type) {
|
||||||
|
case Freight.InvoiceType.Prepaid:
|
||||||
|
await this.advanceBookingOnPayment(payload.sourceId);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.logger.warn(
|
||||||
|
`Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance a booking once its prepaid invoice settles — the domain side-effect
|
||||||
|
* of payment, relocated out of the payment service: the booking becomes PAID
|
||||||
|
* and is allocated into its batch. Idempotent — no-op when already PAID.
|
||||||
|
*
|
||||||
|
* General contracts are a separate aggregate now: their CONTRACT_ACTIVE
|
||||||
|
* lifecycle and ordering window live in the contracts module, advanced by the
|
||||||
|
* contract transition/clearance services — not by booking payment. Every
|
||||||
|
* booking that settles here is a ONE_TIME shipment, so there is no contract
|
||||||
|
* branch (legacy GENERAL_CONTRACT booking creation now 410s).
|
||||||
|
*/
|
||||||
|
private async advanceBookingOnPayment(bookingId: string): Promise<void> {
|
||||||
|
const booking = await this.bookingsRepository.findById(bookingId);
|
||||||
|
if (!booking) {
|
||||||
|
this.logger.warn(`Cannot advance unknown booking ${bookingId} on payment.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (booking.paymentStatus === 'PAID') return;
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (mg) => {
|
||||||
|
await mg.update(
|
||||||
|
Booking,
|
||||||
|
{ id: bookingId },
|
||||||
|
{ paymentStatus: 'PAID', status: 'PAID' },
|
||||||
|
);
|
||||||
|
await this.firstMile.acceptBooking(bookingId);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.bookingBatch.ensurePaidBookingAllocated(bookingId);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a booking's pricing snapshot into a generic invoice request. */
|
||||||
|
private buildInput(booking: Booking): GenerateInvoiceInput | null {
|
||||||
|
const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown;
|
||||||
|
const currency = breakdown.currency ?? booking.paymentCurrency ?? 'ETB';
|
||||||
|
|
||||||
|
const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({
|
||||||
|
chargeType: l.code,
|
||||||
|
description: l.description,
|
||||||
|
quantity: l.quantity,
|
||||||
|
unitRate: l.unitAmount,
|
||||||
|
amount: l.amount,
|
||||||
|
currency: l.currency ?? currency,
|
||||||
|
metadata: l.unit ? { unit: l.unit } : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Fall back to a single freight line when no breakdown was snapshotted.
|
||||||
|
if (lines.length === 0) {
|
||||||
|
const amount = Number(booking.totalAmount);
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) return null;
|
||||||
|
lines.push({
|
||||||
|
chargeType: 'FREIGHT',
|
||||||
|
description: 'Rail freight',
|
||||||
|
quantity: 1,
|
||||||
|
unitRate: amount,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtotal = round2(lines.reduce((sum, l) => sum + Number(l.amount), 0));
|
||||||
|
let totalAmount = subtotal;
|
||||||
|
|
||||||
|
// Honor a staff price override: bill the adjusted total, recording the delta
|
||||||
|
// as an ADJUSTMENT line so the lines still sum to the invoice total.
|
||||||
|
const adjusted = booking.adjustedTotalAmount;
|
||||||
|
if (adjusted != null && Number.isFinite(Number(adjusted))) {
|
||||||
|
const delta = round2(Number(adjusted) - subtotal);
|
||||||
|
if (delta !== 0) {
|
||||||
|
lines.push({
|
||||||
|
chargeType: 'ADJUSTMENT',
|
||||||
|
description: 'Staff price adjustment',
|
||||||
|
quantity: 1,
|
||||||
|
unitRate: delta,
|
||||||
|
amount: delta,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
totalAmount = round2(Number(adjusted));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: Freight.InvoiceSource.Booking,
|
||||||
|
sourceId: booking.id,
|
||||||
|
type: Freight.InvoiceType.Prepaid,
|
||||||
|
companyId: booking.companyId,
|
||||||
|
companyProfileId: booking.companyProfileId,
|
||||||
|
currency,
|
||||||
|
lines,
|
||||||
|
totalAmount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiProduces,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
|
import { Response } from "express";
|
||||||
|
import { Public } from "@edr/api-common";
|
||||||
|
import { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { BillingService } from "../billing/billing.service";
|
||||||
|
import {
|
||||||
|
InitiatePaymentDto,
|
||||||
|
InitiateResponseDto,
|
||||||
|
PaymentMethodTypeEnum,
|
||||||
|
PaymentPlatformDto,
|
||||||
|
} from "../payment/payments.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booking-payment entrypoints. This is the ONE place that knows a payment is for a
|
||||||
|
* booking — it maps the request to {@link Freight.InvoiceSource.Booking} and hands
|
||||||
|
* off to billing, which resolves the invoice/amount and drives the gateway. Billing
|
||||||
|
* and payment stay source-agnostic; the booking knowledge lives here, in the domain.
|
||||||
|
* Routes are unchanged (`/payments/*`) so the portal is unaffected.
|
||||||
|
*/
|
||||||
|
@ApiTags("Payment")
|
||||||
|
@Controller("payments")
|
||||||
|
export class BookingPaymentController {
|
||||||
|
constructor(private readonly billing: BillingService) { }
|
||||||
|
|
||||||
|
@Post("initiate")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Initiate payment for a freight booking",
|
||||||
|
description: "Charges the booking's open invoice through the payment gateway.",
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: InitiateResponseDto })
|
||||||
|
initiate(@Body() dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||||||
|
return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, {
|
||||||
|
method: dto.method,
|
||||||
|
platform: dto.platform,
|
||||||
|
payerAccount: dto.payerAccount,
|
||||||
|
returnUrl: dto.returnUrl,
|
||||||
|
failureUrl: dto.failureUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("checkout")
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Browser checkout redirect",
|
||||||
|
description:
|
||||||
|
"Charges the booking's invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: "bookingId", required: true })
|
||||||
|
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
|
||||||
|
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
|
||||||
|
@ApiProduces("text/html")
|
||||||
|
async checkout(
|
||||||
|
@Query("bookingId") bookingId: string,
|
||||||
|
@Query("method") method: PaymentMethodTypeEnum,
|
||||||
|
@Query("platform") platform: PaymentPlatformDto = "web",
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
if (!bookingId) {
|
||||||
|
return res
|
||||||
|
.status(HttpStatus.BAD_REQUEST)
|
||||||
|
.type("html")
|
||||||
|
.send(this.buildErrorHtml("Missing required query parameter: bookingId"));
|
||||||
|
}
|
||||||
|
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
|
||||||
|
return res
|
||||||
|
.status(HttpStatus.BAD_REQUEST)
|
||||||
|
.type("html")
|
||||||
|
.send(this.buildErrorHtml("Missing or invalid query parameter: method"));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.billing.payInvoice(
|
||||||
|
Freight.InvoiceSource.Booking,
|
||||||
|
bookingId,
|
||||||
|
{ method, platform },
|
||||||
|
);
|
||||||
|
const url =
|
||||||
|
result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined;
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url));
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
.status(HttpStatus.OK)
|
||||||
|
.type("html")
|
||||||
|
.send(this.buildStatusHtml(result.status, result.intentId));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "An unexpected error occurred";
|
||||||
|
return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRedirectHtml(url: string): string {
|
||||||
|
const escaped = url.replace(/\"/g, """);
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="refresh" content="0;url=${escaped}">
|
||||||
|
<title>Redirecting to payment…</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||||
|
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||||
|
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
p { color: #555; margin: 0 0 16px; }
|
||||||
|
a { color: #1a73e8; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Redirecting to payment provider…</p>
|
||||||
|
<p><a href="${escaped}">Click here if you are not redirected</a></p>
|
||||||
|
</div>
|
||||||
|
<script>window.location.href = "${escaped}";</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildStatusHtml(status: string, intentId: string): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Payment status</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||||
|
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||||
|
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
|
||||||
|
small { color: #888; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="status">${status}</div>
|
||||||
|
<small>Intent: ${intentId}</small>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildErrorHtml(message: string): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Payment error</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||||
|
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||||
|
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
|
||||||
|
p { color: #555; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="error">Payment could not be initiated</div>
|
||||||
|
<p>${message}</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +1,37 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Freight } from '@edr/types';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
||||||
import { PaymentService } from '../payment/payment.service';
|
import { BillingService } from '../billing/billing.service';
|
||||||
import { PaymentStatus } from '../payment/entities/payment.entity';
|
|
||||||
import { PaymentMethodTypeEnum } from '../payment/payments.dto';
|
import { PaymentMethodTypeEnum } from '../payment/payments.dto';
|
||||||
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
|
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
|
||||||
|
|
||||||
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
|
|
||||||
"action-required",
|
|
||||||
"processing",
|
|
||||||
"success",
|
|
||||||
];
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingPaymentService {
|
export class BookingPaymentService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
private readonly paymentService: PaymentService,
|
private readonly billing: BillingService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start payment for a booking. The booking never touches the payment gateway
|
||||||
|
* directly — it charges its invoice through billing, which resolves the amount
|
||||||
|
* and drives the provider. Returns the provider redirect URL (empty when none).
|
||||||
|
*/
|
||||||
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
|
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
|
||||||
const booking = await this.requireBooking(bookingId);
|
const booking = await this.requireBooking(bookingId);
|
||||||
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
|
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
|
||||||
|
|
||||||
const existing = await this.paymentService.findBookingById(bookingId);
|
const resp = await this.billing.payInvoice(Freight.InvoiceSource.Booking, bookingId, {
|
||||||
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
|
||||||
if (existing.clientAction) {
|
|
||||||
const action = existing.clientAction as { type?: string; url?: string };
|
|
||||||
if (action.type === "REDIRECT" && action.url) {
|
|
||||||
return { redirectUrl: action.url };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resp = await this.paymentService.initiatePayment({
|
|
||||||
bookingId,
|
|
||||||
method: PaymentMethodTypeEnum.TELEBIRR,
|
method: PaymentMethodTypeEnum.TELEBIRR,
|
||||||
platform: "web",
|
platform: 'web',
|
||||||
});
|
});
|
||||||
|
|
||||||
const action = resp.clientAction as { type?: string; url?: string } | undefined;
|
const action = resp.clientAction as { type?: string; url?: string } | undefined;
|
||||||
return {
|
return {
|
||||||
redirectUrl: action?.type === "REDIRECT" ? (action.url ?? "") : "",
|
redirectUrl: action?.type === 'REDIRECT' ? (action.url ?? '') : '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,9 +121,18 @@ export class BookingPricingService {
|
|||||||
total += line.amount;
|
total += line.amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// First / last mile trucking — billed per the rate's unit (km / container /
|
||||||
|
// ton / flat), only for legs the booking actually carries.
|
||||||
|
const { lineItems: mileLines, usedRates: mileRates } =
|
||||||
|
await this.computeFirstLastMileLines(booking, evalInput);
|
||||||
|
for (const line of mileLines) {
|
||||||
|
lineItems.push(line);
|
||||||
|
total += line.amount;
|
||||||
|
}
|
||||||
|
|
||||||
const liveRates = await this.ratesService.findLiveRates();
|
const liveRates = await this.ratesService.findLiveRates();
|
||||||
const rateById = new Map(liveRates.map((r) => [r.id, r]));
|
const rateById = new Map(liveRates.map((r) => [r.id, r]));
|
||||||
const usedRatesMap = new Map(baseRates.map((r) => [r.id, r]));
|
const usedRatesMap = new Map([...baseRates, ...mileRates].map((r) => [r.id, r]));
|
||||||
|
|
||||||
for (const mod of ruleResult.appliedModifiers) {
|
for (const mod of ruleResult.appliedModifiers) {
|
||||||
const usdAmount = mod.calculatedAmount;
|
const usdAmount = mod.calculatedAmount;
|
||||||
@@ -424,6 +433,97 @@ export class BookingPricingService {
|
|||||||
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-mile (pick-up) and last-mile (delivery) trucking lines. Each leg is
|
||||||
|
* billed only when the booking carries that leg (an address is set) and a LIVE
|
||||||
|
* rate exists, scaled by the rate's own unit:
|
||||||
|
* PER_KM → contract-route road distance (km)
|
||||||
|
* PER_CONTAINER → total container count
|
||||||
|
* PER_TON → total bulk tonnage
|
||||||
|
* FLAT → once
|
||||||
|
* A leg whose rate value (or computed amount) is 0 contributes nothing.
|
||||||
|
*/
|
||||||
|
private async computeFirstLastMileLines(
|
||||||
|
booking: Booking,
|
||||||
|
evalInput: BookingEvaluationInput,
|
||||||
|
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
|
||||||
|
const legs: Array<{ rateType: 'FIRST_MILE' | 'LAST_MILE'; label: string; active: boolean }> = [
|
||||||
|
{
|
||||||
|
rateType: 'FIRST_MILE',
|
||||||
|
label: 'First mile (pick-up)',
|
||||||
|
active: Boolean(booking.firstMilePickupAddress),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rateType: 'LAST_MILE',
|
||||||
|
label: 'Last mile (delivery)',
|
||||||
|
active: Boolean(booking.lastMileDeliveryAddress),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (!legs.some((l) => l.active)) {
|
||||||
|
return { lineItems: [], usedRates: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const liveRates = await this.ratesService.findLiveRates();
|
||||||
|
const paymentCurrency = booking.paymentCurrency;
|
||||||
|
const isEtbBooking = paymentCurrency === 'ETB';
|
||||||
|
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||||
|
|
||||||
|
const containerCount = evalInput.containers.reduce(
|
||||||
|
(sum, c) => sum + Number(c.quantity || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||||
|
const routeKm = await this.bookingsRepository.getContractRouteKm(booking.contractRouteId);
|
||||||
|
|
||||||
|
const lines: PriceLineItemDto[] = [];
|
||||||
|
const usedRatesMap = new Map<string, Rate>();
|
||||||
|
|
||||||
|
for (const leg of legs) {
|
||||||
|
if (!leg.active) continue;
|
||||||
|
const rate = liveRates.find(
|
||||||
|
(r) => r.rateType === leg.rateType && r.currency === 'USD' && r.status === 'LIVE',
|
||||||
|
);
|
||||||
|
if (!rate) continue;
|
||||||
|
|
||||||
|
const value = Number(rate.rateValue);
|
||||||
|
let quantity: number;
|
||||||
|
switch (rate.rateUnit) {
|
||||||
|
case 'PER_KM':
|
||||||
|
quantity = routeKm;
|
||||||
|
break;
|
||||||
|
case 'PER_CONTAINER':
|
||||||
|
quantity = containerCount;
|
||||||
|
break;
|
||||||
|
case 'PER_TON':
|
||||||
|
quantity = bulkTons;
|
||||||
|
break;
|
||||||
|
case 'FLAT':
|
||||||
|
default:
|
||||||
|
quantity = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usdAmount = value * quantity;
|
||||||
|
// Skip legs that resolve to nothing (zero rate, or zero km / count / tons).
|
||||||
|
if (!(usdAmount > 0)) continue;
|
||||||
|
|
||||||
|
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||||
|
const unitUsd = value;
|
||||||
|
usedRatesMap.set(rate.id, rate);
|
||||||
|
lines.push({
|
||||||
|
code: leg.rateType,
|
||||||
|
description: leg.label,
|
||||||
|
amount,
|
||||||
|
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
|
||||||
|
unit: rate.rateUnit,
|
||||||
|
quantity,
|
||||||
|
currency: paymentCurrency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
||||||
|
}
|
||||||
|
|
||||||
/** Friendly container-type label for the per-unit card; degrades to "Container". */
|
/** Friendly container-type label for the per-unit card; degrades to "Container". */
|
||||||
private async containerTypeLabel(containerTypeId: string): Promise<string> {
|
private async containerTypeLabel(containerTypeId: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
|||||||
ruleEngineService as never,
|
ruleEngineService as never,
|
||||||
{} as never, // pricingService
|
{} as never, // pricingService
|
||||||
{} as never, // contractService
|
{} as never, // contractService
|
||||||
|
{} as never, // invoiceService
|
||||||
{} as never, // filesService
|
{} as never, // filesService
|
||||||
{} as never, // fileUploadSettingsService
|
{} as never, // fileUploadSettingsService
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
|||||||
{} as never, // ruleEngineService
|
{} as never, // ruleEngineService
|
||||||
{} as never, // pricingService
|
{} as never, // pricingService
|
||||||
{} as never, // contractService
|
{} as never, // contractService
|
||||||
|
{} as never, // invoiceService
|
||||||
filesService as never,
|
filesService as never,
|
||||||
fileUploadSettingsService as never,
|
fileUploadSettingsService as never,
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
@@ -122,6 +123,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never, // invoiceService
|
||||||
filesService as never,
|
filesService as never,
|
||||||
fileUploadSettingsService as never,
|
fileUploadSettingsService as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
@@ -189,6 +191,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never, // invoiceService
|
||||||
filesService as never,
|
filesService as never,
|
||||||
fileUploadSettingsService as never,
|
fileUploadSettingsService as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { BookingTransitionService } from './booking-transition.service';
|
|||||||
* - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool.
|
* - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool.
|
||||||
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued.
|
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued.
|
||||||
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
|
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
|
||||||
* - ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM.
|
|
||||||
*/
|
*/
|
||||||
describe('BookingTransitionService — operation review', () => {
|
describe('BookingTransitionService — operation review', () => {
|
||||||
function makeService(serviceTypeCode: string) {
|
function makeService(serviceTypeCode: string) {
|
||||||
@@ -34,6 +33,7 @@ describe('BookingTransitionService — operation review', () => {
|
|||||||
{} as never, // ruleEngineService
|
{} as never, // ruleEngineService
|
||||||
{} as never, // pricingService
|
{} as never, // pricingService
|
||||||
{} as never, // contractService
|
{} as never, // contractService
|
||||||
|
{} as never, // invoiceService
|
||||||
{} as never, // filesService
|
{} as never, // filesService
|
||||||
{} as never, // fileUploadSettingsService
|
{} as never, // fileUploadSettingsService
|
||||||
bookingBatchService as never,
|
bookingBatchService as never,
|
||||||
@@ -78,18 +78,4 @@ describe('BookingTransitionService — operation review', () => {
|
|||||||
expect.objectContaining({ status: 'OPERATION_CHANGES_REQUESTED' }),
|
expect.objectContaining({ status: 'OPERATION_CHANGES_REQUESTED' }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM', async () => {
|
|
||||||
const { service, bookingsRepository } = makeService('RAIL_CONTAINER');
|
|
||||||
await service.reviewOperationRequest('b-1', 'ADJUST_PRICE', 'staff-1', {
|
|
||||||
amount: 1500,
|
|
||||||
});
|
|
||||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
|
||||||
'b-1',
|
|
||||||
expect.objectContaining({
|
|
||||||
adjustedTotalAmount: 1500,
|
|
||||||
status: 'OPERATION_PRICE_PENDING_CONFIRM',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,17 +3,19 @@ import {
|
|||||||
forwardRef,
|
forwardRef,
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
import { isRoadService } from '../booking-orders/road.util';
|
import { isRoadService } from './road.util';
|
||||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||||
import { BookingContractService } from './booking-contract.service';
|
import { BookingContractService } from './booking-contract.service';
|
||||||
|
import { BookingInvoiceService } from './booking-invoice.service';
|
||||||
import { BookingPricingService } from './booking-pricing.service';
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
@@ -26,11 +28,14 @@ import { BookingsService } from './bookings.service';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingTransitionService {
|
export class BookingTransitionService {
|
||||||
|
private readonly logger = new Logger(BookingTransitionService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
private readonly ruleEngineService: RuleEngineService,
|
private readonly ruleEngineService: RuleEngineService,
|
||||||
private readonly pricingService: BookingPricingService,
|
private readonly pricingService: BookingPricingService,
|
||||||
private readonly contractService: BookingContractService,
|
private readonly contractService: BookingContractService,
|
||||||
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||||
@Inject(forwardRef(() => BookingBatchService))
|
@Inject(forwardRef(() => BookingBatchService))
|
||||||
@@ -404,7 +409,22 @@ export class BookingTransitionService {
|
|||||||
marketingApprovedAt: new Date(),
|
marketingApprovedAt: new Date(),
|
||||||
lockedAt: new Date(),
|
lockedAt: new Date(),
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
|
||||||
|
const executed = await this.bookingsService.findById(updated!.id);
|
||||||
|
|
||||||
|
// Billable state reached — generate the invoice payment will settle.
|
||||||
|
// Non-blocking: a billing hiccup must not undo the execution.
|
||||||
|
await this.invoiceService
|
||||||
|
.ensureInvoiceForBooking(executed)
|
||||||
|
.catch((err) =>
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to generate invoice for booking ${executed.reference}: ${
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return executed;
|
||||||
}
|
}
|
||||||
|
|
||||||
async startTransit(bookingId: string): Promise<Booking> {
|
async startTransit(bookingId: string): Promise<Booking> {
|
||||||
@@ -477,30 +497,6 @@ export class BookingTransitionService {
|
|||||||
return this.bookingsService.findById(updated!.id);
|
return this.bookingsService.findById(updated!.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Staff adjusts a booking's total price. Stores an override (with who/when/why)
|
|
||||||
* that supersedes the computed total for the customer, who sees an
|
|
||||||
* "Adjusted by EDR" badge. Passing null clears the adjustment.
|
|
||||||
*/
|
|
||||||
async adjustPrice(
|
|
||||||
bookingId: string,
|
|
||||||
amount: number | null,
|
|
||||||
staffId: string,
|
|
||||||
reason?: string,
|
|
||||||
): Promise<Booking> {
|
|
||||||
await this.bookingsService.findById(bookingId);
|
|
||||||
if (amount != null && amount < 0) {
|
|
||||||
throw new BadRequestException('Adjusted amount cannot be negative');
|
|
||||||
}
|
|
||||||
await this.bookingsRepository.update(bookingId, {
|
|
||||||
adjustedTotalAmount: amount,
|
|
||||||
adjustedByStaffId: amount == null ? null : staffId,
|
|
||||||
adjustedAt: amount == null ? null : new Date(),
|
|
||||||
adjustmentReason: amount == null ? null : (reason ?? null),
|
|
||||||
} as never);
|
|
||||||
return this.bookingsService.findById(bookingId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Document clearance gate (post counter-sign) ───────────────────────────
|
// ── Document clearance gate (post counter-sign) ───────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -861,17 +857,17 @@ export class BookingTransitionService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Operations team reviews a pending operation request (capacity, documents,
|
* Operations team reviews a pending operation request (capacity, documents,
|
||||||
* route). Three outcomes:
|
* route). Two outcomes:
|
||||||
* - ACCEPT → booking enters the batch holding pool (FULLY_EXECUTED).
|
* - ACCEPT → booking enters the batch holding pool (FULLY_EXECUTED).
|
||||||
* - REQUEST_CHANGES → returned to the customer with a note to fix and resubmit.
|
* - REQUEST_CHANGES → returned to the customer with a note to fix and resubmit.
|
||||||
* - ADJUST_PRICE → a new total is set; the customer must re-confirm it
|
*
|
||||||
* before the booking can enter the pool.
|
* The booking price is computed from the contract and is never adjusted here.
|
||||||
*/
|
*/
|
||||||
async reviewOperationRequest(
|
async reviewOperationRequest(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
decision: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE',
|
decision: 'ACCEPT' | 'REQUEST_CHANGES',
|
||||||
actorId: string,
|
actorId: string,
|
||||||
options: { note?: string; amount?: number } = {},
|
options: { note?: string } = {},
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']);
|
assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']);
|
||||||
@@ -894,48 +890,10 @@ export class BookingTransitionService {
|
|||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (decision === 'ADJUST_PRICE') {
|
|
||||||
if (options.amount == null || options.amount < 0) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'A non-negative adjusted amount is required to adjust the price',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await this.bookingsRepository.update(bookingId, {
|
|
||||||
adjustedTotalAmount: options.amount,
|
|
||||||
adjustedByStaffId: actorId,
|
|
||||||
adjustedAt: new Date(),
|
|
||||||
adjustmentReason: options.note ?? null,
|
|
||||||
status: 'OPERATION_PRICE_PENDING_CONFIRM',
|
|
||||||
} as never);
|
|
||||||
return this.bookingsService.findById(bookingId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ACCEPT — enter the batch holding pool.
|
// ACCEPT — enter the batch holding pool.
|
||||||
return this.acceptOperationRequest(booking);
|
return this.acceptOperationRequest(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Customer re-confirms (or rejects) an operations price adjustment. Accepting
|
|
||||||
* pushes the booking into the pool; rejecting returns it to the customer as an
|
|
||||||
* operation change request so they can resubmit or cancel.
|
|
||||||
*/
|
|
||||||
async confirmOperationPrice(
|
|
||||||
bookingId: string,
|
|
||||||
accept: boolean,
|
|
||||||
): Promise<Booking> {
|
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
|
||||||
assertBookingStatus(booking, ['OPERATION_PRICE_PENDING_CONFIRM']);
|
|
||||||
|
|
||||||
if (!accept) {
|
|
||||||
await this.bookingsRepository.update(bookingId, {
|
|
||||||
status: 'OPERATION_CHANGES_REQUESTED',
|
|
||||||
} as never);
|
|
||||||
return this.bookingsService.findById(bookingId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.acceptOperationRequest(booking);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Move a reviewed operation request forward after Marketing accepts.
|
* Move a reviewed operation request forward after Marketing accepts.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
|||||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||||
import {
|
import {
|
||||||
AcceptIntakeDto,
|
AcceptIntakeDto,
|
||||||
AdjustPriceDto,
|
|
||||||
ApproveStepDto,
|
ApproveStepDto,
|
||||||
CancelBookingDto,
|
CancelBookingDto,
|
||||||
RejectBookingDto,
|
RejectBookingDto,
|
||||||
@@ -52,7 +51,6 @@ import {
|
|||||||
ReviewDocumentDto,
|
ReviewDocumentDto,
|
||||||
RequestOperationDto,
|
RequestOperationDto,
|
||||||
OperationReviewDto,
|
OperationReviewDto,
|
||||||
ConfirmOperationPriceDto,
|
|
||||||
StaffRejectDto,
|
StaffRejectDto,
|
||||||
} from './dto/request-changes.dto';
|
} from './dto/request-changes.dto';
|
||||||
import { ContractViewDto } from './dto/contract-view.dto';
|
import { ContractViewDto } from './dto/contract-view.dto';
|
||||||
@@ -404,24 +402,7 @@ export class BookingsController {
|
|||||||
id,
|
id,
|
||||||
dto.decision,
|
dto.decision,
|
||||||
resolveAuthUserId(user),
|
resolveAuthUserId(user),
|
||||||
{ note: dto.note, amount: dto.amount },
|
{ note: dto.note },
|
||||||
);
|
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(':id/operation/confirm-price')
|
|
||||||
@ApiOperation({
|
|
||||||
summary:
|
|
||||||
'Customer confirms or rejects an operations price adjustment ' +
|
|
||||||
'(OPERATION_PRICE_PENDING_CONFIRM → batch pool | OPERATION_CHANGES_REQUESTED)',
|
|
||||||
})
|
|
||||||
async confirmOperationPrice(
|
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
|
||||||
@Body() dto: ConfirmOperationPriceDto,
|
|
||||||
) {
|
|
||||||
const booking = await this.transitionService.confirmOperationPrice(
|
|
||||||
id,
|
|
||||||
dto.accept,
|
|
||||||
);
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
@@ -521,25 +502,6 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/adjust-price')
|
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Staff adjust booking total price (override; null clears it)',
|
|
||||||
})
|
|
||||||
async adjustPrice(
|
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
|
||||||
@Body() dto: AdjustPriceDto,
|
|
||||||
@CurrentUser() user: AuthUserPayload,
|
|
||||||
) {
|
|
||||||
const booking = await this.transitionService.adjustPrice(
|
|
||||||
id,
|
|
||||||
dto.amount ?? null,
|
|
||||||
resolveAuthUserId(user),
|
|
||||||
dto.reason,
|
|
||||||
);
|
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(':id/government-expedite')
|
@Post(':id/government-expedite')
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||||
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })
|
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import { MinioModule } from '../minio/minio.module';
|
|||||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||||
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
||||||
import { SignaturesModule } from '../signatures/signatures.module';
|
import { SignaturesModule } from '../signatures/signatures.module';
|
||||||
|
import { BillingModule } from '../billing/billing.module';
|
||||||
|
import { FirstMileModule } from '../first-mile/first-mile.module';
|
||||||
import { BookingContractService } from './booking-contract.service';
|
import { BookingContractService } from './booking-contract.service';
|
||||||
|
import { BookingInvoiceService } from './booking-invoice.service';
|
||||||
|
import { BookingPaymentController } from './booking-payment.controller';
|
||||||
import { BookingPaymentService } from './booking-payment.service';
|
import { BookingPaymentService } from './booking-payment.service';
|
||||||
import { BookingPricingService } from './booking-pricing.service';
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||||
@@ -28,12 +32,12 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
|||||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||||
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
|
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
|
||||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||||
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
|
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
|
||||||
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||||||
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||||
import { PaymentModule } from '../payment/payment.module';
|
|
||||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -47,8 +51,10 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
|||||||
BookingRateSnapshot,
|
BookingRateSnapshot,
|
||||||
BookingReviewNote,
|
BookingReviewNote,
|
||||||
BookingContractSignature,
|
BookingContractSignature,
|
||||||
|
BookingContainerAllocation,
|
||||||
]),
|
]),
|
||||||
PaymentModule,
|
BillingModule,
|
||||||
|
forwardRef(() => FirstMileModule),
|
||||||
forwardRef(() => TrainSchedulingModule),
|
forwardRef(() => TrainSchedulingModule),
|
||||||
FilesModule,
|
FilesModule,
|
||||||
MinioModule,
|
MinioModule,
|
||||||
@@ -63,7 +69,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
|||||||
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [BookingsController, PayController],
|
controllers: [BookingsController, PayController, BookingPaymentController],
|
||||||
providers: [
|
providers: [
|
||||||
BookingsService,
|
BookingsService,
|
||||||
BookingsRepository,
|
BookingsRepository,
|
||||||
@@ -72,6 +78,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
|||||||
BookingPricingService,
|
BookingPricingService,
|
||||||
BookingTransitionService,
|
BookingTransitionService,
|
||||||
BookingContractService,
|
BookingContractService,
|
||||||
|
BookingInvoiceService,
|
||||||
BookingPaymentService,
|
BookingPaymentService,
|
||||||
ContractTemplateResolver,
|
ContractTemplateResolver,
|
||||||
ContractViewModelBuilder,
|
ContractViewModelBuilder,
|
||||||
@@ -79,6 +86,6 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
|||||||
ContractRendererService,
|
ContractRendererService,
|
||||||
ContractPdfService,
|
ContractPdfService,
|
||||||
],
|
],
|
||||||
exports: [BookingsService, BookingsRepository, BookingPricingService],
|
exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService],
|
||||||
})
|
})
|
||||||
export class BookingsModule {}
|
export class BookingsModule {}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
|
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
|
import { ContractRoute } from '../contracts/entities/contract-route.entity';
|
||||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||||
import {
|
import {
|
||||||
@@ -34,7 +35,6 @@ export interface BookingListFilterOptions {
|
|||||||
serviceTypeId?: string;
|
serviceTypeId?: string;
|
||||||
cargoTypeId?: string;
|
cargoTypeId?: string;
|
||||||
freightType?: string;
|
freightType?: string;
|
||||||
bookingType?: string;
|
|
||||||
tradeDirection?: string;
|
tradeDirection?: string;
|
||||||
paymentCurrency?: string;
|
paymentCurrency?: string;
|
||||||
paymentStatus?: string;
|
paymentStatus?: string;
|
||||||
@@ -164,6 +164,19 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
return Number(result?.total ?? 0);
|
return Number(result?.total ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The road billing distance (km) of a booking's contract route, used to price
|
||||||
|
* per-km first/last-mile trucking. Returns 0 when there is no route or no km
|
||||||
|
* recorded (rail-only lanes) so a PER_KM rate bills nothing.
|
||||||
|
*/
|
||||||
|
async getContractRouteKm(contractRouteId: string | null | undefined): Promise<number> {
|
||||||
|
if (!contractRouteId) return 0;
|
||||||
|
const route = await this.dataSource
|
||||||
|
.getRepository(ContractRoute)
|
||||||
|
.findOne({ where: { id: contractRouteId }, select: { id: true, km: true } });
|
||||||
|
return Number(route?.km ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
||||||
* (same route, same container type, partial wagon on both sides).
|
* (same route, same container type, partial wagon on both sides).
|
||||||
@@ -693,11 +706,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
freightType: options.freightType,
|
freightType: options.freightType,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.bookingType) {
|
|
||||||
qb.andWhere('booking.booking_type = :bookingType', {
|
|
||||||
bookingType: options.bookingType,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (options.createdFrom) {
|
if (options.createdFrom) {
|
||||||
qb.andWhere('booking.created_at >= :createdFrom', {
|
qb.andWhere('booking.created_at >= :createdFrom', {
|
||||||
createdFrom: options.createdFrom,
|
createdFrom: options.createdFrom,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ConflictException,
|
ConflictException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
|
GoneException,
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -11,7 +12,7 @@ import { Freight, SchedulingStatus } from '@edr/types';
|
|||||||
// import { CustomersService } from '../customers/customers.service';
|
// import { CustomersService } from '../customers/customers.service';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
import { ProfileType } from '../companies/entities/company-profile.entity';
|
||||||
import { CompanyStatus } from '../companies/entities/company.entity';
|
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
||||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
@@ -27,7 +28,6 @@ import { DataSource, In } from 'typeorm';
|
|||||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||||
import { ContractRouteLine } from '../booking-orders/entities/contract-route-line.entity';
|
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { ConsolidationService } from './consolidation.service';
|
import { ConsolidationService } from './consolidation.service';
|
||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
FreightType,
|
FreightType,
|
||||||
} from './entities/booking.entity';
|
} from './entities/booking.entity';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
|
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
|
||||||
import { FileRecord } from '../files/entities/file.entity';
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
|
|
||||||
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
|
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
|
||||||
@@ -283,6 +284,15 @@ export class BookingsService {
|
|||||||
): Promise<{ booking: Booking; warnings: string[] }> {
|
): Promise<{ booking: Booking; warnings: string[] }> {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// Contract–booking separation: contracts are no longer created through the
|
||||||
|
// booking endpoint. Legacy GENERAL_CONTRACT creation is deprecated — clients
|
||||||
|
// must use POST /contracts (and create shipments via POST /contracts/:id/bookings).
|
||||||
|
if (dto.bookingType === 'GENERAL_CONTRACT') {
|
||||||
|
throw new GoneException(
|
||||||
|
'General contracts are no longer created here. Use POST /contracts instead.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// let customerId = dto.customerId;
|
// let customerId = dto.customerId;
|
||||||
// if (!customerId) {
|
// if (!customerId) {
|
||||||
// if (!userId) {
|
// if (!userId) {
|
||||||
@@ -295,14 +305,26 @@ export class BookingsService {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
const isGovernment = dto.isGovernment === true;
|
const isGovernment = dto.isGovernment === true;
|
||||||
const isGeneralContract = dto.bookingType === 'GENERAL_CONTRACT';
|
|
||||||
|
|
||||||
let companyId: string | null | undefined = dto.companyId;
|
let companyId: string | null | undefined = dto.companyId;
|
||||||
if (isGovernment) {
|
if (isGovernment) {
|
||||||
if (!dto.governmentInstitution?.trim()) {
|
// Government bookings bill to a real seeded government company + an
|
||||||
throw new BadRequestException('governmentInstitution is required for government bookings');
|
// explicitly-chosen importer/exporter profile (no more null company +
|
||||||
|
// free-text institution).
|
||||||
|
if (!dto.companyId) {
|
||||||
|
throw new BadRequestException('A government company is required for government bookings');
|
||||||
}
|
}
|
||||||
companyId = dto.companyId ?? null;
|
const govCompany = await this.companiesService.findCompanyById(dto.companyId);
|
||||||
|
if (govCompany.kind !== CompanyKind.Government) {
|
||||||
|
throw new BadRequestException('Selected company is not a government entity');
|
||||||
|
}
|
||||||
|
if (govCompany.status !== CompanyStatus.Active) {
|
||||||
|
throw new BadRequestException('Selected government company is not active');
|
||||||
|
}
|
||||||
|
if (!dto.companyProfileId) {
|
||||||
|
throw new BadRequestException('A government company profile is required for government bookings');
|
||||||
|
}
|
||||||
|
companyId = govCompany.id;
|
||||||
} else if (!companyId) {
|
} else if (!companyId) {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -375,7 +397,16 @@ export class BookingsService {
|
|||||||
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
|
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
|
||||||
// for non-government bookings with a resolved company; never blocks creation.
|
// for non-government bookings with a resolved company; never blocks creation.
|
||||||
let companyProfileId: string | null = null;
|
let companyProfileId: string | null = null;
|
||||||
if (!isGovernment && companyId) {
|
if (dto.companyProfileId && companyId) {
|
||||||
|
// Explicit profile pin (government booking, or staff booking on behalf):
|
||||||
|
// must belong to the chosen company and be active.
|
||||||
|
const profile =
|
||||||
|
await this.companiesService.getActiveCompanyProfileForBooking(
|
||||||
|
companyId,
|
||||||
|
dto.companyProfileId,
|
||||||
|
);
|
||||||
|
companyProfileId = profile.id;
|
||||||
|
} else if (companyId) {
|
||||||
let fallbackType: ProfileType | null = null;
|
let fallbackType: ProfileType | null = null;
|
||||||
if (userId) {
|
if (userId) {
|
||||||
try {
|
try {
|
||||||
@@ -405,6 +436,16 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every booking must link to a company and a company profile.
|
||||||
|
if (!companyId) {
|
||||||
|
throw new BadRequestException('A company is required to create a booking');
|
||||||
|
}
|
||||||
|
if (!companyProfileId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A company profile is required to create a booking — none could be resolved for this company',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const needsConsolidation =
|
const needsConsolidation =
|
||||||
dto.freightType === 'CONTAINER'
|
dto.freightType === 'CONTAINER'
|
||||||
? await this.needsConsolidation(containers)
|
? await this.needsConsolidation(containers)
|
||||||
@@ -435,14 +476,13 @@ export class BookingsService {
|
|||||||
|
|
||||||
const booking = await this.bookingsRepository.create({
|
const booking = await this.bookingsRepository.create({
|
||||||
reference,
|
reference,
|
||||||
companyId: companyId ?? null,
|
companyId,
|
||||||
companyProfileId,
|
companyProfileId,
|
||||||
isGovernment,
|
isGovernment,
|
||||||
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
|
governmentInstitution: dto.governmentInstitution?.trim() || null,
|
||||||
trainId: dto.trainId,
|
trainId: dto.trainId,
|
||||||
trainScheduleId: dto.trainScheduleId ?? null,
|
trainScheduleId: dto.trainScheduleId ?? null,
|
||||||
contractType: dto.contractType,
|
contractType: dto.contractType,
|
||||||
previousContractId: dto.previousContractId,
|
|
||||||
serviceTypeId: dto.serviceTypeId,
|
serviceTypeId: dto.serviceTypeId,
|
||||||
firstMilePickupAddress: dto.firstMilePickupAddress,
|
firstMilePickupAddress: dto.firstMilePickupAddress,
|
||||||
firstMilePickupLat: dto.firstMilePickupLat ?? null,
|
firstMilePickupLat: dto.firstMilePickupLat ?? null,
|
||||||
@@ -469,7 +509,6 @@ export class BookingsService {
|
|||||||
paymentCurrency: dto.paymentCurrency,
|
paymentCurrency: dto.paymentCurrency,
|
||||||
pnrCode: dto.pnrCode,
|
pnrCode: dto.pnrCode,
|
||||||
financialTerms: dto.financialTerms,
|
financialTerms: dto.financialTerms,
|
||||||
bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME',
|
|
||||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||||
estimatedShipmentDate: dto.estimatedShipmentDate
|
estimatedShipmentDate: dto.estimatedShipmentDate
|
||||||
? new Date(dto.estimatedShipmentDate)
|
? new Date(dto.estimatedShipmentDate)
|
||||||
@@ -496,27 +535,6 @@ export class BookingsService {
|
|||||||
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multi-route general contracts: persist the contracted routes (lanes). Routes
|
|
||||||
// carry NO quantity — the contract has a single shared pool (the cargo-step
|
|
||||||
// total / container quantities). Each drawdown order picks one lane for
|
|
||||||
// scheduling + road billing and draws from that shared pool. `quantity` on the
|
|
||||||
// route line is retained for legacy rows but is no longer meaningful (0).
|
|
||||||
if (isGeneralContract && dto.routes?.length) {
|
|
||||||
const routeRepo = this.dataSource.getRepository(ContractRouteLine);
|
|
||||||
await routeRepo.save(
|
|
||||||
dto.routes.map((r) =>
|
|
||||||
routeRepo.create({
|
|
||||||
contractBookingId: booking.id,
|
|
||||||
originYardId: r.originYardId,
|
|
||||||
destinationYardId: r.destinationYardId,
|
|
||||||
containerTypeId: null,
|
|
||||||
quantity: 0,
|
|
||||||
km: r.km ?? null,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
try {
|
try {
|
||||||
await this.filesService.uploadMany(booking.id, 'bookings', files);
|
await this.filesService.uploadMany(booking.id, 'bookings', files);
|
||||||
@@ -808,7 +826,6 @@ export class BookingsService {
|
|||||||
serviceTypeId: filter.serviceTypeId,
|
serviceTypeId: filter.serviceTypeId,
|
||||||
cargoTypeId: filter.cargoTypeId,
|
cargoTypeId: filter.cargoTypeId,
|
||||||
freightType: filter.freightType,
|
freightType: filter.freightType,
|
||||||
bookingType: filter.bookingType,
|
|
||||||
tradeDirection: filter.tradeDirection,
|
tradeDirection: filter.tradeDirection,
|
||||||
paymentCurrency: filter.paymentCurrency,
|
paymentCurrency: filter.paymentCurrency,
|
||||||
paymentStatus: filter.paymentStatus,
|
paymentStatus: filter.paymentStatus,
|
||||||
@@ -1017,7 +1034,6 @@ export class BookingsService {
|
|||||||
serviceTypeId: filter.serviceTypeId,
|
serviceTypeId: filter.serviceTypeId,
|
||||||
cargoTypeId: filter.cargoTypeId,
|
cargoTypeId: filter.cargoTypeId,
|
||||||
freightType: filter.freightType,
|
freightType: filter.freightType,
|
||||||
bookingType: filter.bookingType,
|
|
||||||
tradeDirection: filter.tradeDirection,
|
tradeDirection: filter.tradeDirection,
|
||||||
paymentCurrency: filter.paymentCurrency,
|
paymentCurrency: filter.paymentCurrency,
|
||||||
paymentStatus: filter.paymentStatus,
|
paymentStatus: filter.paymentStatus,
|
||||||
@@ -1322,4 +1338,35 @@ export class BookingsService {
|
|||||||
createdAt: b.createdAt,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,14 @@ export function clearanceCodesForBooking(booking: Booking): {
|
|||||||
outputCode: string | null;
|
outputCode: string | null;
|
||||||
includesCustoms: boolean;
|
includesCustoms: boolean;
|
||||||
} {
|
} {
|
||||||
const includesCustoms = booking.serviceType?.includesCustoms ?? false;
|
// Customs applies when EITHER the service type bundles it OR the booking was
|
||||||
|
// created with customsClearingEnabled (copied from the contract). Contract
|
||||||
|
// bookings carry customsClearingEnabled even when the serviceType relation
|
||||||
|
// isn't loaded / has includesCustoms=false — without this the per-booking
|
||||||
|
// clearance grid would resolve empty.
|
||||||
|
const includesCustoms =
|
||||||
|
Boolean(booking.serviceType?.includesCustoms) ||
|
||||||
|
Boolean(booking.customsClearingEnabled);
|
||||||
return {
|
return {
|
||||||
inputCode: clearanceSettingCode(
|
inputCode: clearanceSettingCode(
|
||||||
booking.tradeDirection,
|
booking.tradeDirection,
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export class ContainerAllocationDto {
|
||||||
|
containerId!: string;
|
||||||
|
vehicleId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AllocateContainersDto {
|
||||||
|
allocations!: ContainerAllocationDto[];
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
Max,
|
Max,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
|
||||||
Validate,
|
Validate,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
@@ -104,19 +103,31 @@ export class CreateBookingDto {
|
|||||||
@Transform(({ value }) => value === 'true' || value === true)
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
isGovernment?: boolean;
|
isGovernment?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Required when isGovernment is true' })
|
/** @deprecated Government bookings now bill to a real government company. */
|
||||||
@ValidateIf((o) => o.isGovernment === true)
|
@ApiPropertyOptional({ description: 'Deprecated: free-text institution (superseded by companyId)' })
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(2)
|
|
||||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
governmentInstitution?: string;
|
governmentInstitution?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
|
@ApiPropertyOptional({
|
||||||
@ValidateIf((o) => o.isGovernment !== true)
|
format: 'uuid',
|
||||||
|
description:
|
||||||
|
'Target company. Required for staff/government bookings; resolved from the auth token for customer self-bookings.',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'uuid',
|
||||||
|
description:
|
||||||
|
'Explicit company profile (importer/exporter). Required for government bookings; commercial bookings auto-resolve from trade direction.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
companyProfileId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
|
||||||
IsDateString,
|
IsDateString,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNumber,
|
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Max,
|
Max,
|
||||||
@@ -70,22 +68,6 @@ export class RejectBookingDto {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AdjustPriceDto {
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description:
|
|
||||||
'New total price. Omit or send null to clear a previous adjustment.',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
amount?: number | null;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Reason for the adjustment' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ReviewDocumentDto {
|
export class ReviewDocumentDto {
|
||||||
@ApiProperty({ description: 'The document fileKey being reviewed' })
|
@ApiProperty({ description: 'The document fileKey being reviewed' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -117,12 +99,12 @@ export class OperationReviewDto {
|
|||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description:
|
description:
|
||||||
'The operations decision: ACCEPT enters the batch pool; REQUEST_CHANGES ' +
|
'The operations decision: ACCEPT enters the batch pool; REQUEST_CHANGES ' +
|
||||||
'returns it to the customer with a note; ADJUST_PRICE sets a new total the ' +
|
'returns it to the customer with a note. The booking price is computed ' +
|
||||||
'customer must re-confirm before it proceeds.',
|
'from the contract and cannot be adjusted by staff.',
|
||||||
enum: ['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE'],
|
enum: ['ACCEPT', 'REQUEST_CHANGES'],
|
||||||
})
|
})
|
||||||
@IsIn(['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE'])
|
@IsIn(['ACCEPT', 'REQUEST_CHANGES'])
|
||||||
decision!: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE';
|
decision!: 'ACCEPT' | 'REQUEST_CHANGES';
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Required for REQUEST_CHANGES (what the customer must fix).',
|
description: 'Required for REQUEST_CHANGES (what the customer must fix).',
|
||||||
@@ -130,22 +112,4 @@ export class OperationReviewDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
note?: string;
|
note?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'New total price — required for ADJUST_PRICE.',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
amount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ConfirmOperationPriceDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description:
|
|
||||||
'true to accept the operations price adjustment and proceed to the ' +
|
|
||||||
'batch pool; false to reject it (returns to operation changes requested).',
|
|
||||||
})
|
|
||||||
@IsBoolean()
|
|
||||||
accept!: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
import { BookingContainer } from './booking-container.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One physical container under a booking_container line — its number, seal, and
|
||||||
|
* per-unit VGM. Entered at booking time (by the customer in Path A or by GL ET
|
||||||
|
* in Path B). See §5.10.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'booking_container_units' })
|
||||||
|
@Index(['bookingContainerId'])
|
||||||
|
export class BookingContainerUnit extends BaseEntity {
|
||||||
|
@Column({ name: 'booking_container_id', type: 'uuid' })
|
||||||
|
bookingContainerId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => BookingContainer, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'booking_container_id' })
|
||||||
|
bookingContainer?: BookingContainer;
|
||||||
|
|
||||||
|
@Column({ name: 'container_number', type: 'varchar', length: 64 })
|
||||||
|
containerNumber!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'seal_number', type: 'varchar', length: 64, nullable: true })
|
||||||
|
sealNumber?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'vgm_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||||
|
vgmTons!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
||||||
|
isHazardous!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_reefer', type: 'boolean', default: false })
|
||||||
|
isReefer!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
|
||||||
|
sortOrder!: number;
|
||||||
|
}
|
||||||
@@ -25,9 +25,21 @@ export class BookingContainer extends BaseEntity {
|
|||||||
@Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true })
|
@Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true })
|
||||||
containerNumber?: string | null;
|
containerNumber?: string | null;
|
||||||
|
|
||||||
|
/** Contract container size this line covers (20ft | 40ft). Null for legacy rows. */
|
||||||
|
@Column({ name: 'container_size', type: 'varchar', length: 10, nullable: true })
|
||||||
|
containerSize?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'quantity', type: 'smallint' })
|
@Column({ name: 'quantity', type: 'smallint' })
|
||||||
quantity!: number;
|
quantity!: number;
|
||||||
|
|
||||||
|
/** How many units of this line are hazardous (≤ quantity). */
|
||||||
|
@Column({ name: 'hazardous_quantity', type: 'smallint', default: 0 })
|
||||||
|
hazardousQuantity!: number;
|
||||||
|
|
||||||
|
/** How many units of this line are refrigerated (≤ quantity). */
|
||||||
|
@Column({ name: 'reefer_quantity', type: 'smallint', default: 0 })
|
||||||
|
reeferQuantity!: number;
|
||||||
|
|
||||||
@Column({ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 })
|
@Column({ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||||
vgmPerUnitTons!: number;
|
vgmPerUnitTons!: number;
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { FileRecord } from '../../files/entities/file.entity';
|
|||||||
import { BookingApprovalStep } from './booking-approval-step.entity';
|
import { BookingApprovalStep } from './booking-approval-step.entity';
|
||||||
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
|
||||||
import { BookingContainer } from './booking-container.entity';
|
import { BookingContainer } from './booking-container.entity';
|
||||||
|
import { BookingContainerAllocation } from './booking-container-allocation.entity';
|
||||||
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
|
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
|
||||||
import { BookingReviewNote } from './booking-review-note.entity';
|
import { BookingReviewNote } from './booking-review-note.entity';
|
||||||
|
|
||||||
@@ -105,8 +106,10 @@ export class Booking extends BaseEntity {
|
|||||||
// @JoinColumn({ name: 'customer_id' })
|
// @JoinColumn({ name: 'customer_id' })
|
||||||
// customer?: Customer;
|
// customer?: Customer;
|
||||||
|
|
||||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
// Every booking is billed to a company — government bookings bill to a seeded
|
||||||
companyId?: string | null;
|
// government company (companies.kind = 'government'). Enforced NOT NULL.
|
||||||
|
@Column({ name: 'company_id', type: 'uuid' })
|
||||||
|
companyId!: string;
|
||||||
|
|
||||||
@ManyToOne(() => Company, { nullable: true })
|
@ManyToOne(() => Company, { nullable: true })
|
||||||
@JoinColumn({ name: 'company_id' })
|
@JoinColumn({ name: 'company_id' })
|
||||||
@@ -116,11 +119,12 @@ export class Booking extends BaseEntity {
|
|||||||
* The operational profile (importer/exporter/forwarder) this booking belongs
|
* The operational profile (importer/exporter/forwarder) this booking belongs
|
||||||
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
|
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
|
||||||
* EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder.
|
* EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder.
|
||||||
* Customer portal lists and dashboard KPIs are scoped by this. Nullable for
|
* Customer portal lists and dashboard KPIs are scoped by this. Required:
|
||||||
* legacy/government/staff-created bookings.
|
* commercial bookings resolve it from trade direction / active mode;
|
||||||
|
* government bookings carry the explicitly-picked government profile.
|
||||||
*/
|
*/
|
||||||
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
|
@Column({ name: 'company_profile_id', type: 'uuid' })
|
||||||
companyProfileId?: string | null;
|
companyProfileId!: string;
|
||||||
|
|
||||||
@ManyToOne(() => CompanyProfile, { nullable: true })
|
@ManyToOne(() => CompanyProfile, { nullable: true })
|
||||||
@JoinColumn({ name: 'company_profile_id' })
|
@JoinColumn({ name: 'company_profile_id' })
|
||||||
@@ -144,13 +148,24 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
|
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
|
||||||
status!: string;
|
status!: string;
|
||||||
|
|
||||||
/**
|
/** The contract this shipment booking was created under (contract–booking split). */
|
||||||
* ONE_TIME for a normal single-shipment booking; GENERAL_CONTRACT for an
|
@Column({ name: 'contract_id', type: 'uuid', nullable: true })
|
||||||
* umbrella contract that is signed/paid once and then drawn down by many
|
contractId?: string | null;
|
||||||
* orders (each order spawns its own ONE_TIME child booking).
|
|
||||||
*/
|
/** The contract route (lane) this shipment uses. */
|
||||||
@Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' })
|
@Column({ name: 'contract_route_id', type: 'uuid', nullable: true })
|
||||||
bookingType!: string;
|
contractRouteId?: string | null;
|
||||||
|
|
||||||
|
/** Denormalized contract kind (ONE_TIME | GENERAL) for the single-active-booking index. */
|
||||||
|
@Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true })
|
||||||
|
contractKind?: string | null;
|
||||||
|
|
||||||
|
/** Who created this booking: CUSTOMER (Path A), GL_ET (Path B), or STAFF. */
|
||||||
|
@Column({ name: 'created_by_role', type: 'varchar', length: 20, default: 'CUSTOMER', nullable: true })
|
||||||
|
createdByRole?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by_user_id', type: 'uuid', nullable: true })
|
||||||
|
createdByUserId?: string | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nullable: general contracts have no shipment date at creation — the date is
|
* Nullable: general contracts have no shipment date at creation — the date is
|
||||||
@@ -220,13 +235,6 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'contract_type', type: 'varchar', length: 20 })
|
@Column({ name: 'contract_type', type: 'varchar', length: 20 })
|
||||||
contractType!: string;
|
contractType!: string;
|
||||||
|
|
||||||
@Column({ name: 'previous_contract_id', type: 'uuid', nullable: true })
|
|
||||||
previousContractId?: string | null;
|
|
||||||
|
|
||||||
@ManyToOne(() => Booking, { nullable: true })
|
|
||||||
@JoinColumn({ name: 'previous_contract_id' })
|
|
||||||
previousContract?: Booking | null;
|
|
||||||
|
|
||||||
@Column({ name: 'service_type_id', type: 'uuid' })
|
@Column({ name: 'service_type_id', type: 'uuid' })
|
||||||
serviceTypeId!: string;
|
serviceTypeId!: string;
|
||||||
|
|
||||||
@@ -422,9 +430,24 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'selected_for_batch_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'selected_for_batch_at', type: 'timestamptz', nullable: true })
|
||||||
selectedForBatchAt?: Date | null;
|
selectedForBatchAt?: Date | null;
|
||||||
|
|
||||||
|
// ── Global Logistics station routing (GL Import/Export US-02) ──────────────
|
||||||
|
/** Origin-station yard the shipment is routed to for GL handling. */
|
||||||
|
@Column({ name: 'gl_station_yard_id', type: 'uuid', nullable: true })
|
||||||
|
glStationYardId?: string | null;
|
||||||
|
|
||||||
|
/** GL staff user bound to this shipment by the station manager. */
|
||||||
|
@Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true })
|
||||||
|
glAssignedStaffId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'gl_assigned_at', type: 'timestamptz', nullable: true })
|
||||||
|
glAssignedAt?: Date | null;
|
||||||
|
|
||||||
@OneToMany(() => BookingContainer, (bc) => bc.booking)
|
@OneToMany(() => BookingContainer, (bc) => bc.booking)
|
||||||
bookingContainers?: BookingContainer[];
|
bookingContainers?: BookingContainer[];
|
||||||
|
|
||||||
|
@OneToMany(() => BookingContainerAllocation, (ca) => ca.booking)
|
||||||
|
containerAllocations?: BookingContainerAllocation[];
|
||||||
|
|
||||||
@OneToMany(() => BookingCargoModifier, (m) => m.booking)
|
@OneToMany(() => BookingCargoModifier, (m) => m.booking)
|
||||||
cargoModifiers?: BookingCargoModifier[];
|
cargoModifiers?: BookingCargoModifier[];
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
async findPaginated(
|
async findPaginated(
|
||||||
query: ListCompaniesQueryDto,
|
query: ListCompaniesQueryDto,
|
||||||
): Promise<{ items: Company[]; total: number }> {
|
): Promise<{ items: Company[]; total: number }> {
|
||||||
const { page = 1, pageSize = 20, search, type, status } = query;
|
const { page = 1, pageSize = 20, search, type, kind, status } = query;
|
||||||
|
|
||||||
const qb = this.repository
|
const qb = this.repository
|
||||||
.createQueryBuilder('company')
|
.createQueryBuilder('company')
|
||||||
@@ -49,6 +49,10 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
qb.andWhere('company.type = :type', { type });
|
qb.andWhere('company.type = :type', { type });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (kind) {
|
||||||
|
qb.andWhere('company.kind = :kind', { kind });
|
||||||
|
}
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
qb.andWhere('company.status = :status', { status });
|
qb.andWhere('company.status = :status', { status });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -337,6 +337,29 @@ export class CompaniesService {
|
|||||||
return company;
|
return company;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an explicitly-chosen company profile for a booking: it must belong
|
||||||
|
* to the booking's company and be Active. Used for government bookings (staff
|
||||||
|
* pick the profile) and any staff booking that pins a profile directly.
|
||||||
|
*/
|
||||||
|
async getActiveCompanyProfileForBooking(
|
||||||
|
companyId: string,
|
||||||
|
profileId: string,
|
||||||
|
): Promise<CompanyProfile> {
|
||||||
|
const profile = await this.companyProfilesRepo.findById(profileId);
|
||||||
|
if (!profile || profile.companyId !== companyId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Selected company profile does not belong to the chosen company",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (profile.status !== ProfileStatus.Active) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Selected company profile is not active",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
async getCompanyInfoByUserId(
|
async getCompanyInfoByUserId(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
|
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
|
||||||
import { Transform } from "class-transformer";
|
import { Transform } from "class-transformer";
|
||||||
import { CompanyStatus, CompanyType } from "../entities/company.entity";
|
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity";
|
||||||
|
|
||||||
export class ListCompaniesQueryDto {
|
export class ListCompaniesQueryDto {
|
||||||
@ApiPropertyOptional({ default: 1 })
|
@ApiPropertyOptional({ default: 1 })
|
||||||
@@ -28,6 +28,11 @@ export class ListCompaniesQueryDto {
|
|||||||
@IsIn(Object.values(CompanyType))
|
@IsIn(Object.values(CompanyType))
|
||||||
type?: CompanyType;
|
type?: CompanyType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: CompanyKind })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(Object.values(CompanyKind))
|
||||||
|
kind?: CompanyKind;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: CompanyStatus })
|
@ApiPropertyOptional({ enum: CompanyStatus })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(Object.values(CompanyStatus))
|
@IsIn(Object.values(CompanyStatus))
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ export enum CompanyType {
|
|||||||
Transporter = "transporter",
|
Transporter = "transporter",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sector of the company — orthogonal to {@link CompanyType} (the trade role).
|
||||||
|
* Government bookings are billed to a single seeded `GOVERNMENT` company instead
|
||||||
|
* of carrying a null company + free-text institution.
|
||||||
|
*/
|
||||||
|
export enum CompanyKind {
|
||||||
|
Commercial = "commercial",
|
||||||
|
Government = "government",
|
||||||
|
}
|
||||||
|
|
||||||
export enum CompanyStatus {
|
export enum CompanyStatus {
|
||||||
Active = "active",
|
Active = "active",
|
||||||
Pending = "pending",
|
Pending = "pending",
|
||||||
@@ -25,6 +35,7 @@ export enum CompanyNationality {
|
|||||||
@Entity({ schema: "freight", name: "companies" })
|
@Entity({ schema: "freight", name: "companies" })
|
||||||
@Index(["tin"])
|
@Index(["tin"])
|
||||||
@Index(["type"])
|
@Index(["type"])
|
||||||
|
@Index(["kind"])
|
||||||
export class Company extends BaseEntity {
|
export class Company extends BaseEntity {
|
||||||
@Column({ name: "name", type: "varchar", length: 200 })
|
@Column({ name: "name", type: "varchar", length: 200 })
|
||||||
name!: string;
|
name!: string;
|
||||||
@@ -32,6 +43,16 @@ export class Company extends BaseEntity {
|
|||||||
@Column({ name: "type", type: "varchar", length: 32, enum: CompanyType })
|
@Column({ name: "type", type: "varchar", length: 32, enum: CompanyType })
|
||||||
type!: CompanyType;
|
type!: CompanyType;
|
||||||
|
|
||||||
|
/** Commercial customer vs. the seeded government entity. */
|
||||||
|
@Column({
|
||||||
|
name: "kind",
|
||||||
|
type: "varchar",
|
||||||
|
length: 20,
|
||||||
|
default: CompanyKind.Commercial,
|
||||||
|
enum: CompanyKind,
|
||||||
|
})
|
||||||
|
kind!: CompanyKind;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
name: "status",
|
name: "status",
|
||||||
type: "varchar",
|
type: "varchar",
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { BookingRequest } from './entities/booking-request.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingRequestRepository extends BaseRepository<BookingRequest> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(BookingRequest)
|
||||||
|
repository: Repository<BookingRequest>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All requests on a contract, newest first. */
|
||||||
|
async findForContract(contractId: string): Promise<BookingRequest[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
where: { contractId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL queue: pending requests across all contracts, oldest first. */
|
||||||
|
async findPending(): Promise<BookingRequest[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
where: { status: 'PENDING' },
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
relations: { contract: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<BookingRequest | null> {
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: { contract: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total rows — used to mint the next sequential reference. */
|
||||||
|
async count(): Promise<number> {
|
||||||
|
return this.repository.count();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { Freight } from '@edr/types';
|
||||||
|
|
||||||
|
import { BookingRequestRepository } from './booking-request.repository';
|
||||||
|
import { ContractsService } from './contracts.service';
|
||||||
|
import { ContractBookingService } from './contract-booking.service';
|
||||||
|
import { BookingRequest } from './entities/booking-request.entity';
|
||||||
|
import { Contract } from './entities/contract.entity';
|
||||||
|
import { CreateBookingRequestDto } from './dto/create-booking-request.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer shipment requests on GENERAL customs (Path B) contracts. The customer
|
||||||
|
* submits a request (date + quantities); GL reviews the queue and, on accept,
|
||||||
|
* creates the booking — after which per-booking clearance begins. ONE_TIME and
|
||||||
|
* Path A do not use this flow.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class BookingRequestService {
|
||||||
|
constructor(
|
||||||
|
private readonly repo: BookingRequestRepository,
|
||||||
|
private readonly contractsService: ContractsService,
|
||||||
|
private readonly contractBookingService: ContractBookingService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */
|
||||||
|
private assertGeneralCustoms(contract: Contract): void {
|
||||||
|
if (
|
||||||
|
contract.contractKind !== 'GENERAL' ||
|
||||||
|
!contract.customsClearingEnabled
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Shipment requests apply only to general customs-clearance contracts.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer submits a shipment request. */
|
||||||
|
async submit(
|
||||||
|
contractId: string,
|
||||||
|
dto: CreateBookingRequestDto,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<BookingRequest> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
await this.contractsService.assertCustomerCanAccessContract(userId, contract);
|
||||||
|
this.assertGeneralCustoms(contract);
|
||||||
|
if (contract.status !== 'CONTRACT_ACTIVE') {
|
||||||
|
throw new ConflictException(
|
||||||
|
'The contract must be active before requesting a shipment.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isContainer = contract.freightType === 'CONTAINER';
|
||||||
|
const hasLines = isContainer
|
||||||
|
? (dto.containers?.length ?? 0) > 0
|
||||||
|
: Boolean(dto.bulk);
|
||||||
|
if (!hasLines) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
isContainer
|
||||||
|
? 'Add at least one container line.'
|
||||||
|
: 'Enter the bulk cargo amount.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate requested container sizes against the contract cargo scope and
|
||||||
|
// remaining draw-down capacity (reuses the booking quantity-cap check).
|
||||||
|
if (isContainer) {
|
||||||
|
const allowed = new Set(
|
||||||
|
(contract.cargoScope ?? [])
|
||||||
|
.map((s) => s.containerSize)
|
||||||
|
.filter((s): s is string => !!s),
|
||||||
|
);
|
||||||
|
for (const line of dto.containers ?? []) {
|
||||||
|
if (allowed.size && !allowed.has(line.containerSize)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Container size ${line.containerSize} is not in this contract's scope.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.contractBookingService.assertRequestWithinCapacity(contract, {
|
||||||
|
containers: dto.containers,
|
||||||
|
bulk: dto.bulk,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestedLines: Freight.RequestedShipmentLines = isContainer
|
||||||
|
? {
|
||||||
|
containers: (dto.containers ?? []).map((l) => ({
|
||||||
|
containerSize: l.containerSize,
|
||||||
|
quantity: l.quantity,
|
||||||
|
hazardousQuantity: l.hazardousQuantity,
|
||||||
|
reeferQuantity: l.reeferQuantity,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
bulk: {
|
||||||
|
cargoTypeId: dto.bulk?.cargoTypeId ?? null,
|
||||||
|
cargoWeightTons: dto.bulk?.cargoWeightTons,
|
||||||
|
itemCount: dto.bulk?.itemCount,
|
||||||
|
hazardousQuantity: dto.bulk?.hazardousQuantity,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const reference = await this.generateReference();
|
||||||
|
return this.repo.create({
|
||||||
|
reference,
|
||||||
|
contractId,
|
||||||
|
requestedByUserId: userId ?? null,
|
||||||
|
contractRouteId: dto.contractRouteId ?? null,
|
||||||
|
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||||
|
status: 'PENDING',
|
||||||
|
requestedLines,
|
||||||
|
notes: dto.notes ?? null,
|
||||||
|
} as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
listForContract(contractId: string): Promise<BookingRequest[]> {
|
||||||
|
return this.repo.findForContract(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(requestId: string): Promise<BookingRequest> {
|
||||||
|
const request = await this.repo.findById(requestId);
|
||||||
|
if (!request) throw new NotFoundException(`Booking request ${requestId} not found`);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
queue(): Promise<BookingRequest[]> {
|
||||||
|
return this.repo.findPending();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findPending(requestId: string): Promise<BookingRequest> {
|
||||||
|
const request = await this.repo.findById(requestId);
|
||||||
|
if (!request) throw new NotFoundException(`Booking request ${requestId} not found`);
|
||||||
|
if (request.status !== 'PENDING') {
|
||||||
|
throw new ConflictException(
|
||||||
|
`This request is already ${request.status.toLowerCase()}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL accepts a request. The booking itself is created via the GL booking form
|
||||||
|
* (POST /contracts/:id/bookings) which carries the per-unit container data the
|
||||||
|
* request omits; this endpoint records the acceptance + links the created
|
||||||
|
* booking. `bookingId` is supplied by the GL form on success.
|
||||||
|
*/
|
||||||
|
async accept(
|
||||||
|
requestId: string,
|
||||||
|
bookingId: string,
|
||||||
|
staffId?: string,
|
||||||
|
): Promise<BookingRequest> {
|
||||||
|
const request = await this.findPending(requestId);
|
||||||
|
await this.repo.update(requestId, {
|
||||||
|
status: 'ACCEPTED',
|
||||||
|
createdBookingId: bookingId,
|
||||||
|
reviewedByStaffId: staffId ?? null,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
} as never);
|
||||||
|
return (await this.repo.findById(requestId)) ?? request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL rejects a request with a note. */
|
||||||
|
async reject(
|
||||||
|
requestId: string,
|
||||||
|
note?: string,
|
||||||
|
staffId?: string,
|
||||||
|
): Promise<BookingRequest> {
|
||||||
|
const request = await this.findPending(requestId);
|
||||||
|
await this.repo.update(requestId, {
|
||||||
|
status: 'REJECTED',
|
||||||
|
reviewNote: note ?? null,
|
||||||
|
reviewedByStaffId: staffId ?? null,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
} as never);
|
||||||
|
return (await this.repo.findById(requestId)) ?? request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer cancels their own pending request. */
|
||||||
|
async cancel(requestId: string, userId?: string): Promise<BookingRequest> {
|
||||||
|
const request = await this.findPending(requestId);
|
||||||
|
if (request.requestedByUserId && request.requestedByUserId !== userId) {
|
||||||
|
throw new ForbiddenException('You can only cancel your own requests.');
|
||||||
|
}
|
||||||
|
await this.repo.update(requestId, { status: 'CANCELLED' } as never);
|
||||||
|
return (await this.repo.findById(requestId)) ?? request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateReference(): Promise<string> {
|
||||||
|
const count = await this.repo.count();
|
||||||
|
const seq = String(count + 1).padStart(6, '0');
|
||||||
|
return `SR-${seq}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { IMPORT_MILESTONES, EXPORT_MILESTONES } from '@edr/types';
|
||||||
|
import type { MilestoneOwnerRegion } from './entities/clearance-milestone.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static catalog of clearance milestones per trade direction (doc §11.3, §12.2).
|
||||||
|
* Drives the rows seeded onto a contract clearance cycle (pre-booking) and
|
||||||
|
* booking (post-booking). `phaseBoundaryAfter` marks the last pre-booking
|
||||||
|
* milestone — everything after it tracks on the booking.
|
||||||
|
*/
|
||||||
|
export interface MilestoneDef {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
ownerRegion: MilestoneOwnerRegion;
|
||||||
|
triggeredByDoc: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
|
||||||
|
IMPORT_DOCS_UPLOADED: { label: 'Import Documents Uploaded', ownerRegion: 'CUST', triggeredByDoc: false },
|
||||||
|
PENDING_DOCUMENT_REVIEW: { label: 'Pending Document Review', ownerRegion: 'ET', triggeredByDoc: true },
|
||||||
|
DOCUMENTS_APPROVED: { label: 'Documents Approved', ownerRegion: 'ET', triggeredByDoc: false },
|
||||||
|
UNDER_CUSTOMS_CLEARANCE: { label: 'Under Customs Clearance', ownerRegion: 'ET', triggeredByDoc: false },
|
||||||
|
DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true },
|
||||||
|
DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false },
|
||||||
|
DUTY_TAX_PAID: { label: 'Duty and Tax Paid', ownerRegion: 'CUST', triggeredByDoc: true },
|
||||||
|
DO_COLLECTED: { label: 'DO Collected', ownerRegion: 'DJ', triggeredByDoc: true },
|
||||||
|
WAGON_REQUESTED: { label: 'Wagon Allocation Requested', ownerRegion: 'ET', triggeredByDoc: false },
|
||||||
|
FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled (freight)', ownerRegion: 'CUST', triggeredByDoc: true },
|
||||||
|
WAGON_ALLOCATED: { label: 'Wagon Allocated', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||||
|
GATEPASS_GRANTED: { label: 'Gatepass Granted', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||||
|
READY_FOR_LOADING: { label: 'Ready for Loading', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||||
|
LOADED: { label: 'Loaded', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||||
|
DEPARTED_FROM_DJIBOUTI: { label: 'Departed from Djibouti', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||||
|
ARRIVED_ETHIOPIA: { label: 'Arrived at Port in Ethiopia', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||||
|
OFFLOADED: { label: 'Offloaded', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||||
|
T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'ET', triggeredByDoc: false },
|
||||||
|
RISK_ASSIGNED: { label: 'Risk Assigned', ownerRegion: 'ET', triggeredByDoc: false },
|
||||||
|
IMPORT_RELEASE_GRANTED: { label: 'Import Release Granted', ownerRegion: 'ET', triggeredByDoc: true },
|
||||||
|
IMPORT_PROCESS_COMPLETED: { label: 'Import Process Completed', ownerRegion: 'ET', triggeredByDoc: true },
|
||||||
|
STORAGE_INVOICE_RAISED: { label: 'Storage Invoice Raised', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||||
|
EXIT_NOTE_GENERATED: { label: 'Exit Note Generated', ownerRegion: 'OPS', triggeredByDoc: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const EXPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
|
||||||
|
EXPORT_DOCS_UPLOADED: { label: 'Export Documents Uploaded', ownerRegion: 'CUST', triggeredByDoc: false },
|
||||||
|
PENDING_DOCUMENT_REVIEW: { label: 'Pending Document Review', ownerRegion: 'ET', triggeredByDoc: true },
|
||||||
|
DOCUMENTS_APPROVED: { label: 'Documents Approved', ownerRegion: 'ET', triggeredByDoc: false },
|
||||||
|
RELEASE_ORDER_SECURED: { label: 'Release Order Secured', ownerRegion: 'DJ', triggeredByDoc: true },
|
||||||
|
UNDER_CUSTOMS_CLEARANCE: { label: 'Under Customs Clearance', ownerRegion: 'ET', triggeredByDoc: false },
|
||||||
|
DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true },
|
||||||
|
EXPORT_RELEASED: { label: 'Export Released', ownerRegion: 'ET', triggeredByDoc: false },
|
||||||
|
WAGON_REQUESTED: { label: 'Wagon Requested', ownerRegion: 'ET', triggeredByDoc: false },
|
||||||
|
FREIGHT_PAYMENT_PENDING: { label: 'Pending Payment', ownerRegion: 'CUST', triggeredByDoc: false },
|
||||||
|
FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled', ownerRegion: 'CUST', triggeredByDoc: true },
|
||||||
|
WAGON_ALLOCATED: { label: 'Wagon Allocated', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||||
|
CARGO_ARRIVED: { label: 'Cargo Arrived', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||||
|
READY_FOR_LOADING: { label: 'Ready for Loading', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||||
|
LOADED: { label: 'Loaded', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||||
|
DEPARTED_TO_DJIBOUTI: { label: 'Departed to Djibouti', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||||
|
ARRIVED_AT_DJIBOUTI: { label: 'Arrived at Djibouti', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||||
|
GATEPASS_GRANTED: { label: 'Gatepass Granted', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||||
|
OFFLOADED: { label: 'Offloaded', ownerRegion: 'DJ', triggeredByDoc: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Codes BEFORE (and including) this one are pre-booking — they attach to the
|
||||||
|
* contract clearance cycle. From the next code onward, milestones attach to the
|
||||||
|
* booking GL creates. Per doc §11.3 the booking is created right after DO_COLLECTED
|
||||||
|
* (import) / EXPORT_RELEASED (export), i.e. before WAGON_REQUESTED.
|
||||||
|
*/
|
||||||
|
const IMPORT_PRE_BOOKING_LAST = 'DO_COLLECTED';
|
||||||
|
const EXPORT_PRE_BOOKING_LAST = 'EXPORT_RELEASED';
|
||||||
|
|
||||||
|
function buildDefs(
|
||||||
|
codes: readonly string[],
|
||||||
|
defs: Record<string, Omit<MilestoneDef, 'code'>>,
|
||||||
|
): MilestoneDef[] {
|
||||||
|
return codes.map((code) => ({ code, ...defs[code] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function milestonesForDirection(tradeDirection: string): MilestoneDef[] {
|
||||||
|
if (tradeDirection === 'IMPORT') return buildDefs(IMPORT_MILESTONES, IMPORT_DEFS);
|
||||||
|
if (tradeDirection === 'EXPORT') return buildDefs(EXPORT_MILESTONES, EXPORT_DEFS);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split the milestone list into pre-booking (contract) and post-booking (booking). */
|
||||||
|
export function splitMilestones(tradeDirection: string): {
|
||||||
|
preBooking: MilestoneDef[];
|
||||||
|
postBooking: MilestoneDef[];
|
||||||
|
} {
|
||||||
|
const all = milestonesForDirection(tradeDirection);
|
||||||
|
const boundary =
|
||||||
|
tradeDirection === 'IMPORT' ? IMPORT_PRE_BOOKING_LAST : EXPORT_PRE_BOOKING_LAST;
|
||||||
|
const idx = all.findIndex((m) => m.code === boundary);
|
||||||
|
if (idx < 0) return { preBooking: all, postBooking: [] };
|
||||||
|
return { preBooking: all.slice(0, idx + 1), postBooking: all.slice(idx + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The handoff milestone that flips primary ownership ET ↔ DJ (doc §11.5/§12.3). */
|
||||||
|
export const HANDOFF_MILESTONES = ['DEPARTED_FROM_DJIBOUTI', 'DEPARTED_TO_DJIBOUTI'];
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ClearanceMilestone,
|
||||||
|
CustomsRiskLevel,
|
||||||
|
MilestoneMetadata,
|
||||||
|
} from './entities/clearance-milestone.entity';
|
||||||
|
import { Contract } from './entities/contract.entity';
|
||||||
|
import {
|
||||||
|
HANDOFF_MILESTONES,
|
||||||
|
MilestoneDef,
|
||||||
|
milestonesForDirection,
|
||||||
|
splitMilestones,
|
||||||
|
} from './clearance-milestone.catalog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds and advances the GL clearance milestones (18–23 per direction). Pre-booking
|
||||||
|
* milestones attach to the contract clearance cycle; post-booking milestones attach
|
||||||
|
* to the booking. See docs/new-doc.md §5.12, §5.16, §11.3, §12.2.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ClearanceMilestoneService {
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
private get repo() {
|
||||||
|
return this.dataSource.getRepository(ClearanceMilestone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed the pre-booking milestones onto a contract's current clearance cycle. */
|
||||||
|
async seedPreBookingMilestones(
|
||||||
|
contract: Contract,
|
||||||
|
clearanceCycleId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const { preBooking } = splitMilestones(contract.tradeDirection);
|
||||||
|
await this.seed(preBooking, {
|
||||||
|
contractId: contract.id,
|
||||||
|
clearanceCycleId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed the post-booking milestones onto a freshly created booking. */
|
||||||
|
async seedPostBookingMilestones(
|
||||||
|
bookingId: string,
|
||||||
|
tradeDirection: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const { postBooking } = splitMilestones(tradeDirection);
|
||||||
|
await this.seed(postBooking, { bookingId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seed(
|
||||||
|
defs: MilestoneDef[],
|
||||||
|
scope: { contractId?: string; clearanceCycleId?: string; bookingId?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
if (!defs.length) return;
|
||||||
|
const rows = defs.map((def, i) =>
|
||||||
|
this.repo.create({
|
||||||
|
...scope,
|
||||||
|
milestoneCode: def.code,
|
||||||
|
milestoneLabel: def.label,
|
||||||
|
ownerRegion: def.ownerRegion,
|
||||||
|
triggeredByDoc: def.triggeredByDoc,
|
||||||
|
status: 'PENDING',
|
||||||
|
sortOrder: i,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await this.repo.save(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List milestones for a contract cycle or a booking. */
|
||||||
|
async listForContract(contractId: string): Promise<ClearanceMilestone[]> {
|
||||||
|
return this.repo.find({
|
||||||
|
where: { contractId },
|
||||||
|
order: { sortOrder: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listForBooking(bookingId: string): Promise<ClearanceMilestone[]> {
|
||||||
|
return this.repo.find({
|
||||||
|
where: { bookingId },
|
||||||
|
order: { sortOrder: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark a milestone complete (by code) on a booking. */
|
||||||
|
async completeForBooking(
|
||||||
|
bookingId: string,
|
||||||
|
code: string,
|
||||||
|
userId?: string,
|
||||||
|
note?: string,
|
||||||
|
): Promise<ClearanceMilestone> {
|
||||||
|
const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
|
||||||
|
if (!milestone) {
|
||||||
|
throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`);
|
||||||
|
}
|
||||||
|
if (milestone.status === 'COMPLETED') {
|
||||||
|
throw new BadRequestException(`Milestone ${code} is already completed.`);
|
||||||
|
}
|
||||||
|
milestone.status = 'COMPLETED';
|
||||||
|
milestone.triggeredAt = new Date();
|
||||||
|
milestone.triggeredByUserId = userId ?? null;
|
||||||
|
if (note) milestone.note = note;
|
||||||
|
const saved = await this.repo.save(milestone);
|
||||||
|
|
||||||
|
if (HANDOFF_MILESTONES.includes(code)) {
|
||||||
|
await this.onHandoff(bookingId, code);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign a customs risk level (GREEN/YELLOW/RED) and complete the RISK_ASSIGNED
|
||||||
|
* milestone on a booking (GL Import US-04 / §11.3 #19). Stores the level in the
|
||||||
|
* milestone metadata so the timeline shows it.
|
||||||
|
*/
|
||||||
|
async assignRisk(
|
||||||
|
bookingId: string,
|
||||||
|
riskLevel: CustomsRiskLevel,
|
||||||
|
userId?: string,
|
||||||
|
note?: string,
|
||||||
|
): Promise<ClearanceMilestone> {
|
||||||
|
return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advise duty & tax (amount + declaration serial) and complete the
|
||||||
|
* DUTY_TAXES_ADVISED milestone (§11.3 #6). The customer then uploads the
|
||||||
|
* payment slip, which doc-triggers DUTY_TAX_PAID.
|
||||||
|
*/
|
||||||
|
async adviseDuty(
|
||||||
|
bookingId: string,
|
||||||
|
input: { amount: number; currency: string; declarationSerial?: string },
|
||||||
|
userId?: string,
|
||||||
|
note?: string,
|
||||||
|
): Promise<ClearanceMilestone> {
|
||||||
|
return this.completeWithMetadata(
|
||||||
|
bookingId,
|
||||||
|
'DUTY_TAXES_ADVISED',
|
||||||
|
{
|
||||||
|
dutyAmount: input.amount,
|
||||||
|
dutyCurrency: input.currency,
|
||||||
|
declarationSerial: input.declarationSerial,
|
||||||
|
},
|
||||||
|
userId,
|
||||||
|
note,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Complete a milestone and merge structured metadata onto it. */
|
||||||
|
private async completeWithMetadata(
|
||||||
|
bookingId: string,
|
||||||
|
code: string,
|
||||||
|
metadata: MilestoneMetadata,
|
||||||
|
userId?: string,
|
||||||
|
note?: string,
|
||||||
|
): Promise<ClearanceMilestone> {
|
||||||
|
const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
|
||||||
|
if (!milestone) {
|
||||||
|
throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`);
|
||||||
|
}
|
||||||
|
milestone.status = 'COMPLETED';
|
||||||
|
milestone.triggeredAt = new Date();
|
||||||
|
milestone.triggeredByUserId = userId ?? null;
|
||||||
|
milestone.metadata = { ...(milestone.metadata ?? {}), ...metadata };
|
||||||
|
if (note) milestone.note = note;
|
||||||
|
return this.repo.save(milestone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark a pre-booking milestone complete (by code) on a contract cycle. */
|
||||||
|
async completeForContract(
|
||||||
|
contractId: string,
|
||||||
|
code: string,
|
||||||
|
userId?: string,
|
||||||
|
note?: string,
|
||||||
|
): Promise<ClearanceMilestone> {
|
||||||
|
const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
|
||||||
|
if (!milestone) {
|
||||||
|
throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
|
||||||
|
}
|
||||||
|
if (milestone.status === 'COMPLETED') {
|
||||||
|
throw new BadRequestException(`Milestone ${code} is already completed.`);
|
||||||
|
}
|
||||||
|
milestone.status = 'COMPLETED';
|
||||||
|
milestone.triggeredAt = new Date();
|
||||||
|
milestone.triggeredByUserId = userId ?? null;
|
||||||
|
if (note) milestone.note = note;
|
||||||
|
return this.repo.save(milestone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Complete a doc-triggered milestone when its document is uploaded/approved. */
|
||||||
|
async completeByDocTrigger(
|
||||||
|
scope: { bookingId?: string; contractId?: string },
|
||||||
|
code: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const where = scope.bookingId
|
||||||
|
? { bookingId: scope.bookingId, milestoneCode: code }
|
||||||
|
: { contractId: scope.contractId, milestoneCode: code };
|
||||||
|
const milestone = await this.repo.findOne({ where });
|
||||||
|
if (!milestone || milestone.status === 'COMPLETED') return;
|
||||||
|
milestone.status = 'COMPLETED';
|
||||||
|
milestone.triggeredAt = new Date();
|
||||||
|
await this.repo.save(milestone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ET ↔ DJ ownership handoff (doc §11.5/§12.3). On DEPARTED_FROM_DJIBOUTI the
|
||||||
|
* lead transfers to GL Ethiopia + Operations; on DEPARTED_TO_DJIBOUTI to GL
|
||||||
|
* Djibouti. Notifications are handled by the notification layer (out of scope);
|
||||||
|
* here we only record the ownership flip on subsequent pending milestones.
|
||||||
|
*/
|
||||||
|
private async onHandoff(bookingId: string, code: string): Promise<void> {
|
||||||
|
void bookingId;
|
||||||
|
void code;
|
||||||
|
// Ownership region is already encoded per-milestone in the catalog; no
|
||||||
|
// mutation is required. This hook exists for the notification dispatch that
|
||||||
|
// the GL US-09 handoff requires once the notification module lands.
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Catalog passthrough for the frontend timeline (labels + owners). */
|
||||||
|
catalogForDirection(tradeDirection: string): MilestoneDef[] {
|
||||||
|
return milestonesForDirection(tradeDirection);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||||
|
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||||
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
|
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||||
|
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||||
|
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
|
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||||
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
|
import { hasFreightPermission } from '../../common/freight-permission.util';
|
||||||
|
|
||||||
|
import { Contract } from './entities/contract.entity';
|
||||||
|
import { ContractRoute } from './entities/contract-route.entity';
|
||||||
|
import { ContractsRepository } from './contracts.repository';
|
||||||
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
|
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||||
|
|
||||||
|
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
|
||||||
|
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
|
||||||
|
|
||||||
|
export interface CreateBookingUnderContractResult {
|
||||||
|
booking: Booking;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single create path for shipment bookings under a contract.
|
||||||
|
*
|
||||||
|
* - Path A (transport only): the customer creates the booking once the contract
|
||||||
|
* is FULLY_EXECUTED / CONTRACT_ACTIVE and customs is NOT bundled.
|
||||||
|
* - Path B (customs clearance): only GL Ethiopia creates the booking, once the
|
||||||
|
* contract reaches CLEARANCE_READY_FOR_BOOKING; the customer never enters
|
||||||
|
* shipment data.
|
||||||
|
*
|
||||||
|
* From booking creation onward the existing batch/payment/allocation pipeline
|
||||||
|
* runs unchanged. See docs/new-doc.md §8, §13.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ContractBookingService {
|
||||||
|
private readonly logger = new Logger(ContractBookingService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly contractsRepository: ContractsRepository,
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly bookingPricingService: BookingPricingService,
|
||||||
|
private readonly containerTypesService: ContainerTypesService,
|
||||||
|
private readonly ruleEngineService: RuleEngineService,
|
||||||
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createUnderContract(
|
||||||
|
contractId: string,
|
||||||
|
dto: CreateBookingUnderContractDto,
|
||||||
|
user?: { id?: string } | null,
|
||||||
|
actorPermissions?: unknown,
|
||||||
|
): Promise<CreateBookingUnderContractResult> {
|
||||||
|
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||||
|
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
||||||
|
|
||||||
|
// GL Ethiopia is identified by the dedicated contract create-booking permission
|
||||||
|
// (granted to the edr_gl_ethiopia preset).
|
||||||
|
const isGlActor =
|
||||||
|
actorPermissions != null &&
|
||||||
|
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
||||||
|
|
||||||
|
const createdByRole = await this.assertGate(contract, isGlActor);
|
||||||
|
|
||||||
|
// Validity window must still be open.
|
||||||
|
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
||||||
|
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ONE_TIME: a single shipment at a time. The slot frees only if the prior
|
||||||
|
// booking reached a terminal state (e.g. payment expired without shipping),
|
||||||
|
// letting the customer re-book within contract validity (doc §10.4).
|
||||||
|
if (contract.contractKind === 'ONE_TIME') {
|
||||||
|
const active = await this.countActiveBookings(contractId);
|
||||||
|
if (active > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This one-time contract already has an active booking.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// GENERAL: draw down against the cargo quantity cap until it is full.
|
||||||
|
await this.assertWithinQuantityCap(contract, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
const reference = await this.generateReference();
|
||||||
|
const freightType = contract.freightType;
|
||||||
|
|
||||||
|
// GENERAL + customs (Path B) runs per-booking clearance: the booking starts
|
||||||
|
// in the clearance gate (AWAITING_DOCUMENTS) instead of going straight to
|
||||||
|
// operations, and there is NO contract-level clearance cycle to link.
|
||||||
|
const generalCustoms =
|
||||||
|
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
||||||
|
|
||||||
|
// Denormalize route/direction/freight onto the booking for the scheduling engine.
|
||||||
|
const booking = await this.bookingsRepository.create({
|
||||||
|
reference,
|
||||||
|
companyId: contract.companyId ?? null,
|
||||||
|
companyProfileId: contract.companyProfileId ?? null,
|
||||||
|
isGovernment: contract.isGovernment,
|
||||||
|
governmentInstitution: contract.governmentInstitution ?? null,
|
||||||
|
status: generalCustoms ? 'AWAITING_DOCUMENTS' : 'OPERATION_REQUEST_PENDING',
|
||||||
|
bookingType: 'ONE_TIME',
|
||||||
|
contractId: contract.id,
|
||||||
|
contractRouteId: route?.id ?? null,
|
||||||
|
contractKind: contract.contractKind,
|
||||||
|
createdByRole,
|
||||||
|
createdByUserId: user?.id ?? null,
|
||||||
|
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||||
|
serviceTypeId: contract.serviceTypeId,
|
||||||
|
paymentCurrency: contract.paymentCurrency,
|
||||||
|
contractType: 'NEW',
|
||||||
|
customsClearingEnabled: contract.customsClearingEnabled,
|
||||||
|
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||||
|
equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN',
|
||||||
|
originYardId: route?.originYardId ?? null,
|
||||||
|
destinationYardId: route?.destinationYardId ?? null,
|
||||||
|
tradeDirection: contract.tradeDirection,
|
||||||
|
freightType,
|
||||||
|
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||||
|
isHazardous: contract.isHazardous,
|
||||||
|
isReefer: contract.isReefer,
|
||||||
|
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||||
|
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||||
|
firstMilePickupLat: contract.firstMilePickupLat ?? null,
|
||||||
|
firstMilePickupLng: contract.firstMilePickupLng ?? null,
|
||||||
|
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||||
|
lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
|
||||||
|
lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
// Persist container lines + per-unit container numbers (container freight only).
|
||||||
|
if (freightType === 'CONTAINER') {
|
||||||
|
await this.persistContainers(booking.id, contract, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload with containers to compute the total from contract unit rates × qty.
|
||||||
|
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||||
|
if (loaded) {
|
||||||
|
if (freightType === 'CONTAINER') {
|
||||||
|
await this.applyWeightResults(loaded);
|
||||||
|
}
|
||||||
|
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
||||||
|
await this.bookingsRepository.update(booking.id, {
|
||||||
|
totalAmount: computed.totalAmount,
|
||||||
|
priorityScore: computed.priorityScore,
|
||||||
|
pricingBreakdown: {
|
||||||
|
lineItems: computed.lineItems,
|
||||||
|
totalAmount: computed.totalAmount,
|
||||||
|
currency: computed.currency,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
await this.bookingPricingService.createPricingSnapshots(
|
||||||
|
booking.id,
|
||||||
|
computed.usedRates,
|
||||||
|
computed.appliedModifiers,
|
||||||
|
);
|
||||||
|
warnings.push(...computed.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ONE_TIME customs (legacy contract-cycle path): link the contract clearance
|
||||||
|
// cycle to this booking, seed post-booking milestones, and lock the contract
|
||||||
|
// to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle
|
||||||
|
// and must stay CONTRACT_ACTIVE so further shipment requests can be accepted.
|
||||||
|
if (contract.customsClearingEnabled && !generalCustoms) {
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contract.id);
|
||||||
|
if (cycle) {
|
||||||
|
await this.contractsRepository.linkBooking(cycle.id, booking.id);
|
||||||
|
}
|
||||||
|
await this.milestoneService.seedPostBookingMilestones(
|
||||||
|
booking.id,
|
||||||
|
contract.tradeDirection,
|
||||||
|
);
|
||||||
|
await this.contractsRepository.update(contract.id, {
|
||||||
|
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||||
|
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||||
|
} as never);
|
||||||
|
} else if (generalCustoms) {
|
||||||
|
// Per-booking clearance: seed post-booking milestones on the booking (no
|
||||||
|
// cycle needed) and leave the contract active. The booking now drives its
|
||||||
|
// own clearance via the booking-level pipeline.
|
||||||
|
await this.milestoneService.seedPostBookingMilestones(
|
||||||
|
booking.id,
|
||||||
|
contract.tradeDirection,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||||
|
|
||||||
|
// Contract bookings are born past the billable gate (the contract is already
|
||||||
|
// executed), so the invoice is generated here — they never pass through the
|
||||||
|
// legacy marketingApprove → FULLY_EXECUTED path that invoices direct bookings.
|
||||||
|
// Idempotent and non-blocking: a billing hiccup must not undo the booking.
|
||||||
|
// Skips silently when unbillable (no company / no priced amount).
|
||||||
|
await this.invoiceService
|
||||||
|
.ensureInvoiceForBooking(result ?? booking)
|
||||||
|
.catch((err) =>
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to generate invoice for contract booking ${booking.reference}: ${
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { booking: result ?? booking, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the role to stamp on the booking, or throws if the caller is not
|
||||||
|
* allowed to create one for this contract's execution path.
|
||||||
|
*/
|
||||||
|
private async assertGate(contract: Contract, isGlActor: boolean): Promise<string> {
|
||||||
|
if (contract.customsClearingEnabled) {
|
||||||
|
// Path B — Global Logistics creates the booking ON BEHALF OF the customer.
|
||||||
|
// The customer never books a customs contract himself.
|
||||||
|
if (!isGlActor) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (contract.contractKind === 'GENERAL') {
|
||||||
|
// GENERAL customs has NO contract clearance cycle — GL books per accepted
|
||||||
|
// shipment request while the contract is active; clearance is per booking.
|
||||||
|
if (contract.status !== 'CONTRACT_ACTIVE') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Contract must be active to book a shipment.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return 'GL_ET';
|
||||||
|
}
|
||||||
|
// ONE_TIME customs — UNCHANGED: requires the finalized contract cycle.
|
||||||
|
if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Contract clearance is not ready for booking yet.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return 'GL_ET';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path A — customer (or staff) once the contract is executed.
|
||||||
|
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Contract must be fully executed before booking a shipment.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return isGlActor ? 'STAFF' : 'CUSTOMER';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async countActiveBookings(contractId: string): Promise<number> {
|
||||||
|
return this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.where('b.contract_id = :contractId', { contractId })
|
||||||
|
.andWhere('b.status NOT IN (:...terminal)', { terminal: TERMINAL_BOOKING_STATUSES })
|
||||||
|
.getCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GENERAL contract quantity cap (draw-down) ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject a GENERAL booking whose cargo would exceed the contract's quantity
|
||||||
|
* cap. Container caps are per size; bulk is a single tons/items cap. Bookings
|
||||||
|
* that never shipped (CANCELLED / REJECTED / EXPIRED) release their hold.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Capacity check for a SHIPMENT REQUEST (no per-unit data) — mirrors
|
||||||
|
* {@link assertWithinQuantityCap} but reads the request's quantity shape.
|
||||||
|
*/
|
||||||
|
async assertRequestWithinCapacity(
|
||||||
|
contract: Contract,
|
||||||
|
lines: {
|
||||||
|
containers?: Array<{ containerSize: string; quantity: number }>;
|
||||||
|
bulk?: { cargoWeightTons?: number; itemCount?: number };
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
const capacity = await this.computeCapacity(contract);
|
||||||
|
if (capacity.length === 0) return; // uncapped contract
|
||||||
|
|
||||||
|
if (contract.freightType === 'CONTAINER') {
|
||||||
|
for (const line of lines.containers ?? []) {
|
||||||
|
const cap = capacity.find((c) => c.containerSize === line.containerSize);
|
||||||
|
if (!cap || cap.remaining == null) continue;
|
||||||
|
if (line.quantity > cap.remaining) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Only ${cap.remaining} of ${cap.cap} ${line.containerSize} containers remain on this contract.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const requested =
|
||||||
|
(lines.bulk?.cargoWeightTons ?? lines.bulk?.itemCount ?? 0) || 0;
|
||||||
|
const cap = capacity.find((c) => c.cap != null);
|
||||||
|
if (cap && cap.remaining != null && requested > cap.remaining) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Only ${cap.remaining} of ${cap.cap} remain on this contract.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertWithinQuantityCap(
|
||||||
|
contract: Contract,
|
||||||
|
dto: CreateBookingUnderContractDto,
|
||||||
|
): Promise<void> {
|
||||||
|
const capacity = await this.computeCapacity(contract);
|
||||||
|
if (capacity.length === 0) return; // uncapped contract
|
||||||
|
|
||||||
|
if (contract.freightType === 'CONTAINER') {
|
||||||
|
for (const line of dto.containers ?? []) {
|
||||||
|
const cap = capacity.find((c) => c.containerSize === line.containerSize);
|
||||||
|
if (!cap || cap.remaining == null) continue; // size uncapped
|
||||||
|
if (line.quantity > cap.remaining) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Only ${cap.remaining} of ${cap.cap} ${line.containerSize} containers remain on this contract.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const requested =
|
||||||
|
(dto.bulkLines ?? []).reduce(
|
||||||
|
(sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0),
|
||||||
|
0,
|
||||||
|
) || this.resolveBulkTons(dto) || 0;
|
||||||
|
const cap = capacity.find((c) => c.cap != null);
|
||||||
|
if (cap && cap.remaining != null && requested > cap.remaining) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Only ${cap.remaining} of ${cap.cap} remain on this contract.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remaining bookable quantity per cargo-scope line: cap minus what prior
|
||||||
|
* bookings already consumed. Returns [] when the contract has no caps.
|
||||||
|
*/
|
||||||
|
async computeCapacity(
|
||||||
|
contract: Contract,
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
containerSize?: string | null;
|
||||||
|
cargoTypeId?: string | null;
|
||||||
|
cap: number | null;
|
||||||
|
booked: number;
|
||||||
|
remaining: number | null;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const scope = contract.cargoScope ?? [];
|
||||||
|
const capped = scope.filter((s) => s.quantityCap != null);
|
||||||
|
if (capped.length === 0) return [];
|
||||||
|
|
||||||
|
const booked = await this.bookedQuantities(contract);
|
||||||
|
return capped.map((s) => {
|
||||||
|
const cap = Number(s.quantityCap);
|
||||||
|
const used =
|
||||||
|
contract.freightType === 'CONTAINER'
|
||||||
|
? (booked.bySize.get(s.containerSize ?? '') ?? 0)
|
||||||
|
: booked.bulk;
|
||||||
|
return {
|
||||||
|
containerSize: s.containerSize,
|
||||||
|
cargoTypeId: s.cargoTypeId,
|
||||||
|
cap,
|
||||||
|
booked: used,
|
||||||
|
remaining: Math.max(0, cap - used),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quantities already booked under a contract that still hold capacity. Excludes
|
||||||
|
* bookings that never shipped (CANCELLED / REJECTED / EXPIRED).
|
||||||
|
*/
|
||||||
|
private async bookedQuantities(
|
||||||
|
contract: Contract,
|
||||||
|
): Promise<{ bySize: Map<string, number>; bulk: number }> {
|
||||||
|
const releasing = ['CANCELLED', 'REJECTED', 'EXPIRED'];
|
||||||
|
if (contract.freightType === 'CONTAINER') {
|
||||||
|
const rows = await this.dataSource
|
||||||
|
.getRepository(BookingContainer)
|
||||||
|
.createQueryBuilder('bc')
|
||||||
|
.innerJoin(Booking, 'b', 'b.id = bc.booking_id')
|
||||||
|
.select('bc.container_size', 'size')
|
||||||
|
.addSelect('COALESCE(SUM(bc.quantity), 0)', 'qty')
|
||||||
|
.where('b.contract_id = :contractId', { contractId: contract.id })
|
||||||
|
.andWhere('b.status NOT IN (:...releasing)', { releasing })
|
||||||
|
.groupBy('bc.container_size')
|
||||||
|
.getRawMany<{ size: string | null; qty: string }>();
|
||||||
|
const bySize = new Map<string, number>();
|
||||||
|
for (const r of rows) bySize.set(r.size ?? '', Number(r.qty));
|
||||||
|
return { bySize, bulk: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'tons')
|
||||||
|
.where('b.contract_id = :contractId', { contractId: contract.id })
|
||||||
|
.andWhere('b.status NOT IN (:...releasing)', { releasing })
|
||||||
|
.getRawOne<{ tons: string }>();
|
||||||
|
return { bySize: new Map(), bulk: Number(row?.tons ?? 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveRoute(
|
||||||
|
contract: Contract,
|
||||||
|
contractRouteId?: string,
|
||||||
|
): Promise<ContractRoute | null> {
|
||||||
|
const routes = contract.routes ?? [];
|
||||||
|
if (contractRouteId) {
|
||||||
|
const found = routes.find((r) => r.id === contractRouteId);
|
||||||
|
if (!found) {
|
||||||
|
throw new BadRequestException('Selected route is not part of this contract.');
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
// ONE_TIME (or single-route GENERAL): auto-select the only route.
|
||||||
|
if (routes.length === 1) return routes[0];
|
||||||
|
if (routes.length === 0) return null;
|
||||||
|
throw new BadRequestException(
|
||||||
|
'contractRouteId is required for multi-route general contracts.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveCargoTypeId(
|
||||||
|
contract: Contract,
|
||||||
|
dto: CreateBookingUnderContractDto,
|
||||||
|
): string | null {
|
||||||
|
if (contract.freightType === 'BULK') {
|
||||||
|
const bulk = dto.bulkLines?.[0];
|
||||||
|
if (bulk?.cargoTypeId) return bulk.cargoTypeId;
|
||||||
|
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||||
|
return scope?.cargoTypeId ?? null;
|
||||||
|
}
|
||||||
|
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||||
|
return scope?.cargoTypeId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveBulkTons(dto: CreateBookingUnderContractDto): number {
|
||||||
|
if (!dto.bulkLines?.length) return 0;
|
||||||
|
return dto.bulkLines.reduce(
|
||||||
|
(sum, l) => sum + Number(l.cargoWeightTons ?? l.itemCount ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map each contract-scope container size to a concrete container type and
|
||||||
|
* persist the booking_container line + its per-unit container numbers. Weight
|
||||||
|
* rule results are filled in afterward by {@link applyWeightResults} once all
|
||||||
|
* lines exist (a single rule-engine pass over the booking).
|
||||||
|
*/
|
||||||
|
private async persistContainers(
|
||||||
|
bookingId: string,
|
||||||
|
contract: Contract,
|
||||||
|
dto: CreateBookingUnderContractDto,
|
||||||
|
): Promise<void> {
|
||||||
|
const lines = dto.containers ?? [];
|
||||||
|
if (!lines.length) {
|
||||||
|
throw new BadRequestException('At least one container line is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedSizes = new Set(
|
||||||
|
(contract.cargoScope ?? [])
|
||||||
|
.map((c) => c.containerSize)
|
||||||
|
.filter((s): s is string => !!s),
|
||||||
|
);
|
||||||
|
|
||||||
|
const containerRepo = this.dataSource.getRepository(BookingContainer);
|
||||||
|
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (allowedSizes.size && !allowedSizes.has(line.containerSize)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Container size ${line.containerSize} is outside the contract scope.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerType = await this.resolveContainerTypeForSize(
|
||||||
|
line.containerSize,
|
||||||
|
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const vgmPerUnit = line.units.length
|
||||||
|
? line.units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0) / line.units.length
|
||||||
|
: 0;
|
||||||
|
const totalVgm = line.units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0);
|
||||||
|
|
||||||
|
const containerRow = await containerRepo.save(
|
||||||
|
containerRepo.create({
|
||||||
|
bookingId,
|
||||||
|
containerTypeId: containerType.id,
|
||||||
|
containerSize: line.containerSize,
|
||||||
|
quantity: line.quantity,
|
||||||
|
hazardousQuantity: line.hazardousQuantity ?? 0,
|
||||||
|
reeferQuantity: line.reeferQuantity ?? 0,
|
||||||
|
vgmPerUnitTons: vgmPerUnit,
|
||||||
|
totalVgmTons: totalVgm,
|
||||||
|
wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)),
|
||||||
|
isOverweight: false,
|
||||||
|
overweightExcessTons: null,
|
||||||
|
} as Partial<BookingContainer>),
|
||||||
|
);
|
||||||
|
|
||||||
|
let sortOrder = 0;
|
||||||
|
for (const unit of line.units) {
|
||||||
|
await unitRepo.save(
|
||||||
|
unitRepo.create({
|
||||||
|
bookingContainerId: containerRow.id,
|
||||||
|
containerNumber: unit.containerNumber,
|
||||||
|
sealNumber: unit.sealNumber ?? null,
|
||||||
|
vgmTons: unit.vgmTons,
|
||||||
|
isHazardous: unit.isHazardous ?? false,
|
||||||
|
isReefer: unit.isReefer ?? false,
|
||||||
|
sortOrder: sortOrder++,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the rule engine once over the freshly-created booking and persist the
|
||||||
|
* overweight result per container line (same ordering the engine returns).
|
||||||
|
*/
|
||||||
|
private async applyWeightResults(booking: Booking): Promise<void> {
|
||||||
|
const evalInput = await this.bookingPricingService.buildEvalInputForBooking(booking);
|
||||||
|
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||||
|
const containers = booking.bookingContainers ?? [];
|
||||||
|
const containerRepo = this.dataSource.getRepository(BookingContainer);
|
||||||
|
for (let i = 0; i < containers.length; i++) {
|
||||||
|
const wr = ruleResult.containerWeightResults[i];
|
||||||
|
if (!wr) continue;
|
||||||
|
await containerRepo.update(containers[i].id, {
|
||||||
|
weightLimitRuleId: wr.weightLimitRuleId,
|
||||||
|
isOverweight: wr.isOverweight,
|
||||||
|
overweightExcessTons: wr.overweightExcessTons,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick the default container type for a size; prefer reefer when requested. */
|
||||||
|
private async resolveContainerTypeForSize(
|
||||||
|
size: string,
|
||||||
|
preferReefer: boolean,
|
||||||
|
): Promise<ContainerType> {
|
||||||
|
const sizeFt = parseInt(size, 10);
|
||||||
|
const { data } = await this.containerTypesService.findAll({ pageSize: 200 });
|
||||||
|
const types = data.filter((t) => Number(t.sizeFt) === sizeFt && t.isActive !== false);
|
||||||
|
if (!types.length) {
|
||||||
|
throw new BadRequestException(`No container type configured for size ${size}.`);
|
||||||
|
}
|
||||||
|
if (preferReefer) {
|
||||||
|
const reefer = types.find((t) => t.isReefer);
|
||||||
|
if (reefer) return reefer;
|
||||||
|
}
|
||||||
|
return types.find((t) => !t.isReefer) ?? types[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateReference(): Promise<string> {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const count = await this.bookingsRepository.countByYear(year);
|
||||||
|
const seq = String(count + 1).padStart(6, '0');
|
||||||
|
return `BK-${year}-${seq}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||||
|
import { FilesService } from '../files/files.service';
|
||||||
|
import { ContractsRepository } from './contracts.repository';
|
||||||
|
import { ContractsService, PaginatedContracts } from './contracts.service';
|
||||||
|
import { contractClearanceCodes } from './contract-clearance.util';
|
||||||
|
import { Contract } from './entities/contract.entity';
|
||||||
|
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
||||||
|
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||||
|
|
||||||
|
export interface ContractClearanceDocument {
|
||||||
|
fileKey: string;
|
||||||
|
label: string;
|
||||||
|
required: boolean;
|
||||||
|
uploadedBy: 'customer' | 'gl';
|
||||||
|
settingCode: string;
|
||||||
|
file: { id: string; name: string; url: string } | null;
|
||||||
|
reviewStatus: ContractDocReviewStatus | null;
|
||||||
|
note: string | null;
|
||||||
|
/** When the review decision (approve/query) was recorded. */
|
||||||
|
reviewedAt: string | null;
|
||||||
|
/** Staff id that recorded the decision (no user directory to resolve names). */
|
||||||
|
reviewedByStaffId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContractClearanceView {
|
||||||
|
contractId: string;
|
||||||
|
status: string;
|
||||||
|
clearanceStatus: string;
|
||||||
|
cycleNumber: number;
|
||||||
|
includesCustoms: boolean;
|
||||||
|
inputCode: string | null;
|
||||||
|
outputCode: string | null;
|
||||||
|
documents: ContractClearanceDocument[];
|
||||||
|
allApproved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContractClearanceService {
|
||||||
|
constructor(
|
||||||
|
private readonly contractsRepository: ContractsRepository,
|
||||||
|
private readonly contractsService: ContractsService,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
|
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** The pre-booking clearance document grid for a contract (Path B). */
|
||||||
|
async getClearanceView(contractId: string): Promise<ContractClearanceView> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
const { inputCode, outputCode, includesCustoms } = contractClearanceCodes(contract);
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
|
||||||
|
const files = await this.filesService.findByResource(contractId, 'contracts');
|
||||||
|
const fileByCode = new Map(files.map((f) => [f.code, f]));
|
||||||
|
const reviews = await this.contractsRepository.findDocumentReviews(
|
||||||
|
contractId,
|
||||||
|
cycle?.id ?? null,
|
||||||
|
);
|
||||||
|
const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]));
|
||||||
|
|
||||||
|
const documents: ContractClearanceDocument[] = [];
|
||||||
|
|
||||||
|
const pushSetting = async (code: string | null, uploadedBy: 'customer' | 'gl') => {
|
||||||
|
if (!code) return;
|
||||||
|
let setting;
|
||||||
|
try {
|
||||||
|
setting = await this.fileUploadSettingsService.getByCode(code);
|
||||||
|
} catch {
|
||||||
|
return; // setting not seeded — skip gracefully
|
||||||
|
}
|
||||||
|
for (const field of setting.fields ?? []) {
|
||||||
|
const file = fileByCode.get(field.fileKey) ?? null;
|
||||||
|
const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null;
|
||||||
|
documents.push({
|
||||||
|
fileKey: field.fileKey,
|
||||||
|
label: field.fileLabel,
|
||||||
|
required: field.isRequired,
|
||||||
|
uploadedBy,
|
||||||
|
settingCode: code,
|
||||||
|
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
||||||
|
reviewStatus: review?.status ?? null,
|
||||||
|
note: review?.note ?? null,
|
||||||
|
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
|
||||||
|
reviewedByStaffId: review?.reviewedByStaffId ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await pushSetting(inputCode, 'customer');
|
||||||
|
await pushSetting(outputCode, 'gl');
|
||||||
|
|
||||||
|
// Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set.
|
||||||
|
for (const f of files) {
|
||||||
|
if (!f.code?.startsWith('custom_')) continue;
|
||||||
|
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
||||||
|
documents.push({
|
||||||
|
fileKey: f.code,
|
||||||
|
label: f.name,
|
||||||
|
required: false,
|
||||||
|
uploadedBy: 'customer',
|
||||||
|
settingCode: 'custom',
|
||||||
|
file: { id: f.id, name: f.name, url: f.url },
|
||||||
|
reviewStatus: review?.status ?? null,
|
||||||
|
note: review?.note ?? null,
|
||||||
|
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
|
||||||
|
reviewedByStaffId: review?.reviewedByStaffId ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const allApproved = await this.isClearanceFullyApproved(contract);
|
||||||
|
|
||||||
|
return {
|
||||||
|
contractId,
|
||||||
|
status: contract.status,
|
||||||
|
clearanceStatus: contract.clearanceStatus,
|
||||||
|
cycleNumber: cycle?.cycleNumber ?? contract.clearanceCycleNumber,
|
||||||
|
includesCustoms,
|
||||||
|
inputCode,
|
||||||
|
outputCode,
|
||||||
|
documents,
|
||||||
|
allApproved,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when every REQUIRED customer-input field has an APPROVED review row in
|
||||||
|
* the current cycle. The 100% gate before clearance can be finalized.
|
||||||
|
*/
|
||||||
|
private async isClearanceFullyApproved(contract: Contract): Promise<boolean> {
|
||||||
|
const { inputCode } = contractClearanceCodes(contract);
|
||||||
|
if (!inputCode) return true;
|
||||||
|
let setting;
|
||||||
|
try {
|
||||||
|
setting = await this.fileUploadSettingsService.getByCode(inputCode);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
||||||
|
if (required.length === 0) return true;
|
||||||
|
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contract.id);
|
||||||
|
const reviews = await this.contractsRepository.findDocumentReviews(
|
||||||
|
contract.id,
|
||||||
|
cycle?.id ?? null,
|
||||||
|
);
|
||||||
|
return required.every((field) =>
|
||||||
|
reviews.some(
|
||||||
|
(r) =>
|
||||||
|
r.settingCode === inputCode &&
|
||||||
|
r.fileKey === field.fileKey &&
|
||||||
|
r.status === 'APPROVED',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer uploads clearance documents on the contract. When every required
|
||||||
|
* input is present, auto-advance to CLEARANCE_UNDER_REVIEW for GL ET.
|
||||||
|
*/
|
||||||
|
async uploadDocuments(
|
||||||
|
contractId: string,
|
||||||
|
files: Express.Multer.File[],
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
if (
|
||||||
|
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
|
||||||
|
contract.status !== 'CLEARANCE_UNDER_REVIEW'
|
||||||
|
) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Cannot upload clearance documents on status "${contract.status}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { inputCode } = contractClearanceCodes(contract);
|
||||||
|
if (!inputCode) {
|
||||||
|
throw new BadRequestException('This contract has no document-clearance step');
|
||||||
|
}
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new BadRequestException('No documents uploaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
|
||||||
|
// First submission: every required input field must be present.
|
||||||
|
if (contract.status === 'AWAITING_CLEARANCE_DOCUMENTS') {
|
||||||
|
await this.assertRequiredInputsPresent(contractId, inputCode, files);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const record = await this.filesService.upsertByCode({
|
||||||
|
resourceId: contractId,
|
||||||
|
resource: 'contracts',
|
||||||
|
code: file.fieldname,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
const settingCode = file.fieldname.startsWith('custom_') ? 'custom' : inputCode;
|
||||||
|
await this.contractsRepository.upsertDocumentReviewPending({
|
||||||
|
contractId,
|
||||||
|
clearanceCycleId: cycle?.id ?? null,
|
||||||
|
settingCode,
|
||||||
|
fileKey: file.fieldname,
|
||||||
|
fileRecordId: record.id,
|
||||||
|
uploadedByRole: 'CUSTOMER',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: 'CLEARANCE_UNDER_REVIEW',
|
||||||
|
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
|
||||||
|
} as never);
|
||||||
|
if (cycle) {
|
||||||
|
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW');
|
||||||
|
}
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertRequiredInputsPresent(
|
||||||
|
contractId: string,
|
||||||
|
inputCode: string,
|
||||||
|
files: Express.Multer.File[],
|
||||||
|
): Promise<void> {
|
||||||
|
let setting;
|
||||||
|
try {
|
||||||
|
setting = await this.fileUploadSettingsService.getByCode(inputCode);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
||||||
|
if (required.length === 0) return;
|
||||||
|
|
||||||
|
const existing = await this.filesService.findByResource(contractId, 'contracts');
|
||||||
|
const presentKeys = new Set<string>([
|
||||||
|
...existing.map((f) => f.code),
|
||||||
|
...files.map((f) => f.fieldname),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const missing = required.filter((f) => !presentKeys.has(f.fileKey));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
const labels = missing.map((f) => f.fileLabel).join(', ');
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Please upload all required documents before submitting: ${labels}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path A (no customs) — the customer clears the cargo himself and uploads his
|
||||||
|
* own clearance proof, reviewed by Operations rather than GL. True when a
|
||||||
|
* clearance doc set resolves for a non-customs contract.
|
||||||
|
*/
|
||||||
|
private isSelfClear(contract: Contract): boolean {
|
||||||
|
if (contract.customsClearingEnabled) return false;
|
||||||
|
return contractClearanceCodes(contract).inputCode != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL ET (Path B) reviews a single document: APPROVED or QUERIED. */
|
||||||
|
async reviewDocument(
|
||||||
|
contractId: string,
|
||||||
|
fileKey: string,
|
||||||
|
status: 'APPROVED' | 'QUERIED',
|
||||||
|
staffId: string,
|
||||||
|
note?: string,
|
||||||
|
): Promise<Contract> {
|
||||||
|
return this.applyReview(contractId, fileKey, status, staffId, 'GL_ET', note);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations (Path A) reviews a customer self-clearance document. Identical
|
||||||
|
* approve/query loop to {@link reviewDocument}; rejects customs (Path B)
|
||||||
|
* contracts, which are GL-reviewed.
|
||||||
|
*/
|
||||||
|
async opsReviewDocument(
|
||||||
|
contractId: string,
|
||||||
|
fileKey: string,
|
||||||
|
status: 'APPROVED' | 'QUERIED',
|
||||||
|
staffId: string,
|
||||||
|
note?: string,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
if (!this.isSelfClear(contract)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Operations review applies only to self-clearance (non-customs) contracts.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.applyReview(contractId, fileKey, status, staffId, 'OPERATIONS', note);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyReview(
|
||||||
|
contractId: string,
|
||||||
|
fileKey: string,
|
||||||
|
status: 'APPROVED' | 'QUERIED',
|
||||||
|
staffId: string,
|
||||||
|
reviewerRole: 'GL_ET' | 'OPERATIONS',
|
||||||
|
note?: string,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
// Reviewing is allowed both while the batch is UNDER_REVIEW and after it has
|
||||||
|
// dropped back to AWAITING_CLEARANCE_DOCUMENTS — querying one document flips
|
||||||
|
// the contract to "awaiting" (the customer must re-upload), but the reviewer
|
||||||
|
// may still be working through the rest of the batch. Restricting to
|
||||||
|
// UNDER_REVIEW only would 409 every review after the first query.
|
||||||
|
if (
|
||||||
|
contract.status !== 'CLEARANCE_UNDER_REVIEW' &&
|
||||||
|
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS'
|
||||||
|
) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Cannot review clearance documents on status "${contract.status}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === 'QUERIED' && !note?.trim()) {
|
||||||
|
throw new BadRequestException('A note is required when querying a document');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { inputCode, outputCode } = contractClearanceCodes(contract);
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
const reviews = await this.contractsRepository.findDocumentReviews(
|
||||||
|
contractId,
|
||||||
|
cycle?.id ?? null,
|
||||||
|
);
|
||||||
|
const match = reviews.find((r) => r.fileKey === fileKey);
|
||||||
|
const settingCode =
|
||||||
|
match?.settingCode ??
|
||||||
|
(fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));
|
||||||
|
|
||||||
|
await this.contractsRepository.setDocumentReviewStatus({
|
||||||
|
contractId,
|
||||||
|
clearanceCycleId: cycle?.id ?? null,
|
||||||
|
settingCode,
|
||||||
|
fileKey,
|
||||||
|
status,
|
||||||
|
staffId,
|
||||||
|
note,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status === 'QUERIED') {
|
||||||
|
await this.contractsRepository.createReviewNote(
|
||||||
|
contractId,
|
||||||
|
`Document "${fileKey}" queried: ${note}`,
|
||||||
|
'CHANGES_REQUESTED',
|
||||||
|
staffId,
|
||||||
|
reviewerRole,
|
||||||
|
);
|
||||||
|
// Return the contract to the customer to re-upload the queried document.
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: 'AWAITING_CLEARANCE_DOCUMENTS',
|
||||||
|
clearanceStatus: 'AWAITING_DOCUMENTS',
|
||||||
|
} as never);
|
||||||
|
if (cycle) {
|
||||||
|
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL uploads customs output documents (IM4/IM5/EX3/etc.) during clearance. */
|
||||||
|
async uploadOutputDocuments(
|
||||||
|
contractId: string,
|
||||||
|
files: Express.Multer.File[],
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Cannot upload output documents on status "${contract.status}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { outputCode } = contractClearanceCodes(contract);
|
||||||
|
if (!outputCode) {
|
||||||
|
throw new BadRequestException('This contract has no customs output documents');
|
||||||
|
}
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new BadRequestException('No documents uploaded');
|
||||||
|
}
|
||||||
|
for (const file of files) {
|
||||||
|
await this.filesService.upsertByCode({
|
||||||
|
resourceId: contractId,
|
||||||
|
resource: 'contracts',
|
||||||
|
code: file.fieldname,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL ET finalizes Path B pre-booking clearance: requires every customer
|
||||||
|
* document APPROVED and required output docs present → CLEARANCE_READY_FOR_BOOKING
|
||||||
|
* (GL then creates the booking). Rejects self-clearance (Path A) contracts.
|
||||||
|
*/
|
||||||
|
async finalize(contractId: string): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
if (this.isSelfClear(contract)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Self-clearance (Path A) contracts are finalized by Operations, not GL.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Cannot finalize clearance on status "${contract.status}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const approved = await this.isClearanceFullyApproved(contract);
|
||||||
|
if (!approved) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'All required documents must be approved before clearance can be finalized',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { outputCode } = contractClearanceCodes(contract);
|
||||||
|
if (outputCode) {
|
||||||
|
const setting = await this.fileUploadSettingsService.getByCode(outputCode);
|
||||||
|
const files = await this.filesService.findByResource(contractId, 'contracts');
|
||||||
|
const uploaded = new Set(files.map((f) => f.code));
|
||||||
|
const missing = (setting.fields ?? []).filter(
|
||||||
|
(f) => f.isRequired && !uploaded.has(f.fileKey),
|
||||||
|
);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Upload all required customs output documents first: ${missing
|
||||||
|
.map((m) => m.fileLabel)
|
||||||
|
.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: 'CLEARANCE_READY_FOR_BOOKING',
|
||||||
|
clearanceStatus: 'CLEARANCE_READY_FOR_BOOKING',
|
||||||
|
} as never);
|
||||||
|
if (cycle) {
|
||||||
|
await this.contractsRepository.setCycleStatus(
|
||||||
|
cycle.id,
|
||||||
|
'CLEARANCE_READY_FOR_BOOKING',
|
||||||
|
{ clearanceReadyAt: new Date() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations finalizes Path A self-clearance: requires every customer document
|
||||||
|
* APPROVED, then the contract becomes bookable BY THE CUSTOMER. There is no GL
|
||||||
|
* output phase on Path A, so the contract goes straight to FULLY_EXECUTED
|
||||||
|
* (ONE_TIME) / CONTRACT_ACTIVE (GENERAL).
|
||||||
|
*/
|
||||||
|
async opsFinalize(contractId: string): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
if (!this.isSelfClear(contract)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Operations finalize applies only to self-clearance (non-customs) contracts.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Cannot finalize clearance on status "${contract.status}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const approved = await this.isClearanceFullyApproved(contract);
|
||||||
|
if (!approved) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'All required documents must be approved before clearance can be finalized',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED',
|
||||||
|
clearanceStatus: 'SELF_CLEARED',
|
||||||
|
} as never);
|
||||||
|
if (cycle) {
|
||||||
|
await this.contractsRepository.setCycleStatus(cycle.id, 'CLEARANCE_READY_FOR_BOOKING', {
|
||||||
|
clearanceReadyAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL ET clearance hub: every customs (Path B) contract that still needs
|
||||||
|
* customs clearance — awaiting the customer's documents, under GL review, or
|
||||||
|
* finalized and waiting for the customer to create the booking in the portal.
|
||||||
|
*/
|
||||||
|
async queue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||||
|
return this.contractsRepository.findAllPaginated({
|
||||||
|
page: filter.page ?? 1,
|
||||||
|
pageSize: filter.pageSize ?? 100,
|
||||||
|
statuses: [
|
||||||
|
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||||
|
'CLEARANCE_UNDER_REVIEW',
|
||||||
|
'CLEARANCE_READY_FOR_BOOKING',
|
||||||
|
],
|
||||||
|
customsClearingEnabled: true,
|
||||||
|
sortBy: filter.sortBy,
|
||||||
|
sortOrder: filter.sortOrder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations queue: self-clearance (Path A) contracts awaiting Operations
|
||||||
|
* review of the customer's own clearance documents.
|
||||||
|
*/
|
||||||
|
async opsQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||||
|
return this.contractsRepository.findAllPaginated({
|
||||||
|
page: filter.page ?? 1,
|
||||||
|
pageSize: filter.pageSize ?? 100,
|
||||||
|
statuses: ['CLEARANCE_UNDER_REVIEW'],
|
||||||
|
customsClearingEnabled: false,
|
||||||
|
sortBy: filter.sortBy,
|
||||||
|
sortOrder: filter.sortOrder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL ET history: contracts that completed Path B clearance. */
|
||||||
|
async history(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||||
|
return this.contractsRepository.findAllPaginated({
|
||||||
|
page: filter.page ?? 1,
|
||||||
|
pageSize: filter.pageSize ?? 50,
|
||||||
|
statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'],
|
||||||
|
customsClearingEnabled: true,
|
||||||
|
sortBy: filter.sortBy ?? 'createdAt',
|
||||||
|
sortOrder: filter.sortOrder ?? 'DESC',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Operations history: contracts that completed Path A self-clearance review. */
|
||||||
|
async opsHistory(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||||
|
return this.contractsRepository.findAllPaginated({
|
||||||
|
page: filter.page ?? 1,
|
||||||
|
pageSize: filter.pageSize ?? 50,
|
||||||
|
statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'],
|
||||||
|
customsClearingEnabled: false,
|
||||||
|
sortBy: filter.sortBy ?? 'createdAt',
|
||||||
|
sortOrder: filter.sortOrder ?? 'DESC',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Contract } from './entities/contract.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves which seeded clearance FileUploadSetting applies to a contract during
|
||||||
|
* the CONTRACT pre-booking phase (Path B). Mirrors clearance.util.ts but emits
|
||||||
|
* `contract_clearance_*` codes keyed on (tradeDirection, freightType, customs).
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Op = 'import' | 'export';
|
||||||
|
type Freight = 'container' | 'bulk';
|
||||||
|
|
||||||
|
/** Trade direction → clearance operation. DOMESTIC has no customs clearance. */
|
||||||
|
function operationFor(tradeDirection: string): Op | null {
|
||||||
|
if (tradeDirection === 'IMPORT') return 'import';
|
||||||
|
if (tradeDirection === 'EXPORT') return 'export';
|
||||||
|
return null; // DOMESTIC / intercity — no clearance gate
|
||||||
|
}
|
||||||
|
|
||||||
|
function freightFor(freightType: string): Freight {
|
||||||
|
return freightType === 'BULK' ? 'bulk' : 'container';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer-input clearance setting code, or null when no gate applies.
|
||||||
|
*
|
||||||
|
* - Path B (customs bundled): the customer uploads the documents GL needs to do
|
||||||
|
* the clearance work → `contract_clearance_{op}_{freight}`.
|
||||||
|
* - Path A (no customs): the customer clears the cargo himself and uploads his
|
||||||
|
* own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`,
|
||||||
|
* reviewed by Operations rather than GL.
|
||||||
|
*
|
||||||
|
* DOMESTIC/intercity has no border, so no clearance gate applies on either path.
|
||||||
|
*/
|
||||||
|
export function contractClearanceSettingCode(
|
||||||
|
tradeDirection: string,
|
||||||
|
freightType: string,
|
||||||
|
includesCustoms: boolean,
|
||||||
|
): string | null {
|
||||||
|
const op = operationFor(tradeDirection);
|
||||||
|
if (!op) return null;
|
||||||
|
const freight = freightFor(freightType);
|
||||||
|
if (!includesCustoms) {
|
||||||
|
return `contract_clearance_selfclear_${op}_${freight}`;
|
||||||
|
}
|
||||||
|
return `contract_clearance_${op}_${freight}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The GL-output (customs output) setting code; only container customs sets exist. */
|
||||||
|
export function contractClearanceOutputSettingCode(
|
||||||
|
tradeDirection: string,
|
||||||
|
freightType: string,
|
||||||
|
includesCustoms: boolean,
|
||||||
|
): string | null {
|
||||||
|
if (!includesCustoms) return null;
|
||||||
|
const op = operationFor(tradeDirection);
|
||||||
|
if (!op) return null;
|
||||||
|
if (freightFor(freightType) !== 'container') return null;
|
||||||
|
return `contract_clearance_output_${op}_container`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: resolve both codes for a loaded contract. */
|
||||||
|
export function contractClearanceCodes(contract: Contract): {
|
||||||
|
inputCode: string | null;
|
||||||
|
outputCode: string | null;
|
||||||
|
includesCustoms: boolean;
|
||||||
|
} {
|
||||||
|
const includesCustoms = contract.customsClearingEnabled ?? false;
|
||||||
|
return {
|
||||||
|
inputCode: contractClearanceSettingCode(
|
||||||
|
contract.tradeDirection,
|
||||||
|
contract.freightType,
|
||||||
|
includesCustoms,
|
||||||
|
),
|
||||||
|
outputCode: contractClearanceOutputSettingCode(
|
||||||
|
contract.tradeDirection,
|
||||||
|
contract.freightType,
|
||||||
|
includesCustoms,
|
||||||
|
),
|
||||||
|
includesCustoms,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { RatesService } from '../rule-engine/services/rates.service';
|
||||||
|
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
|
import { ExchangeService } from '@edr/api-common';
|
||||||
|
import { ContractsRepository } from './contracts.repository';
|
||||||
|
import { Contract } from './entities/contract.entity';
|
||||||
|
|
||||||
|
/** A single unit-rate line at contract phase — NO quantities, NO totals. */
|
||||||
|
export interface ContractUnitRateLineItem {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
unit: 'per_container' | 'per_ton' | 'per_item' | 'per_km' | 'flat';
|
||||||
|
unitPrice: number;
|
||||||
|
containerSize?: string | null;
|
||||||
|
conditionalOn?: string | null;
|
||||||
|
cargoTypeCode?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The contract `pricing_breakdown` shape (doc §9.1). */
|
||||||
|
export interface ContractPricingBreakdown {
|
||||||
|
displayMode: 'UNIT_RATES';
|
||||||
|
currency: string;
|
||||||
|
lineItems: ContractUnitRateLineItem[];
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a rate's storage unit to the contract-display unit. */
|
||||||
|
function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
|
||||||
|
switch (rateUnit) {
|
||||||
|
case 'PER_TON':
|
||||||
|
return 'per_ton';
|
||||||
|
case 'PER_KM':
|
||||||
|
return 'per_km';
|
||||||
|
case 'PER_CONTAINER':
|
||||||
|
case 'PER_WAGON':
|
||||||
|
return 'per_container';
|
||||||
|
default:
|
||||||
|
return 'flat';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContractPricingService {
|
||||||
|
constructor(
|
||||||
|
private readonly contractsRepository: ContractsRepository,
|
||||||
|
private readonly ratesService: RatesService,
|
||||||
|
private readonly containerTypesService: ContainerTypesService,
|
||||||
|
private readonly exchangeService: ExchangeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Base rail rate type for the contract's direction + freight. */
|
||||||
|
private baseRateType(contract: Contract): string {
|
||||||
|
const isBulk = contract.freightType === 'BULK';
|
||||||
|
if (contract.tradeDirection === 'IMPORT') {
|
||||||
|
return isBulk ? 'BULK_IMPORT' : 'CONTAINER_IMPORT';
|
||||||
|
}
|
||||||
|
if (contract.tradeDirection === 'EXPORT') {
|
||||||
|
return isBulk ? 'BULK_EXPORT' : 'CONTAINER_EXPORT';
|
||||||
|
}
|
||||||
|
return isBulk ? 'INTERCITY_BULK' : 'INTERCITY_CONTAINER';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the unit-rate breakdown from live rates. Emits per-unit prices only
|
||||||
|
* (one per container size, conditional hazard/reefer surcharges, and bulk
|
||||||
|
* commodity rate) — NO totals or quantities (doc §9.1).
|
||||||
|
*/
|
||||||
|
async buildBreakdown(contract: Contract): Promise<ContractPricingBreakdown> {
|
||||||
|
const liveRates = await this.ratesService.findLiveRates();
|
||||||
|
const currency = contract.paymentCurrency;
|
||||||
|
const isEtb = currency === 'ETB';
|
||||||
|
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||||
|
const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd);
|
||||||
|
|
||||||
|
const lineItems: ContractUnitRateLineItem[] = [];
|
||||||
|
const baseType = this.baseRateType(contract);
|
||||||
|
|
||||||
|
if (contract.freightType === 'CONTAINER') {
|
||||||
|
const sizes = (contract.cargoScope ?? [])
|
||||||
|
.map((c) => c.containerSize)
|
||||||
|
.filter((s): s is string => !!s);
|
||||||
|
const { data: containerTypes } = await this.containerTypesService.findAll({
|
||||||
|
isActive: true,
|
||||||
|
pageSize: 500,
|
||||||
|
});
|
||||||
|
for (const size of sizes) {
|
||||||
|
const sizeFt = size === '40ft' ? 40 : 20;
|
||||||
|
const matchedTypes = containerTypes.filter((ct) => ct.sizeFt === sizeFt);
|
||||||
|
const matchedIds = new Set(matchedTypes.map((ct) => ct.id));
|
||||||
|
const rate =
|
||||||
|
liveRates.find(
|
||||||
|
(r) =>
|
||||||
|
r.rateType === baseType &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
r.containerTypeId &&
|
||||||
|
matchedIds.has(r.containerTypeId),
|
||||||
|
) ??
|
||||||
|
liveRates.find(
|
||||||
|
(r) => r.rateType === baseType && r.currency === 'USD' && !r.containerTypeId,
|
||||||
|
);
|
||||||
|
if (!rate) continue;
|
||||||
|
lineItems.push({
|
||||||
|
code: `CONTAINER_${size.toUpperCase()}`,
|
||||||
|
label: `${size} container`,
|
||||||
|
unit: toContractUnit(rate.rateUnit),
|
||||||
|
unitPrice: convert(Number(rate.rateValue)),
|
||||||
|
containerSize: size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const bulkRate =
|
||||||
|
liveRates.find((r) => r.rateType === baseType && r.currency === 'USD') ?? null;
|
||||||
|
const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||||
|
if (bulkRate) {
|
||||||
|
lineItems.push({
|
||||||
|
code: 'BULK_FREIGHT',
|
||||||
|
label: cargoScope?.cargoType?.cargoTypeName ?? 'Bulk cargo',
|
||||||
|
unit: toContractUnit(bulkRate.rateUnit),
|
||||||
|
unitPrice: convert(Number(bulkRate.rateValue)),
|
||||||
|
cargoTypeCode: cargoScope?.cargoType?.code ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// First / last mile trucking unit rates — shown when the contract carries
|
||||||
|
// that leg. Per-unit prices only; the actual amount (× km / containers /
|
||||||
|
// tons / flat) is computed at booking time.
|
||||||
|
if (contract.firstMilePickupAddress) {
|
||||||
|
const fm = liveRates.find(
|
||||||
|
(r) => r.rateType === 'FIRST_MILE' && r.currency === 'USD',
|
||||||
|
);
|
||||||
|
if (fm && Number(fm.rateValue) > 0) {
|
||||||
|
lineItems.push({
|
||||||
|
code: 'FIRST_MILE',
|
||||||
|
label: 'First mile (pick-up)',
|
||||||
|
unit: toContractUnit(fm.rateUnit),
|
||||||
|
unitPrice: convert(Number(fm.rateValue)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (contract.lastMileDeliveryAddress) {
|
||||||
|
const lm = liveRates.find(
|
||||||
|
(r) => r.rateType === 'LAST_MILE' && r.currency === 'USD',
|
||||||
|
);
|
||||||
|
if (lm && Number(lm.rateValue) > 0) {
|
||||||
|
lineItems.push({
|
||||||
|
code: 'LAST_MILE',
|
||||||
|
label: 'Last mile (delivery)',
|
||||||
|
unit: toContractUnit(lm.rateUnit),
|
||||||
|
unitPrice: convert(Number(lm.rateValue)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional surcharges — shown only when the contract toggles them on AND
|
||||||
|
// the rate has a non-zero value (a 0 rate means "no surcharge").
|
||||||
|
if (contract.isHazardous) {
|
||||||
|
const hazard = liveRates.find(
|
||||||
|
(r) => r.rateType === 'HAZARD_SURCHARGE' && r.currency === 'USD',
|
||||||
|
);
|
||||||
|
if (hazard && Number(hazard.rateValue) > 0) {
|
||||||
|
lineItems.push({
|
||||||
|
code: 'HAZARD_SURCHARGE',
|
||||||
|
label: 'Hazardous surcharge',
|
||||||
|
unit: toContractUnit(hazard.rateUnit),
|
||||||
|
unitPrice: convert(Number(hazard.rateValue)),
|
||||||
|
conditionalOn: 'is_hazardous',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (contract.isReefer) {
|
||||||
|
const reefer = liveRates.find(
|
||||||
|
(r) => r.rateType === 'REEFER_SURCHARGE' && r.currency === 'USD',
|
||||||
|
);
|
||||||
|
if (reefer && Number(reefer.rateValue) > 0) {
|
||||||
|
lineItems.push({
|
||||||
|
code: 'REEFER_SURCHARGE',
|
||||||
|
label: 'Reefer surcharge',
|
||||||
|
unit: toContractUnit(reefer.rateUnit),
|
||||||
|
unitPrice: convert(Number(reefer.rateValue)),
|
||||||
|
conditionalOn: 'is_reefer',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayMode: 'UNIT_RATES',
|
||||||
|
currency,
|
||||||
|
lineItems,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate (and persist) the unit-rate breakdown for a contract. */
|
||||||
|
async generatePrice(contractId: string): Promise<ContractPricingBreakdown> {
|
||||||
|
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||||
|
if (!contract) {
|
||||||
|
throw new Error(`Contract ${contractId} not found`);
|
||||||
|
}
|
||||||
|
const breakdown = await this.buildBreakdown(contract);
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
pricingBreakdown: breakdown as never,
|
||||||
|
pricingDisplayMode: 'UNIT_RATES',
|
||||||
|
} as never);
|
||||||
|
return breakdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freeze the contract's unit rates into contract_rate_snapshots (one row per
|
||||||
|
* rate line) at submit time. The booking later computes totals from these.
|
||||||
|
*/
|
||||||
|
async freezeRateSnapshots(contractId: string): Promise<void> {
|
||||||
|
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||||
|
if (!contract) return;
|
||||||
|
const breakdown =
|
||||||
|
(contract.pricingBreakdown as ContractPricingBreakdown | null) ??
|
||||||
|
(await this.buildBreakdown(contract));
|
||||||
|
|
||||||
|
await this.contractsRepository.clearRateSnapshots(contractId);
|
||||||
|
for (const line of breakdown.lineItems) {
|
||||||
|
await this.contractsRepository.createRateSnapshot({
|
||||||
|
contractId,
|
||||||
|
rateCode: line.code,
|
||||||
|
description: line.label,
|
||||||
|
unitPrice: line.unitPrice,
|
||||||
|
unitOfMeasure: line.unit,
|
||||||
|
currency: breakdown.currency,
|
||||||
|
containerSize: line.containerSize ?? null,
|
||||||
|
isSurcharge: !!line.conditionalOn,
|
||||||
|
conditionalOn: line.conditionalOn ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,642 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
|
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
|
||||||
|
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||||||
|
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||||
|
import { ContractViewModel } from '../../contracts/contract-view-model.builder';
|
||||||
|
import { MinioService } from '../minio/minio.service';
|
||||||
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
|
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||||
|
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
|
||||||
|
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||||
|
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||||
|
import { FilesService } from '../files/files.service';
|
||||||
|
import { SignaturesService } from '../signatures/signatures.service';
|
||||||
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
|
import { ContractsRepository } from './contracts.repository';
|
||||||
|
import { ContractsService } from './contracts.service';
|
||||||
|
import { contractClearanceSettingCode } from './contract-clearance.util';
|
||||||
|
import { Contract } from './entities/contract.entity';
|
||||||
|
import { ContractSignerRole } from './entities/contract-signature.entity';
|
||||||
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dropdown-settings code holding the admin-configured contract validity options
|
||||||
|
* (each option's `value` is a day count). The staff accept dialog reads the same
|
||||||
|
* code, so accept can only use a configured duration. See the seed migration
|
||||||
|
* `SeedContractValidityPeriods`.
|
||||||
|
*/
|
||||||
|
const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods';
|
||||||
|
|
||||||
|
/** Status-machine guard mirroring booking-status.util. */
|
||||||
|
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
||||||
|
if (!allowed.includes(contract.status)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Cannot perform this action on status "${contract.status}". Allowed: ${allowed.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContractTransitionService {
|
||||||
|
private readonly logger = new Logger(ContractTransitionService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly contractsRepository: ContractsRepository,
|
||||||
|
private readonly contractsService: ContractsService,
|
||||||
|
private readonly pricingService: ContractPricingService,
|
||||||
|
private readonly approvalRulesService: ApprovalRulesService,
|
||||||
|
private readonly cargoTypesService: CargoTypesService,
|
||||||
|
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
|
private readonly signaturesService: SignaturesService,
|
||||||
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
|
private readonly documentViewModelBuilder: ContractDocumentViewModelBuilder,
|
||||||
|
private readonly renderer: ContractRendererService,
|
||||||
|
private readonly pdfService: ContractPdfService,
|
||||||
|
private readonly minioService: MinioService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||||
|
async submit(contractId: string): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
assertContractStatus(contract, ['DRAFT', 'CHANGES_REQUESTED']);
|
||||||
|
|
||||||
|
await this.pricingService.generatePrice(contractId);
|
||||||
|
await this.pricingService.freezeRateSnapshots(contractId);
|
||||||
|
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: 'SUBMITTED',
|
||||||
|
} as never);
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirm a price change before submit (mirrors booking confirm-submit). */
|
||||||
|
async confirmSubmit(contractId: string): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
assertContractStatus(contract, ['PRICE_CHANGED_PENDING_CONFIRM']);
|
||||||
|
|
||||||
|
await this.pricingService.generatePrice(contractId);
|
||||||
|
await this.pricingService.freezeRateSnapshots(contractId);
|
||||||
|
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: 'SUBMITTED',
|
||||||
|
} as never);
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Line staff accepts intake: set the validity window from validityDays and
|
||||||
|
* instantiate the approval steps from approval_rules → PENDING_APPROVAL.
|
||||||
|
*/
|
||||||
|
async staffAccept(
|
||||||
|
contractId: string,
|
||||||
|
actorId: string,
|
||||||
|
validityDays: number,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
assertContractStatus(contract, ['SUBMITTED']);
|
||||||
|
|
||||||
|
if (!Number.isInteger(validityDays) || validityDays < 1) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A contract validity (in days) is required to accept this contract.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.assertValidityDaysConfigured(validityDays);
|
||||||
|
|
||||||
|
const validFrom = new Date();
|
||||||
|
const validUntil = new Date(validFrom);
|
||||||
|
validUntil.setDate(validUntil.getDate() + validityDays);
|
||||||
|
|
||||||
|
await this.instantiateApprovalSteps(contract);
|
||||||
|
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: 'PENDING_APPROVAL',
|
||||||
|
approvedByStaffId: actorId,
|
||||||
|
approvedByStaffAt: validFrom,
|
||||||
|
contractValidityDays: validityDays,
|
||||||
|
contractValidFrom: validFrom,
|
||||||
|
contractValidUntil: validUntil,
|
||||||
|
} as never);
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the chosen validity (days) is one of the admin-configured options in
|
||||||
|
* the `contract_validity_periods` dropdown setting. If the setting is missing
|
||||||
|
* or has no options yet, fall back to the DTO range check (already applied) so
|
||||||
|
* acceptance is never hard-blocked before an admin configures the list.
|
||||||
|
*/
|
||||||
|
private async assertValidityDaysConfigured(validityDays: number): Promise<void> {
|
||||||
|
let setting;
|
||||||
|
try {
|
||||||
|
setting = await this.dropdownSettingsService.getByCode(
|
||||||
|
CONTRACT_VALIDITY_PERIODS_CODE,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Not configured yet — keep the flow working with the DTO range only.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = (setting.children ?? [])
|
||||||
|
.map((o) => Number(o.value))
|
||||||
|
.filter((n) => Number.isFinite(n));
|
||||||
|
if (allowed.length === 0) return;
|
||||||
|
|
||||||
|
if (!allowed.includes(validityDays)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Validity ${validityDays} days is not a configured option. Allowed: ${allowed
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.join(', ')} days.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build contract approval steps from the system approval_rules chain (US-06:
|
||||||
|
* container → line staff + director; bulk → directors + CEO). Mirrors the
|
||||||
|
* booking transition's instantiateApprovalSteps but writes contract steps.
|
||||||
|
*/
|
||||||
|
private async instantiateApprovalSteps(contract: Contract): Promise<void> {
|
||||||
|
if ((contract.approvalSteps?.length ?? 0) > 0) return;
|
||||||
|
|
||||||
|
const cargoTypeId =
|
||||||
|
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId ?? null;
|
||||||
|
|
||||||
|
// US-06 routing: bulk always needs director approval; container needs it only
|
||||||
|
// when its cargo type flags it. Resolve the chain via the same approval_rules
|
||||||
|
// source of truth the booking flow uses (no booking row is created here).
|
||||||
|
let requiresDirectorApproval = contract.freightType === 'BULK';
|
||||||
|
if (cargoTypeId) {
|
||||||
|
const cargoType = await this.cargoTypesService.findById(cargoTypeId);
|
||||||
|
if (cargoType?.requiresDirectorApproval) {
|
||||||
|
requiresDirectorApproval = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chain = await this.approvalRulesService.findChain(requiresDirectorApproval);
|
||||||
|
if (chain.length === 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rule of chain) {
|
||||||
|
await this.contractsRepository.createApprovalStep({
|
||||||
|
contractId: contract.id,
|
||||||
|
stepOrder: rule.stepOrder,
|
||||||
|
requiredRole: rule.requiredRole,
|
||||||
|
blocksRole: rule.blocksRole ?? null,
|
||||||
|
status: 'PENDING',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestChanges(
|
||||||
|
contractId: string,
|
||||||
|
note: string,
|
||||||
|
actorId: string,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
assertContractStatus(contract, ['SUBMITTED']);
|
||||||
|
|
||||||
|
await this.contractsRepository.createReviewNote(
|
||||||
|
contractId,
|
||||||
|
note,
|
||||||
|
'CHANGES_REQUESTED',
|
||||||
|
actorId,
|
||||||
|
'STAFF',
|
||||||
|
);
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: 'CHANGES_REQUESTED',
|
||||||
|
} as never);
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reject(contractId: string, reason: string, actorId: string): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']);
|
||||||
|
|
||||||
|
await this.contractsRepository.createReviewNote(
|
||||||
|
contractId,
|
||||||
|
reason,
|
||||||
|
'REJECTION',
|
||||||
|
actorId,
|
||||||
|
'STAFF',
|
||||||
|
);
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: 'REJECTED',
|
||||||
|
} as never);
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Approve one approval step in sequence; → APPROVED when all complete. */
|
||||||
|
async approveStep(
|
||||||
|
contractId: string,
|
||||||
|
stepId: string,
|
||||||
|
actorId: string,
|
||||||
|
requiredRole: string,
|
||||||
|
authUser?: TCurrentUser,
|
||||||
|
): Promise<Contract> {
|
||||||
|
if (authUser) {
|
||||||
|
assertCanApproveBookingStep(authUser, requiredRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
|
||||||
|
|
||||||
|
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
|
||||||
|
if (!step || step.status !== 'PENDING') {
|
||||||
|
throw new BadRequestException('Approval step not found or already actioned');
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = await this.contractsRepository.findNextPendingApprovalStep(contractId);
|
||||||
|
if (!next || next.id !== step.id) {
|
||||||
|
throw new BadRequestException('Approval steps must be completed in order');
|
||||||
|
}
|
||||||
|
if (step.requiredRole !== requiredRole) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Step requires role ${step.requiredRole}, not ${requiredRole}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (step.blocksRole && step.blocksRole === requiredRole) {
|
||||||
|
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
|
||||||
|
|
||||||
|
// Record who acted on this step, but DO NOT advance the contract status here —
|
||||||
|
// approving one step (e.g. LINE_STAFF) must not finalize the chain while later
|
||||||
|
// steps (e.g. DIRECTOR) are still pending. Status only moves to APPROVED once
|
||||||
|
// every step in the chain is complete; until then the contract stays in
|
||||||
|
// PENDING_APPROVAL so the next required role can act.
|
||||||
|
const updates: Record<string, unknown> = {};
|
||||||
|
const now = new Date();
|
||||||
|
if (requiredRole === 'LINE_STAFF') {
|
||||||
|
updates.approvedByStaffId = actorId;
|
||||||
|
updates.approvedByStaffAt = now;
|
||||||
|
} else if (requiredRole === 'DIRECTOR') {
|
||||||
|
updates.signedByDirectorId = actorId;
|
||||||
|
updates.signedByDirectorAt = now;
|
||||||
|
} else if (requiredRole === 'CEO') {
|
||||||
|
updates.signedByCeoId = actorId;
|
||||||
|
updates.signedByCeoAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allDone = await this.contractsRepository.allApprovalStepsComplete(contractId);
|
||||||
|
updates.status = allDone ? 'APPROVED' : 'PENDING_APPROVAL';
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await this.contractsRepository.update(contractId, updates as never);
|
||||||
|
}
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the contract PDF from the Contract aggregate, store it via FilesService,
|
||||||
|
* stamp the template key, and move to CONTRACT_READY. PDF rendering (Puppeteer/
|
||||||
|
* Chromium) is best-effort and must NOT block the contract from becoming ready —
|
||||||
|
* the document is (re)rendered lazily on view/download once Chromium is available.
|
||||||
|
*/
|
||||||
|
async generateContract(contractId: string): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']);
|
||||||
|
|
||||||
|
const { view } = await this.documentViewModelBuilder.build(contractId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.upsertContractPdf(contractId, contract.reference, view);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: 'CONTRACT_READY',
|
||||||
|
contractTemplateKey: view.templateKey,
|
||||||
|
contractGeneratedAt: new Date(),
|
||||||
|
} as never);
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the contract PDF view-model and rendered HTML for portal/backoffice
|
||||||
|
* signing. Sourced entirely from the Contract aggregate (unit-rate schedule, no
|
||||||
|
* totals). Returns the view-model, the rendered HTML and the signature rows.
|
||||||
|
*/
|
||||||
|
async getContractDocumentView(contractId: string): Promise<{
|
||||||
|
view: ContractViewModel;
|
||||||
|
html: string;
|
||||||
|
signatures: ContractViewModel['signatures'];
|
||||||
|
}> {
|
||||||
|
const { view } = await this.documentViewModelBuilder.build(contractId);
|
||||||
|
await this.inlineSignatureImages(view.signatures);
|
||||||
|
const html = this.renderer.render(view);
|
||||||
|
return { view, html, signatures: view.signatures };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild the stored `contract` PDF from the current aggregate (now including
|
||||||
|
* the latest signatures) so the downloaded/viewed file matches the live HTML
|
||||||
|
* view. Best-effort — a Chromium hiccup must never fail the signing
|
||||||
|
* transaction; the doc still re-renders live on /contract/view.
|
||||||
|
*/
|
||||||
|
private async regenerateContractPdf(
|
||||||
|
contractId: string,
|
||||||
|
reference: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { view } = await this.documentViewModelBuilder.build(contractId);
|
||||||
|
await this.upsertContractPdf(contractId, reference, view);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Signed contract PDF regen deferred for ${reference}: ${err}. It re-renders live on view.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the contract PDF and upsert it as the `contract` file on the contract. */
|
||||||
|
private async upsertContractPdf(
|
||||||
|
contractId: string,
|
||||||
|
reference: string,
|
||||||
|
view: ContractViewModel,
|
||||||
|
): Promise<FileRecord> {
|
||||||
|
await this.inlineSignatureImages(view.signatures);
|
||||||
|
const html = this.renderer.render(view);
|
||||||
|
const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
|
||||||
|
const file: Express.Multer.File = {
|
||||||
|
fieldname: 'contract',
|
||||||
|
originalname: `contract-${reference}.pdf`,
|
||||||
|
encoding: '7bit',
|
||||||
|
mimetype: 'application/pdf',
|
||||||
|
size: pdfBuffer.length,
|
||||||
|
buffer: pdfBuffer,
|
||||||
|
stream: Readable.from(pdfBuffer),
|
||||||
|
destination: '',
|
||||||
|
filename: '',
|
||||||
|
path: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.filesService.upsertByCode({
|
||||||
|
resourceId: contractId,
|
||||||
|
resource: 'contracts',
|
||||||
|
code: 'contract',
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace MinIO signature URLs with inline data URIs so they render in the PDF. */
|
||||||
|
private async inlineSignatureImages(
|
||||||
|
signatures: Array<{ signatureImageUrl?: string | null }>,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const sig of signatures) {
|
||||||
|
if (!sig.signatureImageUrl) continue;
|
||||||
|
try {
|
||||||
|
if (sig.signatureImageUrl.startsWith('data:')) continue;
|
||||||
|
const objectName = this.minioService.getObjectNameFromUrl(
|
||||||
|
sig.signatureImageUrl,
|
||||||
|
);
|
||||||
|
const stream = await this.minioService.getFileStream(objectName);
|
||||||
|
const buffer = await this.streamToBuffer(stream);
|
||||||
|
sig.signatureImageUrl = `data:image/png;base64,${buffer.toString('base64')}`;
|
||||||
|
} catch {
|
||||||
|
/* keep original url */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
stream.on('data', (chunk: Buffer | string) => {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
});
|
||||||
|
stream.on('error', reject);
|
||||||
|
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a digital signature row (mirrors booking-contract.service). */
|
||||||
|
private async applySignature(
|
||||||
|
contract: Contract,
|
||||||
|
dto: SignContractDto,
|
||||||
|
options: { signerUserId?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const role = dto.role as ContractSignerRole;
|
||||||
|
|
||||||
|
// Resolve the signature image. The client may send a freshly-drawn image, or
|
||||||
|
// omit it to reuse the signer's saved profile signature. Fall back to the
|
||||||
|
// saved one whenever no image is supplied.
|
||||||
|
let imageBase64 = dto.signatureImageBase64;
|
||||||
|
let signerDisplayName = dto.signerDisplayName;
|
||||||
|
if (!imageBase64 && options.signerUserId) {
|
||||||
|
const saved = await this.signaturesService.getForUser(options.signerUserId);
|
||||||
|
if (saved?.signatureImageUrl) {
|
||||||
|
imageBase64 = saved.signatureImageUrl;
|
||||||
|
signerDisplayName = signerDisplayName || saved.signerDisplayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!imageBase64) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'No signature provided and no saved signature found on the profile.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = imageBase64.includes(',')
|
||||||
|
? imageBase64.split(',')[1]!
|
||||||
|
: imageBase64;
|
||||||
|
const buffer = Buffer.from(raw, 'base64');
|
||||||
|
const sigFile: Express.Multer.File = {
|
||||||
|
fieldname: `signature_${role.toLowerCase()}`,
|
||||||
|
originalname: `signature-${role.toLowerCase()}-${contract.reference}.png`,
|
||||||
|
encoding: '7bit',
|
||||||
|
mimetype: 'image/png',
|
||||||
|
size: buffer.length,
|
||||||
|
buffer,
|
||||||
|
stream: Readable.from(buffer),
|
||||||
|
destination: '',
|
||||||
|
filename: '',
|
||||||
|
path: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileRecord = await this.filesService.upsertByCode({
|
||||||
|
resourceId: contract.id,
|
||||||
|
resource: 'contracts',
|
||||||
|
code: `signature_${role.toLowerCase()}`,
|
||||||
|
file: sigFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.contractsRepository.saveSignature({
|
||||||
|
contractId: contract.id,
|
||||||
|
role,
|
||||||
|
signerDisplayName,
|
||||||
|
signedAt: new Date(),
|
||||||
|
signatureFileId: fileRecord.id,
|
||||||
|
consentText: dto.consentText ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only (re)save the reusable profile signature when the signer drew a NEW
|
||||||
|
// image. Reusing the saved signature must not rewrite it with itself.
|
||||||
|
if (options.signerUserId && dto.signatureImageBase64) {
|
||||||
|
try {
|
||||||
|
await this.signaturesService.upsertForUser({
|
||||||
|
userId: options.signerUserId,
|
||||||
|
signerDisplayName,
|
||||||
|
signatureImageBase64: dto.signatureImageBase64,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Could not save reusable signature for user ${options.signerUserId}: ${err}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
|
||||||
|
async sign(
|
||||||
|
contractId: string,
|
||||||
|
dto: SignContractDto,
|
||||||
|
options: { signerUserId?: string },
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
|
||||||
|
if (dto.role === 'CUSTOMER') {
|
||||||
|
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||||
|
const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER');
|
||||||
|
if (existing) {
|
||||||
|
throw new BadRequestException('Customer has already signed this contract');
|
||||||
|
}
|
||||||
|
await this.applySignature(contract, dto, options);
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: 'SIGNED_CUSTOMER',
|
||||||
|
customerSignedAt: new Date(),
|
||||||
|
} as never);
|
||||||
|
await this.regenerateContractPdf(contractId, contract.reference);
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.counterSign(contractId, dto, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff/Director/CEO counter-sign → branch on the execution path. A customs
|
||||||
|
* border (IMPORT/EXPORT) always requires a clearance gate before any shipment;
|
||||||
|
* who reviews differs:
|
||||||
|
* - Path B (customs bundled): customer uploads GL-input docs, GL reviews, GL
|
||||||
|
* uploads output, then GL creates the booking.
|
||||||
|
* - Path A (no customs): the customer clears the cargo himself and uploads his
|
||||||
|
* own clearance proof; Operations reviews it; then the CUSTOMER books.
|
||||||
|
* DOMESTIC/intercity has no border, so it goes straight to executed.
|
||||||
|
*/
|
||||||
|
async counterSign(
|
||||||
|
contractId: string,
|
||||||
|
dto: SignContractDto,
|
||||||
|
options: { signerUserId?: string },
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
assertContractStatus(contract, ['SIGNED_CUSTOMER']);
|
||||||
|
|
||||||
|
await this.applySignature(contract, dto, options);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const updates: Record<string, unknown> = {
|
||||||
|
fullyExecutedAt: now,
|
||||||
|
lockedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
// A clearance gate applies whenever a clearance doc set resolves — Path B
|
||||||
|
// (customs) or Path A self-clearance (IMPORT/EXPORT without customs). DOMESTIC
|
||||||
|
// resolves to null on both paths and skips straight to executed.
|
||||||
|
const clearanceCode = contractClearanceSettingCode(
|
||||||
|
contract.tradeDirection,
|
||||||
|
contract.freightType,
|
||||||
|
contract.customsClearingEnabled ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// GENERAL + customs (Path B) runs clearance PER BOOKING, not at the contract
|
||||||
|
// level: there is no contract clearance cycle. The contract just becomes
|
||||||
|
// active; the customer then files shipment requests and GL books + clears
|
||||||
|
// each one. ONE_TIME customs and Path A self-clearance keep the contract
|
||||||
|
// cycle below.
|
||||||
|
const isGeneralCustoms =
|
||||||
|
contract.contractKind === 'GENERAL' &&
|
||||||
|
Boolean(contract.customsClearingEnabled);
|
||||||
|
|
||||||
|
if (clearanceCode && !isGeneralCustoms) {
|
||||||
|
// Open a clearance cycle, seed the pre-booking milestones, and route the
|
||||||
|
// customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the
|
||||||
|
// distinction is enforced at the review/finalize endpoints, not here.
|
||||||
|
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
|
||||||
|
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
|
||||||
|
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
|
||||||
|
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
||||||
|
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||||
|
updates.clearanceCycleNumber = cycleNumber;
|
||||||
|
} else {
|
||||||
|
// No contract-level clearance gate — DOMESTIC, or GENERAL+customs (which
|
||||||
|
// clears per booking). Ready for shipment requests / direct booking.
|
||||||
|
updates.status =
|
||||||
|
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
|
||||||
|
updates.clearanceStatus = 'NOT_APPLICABLE';
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.contractsRepository.update(contractId, updates as never);
|
||||||
|
await this.regenerateContractPdf(contractId, contract.reference);
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */
|
||||||
|
async renew(contractId: string, userId?: string): Promise<Contract> {
|
||||||
|
const source = await this.contractsService.findById(contractId);
|
||||||
|
|
||||||
|
const reference = await this.generateRenewalReference();
|
||||||
|
const renewal = await this.contractsRepository.create({
|
||||||
|
reference,
|
||||||
|
companyId: source.companyId,
|
||||||
|
companyProfileId: source.companyProfileId,
|
||||||
|
isGovernment: source.isGovernment,
|
||||||
|
governmentInstitution: source.governmentInstitution,
|
||||||
|
contractKind: source.contractKind,
|
||||||
|
renewalOfId: source.id,
|
||||||
|
tradeDirection: source.tradeDirection,
|
||||||
|
freightType: source.freightType,
|
||||||
|
serviceTypeId: source.serviceTypeId,
|
||||||
|
paymentCurrency: source.paymentCurrency,
|
||||||
|
customsClearingEnabled: source.customsClearingEnabled,
|
||||||
|
customsClearingAgent: source.customsClearingAgent,
|
||||||
|
equipmentReturn: source.equipmentReturn,
|
||||||
|
firstMilePickupAddress: source.firstMilePickupAddress,
|
||||||
|
firstMilePickupLat: source.firstMilePickupLat,
|
||||||
|
firstMilePickupLng: source.firstMilePickupLng,
|
||||||
|
lastMileDeliveryAddress: source.lastMileDeliveryAddress,
|
||||||
|
lastMileDeliveryLat: source.lastMileDeliveryLat,
|
||||||
|
lastMileDeliveryLng: source.lastMileDeliveryLng,
|
||||||
|
isHazardous: source.isHazardous,
|
||||||
|
isReefer: source.isReefer,
|
||||||
|
contractType: source.contractType,
|
||||||
|
versionNumber: (source.versionNumber ?? 1) + 1,
|
||||||
|
status: 'RENEWAL_DRAFT',
|
||||||
|
clearanceStatus: 'NOT_APPLICABLE',
|
||||||
|
clearanceCycleNumber: 0,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
void userId;
|
||||||
|
return this.contractsService.findById(renewal.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateRenewalReference(): Promise<string> {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const count = await this.contractsRepository.countByYear(year);
|
||||||
|
return `CTR-${year}-${String(count + 1).padStart(5, '0')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,729 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UnauthorizedException,
|
||||||
|
UploadedFiles,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CurrentUser } from '@edr/api-common';
|
||||||
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiConsumes,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { BookingStaff } from '../../common/booking-guards';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
|
import {
|
||||||
|
assertFreightPermission,
|
||||||
|
hasFreightPermission,
|
||||||
|
} from '../../common/freight-permission.util';
|
||||||
|
import {
|
||||||
|
type AuthUserPayload,
|
||||||
|
resolveAuthUserId,
|
||||||
|
} from '../../common/resolve-auth-user-id';
|
||||||
|
|
||||||
|
import { ContractsService } from './contracts.service';
|
||||||
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
|
import { ContractTransitionService } from './contract-transition.service';
|
||||||
|
import { ContractClearanceService } from './contract-clearance.service';
|
||||||
|
import { ContractBookingService } from './contract-booking.service';
|
||||||
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
|
import { GlOperationsService } from './gl-operations.service';
|
||||||
|
import { BookingRequestService } from './booking-request.service';
|
||||||
|
import { SignaturesService } from '../signatures/signatures.service';
|
||||||
|
import { CreateContractDto } from './dto/create-contract.dto';
|
||||||
|
import { UpdateContractDto } from './dto/update-contract.dto';
|
||||||
|
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||||
|
import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
|
||||||
|
import { AcceptContractDto } from './dto/accept-contract.dto';
|
||||||
|
import {
|
||||||
|
ApproveStepDto,
|
||||||
|
RejectContractDto,
|
||||||
|
RequestChangesDto,
|
||||||
|
} from './dto/approve-step.dto';
|
||||||
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
|
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
||||||
|
import { RenewContractDto } from './dto/renew-contract.dto';
|
||||||
|
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||||
|
import {
|
||||||
|
CreateBookingRequestDto,
|
||||||
|
ReviewBookingRequestDto,
|
||||||
|
} from './dto/create-booking-request.dto';
|
||||||
|
import {
|
||||||
|
AdviseDutyDto,
|
||||||
|
AssignRiskDto,
|
||||||
|
AssignStationDto,
|
||||||
|
CompleteMilestoneDto,
|
||||||
|
ReportIncidentDto,
|
||||||
|
} from './dto/gl-operations.dto';
|
||||||
|
|
||||||
|
@ApiTags('contracts')
|
||||||
|
@Controller('contracts')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class ContractsController {
|
||||||
|
constructor(
|
||||||
|
private readonly contractsService: ContractsService,
|
||||||
|
private readonly pricingService: ContractPricingService,
|
||||||
|
private readonly transitionService: ContractTransitionService,
|
||||||
|
private readonly clearanceService: ContractClearanceService,
|
||||||
|
private readonly contractBookingService: ContractBookingService,
|
||||||
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
|
private readonly glOperationsService: GlOperationsService,
|
||||||
|
private readonly bookingRequestService: BookingRequestService,
|
||||||
|
private readonly signaturesService: SignaturesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ── Shipment / booking requests (GENERAL + customs, Path B) ───────────────
|
||||||
|
// STATIC routes declared before any `:id`-param route so Nest matches them
|
||||||
|
// (mirrors the clearance/queue ordering below).
|
||||||
|
|
||||||
|
@Get('booking-requests/queue')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
||||||
|
@ApiOperation({ summary: 'GL queue: pending shipment requests across contracts' })
|
||||||
|
bookingRequestQueue() {
|
||||||
|
return this.bookingRequestService.queue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('booking-requests/:reqId')
|
||||||
|
@ApiOperation({ summary: 'A single shipment request' })
|
||||||
|
getBookingRequest(@Param('reqId', ParseUUIDPipe) reqId: string) {
|
||||||
|
return this.bookingRequestService.findOne(reqId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('booking-requests/:reqId/accept')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
||||||
|
@ApiOperation({ summary: 'GL marks a shipment request accepted + links the created booking' })
|
||||||
|
acceptBookingRequest(
|
||||||
|
@Param('reqId', ParseUUIDPipe) reqId: string,
|
||||||
|
@Body() body: { bookingId: string },
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.bookingRequestService.accept(
|
||||||
|
reqId,
|
||||||
|
body.bookingId,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('booking-requests/:reqId/reject')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
||||||
|
@ApiOperation({ summary: 'GL rejects a shipment request' })
|
||||||
|
rejectBookingRequest(
|
||||||
|
@Param('reqId', ParseUUIDPipe) reqId: string,
|
||||||
|
@Body() dto: ReviewBookingRequestDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.bookingRequestService.reject(reqId, dto.note, resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('booking-requests/:reqId/cancel')
|
||||||
|
@ApiOperation({ summary: 'Customer cancels their own pending shipment request' })
|
||||||
|
cancelBookingRequest(
|
||||||
|
@Param('reqId', ParseUUIDPipe) reqId: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.bookingRequestService.cancel(reqId, resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/booking-requests')
|
||||||
|
@ApiOperation({ summary: 'Customer submits a shipment request on a GENERAL customs contract' })
|
||||||
|
submitBookingRequest(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: CreateBookingRequestDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.bookingRequestService.submit(id, dto, resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/booking-requests')
|
||||||
|
@ApiOperation({ summary: 'List the shipment requests on a contract' })
|
||||||
|
listBookingRequests(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.bookingRequestService.listForContract(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'Create a new contract (DRAFT) with routes + cargo scope' })
|
||||||
|
@ApiBody({ type: CreateContractDto })
|
||||||
|
async create(
|
||||||
|
@Body() dto: CreateContractDto,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
if (dto.isGovernment) {
|
||||||
|
assertFreightPermission(user, FREIGHT_PERMS.contracts.staffAccept);
|
||||||
|
}
|
||||||
|
return this.contractsService.create(dto, files ?? [], user?.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List contracts (paginated)' })
|
||||||
|
async findAll(
|
||||||
|
@Query() filter: FilterContractDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
// Staff see every contract; customers are force-scoped to their own company.
|
||||||
|
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
|
return this.contractsService.findAll(filter);
|
||||||
|
}
|
||||||
|
const userId = user?.id;
|
||||||
|
if (!userId) throw new UnauthorizedException('Authentication required');
|
||||||
|
const companyId = await this.contractsService.resolveCustomerCompanyId(userId);
|
||||||
|
if (!companyId) {
|
||||||
|
const page = filter.page ?? 1;
|
||||||
|
const pageSize = filter.pageSize ?? 20;
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
meta: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
hasNextPage: false,
|
||||||
|
hasPreviousPage: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return this.contractsService.findAll(filter, companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('my')
|
||||||
|
@ApiOperation({ summary: "List the current customer's contracts" })
|
||||||
|
async findMy(
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
@Query() filter: FilterContractDto,
|
||||||
|
) {
|
||||||
|
const userId = resolveAuthUserId(user);
|
||||||
|
const companyId = await this.contractsService.resolveCustomerCompanyId(userId);
|
||||||
|
if (!companyId) {
|
||||||
|
const page = filter.page ?? 1;
|
||||||
|
const pageSize = filter.pageSize ?? 20;
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
meta: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
hasNextPage: false,
|
||||||
|
hasPreviousPage: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return this.contractsService.findAll(filter, companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('list-summary')
|
||||||
|
@ApiOperation({ summary: 'Contract list metrics and status counts (backoffice)' })
|
||||||
|
@ApiOkResponse({ type: ContractListSummaryDto })
|
||||||
|
findListSummary(@Query() filter: FilterContractDto) {
|
||||||
|
return this.contractsService.getListSummary(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('clearance/queue')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
|
||||||
|
@ApiOperation({ summary: 'GL ET queue: contracts awaiting pre-booking document review' })
|
||||||
|
clearanceQueue(@Query() filter: FilterContractDto) {
|
||||||
|
return this.clearanceService.queue(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get contract by ID (routes, cargo scope, unit rates)' })
|
||||||
|
async findOne(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
const contract = await this.contractsService.findById(id);
|
||||||
|
if (
|
||||||
|
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||||
|
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) &&
|
||||||
|
!hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments)
|
||||||
|
) {
|
||||||
|
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||||
|
}
|
||||||
|
return contract;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Update contract',
|
||||||
|
description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.',
|
||||||
|
})
|
||||||
|
@ApiBody({ type: UpdateContractDto })
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateContractDto,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
) {
|
||||||
|
return this.contractsService.update(id, dto, files ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(204)
|
||||||
|
@ApiOperation({ summary: 'Soft-delete DRAFT contract' })
|
||||||
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.contractsService.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/documents')
|
||||||
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'Upload intake documents for a contract (DRAFT only)' })
|
||||||
|
uploadDocuments(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
) {
|
||||||
|
return this.contractsService.uploadDocuments(id, files ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/generate-price')
|
||||||
|
@ApiOperation({ summary: 'Generate unit-rate breakdown (no totals at contract phase)' })
|
||||||
|
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.pricingService.generatePrice(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/submit')
|
||||||
|
@ApiOperation({ summary: 'Customer submit contract (freezes contract_rate_snapshots)' })
|
||||||
|
submit(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.transitionService.submit(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/confirm-submit')
|
||||||
|
@ApiOperation({ summary: 'Confirm submit after a price change' })
|
||||||
|
confirmSubmit(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.transitionService.confirmSubmit(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/staff/accept')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
||||||
|
@ApiOperation({ summary: 'Staff accept → set validity window + start approval chain' })
|
||||||
|
staffAccept(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: AcceptContractDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.transitionService.staffAccept(
|
||||||
|
id,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
dto.validityDays,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/staff/request-changes')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.requestChanges)
|
||||||
|
@ApiOperation({ summary: 'Staff return contract for customer updates' })
|
||||||
|
requestChanges(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: RequestChangesDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.transitionService.requestChanges(
|
||||||
|
id,
|
||||||
|
dto.note,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/staff/reject')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.reject)
|
||||||
|
@ApiOperation({ summary: 'Staff reject contract' })
|
||||||
|
reject(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: RejectContractDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.transitionService.reject(id, dto.reason, resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/approval-steps/:stepId/approve')
|
||||||
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.contracts.approveLineStaff,
|
||||||
|
FREIGHT_PERMS.contracts.approveDirector,
|
||||||
|
FREIGHT_PERMS.contracts.approveCeo,
|
||||||
|
])
|
||||||
|
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
||||||
|
approveStep(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('stepId', ParseUUIDPipe) stepId: string,
|
||||||
|
@Body() dto: ApproveStepDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.transitionService.approveStep(
|
||||||
|
id,
|
||||||
|
stepId,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
dto.requiredRole,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/contract/generate')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.generateContract)
|
||||||
|
@ApiOperation({ summary: 'Generate contract document → CONTRACT_READY' })
|
||||||
|
generateContract(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.transitionService.generateContract(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/contract/view')
|
||||||
|
@ApiOperation({ summary: 'Contract PDF view-model + rendered HTML for signing' })
|
||||||
|
async getContractView(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
const contract = await this.contractsService.findById(id);
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
|
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||||
|
}
|
||||||
|
const { view, html, signatures } =
|
||||||
|
await this.transitionService.getContractDocumentView(id);
|
||||||
|
// The signer's reusable saved signature (if any) so the sign UI can offer
|
||||||
|
// "Approve & sign" with the stored image instead of forcing a fresh draw.
|
||||||
|
const signerId = resolveAuthUserId(user);
|
||||||
|
const savedSignature = signerId
|
||||||
|
? await this.signaturesService.getForUser(signerId)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
contractId: view.bookingId,
|
||||||
|
reference: view.reference,
|
||||||
|
status: view.status,
|
||||||
|
templateKey: view.templateKey,
|
||||||
|
title: view.template.title,
|
||||||
|
html,
|
||||||
|
view,
|
||||||
|
canSignCustomer: view.canSignCustomer,
|
||||||
|
canSignStaff: view.canSignStaff,
|
||||||
|
hasContractDocument: view.hasContractDocument,
|
||||||
|
signatures,
|
||||||
|
savedSignature,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/contract/sign')
|
||||||
|
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
||||||
|
signContract(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: SignContractDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.transitionService.sign(id, dto, {
|
||||||
|
signerUserId: user?.id ?? user?.sub,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/renew')
|
||||||
|
@ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' })
|
||||||
|
renew(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() _dto: RenewContractDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.transitionService.renew(id, user?.id ?? user?.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pre-booking clearance (Path B, doc §15.2.1) ────────────────────────────
|
||||||
|
|
||||||
|
@Get(':id/clearance')
|
||||||
|
@ApiOperation({ summary: 'Pre-booking clearance document grid on the contract' })
|
||||||
|
getClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.clearanceService.getClearanceView(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/documents')
|
||||||
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)' })
|
||||||
|
uploadClearanceDocuments(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
) {
|
||||||
|
return this.clearanceService.uploadDocuments(id, files ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/review')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
|
||||||
|
@ApiOperation({ summary: 'GL ET reviews a clearance document (Approve | Query)' })
|
||||||
|
reviewClearanceDocument(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: ReviewClearanceDocumentDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceService.reviewDocument(
|
||||||
|
id,
|
||||||
|
dto.fileKey,
|
||||||
|
dto.status,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
dto.note,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/output-documents')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
|
||||||
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…) pre-booking' })
|
||||||
|
uploadOutputDocuments(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
) {
|
||||||
|
return this.clearanceService.uploadOutputDocuments(id, files ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/finalize')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.finalizeClearance)
|
||||||
|
@ApiOperation({ summary: 'GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING' })
|
||||||
|
finalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.clearanceService.finalize(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
|
||||||
|
|
||||||
|
@Get('clearance/ops-queue')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Operations queue: self-clearance (non-customs) contracts awaiting review',
|
||||||
|
})
|
||||||
|
opsClearanceQueue(@Query() filter: FilterContractDto) {
|
||||||
|
return this.clearanceService.opsQueue(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/ops-review')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Operations reviews a customer self-clearance document (Approve | Query)',
|
||||||
|
})
|
||||||
|
opsReviewClearanceDocument(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: ReviewClearanceDocumentDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceService.opsReviewDocument(
|
||||||
|
id,
|
||||||
|
dto.fileKey,
|
||||||
|
dto.status,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
dto.note,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/ops-finalize')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Operations finalizes self-clearance → customer may create the booking',
|
||||||
|
})
|
||||||
|
opsFinalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.clearanceService.opsFinalize(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('clearance/history')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.finalizeClearance)
|
||||||
|
@ApiOperation({ summary: 'GL ET clearance history: contracts that completed Path B clearance' })
|
||||||
|
clearanceHistory(@Query() filter: FilterContractDto) {
|
||||||
|
return this.clearanceService.history(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('clearance/ops-history')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
|
||||||
|
@ApiOperation({ summary: 'Operations clearance history: contracts that completed Path A self-clearance' })
|
||||||
|
opsClearanceHistory(@Query() filter: FilterContractDto) {
|
||||||
|
return this.clearanceService.opsHistory(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Booking under contract (Path A customer / Path B GL ET) ────────────────
|
||||||
|
|
||||||
|
@Post(':id/bookings')
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).',
|
||||||
|
})
|
||||||
|
createBooking(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: CreateBookingUnderContractDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
// The service decides the execution path from the contract:
|
||||||
|
// Path A (customs disabled) → customer/staff create; status checks apply.
|
||||||
|
// Path B (customs enabled) → GL Ethiopia only, once clearance is ready.
|
||||||
|
return this.contractBookingService.createUnderContract(
|
||||||
|
id,
|
||||||
|
dto,
|
||||||
|
{ id: user?.id ?? user?.sub },
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/capacity')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)',
|
||||||
|
})
|
||||||
|
async capacity(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
const contract = await this.contractsService.findById(id);
|
||||||
|
return this.contractBookingService.computeCapacity(contract);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Clearance milestones (doc §11.3, §12.2) ────────────────────────────────
|
||||||
|
|
||||||
|
@Get(':id/milestones')
|
||||||
|
@ApiOperation({ summary: 'Pre-booking clearance milestones for a contract cycle' })
|
||||||
|
listContractMilestones(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.milestoneService.listForContract(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('bookings/:bookingId/milestones')
|
||||||
|
@ApiOperation({ summary: 'Post-booking clearance milestones for a shipment booking' })
|
||||||
|
listBookingMilestones(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
|
return this.milestoneService.listForBooking(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bookings/:bookingId/milestones/:code/complete')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||||
|
@ApiOperation({ summary: 'GL / Ops / Terminal marks a post-booking milestone complete' })
|
||||||
|
completeBookingMilestone(
|
||||||
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
@Param('code') code: string,
|
||||||
|
@Body() body: { note?: string },
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.milestoneService.completeForBooking(
|
||||||
|
bookingId,
|
||||||
|
code,
|
||||||
|
user?.id ?? user?.sub,
|
||||||
|
body?.note,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/milestones/:code/complete')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
|
||||||
|
@ApiOperation({ summary: 'GL marks a pre-booking (contract) milestone complete' })
|
||||||
|
completeContractMilestone(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('code') code: string,
|
||||||
|
@Body() body: CompleteMilestoneDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.milestoneService.completeForContract(
|
||||||
|
id,
|
||||||
|
code,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
body?.note,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GL operational actions on a booking (doc §11–§13) ──────────────────────
|
||||||
|
|
||||||
|
@Post('bookings/:bookingId/risk')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||||
|
@ApiOperation({ summary: 'GL ET assigns a customs risk level (GREEN/YELLOW/RED)' })
|
||||||
|
assignRisk(
|
||||||
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
@Body() dto: AssignRiskDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.milestoneService.assignRisk(
|
||||||
|
bookingId,
|
||||||
|
dto.riskLevel,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
dto.note,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bookings/:bookingId/duty')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||||
|
@ApiOperation({ summary: 'GL ET advises duty & tax amount + declaration serial' })
|
||||||
|
adviseDuty(
|
||||||
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
@Body() dto: AdviseDutyDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.milestoneService.adviseDuty(
|
||||||
|
bookingId,
|
||||||
|
{ amount: dto.amount, currency: dto.currency, declarationSerial: dto.declarationSerial },
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
dto.note,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bookings/:bookingId/station-assign')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||||
|
@ApiOperation({ summary: 'GL station manager routes the shipment + binds staff' })
|
||||||
|
assignStation(
|
||||||
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
@Body() dto: AssignStationDto,
|
||||||
|
) {
|
||||||
|
return this.glOperationsService.assignStation(bookingId, {
|
||||||
|
stationYardId: dto.stationYardId,
|
||||||
|
staffId: dto.staffId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bookings/:bookingId/documents')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
||||||
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'GL uploads post-booking operational documents (DO/RO/T1/…)',
|
||||||
|
})
|
||||||
|
uploadGlDocuments(
|
||||||
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
) {
|
||||||
|
return this.glOperationsService.uploadDocuments(bookingId, files ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bookings/:bookingId/duty-slip')
|
||||||
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' })
|
||||||
|
uploadDutySlip(
|
||||||
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
) {
|
||||||
|
return this.glOperationsService.uploadDutySlip(bookingId, (files ?? [])[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('bookings/:bookingId/incidents')
|
||||||
|
@ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' })
|
||||||
|
listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
|
return this.glOperationsService.listIncidents(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bookings/:bookingId/incidents')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||||
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'GL DJ logs a cargo exception with photo evidence' })
|
||||||
|
reportIncident(
|
||||||
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
@Body() dto: ReportIncidentDto,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.glOperationsService.reportIncident(
|
||||||
|
bookingId,
|
||||||
|
{
|
||||||
|
incidentType: dto.incidentType,
|
||||||
|
description: dto.description,
|
||||||
|
files: files ?? [],
|
||||||
|
},
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
110
apps/edr-freight-api/src/modules/contracts/contracts.module.ts
Normal file
110
apps/edr-freight-api/src/modules/contracts/contracts.module.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||||
|
|
||||||
|
import { CompaniesModule } from '../companies/companies.module';
|
||||||
|
import { FilesModule } from '../files/files.module';
|
||||||
|
import { MinioModule } from '../minio/minio.module';
|
||||||
|
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||||
|
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
||||||
|
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
|
||||||
|
import { SignaturesModule } from '../signatures/signatures.module';
|
||||||
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
|
|
||||||
|
import { ContractsController } from './contracts.controller';
|
||||||
|
import { ContractsService } from './contracts.service';
|
||||||
|
import { ContractsRepository } from './contracts.repository';
|
||||||
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
|
import { ContractTransitionService } from './contract-transition.service';
|
||||||
|
import { ContractClearanceService } from './contract-clearance.service';
|
||||||
|
import { ContractBookingService } from './contract-booking.service';
|
||||||
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
|
import { GlOperationsService } from './gl-operations.service';
|
||||||
|
import { BookingRequestService } from './booking-request.service';
|
||||||
|
import { BookingRequestRepository } from './booking-request.repository';
|
||||||
|
|
||||||
|
import { Contract } from './entities/contract.entity';
|
||||||
|
import { ContractRoute } from './entities/contract-route.entity';
|
||||||
|
import { ContractCargoScope } from './entities/contract-cargo-scope.entity';
|
||||||
|
import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
|
||||||
|
import { ContractSignature } from './entities/contract-signature.entity';
|
||||||
|
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
|
||||||
|
import { ContractReviewNote } from './entities/contract-review-note.entity';
|
||||||
|
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||||
|
import { ContractDocumentReview } from './entities/contract-document-review.entity';
|
||||||
|
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||||
|
import { ClearanceIncident } from './entities/clearance-incident.entity';
|
||||||
|
import { BookingRequest } from './entities/booking-request.entity';
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||||
|
|
||||||
|
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||||
|
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||||||
|
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||||
|
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
Contract,
|
||||||
|
ContractRoute,
|
||||||
|
ContractCargoScope,
|
||||||
|
ContractRateSnapshot,
|
||||||
|
ContractSignature,
|
||||||
|
ContractApprovalStep,
|
||||||
|
ContractReviewNote,
|
||||||
|
ContractClearanceCycle,
|
||||||
|
ContractDocumentReview,
|
||||||
|
ClearanceMilestone,
|
||||||
|
ClearanceIncident,
|
||||||
|
BookingRequest,
|
||||||
|
Booking,
|
||||||
|
BookingContainerUnit,
|
||||||
|
]),
|
||||||
|
RuleEngineModule,
|
||||||
|
FileUploadSettingsModule,
|
||||||
|
DropdownSettingsModule,
|
||||||
|
FilesModule,
|
||||||
|
MinioModule,
|
||||||
|
SignaturesModule,
|
||||||
|
CompaniesModule,
|
||||||
|
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
||||||
|
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
||||||
|
BookingsModule,
|
||||||
|
ExchangeModule.forRootAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||||
|
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [ContractsController],
|
||||||
|
providers: [
|
||||||
|
ContractsService,
|
||||||
|
ContractsRepository,
|
||||||
|
ContractPricingService,
|
||||||
|
ContractTransitionService,
|
||||||
|
ContractClearanceService,
|
||||||
|
ContractBookingService,
|
||||||
|
ClearanceMilestoneService,
|
||||||
|
GlOperationsService,
|
||||||
|
BookingRequestService,
|
||||||
|
BookingRequestRepository,
|
||||||
|
// Contract PDF providers (template resolution + render + PDF) — stateless
|
||||||
|
// helpers reused from src/contracts/.
|
||||||
|
ContractTemplateResolver,
|
||||||
|
ContractRendererService,
|
||||||
|
ContractPdfService,
|
||||||
|
ContractDocumentViewModelBuilder,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
ContractsService,
|
||||||
|
ContractsRepository,
|
||||||
|
ContractPricingService,
|
||||||
|
ContractTransitionService,
|
||||||
|
ContractClearanceService,
|
||||||
|
ContractBookingService,
|
||||||
|
ClearanceMilestoneService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ContractsModule {}
|
||||||
@@ -0,0 +1,555 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
|
import { Contract } from './entities/contract.entity';
|
||||||
|
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
|
||||||
|
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||||
|
import {
|
||||||
|
ContractDocReviewStatus,
|
||||||
|
ContractDocumentReview,
|
||||||
|
} from './entities/contract-document-review.entity';
|
||||||
|
import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
|
||||||
|
import { ContractReviewNote, ContractReviewNoteType } from './entities/contract-review-note.entity';
|
||||||
|
import { ContractSignature, ContractSignerRole } from './entities/contract-signature.entity';
|
||||||
|
|
||||||
|
export interface ContractListFilterOptions {
|
||||||
|
statuses?: string[];
|
||||||
|
status?: string;
|
||||||
|
companyId?: string;
|
||||||
|
companyProfileId?: string;
|
||||||
|
contractKind?: string;
|
||||||
|
serviceTypeId?: string;
|
||||||
|
freightType?: string;
|
||||||
|
tradeDirection?: string;
|
||||||
|
paymentCurrency?: string;
|
||||||
|
customsClearingEnabled?: boolean;
|
||||||
|
createdFrom?: string;
|
||||||
|
createdTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContractsRepository extends BaseRepository<Contract> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Contract)
|
||||||
|
repository: Repository<Contract>,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find a contract by its human-readable reference number. */
|
||||||
|
findByReference(reference: string): Promise<Contract | null> {
|
||||||
|
return this.repository.findOne({ where: { reference } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count contracts created in a specific year. */
|
||||||
|
async countByYear(year: number): Promise<number> {
|
||||||
|
const startDate = new Date(year, 0, 1);
|
||||||
|
const endDate = new Date(year + 1, 0, 1);
|
||||||
|
|
||||||
|
return this.repository
|
||||||
|
.createQueryBuilder('contract')
|
||||||
|
.where('contract.created_at >= :startDate', { startDate })
|
||||||
|
.andWhere('contract.created_at < :endDate', { endDate })
|
||||||
|
.getCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find a contract by ID with all child collections, service type, company and files. */
|
||||||
|
async findByIdWithRelations(id: string): Promise<Contract | null> {
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
const contract = await this.repository
|
||||||
|
.createQueryBuilder('contract')
|
||||||
|
.leftJoinAndSelect('contract.routes', 'routes')
|
||||||
|
.leftJoinAndSelect('routes.originYard', 'routeOrigin')
|
||||||
|
.leftJoinAndSelect('routes.destinationYard', 'routeDestination')
|
||||||
|
.leftJoinAndSelect('contract.cargoScope', 'cargoScope')
|
||||||
|
.leftJoinAndSelect('cargoScope.cargoType', 'cargoType')
|
||||||
|
.leftJoinAndSelect('contract.rateSnapshots', 'rateSnapshots')
|
||||||
|
.leftJoinAndSelect('contract.signatures', 'signatures')
|
||||||
|
.leftJoinAndSelect('signatures.signatureFile', 'signatureFile')
|
||||||
|
.leftJoinAndSelect('contract.approvalSteps', 'approvalSteps')
|
||||||
|
.leftJoinAndSelect('contract.serviceType', 'serviceType')
|
||||||
|
.leftJoinAndSelect('contract.company', 'company')
|
||||||
|
.where('contract.id = :id', { id })
|
||||||
|
.leftJoinAndMapMany(
|
||||||
|
'contract.files',
|
||||||
|
FileRecord,
|
||||||
|
'file',
|
||||||
|
"file.resource_id = contract.id AND file.resource = 'contracts'",
|
||||||
|
)
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
return contract ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paginated list with optional multi-status filter (API tab queues). */
|
||||||
|
async findAllPaginated(
|
||||||
|
options: ContractListFilterOptions & {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
},
|
||||||
|
): Promise<{
|
||||||
|
items: Contract[];
|
||||||
|
total: number;
|
||||||
|
meta: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPreviousPage: boolean;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const page = options.page;
|
||||||
|
const pageSize = options.pageSize;
|
||||||
|
|
||||||
|
const qb = this.repository
|
||||||
|
.createQueryBuilder('contract')
|
||||||
|
.leftJoinAndSelect('contract.company', 'company')
|
||||||
|
.leftJoinAndSelect('contract.serviceType', 'serviceType')
|
||||||
|
.leftJoinAndSelect('contract.routes', 'routes')
|
||||||
|
.leftJoinAndSelect('routes.originYard', 'routeOrigin')
|
||||||
|
.leftJoinAndSelect('routes.destinationYard', 'routeDestination')
|
||||||
|
.leftJoinAndSelect('contract.cargoScope', 'cargoScope')
|
||||||
|
.where('contract.deleted_at IS NULL');
|
||||||
|
|
||||||
|
this.applyListFilters(qb, options);
|
||||||
|
|
||||||
|
const sortField =
|
||||||
|
options.sortBy === 'contractValidUntil'
|
||||||
|
? 'contract.contractValidUntil'
|
||||||
|
: 'contract.createdAt';
|
||||||
|
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
|
||||||
|
|
||||||
|
const [items, total] = await qb
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize)
|
||||||
|
.getManyAndCount();
|
||||||
|
|
||||||
|
// Attach the generated contract PDF to each row so list/home can offer a
|
||||||
|
// direct download. Loaded separately to keep pagination counts correct.
|
||||||
|
await this.attachContractFiles(items);
|
||||||
|
|
||||||
|
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
meta: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
hasNextPage: page < totalPages,
|
||||||
|
hasPreviousPage: page > 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load contract-resource files for the given contracts and attach them to
|
||||||
|
* `contract.files`. Kept separate from the paginated query so the one-to-many
|
||||||
|
* join doesn't inflate the page count.
|
||||||
|
*/
|
||||||
|
private async attachContractFiles(contracts: Contract[]): Promise<void> {
|
||||||
|
if (contracts.length === 0) return;
|
||||||
|
const ids = contracts.map((c) => c.id);
|
||||||
|
const files = await this.dataSource.getRepository(FileRecord).find({
|
||||||
|
where: { resource: 'contracts', resourceId: In(ids), deletedAt: IsNull() },
|
||||||
|
});
|
||||||
|
const byContract = new Map<string, FileRecord[]>();
|
||||||
|
for (const file of files) {
|
||||||
|
const list = byContract.get(file.resourceId) ?? [];
|
||||||
|
list.push(file);
|
||||||
|
byContract.set(file.resourceId, list);
|
||||||
|
}
|
||||||
|
for (const contract of contracts) {
|
||||||
|
contract.files = byContract.get(contract.id) ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatusCounts(): Promise<Record<string, number>> {
|
||||||
|
const rows = await this.repository
|
||||||
|
.createQueryBuilder('contract')
|
||||||
|
.select('contract.status', 'status')
|
||||||
|
.addSelect('COUNT(*)::int', 'count')
|
||||||
|
.where('contract.deleted_at IS NULL')
|
||||||
|
.groupBy('contract.status')
|
||||||
|
.getRawMany<{ status: string; count: string }>();
|
||||||
|
|
||||||
|
return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getListSummaryMetrics(
|
||||||
|
options: ContractListFilterOptions & {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
needsActionStatuses: readonly string[];
|
||||||
|
},
|
||||||
|
): Promise<{ inQueue: number; onThisPage: number; needsAction: number }> {
|
||||||
|
const baseQb = () => {
|
||||||
|
const qb = this.repository
|
||||||
|
.createQueryBuilder('contract')
|
||||||
|
.where('contract.deleted_at IS NULL');
|
||||||
|
this.applyListFilters(qb, options);
|
||||||
|
return qb;
|
||||||
|
};
|
||||||
|
|
||||||
|
const inQueue = await baseQb().getCount();
|
||||||
|
|
||||||
|
const needsAction = await baseQb()
|
||||||
|
.andWhere('contract.status IN (:...needsActionStatuses)', {
|
||||||
|
needsActionStatuses: [...options.needsActionStatuses],
|
||||||
|
})
|
||||||
|
.getCount();
|
||||||
|
|
||||||
|
const offset = (options.page - 1) * options.pageSize;
|
||||||
|
const onThisPage = Math.min(options.pageSize, Math.max(0, inQueue - offset));
|
||||||
|
|
||||||
|
return { inQueue, onThisPage, needsAction };
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyListFilters(
|
||||||
|
qb: SelectQueryBuilder<Contract>,
|
||||||
|
options: ContractListFilterOptions,
|
||||||
|
): void {
|
||||||
|
if (options.statuses?.length) {
|
||||||
|
qb.andWhere('contract.status IN (:...statuses)', { statuses: options.statuses });
|
||||||
|
} else if (options.status) {
|
||||||
|
qb.andWhere('contract.status = :status', { status: options.status });
|
||||||
|
}
|
||||||
|
if (options.companyId) {
|
||||||
|
qb.andWhere('contract.company_id = :companyId', { companyId: options.companyId });
|
||||||
|
}
|
||||||
|
if (options.companyProfileId) {
|
||||||
|
qb.andWhere('contract.company_profile_id = :companyProfileId', {
|
||||||
|
companyProfileId: options.companyProfileId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.contractKind) {
|
||||||
|
qb.andWhere('contract.contract_kind = :contractKind', {
|
||||||
|
contractKind: options.contractKind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.customsClearingEnabled !== undefined) {
|
||||||
|
qb.andWhere('contract.customs_clearing_enabled = :customsClearingEnabled', {
|
||||||
|
customsClearingEnabled: options.customsClearingEnabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.serviceTypeId) {
|
||||||
|
qb.andWhere('contract.service_type_id = :serviceTypeId', {
|
||||||
|
serviceTypeId: options.serviceTypeId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.freightType) {
|
||||||
|
qb.andWhere('contract.freight_type = :freightType', {
|
||||||
|
freightType: options.freightType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.tradeDirection) {
|
||||||
|
qb.andWhere('contract.trade_direction = :tradeDirection', {
|
||||||
|
tradeDirection: options.tradeDirection,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.paymentCurrency) {
|
||||||
|
qb.andWhere('contract.payment_currency = :paymentCurrency', {
|
||||||
|
paymentCurrency: options.paymentCurrency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.createdFrom) {
|
||||||
|
qb.andWhere('contract.created_at >= :createdFrom', {
|
||||||
|
createdFrom: options.createdFrom,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.createdTo) {
|
||||||
|
qb.andWhere('contract.created_at <= :createdTo', { createdTo: options.createdTo });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Approval steps ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Lowest-order pending approval step (sequential enforcement). */
|
||||||
|
async findNextPendingApprovalStep(
|
||||||
|
contractId: string,
|
||||||
|
): Promise<ContractApprovalStep | null> {
|
||||||
|
return this.dataSource.getRepository(ContractApprovalStep).findOne({
|
||||||
|
where: { contractId, status: 'PENDING' },
|
||||||
|
order: { stepOrder: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findApprovalStepById(
|
||||||
|
contractId: string,
|
||||||
|
stepId: string,
|
||||||
|
): Promise<ContractApprovalStep | null> {
|
||||||
|
return this.dataSource.getRepository(ContractApprovalStep).findOne({
|
||||||
|
where: { contractId, id: stepId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark an approval step complete. */
|
||||||
|
async completeApprovalStep(
|
||||||
|
stepId: string,
|
||||||
|
actorId: string,
|
||||||
|
status: 'APPROVED' | 'REJECTED',
|
||||||
|
note?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.dataSource.getRepository(ContractApprovalStep).update(stepId, {
|
||||||
|
status,
|
||||||
|
actedByStaffId: actorId,
|
||||||
|
actedAt: new Date(),
|
||||||
|
note,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if all approval steps are approved. */
|
||||||
|
async allApprovalStepsComplete(contractId: string): Promise<boolean> {
|
||||||
|
const pending = await this.dataSource.getRepository(ContractApprovalStep).count({
|
||||||
|
where: { contractId, status: 'PENDING' },
|
||||||
|
});
|
||||||
|
return pending === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist a contract approval step (instantiated at staff accept). */
|
||||||
|
async createApprovalStep(
|
||||||
|
data: Partial<ContractApprovalStep>,
|
||||||
|
): Promise<ContractApprovalStep> {
|
||||||
|
const repo = this.dataSource.getRepository(ContractApprovalStep);
|
||||||
|
return repo.save(repo.create(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Signatures ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
findSignatures(contractId: string): Promise<ContractSignature[]> {
|
||||||
|
return this.dataSource.getRepository(ContractSignature).find({
|
||||||
|
where: { contractId },
|
||||||
|
relations: ['signatureFile'],
|
||||||
|
order: { signedAt: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findSignature(
|
||||||
|
contractId: string,
|
||||||
|
role: ContractSignerRole,
|
||||||
|
): Promise<ContractSignature | null> {
|
||||||
|
return this.dataSource.getRepository(ContractSignature).findOne({
|
||||||
|
where: { contractId, role },
|
||||||
|
relations: ['signatureFile'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSignature(data: Partial<ContractSignature>): Promise<ContractSignature> {
|
||||||
|
const repo = this.dataSource.getRepository(ContractSignature);
|
||||||
|
const existing = await repo.findOne({
|
||||||
|
where: { contractId: data.contractId!, role: data.role! },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
Object.assign(existing, data);
|
||||||
|
return repo.save(existing);
|
||||||
|
}
|
||||||
|
return repo.save(repo.create(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Review notes ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async createReviewNote(
|
||||||
|
contractId: string,
|
||||||
|
body: string,
|
||||||
|
noteType: ContractReviewNoteType,
|
||||||
|
authorUserId?: string,
|
||||||
|
authorRole?: string,
|
||||||
|
): Promise<ContractReviewNote> {
|
||||||
|
const repo = this.dataSource.getRepository(ContractReviewNote);
|
||||||
|
return repo.save(
|
||||||
|
repo.create({
|
||||||
|
contractId,
|
||||||
|
body,
|
||||||
|
noteType,
|
||||||
|
authorUserId: authorUserId ?? null,
|
||||||
|
authorRole: authorRole ?? null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findLatestReviewNote(
|
||||||
|
contractId: string,
|
||||||
|
noteType?: ContractReviewNoteType,
|
||||||
|
): Promise<ContractReviewNote | null> {
|
||||||
|
const repo = this.dataSource.getRepository(ContractReviewNote);
|
||||||
|
return repo.findOne({
|
||||||
|
where: noteType ? { contractId, noteType } : { contractId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pre-booking clearance document reviews ────────────────────────────────────
|
||||||
|
|
||||||
|
findDocumentReviews(
|
||||||
|
contractId: string,
|
||||||
|
cycleId?: string | null,
|
||||||
|
): Promise<ContractDocumentReview[]> {
|
||||||
|
return this.dataSource.getRepository(ContractDocumentReview).find({
|
||||||
|
where:
|
||||||
|
cycleId !== undefined
|
||||||
|
? { contractId, clearanceCycleId: cycleId === null ? IsNull() : cycleId }
|
||||||
|
: { contractId },
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert a document-review row to PENDING for a freshly uploaded file. Resets
|
||||||
|
* any prior QUERIED/APPROVED state so the GL re-reviews the new upload. Keyed
|
||||||
|
* on (contractId, clearanceCycleId, settingCode, fileKey).
|
||||||
|
*/
|
||||||
|
async upsertDocumentReviewPending(input: {
|
||||||
|
contractId: string;
|
||||||
|
clearanceCycleId?: string | null;
|
||||||
|
settingCode: string;
|
||||||
|
fileKey: string;
|
||||||
|
fileRecordId: string;
|
||||||
|
uploadedByRole?: 'CUSTOMER' | 'GL_ET' | 'GL_DJ';
|
||||||
|
}): Promise<void> {
|
||||||
|
const repo = this.dataSource.getRepository(ContractDocumentReview);
|
||||||
|
const cycleId = input.clearanceCycleId ?? null;
|
||||||
|
const existing = await repo.findOne({
|
||||||
|
where: {
|
||||||
|
contractId: input.contractId,
|
||||||
|
clearanceCycleId: cycleId === null ? IsNull() : cycleId,
|
||||||
|
settingCode: input.settingCode,
|
||||||
|
fileKey: input.fileKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
await repo.update(existing.id, {
|
||||||
|
fileRecordId: input.fileRecordId,
|
||||||
|
status: 'PENDING',
|
||||||
|
note: null,
|
||||||
|
reviewedByStaffId: null,
|
||||||
|
reviewedAt: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await repo.save(
|
||||||
|
repo.create({
|
||||||
|
contractId: input.contractId,
|
||||||
|
clearanceCycleId: cycleId,
|
||||||
|
settingCode: input.settingCode,
|
||||||
|
fileKey: input.fileKey,
|
||||||
|
fileRecordId: input.fileRecordId,
|
||||||
|
status: 'PENDING',
|
||||||
|
uploadedByRole: input.uploadedByRole ?? 'CUSTOMER',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL marks a document APPROVED or QUERIED (with an optional note). */
|
||||||
|
async setDocumentReviewStatus(input: {
|
||||||
|
contractId: string;
|
||||||
|
clearanceCycleId?: string | null;
|
||||||
|
settingCode: string;
|
||||||
|
fileKey: string;
|
||||||
|
status: ContractDocReviewStatus;
|
||||||
|
staffId: string;
|
||||||
|
note?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const repo = this.dataSource.getRepository(ContractDocumentReview);
|
||||||
|
const cycleId = input.clearanceCycleId ?? null;
|
||||||
|
const existing = await repo.findOne({
|
||||||
|
where: {
|
||||||
|
contractId: input.contractId,
|
||||||
|
clearanceCycleId: cycleId === null ? IsNull() : cycleId,
|
||||||
|
settingCode: input.settingCode,
|
||||||
|
fileKey: input.fileKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const patch = {
|
||||||
|
status: input.status,
|
||||||
|
note: input.note ?? null,
|
||||||
|
reviewedByStaffId: input.staffId,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (existing) {
|
||||||
|
await repo.update(existing.id, patch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await repo.save(
|
||||||
|
repo.create({
|
||||||
|
contractId: input.contractId,
|
||||||
|
clearanceCycleId: cycleId,
|
||||||
|
settingCode: input.settingCode,
|
||||||
|
fileKey: input.fileKey,
|
||||||
|
...patch,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Clearance cycles ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** The current (latest, non-completed) clearance cycle for a contract. */
|
||||||
|
async currentCycle(contractId: string): Promise<ContractClearanceCycle | null> {
|
||||||
|
return this.dataSource.getRepository(ContractClearanceCycle).findOne({
|
||||||
|
where: { contractId },
|
||||||
|
order: { cycleNumber: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a new clearance cycle (incrementing cycle_number). */
|
||||||
|
async openCycle(
|
||||||
|
contractId: string,
|
||||||
|
cycleNumber: number,
|
||||||
|
): Promise<ContractClearanceCycle> {
|
||||||
|
const repo = this.dataSource.getRepository(ContractClearanceCycle);
|
||||||
|
return repo.save(
|
||||||
|
repo.create({
|
||||||
|
contractId,
|
||||||
|
cycleNumber,
|
||||||
|
status: 'AWAITING_DOCUMENTS',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setCycleStatus(
|
||||||
|
cycleId: string,
|
||||||
|
status: string,
|
||||||
|
fields: Partial<
|
||||||
|
Pick<ContractClearanceCycle, 'bookingId' | 'clearanceReadyAt' | 'completedAt'>
|
||||||
|
> = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await this.dataSource
|
||||||
|
.getRepository(ContractClearanceCycle)
|
||||||
|
.update(cycleId, { status, ...fields } as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Link the GL-created booking to a clearance cycle. */
|
||||||
|
async linkBooking(cycleId: string, bookingId: string): Promise<void> {
|
||||||
|
await this.dataSource
|
||||||
|
.getRepository(ContractClearanceCycle)
|
||||||
|
.update(cycleId, { bookingId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rate snapshots ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async createRateSnapshot(
|
||||||
|
data: Partial<ContractRateSnapshot>,
|
||||||
|
): Promise<ContractRateSnapshot> {
|
||||||
|
const repo = this.dataSource.getRepository(ContractRateSnapshot);
|
||||||
|
return repo.save(repo.create(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearRateSnapshots(contractId: string): Promise<void> {
|
||||||
|
await this.dataSource.getRepository(ContractRateSnapshot).delete({ contractId });
|
||||||
|
}
|
||||||
|
|
||||||
|
findRateSnapshots(contractId: string): Promise<ContractRateSnapshot[]> {
|
||||||
|
return this.dataSource.getRepository(ContractRateSnapshot).find({
|
||||||
|
where: { contractId },
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
532
apps/edr-freight-api/src/modules/contracts/contracts.service.ts
Normal file
532
apps/edr-freight-api/src/modules/contracts/contracts.service.ts
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
|
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
|
||||||
|
import { CompanyStatus } from '../companies/entities/company.entity';
|
||||||
|
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||||
|
import { FilesService } from '../files/files.service';
|
||||||
|
import { MinioService } from '../minio/minio.service';
|
||||||
|
import { ContractsRepository } from './contracts.repository';
|
||||||
|
import { CreateContractDto } from './dto/create-contract.dto';
|
||||||
|
import { UpdateContractDto } from './dto/update-contract.dto';
|
||||||
|
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||||
|
import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
|
||||||
|
import { Contract, CONTRACT_STATUSES, CONTRACT_CUSTOMER_EDITABLE_STATUSES } from './entities/contract.entity';
|
||||||
|
import { ContractRoute } from './entities/contract-route.entity';
|
||||||
|
import { ContractCargoScope } from './entities/contract-cargo-scope.entity';
|
||||||
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
|
|
||||||
|
/** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */
|
||||||
|
export interface PaginatedContracts {
|
||||||
|
items: Contract[];
|
||||||
|
total: number;
|
||||||
|
meta: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPreviousPage: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEEDS_ACTION_STATUSES = [
|
||||||
|
'SUBMITTED',
|
||||||
|
'PENDING_APPROVAL',
|
||||||
|
'APPROVED_PENDING_SIGNATURE',
|
||||||
|
'SIGNED_CUSTOMER',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContractsService {
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
private readonly contractsRepository: ContractsRepository,
|
||||||
|
private readonly companiesService: CompaniesService,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
|
private readonly minioService: MinioService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Generate a unique contract reference number (CTR-YYYY-NNNNN). */
|
||||||
|
private async generateReference(): Promise<string> {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const count = await this.contractsRepository.countByYear(year);
|
||||||
|
return `CTR-${year}-${String(count + 1).padStart(5, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a service type bundles customs clearance. */
|
||||||
|
private async resolveIncludesCustoms(serviceTypeId: string): Promise<boolean> {
|
||||||
|
const serviceType = await this.dataSource
|
||||||
|
.getRepository(ServiceType)
|
||||||
|
.findOne({ where: { id: serviceTypeId } });
|
||||||
|
return serviceType?.includesCustoms ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate cargo-scope rows against freight type (doc §5.4). */
|
||||||
|
private assertCargoScopeShape(
|
||||||
|
freightType: string,
|
||||||
|
cargoScope: CreateContractDto['cargoScope'],
|
||||||
|
): void {
|
||||||
|
if (freightType === 'CONTAINER') {
|
||||||
|
const sizes = cargoScope.filter((c) =>
|
||||||
|
['20ft', '40ft'].includes(c.containerSize ?? ''),
|
||||||
|
);
|
||||||
|
if (sizes.length === 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'CONTAINER contracts require at least one container size (20ft/40ft) in scope',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const bulk = cargoScope.filter((c) => c.cargoTypeId);
|
||||||
|
if (bulk.length !== 1) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'BULK contracts require exactly one cargo-type scope row',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cargoScope.some((c) => c.containerSize)) {
|
||||||
|
throw new BadRequestException('BULK contracts must not set a container size');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate route count against contract kind (doc §5.3). */
|
||||||
|
private assertRouteShape(
|
||||||
|
contractKind: string,
|
||||||
|
routes: CreateContractDto['routes'],
|
||||||
|
): void {
|
||||||
|
if (contractKind === 'ONE_TIME' && routes.length !== 1) {
|
||||||
|
throw new BadRequestException('ONE_TIME contracts require exactly one route');
|
||||||
|
}
|
||||||
|
if (routes.length < 1) {
|
||||||
|
throw new BadRequestException('A contract requires at least one route');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new contract (DRAFT) with its routes and cargo-scope rows. */
|
||||||
|
async create(
|
||||||
|
dto: CreateContractDto,
|
||||||
|
files: Express.Multer.File[],
|
||||||
|
userId?: string,
|
||||||
|
): Promise<{ contract: Contract; warnings: string[] }> {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const isGovernment = dto.isGovernment === true;
|
||||||
|
|
||||||
|
let companyId: string | null | undefined = dto.companyId;
|
||||||
|
if (isGovernment) {
|
||||||
|
if (!dto.governmentInstitution?.trim()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'governmentInstitution is required for government contracts',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
companyId = dto.companyId ?? null;
|
||||||
|
} else if (!companyId) {
|
||||||
|
if (!userId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'companyId is required or must be resolvable from auth token',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||||
|
if (company.status !== CompanyStatus.Active) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"Your company is awaiting approval — you can't create contracts yet.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
companyId = company.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
|
||||||
|
this.assertRouteShape(dto.contractKind, dto.routes);
|
||||||
|
|
||||||
|
const reference = dto.reference || (await this.generateReference());
|
||||||
|
|
||||||
|
// Stamp the operational profile (importer/exporter) for portal scoping.
|
||||||
|
let companyProfileId: string | null = null;
|
||||||
|
if (!isGovernment && companyId) {
|
||||||
|
let fallbackType: ProfileType | null = null;
|
||||||
|
if (userId) {
|
||||||
|
try {
|
||||||
|
const { profile } =
|
||||||
|
await this.companiesService.getCompanyInfoByUserId(userId);
|
||||||
|
fallbackType = profile.activeProfileType ?? null;
|
||||||
|
} catch {
|
||||||
|
// No profile (e.g. staff creating on behalf) — fall back to mapping.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
companyProfileId =
|
||||||
|
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||||
|
companyId,
|
||||||
|
dto.tradeDirection,
|
||||||
|
fallbackType,
|
||||||
|
);
|
||||||
|
|
||||||
|
const customerSelfBooking = !dto.companyId && !!userId;
|
||||||
|
if (customerSelfBooking && companyProfileId) {
|
||||||
|
await this.companiesService.assertCompanyProfileApprovedForBooking(
|
||||||
|
companyProfileId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customs clearing is owned by the service type, not the customer.
|
||||||
|
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
|
||||||
|
|
||||||
|
const contract = await this.contractsRepository.create({
|
||||||
|
reference,
|
||||||
|
companyId: companyId ?? null,
|
||||||
|
companyProfileId,
|
||||||
|
isGovernment,
|
||||||
|
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
|
||||||
|
contractKind: dto.contractKind,
|
||||||
|
renewalOfId: dto.renewalOfId ?? null,
|
||||||
|
tradeDirection: dto.tradeDirection,
|
||||||
|
freightType: dto.freightType,
|
||||||
|
serviceTypeId: dto.serviceTypeId,
|
||||||
|
paymentCurrency: dto.paymentCurrency,
|
||||||
|
customsClearingEnabled: includesCustoms,
|
||||||
|
customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null),
|
||||||
|
equipmentReturn: dto.equipmentReturn ?? null,
|
||||||
|
firstMilePickupAddress: dto.firstMilePickupAddress ?? null,
|
||||||
|
firstMilePickupLat: dto.firstMilePickupLat ?? null,
|
||||||
|
firstMilePickupLng: dto.firstMilePickupLng ?? null,
|
||||||
|
lastMileDeliveryAddress: dto.lastMileDeliveryAddress ?? null,
|
||||||
|
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
|
||||||
|
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
|
||||||
|
isHazardous: dto.isHazardous ?? false,
|
||||||
|
isReefer: dto.isReefer ?? false,
|
||||||
|
estimatedShipmentDate: dto.estimatedShipmentDate
|
||||||
|
? new Date(dto.estimatedShipmentDate)
|
||||||
|
: null,
|
||||||
|
contractType: dto.contractType ?? null,
|
||||||
|
status: 'DRAFT',
|
||||||
|
clearanceStatus: 'NOT_APPLICABLE',
|
||||||
|
clearanceCycleNumber: 0,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await this.persistRoutes(contract.id, dto.routes);
|
||||||
|
await this.persistCargoScope(contract.id, dto.cargoScope, contract.contractKind);
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
try {
|
||||||
|
await this.filesService.uploadMany(contract.id, 'contracts', files);
|
||||||
|
} catch {
|
||||||
|
warnings.push('File upload failed — contract was created without attached files.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach the company profile's onboarding / business-license documents to the
|
||||||
|
// contract by reference. The separate "Documents" intake step was removed —
|
||||||
|
// the profile documents are simply carried onto every contract automatically.
|
||||||
|
await this.attachProfileDocuments(contract.id, companyProfileId);
|
||||||
|
|
||||||
|
return { contract: await this.findById(contract.id), warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy a company profile's stored business-license / onboarding documents onto
|
||||||
|
* a contract by reference (no byte re-upload). Codes are slugged from each
|
||||||
|
* document name so they group under "Profile documents" on the contract detail
|
||||||
|
* page. No-op when the contract has no profile or the profile has no documents.
|
||||||
|
*/
|
||||||
|
private async attachProfileDocuments(
|
||||||
|
contractId: string,
|
||||||
|
companyProfileId: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!companyProfileId) return;
|
||||||
|
const profile = await this.dataSource
|
||||||
|
.getRepository(CompanyProfile)
|
||||||
|
.findOne({ where: { id: companyProfileId } });
|
||||||
|
const docs = profile?.businessLicenseFiles ?? [];
|
||||||
|
if (docs.length === 0) return;
|
||||||
|
|
||||||
|
const slug = (name: string) =>
|
||||||
|
name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\.[a-z0-9]+$/, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '') || 'profile_document';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.filesService.attachExistingFiles(
|
||||||
|
contractId,
|
||||||
|
'contracts',
|
||||||
|
docs.map((d, i) => ({
|
||||||
|
code: `${slug(d.name)}_${i + 1}`,
|
||||||
|
name: d.name,
|
||||||
|
url: d.url,
|
||||||
|
size: d.size,
|
||||||
|
mimeType: d.mimeType,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — the contract is still valid without the carried documents.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistRoutes(
|
||||||
|
contractId: string,
|
||||||
|
routes: CreateContractDto['routes'],
|
||||||
|
): Promise<void> {
|
||||||
|
const repo = this.dataSource.getRepository(ContractRoute);
|
||||||
|
await repo.save(
|
||||||
|
routes.map((r, i) =>
|
||||||
|
repo.create({
|
||||||
|
contractId,
|
||||||
|
originYardId: r.originYardId,
|
||||||
|
destinationYardId: r.destinationYardId,
|
||||||
|
km: r.km ?? null,
|
||||||
|
sortOrder: r.sortOrder ?? i,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistCargoScope(
|
||||||
|
contractId: string,
|
||||||
|
cargoScope: CreateContractDto['cargoScope'],
|
||||||
|
contractKind: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const repo = this.dataSource.getRepository(ContractCargoScope);
|
||||||
|
// A quantity cap only governs GENERAL contracts (multi-shipment draw-down).
|
||||||
|
// ONE_TIME allows a single booking, so any cap on it is meaningless → null.
|
||||||
|
const isGeneral = contractKind === 'GENERAL';
|
||||||
|
await repo.save(
|
||||||
|
cargoScope.map((c) =>
|
||||||
|
repo.create({
|
||||||
|
contractId,
|
||||||
|
containerSize: c.containerSize ?? null,
|
||||||
|
cargoTypeId: c.cargoTypeId ?? null,
|
||||||
|
cargoFreeText: c.cargoFreeText ?? null,
|
||||||
|
quantityCap: isGeneral ? (c.quantityCap ?? null) : null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a DRAFT / CHANGES_REQUESTED contract. */
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateContractDto,
|
||||||
|
files: Express.Multer.File[],
|
||||||
|
): Promise<{ contract: Contract; warnings: string[] }> {
|
||||||
|
const existing = await this.findById(id);
|
||||||
|
if (!CONTRACT_CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Only DRAFT or CHANGES_REQUESTED contracts can be updated',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const freightType = dto.freightType ?? existing.freightType;
|
||||||
|
const contractKind = dto.contractKind ?? existing.contractKind;
|
||||||
|
|
||||||
|
if (dto.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope);
|
||||||
|
if (dto.routes) this.assertRouteShape(contractKind, dto.routes);
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = {
|
||||||
|
contractKind,
|
||||||
|
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
|
||||||
|
freightType,
|
||||||
|
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
|
||||||
|
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
||||||
|
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||||
|
isReefer: dto.isReefer ?? existing.isReefer,
|
||||||
|
equipmentReturn: dto.equipmentReturn ?? existing.equipmentReturn,
|
||||||
|
firstMilePickupAddress: dto.firstMilePickupAddress ?? existing.firstMilePickupAddress,
|
||||||
|
firstMilePickupLat: dto.firstMilePickupLat ?? existing.firstMilePickupLat,
|
||||||
|
firstMilePickupLng: dto.firstMilePickupLng ?? existing.firstMilePickupLng,
|
||||||
|
lastMileDeliveryAddress:
|
||||||
|
dto.lastMileDeliveryAddress ?? existing.lastMileDeliveryAddress,
|
||||||
|
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? existing.lastMileDeliveryLat,
|
||||||
|
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? existing.lastMileDeliveryLng,
|
||||||
|
contractType: dto.contractType ?? existing.contractType,
|
||||||
|
};
|
||||||
|
if (dto.estimatedShipmentDate) {
|
||||||
|
updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate);
|
||||||
|
}
|
||||||
|
if (dto.renewalOfId !== undefined) updates.renewalOfId = dto.renewalOfId ?? null;
|
||||||
|
|
||||||
|
// Customs clearing always mirrors the (possibly changed) service type.
|
||||||
|
const includesCustoms = await this.resolveIncludesCustoms(
|
||||||
|
dto.serviceTypeId ?? existing.serviceTypeId,
|
||||||
|
);
|
||||||
|
updates.customsClearingEnabled = includesCustoms;
|
||||||
|
updates.customsClearingAgent = includesCustoms
|
||||||
|
? null
|
||||||
|
: (dto.customsClearingAgent ?? existing.customsClearingAgent ?? null);
|
||||||
|
|
||||||
|
await this.contractsRepository.update(id, updates);
|
||||||
|
|
||||||
|
if (dto.routes) {
|
||||||
|
await this.dataSource.getRepository(ContractRoute).delete({ contractId: id });
|
||||||
|
await this.persistRoutes(id, dto.routes);
|
||||||
|
}
|
||||||
|
if (dto.cargoScope) {
|
||||||
|
await this.dataSource.getRepository(ContractCargoScope).delete({ contractId: id });
|
||||||
|
await this.persistCargoScope(id, dto.cargoScope, existing.contractKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
await this.filesService.uploadMany(id, 'contracts', files);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { contract: await this.findById(id), warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse comma-separated or repeated status query values. */
|
||||||
|
private parseStatusFilter(filter: FilterContractDto): {
|
||||||
|
statuses?: string[];
|
||||||
|
status?: string;
|
||||||
|
} {
|
||||||
|
const allowed = new Set<string>(CONTRACT_STATUSES);
|
||||||
|
const raw = filter.statuses;
|
||||||
|
const statusList = raw
|
||||||
|
? raw
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => allowed.has(s))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (statusList.length > 0) return { statuses: statusList };
|
||||||
|
if (filter.status && allowed.has(filter.status)) return { status: filter.status };
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(
|
||||||
|
filter: FilterContractDto,
|
||||||
|
forceCompanyId?: string,
|
||||||
|
forceCompanyProfileId?: string,
|
||||||
|
): Promise<PaginatedContracts> {
|
||||||
|
const page = filter.page ?? 1;
|
||||||
|
const pageSize = filter.pageSize ?? 20;
|
||||||
|
const statusFilter = this.parseStatusFilter(filter);
|
||||||
|
|
||||||
|
return this.contractsRepository.findAllPaginated({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
...statusFilter,
|
||||||
|
companyId: forceCompanyId ?? filter.companyId,
|
||||||
|
companyProfileId: forceCompanyProfileId ?? filter.companyProfileId,
|
||||||
|
contractKind: filter.contractKind,
|
||||||
|
serviceTypeId: filter.serviceTypeId,
|
||||||
|
freightType: filter.freightType,
|
||||||
|
tradeDirection: filter.tradeDirection,
|
||||||
|
paymentCurrency: filter.paymentCurrency,
|
||||||
|
createdFrom: filter.createdFrom,
|
||||||
|
createdTo: filter.createdTo,
|
||||||
|
sortBy: filter.sortBy,
|
||||||
|
sortOrder: filter.sortOrder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aggregate metrics and status counts for the backoffice contract list. */
|
||||||
|
async getListSummary(filter: FilterContractDto): Promise<ContractListSummaryDto> {
|
||||||
|
const page = filter.page ?? 1;
|
||||||
|
const pageSize = filter.pageSize ?? 20;
|
||||||
|
const statusFilter = this.parseStatusFilter(filter);
|
||||||
|
const listFilter = {
|
||||||
|
...statusFilter,
|
||||||
|
companyId: filter.companyId,
|
||||||
|
contractKind: filter.contractKind,
|
||||||
|
serviceTypeId: filter.serviceTypeId,
|
||||||
|
freightType: filter.freightType,
|
||||||
|
tradeDirection: filter.tradeDirection,
|
||||||
|
paymentCurrency: filter.paymentCurrency,
|
||||||
|
createdFrom: filter.createdFrom,
|
||||||
|
createdTo: filter.createdTo,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [statusCounts, metrics] = await Promise.all([
|
||||||
|
this.contractsRepository.getStatusCounts(),
|
||||||
|
this.contractsRepository.getListSummaryMetrics({
|
||||||
|
...listFilter,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
needsActionStatuses: NEEDS_ACTION_STATUSES,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { metrics, statusCounts };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a single contract by ID with relations and signed file URLs. */
|
||||||
|
async findById(id: string): Promise<Contract> {
|
||||||
|
const contract = await this.contractsRepository.findByIdWithRelations(id);
|
||||||
|
if (!contract) {
|
||||||
|
throw new NotFoundException(`Contract ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contract.files && contract.files.length > 0) {
|
||||||
|
contract.files = await Promise.all(
|
||||||
|
contract.files.map(async (file: FileRecord) => {
|
||||||
|
const objectName = this.minioService.getObjectNameFromUrl(file.url);
|
||||||
|
const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
|
||||||
|
return { ...file, signedUrl } as FileRecord;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return contract;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByReference(reference: string): Promise<Contract> {
|
||||||
|
const found = await this.contractsRepository.findByReference(reference);
|
||||||
|
if (!found) {
|
||||||
|
throw new NotFoundException(`Contract with reference "${reference}" not found`);
|
||||||
|
}
|
||||||
|
return this.findById(found.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload intake documents for a DRAFT contract. */
|
||||||
|
async uploadDocuments(
|
||||||
|
id: string,
|
||||||
|
files: Express.Multer.File[],
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.findById(id);
|
||||||
|
if (contract.status !== 'DRAFT') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Documents can only be uploaded for DRAFT contracts',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.filesService.uploadMany(id, 'contracts', files);
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
const contract = await this.findById(id);
|
||||||
|
if (contract.status !== 'DRAFT') {
|
||||||
|
throw new BadRequestException('Only DRAFT contracts can be deleted');
|
||||||
|
}
|
||||||
|
await this.contractsRepository.softDelete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the company a customer user belongs to, for scoping their contracts. */
|
||||||
|
async resolveCustomerCompanyId(userId: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||||
|
return company?.id ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authorize a customer's access to a single contract (hides as NotFound otherwise). */
|
||||||
|
async assertCustomerCanAccessContract(
|
||||||
|
userId: string | undefined,
|
||||||
|
contract: Contract,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!userId) {
|
||||||
|
throw new ForbiddenException('Authentication required');
|
||||||
|
}
|
||||||
|
const companyId = await this.resolveCustomerCompanyId(userId);
|
||||||
|
if (!companyId || contract.companyId !== companyId) {
|
||||||
|
throw new NotFoundException(`Contract ${contract.id} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsInt, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class AcceptContractDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
'How many days the contract stays valid, counted from the accept date. ' +
|
||||||
|
'The contract is valid from now through now + validityDays.',
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 3650,
|
||||||
|
example: 365,
|
||||||
|
})
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(3650)
|
||||||
|
validityDays!: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class ApproveStepDto {
|
||||||
|
@ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' })
|
||||||
|
@IsString()
|
||||||
|
requiredRole!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RequestChangesDto {
|
||||||
|
@ApiProperty({ description: 'Note explaining what the customer must fix' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
note!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RejectContractDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RejectStepDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CancelContractDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ContractListSummaryMetricsDto {
|
||||||
|
@ApiProperty({ example: 42 })
|
||||||
|
inQueue!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 10 })
|
||||||
|
onThisPage!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 8 })
|
||||||
|
needsAction!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContractListSummaryDto {
|
||||||
|
@ApiProperty({ type: ContractListSummaryMetricsDto })
|
||||||
|
metrics!: ContractListSummaryMetricsDto;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Count per contract status', type: 'object', additionalProperties: { type: 'number' } })
|
||||||
|
statusCounts!: Record<string, number>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/** A requested container line (no per-unit data — GL enters that at booking). */
|
||||||
|
export class RequestContainerLineDto {
|
||||||
|
@ApiProperty({ description: '"20ft" | "40ft" — must be in the contract scope' })
|
||||||
|
@IsString()
|
||||||
|
containerSize!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ minimum: 1 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
quantity!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
hazardousQuantity?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
reeferQuantity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RequestBulkLineDto {
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
cargoTypeId?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
cargoWeightTons?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
itemCount?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
hazardousQuantity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer's shipment request on a GENERAL customs contract. */
|
||||||
|
export class CreateBookingRequestDto {
|
||||||
|
@ApiPropertyOptional({ format: 'uuid', description: 'Required for multi-route GENERAL.' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
contractRouteId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Preferred shipment day (informational).' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
scheduledDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [RequestContainerLineDto] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => RequestContainerLineDto)
|
||||||
|
containers?: RequestContainerLineDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: RequestBulkLineDto })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => RequestBulkLineDto)
|
||||||
|
bulk?: RequestBulkLineDto;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReviewBookingRequestDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsDateString,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/** One physical container under a booking line — entered at booking time. */
|
||||||
|
export class CreateContainerUnitDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
containerNumber!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sealNumber?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'VGM in tons', minimum: 0 })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
vgmTons!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
|
isHazardous?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
|
isReefer?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateBookingContainerLineDto {
|
||||||
|
@ApiProperty({ description: '"20ft" | "40ft" — must be in the contract scope' })
|
||||||
|
@IsString()
|
||||||
|
containerSize!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ minimum: 1 })
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
quantity!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
hazardousQuantity?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
reeferQuantity?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [CreateContainerUnitDto] })
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreateContainerUnitDto)
|
||||||
|
units!: CreateContainerUnitDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateBulkLineDto {
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
cargoTypeId?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
cargoWeightTons?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
itemCount?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
hazardousQuantity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shipment booking created under a contract (Path A customer, Path B GL ET). */
|
||||||
|
export class CreateBookingUnderContractDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'Required for GENERAL multi-route contracts; ONE_TIME auto-selected.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
contractRouteId?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Binding shipment day.', example: '2026-07-15' })
|
||||||
|
@IsDateString()
|
||||||
|
scheduledDate!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreateBookingContainerLineDto)
|
||||||
|
containers?: CreateBookingContainerLineDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [CreateBulkLineDto] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreateBulkLineDto)
|
||||||
|
bulkLines?: CreateBulkLineDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsDateString,
|
||||||
|
IsIn,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateIf,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
import { CONTRACT_KINDS } from '../entities/contract.entity';
|
||||||
|
|
||||||
|
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||||
|
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||||
|
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
|
||||||
|
const EQUIPMENT_RETURNS = ['with_return', 'without_return'] as const;
|
||||||
|
|
||||||
|
export {
|
||||||
|
CONTRACT_KINDS,
|
||||||
|
TRADE_DIRECTIONS,
|
||||||
|
FREIGHT_TYPES,
|
||||||
|
PAYMENT_CURRENCIES,
|
||||||
|
EQUIPMENT_RETURNS,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One cargo-scope row — a container size OR a bulk commodity. NO quantities. */
|
||||||
|
export class CreateContractCargoScopeDto {
|
||||||
|
@ApiPropertyOptional({ description: '"20ft" | "40ft"; omit for bulk' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
containerSize?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid', description: 'FK to cargo_types.id' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
cargoTypeId?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 200 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
cargoFreeText?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'GENERAL only: total bookable quantity for this line (containers per size, or tons/items for bulk). Omit for uncapped.',
|
||||||
|
minimum: 1,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Transform(({ value }) => (value == null || value === '' ? null : Number(value)))
|
||||||
|
quantityCap?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A contracted lane (origin → destination). Routes carry NO quantity. */
|
||||||
|
export class CreateContractRouteInputDto {
|
||||||
|
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' })
|
||||||
|
@IsUUID()
|
||||||
|
originYardId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (destination)' })
|
||||||
|
@IsUUID()
|
||||||
|
destinationYardId!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Road billing distance (km); null for rail-only.',
|
||||||
|
minimum: 0,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
value === undefined || value === null || value === '' ? undefined : Number(value),
|
||||||
|
)
|
||||||
|
km?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
value === undefined || value === null || value === '' ? undefined : Number(value),
|
||||||
|
)
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateContractDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Unique contract reference (auto-generated if omitted)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
reference?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Staff only: government contract flag' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
|
isGovernment?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Required when isGovernment is true' })
|
||||||
|
@ValidateIf((o) => o.isGovernment === true)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
governmentInstitution?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
|
||||||
|
@ValidateIf((o) => o.isGovernment !== true)
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
companyId?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: CONTRACT_KINDS, description: 'ONE_TIME | GENERAL' })
|
||||||
|
@IsIn([...CONTRACT_KINDS])
|
||||||
|
contractKind!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid', description: 'FK to contracts.id when renewing' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
@Transform(({ value }) => (value === '' || value == null ? undefined : value))
|
||||||
|
renewalOfId?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: TRADE_DIRECTIONS })
|
||||||
|
@IsIn([...TRADE_DIRECTIONS])
|
||||||
|
tradeDirection!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: FREIGHT_TYPES })
|
||||||
|
@IsIn([...FREIGHT_TYPES])
|
||||||
|
freightType!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid', description: 'FK to service_types.id' })
|
||||||
|
@IsUUID()
|
||||||
|
serviceTypeId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: PAYMENT_CURRENCIES })
|
||||||
|
@IsIn([...PAYMENT_CURRENCIES])
|
||||||
|
paymentCurrency!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Whether EDR/GL handles customs clearance' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
|
customsClearingEnabled?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ maxLength: 200 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
customsClearingAgent?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: EQUIPMENT_RETURNS })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...EQUIPMENT_RETURNS])
|
||||||
|
equipmentReturn?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
firstMilePickupAddress?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'First-mile pickup latitude (-90..90)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-90)
|
||||||
|
@Max(90)
|
||||||
|
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
|
||||||
|
firstMilePickupLat?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'First-mile pickup longitude (-180..180)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-180)
|
||||||
|
@Max(180)
|
||||||
|
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
|
||||||
|
firstMilePickupLng?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
lastMileDeliveryAddress?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Last-mile delivery latitude (-90..90)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-90)
|
||||||
|
@Max(90)
|
||||||
|
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
|
||||||
|
lastMileDeliveryLat?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Last-mile delivery longitude (-180..180)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(-180)
|
||||||
|
@Max(180)
|
||||||
|
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
|
||||||
|
lastMileDeliveryLng?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: false, description: 'Sets contracts.is_hazardous' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
|
isHazardous?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: false, description: 'Sets contracts.is_reefer' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
|
isReefer?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Non-binding estimate from the wizard (NOT validated against departures)',
|
||||||
|
example: '2026-07-15T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
estimatedShipmentDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Contract document type (SPOT, etc.)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
contractType?: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: [CreateContractCargoScopeDto],
|
||||||
|
description:
|
||||||
|
'Cargo scope rows. CONTAINER: ≥1 size row. BULK: exactly one cargo-type row.',
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreateContractCargoScopeDto)
|
||||||
|
cargoScope!: CreateContractCargoScopeDto[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: [CreateContractRouteInputDto],
|
||||||
|
description: 'Contracted lanes. ONE_TIME: exactly 1. GENERAL: 1..N.',
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreateContractRouteInputDto)
|
||||||
|
routes!: CreateContractRouteInputDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
import { CONTRACT_STATUSES, CONTRACT_KINDS } from '../entities/contract.entity';
|
||||||
|
|
||||||
|
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||||
|
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||||
|
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
|
||||||
|
|
||||||
|
export class FilterContractDto {
|
||||||
|
@ApiPropertyOptional({ enum: CONTRACT_STATUSES })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...CONTRACT_STATUSES])
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Filter by statuses: comma-separated or repeated query params. Overrides status when set.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => {
|
||||||
|
if (value === undefined || value === null || value === '') return undefined;
|
||||||
|
if (Array.isArray(value)) return value.map(String).join(',');
|
||||||
|
return String(value);
|
||||||
|
})
|
||||||
|
statuses?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
companyId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
companyProfileId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: CONTRACT_KINDS })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...CONTRACT_KINDS])
|
||||||
|
contractKind?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
serviceTypeId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: FREIGHT_TYPES })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...FREIGHT_TYPES])
|
||||||
|
freightType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...TRADE_DIRECTIONS])
|
||||||
|
tradeDirection?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: PAYMENT_CURRENCIES })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...PAYMENT_CURRENCIES])
|
||||||
|
paymentCurrency?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Filter contracts created on/after this date (ISO)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
createdFrom?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Filter contracts created on/before this date (ISO)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
createdTo?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => (value ? parseInt(value, 10) : 1))
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => (value ? parseInt(value, 10) : 20))
|
||||||
|
pageSize?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 'createdAt' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['createdAt', 'contractValidUntil'])
|
||||||
|
sortBy?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['ASC', 'DESC'])
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsIn,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsPositive,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
import { CUSTOMS_RISK_LEVELS } from '../entities/clearance-milestone.entity';
|
||||||
|
import { INCIDENT_TYPES } from '../entities/clearance-incident.entity';
|
||||||
|
|
||||||
|
export class AssignRiskDto {
|
||||||
|
@ApiProperty({ enum: CUSTOMS_RISK_LEVELS })
|
||||||
|
@IsIn(CUSTOMS_RISK_LEVELS as unknown as string[])
|
||||||
|
riskLevel!: (typeof CUSTOMS_RISK_LEVELS)[number];
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdviseDutyDto {
|
||||||
|
@ApiProperty({ description: 'Duty & tax amount advised to the customer' })
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@IsPositive()
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'ETB' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Customs declaration serial number' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
declarationSerial?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AssignStationDto {
|
||||||
|
@ApiProperty({ description: 'Origin-station yard the shipment is routed to' })
|
||||||
|
@IsUUID()
|
||||||
|
stationYardId!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'GL staff user bound to this shipment' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
staffId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReportIncidentDto {
|
||||||
|
@ApiProperty({ enum: INCIDENT_TYPES })
|
||||||
|
@IsIn(INCIDENT_TYPES as unknown as string[])
|
||||||
|
incidentType!: (typeof INCIDENT_TYPES)[number];
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Mandatory free-text narrative of the anomaly' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
description!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CompleteMilestoneDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class RenewContractDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Reference of the prior contract being renewed.' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
previousContractReference?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsIn, IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class ReviewClearanceDocumentDto {
|
||||||
|
@ApiProperty({ description: 'The document fileKey being reviewed' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
fileKey!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['APPROVED', 'QUERIED'] })
|
||||||
|
@IsIn(['APPROVED', 'QUERIED'])
|
||||||
|
status!: 'APPROVED' | 'QUERIED';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Required when querying a document' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsIn, IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class SignContractDto {
|
||||||
|
@ApiProperty({ enum: ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] })
|
||||||
|
@IsIn(['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'])
|
||||||
|
role!: 'CUSTOMER' | 'STAFF' | 'DIRECTOR' | 'CEO';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'PNG signature image as base64 (with or without data URL prefix). ' +
|
||||||
|
'Optional: when omitted, the signer\'s reusable saved signature from their ' +
|
||||||
|
'profile is used instead.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(20)
|
||||||
|
signatureImageBase64?: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
signerDisplayName!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
consentText?: string;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user