Merge branch 'dev' into freight/feat/invoice

This commit is contained in:
Nathnael
2026-06-30 11:26:32 +00:00
110 changed files with 4261 additions and 2260 deletions

View File

@@ -3,6 +3,10 @@
FROM node:24.15.0-alpine AS base
RUN apk add --no-cache libc6-compat
# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit
# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds.
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
@@ -14,6 +18,7 @@ FROM base AS installer
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
--mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
FROM base AS builder
@@ -23,7 +28,8 @@ RUN pnpm turbo build --filter="@edr/freight-api..."
FROM base AS deployer
COPY --from=builder /app/ .
RUN pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
FROM node:24.15.0-alpine AS runner
RUN apk add --no-cache libc6-compat
@@ -34,4 +40,4 @@ RUN addgroup --system --gid 1001 nodejs \
COPY --from=deployer --chown=nestjs:nodejs /deploy .
USER nestjs
EXPOSE 3001
CMD ["sh", "-c", "pnpm run migrate && node dist/main.js"]
CMD ["node", "dist/main.js"]

View 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 &amp; 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>

View File

@@ -18,6 +18,7 @@
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
"seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts",
"seed:warehouse-export-receive-ready": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-export-receive-ready.ts",
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
"seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts",
"seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",

View File

@@ -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');
}
}
}

View File

@@ -1,7 +1,7 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
export class AddPostPaymentCompletedColumn1719667261000 implements MigrationInterface {
name = 'AddPostPaymentCompletedColumn1719667261000';
export class AddPostPaymentCompletedColumn1810000000004 implements MigrationInterface {
name = 'AddPostPaymentCompletedColumn1810000000004';
public async up(queryRunner: QueryRunner): Promise<void> {
const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries');

View File

@@ -15,19 +15,25 @@ export class CreateInvoices1821000000002 implements MigrationInterface {
name = "CreateInvoices1821000000002";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TYPE freight.invoices_status_enum AS ENUM (
'DRAFT',
'PENDING',
'PAID',
'OVERDUE',
'CANCELLED',
'REFUNDED'
);
`);
const typeExists = await queryRunner.query(
`SELECT 1 FROM pg_type WHERE typname = 'invoices_status_enum' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'freight');`,
);
if (!typeExists.length) {
await queryRunner.query(`
CREATE TYPE freight.invoices_status_enum AS ENUM (
'DRAFT',
'PENDING',
'PAID',
'OVERDUE',
'CANCELLED',
'REFUNDED'
);
`);
}
await queryRunner.query(`
CREATE TABLE freight.invoices (
CREATE TABLE IF NOT EXISTS freight.invoices (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
invoice_number varchar(64) NOT NULL,
company_id uuid NOT NULL,
@@ -96,6 +102,8 @@ export class CreateInvoices1821000000002 implements MigrationInterface {
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.invoice_lines;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.invoices;`);
await queryRunner.query(`DROP TYPE IF EXISTS freight.invoices_status_enum;`);
await queryRunner.query(
`DROP TYPE IF EXISTS freight.invoices_status_enum;`,
);
}
}

View File

@@ -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');
}
}
}

View File

@@ -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
`);
}
}

View File

@@ -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');
}
}
}

View File

@@ -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);
}
}

View File

@@ -32,6 +32,7 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
import { BookingReviewNote } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity';
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
import { ContractRendererService } from '../../contracts/contract-renderer.service';
@@ -50,6 +51,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
BookingRateSnapshot,
BookingReviewNote,
BookingContractSignature,
BookingContainerAllocation,
]),
BillingModule,
forwardRef(() => FirstMileModule),

View File

@@ -43,6 +43,7 @@ import {
FreightType,
} from './entities/booking.entity';
import { Booking } from './entities/booking.entity';
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
import { FileRecord } from '../files/entities/file.entity';
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
@@ -1337,4 +1338,35 @@ export class BookingsService {
createdAt: b.createdAt,
}));
}
async allocateContainers(
bookingId: string,
allocations: Array<{ containerId: string; vehicleId: string }>,
) {
const booking = await this.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
await this.dataSource.transaction(async (manager) => {
for (const allocation of allocations) {
await manager.delete(BookingContainerAllocation, {
bookingId,
containerId: allocation.containerId,
});
await manager.insert(BookingContainerAllocation, {
bookingId,
containerId: allocation.containerId,
vehicleId: allocation.vehicleId,
containerType: 'CONTAINER',
quantity: 1,
});
}
});
return {
success: true,
allocated: allocations.length,
};
}
}

View File

@@ -0,0 +1,8 @@
export class ContainerAllocationDto {
containerId!: string;
vehicleId!: string;
}
export class AllocateContainersDto {
allocations!: ContainerAllocationDto[];
}

View File

@@ -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;
}

View File

@@ -13,6 +13,7 @@ import { FileRecord } from '../../files/entities/file.entity';
import { BookingApprovalStep } from './booking-approval-step.entity';
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
import { BookingContainer } from './booking-container.entity';
import { BookingContainerAllocation } from './booking-container-allocation.entity';
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
import { BookingReviewNote } from './booking-review-note.entity';
@@ -444,6 +445,9 @@ export class Booking extends BaseEntity {
@OneToMany(() => BookingContainer, (bc) => bc.booking)
bookingContainers?: BookingContainer[];
@OneToMany(() => BookingContainerAllocation, (ca) => ca.booking)
containerAllocations?: BookingContainerAllocation[];
@OneToMany(() => BookingCargoModifier, (m) => m.booking)
cargoModifiers?: BookingCargoModifier[];

View File

@@ -0,0 +1,8 @@
export class FirstMileContainerAllocationDto {
containerId!: string;
vehicleId!: string;
}
export class AllocateFirstMileContainersDto {
allocations!: FirstMileContainerAllocationDto[];
}

View File

@@ -0,0 +1,36 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { FirstMile } from './first-mile.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
@Entity({ name: 'first_mile_container_allocations', schema: 'freight' })
@Index(['firstMileId'])
@Index(['vehicleId'])
export class FirstMileContainerAllocation extends BaseEntity {
@Column({ name: 'first_mile_id', type: 'uuid' })
firstMileId!: string;
@ManyToOne(() => FirstMile, (firstMile) => firstMile.containerAllocations, {
nullable: false,
eager: false,
})
@JoinColumn({ name: 'first_mile_id' })
firstMile?: FirstMile;
@Column({ name: 'container_id', type: 'uuid' })
containerId!: string;
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string | null;
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
@Column({ name: 'container_type', type: 'text' })
containerType!: string;
@Column({ name: 'quantity', type: 'int', default: 1 })
quantity!: number;
}

View File

@@ -1,8 +1,9 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { FirstMileContainerAllocation } from './first-mile-container-allocation.entity';
export const FIRST_MILE_STATUSES = [
'PAYMENT_PENDING',
@@ -34,6 +35,7 @@ export class FirstMile extends BaseEntity {
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
remainingPayment!: number;
// TODO: uncomment after migration creates column
// @Column({ type: 'boolean', default: false })
// isPostPaymentCompleted!: boolean;
@@ -49,4 +51,11 @@ export class FirstMile extends BaseEntity {
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
@OneToMany(
() => FirstMileContainerAllocation,
(containerAllocation) => containerAllocation.firstMile,
{ eager: false },
)
containerAllocations!: FirstMileContainerAllocation[];
}

View File

@@ -0,0 +1,106 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
import {
BillingService,
InvoiceEventPayload,
} from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { FirstMileRepository } from './first-mile.repository';
import { FirstMile } from './entities/first-mile.entity';
/**
* Owns the first-mile ⇄ invoice mapping — the one place that knows how a
* first-mile record turns into invoices, which type to use, and how it
* advances when paid. First-mile records are billable entities, so they
* generate their own invoices directly via {@link BillingService}.
*/
@Injectable()
export class FirstMileInvoiceService {
private readonly logger = new Logger(FirstMileInvoiceService.name);
constructor(
private readonly billing: BillingService,
private readonly firstMileRepo: FirstMileRepository,
) {}
/**
* Ensure the first-mile record has its invoice, generating one from the
* remaining payment if absent. Called when a first-mile record reaches a
* billable state. Idempotent — returns the existing open invoice instead
* of a duplicate. Returns `null` (and logs) when the record is not billable:
* no company to bill.
*/
async ensureInvoiceFor(record: FirstMile): Promise<Invoice | null> {
const existing = await this.billing.findPayable(
'first_mile' as Freight.InvoiceSource,
record.id,
'DELIVERY_FEE',
);
if (existing) return existing;
if (!record.bookingId) {
this.logger.warn(
`Skipping invoice for first-mile record ${record.id}: no booking to reference.`,
);
return null;
}
// Fetch the booking to get the companyId and companyProfileId
const fm = record.booking ? record : (await this.firstMileRepo.findById(record.bookingId, { relations: { booking: true } }));
if (!fm) return null;
if (!fm.booking?.companyId) {
this.logger.warn(
`Skipping invoice for first-mile record ${record.id}: no company to bill.`,
);
return null;
}
const totalAmount = record.remainingPayment || 0;
if (!Number.isFinite(totalAmount) || totalAmount <= 0) {
this.logger.warn(
`Skipping invoice for first-mile record ${record.id}: no remaining payment.`,
);
return null;
}
return this.billing.generateInvoice({
source: 'first_mile' as Freight.InvoiceSource,
sourceId: record.id,
type: 'DELIVERY_FEE',
companyId: fm.booking!.companyId,
companyProfileId: fm.booking!.companyProfileId || '',
currency: 'ETB',
lines: [
{
chargeType: 'DELIVERY',
description: 'First-mile delivery',
quantity: 1,
unitRate: totalAmount,
amount: totalAmount,
},
],
totalAmount,
});
}
/**
* React to a first-mile invoice being paid — the settlement branch point.
* Mark the first-mile record as having completed post-payment processing.
*/
@OnEvent('first_mile.invoice.paid')
async onPaid(payload: InvoiceEventPayload): Promise<void> {
if (payload.type === 'DELIVERY_FEE') {
const record = await this.firstMileRepo.findById(payload.sourceId);
if (!record) {
this.logger.warn(
`Cannot mark unknown first-mile record ${payload.sourceId} as paid.`,
);
return;
}
this.logger.log(`First-mile invoice paid for record ${payload.sourceId}.`);
}
}
}

View File

@@ -17,15 +17,20 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto';
import { FirstMileStatus } from './entities/first-mile.entity';
import { FirstMileService } from './first-mile.service';
import { FirstMileInvoiceService } from './first-mile-invoice.service';
@ApiTags('first-mile')
@ApiBearerAuth()
@Controller('first-mile')
@TrainSchedulingView()
export class FirstMileController {
constructor(private readonly firstMileService: FirstMileService) {}
constructor(
private readonly firstMileService: FirstMileService,
private readonly firstMileInvoiceService: FirstMileInvoiceService,
) {}
@Get()
@ApiOperation({ summary: 'List first-mile legs' })
@@ -72,8 +77,13 @@ export class FirstMileController {
@Patch(':id')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Update a first-mile leg' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
return this.firstMileService.update(id, dto);
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
const record = await this.firstMileService.update(id, dto);
// Auto-generate invoice if distance or payment was updated
if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) {
await this.firstMileInvoiceService.ensureInvoiceFor(record);
}
return record;
}
@Delete(':id')
@@ -83,4 +93,14 @@ export class FirstMileController {
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.firstMileService.remove(id);
}
@Post(':firstMileId/allocate-containers')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Allocate containers to vehicles for a first-mile leg' })
allocateContainers(
@Param('firstMileId', ParseUUIDPipe) firstMileId: string,
@Body() dto: AllocateFirstMileContainersDto,
) {
return this.firstMileService.allocateContainers(firstMileId, dto.allocations);
}
}

View File

@@ -1,25 +1,29 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingModule } from '../billing/billing.module';
import { BookingsModule } from '../bookings/bookings.module';
import { DriversModule } from '../drivers/drivers.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { VehiclesModule } from '../vehicles/vehicles.module';
import { FirstMile } from './entities/first-mile.entity';
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
import { FirstMileController } from './first-mile.controller';
import { FirstMileInvoiceService } from './first-mile-invoice.service';
import { FirstMileRepository } from './first-mile.repository';
import { FirstMileService } from './first-mile.service';
@Module({
imports: [
TypeOrmModule.forFeature([FirstMile]),
TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]),
BillingModule,
forwardRef(() => BookingsModule),
VehiclesModule,
DriversModule,
NotificationsModule,
],
controllers: [FirstMileController],
providers: [FirstMileRepository, FirstMileService],
exports: [FirstMileRepository, FirstMileService],
providers: [FirstMileRepository, FirstMileService, FirstMileInvoiceService],
exports: [FirstMileRepository, FirstMileService, FirstMileInvoiceService],
})
export class FirstMileModule {}

View File

@@ -1,5 +1,7 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
@@ -8,6 +10,7 @@ import { VehiclesService } from '../vehicles/vehicles.service';
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
import { FirstMileRepository } from './first-mile.repository';
type FirstMileListFilter = {
@@ -32,6 +35,7 @@ export class FirstMileService {
private readonly logger = new Logger(FirstMileService.name);
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly firstMileRepository: FirstMileRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly vehiclesService: VehiclesService,
@@ -273,4 +277,35 @@ export class FirstMileService {
await this.findById(id);
await this.firstMileRepository.softDelete(id);
}
async allocateContainers(
firstMileId: string,
allocations: Array<{ containerId: string; vehicleId: string }>,
) {
const firstMile = await this.findById(firstMileId);
if (!firstMile) {
throw new NotFoundException(`First-mile record ${firstMileId} not found`);
}
await this.dataSource.transaction(async (manager) => {
for (const allocation of allocations) {
await manager.delete(FirstMileContainerAllocation, {
firstMileId,
containerId: allocation.containerId,
});
await manager.insert(FirstMileContainerAllocation, {
firstMileId,
containerId: allocation.containerId,
vehicleId: allocation.vehicleId,
containerType: 'CONTAINER',
quantity: 1,
});
}
});
return {
success: true,
allocated: allocations.length,
};
}
}

View File

@@ -0,0 +1,8 @@
export class LastMileContainerAllocationDto {
containerId!: string;
vehicleId!: string;
}
export class AllocateLastMileContainersDto {
allocations!: LastMileContainerAllocationDto[];
}

View File

@@ -0,0 +1,32 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { LastMile } from './last-mile.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
@Entity({ schema: 'freight', name: 'last_mile_container_allocations' })
@Index(['lastMileId'])
@Index(['vehicleId'])
export class LastMileContainerAllocation extends BaseEntity {
@ManyToOne(() => LastMile, (lm) => lm.containerAllocations)
@JoinColumn({ name: 'last_mile_id' })
lastMile!: LastMile;
@Column('uuid', { name: 'last_mile_id' })
lastMileId!: string;
@Column('uuid', { name: 'container_id' })
containerId!: string;
@ManyToOne(() => Vehicle)
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
@Column('uuid', { name: 'vehicle_id', nullable: true })
vehicleId?: string | null;
@Column('text')
containerType!: string;
@Column('integer', { default: 1 })
quantity!: number;
}

View File

@@ -1,8 +1,9 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { LastMileContainerAllocation } from './last-mile-container-allocation.entity';
export const LAST_MILE_STATUSES = [
'PAYMENT_PENDING',
@@ -34,6 +35,7 @@ export class LastMile extends BaseEntity {
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
remainingPayment!: number;
// TODO: uncomment after migration creates column
// @Column({ type: 'boolean', default: false })
// isPostPaymentCompleted!: boolean;
@@ -49,4 +51,7 @@ export class LastMile extends BaseEntity {
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
@OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile)
containerAllocations?: LastMileContainerAllocation[];
}

View File

@@ -0,0 +1,97 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
import {
BillingService,
GenerateInvoiceInput,
InvoiceEventPayload,
} from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { LastMileRepository } from './last-mile.repository';
import { LastMile } from './entities/last-mile.entity';
/**
* Owns the last-mile ⇄ invoice mapping — the one place that knows how a last-mile
* record turns into invoices, which type to use, and how it advances when paid.
* Last-mile records are billable business entities for delivery fees, so they
* generate their own invoices directly via {@link BillingService}. All last-mile-specific
* type branching lives here, at the two points it belongs: invoice creation and
* settlement (the paid handler).
*/
@Injectable()
export class LastMileInvoiceService {
private readonly logger = new Logger(LastMileInvoiceService.name);
constructor(
private readonly billing: BillingService,
private readonly lastMileRepo: LastMileRepository,
) {}
/**
* Ensure the last-mile record has its invoice, generating one from the
* remainingPayment if absent. Called when a last-mile record reaches a
* billable state. Idempotent — returns the existing open invoice instead
* of a duplicate. Returns `null` (and logs) when the record is not billable:
* no company to bill (invoices FK requires a companyId).
*/
async ensureInvoiceFor(record: LastMile): Promise<Invoice | null> {
// Check if invoice already exists
const existing = await this.billing.findPayable(
'last_mile' as Freight.InvoiceSource,
record.id,
'DELIVERY_FEE',
);
if (existing) return existing;
// Can't bill without company
const lm = record.booking ? record : (await this.lastMileRepo.findById(record.id, { relations: { booking: true } }));
if (!lm) return null;
if (!lm.booking?.companyId) {
this.logger.warn(
`Skipping invoice for last-mile record ${record.id}: no company to bill.`,
);
return null;
}
// Generate invoice with remainingPayment as totalAmount
const input: GenerateInvoiceInput = {
source: 'last_mile' as Freight.InvoiceSource,
sourceId: record.id,
type: 'DELIVERY_FEE',
companyId: lm.booking!.companyId,
companyProfileId: lm.booking!.companyProfileId || '',
currency: 'ETB',
lines: [
{
chargeType: 'DELIVERY',
description: 'Last-mile delivery',
quantity: 1,
unitRate: record.remainingPayment || 0,
amount: record.remainingPayment || 0,
},
],
totalAmount: record.remainingPayment || 0,
};
return this.billing.generateInvoice(input);
}
/**
* React to a last-mile invoice being paid — the settlement branch point.
* Advances the last-mile record to mark post-payment as completed.
*/
@OnEvent('last_mile.invoice.paid')
async onPaid(payload: InvoiceEventPayload): Promise<void> {
if (payload.type === 'DELIVERY_FEE') {
const record = await this.lastMileRepo.findById(payload.sourceId);
if (record) {
this.logger.log(`Last-mile invoice paid for record ${payload.sourceId}.`);
} else {
this.logger.warn(
`Cannot mark last-mile record ${payload.sourceId} as paid: not found.`,
);
}
}
}
}

View File

@@ -17,15 +17,20 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto';
import { LastMileStatus } from './entities/last-mile.entity';
import { LastMileService } from './last-mile.service';
import { LastMileInvoiceService } from './last-mile-invoice.service';
@ApiTags('last-mile')
@ApiBearerAuth()
@Controller('last-mile')
@TrainSchedulingView()
export class LastMileController {
constructor(private readonly lastMileService: LastMileService) {}
constructor(
private readonly lastMileService: LastMileService,
private readonly lastMileInvoiceService: LastMileInvoiceService,
) {}
@Get()
@ApiOperation({ summary: 'List last-mile legs' })
@@ -72,8 +77,13 @@ export class LastMileController {
@Patch(':id')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Update a last-mile leg' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
return this.lastMileService.update(id, dto);
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
const record = await this.lastMileService.update(id, dto);
// Auto-generate invoice if distance or payment was updated
if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) {
await this.lastMileInvoiceService.ensureInvoiceFor(record);
}
return record;
}
@Delete(':id')
@@ -83,4 +93,14 @@ export class LastMileController {
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.lastMileService.remove(id);
}
@Post(':id/allocate-containers')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Allocate containers to vehicles' })
async allocateContainers(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AllocateLastMileContainersDto,
) {
return this.lastMileService.allocateContainers(id, dto.allocations);
}
}

View File

@@ -1,25 +1,29 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingModule } from '../billing/billing.module';
import { BookingsModule } from '../bookings/bookings.module';
import { DriversModule } from '../drivers/drivers.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { VehiclesModule } from '../vehicles/vehicles.module';
import { LastMile } from './entities/last-mile.entity';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
import { LastMileController } from './last-mile.controller';
import { LastMileInvoiceService } from './last-mile-invoice.service';
import { LastMileRepository } from './last-mile.repository';
import { LastMileService } from './last-mile.service';
@Module({
imports: [
TypeOrmModule.forFeature([LastMile]),
TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation]),
BillingModule,
forwardRef(() => BookingsModule),
VehiclesModule,
DriversModule,
NotificationsModule,
],
controllers: [LastMileController],
providers: [LastMileRepository, LastMileService],
exports: [LastMileRepository, LastMileService],
providers: [LastMileRepository, LastMileService, LastMileInvoiceService],
exports: [LastMileRepository, LastMileService, LastMileInvoiceService],
})
export class LastMileModule {}

View File

@@ -1,5 +1,5 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { DataSource, FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
@@ -8,6 +8,7 @@ import { VehiclesService } from '../vehicles/vehicles.service';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
import { LastMileRepository } from './last-mile.repository';
type LastMileListFilter = {
@@ -37,6 +38,7 @@ export class LastMileService {
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly smsClient: SmsClientService,
private readonly dataSource: DataSource,
) {}
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
@@ -206,4 +208,35 @@ export class LastMileService {
await this.findById(id);
await this.lastMileRepository.softDelete(id);
}
async allocateContainers(
lastMileId: string,
allocations: Array<{ containerId: string; vehicleId: string }>,
) {
const lastMile = await this.findById(lastMileId);
if (!lastMile) {
throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
}
await this.dataSource.transaction(async (manager) => {
for (const allocation of allocations) {
await manager.delete(LastMileContainerAllocation, {
lastMileId,
containerId: allocation.containerId,
});
await manager.insert(LastMileContainerAllocation, {
lastMileId,
containerId: allocation.containerId,
vehicleId: allocation.vehicleId,
containerType: 'CONTAINER',
quantity: 1,
});
}
});
return {
success: true,
allocated: allocations.length,
};
}
}

View File

@@ -462,16 +462,29 @@ export class PaymentService {
failureCode?: string;
failureMessage?: string;
}): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> {
console.log(`Received payment event: ${JSON.stringify(event)}`);
if (event.eventType === "payment.succeeded") {
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId });
if (!intent) {
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
}
console.log(`Processing payment succeeded event for intent: }`,intent);
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
providerTxnId: event.providerTxnId,
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
notify: true,
});
console.log(`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
// When the intent references a booking, flip the booking itself paid.
// refId holds the booking id (the domain reference the intent opened with).
if (intent.referenceType === PaymentReferenceType.BOOKING) {
await this.datasource.manager.update(
Booking,
{ id: intent.refId },
{ status: "PAID", paymentStatus: "PAID" },
);
}
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
return { processed: true, alreadyFinalized };
}

View File

@@ -110,6 +110,9 @@ export class WarehouseInventory extends BaseEntity {
@Column({ name: 'volume', type: 'numeric', precision: 12, scale: 3, nullable: true })
volume?: number | null;
@Column({ name: 'grn_number', type: 'varchar', length: 100, nullable: true })
grnNumber?: string | null;
@Column({ name: 'status', type: 'varchar', length: 32, default: 'RECEIVED' })
status!: WarehouseInventoryStatus;

View File

@@ -273,6 +273,16 @@ export class WarehouseInventoryController {
return res.send(buffer);
}
@Get(':id/grn-document')
@ApiOperation({ summary: 'View goods received note PDF' })
async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.grnDocument(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Get(':id/handover-document')
@ApiOperation({ summary: 'View import goods handover document PDF' })
async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {

View File

@@ -52,6 +52,7 @@ const isLoadableWagonStatus = (status: string | null | undefined) =>
LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status));
const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
const HANDOVER_DOCUMENT_MARKER = '[Handover Document]';
export interface InventoryInquiryResult {
id: string;
@@ -249,6 +250,7 @@ export interface ReadyToLoadRow {
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
grnNumber: string | null;
origin: string | null;
destination: string | null;
inspectionStatus: string | null;
@@ -295,6 +297,7 @@ export interface ImportUnloadedRow {
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
grnNumber: string | null;
trainSchedule: string | null;
inspectionStatus: string | null;
pickupOption: string;
@@ -302,6 +305,8 @@ export interface ImportUnloadedRow {
currentStatus: string;
releaseDate: string | null;
releaseOrderReference: string | null;
handoverDocumentReference: string | null;
handoverDocumentDate: string | null;
deliveredAt: string | null;
}
@@ -415,7 +420,10 @@ export class WarehouseInventoryService {
const search = filter.search?.trim();
const where: FindManyOptions<WarehouseInventory>['where'] = search
? { ...base, notes: ILike(`%${search}%`) }
? [
{ ...base, notes: ILike(`%${search}%`) },
{ ...base, grnNumber: ILike(`%${search}%`) },
]
: base;
const items = await this.inventoryRepository.findAll({
@@ -766,6 +774,7 @@ export class WarehouseInventoryService {
const [booking] = await manager.query(
`SELECT b.reference AS "reference",
b.payment_status AS "paymentStatus",
b.freight_type AS "freightType",
b.cargo_total_weight_vgm AS "weight",
company.name AS "customer",
company.tin AS "customerTin",
@@ -847,6 +856,12 @@ export class WarehouseInventoryService {
const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } });
if (existing) { skip('Already received'); continue; }
const containerQuantity = Number(booking.containerQuantity ?? 0);
if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) {
skip('Container booking has no container quantity');
continue;
}
const now = new Date();
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
const truckEntrance = dto.truckEntrance
@@ -867,8 +882,9 @@ export class WarehouseInventoryService {
yardId: dto.yardId,
zoneId: dto.zoneId,
bookingId,
quantity: Number(booking.containerQuantity) || 1,
quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1,
weight: Number(booking.weight) || 0,
grnNumber,
status: 'RECEIVED',
arrivedAt: now,
notes: receiveNote,
@@ -962,6 +978,7 @@ export class WarehouseInventoryService {
ct.container_number AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
inv.weight AS "weight",
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
oy.code AS "origin",
dy.code AS "destination",
oy.country AS "originCountry",
@@ -1021,6 +1038,7 @@ export class WarehouseInventoryService {
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
inv.weight AS "weight",
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
ts.train_number AS "trainSchedule",
inv.inspection_status AS "inspectionStatus",
CASE WHEN b.last_mile_delivery_address IS NOT NULL
@@ -1029,6 +1047,8 @@ export class WarehouseInventoryService {
inv.status AS "currentStatus",
inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference",
substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate",
inv.delivered_at AS "deliveredAt",
oy.country AS "originCountry",
dy.country AS "destinationCountry"
@@ -1669,6 +1689,7 @@ export class WarehouseInventoryService {
quantity,
weight,
volume: dto.volume ?? null,
grnNumber,
status: 'RECEIVED',
arrivedAt: now,
notes: receiveNote,
@@ -1933,24 +1954,31 @@ export class WarehouseInventoryService {
);
}
const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date();
const reference = dto.reference?.trim() || null;
const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
const releaseDate = isTruckLeaving
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
: item.releaseDate ?? null;
const reference = dto.reference?.trim() || (await this.generateReleaseReference(item));
const exitInspectionNote = this.buildExitInspectionNote(dto);
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(id, {
releaseDate,
releaseOrderReference: reference,
notes: [item.notes?.trim(), exitInspectionNote].filter(Boolean).join('\n\n'),
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
});
await this.activityLog.record(
{
activityType: 'INVENTORY_RELEASED',
inventoryId: id,
warehouseId: item.warehouseId,
description: reference
? `Release order ${reference} sent to customer`
: 'Release order sent to customer',
description: isTruckLeaving
? reference
? `Exit paper ${reference} generated`
: 'Exit paper generated'
: reference
? `Truck arrival ${reference} registered`
: 'Truck arrival registered',
performedBy: dto.performedBy,
},
manager,
@@ -2039,6 +2067,106 @@ export class WarehouseInventoryService {
}
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
const [row] = await this.dataSource.query(
`SELECT inv.id,
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
COALESCE(inv.arrived_at, inv.created_at) AS "receivedAt",
inv.quantity,
inv.weight,
inv.volume,
inv.status,
inv.notes,
b.id AS "bookingId",
b.reference AS "bookingReference",
b.status AS "bookingStatus",
b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection",
b.cargo_total_weight_vgm AS "bookingDeclaredWeight",
company.name AS "customerName",
company.tin AS "customerTin",
service_type.service_name AS "serviceType",
origin_yard.label AS "originYardLabel",
origin_yard.code AS "originYardCode",
destination_yard.label AS "destinationYardLabel",
destination_yard.code AS "destinationYardCode",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
booking_container."containerSummary" AS "bookingContainerSummary",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
wh.name AS "warehouseName",
wh.code AS "warehouseCode",
yard.name AS "yardName",
yard.code AS "yardCode",
zone.name AS "zoneName",
zone.code AS "zoneCode"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.service_types service_type ON service_type.id = b.service_type_id
LEFT JOIN freight.yards origin_yard ON origin_yard.id = b.origin_yard_id
LEFT JOIN freight.yards destination_yard ON destination_yard.id = b.destination_yard_id
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN LATERAL (
SELECT MIN(bc.container_number) AS container_number,
STRING_AGG(
CONCAT_WS(' ', bc.quantity::text, COALESCE(ct.label, ct.code, 'container')),
', '
ORDER BY COALESCE(ct.label, ct.code, bc.container_type_id::text)
) AS "containerSummary"
FROM freight.booking_container bc
LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id
WHERE bc.booking_id = b.id
AND bc.deleted_at IS NULL
) booking_container ON true
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[id],
);
if (!row) {
throw new NotFoundException(`Inventory item ${id} not found`);
}
if (!row.grnNumber) {
throw new BadRequestException('GRN number is missing for this inventory item');
}
const html = this.buildGrnDocumentHtml({
grnNumber: row.grnNumber,
receivedAt: row.receivedAt ? new Date(row.receivedAt) : new Date(),
bookingReference: row.bookingReference ?? row.bookingId ?? 'N/A',
bookingStatus: row.bookingStatus ?? null,
customerName: row.customerName ?? null,
customerTin: row.customerTin ?? null,
serviceType: row.serviceType ?? null,
freightType: row.freightType ?? null,
tradeDirection: row.tradeDirection ?? null,
route: [row.originYardLabel ?? row.originYardCode, row.destinationYardLabel ?? row.destinationYardCode]
.filter(Boolean)
.join(' to ') || null,
containerNumber: row.containerNumber ?? null,
bookingContainerSummary: row.bookingContainerSummary ?? null,
cargoDescription: row.cargoDescription ?? null,
quantity: Number(row.quantity ?? 0),
weight: Number(row.weight ?? 0),
volume: row.volume == null ? null : Number(row.volume),
bookingDeclaredWeight: Number(row.bookingDeclaredWeight ?? 0),
warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null,
yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null,
zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null,
inventoryStatus: row.status ?? null,
receiveSummary: this.extractReceiveSummary(row.notes),
});
return {
filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
};
}
async approveDeliveryForBooking(
bookingId: string,
userId?: string,
@@ -2121,8 +2249,17 @@ export class WarehouseInventoryService {
b.status AS "bookingStatus",
b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection",
b.scheduled_date AS "scheduledDate",
b.cargo_total_weight_vgm AS "bookingDeclaredWeight",
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
company.name AS "customerName",
service_type.service_name AS "serviceType",
origin_yard.label AS "originYardLabel",
origin_yard.code AS "originYardCode",
destination_yard.label AS "destinationYardLabel",
destination_yard.code AS "destinationYardCode",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
booking_container."containerSummary" AS "bookingContainerSummary",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
wh.name AS "warehouseName",
wh.code AS "warehouseCode",
@@ -2134,14 +2271,25 @@ export class WarehouseInventoryService {
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.service_types service_type ON service_type.id = b.service_type_id
LEFT JOIN freight.yards origin_yard ON origin_yard.id = b.origin_yard_id
LEFT JOIN freight.yards destination_yard ON destination_yard.id = b.destination_yard_id
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
AND booking_container.deleted_at IS NULL
)
LEFT JOIN LATERAL (
SELECT MIN(bc.container_number) AS container_number,
STRING_AGG(
CONCAT_WS(' ', bc.quantity::text, COALESCE(ct.label, ct.code, 'container')),
', '
ORDER BY COALESCE(ct.label, ct.code, bc.container_type_id::text)
) AS "containerSummary"
FROM freight.booking_container bc
LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id
WHERE bc.booking_id = b.id
AND bc.deleted_at IS NULL
) booking_container ON true
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
@@ -2158,18 +2306,37 @@ export class WarehouseInventoryService {
}
const bookingReference = row.bookingReference || row.bookingId || 'N/A';
const reference =
this.extractHandoverDocumentLine(row.notes, 'Handover Reference') ||
`HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`;
const generatedAtValue = this.extractHandoverDocumentLine(row.notes, 'Generated At');
const generatedAt = generatedAtValue ? new Date(generatedAtValue) : new Date();
const handedOverAt = Number.isNaN(generatedAt.getTime()) ? new Date() : generatedAt;
if (!generatedAtValue) {
await this.inventoryRepository.update(id, {
notes: this.replaceHandoverDocumentNote(row.notes, this.buildHandoverDocumentNote(reference, handedOverAt)),
});
}
const html = this.buildHandoverDocumentHtml({
reference: `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`,
handedOverAt: new Date(row.handoverDate ?? Date.now()),
reference,
handedOverAt,
bookingReference,
bookingStatus: row.bookingStatus ?? null,
customerName: row.customerName ?? null,
serviceType: row.serviceType ?? null,
freightType: row.freightType ?? null,
tradeDirection: row.tradeDirection ?? null,
route: [row.originYardLabel ?? row.originYardCode, row.destinationYardLabel ?? row.destinationYardCode]
.filter(Boolean)
.join(' to ') || null,
scheduledDate: row.scheduledDate ? new Date(row.scheduledDate) : null,
containerNumber: row.containerNumber ?? null,
bookingContainerSummary: row.bookingContainerSummary ?? null,
cargoDescription: row.cargoDescription ?? null,
quantity: Number(row.quantity ?? 0),
weight: Number(row.weight ?? 0),
bookingDeclaredWeight: Number(row.bookingDeclaredWeight ?? 0),
warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null,
yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null,
zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null,
@@ -2178,11 +2345,12 @@ export class WarehouseInventoryService {
releaseOrderReference: row.releaseOrderReference ?? null,
releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
trainSchedule: row.trainSchedule ?? null,
lastMileDeliveryAddress: row.lastMileDeliveryAddress ?? null,
customerApproval: this.extractCustomerDeliveryApproval(row.notes),
});
return {
filename: `handover-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
};
}
@@ -2666,6 +2834,128 @@ export class WarehouseInventoryService {
return this.findById(id);
}
private buildGrnDocumentHtml(data: {
grnNumber: string;
receivedAt: Date;
bookingReference: string;
bookingStatus: string | null;
customerName: string | null;
customerTin: string | null;
serviceType: string | null;
freightType: string | null;
tradeDirection: string | null;
route: string | null;
containerNumber: string | null;
bookingContainerSummary: string | null;
cargoDescription: string | null;
quantity: number;
weight: number;
volume: number | null;
bookingDeclaredWeight: number;
warehouse: string | null;
yard: string | null;
zone: string | null;
inventoryStatus: string | null;
receiveSummary: string | null;
}): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const receivedAt = data.receivedAt.toLocaleString('en-GB', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
const rows: Array<[string, unknown]> = [
['Booking Reference', data.bookingReference],
['Customer / Consignee', data.customerName],
['Customer TIN', data.customerTin],
['Booking Status', data.bookingStatus],
['Service Type', data.serviceType],
['Freight Type', data.freightType],
['Trade Direction', data.tradeDirection],
['Route', data.route],
['Container Number', data.containerNumber],
['Booking Containers', data.bookingContainerSummary],
['Cargo / Goods Description', data.cargoDescription],
['Quantity', data.quantity],
['Received Weight', `${data.weight.toLocaleString()} kg`],
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null],
['Volume', data.volume == null ? null : data.volume.toLocaleString()],
['Warehouse', data.warehouse],
['Yard', data.yard],
['Zone', data.zone],
['Inventory Status', data.inventoryStatus],
...(data.receiveSummary ? [['Receive Details', data.receiveSummary] as [string, string]] : []),
];
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Goods Received Note</title>
<style>
* { box-sizing: border-box; }
@page { size: A4; margin: 12mm 15mm 14mm; }
body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 0; background: #fff; }
.top { display: grid; grid-template-columns: 1fr 210px; gap: 24px; border-top: 5px solid #0f766e; padding-top: 18px; }
.brand { font-size: 12px; color: #064c27; text-transform: uppercase; letter-spacing: .13em; font-weight: 800; }
h1 { margin: 8px 0 0; font-size: 31px; line-height: .98; text-transform: uppercase; letter-spacing: .02em; }
.subtitle { margin-top: 12px; font-size: 11px; color: #3d516a; text-transform: uppercase; letter-spacing: .14em; }
.ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; }
.ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; }
.rule { height: 3px; background: #0f766e; margin: 16px 0 22px; }
.notice { width: 76%; margin: 0 0 18px; padding: 13px 18px; background: #f0fdfa; border: 1px solid #5eead4; border-left: 5px solid #0f766e; font-size: 13px; line-height: 1.45; }
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #0f766e; text-transform: uppercase; letter-spacing: .12em; }
table { width: 100%; border-collapse: collapse; }
th { width: 31%; text-align: left; color: #0f2744; background: #f8fafc; font-weight: 800; }
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12.2px; vertical-align: top; white-space: pre-line; }
.clause { margin-top: 14px; border: 1px solid #b9c7d8; padding: 12px 15px; font-size: 12.2px; line-height: 1.45; }
.signatures { display: grid; grid-template-columns: 1fr 1fr; gap: 34px; align-items: start; margin-top: 42px; }
.line { border-top: 1.4px solid #061323; padding-top: 7px; font-size: 10.8px; color: #24384f; min-height: 42px; }
</style>
</head>
<body>
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Goods Received Note</h1>
<div class="subtitle">Warehouse receiving confirmation</div>
</div>
<div class="ref">
GRN Number
<strong>${esc(data.grnNumber)}</strong>
Received: ${esc(receivedAt)}
</div>
</div>
<div class="rule"></div>
<div class="notice">
This Goods Received Note confirms that the listed goods were received into EDR warehouse custody at the stated location.
</div>
<div class="section-title">Receiving Particulars</div>
<table>
<tbody>
${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')}
</tbody>
</table>
<div class="section-title">Receipt Clause</div>
<div class="clause">
This document records warehouse receipt only. Loading, dispatch, release, delivery, customs, and fee clearance remain subject to their respective operational approvals.
</div>
<div class="signatures">
<div class="line">Warehouse receiver name / signature / date</div>
<div class="line">Driver or customer representative name / signature / date</div>
</div>
</body>
</html>`;
}
private buildReleaseDocumentHtml(data: {
reference: string;
issuedAt: Date;
@@ -2721,7 +3011,7 @@ export class WarehouseInventoryService {
<html>
<head>
<meta charset="utf-8" />
<title>Warehouse Gate Clearance / Release Order</title>
<title>Warehouse Release / Exit Paper</title>
<style>
* { box-sizing: border-box; }
@page { size: A4; margin: 12mm 15mm 14mm; }
@@ -2752,8 +3042,8 @@ export class WarehouseInventoryService {
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Warehouse Gate Clearance / Release Order</h1>
<div class="subtitle">Official warehouse release and exit authorization</div>
<h1>Warehouse Release / Exit Paper</h1>
<div class="subtitle">Official gate clearance and warehouse exit authorization</div>
</div>
<div class="ref">
Document / Release No.
@@ -2763,7 +3053,7 @@ export class WarehouseInventoryService {
</div>
<div class="rule"></div>
<div class="notice">
This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
This Exit Paper confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
</div>
<div class="section-title">Release Particulars</div>
<table>
@@ -2792,12 +3082,17 @@ export class WarehouseInventoryService {
bookingReference: string;
bookingStatus: string | null;
customerName: string | null;
serviceType: string | null;
freightType: string | null;
tradeDirection: string | null;
route: string | null;
scheduledDate: Date | null;
containerNumber: string | null;
bookingContainerSummary: string | null;
cargoDescription: string | null;
quantity: number;
weight: number;
bookingDeclaredWeight: number;
warehouse: string | null;
yard: string | null;
zone: string | null;
@@ -2806,6 +3101,7 @@ export class WarehouseInventoryService {
releaseOrderReference: string | null;
releaseDate: Date | null;
trainSchedule: string | null;
lastMileDeliveryAddress: string | null;
customerApproval: {
approvedAt: string;
signerDisplayName: string;
@@ -2835,13 +3131,18 @@ export class WarehouseInventoryService {
['Booking Reference', data.bookingReference],
['Customer / Consignee', data.customerName],
['Booking Status', data.bookingStatus],
['Service Type', data.serviceType],
['Freight Type', data.freightType],
['Trade Direction', data.tradeDirection],
['Route', data.route],
['Scheduled Date', fmt(data.scheduledDate)],
['Train Schedule', data.trainSchedule],
['Container Number', data.containerNumber],
['Booking Containers', data.bookingContainerSummary],
['Cargo / Goods Description', data.cargoDescription],
['Quantity', data.quantity],
['Declared Weight', `${data.weight.toLocaleString()} kg`],
['Inventory Weight', `${data.weight.toLocaleString()} kg`],
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null],
['Warehouse', data.warehouse],
['Yard', data.yard],
['Zone', data.zone],
@@ -2849,6 +3150,7 @@ export class WarehouseInventoryService {
['Inspection Status', data.inspectionStatus],
['Release Order', data.releaseOrderReference],
['Release Date', fmt(data.releaseDate)],
['Last-mile Delivery Address', data.lastMileDeliveryAddress],
];
const approval = data.customerApproval;
@@ -2898,7 +3200,8 @@ export class WarehouseInventoryService {
</div>
<div class="rule"></div>
<div class="notice">
This document confirms EDR handed over the listed import goods to the customer after warehouse inspection passed.
This handover document is separate from the warehouse Exit Paper. It records the booking, route, cargo, container,
inspection, release, and customer approval details for the goods being handed to the customer.
</div>
<div class="section-title">Handover Particulars</div>
<table>
@@ -2911,7 +3214,9 @@ export class WarehouseInventoryService {
<tbody>
<tr><th>1. Goods</th><td>${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}</td></tr>
<tr><th>Container</th><td>${esc(data.containerNumber)}</td></tr>
<tr><th>Weight</th><td>${esc(`${data.weight.toLocaleString()} kg`)}</td></tr>
<tr><th>Booking Containers</th><td>${esc(data.bookingContainerSummary)}</td></tr>
<tr><th>Inventory Weight</th><td>${esc(`${data.weight.toLocaleString()} kg`)}</td></tr>
<tr><th>Booking Declared Weight</th><td>${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null)}</td></tr>
</tbody>
</table>
<div class="section-title">Handover Clause</div>
@@ -3156,6 +3461,21 @@ export class WarehouseInventoryService {
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
}
private async generateReleaseReference(item: WarehouseInventory): Promise<string> {
let bookingReference = item.booking?.reference;
if (!bookingReference && item.bookingId) {
const [booking]: Array<{ reference: string | null }> = await this.dataSource.query(
`SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1`,
[item.bookingId],
);
bookingReference = booking?.reference ?? undefined;
}
if (bookingReference) {
return `REL-${String(bookingReference).replace(/^BK-?/i, '')}`;
}
return `REL-${new Date().toISOString().slice(0, 10).replace(/-/g, '')}-${item.id.replace(/-/g, '').slice(0, 8).toUpperCase()}`;
}
private buildExitInspectionNote(dto: ReleaseOrderDto): string | null {
const hasExitInspection =
Boolean(dto.truckPlateNumber?.trim()) ||
@@ -3179,17 +3499,27 @@ export class WarehouseInventoryService {
if (!dto.driverName?.trim()) {
throw new BadRequestException('Driver name is required for exit inspection');
}
if (dto.tareWeight === undefined || dto.grossWeight === undefined) {
throw new BadRequestException('Tare weight and gross weight are required for exit inspection');
if (dto.tareWeight === undefined) {
throw new BadRequestException('Tare weight is required for truck arrival');
}
const tareWeight = Number(dto.tareWeight);
const grossWeight = Number(dto.grossWeight);
const computedNetWeight = Number((grossWeight - tareWeight).toFixed(3));
const submittedNetWeight = dto.netWeight === undefined ? computedNetWeight : Number(dto.netWeight);
const grossWeight = dto.grossWeight === undefined ? null : Number(dto.grossWeight);
const computedNetWeight =
grossWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
const submittedNetWeight =
dto.netWeight === undefined || computedNetWeight == null ? computedNetWeight : Number(dto.netWeight);
if (Math.abs(submittedNetWeight - computedNetWeight) > 0.001) {
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
if (grossWeight != null && !dto.gateOutTime) {
throw new BadRequestException('Gate out time is required for truck exit');
}
if (grossWeight != null && computedNetWeight != null && submittedNetWeight != null) {
if (Math.abs(submittedNetWeight - computedNetWeight) > 0.001) {
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
}
}
if ((dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) && grossWeight == null) {
throw new BadRequestException('Gross weight is required for truck exit');
}
const rows = [
@@ -3205,14 +3535,27 @@ export class WarehouseInventoryService {
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
`Tare Weight: ${tareWeight} kg`,
`Gross Weight: ${grossWeight} kg`,
`Net Weight: ${computedNetWeight} kg`,
grossWeight == null ? null : `Gross Weight: ${grossWeight} kg`,
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} kg`,
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
];
return rows.filter(Boolean).join('\n');
}
private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null {
const trimmed = notes?.trim();
if (!exitInspectionNote) return trimmed || null;
if (!trimmed) return exitInspectionNote;
const marker = '[Exit Inspection]';
const index = trimmed.lastIndexOf(marker);
if (index < 0) {
return `${trimmed}\n\n${exitInspectionNote}`;
}
return [trimmed.slice(0, index).trim(), exitInspectionNote].filter(Boolean).join('\n\n');
}
private extractExitInspectionNote(notes?: string | null): string | null {
if (!notes) return null;
const marker = '[Exit Inspection]';
@@ -3221,6 +3564,40 @@ export class WarehouseInventoryService {
return notes.slice(index + marker.length).trim() || null;
}
private extractReceiveSummary(notes?: string | null): string | null {
if (!notes?.trim()) return null;
const withoutExit = notes.split('\n\n[Exit Inspection]')[0] ?? notes;
const withoutHandover = withoutExit.split(`\n\n${HANDOVER_DOCUMENT_MARKER}`)[0] ?? withoutExit;
return this.stripCustomerDeliveryApproval(withoutHandover)?.trim() || withoutHandover.trim() || null;
}
private buildHandoverDocumentNote(reference: string, generatedAt: Date): string {
return [
HANDOVER_DOCUMENT_MARKER,
`Handover Reference: ${reference}`,
`Generated At: ${generatedAt.toISOString()}`,
].join('\n');
}
private replaceHandoverDocumentNote(notes: string | null | undefined, handoverDocumentNote: string): string {
const trimmed = notes?.trim();
if (!trimmed) return handoverDocumentNote;
const index = trimmed.lastIndexOf(HANDOVER_DOCUMENT_MARKER);
if (index < 0) {
return `${trimmed}\n\n${handoverDocumentNote}`;
}
return [trimmed.slice(0, index).trim(), handoverDocumentNote].filter(Boolean).join('\n\n');
}
private extractHandoverDocumentLine(notes: string | null | undefined, label: string): string | null {
if (!notes) return null;
const index = notes.lastIndexOf(HANDOVER_DOCUMENT_MARKER);
if (index < 0) return null;
const section = notes.slice(index + HANDOVER_DOCUMENT_MARKER.length);
const match = section.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
return match?.[1]?.trim() || null;
}
private buildReceiveNote(input: {
grnNumber: string;
direction?: string | null;

View File

@@ -0,0 +1,142 @@
import 'reflect-metadata';
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(__dirname, '../../.env') });
import { NestFactory } from '@nestjs/core';
import { DataSource } from 'typeorm';
import { AppModule } from '../app.module';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity';
const BOOKING_REFS = [
'WH-EXP-RCV-001',
'WH-EXP-RCV-002',
'WH-EXP-RCV-003',
'WH-EXP-RCV-004',
'WH-EXP-RCV-005',
];
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['error', 'warn', 'log'],
});
try {
const dataSource = app.get(DataSource);
const yardRepo = dataSource.getRepository(Yard);
const serviceTypeRepo = dataSource.getRepository(ServiceType);
const cargoTypeRepo = dataSource.getRepository(CargoType);
const containerTypeRepo = dataSource.getRepository(ContainerType);
const bookingRepo = dataSource.getRepository(Booking);
const bookingContainerRepo = dataSource.getRepository(BookingContainer);
const inventoryRepo = dataSource.getRepository(WarehouseInventory);
const originYard =
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
const destinationYard =
(await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ??
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
const serviceType =
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER', includesFirstMile: false, isActive: true } })) ??
(await serviceTypeRepo.findOne({ where: { includesFirstMile: false, isActive: true } }));
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
const containerType =
(await containerTypeRepo.findOne({ where: { code: '40FT', isActive: true } })) ??
(await containerTypeRepo.findOne({ where: { code: '40', isActive: true } })) ??
(await containerTypeRepo.findOne({ where: { sizeFt: 40, isActive: true } })) ??
(await containerTypeRepo.findOne({ where: { isActive: true } }));
const missing = [
!originYard ? 'MOJO/Ethiopia origin yard' : '',
!destinationYard ? 'DJIB_PORT/Djibouti destination yard' : '',
!serviceType ? 'active service type without first mile' : '',
!containerType ? 'active container type' : '',
].filter(Boolean);
if (missing.length) {
throw new Error(`Cannot seed warehouse export receive-ready bookings, missing: ${missing.join(', ')}`);
}
let created = 0;
let skipped = 0;
const now = Date.now();
for (const [index, reference] of BOOKING_REFS.entries()) {
const existing = await bookingRepo.findOne({ where: { reference } });
if (existing) {
skipped += 1;
continue;
}
const containerQuantity = index === 4 ? 2 : 1;
const weightKg = 18_000 + index * 1_250 + (containerQuantity - 1) * 9_000;
const scheduledDate = new Date(now + index * 60 * 60_000);
const booking = await bookingRepo.save(
bookingRepo.create({
reference,
originYardId: originYard!.id,
destinationYardId: destinationYard!.id,
serviceTypeId: serviceType!.id,
status: 'PAID',
paymentStatus: 'PAID',
scheduledDate,
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',
paymentCurrency: 'ETB',
totalAmount: 0,
isGovernment: false,
tradeDirection: 'EXPORT',
freightType: 'CONTAINER',
cargoTypeId: cargoType?.id ?? null,
cargoFreeText: cargoType ? null : `Warehouse export receive-ready cargo ${index + 1}`,
cargoTotalWeightVgm: weightKg,
schedulingStatus: 'NOT_SCHEDULED',
}),
);
await bookingContainerRepo.save(
bookingContainerRepo.create({
bookingId: booking.id,
containerTypeId: containerType!.id,
containerNumber: `EDRU${String(730100 + index).padStart(6, '0')}`,
containerSize: containerType!.sizeFt ? `${containerType!.sizeFt}ft` : containerType!.code,
quantity: containerQuantity,
hazardousQuantity: 0,
reeferQuantity: 0,
vgmPerUnitTons: Number((weightKg / containerQuantity / 1000).toFixed(3)),
totalVgmTons: Number((weightKg / 1000).toFixed(3)),
wagonsRequired: Math.max(1, containerQuantity * Number(containerType!.wagonsPerUnit ?? 1)),
isOverweight: false,
}),
);
const inventory = await inventoryRepo.findOne({ where: { bookingId: booking.id } });
if (inventory) {
throw new Error(`Seed invariant failed: booking ${reference} unexpectedly has warehouse inventory`);
}
created += 1;
}
console.log(`Warehouse export receive-ready seed complete. Created ${created}, skipped ${skipped}.`);
console.log(`Booking refs: ${BOOKING_REFS.join(', ')}`);
console.log('Open Backoffice Warehouse > Receive for loading > Export / Receive to Warehouse.');
} finally {
await app.close();
}
}
main().catch((error) => {
console.error('Warehouse export receive-ready seed failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,164 @@
import { useState, useMemo } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Box,
Button,
Group,
Loader,
Select,
Stack,
Table,
Text,
Alert,
} from "@mantine/core";
import { AlertCircle } from "lucide-react";
import toast from "react-hot-toast";
import { vehiclesService } from "@/services/vehicles.service";
export interface ContainerAllocationRow {
id: string;
type: string;
qty: number;
}
export interface ContainerAllocationTableProps {
bookingId: string;
containers: ContainerAllocationRow[];
onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise<void>;
}
/**
* Manual container-to-vehicle allocation table for freight bookings.
* Displays containers with type/qty, vehicle dropdown per row, and save action.
*/
export function ContainerAllocationTable({
bookingId,
containers,
onSave,
}: ContainerAllocationTableProps) {
const [allocations, setAllocations] = useState<Record<string, string | null>>(
() => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
queryKey: ["vehicles", "active"],
queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }),
});
const vehicleOptions = useMemo(
() =>
vehicles.map((v) => ({
value: v.id,
label: `${v.plateNumber} (${v.vehicleType})`,
description: `${v.model} · ${v.manufacturer}`,
})),
[vehicles],
);
const saveAllocation = useMutation({
mutationFn: async () => {
const mappings = containers
.filter((c) => allocations[c.id])
.map((c) => ({
containerId: c.id,
vehicleId: allocations[c.id]!,
}));
if (mappings.length === 0) {
throw new Error("No containers allocated to vehicles");
}
await onSave(mappings);
},
onSuccess: () => {
toast.success("Container allocations saved");
setAllocations(
containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to save allocations",
);
},
});
const allocatedCount = Object.values(allocations).filter(Boolean).length;
const allAllocated = allocatedCount === containers.length;
if (vehiclesLoading) {
return (
<Box display="flex" justifyContent="center" p="xl">
<Loader size="sm" />
</Box>
);
}
return (
<Stack gap="md">
{vehicles.length === 0 && (
<Alert icon={<AlertCircle size={16} />} color="yellow">
No active vehicles available. Add vehicles before allocating containers.
</Alert>
)}
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Container ID</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Assigned Vehicle</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((container) => (
<Table.Tr key={container.id}>
<Table.Td>
<Text fw={600} size="sm">
{container.id}
</Text>
</Table.Td>
<Table.Td>{container.type}</Table.Td>
<Table.Td>{container.qty}</Table.Td>
<Table.Td>
<Select
placeholder="Select vehicle"
data={vehicleOptions}
value={allocations[container.id] ?? null}
onChange={(value) =>
setAllocations((prev) => ({
...prev,
[container.id]: value,
}))
}
searchable
clearable
disabled={vehicles.length === 0}
style={{ minWidth: 200 }}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<Group justify="space-between">
<Text size="sm" c="dimmed">
{allocatedCount} of {containers.length} containers allocated
</Text>
<Button
color="edr-green"
loading={saveAllocation.isPending}
disabled={allocatedCount === 0 || vehicles.length === 0}
onClick={() => saveAllocation.mutate()}
>
Save Allocations
</Button>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,164 @@
import { useState, useMemo } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Box,
Button,
Group,
Loader,
Select,
Stack,
Table,
Text,
Alert,
} from "@mantine/core";
import { AlertCircle } from "lucide-react";
import toast from "react-hot-toast";
import { vehiclesService } from "@/services/vehicles.service";
export interface ContainerAllocationRow {
id: string;
type: string;
qty: number;
}
export interface FirstMileContainerAllocationTableProps {
firstMileId: string;
containers: ContainerAllocationRow[];
onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise<void>;
}
/**
* Manual container-to-vehicle allocation table for first-mile pickups.
* Displays containers with type/qty, vehicle dropdown per row, and save action.
*/
export function FirstMileContainerAllocationTable({
firstMileId,
containers,
onSave,
}: FirstMileContainerAllocationTableProps) {
const [allocations, setAllocations] = useState<Record<string, string | null>>(
() => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
queryKey: ["vehicles", "active"],
queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }),
});
const vehicleOptions = useMemo(
() =>
vehicles.map((v) => ({
value: v.id,
label: `${v.plateNumber} (${v.vehicleType})`,
description: `${v.model} · ${v.manufacturer}`,
})),
[vehicles],
);
const saveAllocation = useMutation({
mutationFn: async () => {
const mappings = containers
.filter((c) => allocations[c.id])
.map((c) => ({
containerId: c.id,
vehicleId: allocations[c.id]!,
}));
if (mappings.length === 0) {
throw new Error("No containers allocated to vehicles");
}
await onSave(mappings);
},
onSuccess: () => {
toast.success("Container allocations saved");
setAllocations(
containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to save allocations",
);
},
});
const allocatedCount = Object.values(allocations).filter(Boolean).length;
const allAllocated = allocatedCount === containers.length;
if (vehiclesLoading) {
return (
<Box display="flex" justifyContent="center" p="xl">
<Loader size="sm" />
</Box>
);
}
return (
<Stack gap="md">
{vehicles.length === 0 && (
<Alert icon={<AlertCircle size={16} />} color="yellow">
No active vehicles available. Add vehicles before allocating containers.
</Alert>
)}
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Container ID</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Assigned Vehicle</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((container) => (
<Table.Tr key={container.id}>
<Table.Td>
<Text fw={600} size="sm">
{container.id}
</Text>
</Table.Td>
<Table.Td>{container.type}</Table.Td>
<Table.Td>{container.qty}</Table.Td>
<Table.Td>
<Select
placeholder="Select vehicle"
data={vehicleOptions}
value={allocations[container.id] ?? null}
onChange={(value) =>
setAllocations((prev) => ({
...prev,
[container.id]: value,
}))
}
searchable
clearable
disabled={vehicles.length === 0}
style={{ minWidth: 200 }}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<Group justify="space-between">
<Text size="sm" c="dimmed">
{allocatedCount} of {containers.length} containers allocated
</Text>
<Button
color="edr-green"
loading={saveAllocation.isPending}
disabled={allocatedCount === 0 || vehicles.length === 0}
onClick={() => saveAllocation.mutate()}
>
Save Allocations
</Button>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,164 @@
import { useState, useMemo } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Box,
Button,
Group,
Loader,
Select,
Stack,
Table,
Text,
Alert,
} from "@mantine/core";
import { AlertCircle } from "lucide-react";
import toast from "react-hot-toast";
import { vehiclesService } from "@/services/vehicles.service";
export interface LastMileContainerRow {
id: string;
type: string;
qty: number;
}
export interface LastMileContainerAllocationTableProps {
lastMileId: string;
containers: LastMileContainerRow[];
onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise<void>;
}
/**
* Manual container-to-vehicle allocation table for last-mile deliveries.
* Displays containers with type/qty, vehicle dropdown per row, and save action.
*/
export function LastMileContainerAllocationTable({
lastMileId,
containers,
onSave,
}: LastMileContainerAllocationTableProps) {
const [allocations, setAllocations] = useState<Record<string, string | null>>(
() => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
queryKey: ["vehicles", "active"],
queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }),
});
const vehicleOptions = useMemo(
() =>
vehicles.map((v) => ({
value: v.id,
label: `${v.plateNumber} (${v.vehicleType})`,
description: `${v.model} · ${v.manufacturer}`,
})),
[vehicles],
);
const saveAllocation = useMutation({
mutationFn: async () => {
const mappings = containers
.filter((c) => allocations[c.id])
.map((c) => ({
containerId: c.id,
vehicleId: allocations[c.id]!,
}));
if (mappings.length === 0) {
throw new Error("No containers allocated to vehicles");
}
await onSave(mappings);
},
onSuccess: () => {
toast.success("Container allocations saved");
setAllocations(
containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to save allocations",
);
},
});
const allocatedCount = Object.values(allocations).filter(Boolean).length;
const allAllocated = allocatedCount === containers.length;
if (vehiclesLoading) {
return (
<Box display="flex" justifyContent="center" p="xl">
<Loader size="sm" />
</Box>
);
}
return (
<Stack gap="md">
{vehicles.length === 0 && (
<Alert icon={<AlertCircle size={16} />} color="yellow">
No active vehicles available. Add vehicles before allocating containers.
</Alert>
)}
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Container ID</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Assigned Vehicle</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((container) => (
<Table.Tr key={container.id}>
<Table.Td>
<Text fw={600} size="sm">
{container.id}
</Text>
</Table.Td>
<Table.Td>{container.type}</Table.Td>
<Table.Td>{container.qty}</Table.Td>
<Table.Td>
<Select
placeholder="Select vehicle"
data={vehicleOptions}
value={allocations[container.id] ?? null}
onChange={(value) =>
setAllocations((prev) => ({
...prev,
[container.id]: value,
}))
}
searchable
clearable
disabled={vehicles.length === 0}
style={{ minWidth: 200 }}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<Group justify="space-between">
<Text size="sm" c="dimmed">
{allocatedCount} of {containers.length} containers allocated
</Text>
<Button
color="edr-green"
loading={saveAllocation.isPending}
disabled={allocatedCount === 0 || vehicles.length === 0}
onClick={() => saveAllocation.mutate()}
>
Save Allocations
</Button>
</Group>
</Stack>
);
}

View File

@@ -23,11 +23,20 @@ function DetailRow({ label, value }: { label: string; value: React.ReactNode })
);
}
const noteLineValue = (notes: string | null | undefined, label: string) => {
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
return match?.[1]?.trim() ?? '';
};
export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) {
const bookingReference = item?.booking?.reference ?? '-';
const handoverReference = item?.handoverDocumentReference ?? noteLineValue(item?.notes, 'Handover Reference');
const handoverDate = item?.handoverDocumentDate ?? noteLineValue(item?.notes, 'Generated At');
const inventorySummary = [
item?.status?.replace(/_/g, ' '),
item?.grnNumber ? `GRN ${item.grnNumber}` : null,
item?.releaseOrderReference ? `Release ${item.releaseOrderReference}` : null,
handoverReference ? `Handover ${handoverReference}` : null,
item?.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : null,
]
.filter(Boolean)
@@ -61,11 +70,13 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
<Divider label="Booking & item" labelPosition="left" />
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Booking reference" value={bookingReference} />
<DetailRow label="GRN" value={item.grnNumber ?? '-'} />
<DetailRow label="Booking status" value={item.booking?.status ?? '-'} />
<DetailRow label="Payment status" value={item.booking?.paymentStatus ?? '-'} />
<DetailRow label="Trade direction" value={item.booking?.tradeDirection ?? '-'} />
<DetailRow label="Inventory status" value={item.status.replace(/_/g, ' ')} />
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
<DetailRow label="Handover reference" value={handoverReference || '-'} />
<DetailRow label="Quantity" value={formatNumber(item.quantity)} />
<DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} />
<DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} />
@@ -83,6 +94,7 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
<DetailRow label="Dispatched" value={formatDate(item.dispatchedAt)} />
<DetailRow label="Ready for pickup" value={formatDate(item.readyForPickupAt)} />
<DetailRow label="Released" value={formatDate(item.releaseDate)} />
<DetailRow label="Handover generated" value={formatDate(handoverDate)} />
<DetailRow label="Delivered" value={formatDate(item.deliveredAt)} />
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
</SimpleGrid>

View File

@@ -1,4 +1,4 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import { Fragment, useEffect, useMemo, useState, type MouseEvent } from 'react';
import {
ActionIcon,
Alert,
@@ -79,6 +79,45 @@ interface ReceiveInventoryModalProps {
onReceived?: () => void;
}
function GrnDocumentButton({ inventoryId, grnNumber }: { inventoryId: string; grnNumber?: string | null }) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const openDocument = async (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (!grnNumber) {
toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' });
return;
}
setLoading(true);
const pdfWindow = window.open('', '_blank');
try {
const response = await warehouseService.downloadGrnDocument(inventoryId);
const opened = openPdfBlob(response.data, `grn-${grnNumber}.pdf`, pdfWindow);
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
} finally {
setLoading(false);
}
};
return (
<Button
size="compact-xs"
variant="subtle"
color="teal"
leftSection={<FileText size={12} />}
disabled={!grnNumber}
loading={loading}
onClick={openDocument}
>
{grnNumber ?? 'No GRN'}
</Button>
);
}
interface Location {
warehouseId: string;
yardId: string;
@@ -620,11 +659,13 @@ function EligibleTab({
const { toast } = useToast();
const qc = useQueryClient();
const { data: allRows = [], isLoading } = useQuery(
api.warehouses.eligibleBookings.queryOptions({ enabled }),
api.warehouses.eligibleBookings.queryOptions({
input: { direction },
enabled,
}),
);
const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]);
const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions());
const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions());
const requestFirstMile = useMutation({
mutationFn: (reference: string) => firstMileService.accept(reference),
onSuccess: () => {
@@ -782,10 +823,24 @@ function EligibleTab({
return;
}
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
const totalContainerQuantity = selectedRows.reduce(
(sum, row) => sum + Number(row.containerQuantity ?? 0),
0,
);
const normalizedForm =
nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0
? {
...form,
unitCount: totalContainerQuantity,
}
: form;
setPendingReceiveIds(filteredIds);
setReceivedAt(new Date().toISOString());
setTruckForm(form);
setLockedTruckFields(lockedFields);
setTruckForm(normalizedForm);
setLockedTruckFields({
...lockedFields,
unitCount: nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0,
});
setPackagingFreightType(nextPackagingFreightType);
setTruckOpen(true);
};
@@ -798,18 +853,6 @@ function EligibleTab({
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
};
const loadPassedExport = async () => {
try {
const r = await loadPassed.mutateAsync(undefined);
toast({
title: `${r.loadedCount} loaded`,
description: r.skippedCount ? `${r.skippedCount} skipped — inspection not passed` : undefined,
});
onChanged?.();
} catch (error) {
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) });
}
};
return (
<Stack gap="sm" mt="sm">
@@ -828,18 +871,6 @@ function EligibleTab({
Selected: <b>{selected.size}</b> / {statusFilteredRows.length} eligible
</Text>
<Group gap="xs">
{direction === 'EXPORT' && (
<Button
size="compact-sm"
variant="light"
color="teal"
leftSection={<Truck size={14} />}
loading={loadPassed.isPending}
onClick={loadPassedExport}
>
Load Passed Export Items
</Button>
)}
<Button
size="compact-sm"
color={direction === 'EXPORT' ? 'edr-green' : undefined}
@@ -849,7 +880,7 @@ function EligibleTab({
loading={bulkReceive.isPending}
onClick={() => openTruckReceive(selected.size > 0 ? [...selected] : selectableRows.map((r) => r.id))}
>
{direction === 'EXPORT' ? 'Receive to Warehouse' : 'Receive All to Warehouse'}
{direction === 'EXPORT' ? 'Receive All for Loading' : 'Receive All to Warehouse'}
</Button>
<Button
size="compact-sm"
@@ -858,7 +889,7 @@ function EligibleTab({
loading={bulkReceive.isPending}
onClick={() => openTruckReceive([...selected])}
>
Receive Selected
{direction === 'EXPORT' ? 'Receive Selected for Loading' : 'Receive Selected'}
</Button>
</Group>
</Group>
@@ -1000,7 +1031,7 @@ function EligibleTab({
loading={bulkReceive.isPending}
onClick={() => openTruckReceive([r.id])}
>
{canReceive ? 'Receive to Warehouse' : 'Await First Mile'}
{canReceive ? (direction === 'EXPORT' ? 'Receive for Loading' : 'Receive to Warehouse') : 'Await First Mile'}
</Button>
)}
</Table.Td>
@@ -1015,7 +1046,7 @@ function EligibleTab({
<Modal
opened={truckOpen}
onClose={() => setTruckOpen(false)}
title="Receive to Warehouse"
title={direction === 'EXPORT' ? 'Receive for Loading' : 'Receive to Warehouse'}
centered
size="lg"
>
@@ -1175,6 +1206,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
/>
</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
@@ -1201,7 +1233,13 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
/>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<Stack gap={2}>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Stack>
</Table.Td>
<Table.Td>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
@@ -1322,6 +1360,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
/>
</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
@@ -1344,7 +1383,13 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
/>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<Stack gap={2}>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Stack>
</Table.Td>
<Table.Td>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
@@ -1492,6 +1537,7 @@ function LoadedExportTab({
</Table.Th>
)}
<Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
@@ -1516,7 +1562,13 @@ function LoadedExportTab({
</Table.Td>
)}
<Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<Stack gap={2}>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Stack>
</Table.Td>
<Table.Td>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
@@ -1866,12 +1918,15 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
bookingId: row.bookingId,
quantity: 1,
weight: Number(row.weight) || 0,
grnNumber: row.grnNumber,
status: row.currentStatus,
arrivedAt: row.arrivalTime,
unloadedAt: row.arrivalTime,
inspectionStatus: row.inspectionStatus,
releaseDate: row.releaseDate,
releaseOrderReference: row.releaseOrderReference,
handoverDocumentReference: row.handoverDocumentReference,
handoverDocumentDate: row.handoverDocumentDate,
deliveredAt: row.deliveredAt,
booking: row.bookingId
? {
@@ -1902,6 +1957,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
try {
const response = await warehouseService.downloadHandoverDocument(row.id);
openPdfBlob(response.data, `handover-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) });
@@ -1910,6 +1966,20 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
}
};
const openReleaseDocument = async (row: ImportUnloadedItem) => {
setBusyId(row.id);
const pdfWindow = window.open('', '_blank');
try {
const response = await warehouseService.downloadReleaseDocument(row.id);
openPdfBlob(response.data, `release-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'Exit paper failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
return (
<Stack gap="sm" mt="sm">
<Group justify="space-between">
@@ -1959,6 +2029,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
<Table.Th>Arrival Time</Table.Th>
@@ -1987,7 +2058,13 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<Stack gap={2}>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Stack>
</Table.Td>
<Table.Td>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}` : '—'}</Text>
@@ -2049,7 +2126,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
color="yellow"
onClick={() => setReleaseItem(toInventoryItem(r))}
>
Truck Arrival
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
</Button>
</>
)}
@@ -2064,6 +2141,18 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
Dispatch
</Button>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<FileText size={14} />}
loading={busyId === r.id}
onClick={() => openReleaseDocument(r)}
>
Exit Paper
</Button>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
<Button
size="compact-xs"
@@ -2082,7 +2171,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
leftSection={<FileText size={14} />}
onClick={() => openHandoverDocument(r)}
>
Handover
{r.handoverDocumentReference ? 'View Handover' : 'Handover'}
</Button>
)}
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
@@ -2398,7 +2487,12 @@ function ExportWarehouseTabs({
onChanged?: () => void;
}) {
const [activeTab, setActiveTab] = useState<ExportWarehouseTab>('receive-queue');
const { data: eligibleRows = [] } = useQuery(api.warehouses.eligibleBookings.queryOptions({ enabled }));
const { data: eligibleRows = [] } = useQuery(
api.warehouses.eligibleBookings.queryOptions({
input: { direction: 'EXPORT' },
enabled,
}),
);
const { data: receivedRows = [] } = useQuery(api.warehouses.receivedExport.queryOptions({ enabled }));
const { data: readyRows = [] } = useQuery(api.warehouses.readyToLoadExport.queryOptions({ enabled }));
const { data: loadedRows = [] } = useQuery(api.warehouses.loadedExport.queryOptions({ enabled }));

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, NumberInput, Select, Stack, Text, TextInput } from '@mantine/core';
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
@@ -46,6 +46,77 @@ const toIsoDateTime = (value: string) => {
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
};
const toLocalDateTimeInput = (value?: string | null) => {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const offsetMs = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
};
const generateReleaseReference = (item: WarehouseInventoryItem | null) => {
const bookingReference = item?.booking?.reference;
if (bookingReference) return `REL-${bookingReference.replace(/^BK-?/i, '')}`;
if (item?.bookingId) return `REL-${item.bookingId.replace(/-/g, '').slice(0, 8).toUpperCase()}`;
return '';
};
const lineValue = (notes: string | null | undefined, label: string) => {
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
return match?.[1]?.trim() ?? '';
};
const lineNumber = (notes: string | null | undefined, label: string): number | '' => {
const value = lineValue(notes, label).replace(/\s*kg$/i, '');
if (!value) return '';
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : '';
};
const splitContainerNumbers = (value: string | null | undefined) =>
(value ?? '')
.split(/[,;\n]+/)
.map((number) => number.trim())
.filter(Boolean);
const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
(item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? '';
const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => {
const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
?.booking?.freightType;
return Boolean(item?.containerId || containerCount > 0 || freightType === 'CONTAINER');
};
const initialContainerNumbers = (item: WarehouseInventoryItem | null, savedContainerNumber: string) => {
const savedNumbers = splitContainerNumbers(savedContainerNumber);
const itemNumbers = splitContainerNumbers(getItemContainerNumber(item));
const sourceNumbers = savedNumbers.length ? savedNumbers : itemNumbers;
const quantityCount = isContainerInventory(item, sourceNumbers.length) ? Number(item?.quantity ?? 0) : 0;
const expectedCount = Math.max(1, sourceNumbers.length, quantityCount);
return Array.from({ length: expectedCount }, (_, index) => sourceNumbers[index] ?? '');
};
const parseInspectionNote = (notes: string | null | undefined) => {
const marker = '[Exit Inspection]';
const index = notes?.lastIndexOf(marker) ?? -1;
const note = index >= 0 ? notes?.slice(index + marker.length) : notes;
return {
truckPlateNumber: lineValue(note, 'Truck Plate'),
trailerPlateNumber: lineValue(note, 'Trailer Plate'),
driverName: lineValue(note, 'Driver'),
driverLicense: lineValue(note, 'Driver License'),
driverPhone: lineValue(note, 'Driver Phone'),
truckType: lineValue(note, 'Truck Type'),
containerNumber: lineValue(note, 'Container Number'),
gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')),
tareWeight: lineNumber(note, 'Tare Weight'),
grossWeight: lineNumber(note, 'Gross Weight'),
netWeight: lineNumber(note, 'Net Weight'),
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
};
};
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
const { toast } = useToast();
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
@@ -56,7 +127,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
const [driverLicense, setDriverLicense] = useState('');
const [driverPhone, setDriverPhone] = useState('');
const [truckType, setTruckType] = useState('');
const [containerNumber, setContainerNumber] = useState('');
const [containerNumbers, setContainerNumbers] = useState<string[]>(['']);
const [gateInTime, setGateInTime] = useState('');
const [tareWeight, setTareWeight] = useState<number | ''>('');
const [grossWeight, setGrossWeight] = useState<number | ''>('');
@@ -66,26 +137,32 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
useEffect(() => {
if (opened) {
setReference(item?.releaseOrderReference ?? '');
setTruckPlateNumber('');
setTrailerPlateNumber('');
setDriverName('');
setDriverLicense('');
setDriverPhone('');
setTruckType('');
setContainerNumber('');
setGateInTime('');
setTareWeight('');
setGrossWeight('');
setNetWeight(item?.weight != null ? Number(item.weight) : '');
setGateOutTime('');
const inspection = parseInspectionNote(item?.notes);
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
setTruckPlateNumber(inspection.truckPlateNumber);
setTrailerPlateNumber(inspection.trailerPlateNumber);
setDriverName(inspection.driverName);
setDriverLicense(inspection.driverLicense);
setDriverPhone(inspection.driverPhone);
setTruckType(inspection.truckType);
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber));
setGateInTime(inspection.gateInTime);
setTareWeight(inspection.tareWeight);
setGrossWeight(inspection.grossWeight);
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
setGateOutTime(inspection.gateOutTime);
}
}, [opened, item]);
const savedInspection = parseInspectionNote(item?.notes);
const isExitStep = savedInspection.tareWeight !== '';
const isEntranceLocked = isExitStep;
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
const computedNetWeight =
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
const weightMismatch =
computedNetWeight != null && netWeight !== '' && Math.abs(Number(netWeight) - computedNetWeight) > 0.001;
computedNetWeight != null && systemNetWeight !== '' && Math.abs(Number(systemNetWeight) - computedNetWeight) > 0.001;
const title = isExitStep ? 'Customer truck leaving and exit weighing' : 'Customer truck arrival weighing';
const handleSubmit = async () => {
if (!item) return;
@@ -93,11 +170,19 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
return;
}
if (tareWeight === '' || grossWeight === '') {
toast({ variant: 'destructive', title: 'Tare and gross weight are required' });
if (!gateInTime || tareWeight === '') {
toast({ variant: 'destructive', title: 'Gate in time and tare weight are required' });
return;
}
if (weightMismatch) {
if (isExitStep && (!gateOutTime || grossWeight === '')) {
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
return;
}
if (isExitStep && systemNetWeight === '') {
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
return;
}
if (isExitStep && weightMismatch) {
toast({
variant: 'destructive',
title: 'Weight mismatch',
@@ -105,7 +190,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
});
return;
}
const pdfWindow = window.open('', '_blank');
const pdfWindow = isExitStep ? window.open('', '_blank') : null;
try {
const released = await releaseMutation.mutateAsync({
id: item.id,
@@ -119,14 +204,22 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
driverLicense: driverLicense.trim() || undefined,
driverPhone: driverPhone.trim() || undefined,
truckType: truckType.trim() || undefined,
containerNumber: containerNumber.trim() || undefined,
containerNumber: containerNumbers.map((number) => number.trim()).filter(Boolean).join(', ') || undefined,
gateInTime: toIsoDateTime(gateInTime),
tareWeight: Number(tareWeight),
grossWeight: Number(grossWeight),
netWeight: netWeight === '' ? computedNetWeight ?? undefined : Number(netWeight),
gateOutTime: toIsoDateTime(gateOutTime),
grossWeight: grossWeight === '' ? undefined : Number(grossWeight),
netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
},
});
if (!isExitStep) {
toast({
title: 'Truck arrival saved',
description: `${released.releaseOrderReference ?? reference} is ready for exit weighing.`,
});
onClose();
return;
}
setDownloading(true);
const response = await warehouseService.downloadReleaseDocument(item.id);
const blob = response.data;
@@ -148,20 +241,27 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
};
return (
<Modal opened={opened} onClose={onClose} title="Customer truck arrival and exit weighing" centered size="lg">
<Modal opened={opened} onClose={onClose} title={title} centered size="lg">
<Stack gap="md">
<Alert icon={<Info size={16} />} color="orange" variant="light">
<Text size="sm">
Register the customer truck and driver at arrival, record tare weight, then record gross
weight at exit after loading. Gate clearance is blocked when recorded net weight does not
equal gross weight minus tare weight.
</Text>
{isExitStep ? (
<Text size="sm">
Record the truck leaving time and gross weight. The system recorded net weight is locked,
and the exit paper is generated only when it equals gross weight minus tare weight.
</Text>
) : (
<Text size="sm">
Register the customer truck and driver at arrival, then save gate in time and tare weight.
Reopen this form when the truck is leaving to complete the exit weighing.
</Text>
)}
</Alert>
<TextInput
label="Release document reference"
placeholder="e.g. REL-2026-001"
value={reference}
onChange={(e) => setReference(e.currentTarget.value)}
readOnly={isEntranceLocked}
/>
<Select
label="Registered first / last-mile truck"
@@ -169,6 +269,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
searchable
clearable
data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
disabled={isEntranceLocked}
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => {
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value);
@@ -182,35 +283,53 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
required
value={truckPlateNumber}
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
readOnly={isEntranceLocked}
/>
<TextInput
label="Trailer plate number"
value={trailerPlateNumber}
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
readOnly={isEntranceLocked}
/>
</Group>
<Group grow>
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} />
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} />
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isEntranceLocked} />
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isEntranceLocked} />
</Group>
<Group grow>
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} />
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} />
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isEntranceLocked} />
</Group>
<Group grow>
<TextInput label="Container number" value={containerNumber} onChange={(e) => setContainerNumber(e.currentTarget.value)} />
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} />
<Stack gap={6}>
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
{containerNumbers.map((containerNumber, index) => (
<TextInput
key={index}
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
value={containerNumber}
onChange={(e) =>
setContainerNumbers((numbers) =>
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
)
}
readOnly={isEntranceLocked}
/>
))}
</SimpleGrid>
</Stack>
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
</Group>
<Group grow>
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} />
<NumberInput label="Gross weight (kg)" required min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} />
<NumberInput label="Recorded net weight (kg)" min={0} value={netWeight} onChange={(v) => setNetWeight(v === '' ? '' : Number(v))} />
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
<NumberInput label="Gross weight (kg)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
<NumberInput label="Recorded net weight (system kg)" min={0} value={systemNetWeight} readOnly />
</Group>
<Group justify="space-between">
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} kg`}</b>
</Text>
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} />
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep} />
</Group>
{weightMismatch && (
<Alert icon={<Scale size={16} />} color="red" variant="light">
@@ -225,7 +344,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
Cancel
</Button>
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
Save Truck Arrival & View Exit Paper
{isExitStep ? 'Save Truck Leaving & View Exit Paper' : 'Save Truck Arrival'}
</Button>
</Group>
</Stack>

View File

@@ -1,13 +1,17 @@
import { useState, type MouseEvent } from 'react';
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
import {
getNextInventoryAction,
type InventoryAction,
type WarehouseInventoryItem,
} from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { formatDate, formatNumber, humanizeEnum } from './options';
import { extractErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
import { openPdfBlob } from './pdf';
interface WarehouseInventoryTableProps {
items: WarehouseInventoryItem[];
@@ -46,6 +50,56 @@ const actionColor: Record<InventoryAction, string> = {
deliver: 'green',
};
const releaseActionLabel = (item: WarehouseInventoryItem) =>
item.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival';
const noteLineValue = (notes: string | null | undefined, label: string) => {
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
return match?.[1]?.trim() ?? '';
};
const handoverDocumentReference = (item: WarehouseInventoryItem) =>
item.handoverDocumentReference ?? noteLineValue(item.notes, 'Handover Reference');
function GrnDocumentButton({ item }: { item: WarehouseInventoryItem }) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const openDocument = async (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (!item.grnNumber) {
toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' });
return;
}
setLoading(true);
const pdfWindow = window.open('', '_blank');
try {
const response = await warehouseService.downloadGrnDocument(item.id);
const opened = openPdfBlob(response.data, `grn-${item.grnNumber}.pdf`, pdfWindow);
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
} finally {
setLoading(false);
}
};
return (
<Button
size="compact-xs"
variant="subtle"
color="teal"
leftSection={<FileText size={12} />}
disabled={!item.grnNumber}
loading={loading}
onClick={openDocument}
>
{item.grnNumber ?? 'No GRN'}
</Button>
);
}
export function WarehouseInventoryTable({
items,
busyId,
@@ -90,6 +144,7 @@ export function WarehouseInventoryTable({
</Table.Th>
)}
<Table.Th>Booking</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th>
@@ -111,6 +166,7 @@ export function WarehouseInventoryTable({
item.inspectionStatus === 'PASSED' &&
Boolean(item.bookingId) &&
(!item.booking?.tradeDirection || item.booking.tradeDirection === 'IMPORT');
const handoverReference = handoverDocumentReference(item);
return (
<Table.Tr key={item.id}>
@@ -136,6 +192,9 @@ export function WarehouseInventoryTable({
</Text>
)}
</Table.Td>
<Table.Td>
<GrnDocumentButton item={item} />
</Table.Td>
<Table.Td>{item.warehouse?.facility?.name ?? '-'}</Table.Td>
<Table.Td>{item.warehouse?.code ?? '-'}</Table.Td>
<Table.Td>{item.yard?.code ?? '-'}</Table.Td>
@@ -170,7 +229,7 @@ export function WarehouseInventoryTable({
loading={busy}
onClick={() => onAdvance(item, nextAction)}
>
{nextAction === 'release' ? 'Truck Arrival' : humanizeEnum(nextAction.replace(/-/g, '_'))}
{nextAction === 'release' ? releaseActionLabel(item) : humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button>
)}
{item.status === 'READY_FOR_PICKUP' && (
@@ -224,7 +283,10 @@ export function WarehouseInventoryTable({
</Tooltip>
)}
{onHandoverDocument && canGenerateHandover && (
<Tooltip label="Generate customer handover document" withArrow>
<Tooltip
label={handoverReference ? `View handover document ${handoverReference}` : 'Generate customer handover document'}
withArrow
>
<ActionIcon variant="subtle" color="teal" onClick={() => onHandoverDocument(item)}>
<FileText size={16} />
</ActionIcon>

View File

@@ -389,6 +389,7 @@ export const URL_CONSTANTS = {
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`,
GRN_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/grn-document`,
HANDOVER_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/handover-document`,
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
// Receive (Import/Export bulk)

View File

@@ -1,4 +1,4 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
export const API_BASE_URL = import.meta.env.VITE_BASE_API_URL;
// export const API_BASE_URL = 'http://localhost:3001';

View File

@@ -1,5 +1,7 @@
import { Container, Grid, Stack } from "@mantine/core";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import {
BookingApprovalCard,
@@ -16,10 +18,26 @@ import {
type BookingDetailView,
} from "@/components/bookings/detail";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ContainerAllocationTable from "@/components/ContainerAllocationTable";
import { api } from "@/services/api";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
const BookingDetailPage = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const allocateMutation = useMutation({
mutationFn: (data: any) =>
api.post(`/bookings/${id}/allocate-containers`, data),
onSuccess: () => {
toast.success("Containers allocated");
qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.byId(id ?? "") });
},
onError: () => {
toast.error("Failed to allocate containers");
},
});
// Mock data - replace with actual API call
const booking: BookingDetailView = {
@@ -134,6 +152,17 @@ const BookingDetailPage = () => {
<BookingContainersCard
containers={booking.bookingContainers ?? []}
/>
<ContainerAllocationTable
bookingId={booking.id}
containers={(booking.bookingContainers ?? []).map((c) => ({
id: c.id,
type: c.containerType?.label ?? "Unknown",
qty: c.quantity,
}))}
onSave={(allocations) =>
allocateMutation.mutateAsync({ allocations })
}
/>
<BookingApprovalCard
steps={approvalSteps}
approvedCount={approvedCount}

View File

@@ -30,9 +30,11 @@ import {
Text,
TextInput,
UnstyledButton,
Alert,
} from "@mantine/core";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FirstMileContainerAllocationTable } from "@/components/FirstMileContainerAllocationTable";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useToast } from "@/hooks/use-toast";
import {
@@ -44,6 +46,7 @@ import {
import { bookingsService } from "@/services/bookings.service";
import { vehiclesService } from "@/services/vehicles.service";
import { ratesService } from "@/services/rates.service";
import { api } from "@/auth/http";
import type { BookingDetail } from "@/types/booking";
const formatPrice = (amount: number) =>
@@ -336,6 +339,9 @@ const FirstMilePage = () => {
const [invoiceOpen, setInvoiceOpen] = useState(false);
const [invoiceRecord, setInvoiceRecord] = useState<FirstMileRecord | null>(null);
const [containerAllocationOpen, setContainerAllocationOpen] = useState(false);
const [containerAllocationFirstMileId, setContainerAllocationFirstMileId] = useState<string | null>(null);
const { data: listData, isLoading } = useQuery({
queryKey: QUERY_KEYS.FIRST_MILE.list(),
queryFn: async () => {
@@ -434,6 +440,19 @@ const FirstMilePage = () => {
},
});
const allocateMutation = useMutation({
mutationFn: (data) => apiClient.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data),
onSuccess: () => {
toast({ title: "Containers allocated" });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.detail(containerAllocationFirstMileId ?? "") });
setContainerAllocationOpen(false);
setContainerAllocationFirstMileId(null);
},
onError: () => {
toast({ title: "Allocation failed", variant: "destructive" });
},
});
const activeRecord = useMemo(
() => records.find((r) => r.id === activeId) ?? null,
[records, activeId],
@@ -508,6 +527,16 @@ const FirstMilePage = () => {
setInvoiceRecord(null);
};
const openContainerAllocation = (firstMileId: string) => {
setContainerAllocationFirstMileId(firstMileId);
setContainerAllocationOpen(true);
};
const closeContainerAllocation = () => {
setContainerAllocationOpen(false);
setContainerAllocationFirstMileId(null);
};
const handleSaveDistance = () => {
const distance = parseFloat(distanceValue);
if (!activeId || isNaN(distance) || distance < 0) {
@@ -530,7 +559,6 @@ const FirstMilePage = () => {
};
const matchesFilter = (r: FirstMileRecord) => {
if (filterPostPaymentPending && r.isPostPaymentCompleted) return false;
switch (statusFilter) {
case "ALL": return true;
case "ASSIGNED": return isAssigned(r);
@@ -1272,6 +1300,56 @@ const FirstMilePage = () => {
</Group>
</Stack>
</Modal>
{/* Container Allocation modal */}
<Modal
opened={containerAllocationOpen}
onClose={closeContainerAllocation}
title={<Text fw={600}>Allocate Containers to Vehicles</Text>}
size="xl"
radius="lg"
centered
>
<Stack gap="md">
{activeRecord && (
<>
{/* Capacity guidance */}
{activeRecord.booking?.cargoType?.label === "BULK" ? (
<Alert color="blue" title="Bulk Cargo Allocation">
<Text size="sm">
Select multiple containers per vehicle based on capacity. Each vehicle can carry multiple containers if capacity allows.
</Text>
<Text size="xs" c="dimmed" mt="xs">
Capacity: TBD TODO: add vehicle capacity_tons to vehicle API if missing
</Text>
</Alert>
) : (
<Alert color="blue">
<Text size="sm">
One vehicle per container. Each container will be assigned to a single vehicle.
</Text>
</Alert>
)}
<Divider />
{/* Container table */}
<FirstMileContainerAllocationTable
firstMileId={activeRecord.id}
containers={[
// TODO: Get containers from booking/first-mile data
// For now placeholder with TODO comment
]}
onSave={async (allocations) => {
await allocateMutation.mutateAsync(allocations);
}}
/>
</>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeContainerAllocation}>Close</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};

View File

@@ -45,6 +45,8 @@ import {
} from "@/services/last-mile.service";
import { vehiclesService } from "@/services/vehicles.service";
import { ratesService } from "@/services/rates.service";
import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable";
import { api } from "@/auth/http";
const formatPrice = (amount: number) =>
`ETB ${amount.toLocaleString("en-US", {
@@ -321,6 +323,9 @@ const LastMilePage = () => {
const [invoiceOpen, setInvoiceOpen] = useState(false);
const [invoiceRecord, setInvoiceRecord] = useState<LastMileRecord | null>(null);
const [allocationOpen, setAllocationOpen] = useState(false);
const [allocationContainers, setAllocationContainers] = useState<LastMileContainerRow[]>([]);
const { data: listData, isLoading } = useQuery({
queryKey: QUERY_KEYS.LAST_MILE.list(),
queryFn: async () => {
@@ -385,6 +390,19 @@ const LastMilePage = () => {
},
});
const allocateMutation = useMutation({
mutationFn: (data: Array<{ containerId: string; vehicleId: string }>) =>
api.post(`/last-mile/${activeId}/allocate-containers`, data),
onSuccess: () => {
toast({ title: "Containers allocated", variant: "default" });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.detail(activeId ?? "") });
closeAllocation();
},
onError: () => {
toast({ title: "Allocation failed", variant: "destructive" });
},
});
const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({
queryKey: ["warehouse-inventory", "arrival-queue"],
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
@@ -477,6 +495,18 @@ const LastMilePage = () => {
setInvoiceRecord(null);
};
const openAllocation = (id: string, containers?: LastMileContainerRow[]) => {
setActiveId(id);
setAllocationContainers(containers ?? []);
setAllocationOpen(true);
};
const closeAllocation = () => {
setAllocationOpen(false);
setActiveId(null);
setAllocationContainers([]);
};
const handleSaveDistance = () => {
const distance = parseFloat(distanceValue);
if (!activeId || isNaN(distance) || distance < 0) {
@@ -509,7 +539,6 @@ const LastMilePage = () => {
);
const matchesFilter = (r: LastMileRecord) => {
if (filterPostPaymentPending && r.isPostPaymentCompleted) return false;
switch (statusFilter) {
case "ALL": return true;
case "ASSIGNED": return isAssigned(r);
@@ -1243,6 +1272,76 @@ const LastMilePage = () => {
</Group>
</Stack>
</Modal>
{/* Container Allocation modal */}
<Modal
opened={allocationOpen}
onClose={closeAllocation}
title={<Text fw={600}>Allocate Containers to Vehicles</Text>}
size="xl"
radius="lg"
centered
>
<Stack gap="md">
{activeRecord && (
<>
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Group justify="space-between">
<Stack gap={0}>
<Text fw={600} size="sm">{bookingRef(activeRecord)}</Text>
<Text size="xs" c="dimmed">{customerName(activeRecord)}</Text>
</Stack>
<Stack gap={0} align="flex-end">
<Text size="xs" c="dimmed" tt="uppercase">Cargo Type</Text>
<Text size="sm" fw={600}>{activeRecord.booking?.cargoType?.label ?? activeRecord.booking?.cargoType?.name ?? "—"}</Text>
</Stack>
</Group>
</Stack>
</Card>
{/* Capacity logic based on cargo type */}
{activeRecord.booking?.cargoType?.name === "BULK" ? (
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-blue-0)" style={{ borderColor: "var(--mantine-color-blue-3)" }}>
<Stack gap="sm">
<Group gap="xs">
<Text fw={600} size="sm">Smart Capacity Allocation</Text>
</Group>
<Stack gap={2}>
<Text size="sm">Capacity: TBD</Text>
<Text size="xs" c="dimmed">
TODO: add vehicle capacity_tons to vehicle API if missing
</Text>
<Text size="xs" c="dimmed">
TODO: add container weight to booking if missing
</Text>
</Stack>
<Text size="sm" fw={500} mt="xs">
Select multiple containers per vehicle based on capacity
</Text>
</Stack>
</Card>
) : (
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Text size="sm" fw={500}>One vehicle per container</Text>
</Card>
)}
</>
)}
<LastMileContainerAllocationTable
lastMileId={activeId ?? ""}
containers={allocationContainers}
onSave={async (mappings) => {
await allocateMutation.mutateAsync(mappings);
}}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeAllocation}>Close</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};

View File

@@ -1,5 +1,6 @@
import { Button, Card } from '@mantine/core';
import { PackageSearch } from 'lucide-react';
import { useState } from 'react';
import { Button, Card, Group, Modal, Stack } from '@mantine/core';
import { PackageSearch, Truck } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { PageContainer, PageHeader } from '@/components/page';
@@ -7,6 +8,7 @@ import { WarehouseFlowWorkbench } from '@/components/warehouses';
export default function ExportWarehouseFlowPage() {
const navigate = useNavigate();
const [receiveOpen, setReceiveOpen] = useState(false);
return (
<PageContainer>
@@ -14,15 +16,41 @@ export default function ExportWarehouseFlowPage() {
title="Export Operations"
subtitle="Manage export receive, terminal inventory, loading readiness, loaded items, and dispatch flow."
action={
<Button variant="light" leftSection={<PackageSearch size={16} />} onClick={() => navigate('/dashboard/import-warehouse')}>
Import Operations
</Button>
<Group gap="xs">
<Button
fw={700}
leftSection={<Truck size={16} />}
onClick={() => setReceiveOpen(true)}
>
Receive for Loading
</Button>
<Button variant="light" leftSection={<PackageSearch size={16} />} onClick={() => navigate('/dashboard/import-warehouse')}>
Import Operations
</Button>
</Group>
}
/>
<Card>
<WarehouseFlowWorkbench direction="EXPORT" />
</Card>
<Modal
opened={receiveOpen}
onClose={() => setReceiveOpen(false)}
title="Receive for loading"
centered
size="80rem"
>
<Stack gap="md">
<WarehouseFlowWorkbench enabled={receiveOpen} direction="EXPORT" />
<Group justify="flex-end">
<Button variant="default" onClick={() => setReceiveOpen(false)}>
Close
</Button>
</Group>
</Stack>
</Modal>
</PageContainer>
);
}

View File

@@ -656,11 +656,11 @@ export const api = {
({ filter }) => ["warehouse-inventory", "inquiry", filter],
),
eligibleBookings: endpoint<void, EligibleBooking[]>(
eligibleBookings: endpoint<{ direction?: 'IMPORT' | 'EXPORT' } | void, EligibleBooking[]>(
"warehouse-inventory",
"eligible-bookings",
() => warehouseService.eligibleBookings().then((r) => r.data),
() => ["warehouse-inventory", "eligible-bookings"],
(input) => warehouseService.eligibleBookings(input?.direction).then((r) => r.data),
(input) => ["warehouse-inventory", "eligible-bookings", input?.direction ?? "ALL"],
),
readyToLoadExport: endpoint<void, ReadyToLoadRow[]>(

View File

@@ -137,6 +137,10 @@ export const warehouseService = {
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_DOCUMENT(id), {
responseType: 'blob',
}),
downloadGrnDocument: (id: string) =>
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.GRN_DOCUMENT(id), {
responseType: 'blob',
}),
downloadHandoverDocument: (id: string) =>
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.HANDOVER_DOCUMENT(id), {
responseType: 'blob',

View File

@@ -190,6 +190,7 @@ export interface WarehouseInventoryItem {
quantity: number;
weight: number;
volume: number | null;
grnNumber: string | null;
status: InventoryStatus;
inspectionStatus: string | null;
arrivedAt: string | null;
@@ -203,6 +204,8 @@ export interface WarehouseInventoryItem {
readyForPickupAt: string | null;
releaseDate: string | null;
releaseOrderReference: string | null;
handoverDocumentReference?: string | null;
handoverDocumentDate?: string | null;
deliveredAt: string | null;
notes: string | null;
warehouse?: Warehouse | null;
@@ -472,6 +475,7 @@ export interface ReadyToLoadRow {
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
grnNumber: string | null;
origin: string | null;
destination: string | null;
inspectionStatus: string | null;
@@ -564,6 +568,7 @@ export interface ImportUnloadedItem {
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
grnNumber: string | null;
trainSchedule: string | null;
inspectionStatus: string | null;
pickupOption: string;
@@ -571,6 +576,8 @@ export interface ImportUnloadedItem {
currentStatus: string;
releaseDate: string | null;
releaseOrderReference: string | null;
handoverDocumentReference: string | null;
handoverDocumentDate: string | null;
deliveredAt: string | null;
}

View File

@@ -1,5 +1,4 @@
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
export const API_BASE_URL = 'http://localhost:3001';
export const API_BASE_URL = import.meta.env.VITE_BASE_API_URL;
/**
* URL that streams an uploaded file through the API by its UUID. Routes the
@@ -12,4 +11,3 @@ export function fileViewUrl(fileId: string, download = false): string {
const base = `${API_BASE_URL}/api/files/${fileId}`;
return download ? `${base}?download=1` : base;
}

View File

@@ -9,6 +9,7 @@ import {
ContractSignButton,
bookingIsSignable,
} from "@/pages/bookings/contract/ContractSignButton";
import { ApproveDeliveryButton } from "@/pages/bookings/delivery/ApproveDeliveryButton";
interface BookingRowProps {
booking: any;
@@ -35,6 +36,7 @@ export const BookingRow = memo(function BookingRow({
// Contract ready for signature → "View & sign" jumps straight to the
// full-page contract viewer where the signature flow lives.
const canSign = bookingIsSignable(booking);
const canApproveDelivery = booking.status === "COMPLETED";
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
const dest =
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
@@ -99,6 +101,12 @@ export const BookingRow = memo(function BookingRow({
<PayNowButton booking={booking} size="sm" />
) : canSign ? (
<ContractSignButton booking={booking} size="sm" />
) : canApproveDelivery ? (
<ApproveDeliveryButton
bookingId={booking.id}
size="sm"
stopPropagation
/>
) : hasInlineAction ? (
<BookingActionButton booking={booking} size="sm" />
) : (

View File

@@ -11,6 +11,7 @@ import { useFileViewer } from "@/hooks/useFileViewer";
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
import { ActivityCard } from "./components/ActivityCard";
import { ClearanceCard } from "./components/ClearanceCard";
import { ContainersCard } from "./components/ContainersCard";
@@ -72,6 +73,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
(isGeneralContract
? status === "FULLY_EXECUTED"
: status === "SELECTED_FOR_BATCH");
const canApproveDelivery = status === "COMPLETED";
const showCountdown = canPay && !!booking.paymentDeadline;
const isExpired = status === "EXPIRED";
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
@@ -93,14 +95,20 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
<PageHeader
booking={booking}
actions={
canPay &&
!showCountdown && (
<HeaderButton
green
icon={<CreditCard size={16} />}
label="Pay now"
onClick={() => setPayModalOpen(true)}
/>
(canApproveDelivery || (canPay && !showCountdown)) && (
<Group gap={8} wrap="nowrap">
{canApproveDelivery && (
<ApproveDeliveryButton bookingId={booking.id} />
)}
{canPay && !showCountdown && (
<HeaderButton
green
icon={<CreditCard size={16} />}
label="Pay now"
onClick={() => setPayModalOpen(true)}
/>
)}
</Group>
)
}
menuActions={{
@@ -248,4 +256,4 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
{viewer}
</PageShell>
);
}
}

View File

@@ -0,0 +1,73 @@
import { Button, type ButtonProps } from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2 } from "lucide-react";
import type { MouseEvent } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
type ApproveDeliveryButtonProps = ButtonProps & {
bookingId: string;
stopPropagation?: boolean;
onApproved?: () => void;
};
const errorMessage = (error: unknown) => {
const data = (error as { response?: { data?: { message?: string | string[] } } })
?.response?.data;
if (Array.isArray(data?.message)) return data.message.join(", ");
if (data?.message) return data.message;
return error instanceof Error ? error.message : "Could not approve delivery";
};
export function ApproveDeliveryButton({
bookingId,
stopPropagation,
onApproved,
size = "sm",
variant = "filled",
...props
}: ApproveDeliveryButtonProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const mutation = useMutation({
...api.bookings.approveDelivery.mutationOptions(),
onSuccess: async () => {
toast.success("Delivery approved and handover signed");
await Promise.all([
queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: bookingId }) }),
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
]);
onApproved?.();
},
onError: (error) => {
const message = errorMessage(error);
toast.error(message);
if (message.toLowerCase().includes("save your signature")) {
navigate("/signature");
}
},
});
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
if (stopPropagation) event.stopPropagation();
mutation.mutate({ id: bookingId });
};
return (
<Button
{...props}
size={size}
variant={variant}
color="edr-green"
leftSection={<CheckCircle2 size={16} />}
loading={mutation.isPending}
onClick={handleClick}
>
Approve delivery
</Button>
);
}

View File

@@ -8,6 +8,7 @@ import type {
} from "@/types/fileUploadSettings";
import {
bookingsService,
type ApproveDeliveryResponse,
BookingListFilter,
CreateBookingPayload,
GeneratePriceResponse,
@@ -303,6 +304,12 @@ export const api = {
({ orderId }) => bookingsService.checkPayment(orderId),
),
approveDelivery: endpoint<{ id: string }, ApproveDeliveryResponse>(
"bookings",
"approveDelivery",
({ id }) => bookingsService.approveDelivery(id),
),
getBookableSchedules: endpoint<
{ originYardId?: string; destinationYardId?: string },
Freight.BookableScheduleItem[]

View File

@@ -4,6 +4,12 @@
# `migration` stage, invoked as a one-shot container in CI before deploy.
FROM node:24.15.0-alpine AS base
RUN apk add --no-cache libc6-compat
# Put the pnpm content-addressable store under PNPM_HOME so the BuildKit
# `--mount=type=cache,target=/pnpm/store` below actually persists it across
# builds. Without this, pnpm stores in ~/.local/share/pnpm/store and the
# cache mount is a no-op — deps re-download on every pipeline run.
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
FROM base AS pruner
@@ -22,11 +28,14 @@ RUN pnpm --filter "@edr/passenger-api" exec prisma generate
RUN pnpm turbo build --filter="@edr/passenger-api..."
FROM base AS deployer
COPY --from=builder /app/ .
RUN pnpm deploy --filter="@edr/passenger-api" --legacy /deploy
RUN if [ -d node_modules/.prisma ]; then \
mkdir -p /deploy/node_modules && \
cp -r node_modules/.prisma /deploy/node_modules/.prisma; \
fi
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm deploy --filter="@edr/passenger-api" --legacy /deploy
# The generated Prisma client is NOT in the pnpm store (it's an output of
# `prisma generate`), so `pnpm deploy` does not copy it into /deploy. Regenerate
# it here so the runtime enum values imported from @prisma/client (Currency, …)
# are real objects instead of undefined — otherwise @IsEnum(Currency) throws
# "Cannot convert undefined or null to object" at module load.
RUN cd /deploy && npm run prisma:generate
# --- Migration image: built in CI, run as a one-shot `docker run --rm --env-file ...`
# against the real DB, as its own gated step *before* the app image is built/deployed.

View File

@@ -1,46 +0,0 @@
-- DropForeignKey
ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey";
-- DropForeignKey
ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_ticketId_fkey";
-- DropIndex
DROP INDEX IF EXISTS "passenger"."Ticket_bookingId_key";
-- AlterTable: Station
ALTER TABLE "passenger"."Station" DROP COLUMN IF EXISTS "timezone";
-- AlterTable: Ticket — add columns with safe defaults
ALTER TABLE "passenger"."Ticket"
ADD COLUMN IF NOT EXISTS "leg" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS "passengerName" TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS "scheduleId" TEXT,
ADD COLUMN IF NOT EXISTS "seatId" TEXT NOT NULL DEFAULT '';
-- DropTable
DROP TABLE IF EXISTS "passenger"."TicketSeat";
-- Remove GateValidationLog rows referencing orphan tickets first
DELETE FROM "passenger"."GateValidationLog"
WHERE "ticketId" IN (
SELECT "id" FROM "passenger"."Ticket"
WHERE "seatId" = ''
OR "seatId" NOT IN (SELECT "id" FROM "passenger"."Seat")
);
-- Remove orphan ticket rows
DELETE FROM "passenger"."Ticket"
WHERE "seatId" = ''
OR "seatId" NOT IN (SELECT "id" FROM "passenger"."Seat");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Ticket_bookingId_idx" ON "passenger"."Ticket"("bookingId");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Ticket_seatId_idx" ON "passenger"."Ticket"("seatId");
-- AddForeignKey
ALTER TABLE "passenger"."Ticket"
ADD CONSTRAINT "Ticket_seatId_fkey"
FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -1,3 +0,0 @@
-- Drop temporary defaults that were only needed for the backfill
ALTER TABLE "passenger"."Ticket" ALTER COLUMN "passengerName" DROP DEFAULT;
ALTER TABLE "passenger"."Ticket" ALTER COLUMN "seatId" DROP DEFAULT;

View File

@@ -1,2 +0,0 @@
-- Remove timezone column if it still exists
ALTER TABLE "passenger"."Station" DROP COLUMN IF EXISTS "timezone";

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "passenger"."TravelerProfile" ADD COLUMN "gender" TEXT;

View File

@@ -1,117 +0,0 @@
-- Migration: Add Configurable Fare Management System
-- Main fare configuration table
CREATE TABLE "fare_configurations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"effective_date" TIMESTAMP(3) NOT NULL,
"expiry_date" TIMESTAMP(3),
"is_active" BOOLEAN NOT NULL DEFAULT false,
"is_default" BOOLEAN NOT NULL DEFAULT false,
"created_by" TEXT,
"approved_by" TEXT,
"approved_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "fare_configurations_pkey" PRIMARY KEY ("id")
);
-- Rate structure by nationality and coach/position
CREATE TABLE "fare_rate_rules" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"nationality_type" TEXT NOT NULL, -- 'LOCAL' or 'INTERNATIONAL'
"coach_type" TEXT NOT NULL, -- 'REGULAR_SEAT', 'ECONOMY_BED', 'VIP_BED'
"bed_position" TEXT, -- 'UPPER', 'MIDDLE', 'LOWER', NULL for seats
"rate_per_km_minor" INTEGER NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "fare_rate_rules_pkey" PRIMARY KEY ("id")
);
-- Configurable fare components (insurance, premiums, service charges, taxes)
CREATE TABLE "fare_components" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"component_type" TEXT NOT NULL, -- 'INSURANCE', 'PREMIUM', 'SERVICE_CHARGE', 'TAX', 'DEMAND'
"component_name" TEXT NOT NULL,
"calculation_method" TEXT NOT NULL, -- 'MULTIPLIER', 'PERCENTAGE', 'FIXED_AMOUNT'
"value_minor" INTEGER, -- For fixed amounts
"percentage_value" DECIMAL(10,6), -- For percentages (e.g., 0.02 for 2%)
"applies_to" TEXT NOT NULL DEFAULT 'SUBTOTAL', -- 'BASE_FARE', 'SUBTOTAL', 'TOTAL'
"apply_order" INTEGER NOT NULL DEFAULT 1, -- Order of application
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "fare_components_pkey" PRIMARY KEY ("id")
);
-- Age-based pricing rules
CREATE TABLE "age_pricing_rules" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"rule_name" TEXT NOT NULL,
"min_age" INTEGER NOT NULL,
"max_age" INTEGER,
"pricing_type" TEXT NOT NULL, -- 'FREE', 'FULL_FARE', 'DISCOUNTED'
"discount_percentage" DECIMAL(5,4), -- For discounted fares
"max_free_passengers" INTEGER, -- For free fares (e.g., 1 free child)
"applies_to_components" BOOLEAN NOT NULL DEFAULT false, -- Whether discount applies to components too
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "age_pricing_rules_pkey" PRIMARY KEY ("id")
);
-- Audit trail for configuration changes
CREATE TABLE "fare_configuration_audit" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"action" TEXT NOT NULL, -- 'CREATED', 'UPDATED', 'ACTIVATED', 'DEACTIVATED'
"changed_by" TEXT,
"changes" JSONB, -- Store the actual changes made
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "fare_configuration_audit_pkey" PRIMARY KEY ("id")
);
-- Foreign key constraints
ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Indexes for performance
CREATE INDEX "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date");
CREATE INDEX "fare_configurations_is_active_idx" ON "fare_configurations"("is_active");
CREATE UNIQUE INDEX "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true;
CREATE INDEX "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position");
CREATE INDEX "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order");
CREATE INDEX "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age");
-- Add legacy mode flag to existing fare tables for gradual migration
ALTER TABLE "FareRule" ADD COLUMN "migrated_to_config_id" TEXT;
ALTER TABLE "SegmentFareRule" ADD COLUMN "migrated_to_config_id" TEXT;
-- Add feature flag support
CREATE TABLE "system_features" (
"id" TEXT NOT NULL,
"feature_name" TEXT NOT NULL UNIQUE,
"is_enabled" BOOLEAN NOT NULL DEFAULT false,
"config" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "system_features_pkey" PRIMARY KEY ("id")
);
-- Insert the configurable fares feature flag
INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config", "updated_at")
VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}', CURRENT_TIMESTAMP);

View File

@@ -1,14 +0,0 @@
-- AddColumn: iamUserId to Passenger (cross-schema reference to iam.users — no FK enforced)
ALTER TABLE "passenger"."Passenger" ADD COLUMN "iamUserId" TEXT;
-- Unique constraint: one IAM user maps to exactly one Passenger
ALTER TABLE "passenger"."Passenger" ADD CONSTRAINT "Passenger_iamUserId_key" UNIQUE ("iamUserId");
-- Index for fast lookup by iamUserId on every protected request
CREATE INDEX "Passenger_iamUserId_idx" ON "passenger"."Passenger"("iamUserId");
-- AddColumn: iamUserId to FaydaVerificationSession (no FK — cross-schema reference to iam.users)
ALTER TABLE "passenger"."FaydaVerificationSession" ADD COLUMN "iamUserId" TEXT;
-- Index for Fayda callback to resolve IAM user
CREATE INDEX "FaydaVerificationSession_iamUserId_idx" ON "passenger"."FaydaVerificationSession"("iamUserId");

View File

@@ -1,28 +0,0 @@
-- CreateTable
CREATE TABLE "SegmentFareRule" (
"id" TEXT NOT NULL,
"routeId" TEXT NOT NULL,
"originStopSequence" INTEGER NOT NULL,
"destinationStopSequence" INTEGER NOT NULL,
"seatClassId" TEXT NOT NULL,
"baseFareMinor" INTEGER NOT NULL,
"nationality" TEXT,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"validFrom" TIMESTAMP(3) NOT NULL,
"validUntil" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SegmentFareRule_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SegmentFareRule_routeId_seatClassId_idx" ON "SegmentFareRule"("routeId", "seatClassId");
-- CreateIndex
CREATE UNIQUE INDEX "SegmentFareRule_routeId_originStopSequence_destinationStopS_key" ON "SegmentFareRule"("routeId", "originStopSequence", "destinationStopSequence", "seatClassId", "nationality");
-- AddForeignKey
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -1,54 +0,0 @@
-- DropForeignKey
ALTER TABLE "passenger"."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey";
-- AlterTable
ALTER TABLE "passenger"."Passenger" ALTER COLUMN "userId" DROP NOT NULL;
-- CreateTable
CREATE TABLE IF NOT EXISTS "passenger"."TicketSeat" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"seatId" TEXT NOT NULL,
"seatIndex" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "TicketSeat_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "TicketSeat_ticketId_idx" ON "passenger"."TicketSeat"("ticketId");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "TicketSeat_seatId_idx" ON "passenger"."TicketSeat"("seatId");
-- AddForeignKey
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'Passenger_userId_fkey'
AND conrelid = 'passenger."Passenger"'::regclass
) THEN
ALTER TABLE "passenger"."Passenger" ADD CONSTRAINT "Passenger_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "passenger"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
END IF;
END $$;
-- AddForeignKey
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'TicketSeat_ticketId_fkey'
AND conrelid = 'passenger."TicketSeat"'::regclass
) THEN
ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey"
FOREIGN KEY ("ticketId") REFERENCES "passenger"."Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- AddForeignKey
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'TicketSeat_seatId_fkey'
AND conrelid = 'passenger."TicketSeat"'::regclass
) THEN
ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey"
FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
END $$;

View File

@@ -1,13 +0,0 @@
-- Drop FK constraints (they reference iam.users indirectly via local User, but these are within passenger schema)
ALTER TABLE passenger."UserPreferences" DROP CONSTRAINT IF EXISTS "UserPreferences_userId_fkey";
ALTER TABLE passenger."Device" DROP CONSTRAINT IF EXISTS "Device_userId_fkey";
ALTER TABLE passenger."FraudAlert" DROP CONSTRAINT IF EXISTS "FraudAlert_userId_fkey";
-- Rename columns (preserves all existing data)
ALTER TABLE passenger."UserPreferences" RENAME COLUMN "userId" TO "iamUserId";
ALTER TABLE passenger."Device" RENAME COLUMN "userId" TO "iamUserId";
ALTER TABLE passenger."FraudAlert" RENAME COLUMN "userId" TO "iamUserId";
-- Rename indexes on FraudAlert to match new column name
DROP INDEX IF EXISTS passenger."FraudAlert_userId_createdAt_idx";
CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON passenger."FraudAlert"("iamUserId", "createdAt");

View File

@@ -1,10 +0,0 @@
-- AuditLog: drop FK, rename column, update index
ALTER TABLE passenger."AuditLog" DROP CONSTRAINT IF EXISTS "AuditLog_userId_fkey";
ALTER TABLE passenger."AuditLog" RENAME COLUMN "userId" TO "iamUserId";
DROP INDEX IF EXISTS passenger."AuditLog_userId_createdAt_idx";
CREATE INDEX IF NOT EXISTS "AuditLog_iamUserId_createdAt_idx" ON passenger."AuditLog"("iamUserId", "createdAt");
-- FaydaVerificationSession: drop userId column and FK (iamUserId already carries this data)
ALTER TABLE passenger."FaydaVerificationSession" DROP CONSTRAINT IF EXISTS "FaydaVerificationSession_userId_fkey";
ALTER TABLE passenger."FaydaVerificationSession" DROP COLUMN IF EXISTS "userId";
DROP INDEX IF EXISTS passenger."FaydaVerificationSession_userId_idx";

View File

@@ -1,5 +0,0 @@
-- AlterTable
ALTER TABLE "Passenger" ADD COLUMN "blockedUntil" TIMESTAMP(3);
-- RenameIndex
ALTER INDEX "UserPreferences_userId_key" RENAME TO "UserPreferences_iamUserId_key";

View File

@@ -1,2 +0,0 @@
-- AlterEnum
ALTER TYPE "PaymentMethodType" ADD VALUE 'DMONEY';

View File

@@ -1,275 +0,0 @@
-- DropForeignKey
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_agentId_fkey";
-- DropForeignKey
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "AgentCommission" DROP CONSTRAINT "AgentCommission_agentId_fkey";
-- DropForeignKey
ALTER TABLE "AgentShift" DROP CONSTRAINT "AgentShift_agentId_fkey";
-- DropForeignKey
ALTER TABLE "BaggageBooking" DROP CONSTRAINT "BaggageBooking_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_passengerId_fkey";
-- DropForeignKey
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "BookingCancellation" DROP CONSTRAINT "BookingCancellation_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "BookingModification" DROP CONSTRAINT "BookingModification_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_seatId_fkey";
-- DropForeignKey
ALTER TABLE "Coach" DROP CONSTRAINT "Coach_coachTypeId_fkey";
-- DropForeignKey
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_coachId_fkey";
-- DropForeignKey
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "FaqArticle" DROP CONSTRAINT "FaqArticle_categoryId_fkey";
-- DropForeignKey
ALTER TABLE "FareRule" DROP CONSTRAINT "FareRule_seatClassId_fkey";
-- DropForeignKey
ALTER TABLE "FoodOrder" DROP CONSTRAINT "FoodOrder_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "FoodOrderItem" DROP CONSTRAINT "FoodOrderItem_orderId_fkey";
-- DropForeignKey
ALTER TABLE "GateValidationLog" DROP CONSTRAINT "GateValidationLog_ticketId_fkey";
-- DropForeignKey
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_journeyId_fkey";
-- DropForeignKey
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey";
-- DropForeignKey
ALTER TABLE "LoyaltyReward" DROP CONSTRAINT "LoyaltyReward_accountId_fkey";
-- DropForeignKey
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_categoryId_fkey";
-- DropForeignKey
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_passengerId_fkey";
-- DropForeignKey
ALTER TABLE "PaymentIntent" DROP CONSTRAINT "PaymentIntent_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "PaymentRefund" DROP CONSTRAINT "PaymentRefund_paymentIntentId_fkey";
-- DropForeignKey
ALTER TABLE "RouteFareRule" DROP CONSTRAINT "RouteFareRule_seatClassId_fkey";
-- DropForeignKey
ALTER TABLE "SavedRoute" DROP CONSTRAINT "SavedRoute_passengerId_fkey";
-- DropForeignKey
ALTER TABLE "SeatBlock" DROP CONSTRAINT "SeatBlock_seatId_fkey";
-- DropForeignKey
ALTER TABLE "SegmentFareRule" DROP CONSTRAINT "SegmentFareRule_seatClassId_fkey";
-- DropForeignKey
ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT "StationCrowdSignal_stationId_fkey";
-- DropForeignKey
ALTER TABLE "SupportMessage" DROP CONSTRAINT "SupportMessage_conversationId_fkey";
-- DropForeignKey
ALTER TABLE "Ticket" DROP CONSTRAINT "Ticket_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "TicketSeat" DROP CONSTRAINT "TicketSeat_seatId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_destinationStationId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_originStationId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_routeId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_trainId_fkey";
-- DropForeignKey
ALTER TABLE "TripLiveStatus" DROP CONSTRAINT "TripLiveStatus_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "TripStopTime" DROP CONSTRAINT "TripStopTime_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT "WalletLedgerEntry_walletId_fkey";
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "returnDestinationStationId" TEXT,
ADD COLUMN "returnHoldId" TEXT,
ADD COLUMN "returnOriginStationId" TEXT,
ADD COLUMN "returnScheduleId" TEXT,
ADD COLUMN "returnSeatClassId" TEXT;
-- AlterTable
ALTER TABLE "SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
-- AlterTable
ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
-- gender column already TEXT from init migration
-- CreateIndex
CREATE INDEX "Booking_bookingType_idx" ON "Booking"("bookingType");
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -1,2 +0,0 @@
-- Empty placeholder migration
SELECT 1;

View File

@@ -1,9 +0,0 @@
CREATE INDEX IF NOT EXISTS "Station_sequence_idx" ON "Station"("sequence");
CREATE INDEX IF NOT EXISTS "Coach_sequence_idx" ON "Coach"("sequence");
-- Ensure all indexes exist
CREATE INDEX IF NOT EXISTS "Station_city_countryCode_idx" ON "Station"("city", "countryCode");
CREATE INDEX IF NOT EXISTS "Coach_coachTypeId_idx" ON "Coach"("coachTypeId");
CREATE INDEX IF NOT EXISTS "TrainSchedule_departureAt_originStationId_idx" ON "TrainSchedule"("departureAt", "originStationId");
CREATE INDEX IF NOT EXISTS "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status");

View File

@@ -1,164 +0,0 @@
-- Add CASCADE delete to all foreign key constraints that are missing it
-- TrainSchedule relations
ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_trainId_fkey";
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE CASCADE;
ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_routeId_fkey";
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE;
ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_originStationId_fkey";
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE CASCADE;
ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_destinationStationId_fkey";
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE CASCADE;
-- Coach relation
ALTER TABLE "Coach" DROP CONSTRAINT IF EXISTS "Coach_coachTypeId_fkey";
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE CASCADE;
-- CoachAssignment relations
ALTER TABLE "CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_scheduleId_fkey";
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
ALTER TABLE "CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_coachId_fkey";
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE CASCADE;
-- Booking relations
ALTER TABLE "Booking" DROP CONSTRAINT IF EXISTS "Booking_passengerId_fkey";
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE;
ALTER TABLE "Booking" DROP CONSTRAINT IF EXISTS "Booking_scheduleId_fkey";
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
-- BookingSeat relations
ALTER TABLE "BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_bookingId_fkey";
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
ALTER TABLE "BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_seatId_fkey";
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE;
-- PaymentIntent
ALTER TABLE "PaymentIntent" DROP CONSTRAINT IF EXISTS "PaymentIntent_bookingId_fkey";
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- PaymentRefund
ALTER TABLE "PaymentRefund" DROP CONSTRAINT IF EXISTS "PaymentRefund_paymentIntentId_fkey";
ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE CASCADE;
-- Ticket
ALTER TABLE "Ticket" DROP CONSTRAINT IF EXISTS "Ticket_bookingId_fkey";
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- TicketSeat
ALTER TABLE "TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey";
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE;
-- WalletLedgerEntry
ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT IF EXISTS "WalletLedgerEntry_walletId_fkey";
ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE CASCADE;
-- Notification
ALTER TABLE "Notification" DROP CONSTRAINT IF EXISTS "Notification_passengerId_fkey";
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE;
-- MenuItem
ALTER TABLE "MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_scheduleId_fkey";
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
ALTER TABLE "MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_categoryId_fkey";
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE CASCADE;
-- FoodOrder
ALTER TABLE "FoodOrder" DROP CONSTRAINT IF EXISTS "FoodOrder_bookingId_fkey";
ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- FoodOrderItem
ALTER TABLE "FoodOrderItem" DROP CONSTRAINT IF EXISTS "FoodOrderItem_orderId_fkey";
ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE CASCADE;
-- FaqArticle
ALTER TABLE "FaqArticle" DROP CONSTRAINT IF EXISTS "FaqArticle_categoryId_fkey";
ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE CASCADE;
-- SupportMessage
ALTER TABLE "SupportMessage" DROP CONSTRAINT IF EXISTS "SupportMessage_conversationId_fkey";
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE CASCADE;
-- TripStopTime
ALTER TABLE "TripStopTime" DROP CONSTRAINT IF EXISTS "TripStopTime_scheduleId_fkey";
ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
-- TripLiveStatus
ALTER TABLE "TripLiveStatus" DROP CONSTRAINT IF EXISTS "TripLiveStatus_scheduleId_fkey";
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
-- JourneySegment
ALTER TABLE "JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey";
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE CASCADE;
ALTER TABLE "JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_scheduleId_fkey";
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
-- AgentBooking
ALTER TABLE "AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_agentId_fkey";
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE;
ALTER TABLE "AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_bookingId_fkey";
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- AgentShift
ALTER TABLE "AgentShift" DROP CONSTRAINT IF EXISTS "AgentShift_agentId_fkey";
ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE;
-- AgentCommission
ALTER TABLE "AgentCommission" DROP CONSTRAINT IF EXISTS "AgentCommission_agentId_fkey";
ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE;
-- BookingModification
ALTER TABLE "BookingModification" DROP CONSTRAINT IF EXISTS "BookingModification_bookingId_fkey";
ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- BookingCancellation
ALTER TABLE "BookingCancellation" DROP CONSTRAINT IF EXISTS "BookingCancellation_bookingId_fkey";
ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- GateValidationLog
ALTER TABLE "GateValidationLog" DROP CONSTRAINT IF EXISTS "GateValidationLog_ticketId_fkey";
ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE;
-- BaggageBooking
ALTER TABLE "BaggageBooking" DROP CONSTRAINT IF EXISTS "BaggageBooking_bookingId_fkey";
ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- RouteFareRule
ALTER TABLE "RouteFareRule" DROP CONSTRAINT IF EXISTS "RouteFareRule_seatClassId_fkey";
ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE;
-- SegmentFareRule
ALTER TABLE "SegmentFareRule" DROP CONSTRAINT IF EXISTS "SegmentFareRule_seatClassId_fkey";
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE;
-- StationCrowdSignal
ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT IF EXISTS "StationCrowdSignal_stationId_fkey";
ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE CASCADE;
-- SeatBlock
ALTER TABLE "SeatBlock" DROP CONSTRAINT IF EXISTS "SeatBlock_seatId_fkey";
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE;
-- SavedRoute
ALTER TABLE "SavedRoute" DROP CONSTRAINT IF EXISTS "SavedRoute_passengerId_fkey";
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE;
-- LoyaltyLedgerEntry
ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT IF EXISTS "LoyaltyLedgerEntry_accountId_fkey";
ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE;
-- LoyaltyReward
ALTER TABLE "LoyaltyReward" DROP CONSTRAINT IF EXISTS "LoyaltyReward_accountId_fkey";
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE;
-- FareRule
ALTER TABLE "FareRule" DROP CONSTRAINT IF EXISTS "FareRule_seatClassId_fkey";
ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE;

View File

@@ -1,82 +0,0 @@
-- Catch-up migration: earlier migrations (20260606, 20260608) targeted passenger.*
-- but ran when tables were still in public schema (before 20260626 moved them).
-- All statements use IF NOT EXISTS / conditional blocks so this is safe to re-run.
-- ────────────────────────────────────────────────────────────
-- 1. Passenger.iamUserId
-- ────────────────────────────────────────────────────────────
ALTER TABLE passenger."Passenger" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'Passenger_iamUserId_key'
AND conrelid = 'passenger."Passenger"'::regclass
) THEN
ALTER TABLE passenger."Passenger" ADD CONSTRAINT "Passenger_iamUserId_key" UNIQUE ("iamUserId");
END IF;
END $$;
CREATE INDEX IF NOT EXISTS "Passenger_iamUserId_idx" ON passenger."Passenger"("iamUserId");
-- ────────────────────────────────────────────────────────────
-- 2. FaydaVerificationSession.iamUserId
-- ────────────────────────────────────────────────────────────
ALTER TABLE passenger."FaydaVerificationSession" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT;
CREATE INDEX IF NOT EXISTS "FaydaVerificationSession_iamUserId_idx" ON passenger."FaydaVerificationSession"("iamUserId");
-- ────────────────────────────────────────────────────────────
-- 3. UserPreferences: rename userId → iamUserId (if not yet renamed)
-- ────────────────────────────────────────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'passenger' AND table_name = 'UserPreferences' AND column_name = 'userId'
) THEN
ALTER TABLE passenger."UserPreferences" DROP CONSTRAINT IF EXISTS "UserPreferences_userId_fkey";
ALTER TABLE passenger."UserPreferences" RENAME COLUMN "userId" TO "iamUserId";
END IF;
END $$;
-- ────────────────────────────────────────────────────────────
-- 4. Device: rename userId → iamUserId (if not yet renamed)
-- ────────────────────────────────────────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'passenger' AND table_name = 'Device' AND column_name = 'userId'
) THEN
ALTER TABLE passenger."Device" DROP CONSTRAINT IF EXISTS "Device_userId_fkey";
ALTER TABLE passenger."Device" RENAME COLUMN "userId" TO "iamUserId";
END IF;
END $$;
-- ────────────────────────────────────────────────────────────
-- 5. FraudAlert: rename userId → iamUserId + fix index (if not yet renamed)
-- ────────────────────────────────────────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'passenger' AND table_name = 'FraudAlert' AND column_name = 'userId'
) THEN
ALTER TABLE passenger."FraudAlert" DROP CONSTRAINT IF EXISTS "FraudAlert_userId_fkey";
ALTER TABLE passenger."FraudAlert" RENAME COLUMN "userId" TO "iamUserId";
DROP INDEX IF EXISTS passenger."FraudAlert_userId_createdAt_idx";
CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON passenger."FraudAlert"("iamUserId", "createdAt");
END IF;
END $$;
-- ────────────────────────────────────────────────────────────
-- 6. AuditLog: rename userId → iamUserId + fix index (if not yet renamed)
-- ────────────────────────────────────────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'passenger' AND table_name = 'AuditLog' AND column_name = 'userId'
) THEN
ALTER TABLE passenger."AuditLog" DROP CONSTRAINT IF EXISTS "AuditLog_userId_fkey";
ALTER TABLE passenger."AuditLog" RENAME COLUMN "userId" TO "iamUserId";
DROP INDEX IF EXISTS passenger."AuditLog_userId_createdAt_idx";
CREATE INDEX IF NOT EXISTS "AuditLog_iamUserId_createdAt_idx" ON passenger."AuditLog"("iamUserId", "createdAt");
END IF;
END $$;

View File

@@ -1,5 +0,0 @@
-- 20260608061918 was marked-as-applied without running (it failed on CREATE TABLE TicketSeat).
-- The two ALTER TABLE statements it contained never executed, so userId is still NOT NULL.
ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey";
ALTER TABLE passenger."Passenger" ALTER COLUMN "userId" DROP NOT NULL;

View File

@@ -1,46 +0,0 @@
-- ────────────────────────────────────────────────────────────
-- 1. Add iamUserId to Agent
-- ────────────────────────────────────────────────────────────
ALTER TABLE passenger."Agent" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'Agent_iamUserId_key'
AND conrelid = 'passenger."Agent"'::regclass
) THEN
ALTER TABLE passenger."Agent" ADD CONSTRAINT "Agent_iamUserId_key" UNIQUE ("iamUserId");
END IF;
END $$;
CREATE INDEX IF NOT EXISTS "Agent_iamUserId_idx" ON passenger."Agent"("iamUserId");
-- ────────────────────────────────────────────────────────────
-- 2. Populate iamUserId for existing agent records
-- Match via User.email → iam.users.email (skip if iam schema absent)
-- ────────────────────────────────────────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'iam' AND table_name = 'users'
) THEN
UPDATE passenger."Agent" a
SET "iamUserId" = iu.id
FROM passenger."User" u
JOIN iam.users iu ON iu.email = u.email
WHERE a."userId" = u.id
AND a."iamUserId" IS NULL;
END IF;
END $$;
-- ────────────────────────────────────────────────────────────
-- 3. Drop Agent.userId FK and column — iamUserId replaces it entirely
-- ────────────────────────────────────────────────────────────
ALTER TABLE passenger."Agent" DROP CONSTRAINT IF EXISTS "Agent_userId_fkey";
DROP INDEX IF EXISTS passenger."Agent_userId_key";
ALTER TABLE passenger."Agent" DROP COLUMN IF EXISTS "userId";
-- ────────────────────────────────────────────────────────────
-- 4. Drop Passenger.userId FK (column stays as plain nullable string)
-- ────────────────────────────────────────────────────────────
ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey";

View File

@@ -1,290 +0,0 @@
-- DropForeignKey
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_agentId_fkey";
-- DropForeignKey
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "AgentCommission" DROP CONSTRAINT "AgentCommission_agentId_fkey";
-- DropForeignKey
ALTER TABLE "AgentShift" DROP CONSTRAINT "AgentShift_agentId_fkey";
-- DropForeignKey
ALTER TABLE "BaggageBooking" DROP CONSTRAINT "BaggageBooking_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_passengerId_fkey";
-- DropForeignKey
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "BookingCancellation" DROP CONSTRAINT "BookingCancellation_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "BookingModification" DROP CONSTRAINT "BookingModification_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_seatId_fkey";
-- DropForeignKey
ALTER TABLE "Coach" DROP CONSTRAINT "Coach_coachTypeId_fkey";
-- DropForeignKey
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_coachId_fkey";
-- DropForeignKey
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "FaqArticle" DROP CONSTRAINT "FaqArticle_categoryId_fkey";
-- DropForeignKey
ALTER TABLE "FareRule" DROP CONSTRAINT "FareRule_seatClassId_fkey";
-- DropForeignKey
ALTER TABLE "FoodOrder" DROP CONSTRAINT "FoodOrder_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "FoodOrderItem" DROP CONSTRAINT "FoodOrderItem_orderId_fkey";
-- DropForeignKey
ALTER TABLE "GateValidationLog" DROP CONSTRAINT "GateValidationLog_ticketId_fkey";
-- DropForeignKey
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_journeyId_fkey";
-- DropForeignKey
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey";
-- DropForeignKey
ALTER TABLE "LoyaltyReward" DROP CONSTRAINT "LoyaltyReward_accountId_fkey";
-- DropForeignKey
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_categoryId_fkey";
-- DropForeignKey
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_passengerId_fkey";
-- DropForeignKey
ALTER TABLE "PaymentIntent" DROP CONSTRAINT "PaymentIntent_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "PaymentRefund" DROP CONSTRAINT "PaymentRefund_paymentIntentId_fkey";
-- DropForeignKey
ALTER TABLE "RouteFareRule" DROP CONSTRAINT "RouteFareRule_seatClassId_fkey";
-- DropForeignKey
ALTER TABLE "SavedRoute" DROP CONSTRAINT "SavedRoute_passengerId_fkey";
-- DropForeignKey
ALTER TABLE "SeatBlock" DROP CONSTRAINT "SeatBlock_seatId_fkey";
-- DropForeignKey
ALTER TABLE "SegmentFareRule" DROP CONSTRAINT "SegmentFareRule_seatClassId_fkey";
-- DropForeignKey
ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT "StationCrowdSignal_stationId_fkey";
-- DropForeignKey
ALTER TABLE "SupportMessage" DROP CONSTRAINT "SupportMessage_conversationId_fkey";
-- DropForeignKey
ALTER TABLE "Ticket" DROP CONSTRAINT "Ticket_bookingId_fkey";
-- DropForeignKey
ALTER TABLE "TicketSeat" DROP CONSTRAINT "TicketSeat_seatId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_destinationStationId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_originStationId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_routeId_fkey";
-- DropForeignKey
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_trainId_fkey";
-- DropForeignKey
ALTER TABLE "TripLiveStatus" DROP CONSTRAINT "TripLiveStatus_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "TripStopTime" DROP CONSTRAINT "TripStopTime_scheduleId_fkey";
-- DropForeignKey
ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT "WalletLedgerEntry_walletId_fkey";
-- DropIndex
DROP INDEX IF EXISTS "Journey_bookingId_idx";
-- AlterTable
ALTER TABLE "FaydaVerificationSession" ALTER COLUMN "purpose" SET DEFAULT 'VERIFY';
-- CreateTable
CREATE TABLE "SystemConfig" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SystemConfig_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SystemConfig_key_key" ON "SystemConfig"("key");
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_returnScheduleId_fkey" FOREIGN KEY ("returnScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'passenger' AND table_name = 'Journey' AND column_name = 'bookingId'
) THEN
ALTER TABLE "Journey" ADD CONSTRAINT "Journey_bookingId_fkey"
FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE;
END IF;
END $$;
-- AddForeignKey
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -1,18 +0,0 @@
-- CreateEnum
CREATE TYPE "ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED');
-- AlterTable: add return leg tracking columns to Booking
ALTER TABLE "Booking"
ADD COLUMN "returnLegStatus" "ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE',
ADD COLUMN "outboundBoardedAt" TIMESTAMP(3),
ADD COLUMN "returnBoardedAt" TIMESTAMP(3);
-- Set NEITHER_USED for existing confirmed round-trip bookings
UPDATE "Booking"
SET "returnLegStatus" = 'NEITHER_USED'
WHERE "bookingType" = 'ROUND_TRIP'
AND "status" IN ('CONFIRMED', 'BOARDED');
-- AlterTable: add leg column to GateValidationLog
ALTER TABLE "GateValidationLog"
ADD COLUMN "leg" TEXT;

View File

@@ -1,70 +0,0 @@
-- Create passenger schema if it doesn't exist
CREATE SCHEMA IF NOT EXISTS passenger;
-- Move enums from public to passenger schema (only if they exist in public)
DO $$
DECLARE
e text;
BEGIN
FOR e IN
SELECT typname FROM pg_type
JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace
WHERE pg_namespace.nspname = 'public' AND pg_type.typtype = 'e'
LOOP
EXECUTE format('ALTER TYPE public.%I SET SCHEMA passenger', e);
END LOOP;
EXCEPTION WHEN others THEN NULL;
END $$;
-- Move tables from public to passenger schema (only if they exist in public)
DO $$
DECLARE
t text;
BEGIN
FOR t IN
SELECT tablename FROM pg_tables
WHERE schemaname = 'public' AND tablename NOT IN ('_prisma_migrations')
LOOP
EXECUTE format('ALTER TABLE public.%I SET SCHEMA passenger', t);
END LOOP;
EXCEPTION WHEN others THEN NULL;
END $$;
-- Add missing columns to Booking
ALTER TABLE "passenger"."Booking"
ADD COLUMN IF NOT EXISTS "returnScheduleId" TEXT,
ADD COLUMN IF NOT EXISTS "returnOriginStationId" TEXT,
ADD COLUMN IF NOT EXISTS "returnDestinationStationId" TEXT,
ADD COLUMN IF NOT EXISTS "returnHoldId" TEXT,
ADD COLUMN IF NOT EXISTS "returnSeatClassId" TEXT,
ADD COLUMN IF NOT EXISTS "leg2ScheduleId" TEXT,
ADD COLUMN IF NOT EXISTS "leg2OriginStationId" TEXT,
ADD COLUMN IF NOT EXISTS "leg2DestinationStationId" TEXT,
ADD COLUMN IF NOT EXISTS "leg2SeatClassId" TEXT,
ADD COLUMN IF NOT EXISTS "returnLeg2ScheduleId" TEXT,
ADD COLUMN IF NOT EXISTS "returnLeg2OriginStationId" TEXT,
ADD COLUMN IF NOT EXISTS "returnLeg2DestStationId" TEXT,
ADD COLUMN IF NOT EXISTS "returnLeg2SeatClassId" TEXT,
ADD COLUMN IF NOT EXISTS "outboundBoardedAt" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "returnBoardedAt" TIMESTAMP(3);
-- Add ReturnLegStatus enum and column
DO $$ BEGIN
CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM (
'NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
ALTER TABLE "passenger"."Booking"
ADD COLUMN IF NOT EXISTS "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE';
-- Add missing columns to other tables
ALTER TABLE "passenger"."GateValidationLog" ADD COLUMN IF NOT EXISTS "leg" TEXT;
ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "leg" INTEGER NOT NULL DEFAULT 1;
ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "scheduleId" TEXT;
ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "boardedAt" TIMESTAMP(3);
ALTER TABLE "passenger"."SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
ALTER TABLE "passenger"."Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
CREATE INDEX IF NOT EXISTS "Booking_bookingType_idx" ON "passenger"."Booking"("bookingType");

View File

@@ -1,21 +0,0 @@
-- Add bookingId to Journey for per-booking segment release
ALTER TABLE "passenger"."Journey"
ADD COLUMN IF NOT EXISTS "bookingId" TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS "Journey_bookingId_key" ON "passenger"."Journey"("bookingId");
CREATE INDEX IF NOT EXISTS "Journey_bookingId_idx" ON "passenger"."Journey"("bookingId");
-- AddForeignKey (column created above; FK was misplaced in 20260623073543_config)
ALTER TABLE "passenger"."Journey"
DROP CONSTRAINT IF EXISTS "Journey_bookingId_fkey";
ALTER TABLE "passenger"."Journey"
ADD CONSTRAINT "Journey_bookingId_fkey"
FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Ensure JourneySegment cascades on Journey delete
ALTER TABLE "passenger"."JourneySegment"
DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey";
ALTER TABLE "passenger"."JourneySegment"
ADD CONSTRAINT "JourneySegment_journeyId_fkey"
FOREIGN KEY ("journeyId") REFERENCES "passenger"."Journey"("id") ON DELETE CASCADE;

View File

@@ -1,2 +0,0 @@
-- Migration already applied directly to the database.
-- This file exists only to satisfy Prisma's migration directory check (P3015).

View File

@@ -1,153 +0,0 @@
-- Add iamUserId to Agent (migration 20260622000002 was skipped due to missing iam schema)
ALTER TABLE passenger."Agent" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'Agent_iamUserId_key'
AND conrelid = 'passenger."Agent"'::regclass
) THEN
ALTER TABLE passenger."Agent" ADD CONSTRAINT "Agent_iamUserId_key" UNIQUE ("iamUserId");
END IF;
END $$;
CREATE INDEX IF NOT EXISTS "Agent_iamUserId_idx" ON passenger."Agent"("iamUserId");
-- Drop old Agent.userId FK and column if they still exist
ALTER TABLE passenger."Agent" DROP CONSTRAINT IF EXISTS "Agent_userId_fkey";
DROP INDEX IF EXISTS passenger."Agent_userId_key";
ALTER TABLE passenger."Agent" DROP COLUMN IF EXISTS "userId";
-- Drop old Passenger.userId FK (column stays as plain nullable string)
ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey";
-- TravelPackage
CREATE TABLE IF NOT EXISTS passenger."TravelPackage" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"outboundScheduleId" TEXT NOT NULL,
"returnScheduleId" TEXT NOT NULL,
"originStationId" TEXT NOT NULL,
"destinationStationId" TEXT NOT NULL,
"boardingTime" TIMESTAMP(3) NOT NULL,
"departureTime" TIMESTAMP(3) NOT NULL,
"arrivalTime" TIMESTAMP(3) NOT NULL,
"totalCapacity" INTEGER NOT NULL,
"bookedCount" INTEGER NOT NULL DEFAULT 0,
"includedServices" JSONB NOT NULL,
"coachConfiguration" TEXT,
"busTransferIncluded" BOOLEAN NOT NULL DEFAULT false,
"busTransferRoute" TEXT,
"validFrom" TIMESTAMP(3) NOT NULL,
"validUntil" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TravelPackage_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "TravelPackage_code_key" ON passenger."TravelPackage"("code");
CREATE INDEX IF NOT EXISTS "TravelPackage_status_validFrom_idx" ON passenger."TravelPackage"("status","validFrom");
-- PackagePriceTier
CREATE TABLE IF NOT EXISTS passenger."PackagePriceTier" (
"id" TEXT NOT NULL,
"packageId" TEXT NOT NULL,
"seatType" TEXT NOT NULL,
"label" TEXT NOT NULL,
"priceMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"availableSeats" INTEGER NOT NULL DEFAULT 0,
"bookedSeats" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "PackagePriceTier_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "PackagePriceTier_packageId_seatType_key" ON passenger."PackagePriceTier"("packageId","seatType");
-- PackageBooking
CREATE TABLE IF NOT EXISTS passenger."PackageBooking" (
"id" TEXT NOT NULL,
"bookingRef" TEXT NOT NULL,
"packageId" TEXT NOT NULL,
"priceTierId" TEXT NOT NULL,
"passengerId" TEXT,
"contactEmail" TEXT,
"contactPhone" TEXT,
"status" TEXT NOT NULL DEFAULT 'PENDING_PAYMENT',
"passengerCount" INTEGER NOT NULL DEFAULT 1,
"totalMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"displayCurrency" TEXT,
"displayTotalMinor" INTEGER,
"promoCode" TEXT,
"source" TEXT NOT NULL DEFAULT 'WEB',
"paidAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PackageBooking_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "PackageBooking_bookingRef_key" ON passenger."PackageBooking"("bookingRef");
CREATE INDEX IF NOT EXISTS "PackageBooking_packageId_status_idx" ON passenger."PackageBooking"("packageId","status");
-- PackageBookingPassenger
CREATE TABLE IF NOT EXISTS passenger."PackageBookingPassenger" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"passengerName" TEXT NOT NULL,
"dateOfBirth" TIMESTAMP(3),
"idDocumentType" TEXT,
"idDocumentNumber" TEXT,
"passportNumber" TEXT,
"passportCountry" TEXT,
"seatLabel" TEXT,
CONSTRAINT "PackageBookingPassenger_pkey" PRIMARY KEY ("id")
);
-- PackagePaymentIntent
CREATE TABLE IF NOT EXISTS passenger."PackagePaymentIntent" (
"id" TEXT NOT NULL,
"packageBookingId" TEXT NOT NULL,
"amountMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"method" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'REQUIRES_ACTION',
"providerRef" TEXT,
"paidAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PackagePaymentIntent_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "PackagePaymentIntent_packageBookingId_key" ON passenger."PackagePaymentIntent"("packageBookingId");
-- Foreign keys
ALTER TABLE passenger."TravelPackage"
ADD CONSTRAINT "TravelPackage_outboundScheduleId_fkey"
FOREIGN KEY ("outboundScheduleId") REFERENCES passenger."TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE passenger."TravelPackage"
ADD CONSTRAINT "TravelPackage_returnScheduleId_fkey"
FOREIGN KEY ("returnScheduleId") REFERENCES passenger."TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE passenger."PackagePriceTier"
ADD CONSTRAINT "PackagePriceTier_packageId_fkey"
FOREIGN KEY ("packageId") REFERENCES passenger."TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE passenger."PackageBooking"
ADD CONSTRAINT "PackageBooking_packageId_fkey"
FOREIGN KEY ("packageId") REFERENCES passenger."TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE passenger."PackageBooking"
ADD CONSTRAINT "PackageBooking_priceTierId_fkey"
FOREIGN KEY ("priceTierId") REFERENCES passenger."PackagePriceTier"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE passenger."PackageBooking"
ADD CONSTRAINT "PackageBooking_passengerId_fkey"
FOREIGN KEY ("passengerId") REFERENCES passenger."Passenger"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE passenger."PackageBookingPassenger"
ADD CONSTRAINT "PackageBookingPassenger_bookingId_fkey"
FOREIGN KEY ("bookingId") REFERENCES passenger."PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE passenger."PackagePaymentIntent"
ADD CONSTRAINT "PackagePaymentIntent_packageBookingId_fkey"
FOREIGN KEY ("packageBookingId") REFERENCES passenger."PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -1,12 +0,0 @@
-- Create PackageStatus enum
DO $$ BEGIN
CREATE TYPE passenger."PackageStatus" AS ENUM ('DRAFT','ACTIVE','SOLD_OUT','EXPIRED','CANCELLED');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- Drop default, cast column to enum, restore default
ALTER TABLE passenger."TravelPackage" ALTER COLUMN "status" DROP DEFAULT;
ALTER TABLE passenger."TravelPackage"
ALTER COLUMN "status" TYPE passenger."PackageStatus"
USING "status"::passenger."PackageStatus";
ALTER TABLE passenger."TravelPackage" ALTER COLUMN "status" SET DEFAULT 'DRAFT'::passenger."PackageStatus";

View File

@@ -1,42 +0,0 @@
-- CreateTable
CREATE TABLE "passenger"."ExcessBaggageCharge" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"excessWeightKg" INTEGER NOT NULL,
"feePerKgMinor" INTEGER NOT NULL,
"totalMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"status" TEXT NOT NULL DEFAULT 'PENDING',
"paymentToken" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"paidAt" TIMESTAMP(3),
"waivedBy" TEXT,
"waivedReason" TEXT,
"contactPhone" TEXT,
"contactEmail" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ExcessBaggageCharge_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ExcessBaggageCharge_paymentToken_key" ON "passenger"."ExcessBaggageCharge"("paymentToken");
-- CreateIndex
CREATE INDEX "ExcessBaggageCharge_bookingId_idx" ON "passenger"."ExcessBaggageCharge"("bookingId");
-- CreateIndex
CREATE INDEX "ExcessBaggageCharge_paymentToken_idx" ON "passenger"."ExcessBaggageCharge"("paymentToken");
-- CreateIndex
CREATE INDEX "ExcessBaggageCharge_status_idx" ON "passenger"."ExcessBaggageCharge"("status");
-- AddForeignKey
ALTER TABLE "passenger"."ExcessBaggageCharge"
ADD CONSTRAINT "ExcessBaggageCharge_bookingId_fkey"
FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
-- Seed default paymentToken using gen_random_uuid() for any rows that may exist
UPDATE "passenger"."ExcessBaggageCharge" SET "paymentToken" = gen_random_uuid()::text WHERE "paymentToken" = '';

View File

@@ -1 +0,0 @@
ALTER TABLE passenger."Booking" ADD COLUMN IF NOT EXISTS "paymentReminderSentAt" TIMESTAMP(3);

View File

@@ -22,11 +22,14 @@ CREATE TYPE "Currency" AS ENUM ('ETB', 'DJF', 'USD');
-- CreateEnum
CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'BOARDED', 'NO_SHOW', 'REFUNDED');
-- CreateEnum
CREATE TYPE "ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED');
-- CreateEnum
CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL');
-- CreateEnum
CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET', 'WAAFI');
CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET', 'WAAFI', 'DMONEY');
-- CreateEnum
CREATE TYPE "PaymentIntentStatus" AS ENUM ('REQUIRES_ACTION', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'REFUNDED');
@@ -58,6 +61,9 @@ CREATE TYPE "FoodOrderStatus" AS ENUM ('PENDING', 'PREPARING', 'READY', 'DELIVER
-- CreateEnum
CREATE TYPE "DevicePlatform" AS ENUM ('IOS', 'ANDROID', 'WEB');
-- CreateEnum
CREATE TYPE "PackageStatus" AS ENUM ('DRAFT', 'ACTIVE', 'SOLD_OUT', 'EXPIRED', 'CANCELLED');
-- CreateTable
CREATE TABLE "CoachType" (
"id" TEXT NOT NULL,
@@ -130,9 +136,11 @@ CREATE TABLE "Session" (
-- CreateTable
CREATE TABLE "Passenger" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"userId" TEXT,
"iamUserId" TEXT,
"defaultTravelerProfileId" TEXT,
"preferredLanguage" TEXT,
"blockedUntil" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Passenger_pkey" PRIMARY KEY ("id")
@@ -143,6 +151,7 @@ CREATE TABLE "TravelerProfile" (
"id" TEXT NOT NULL,
"passengerId" TEXT NOT NULL,
"fullName" TEXT NOT NULL,
"gender" TEXT,
"relationship" TEXT NOT NULL,
"dateOfBirth" TIMESTAMP(3),
"nationalId" TEXT,
@@ -161,7 +170,6 @@ CREATE TABLE "Station" (
"countryCode" TEXT,
"sequence" INTEGER NOT NULL DEFAULT 0,
"isOperational" BOOLEAN NOT NULL DEFAULT true,
"timezone" TEXT NOT NULL DEFAULT 'Africa/Addis_Ababa',
"lat" DECIMAL(9,6),
"lng" DECIMAL(9,6),
@@ -314,6 +322,7 @@ CREATE TABLE "Booking" (
"bookingRef" TEXT NOT NULL,
"passengerId" TEXT NOT NULL,
"scheduleId" TEXT NOT NULL,
"bookingType" TEXT NOT NULL DEFAULT 'ONE_WAY',
"status" "BookingStatus" NOT NULL DEFAULT 'DRAFT',
"currency" TEXT NOT NULL DEFAULT 'ETB',
"totalMinor" INTEGER NOT NULL,
@@ -321,13 +330,29 @@ CREATE TABLE "Booking" (
"childCount" INTEGER NOT NULL DEFAULT 0,
"displayCurrency" "Currency",
"displayTotalMinor" INTEGER,
"bookingType" TEXT NOT NULL DEFAULT 'ONE_WAY',
"returnScheduleId" TEXT,
"returnOriginStationId" TEXT,
"returnDestinationStationId" TEXT,
"returnHoldId" TEXT,
"returnSeatClassId" TEXT,
"returnLegStatus" "ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE',
"leg2ScheduleId" TEXT,
"leg2OriginStationId" TEXT,
"leg2DestinationStationId" TEXT,
"leg2SeatClassId" TEXT,
"returnLeg2ScheduleId" TEXT,
"returnLeg2OriginStationId" TEXT,
"returnLeg2DestStationId" TEXT,
"returnLeg2SeatClassId" TEXT,
"outboundBoardedAt" TIMESTAMP(3),
"returnBoardedAt" TIMESTAMP(3),
"contactEmail" TEXT,
"contactPhone" TEXT,
"userAgent" TEXT,
"source" TEXT NOT NULL DEFAULT 'WEB',
"promoCode" TEXT,
"paidAt" TIMESTAMP(3),
"paymentReminderSentAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
@@ -339,6 +364,8 @@ CREATE TABLE "BookingSeat" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"seatId" TEXT NOT NULL,
"leg" INTEGER NOT NULL DEFAULT 1,
"scheduleId" TEXT,
"passengerName" TEXT NOT NULL,
"dateOfBirth" TIMESTAMP(3),
"passengerCategory" "PassengerCategory" NOT NULL DEFAULT 'ADULT',
@@ -438,7 +465,11 @@ CREATE TABLE "Ticket" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"bookingRef" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'CONFIRMED',
"passengerName" TEXT NOT NULL,
"seatId" TEXT NOT NULL,
"leg" INTEGER NOT NULL DEFAULT 1,
"scheduleId" TEXT,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"qrPayload" TEXT NOT NULL,
"barcodePayload" TEXT,
"pdfUrl" TEXT,
@@ -446,20 +477,11 @@ CREATE TABLE "Ticket" (
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"validatedAt" TIMESTAMP(3),
"validatorId" TEXT,
"boardedAt" TIMESTAMP(3),
CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TicketSeat" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"seatId" TEXT NOT NULL,
"seatIndex" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "TicketSeat_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoyaltyAccount" (
"id" TEXT NOT NULL,
@@ -679,7 +701,7 @@ CREATE TABLE "SupportMessage" (
-- CreateTable
CREATE TABLE "UserPreferences" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"iamUserId" TEXT NOT NULL,
"pushEnabled" BOOLEAN NOT NULL DEFAULT true,
"emailEnabled" BOOLEAN NOT NULL DEFAULT true,
"smsEnabled" BOOLEAN NOT NULL DEFAULT false,
@@ -699,7 +721,7 @@ CREATE TABLE "UserPreferences" (
-- CreateTable
CREATE TABLE "Device" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"iamUserId" TEXT NOT NULL,
"platform" "DevicePlatform" NOT NULL,
"name" TEXT NOT NULL,
"pushToken" TEXT,
@@ -727,6 +749,7 @@ CREATE TABLE "SavedRoute" (
CREATE TABLE "Journey" (
"id" TEXT NOT NULL,
"passengerId" TEXT NOT NULL,
"bookingId" TEXT,
"status" TEXT NOT NULL,
"totalMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
@@ -796,7 +819,7 @@ CREATE TABLE "RouteStop" (
"routeId" TEXT NOT NULL,
"stationId" TEXT NOT NULL,
"sequence" INTEGER NOT NULL,
"distanceKm" INTEGER,
"distanceKm" DOUBLE PRECISION,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RouteStop_pkey" PRIMARY KEY ("id")
@@ -820,10 +843,27 @@ CREATE TABLE "RouteFareRule" (
CONSTRAINT "RouteFareRule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SegmentFareRule" (
"id" TEXT NOT NULL,
"routeId" TEXT NOT NULL,
"originStopSequence" INTEGER NOT NULL,
"destinationStopSequence" INTEGER NOT NULL,
"seatClassId" TEXT NOT NULL,
"baseFareMinor" INTEGER NOT NULL,
"nationality" TEXT,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"validFrom" TIMESTAMP(3) NOT NULL,
"validUntil" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SegmentFareRule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Agent" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"iamUserId" TEXT,
"agentCode" TEXT NOT NULL,
"stationId" TEXT,
"commissionRate" INTEGER NOT NULL DEFAULT 5,
@@ -910,6 +950,7 @@ CREATE TABLE "GateValidationLog" (
"ticketId" TEXT NOT NULL,
"validatorId" TEXT NOT NULL,
"gateId" TEXT,
"leg" TEXT,
"status" TEXT NOT NULL,
"reason" TEXT,
"validatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -943,10 +984,32 @@ CREATE TABLE "BaggageBooking" (
CONSTRAINT "BaggageBooking_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ExcessBaggageCharge" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"excessWeightKg" INTEGER NOT NULL,
"feePerKgMinor" INTEGER NOT NULL,
"totalMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"status" TEXT NOT NULL DEFAULT 'PENDING',
"paymentToken" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"paidAt" TIMESTAMP(3),
"waivedBy" TEXT,
"waivedReason" TEXT,
"contactPhone" TEXT,
"contactEmail" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ExcessBaggageCharge_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"userId" TEXT,
"iamUserId" TEXT,
"action" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT,
@@ -1014,7 +1077,7 @@ CREATE TABLE "FraudRule" (
-- CreateTable
CREATE TABLE "FraudAlert" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"iamUserId" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"triggeredRules" TEXT[],
"context" JSONB NOT NULL,
@@ -1077,7 +1140,7 @@ CREATE TABLE "FaydaVerificationSession" (
"id" TEXT NOT NULL,
"state" TEXT NOT NULL,
"codeVerifier" TEXT NOT NULL,
"purpose" TEXT NOT NULL DEFAULT 'PURCHASE',
"purpose" TEXT NOT NULL DEFAULT 'VERIFY',
"platform" TEXT NOT NULL DEFAULT 'WEB',
"saveToAccount" BOOLEAN NOT NULL DEFAULT false,
"status" TEXT NOT NULL DEFAULT 'PENDING',
@@ -1087,12 +1150,137 @@ CREATE TABLE "FaydaVerificationSession" (
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"completedAt" TIMESTAMP(3),
"userId" TEXT,
"iamUserId" TEXT,
"bookingId" TEXT,
CONSTRAINT "FaydaVerificationSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SystemConfig" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SystemConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TravelPackage" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"status" "PackageStatus" NOT NULL DEFAULT 'DRAFT',
"outboundScheduleId" TEXT NOT NULL,
"returnScheduleId" TEXT NOT NULL,
"originStationId" TEXT NOT NULL,
"destinationStationId" TEXT NOT NULL,
"boardingTime" TIMESTAMP(3) NOT NULL,
"departureTime" TIMESTAMP(3) NOT NULL,
"arrivalTime" TIMESTAMP(3) NOT NULL,
"totalCapacity" INTEGER NOT NULL,
"bookedCount" INTEGER NOT NULL DEFAULT 0,
"includedServices" JSONB NOT NULL,
"coachConfiguration" TEXT,
"busTransferIncluded" BOOLEAN NOT NULL DEFAULT false,
"busTransferRoute" TEXT,
"validFrom" TIMESTAMP(3) NOT NULL,
"validUntil" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TravelPackage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PackagePriceTier" (
"id" TEXT NOT NULL,
"packageId" TEXT NOT NULL,
"seatType" TEXT NOT NULL,
"label" TEXT NOT NULL,
"priceMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"availableSeats" INTEGER NOT NULL DEFAULT 0,
"bookedSeats" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "PackagePriceTier_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PackageBooking" (
"id" TEXT NOT NULL,
"bookingRef" TEXT NOT NULL,
"packageId" TEXT NOT NULL,
"priceTierId" TEXT NOT NULL,
"passengerId" TEXT,
"contactEmail" TEXT,
"contactPhone" TEXT,
"status" "BookingStatus" NOT NULL DEFAULT 'PENDING_PAYMENT',
"passengerCount" INTEGER NOT NULL DEFAULT 1,
"totalMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"displayCurrency" "Currency",
"displayTotalMinor" INTEGER,
"promoCode" TEXT,
"source" TEXT NOT NULL DEFAULT 'WEB',
"paidAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PackageBooking_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PackageBookingPassenger" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"passengerName" TEXT NOT NULL,
"dateOfBirth" TIMESTAMP(3),
"idDocumentType" "IdDocumentType",
"idDocumentNumber" TEXT,
"passportNumber" TEXT,
"passportCountry" TEXT,
"seatLabel" TEXT,
CONSTRAINT "PackageBookingPassenger_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PackagePaymentIntent" (
"id" TEXT NOT NULL,
"packageBookingId" TEXT NOT NULL,
"amountMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"method" "PaymentMethodType" NOT NULL,
"status" "PaymentIntentStatus" NOT NULL DEFAULT 'REQUIRES_ACTION',
"providerRef" TEXT,
"paidAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PackagePaymentIntent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PackageInquiry" (
"id" TEXT NOT NULL,
"packageId" TEXT NOT NULL,
"priceTierId" TEXT,
"travelerCount" INTEGER NOT NULL,
"contactName" TEXT NOT NULL,
"contactEmail" TEXT,
"contactPhone" TEXT,
"notes" TEXT,
"status" TEXT NOT NULL DEFAULT 'NEW',
"enquiredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PackageInquiry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SeatClass_coachTypeId_idx" ON "SeatClass"("coachTypeId");
@@ -1114,15 +1302,24 @@ CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
-- CreateIndex
CREATE UNIQUE INDEX "Passenger_userId_key" ON "Passenger"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Passenger_iamUserId_key" ON "Passenger"("iamUserId");
-- CreateIndex
CREATE INDEX "Passenger_userId_idx" ON "Passenger"("userId");
-- CreateIndex
CREATE INDEX "Passenger_iamUserId_idx" ON "Passenger"("iamUserId");
-- CreateIndex
CREATE UNIQUE INDEX "Station_code_key" ON "Station"("code");
-- CreateIndex
CREATE INDEX "Station_city_countryCode_idx" ON "Station"("city", "countryCode");
-- CreateIndex
CREATE INDEX "Station_sequence_idx" ON "Station"("sequence");
-- CreateIndex
CREATE UNIQUE INDEX "Train_number_key" ON "Train"("number");
@@ -1141,6 +1338,9 @@ CREATE UNIQUE INDEX "Coach_number_key" ON "Coach"("number");
-- CreateIndex
CREATE INDEX "Coach_coachTypeId_idx" ON "Coach"("coachTypeId");
-- CreateIndex
CREATE INDEX "Coach_sequence_idx" ON "Coach"("sequence");
-- CreateIndex
CREATE INDEX "CoachAssignment_scheduleId_idx" ON "CoachAssignment"("scheduleId");
@@ -1165,6 +1365,9 @@ CREATE UNIQUE INDEX "Booking_bookingRef_key" ON "Booking"("bookingRef");
-- CreateIndex
CREATE INDEX "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status");
-- CreateIndex
CREATE INDEX "Booking_bookingType_idx" ON "Booking"("bookingType");
-- CreateIndex
CREATE UNIQUE INDEX "PaymentMethod_type_key" ON "PaymentMethod"("type");
@@ -1187,13 +1390,10 @@ CREATE INDEX "PaymentWebhookEvent_merchantOrderId_idx" ON "PaymentWebhookEvent"(
CREATE UNIQUE INDEX "PaymentWebhookEvent_provider_externalEventId_key" ON "PaymentWebhookEvent"("provider", "externalEventId");
-- CreateIndex
CREATE UNIQUE INDEX "Ticket_bookingId_key" ON "Ticket"("bookingId");
CREATE INDEX "Ticket_bookingId_idx" ON "Ticket"("bookingId");
-- CreateIndex
CREATE INDEX "TicketSeat_ticketId_idx" ON "TicketSeat"("ticketId");
-- CreateIndex
CREATE INDEX "TicketSeat_seatId_idx" ON "TicketSeat"("seatId");
CREATE INDEX "Ticket_seatId_idx" ON "Ticket"("seatId");
-- CreateIndex
CREATE UNIQUE INDEX "LoyaltyAccount_passengerId_key" ON "LoyaltyAccount"("passengerId");
@@ -1208,7 +1408,10 @@ CREATE INDEX "WalletAccount_passengerId_idx" ON "WalletAccount"("passengerId");
CREATE UNIQUE INDEX "Promotion_code_key" ON "Promotion"("code");
-- CreateIndex
CREATE UNIQUE INDEX "UserPreferences_userId_key" ON "UserPreferences"("userId");
CREATE UNIQUE INDEX "UserPreferences_iamUserId_key" ON "UserPreferences"("iamUserId");
-- CreateIndex
CREATE UNIQUE INDEX "Journey_bookingId_key" ON "Journey"("bookingId");
-- CreateIndex
CREATE INDEX "OtpCode_email_phone_idx" ON "OtpCode"("email", "phone");
@@ -1232,11 +1435,20 @@ CREATE UNIQUE INDEX "RouteStop_routeId_sequence_key" ON "RouteStop"("routeId", "
CREATE INDEX "RouteFareRule_routeId_seatClassId_idx" ON "RouteFareRule"("routeId", "seatClassId");
-- CreateIndex
CREATE UNIQUE INDEX "Agent_userId_key" ON "Agent"("userId");
CREATE INDEX "SegmentFareRule_routeId_seatClassId_idx" ON "SegmentFareRule"("routeId", "seatClassId");
-- CreateIndex
CREATE UNIQUE INDEX "SegmentFareRule_routeId_originStopSequence_destinationStopS_key" ON "SegmentFareRule"("routeId", "originStopSequence", "destinationStopSequence", "seatClassId", "nationality");
-- CreateIndex
CREATE UNIQUE INDEX "Agent_iamUserId_key" ON "Agent"("iamUserId");
-- CreateIndex
CREATE UNIQUE INDEX "Agent_agentCode_key" ON "Agent"("agentCode");
-- CreateIndex
CREATE INDEX "Agent_iamUserId_idx" ON "Agent"("iamUserId");
-- CreateIndex
CREATE UNIQUE INDEX "AgentBooking_bookingId_key" ON "AgentBooking"("bookingId");
@@ -1262,7 +1474,19 @@ CREATE INDEX "GateValidationLog_validatorId_idx" ON "GateValidationLog"("validat
CREATE INDEX "BaggageBooking_bookingId_idx" ON "BaggageBooking"("bookingId");
-- CreateIndex
CREATE INDEX "AuditLog_userId_createdAt_idx" ON "AuditLog"("userId", "createdAt");
CREATE UNIQUE INDEX "ExcessBaggageCharge_paymentToken_key" ON "ExcessBaggageCharge"("paymentToken");
-- CreateIndex
CREATE INDEX "ExcessBaggageCharge_bookingId_idx" ON "ExcessBaggageCharge"("bookingId");
-- CreateIndex
CREATE INDEX "ExcessBaggageCharge_paymentToken_idx" ON "ExcessBaggageCharge"("paymentToken");
-- CreateIndex
CREATE INDEX "ExcessBaggageCharge_status_idx" ON "ExcessBaggageCharge"("status");
-- CreateIndex
CREATE INDEX "AuditLog_iamUserId_createdAt_idx" ON "AuditLog"("iamUserId", "createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_entityType_entityId_idx" ON "AuditLog"("entityType", "entityId");
@@ -1280,7 +1504,7 @@ CREATE INDEX "OperationalReport_reportType_dateFrom_idx" ON "OperationalReport"(
CREATE UNIQUE INDEX "FraudRule_type_key" ON "FraudRule"("type");
-- CreateIndex
CREATE INDEX "FraudAlert_userId_createdAt_idx" ON "FraudAlert"("userId", "createdAt");
CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON "FraudAlert"("iamUserId", "createdAt");
-- CreateIndex
CREATE INDEX "FraudAlert_acknowledged_idx" ON "FraudAlert"("acknowledged");
@@ -1307,7 +1531,7 @@ CREATE INDEX "SavedPassengerProfile_deviceId_idx" ON "SavedPassengerProfile"("de
CREATE UNIQUE INDEX "FaydaVerificationSession_state_key" ON "FaydaVerificationSession"("state");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_userId_idx" ON "FaydaVerificationSession"("userId");
CREATE INDEX "FaydaVerificationSession_iamUserId_idx" ON "FaydaVerificationSession"("iamUserId");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_bookingId_idx" ON "FaydaVerificationSession"("bookingId");
@@ -1318,6 +1542,30 @@ CREATE INDEX "FaydaVerificationSession_state_idx" ON "FaydaVerificationSession"(
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_expiresAt_idx" ON "FaydaVerificationSession"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "SystemConfig_key_key" ON "SystemConfig"("key");
-- CreateIndex
CREATE UNIQUE INDEX "TravelPackage_code_key" ON "TravelPackage"("code");
-- CreateIndex
CREATE INDEX "TravelPackage_status_validFrom_idx" ON "TravelPackage"("status", "validFrom");
-- CreateIndex
CREATE UNIQUE INDEX "PackagePriceTier_packageId_seatType_key" ON "PackagePriceTier"("packageId", "seatType");
-- CreateIndex
CREATE UNIQUE INDEX "PackageBooking_bookingRef_key" ON "PackageBooking"("bookingRef");
-- CreateIndex
CREATE INDEX "PackageBooking_packageId_status_idx" ON "PackageBooking"("packageId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "PackagePaymentIntent_packageBookingId_key" ON "PackagePaymentIntent"("packageBookingId");
-- CreateIndex
CREATE INDEX "PackageInquiry_packageId_idx" ON "PackageInquiry"("packageId");
-- AddForeignKey
ALTER TABLE "SeatClass" ADD CONSTRAINT "SeatClass_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1325,7 +1573,7 @@ ALTER TABLE "SeatClass" ADD CONSTRAINT "SeatClass_coachTypeId_fkey" FOREIGN KEY
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Passenger" ADD CONSTRAINT "Passenger_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Passenger" ADD CONSTRAINT "Passenger_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TravelerProfile" ADD CONSTRAINT "TravelerProfile_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1372,6 +1620,9 @@ ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("pa
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_returnScheduleId_fkey" FOREIGN KEY ("returnScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1388,10 +1639,7 @@ ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey"
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyAccount" ADD CONSTRAINT "LoyaltyAccount_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1432,15 +1680,12 @@ ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY
-- AddForeignKey
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserPreferences" ADD CONSTRAINT "UserPreferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Journey" ADD CONSTRAINT "Journey_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1457,7 +1702,10 @@ ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_routeId_fkey" FOREIGN
ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Agent" ADD CONSTRAINT "Agent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1484,13 +1732,37 @@ ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey"
ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ExcessBaggageCharge" ADD CONSTRAINT "ExcessBaggageCharge_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FraudAlert" ADD CONSTRAINT "FraudAlert_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TravelPackage" ADD CONSTRAINT "TravelPackage_outboundScheduleId_fkey" FOREIGN KEY ("outboundScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FaydaVerificationSession" ADD CONSTRAINT "FaydaVerificationSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TravelPackage" ADD CONSTRAINT "TravelPackage_returnScheduleId_fkey" FOREIGN KEY ("returnScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PackagePriceTier" ADD CONSTRAINT "PackagePriceTier_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PackageBooking" ADD CONSTRAINT "PackageBooking_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PackageBooking" ADD CONSTRAINT "PackageBooking_priceTierId_fkey" FOREIGN KEY ("priceTierId") REFERENCES "PackagePriceTier"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PackageBooking" ADD CONSTRAINT "PackageBooking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PackageBookingPassenger" ADD CONSTRAINT "PackageBookingPassenger_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PackagePaymentIntent" ADD CONSTRAINT "PackagePaymentIntent_packageBookingId_fkey" FOREIGN KEY ("packageBookingId") REFERENCES "PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PackageInquiry" ADD CONSTRAINT "PackageInquiry_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PackageInquiry" ADD CONSTRAINT "PackageInquiry_priceTierId_fkey" FOREIGN KEY ("priceTierId") REFERENCES "PackagePriceTier"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,13 @@
#!/bin/sh
set -e
echo "🔍 Checking for failed migrations..."
# Mark legacy migrations as applied
npx prisma migrate resolve --applied "20240100000000_fix_failed_migration_state" || true
npx prisma migrate resolve --applied "20240101000000_individual_tickets_no_timezone" || true
npx prisma migrate resolve --applied "20240102000000_drop_ticket_column_defaults" || true
npx prisma migrate resolve --applied "20241201000000_remove_station_timezone" || true
npx prisma migrate resolve --applied "20260101000000_add_configurable_fare_system" || true
echo "✅ Migration resolution complete"

View File

@@ -483,8 +483,8 @@ export class BookingsService {
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor;
@@ -660,8 +660,8 @@ export class BookingsService {
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(combinedBase * 0.05);
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
@@ -854,8 +854,8 @@ export class BookingsService {
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(combinedBase * 0.05);
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
@@ -1060,7 +1060,7 @@ export class BookingsService {
loyaltyRedemptionPoints?: number
) {
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence);
const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence, originStop.stationId, destStop.stationId);
const adultFareMinor = baseFareMinor * adultCount;
const paidChildrenCount = Math.max(0, childCount - 1);
@@ -1076,8 +1076,8 @@ export class BookingsService {
}
const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor);
return {
baseFareMinor,
@@ -1103,6 +1103,8 @@ export class BookingsService {
nationality?: string,
originStopSeq?: number,
destStopSeq?: number,
originStationId?: string,
destinationStationId?: string,
): Promise<number> {
const now = new Date();
@@ -1149,13 +1151,13 @@ export class BookingsService {
const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality);
if (bestMatch) return bestMatch.baseFareMinor;
// 3. FareEngine — distance × rate-per-km from the schedule's route
// 3. FareEngine — distance × rate-per-km from the booking's actual segment stations
if (schedule?.routeId) {
try {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
originStationId: originStationId ?? schedule.originStationId,
destinationStationId: destinationStationId ?? schedule.destinationStationId,
seatClassId,
nationality,
});

View File

@@ -9,6 +9,9 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
/** Booking cutoff: reject new bookings within this many ms of departure. */
const BOOKING_CUTOFF_MS = 30 * 60 * 1000;
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
@@ -74,6 +77,10 @@ export class GuestBookingService {
});
if (!schedule) throw new NotFoundException('Schedule not found');
if (Date.now() >= schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
@@ -142,7 +149,9 @@ export class GuestBookingService {
dto.seatClassId,
segmentRoute,
fullRoute,
primaryNationality
primaryNationality,
dto.originStationId,
dto.destinationStationId,
);
const adultFareMinor = baseFareMinor * adultCount;
@@ -160,8 +169,8 @@ export class GuestBookingService {
}
}
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor;
@@ -293,6 +302,10 @@ export class GuestBookingService {
if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
if (!returnSchedule) throw new NotFoundException('Return schedule not found');
if (Date.now() >= outboundSchedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
@@ -350,8 +363,8 @@ export class GuestBookingService {
const primaryNationality = passengersData[0]?.nationality;
const [outboundBaseFare, returnBaseFare] = await Promise.all([
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality),
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality),
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId),
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId),
]);
const paidChildrenCount = Math.max(0, childCount - 1);
@@ -369,8 +382,8 @@ export class GuestBookingService {
}
}
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
@@ -505,6 +518,10 @@ export class GuestBookingService {
if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found');
if (Date.now() >= leg1Schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
@@ -551,11 +568,11 @@ export class GuestBookingService {
this.getBaseFare(dto.scheduleId, dto.seatClassId,
`${leg1OriginStop.station.code}-${leg1DestStop.station.code}`,
`${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`,
primaryNationality),
primaryNationality, dto.originStationId, dto.transitStationId),
this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId,
`${leg2OriginStop.station.code}-${leg2DestStop.station.code}`,
`${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`,
primaryNationality),
primaryNationality, dto.transitStationId, dto.leg2DestinationStationId),
]);
const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount;
@@ -569,8 +586,8 @@ export class GuestBookingService {
discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
const taxesMinor = Math.round(combinedBase * 0.05);
const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBase - discountMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
@@ -702,6 +719,10 @@ export class GuestBookingService {
if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found');
if (Date.now() >= obL1Sched.departureAt.getTime() - BOOKING_CUTOFF_MS) {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
@@ -750,10 +771,10 @@ export class GuestBookingService {
const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([
this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat),
this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat),
this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat),
this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat),
this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat, dto.originStationId, dto.transitStationId),
this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat, dto.transitStationId, dto.leg2DestinationStationId),
this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat, dto.returnOriginStationId, dto.returnTransitStationId),
this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat, dto.returnTransitStationId, dto.returnLeg2DestinationStationId),
]);
const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount +
@@ -951,17 +972,28 @@ export class GuestBookingService {
segmentRoute?: string,
fullRoute?: string,
nationality?: string,
originStationId?: string,
destinationStationId?: string,
): Promise<number> {
const now = new Date();
// 1. FareRule table — explicit override rules
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
});
// 1. FareRule table — explicit override rules (same priority logic as the fare engine)
const [candidates, seatClass] = await Promise.all([
this.prisma.fareRule.findMany({
where: {
seatClassId,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
}),
this.prisma.seatClass.findUnique({
where: { id: seatClassId },
select: { premiumMinor: true, insuranceFeeMinor: true },
}),
]);
const premiumMinor = seatClass?.premiumMinor ?? 0;
const insuranceMinor = seatClass?.insuranceFeeMinor ?? 0;
const priorities = [
{ tripId: scheduleId, route: segmentRoute, nationality },
@@ -982,10 +1014,11 @@ export class GuestBookingService {
const match = candidates.find(
(c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality,
);
if (match) return match.baseFareMinor;
// Return base fare + seat-class surcharges so the booking total matches the quoted fare
if (match) return match.baseFareMinor + premiumMinor + insuranceMinor;
}
// 2. FareEngine — distance × rate-per-km from the schedule's route
// 2. FareEngine — distance × rate-per-km from the booking's actual segment stations
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
@@ -995,12 +1028,15 @@ export class GuestBookingService {
try {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
// Use the booking's boarding/alighting stations so the distance reflects the
// passenger's actual segment, not the full schedule route.
originStationId: originStationId ?? schedule.originStationId,
destinationStationId: destinationStationId ?? schedule.destinationStationId,
seatClassId,
nationality,
});
return fare.baseFarePerPassengerMinor;
// farePerPassengerMinor already includes base + premiumMinor + insuranceFeeMinor
return fare.farePerPassengerMinor;
} catch {
// FareEngine throws if distanceKm is missing; fall through to error
}

View File

@@ -284,13 +284,16 @@ export class FareEngineService {
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
return fareRules.map(rule => {
const seatClassId = rule.seatClassId;
const taxMinor = Math.round(rule.baseFareMinor * TAX_RATE);
const totalMinor = rule.baseFareMinor + taxMinor;
return {
seatClassId,
seatClassName: 'Unknown',
baseFareMinor: rule.baseFareMinor,
totalMinor: rule.baseFareMinor,
taxMinor,
totalMinor,
billingCurrency,
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
totalInBillingCurrency: Math.round(totalMinor * exchangeRate),
exchangeRate,
source: 'FARE_RULE',
};

View File

@@ -31,6 +31,7 @@ import {
SupportedPaymentMethodDto,
PaymentMethodTypeEnum,
PaymentPlatformDto,
BookingAmountResponseDto,
} from "./payments.dto";
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@@ -139,16 +140,33 @@ export class PaymentsController {
@ApiOperation({
summary: "List payment systems supported by the platform",
description:
"Returns the global catalog of accepted payment systems. Filter by `currency` (e.g. ETB, DJF, USD) to get methods that settle in that currency, and/or by `region` to match a passenger's nationality. Both filters can be combined.",
"Returns all enabled payment methods. Optionally filter by `region` to narrow to methods available for a passenger's nationality.",
})
@ApiQuery({ name: "currency", required: false, example: "DJF", description: "Settlement currency — ETB, DJF, USD, etc." })
@ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false })
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
getMethods(
@Query("currency") currency?: string,
@Query("region") region?: PaymentRegionEnum,
) {
return this.service.getSupportedPaymentMethods(region, currency);
return this.service.getSupportedPaymentMethods(region);
}
@Get("booking-amount")
@SetMetadata('isPublic', true)
@ApiOperation({
summary: "Get booking amount in a specific currency",
description:
"Returns the booking total converted from ETB to the requested currency using the latest exchange rate. " +
"If currency is ETB the stored amount is returned as-is (no conversion). " +
"Amounts are returned in major currency units (e.g. 162.50 DJF, not centimes).",
})
@ApiQuery({ name: "bookingId", required: true, description: "Booking UUID" })
@ApiQuery({ name: "currency", required: true, example: "DJF", description: "Target currency: ETB, DJF, or USD" })
@ApiOkResponse({ type: BookingAmountResponseDto })
getBookingAmount(
@Query("bookingId") bookingId: string,
@Query("currency") currency: string,
) {
return this.service.getBookingAmountByCurrency(bookingId, currency);
}
@Get("checkout")

View File

@@ -136,3 +136,9 @@ export class IntentStatusDto {
@ApiPropertyOptional() failureCode?: string;
@ApiPropertyOptional() failureMessage?: string;
}
export class BookingAmountResponseDto {
@ApiProperty({ example: 'booking-uuid' }) booking_id: string;
@ApiProperty({ example: 'DJF', description: 'Currency of the returned amount' }) currency: string;
@ApiProperty({ example: 162.5, description: 'Booking total converted to the requested currency (major units)' }) amount: number;
}

View File

@@ -476,7 +476,7 @@ export class PaymentsService {
});
}
getSupportedPaymentMethods(region?: PaymentRegionEnum, currency?: string) {
getSupportedPaymentMethods(region?: PaymentRegionEnum) {
return this.prisma.paymentMethod.findMany({
where: {
enabled: true,
@@ -490,12 +490,41 @@ export class PaymentsService {
},
}
: {}),
...(currency ? { currency: currency.toUpperCase() } : {}),
},
orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }],
});
}
async getBookingAmountByCurrency(
bookingId: string,
currency: string,
): Promise<{ booking_id: string; currency: string; amount: number }> {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
select: { id: true, totalMinor: true },
});
if (!booking) throw new NotFoundException('Booking not found');
const requestedCurrency = currency.toUpperCase();
const amountInETB = booking.totalMinor / 100;
if (requestedCurrency === 'ETB') {
return { booking_id: bookingId, currency: 'ETB', amount: amountInETB };
}
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
where: { fromCurrency: 'ETB' as any, toCurrency: requestedCurrency as any },
orderBy: { effectiveDate: 'desc' },
});
if (!exchangeRate) {
throw new NotFoundException(`Exchange rate not found for ETB → ${requestedCurrency}`);
}
const rate = Number(exchangeRate.rate);
const converted = parseFloat((amountInETB * rate).toFixed(2));
return { booking_id: bookingId, currency: requestedCurrency, amount: converted };
}
/**
* Guard against an implausible paidAt from a provider event (e.g. a Telebirr epoch parsed as
* ms×1000 → year 58429), which Prisma/Postgres rejects and would otherwise dead-letter the

View File

@@ -1,11 +1,11 @@
import { IsString, IsInt, IsOptional, IsArray, ValidateNested, IsBoolean, IsDateString, Min } from 'class-validator';
import { IsString, IsInt, IsNumber, IsOptional, IsArray, ValidateNested, IsBoolean, IsDateString, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class RouteStopInputDto {
@ApiProperty({ example: 'station-uuid', description: 'Station UUID' }) @IsString() stationId: string;
@ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number;
@ApiPropertyOptional({ example: 120, description: 'Distance in km from previous stop' }) @IsOptional() @IsInt() distanceKm?: number;
@ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number;
}
export class CreateRouteDto {
@@ -34,7 +34,7 @@ export class CreateRouteDto {
export class AddRouteStopDto {
@ApiProperty({ example: 'station-uuid' }) @IsString() stationId: string;
@ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number;
@ApiPropertyOptional({ example: 75 }) @IsOptional() @IsInt() distanceKm?: number;
@ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number;
}
export class UpdateRouteDto {

View File

@@ -34,7 +34,7 @@ export class RoutesService {
create: dto.stops.map(s => ({
stationId: s.stationId,
sequence: s.sequence,
distanceKm: s.distanceKm,
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
})),
},
},
@@ -135,7 +135,12 @@ export class RoutesService {
if (existing) throw new ConflictException(`Sequence ${dto.sequence} already exists on this route`);
return this.prisma.routeStop.create({
data: { routeId, stationId: dto.stationId, sequence: dto.sequence, distanceKm: dto.distanceKm },
data: {
routeId,
stationId: dto.stationId,
sequence: dto.sequence,
distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null,
},
});
}

View File

@@ -2,9 +2,8 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe,
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SchedulesService } from './schedules.service';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto } from './schedules.dto';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus } from './schedules.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { TripStatus } from '@prisma/client';
@ApiTags('Schedule')
@Controller('schedules')

View File

@@ -1,7 +1,27 @@
import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { TripStatus, StopStatus, PassengerCategory } from '@prisma/client';
export enum TripStatus {
SCHEDULED = 'SCHEDULED',
BOARDING = 'BOARDING',
EN_ROUTE = 'EN_ROUTE',
ARRIVED = 'ARRIVED',
CANCELLED = 'CANCELLED',
DELAYED = 'DELAYED',
}
export enum StopStatus {
COMPLETED = 'COMPLETED',
APPROACHING = 'APPROACHING',
CURRENT = 'CURRENT',
UPCOMING = 'UPCOMING',
}
export enum PassengerCategory {
ADULT = 'ADULT',
CHILD = 'CHILD',
}
export class PlannedStopTimeDto {
@ApiProperty({ example: 1, description: 'Route stop sequence number this timing applies to' }) @IsInt() @Min(1) sequence: number;

View File

@@ -474,8 +474,8 @@ export class SearchService {
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR;
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
const taxesMinor = 0;
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality);
const displayTotalMinor = displayCurrency !== Currency.ETB

View File

@@ -3,12 +3,19 @@ import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../../common/prisma.service';
import { SmsClientService } from '../notifications/sms-client.service';
/** Minutes before departure at which each action fires. */
const REMINDER_MINUTES = 3 * 60; // 3 h → send payment reminder SMS
const DEADLINE_MINUTES = 2 * 60; // 2 h → cancel unpaid booking
/** Maximum time (hours) a passenger has to pay after booking. */
const MAX_PAYMENT_HOURS = 2;
/** Minutes before departure: cutoff for new bookings and payment deadline. */
const CUTOFF_MINUTES = 30;
/** Half-width of the reminder detection window (cron runs every 2 min). */
const REMINDER_WINDOW_MINUTES = 2;
/**
* payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
*/
function computePaymentDeadline(createdAt: Date, departureAt: Date): Date {
const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000);
const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000);
return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
}
function fmtTime(d: Date): string {
return d.toLocaleTimeString('en-GB', {
@@ -28,66 +35,72 @@ export class TasksService {
) {}
// ─────────────────────────────────────────────────────────────────────────
// Every 2 min: advance TrainSchedule statuses (departure / arrival).
// Every 1 min: advance TrainSchedule statuses.
//
// SCHEDULED → BOARDING when departure ≤ 30 min away (closed to new bookings)
// BOARDING → EN_ROUTE at actual departure
// EN_ROUTE → ARRIVED at arrival time
// ─────────────────────────────────────────────────────────────────────────
@Cron('*/2 * * * *')
@Cron('*/1 * * * *')
async syncScheduleStatuses() {
const now = new Date();
const thirtyMinFromNow = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
const [departed, arrived] = await Promise.all([
const [boarding, departed, arrived] = await Promise.all([
this.prisma.trainSchedule.updateMany({
where: { status: 'SCHEDULED', departureAt: { lte: now } },
where: { status: 'SCHEDULED', departureAt: { lte: thirtyMinFromNow } },
data: { status: 'BOARDING' },
}),
this.prisma.trainSchedule.updateMany({
where: { status: 'BOARDING', departureAt: { lte: now } },
data: { status: 'EN_ROUTE' },
}),
this.prisma.trainSchedule.updateMany({
where: { status: { in: ['EN_ROUTE', 'BOARDING'] }, arrivalAt: { lte: now } },
where: { status: 'EN_ROUTE', arrivalAt: { lte: now } },
data: { status: 'ARRIVED' },
}),
]);
if (departed.count > 0 || arrived.count > 0) {
if (boarding.count > 0 || departed.count > 0 || arrived.count > 0) {
this.logger.log(
`Schedule sync: ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`,
`Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`,
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Every 2 min: payment deadline enforcement.
// Every 1 min: payment deadline enforcement.
//
// • 3 h before departure → send one SMS reminder to complete payment.
// • 2 h before departure → cancel booking if payment is still pending
// and notify the passenger by SMS.
// Reminder — sent once at the midpoint of the booking's payment window:
// reminder_at = booking_time + total_window / 2
//
// Example: train departs 08:00
// 05:00 → reminder SMS sent ("pay before 06:00 or booking is cancelled")
// 06:00 → booking auto-cancelled, cancellation SMS sent
// Cancel — when now ≥ payment_deadline
// payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
//
// Examples (departure 10:00, cutoff 9:30):
// Booked 8:00 → deadline 9:30, window 1.5h, reminder at 8:45
// Booked 9:00 → deadline 9:30, window 30min, reminder at 9:15
// ─────────────────────────────────────────────────────────────────────────
@Cron('*/2 * * * *')
@Cron('*/1 * * * *')
async enforcePaymentDeadlines() {
const now = new Date();
await Promise.all([
this.sendPaymentReminders(now),
this.cancelExpiredPendingBookings(now),
]);
}
// ── 3-hour reminder ───────────────────────────────────────────────────────
// ── Send reminder at the midpoint of each booking's payment window ────────
private async sendPaymentReminders(now: Date) {
// Narrow 4-minute window (±2 min around the 3-hour mark) so each booking
// is caught by exactly one cron tick and paymentReminderSentAt guards re-sends.
const windowMs = REMINDER_WINDOW_MINUTES * 60 * 1000;
const reminderMs = REMINDER_MINUTES * 60 * 1000;
const windowStart = new Date(now.getTime() + reminderMs - windowMs);
const windowEnd = new Date(now.getTime() + reminderMs + windowMs);
// Only look at bookings created within the last 3 h with a future departure.
const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000);
const bookings = await this.prisma.booking.findMany({
where: {
status: 'PENDING_PAYMENT',
paymentReminderSentAt: null,
schedule: { departureAt: { gte: windowStart, lte: windowEnd } },
createdAt: { gte: threeHoursAgo },
schedule: { departureAt: { gte: now } },
} as any,
include: {
schedule: {
@@ -101,15 +114,28 @@ export class TasksService {
for (const booking of bookings) {
try {
const dep = booking.schedule.departureAt as Date;
const deadline = new Date(dep.getTime() - DEADLINE_MINUTES * 60 * 1000);
const origin = booking.schedule.originStation?.name ?? '';
const dest = booking.schedule.destinationStation?.name ?? '';
const createdAt = booking.createdAt as Date;
const dep = booking.schedule.departureAt as Date;
const paymentDeadline = computePaymentDeadline(createdAt, dep);
const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime();
// Skip degenerate windows (< 2 min) — the cancel job will handle these immediately
if (totalWindowMs < 2 * 60 * 1000) continue;
// Remind once, at the midpoint of the total payment window
const reminderAt = new Date(createdAt.getTime() + totalWindowMs / 2);
if (now < reminderAt) continue;
const origin = booking.schedule.originStation?.name ?? '';
const dest = booking.schedule.destinationStation?.name ?? '';
const remainingMs = Math.max(0, paymentDeadline.getTime() - now.getTime());
const remainingMin = Math.round(remainingMs / 60_000);
const message =
`EDR: Your booking ${booking.bookingRef} ` +
`(${origin}${dest}) departs at ${fmtTime(dep)}. ` +
`Complete payment by ${fmtTime(deadline)} or your booking will be cancelled.`;
`Complete payment within ${remainingMin} minute(s) (by ${fmtTime(paymentDeadline)}) ` +
`or your booking will be cancelled.`;
if (booking.contactPhone) {
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
@@ -121,7 +147,8 @@ export class TasksService {
});
this.logger.log(
`Payment reminder sent: ${booking.bookingRef} (departs ${fmtTime(dep)}, deadline ${fmtTime(deadline)})`,
`Payment reminder sent: ${booking.bookingRef} ` +
`(deadline ${fmtTime(paymentDeadline)}, ${remainingMin} min remaining)`,
);
} catch (err) {
this.logger.error(
@@ -131,14 +158,22 @@ export class TasksService {
}
}
// ── 2-hour auto-cancel ────────────────────────────────────────────────────
// ── Cancel bookings whose payment deadline has passed ─────────────────────
private async cancelExpiredPendingBookings(now: Date) {
const cutoff = new Date(now.getTime() + DEADLINE_MINUTES * 60 * 1000); // now + 2 h
const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
// payment_deadline = MIN(createdAt + 2h, departureAt - 30min)
// Deadline is reached when either branch of the MIN is in the past:
// (a) createdAt ≤ now - 2h → 2-hour max window elapsed
// (b) departureAt ≤ now + 30min → departure within 30 min
const expiredBookings = await this.prisma.booking.findMany({
where: {
status: 'PENDING_PAYMENT',
schedule: { departureAt: { lte: cutoff } },
OR: [
{ createdAt: { lte: twoHoursAgo } },
{ schedule: { departureAt: { lte: departureCutoff } } },
],
},
include: {
schedule: {
@@ -151,8 +186,16 @@ export class TasksService {
},
});
let cancelledCount = 0;
for (const booking of expiredBookings) {
try {
// Re-verify exact deadline to avoid racing with a concurrent payment confirmation
const createdAt = booking.createdAt as Date;
const dep = booking.schedule.departureAt as Date;
const paymentDeadline = computePaymentDeadline(createdAt, dep);
if (now < paymentDeadline) continue;
// 1. Release held seats (Journey rows are the occupancy source of truth)
await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any });
@@ -161,12 +204,12 @@ export class TasksService {
data: {
bookingId: booking.id,
cancelledBy: 'SYSTEM',
reason: 'Payment not completed before departure deadline',
reason: 'Payment not completed before deadline',
refundAmount: 0,
refundMethod: booking.paymentIntent?.method ?? 'NONE',
refundStatus: 'NOT_APPLICABLE',
},
}).catch(() => null); // booking may already have a cancellation record
}).catch(() => null);
// 3. Mark cancelled
await this.prisma.booking.update({
@@ -175,22 +218,20 @@ export class TasksService {
});
// 4. Notify passenger
const dep = booking.schedule.departureAt as Date;
const origin = booking.schedule.originStation?.name ?? '';
const dest = booking.schedule.destinationStation?.name ?? '';
const message =
`EDR: Your booking ${booking.bookingRef} ` +
`(${origin}${dest}, departs ${fmtTime(dep)}) has been cancelled ` +
`because payment was not completed before the deadline.`;
`because payment was not completed before the deadline (${fmtTime(paymentDeadline)}).`;
if (booking.contactPhone) {
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
}
this.logger.log(
`Auto-cancelled: ${booking.bookingRef} (payment deadline expired, departs ${fmtTime(dep)})`,
);
this.logger.log(`Auto-cancelled: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})`);
cancelledCount++;
} catch (err) {
this.logger.error(
`Auto-cancel failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`,
@@ -198,8 +239,8 @@ export class TasksService {
}
}
if (expiredBookings.length > 0) {
this.logger.log(`Auto-cancelled ${expiredBookings.length} expired pending booking(s)`);
if (cancelledCount > 0) {
this.logger.log(`Auto-cancelled ${cancelledCount} expired pending booking(s)`);
}
}
}

View File

@@ -8,7 +8,6 @@ import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { useEffect, useState, useRef } from 'react';
import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train, FileText } from 'lucide-react';
import { QRCodeSVG } from 'qrcode.react';
import { format } from 'date-fns';
type BookingWithTicket = {
@@ -77,54 +76,71 @@ export default function ConfirmationPage() {
};
const handleDownloadVoucher = async () => {
if (!_booking || !pnr) {
if (!pnr) {
alert('Booking data not available. Please try again.');
return;
}
if (!passengers.length) {
alert('No passenger data found.');
return;
}
setIsGeneratingVoucher(true);
try {
console.log('📄 Generating voucher with data:', { _booking, pnr, selectedSchedule, passengers });
const { generateVoucherPDF } = await import('@/lib/generate-voucher');
const voucherData = {
bookingRef: pnr,
status: _booking.status || 'CONFIRMED',
passengers: passengers.map(p => ({
fullName: p.name,
category: 'ADULT',
seat: p.seatNumber ? {
number: p.seatNumber,
coach: 'N/A',
seatClass: selectedSchedule?.selectedSeatClassName || 'Standard',
} : undefined,
})),
schedule: {
trainNumber: selectedSchedule?.trainNumber || 'N/A',
trainName: 'EDR Express',
origin: {
name: selectedSchedule?.origin || 'Origin',
code: 'ORG',
city: selectedSchedule?.origin || 'Origin',
},
destination: {
name: selectedSchedule?.destination || 'Destination',
code: 'DST',
city: selectedSchedule?.destination || 'Destination',
},
departureAt: selectedSchedule?.departureTime || new Date().toISOString(),
arrivalAt: selectedSchedule?.arrivalTime || new Date().toISOString(),
},
totalMinor: _booking.totalMinor || passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0),
currency: 'ETB',
bookingType: 'ONE_WAY',
createdAt: new Date().toISOString(),
const { generatePassengerVoucherPDF } = await import('@/lib/generate-voucher');
const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
const totalFare = _booking?.totalMinor
|| passengers.reduce((s) => s + (activeSchedule?.baseFareAdult || 0), 0);
const farePerPassenger = Math.round(totalFare / passengers.length);
const createdAt = _booking?.createdAt || new Date().toISOString();
const status = _booking?.status || 'CONFIRMED';
const outbound = {
trainNumber: activeSchedule?.trainNumber || 'N/A',
trainName: 'EDR Express',
origin: { name: activeSchedule?.origin || 'Origin', code: 'ORG', city: activeSchedule?.origin || 'Origin' },
destination: { name: activeSchedule?.destination || 'Destination', code: 'DST', city: activeSchedule?.destination || 'Destination' },
departureAt: activeSchedule?.departureTime || new Date().toISOString(),
arrivalAt: activeSchedule?.arrivalTime || new Date().toISOString(),
seatClass: activeSchedule?.selectedSeatClassName,
};
console.log('📄 Voucher data prepared:', voucherData);
await generateVoucherPDF(voucherData);
console.log('✅ Voucher generated successfully');
const inbound = inboundSchedule ? {
trainNumber: inboundSchedule.trainNumber || 'N/A',
trainName: 'EDR Express',
origin: { name: inboundSchedule.origin, code: 'ORG', city: inboundSchedule.origin },
destination: { name: inboundSchedule.destination, code: 'DST', city: inboundSchedule.destination },
departureAt: inboundSchedule.departureTime || new Date().toISOString(),
arrivalAt: inboundSchedule.arrivalTime || new Date().toISOString(),
seatClass: inboundSchedule.selectedSeatClassName,
} : undefined;
for (let i = 0; i < passengers.length; i++) {
const p = passengers[i];
const ticketNumber = `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(i + 1).toString().padStart(2, '0')}`;
await generatePassengerVoucherPDF({
bookingRef: pnr,
ticketNumber,
passengerName: p.name || `Passenger ${i + 1}`,
dateOfBirth: p.dateOfBirth,
nationality: p.nationality,
seatNumber: p.seatNumber,
outboundSeatNumber: (p as any).outboundSeatNumber,
inboundSeatNumber: (p as any).inboundSeatNumber,
status,
outboundSchedule: outbound,
inboundSchedule: inbound,
isRoundTrip,
fareMinor: farePerPassenger,
currency: 'ETB',
createdAt,
});
// brief pause between downloads so browsers don't block them
if (i < passengers.length - 1) await new Promise(r => setTimeout(r, 400));
}
} catch (error) {
console.error('❌ Failed to generate voucher:', error);
alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -191,17 +207,9 @@ export default function ConfirmationPage() {
</div>
</div>
{/* Trip Summary with QR Code */}
{/* Trip Details */}
<div className="card mb-6">
<div className="flex flex-col md:flex-row gap-6">
{/* QR Code Section */}
<div className="flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-800 rounded-lg p-6 md:w-48 flex-shrink-0">
<QRCodeSVG value={pnr} size={160} level="H" includeMargin={true} />
<p className="text-xs text-gray-600 dark:text-gray-400 mt-2 text-center font-semibold">Scan at gate</p>
</div>
{/* Trip Details */}
<div className="flex-1">
<div>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center">
<Train className="w-6 h-6 text-primary dark:text-primary-400" />
@@ -302,7 +310,6 @@ export default function ConfirmationPage() {
</div>
</div>
)}
</div>
</div>
</div>

View File

@@ -332,12 +332,135 @@ function DobPickerModal({
);
}
// ─── phone validation ─────────────────────────────────────────────────────────
type PhoneNat = 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER';
const PHONE_PRESETS: Record<PhoneNat, { flag: string; code: string; example: string; hint: string }> = {
ETHIOPIAN: { flag: '🇪🇹', code: '+251', example: '912345678', hint: '+251912345678 or 0912345678' },
DJIBOUTIAN: { flag: '🇩🇯', code: '+253', example: '77123456', hint: '+25377123456' },
OTHER: { flag: '🌐', code: '+', example: '14155552671', hint: 'International: +[country code][number]' },
};
function getPhoneNat(nationality: string): PhoneNat {
const n = (nationality || '').toUpperCase();
if (n === 'ETHIOPIAN') return 'ETHIOPIAN';
if (n === 'DJIBOUTIAN') return 'DJIBOUTIAN';
return 'OTHER';
}
function validatePhone(phone: string, nationality: string): string | null {
const normalized = (phone || '').replace(/[\s\-().]/g, '');
if (!normalized) return 'Phone number is required';
const nat = getPhoneNat(nationality);
if (nat === 'ETHIOPIAN') {
if (/^(\+251\d{9}|09\d{8})$/.test(normalized)) return null;
return 'Invalid Ethiopian phone number (e.g., +251912345678 or 0912345678)';
}
if (nat === 'DJIBOUTIAN') {
if (/^\+253\d{8}$/.test(normalized)) return null;
return 'Invalid Djiboutian phone number (e.g., +25377123456)';
}
if (/^\+[1-9]\d{7,14}$/.test(normalized)) return null;
return 'Invalid international phone number (e.g., +14155552671)';
}
function stripPhonePrefix(stored: string, nat: PhoneNat): string {
const code = PHONE_PRESETS[nat].code;
if (nat !== 'OTHER' && stored.startsWith(code)) return stored.slice(code.length);
if (nat === 'OTHER' && stored.startsWith('+')) return stored.slice(1);
return stored;
}
function buildFullNumber(localInput: string, nat: PhoneNat): string {
const stripped = localInput.replace(/[\s\-().]/g, '');
if (!stripped) return stripped;
if (nat === 'ETHIOPIAN') {
if (stripped.startsWith('+') || stripped.startsWith('0')) return stripped;
return '+251' + stripped;
}
if (nat === 'DJIBOUTIAN') {
if (stripped.startsWith('+')) return stripped;
return '+253' + stripped;
}
return stripped.startsWith('+') ? stripped : '+' + stripped;
}
function PhoneInput({
nationality,
storedValue,
onInterimChange,
onNormalized,
error,
}: {
nationality: string;
storedValue: string;
onInterimChange: (full: string) => void;
onNormalized: (full: string) => void;
error?: string;
}) {
const nat = getPhoneNat(nationality);
const preset = PHONE_PRESETS[nat];
const [localInput, setLocalInput] = useState(() => stripPhonePrefix(storedValue || '', nat));
const prevStoredRef = useRef(storedValue);
useEffect(() => {
if (storedValue !== prevStoredRef.current) {
prevStoredRef.current = storedValue;
setLocalInput(stripPhonePrefix(storedValue || '', nat));
}
}, [storedValue, nat]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
setLocalInput(raw);
onInterimChange(buildFullNumber(raw, nat));
};
const handleBlur = () => {
const full = buildFullNumber(localInput, nat);
setLocalInput(stripPhonePrefix(full, nat));
onNormalized(full);
};
return (
<div>
<div className={`flex rounded-lg overflow-hidden border transition-colors focus-within:ring-1 ${
error
? 'border-red-500 focus-within:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus-within:border-primary focus-within:ring-primary'
}`}>
<div className="flex items-center gap-1.5 px-3 py-2.5 bg-gray-50 dark:bg-gray-800 border-r border-gray-300 dark:border-gray-600 select-none flex-shrink-0">
<span className="text-sm leading-none">{preset.flag}</span>
<span className="text-xs font-semibold text-gray-600 dark:text-gray-300">{preset.code}</span>
</div>
<input
type="tel"
value={localInput}
onChange={handleChange}
onBlur={handleBlur}
placeholder={preset.example}
autoComplete="tel"
className="flex-1 px-3 py-2.5 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-white outline-none min-w-0"
/>
</div>
{error ? (
<p className="text-red-500 text-xs mt-1">{error}</p>
) : (
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">Format: {preset.hint}</p>
)}
</div>
);
}
// ─── passenger zod schema ──────────────────────────────────────────────────────
const passengerSchema = z.object({
name: z.string().min(2, 'Full name is required (min 2 characters)'),
dateOfBirth: z.string().min(1, 'Date of birth is required'),
gender: z.string().min(1, 'Gender is required'),
nationality: z.string().min(1, 'Nationality is required'),
phone: z.string().min(1, 'Phone number is required'),
phone: z.string(),
email: z.string().optional(),
nationalId: z.string().optional(),
passportNumber: z.string().optional(),
@@ -358,6 +481,10 @@ const passengerSchema = z.object({
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['email'] });
}
}
const phoneError = validatePhone(data.phone, data.nationality);
if (phoneError) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: phoneError, path: ['phone'] });
}
const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian';
if (isNonEthiopian) {
if (!data.passportNumber || data.passportNumber.trim().length === 0) {
@@ -769,14 +896,13 @@ export default function PassengersPage() {
{/* Phone */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
<input
{...register(`passengers.${index}.phone`)}
className={`input-field ${errors.passengers?.[index]?.phone ? 'border-red-500' : ''}`}
placeholder="+251911234567"
<PhoneInput
nationality={passengers[index]?.nationality || 'ETHIOPIAN'}
storedValue={passengers[index]?.phone || ''}
onInterimChange={(v) => setValue(`passengers.${index}.phone`, v)}
onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
error={errors.passengers?.[index]?.phone?.message}
/>
{errors.passengers?.[index]?.phone && (
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.phone?.message}</p>
)}
</div>
{/* Email */}
@@ -850,14 +976,13 @@ export default function PassengersPage() {
{/* Phone */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
<input
{...register(`passengers.${index}.phone`)}
className={`input-field ${errors.passengers?.[index]?.phone ? 'border-red-500' : ''}`}
placeholder="+254712345678"
<PhoneInput
nationality={passengers[index]?.nationality || 'OTHER'}
storedValue={passengers[index]?.phone || ''}
onInterimChange={(v) => setValue(`passengers.${index}.phone`, v)}
onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
error={errors.passengers?.[index]?.phone?.message}
/>
{errors.passengers?.[index]?.phone && (
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.phone?.message}</p>
)}
</div>
{/* Email */}

Some files were not shown because too many files have changed in this diff Show More