resolve conflict

This commit is contained in:
Marshal
2026-07-07 21:46:25 +00:00
213 changed files with 15163 additions and 1998 deletions

View File

@@ -1,6 +1,16 @@
import { Injectable } from "@nestjs/common";
import { PdfRenderService } from "./pdf-render.service";
import {
PdfColor,
assembleSinglePagePdf,
lineOp,
rectOp,
sealOp,
textOp,
textOpRight,
wrapText,
} from "./styled-pdf.util";
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
@@ -62,10 +72,136 @@ export class InvoiceDocumentService {
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
return {
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
buffer: await this.pdf.htmlToPdfBuffer(html, { label: `${model.title} ${kindLabel}` }),
buffer: await this.pdf.htmlToPdfBuffer(html, {
label: `${model.title} ${kindLabel}`,
// Chromium-less fallback: draw a genuine styled invoice (header, seal,
// summary grid, line-item table, totals) from the model — not a flat
// plain-text dump — so it still reads as a proper invoice document.
fallback: () => this.buildFallbackPdf(model),
}),
};
}
/**
* Vector-drawn styled invoice/receipt used when headless Chromium is
* unavailable. Mirrors the HTML layout closely enough to pass as the same
* document. Single A4 page; long summaries / line lists are capped to fit.
*/
buildFallbackPdf(model: InvoiceDocumentModel): Buffer {
const currency = (cur?: string | null) =>
(cur ?? model.currency) === "ETB" ? "ETB" : (cur ?? model.currency);
const money = (amount: unknown, cur?: string | null) =>
`${Number(amount ?? 0).toLocaleString()} ${currency(cur)}`;
const date = (value: unknown) =>
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`;
const sealText =
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
const showCategory = Boolean(model.categoryHeader);
const ops: string[] = [];
// ── Header ────────────────────────────────────────────────────────────
ops.push(lineOp(36, 806, 559, 806, PdfColor.teal, 2.4));
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", 36, 790, 8.5, "F2", PdfColor.gray));
const titleSize = heading.length > 34 ? 18 : 22;
ops.push(textOp(heading, 36, 762, titleSize, "F2", PdfColor.dark));
ops.push(textOpRight("DOCUMENT NO.", 559, 792, 7.5, "F2", PdfColor.gray));
ops.push(textOpRight(model.documentNumber, 559, 776, 12, "F2", PdfColor.dark));
ops.push(textOpRight(`Issued ${date(model.issuedAt)}`, 559, 762, 8.5, "F1", PdfColor.gray));
ops.push(
textOpRight(
`Status ${model.status}`,
559,
748,
8.5,
"F1",
model.status === "PAID" ? PdfColor.teal : PdfColor.gray,
),
);
ops.push(lineOp(36, 736, 470, 736, PdfColor.line, 1));
// ── Seal ──────────────────────────────────────────────────────────────
ops.push(sealOp(516, 706, 27, sealText.split(/\s+/), PdfColor.teal));
// ── Summary grid (two columns) ────────────────────────────────────────
let y = 700;
const colX = [36, 300];
const colW = 250;
model.summary.slice(0, 16).forEach((row, i) => {
const x = colX[i % 2];
if (i % 2 === 0 && i > 0) y -= 27;
ops.push(textOp((row.label ?? "").toUpperCase(), x, y, 7, "F1", PdfColor.gray));
ops.push(textOp(this.clip(row.value ?? "-", 44), x, y - 11, 9, "F2", PdfColor.dark));
ops.push(lineOp(x, y - 15, x + colW, y - 15, PdfColor.line, 0.6));
});
y -= 34;
// ── Line-item table ───────────────────────────────────────────────────
const qtyR = 402;
const rateR = 486;
const amtR = 555;
ops.push(rectOp(36, y - 18, 523, 18, PdfColor.shade, PdfColor.line, 0.7));
ops.push(textOp("DESCRIPTION", 40, y - 13, 8, "F2", PdfColor.gray));
if (showCategory) {
ops.push(textOp((model.categoryHeader ?? "").toUpperCase(), 250, y - 13, 8, "F2", PdfColor.gray));
}
ops.push(textOpRight("QTY", qtyR, y - 13, 8, "F2", PdfColor.gray));
ops.push(textOpRight("RATE", rateR, y - 13, 8, "F2", PdfColor.gray));
ops.push(textOpRight("AMOUNT", amtR, y - 13, 8, "F2", PdfColor.gray));
y -= 18;
const descChars = showCategory ? 44 : 66;
for (const item of model.lines) {
if (y < 190) break; // leave room for totals + footer
const descLines = wrapText(item.description ?? "-", descChars).slice(0, 2);
const rowH = Math.max(18, descLines.length * 10 + 8);
ops.push(rectOp(36, y - rowH, 523, rowH, "1 1 1", PdfColor.line, 0.6));
descLines.forEach((line, k) => {
ops.push(textOp(line, 40, y - 12 - k * 10, 8, "F1", PdfColor.dark));
});
if (showCategory) {
ops.push(textOp(this.clip((item.category ?? "").replace(/_/g, " "), 18), 250, y - 12, 8, "F1", PdfColor.dark));
}
ops.push(textOpRight(String(item.quantity ?? 0), qtyR, y - 12, 8, "F1", PdfColor.dark));
ops.push(textOpRight(money(item.unitRate, item.currency), rateR, y - 12, 8, "F1", PdfColor.dark));
ops.push(textOpRight(money(item.amount, item.currency), amtR, y - 12, 8, "F1", PdfColor.dark));
y -= rowH;
}
// ── Totals ────────────────────────────────────────────────────────────
let ty = y - 16;
for (const total of model.totals) {
if (ty < 88) break;
if (total.grand) {
ops.push(lineOp(315, ty + 5, 559, ty + 5, PdfColor.dark, 0.9));
ops.push(textOp(total.label, 320, ty - 9, 11, "F2", PdfColor.dark));
ops.push(textOpRight(money(total.amount), 555, ty - 9, 12, "F2", PdfColor.dark));
ty -= 24;
} else {
ops.push(textOp(total.label, 320, ty - 8, 9.5, "F1", PdfColor.gray));
ops.push(textOpRight(money(total.amount), 555, ty - 8, 10, "F1", PdfColor.dark));
ty -= 17;
}
}
// ── Footer ────────────────────────────────────────────────────────────
ops.push(lineOp(36, 64, 250, 64, PdfColor.dark, 0.8));
ops.push(textOp("Prepared by EDR finance", 36, 52, 7.5, "F1", PdfColor.gray));
ops.push(lineOp(340, 64, 559, 64, PdfColor.dark, 0.8));
ops.push(textOp("Authorized seal / signature", 340, 52, 7.5, "F1", PdfColor.gray));
return assembleSinglePagePdf(ops);
}
/** Truncate to `max` chars with an ellipsis. */
private clip(value: string, max: number): string {
const text = String(value ?? "");
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
}
buildHtml(model: InvoiceDocumentModel): string {
const esc = (value: unknown) =>
String(value ?? "-")

View File

@@ -0,0 +1,322 @@
/**
* Minimal hand-built PDF primitives shared by the Chromium-less document
* fallbacks (invoices, receipts). These draw a genuine vector layout — boxes,
* rules, right-aligned money, a round seal — so a document still looks like a
* real document when headless Chromium is unavailable, instead of degrading to
* a flat plain-text dump. Coordinates are PDF user space (origin bottom-left,
* A4 = 595 x 842 pt). Fonts: F1 = Helvetica, F2 = Helvetica-Bold.
*/
export const MIN_VALID_PDF_BYTES = 2_000;
/** Colours as PDF "r g b" triples in the 0..1 range. */
export const PdfColor = {
teal: "0.06 0.46 0.43",
dark: "0.06 0.09 0.16",
gray: "0.39 0.45 0.55",
line: "0.80 0.84 0.89",
shade: "0.96 0.97 0.98",
tint: "0.94 0.99 0.98",
} as const;
export function escapePdfText(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/\(/g, "\\(")
.replace(/\)/g, "\\)")
.replace(/[^\x20-\x7e]/g, " ");
}
/** Approximate rendered width of Helvetica text (slightly over-estimated so
* right-aligned text never crosses its column edge). */
export function textWidth(text: string, size: number): number {
return text.length * size * 0.52;
}
export function textOp(
text: string,
x: number,
y: number,
size: number,
font: "F1" | "F2" = "F1",
color: string = PdfColor.dark,
): string {
return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`;
}
/** Right-align `text` so it ends at `rightX`. */
export function textOpRight(
text: string,
rightX: number,
y: number,
size: number,
font: "F1" | "F2" = "F1",
color: string = PdfColor.dark,
): string {
return textOp(text, rightX - textWidth(text, size), y, size, font, color);
}
export function lineOp(
x1: number,
y1: number,
x2: number,
y2: number,
color: string = PdfColor.line,
width = 0.8,
): string {
return `q\n${color} RG\n${width} w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
}
export function rectOp(
x: number,
y: number,
width: number,
height: number,
fillColor = "1 1 1",
strokeColor: string = PdfColor.line,
lineWidth = 0.7,
): string {
return `q\n${fillColor} rg\n${strokeColor} RG\n${lineWidth} w\n${x} ${y} ${width} ${height} re\nB\nQ`;
}
function circlePath(cx: number, cy: number, r: number): string {
const k = 0.5522847498;
const c = r * k;
return [
`${cx + r} ${cy} m`,
`${cx + r} ${cy + c} ${cx + c} ${cy + r} ${cx} ${cy + r} c`,
`${cx - c} ${cy + r} ${cx - r} ${cy + c} ${cx - r} ${cy} c`,
`${cx - r} ${cy - c} ${cx - c} ${cy - r} ${cx} ${cy - r} c`,
`${cx + c} ${cy - r} ${cx + r} ${cy - c} ${cx + r} ${cy} c`,
"h",
].join("\n");
}
/** A double-ring round rubber-stamp seal carrying up to three centred lines. */
export function sealOp(
cx: number,
cy: number,
r: number,
lines: string[],
color: string = PdfColor.teal,
): string {
const rows = lines.slice(0, 3);
const ops = [
"q",
`${color} RG`,
`${color} rg`,
"2 w",
circlePath(cx, cy, r),
"S",
"0.7 w",
circlePath(cx, cy, r - 6),
"S",
];
const startY = cy + (rows.length - 1) * 6;
rows.forEach((text, i) => {
const size = i === 0 ? 10 : 7.5;
ops.push(textOpRight(text, cx + textWidth(text, size) / 2, startY - i * 12 - 3, size, "F2", color));
});
ops.push("Q");
return ops.join("\n");
}
/** Hard-truncate to `max` chars (no marker — keeps dense table cells tight). */
export function clipText(value: string, max: number): string {
const t = String(value ?? "");
return t.length > max ? t.slice(0, Math.max(1, max)) : t;
}
/** Strip HTML tags → plain text, decoding the basic entities the doc builders emit. */
export function htmlToText(html: string): string {
return String(html ?? "")
.replace(/<br\s*\/?>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/gi, " ")
.replace(/[^\x20-\x7e]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
/**
* Parse a "summary tiles + one <table> + notice + signature lines" document (the
* marshalling / load-list layout the train-scheduling builders emit) and draw it as a
* styled PDF grid. Used as the Chromium-less fallback so the manifest reads as a real
* document, not a flat text dump. Switches to landscape when the table is wide.
*/
export function buildTabularFallbackPdf(html: string): Buffer {
const pick = (re: RegExp) => html.match(re)?.[1];
const title = htmlToText(pick(/<h1[^>]*>([\s\S]*?)<\/h1>/i) ?? "Document");
const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
const metaRef = htmlToText(pick(/class="meta"[\s\S]*?<strong>([\s\S]*?)<\/strong>/i) ?? "");
const generated = htmlToText(pick(/Generated:\s*([^<]+)/i) ?? "");
const tiles: Array<[string, string]> = [];
for (const m of html.matchAll(
/class="tile"[^>]*>\s*<span>([\s\S]*?)<\/span>\s*<strong>([\s\S]*?)<\/strong>/gi,
)) {
tiles.push([htmlToText(m[1]), htmlToText(m[2])]);
}
const thead = pick(/<thead>([\s\S]*?)<\/thead>/i) ?? "";
const headers = [...thead.matchAll(/<th[^>]*>([\s\S]*?)<\/th>/gi)].map((m) => htmlToText(m[1]));
const tbody = pick(/<tbody>([\s\S]*?)<\/tbody>/i) ?? "";
const rows: string[][] = [...tbody.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)].map((tr) =>
[...tr[1].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map((td) => htmlToText(td[1])),
);
const notice = htmlToText(pick(/class="notice"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
const parsedSigs = [...html.matchAll(/class="line"[^>]*>([\s\S]*?)<\/div>/gi)]
.map((m) => htmlToText(m[1]))
.filter(Boolean);
const signatures = parsedSigs.length ? parsedSigs : ["Prepared / date", "Check / date", "Authorization / date"];
const landscape = headers.length > 7;
const page = landscape ? PageSize.landscape : PageSize.portrait;
const M = 32;
const contentW = page.width - M * 2;
const right = page.width - M;
const ops: string[] = [];
// Header
ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4));
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray));
ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark));
if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray));
if (metaRef) {
ops.push(textOpRight("TRAIN / SCHEDULE", right, page.height - 42, 7.5, "F2", PdfColor.gray));
ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark));
}
if (generated) {
ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray));
}
ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1));
// Summary tiles
let y = page.height - 100;
if (tiles.length) {
const cols = landscape ? 6 : 4;
const tileW = contentW / cols;
const tileH = 32;
tiles.forEach(([label, value], i) => {
const col = i % cols;
if (col === 0 && i > 0) y -= tileH;
const x = M + col * tileW;
ops.push(rectOp(x, y - tileH + 4, tileW - 4, tileH - 4, PdfColor.shade, PdfColor.line, 0.5));
ops.push(textOp(clipText(label.toUpperCase(), Math.floor((tileW - 12) / 3.6)), x + 6, y - 8, 6.5, "F1", PdfColor.gray));
ops.push(textOp(clipText(value, Math.floor((tileW - 12) / 4.4)), x + 6, y - 20, 9, "F2", PdfColor.dark));
});
y -= tileH + 12;
}
// Table
if (headers.length) {
const colW = contentW / headers.length;
const headerH = 16;
const rowH = 14;
const cellChars = Math.max(4, Math.floor(colW / 3.9));
ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
headers.forEach((h, c) =>
ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)),
);
y -= headerH;
let shown = 0;
for (const row of rows) {
if (y < 96) break;
ops.push(rectOp(M, y - rowH, contentW, rowH, "1 1 1", PdfColor.line, 0.4));
headers.forEach((_h, c) => {
if (c > 0) ops.push(lineOp(M + c * colW, y - rowH, M + c * colW, y, PdfColor.line, 0.3));
const cell = row[c] ?? "";
if (cell) ops.push(textOp(clipText(cell, cellChars), M + c * colW + 4, y - 10, 6.8, "F1", PdfColor.dark));
});
y -= rowH;
shown += 1;
}
if (shown < rows.length) {
ops.push(textOp(`... ${rows.length - shown} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
}
}
// Notice (verification clause)
if (notice) {
ops.push(lineOp(M, 78, M, 54, PdfColor.teal, 2));
wrapText(notice, landscape ? 155 : 104)
.slice(0, 2)
.forEach((ln, i) => ops.push(textOp(ln, M + 8, 72 - i * 11, 7.5, "F1", PdfColor.gray)));
}
// Signatures
const sigW = contentW / signatures.length;
signatures.forEach((s, i) => {
const x = M + i * sigW;
ops.push(lineOp(x, 40, x + sigW - 18, 40, PdfColor.dark, 0.7));
ops.push(textOp(clipText(s, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
});
return assembleSinglePagePdf(ops, page);
}
/** Greedy word-wrap to a maximum character width. */
export function wrapText(text: string, maxChars: number): string[] {
const out: string[] = [];
for (const raw of String(text ?? "").split("\n")) {
const words = raw.split(/\s+/).filter(Boolean);
let line = "";
for (const word of words) {
const next = line ? `${line} ${word}` : word;
if (next.length > maxChars && line) {
out.push(line);
line = word;
} else {
line = next;
}
}
if (line) out.push(line);
}
return out.length ? out : [""];
}
/** A4 page sizes in PDF points. */
export const PageSize = {
portrait: { width: 595, height: 842 },
landscape: { width: 842, height: 595 },
} as const;
/** Assemble a single-page PDF from content-stream ops (Helvetica fonts). Defaults to A4 portrait. */
export function assembleSinglePagePdf(
ops: string[],
page: { width: number; height: number } = PageSize.portrait,
): Buffer {
const stream = ops.join("\n");
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${page.width} ${page.height}] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>`,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`,
];
let pdf = "%PDF-1.4\n";
const offsets: number[] = [0];
objects.forEach((object, index) => {
offsets.push(Buffer.byteLength(pdf, "latin1"));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) {
pdf += "% fallback padding\n";
}
const xrefOffset = Buffer.byteLength(pdf, "latin1");
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += "0000000000 65535 f \n";
for (const offset of offsets.slice(1)) {
pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, "latin1");
}

View File

@@ -0,0 +1,53 @@
import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ComplianceService } from './compliance.service';
import {
CreateComplianceRecordDto,
UpdateComplianceRecordDto,
} from './dto/create-compliance-record.dto';
import { ComplianceType } from './entities/compliance-record.entity';
@ApiTags('Vehicle Compliance')
@Controller('compliance')
export class ComplianceController {
constructor(private readonly complianceService: ComplianceService) {}
@Post()
@ApiOperation({ summary: 'Create a compliance record' })
create(@Body() dto: CreateComplianceRecordDto) {
return this.complianceService.create(dto);
}
@Get()
@ApiOperation({ summary: 'List compliance records' })
findAll(
@Query('vehicleId') vehicleId?: string,
@Query('type') type?: ComplianceType,
) {
return this.complianceService.findAll({ vehicleId, type });
}
@Get('alerts')
@ApiOperation({ summary: 'List overdue / due-soon compliance & expiry alerts' })
getAlerts() {
return this.complianceService.getAlerts();
}
@Get(':id')
@ApiOperation({ summary: 'Get a compliance record by ID' })
findOne(@Param('id') id: string) {
return this.complianceService.findById(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a compliance record' })
update(@Param('id') id: string, @Body() dto: UpdateComplianceRecordDto) {
return this.complianceService.update(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Soft-delete a compliance record' })
remove(@Param('id') id: string) {
return this.complianceService.remove(id);
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ComplianceRecord } from './entities/compliance-record.entity';
import { Vehicle } from '../vehicles/entities/vehicle.entity';
import { Driver } from '../drivers/entities/driver.entity';
import { ComplianceService } from './compliance.service';
import { ComplianceRepository } from './compliance.repository';
import { ComplianceController } from './compliance.controller';
@Module({
imports: [TypeOrmModule.forFeature([ComplianceRecord, Vehicle, Driver])],
providers: [ComplianceService, ComplianceRepository],
controllers: [ComplianceController],
exports: [ComplianceService],
})
export class ComplianceModule {}

View File

@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository, FindOptionsWhere } from 'typeorm';
import { ComplianceRecord, ComplianceType } from './entities/compliance-record.entity';
@Injectable()
export class ComplianceRepository extends BaseRepository<ComplianceRecord> {
constructor(
@InjectRepository(ComplianceRecord)
private readonly complianceRepository: Repository<ComplianceRecord>,
) {
super(complianceRepository);
}
async findWithFilters(filter: { vehicleId?: string; type?: ComplianceType } = {}) {
const where: FindOptionsWhere<ComplianceRecord> = {};
if (filter.vehicleId) where.vehicleId = filter.vehicleId;
if (filter.type) where.type = filter.type;
return this.complianceRepository.find({
where,
order: { expiryDate: 'ASC' },
});
}
}

View File

@@ -0,0 +1,184 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Repository } from 'typeorm';
import { ComplianceRepository } from './compliance.repository';
import {
ComplianceRecord,
ComplianceStatus,
ComplianceType,
} from './entities/compliance-record.entity';
import {
CreateComplianceRecordDto,
UpdateComplianceRecordDto,
} from './dto/create-compliance-record.dto';
import { Vehicle } from '../vehicles/entities/vehicle.entity';
import { Driver } from '../drivers/entities/driver.entity';
const DUE_SOON_DAYS = 30;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
export type AlertSeverity = 'OVERDUE' | 'DUE_SOON';
export interface ComplianceAlert {
vehicleId: string;
vehiclePlate?: string;
kind: string;
label: string;
expiryDate: string;
daysUntil: number;
severity: AlertSeverity;
}
@Injectable()
export class ComplianceService {
constructor(
private readonly complianceRepository: ComplianceRepository,
@InjectRepository(Vehicle)
private readonly vehicleRepo: Repository<Vehicle>,
@InjectRepository(Driver)
private readonly driverRepo: Repository<Driver>,
) {}
async create(dto: CreateComplianceRecordDto): Promise<ComplianceRecord> {
return this.complianceRepository.create({
...dto,
status: dto.status ?? this.deriveStatus(dto.expiryDate),
});
}
async findAll(filter: { vehicleId?: string; type?: ComplianceType } = {}) {
return this.complianceRepository.findWithFilters(filter);
}
async findById(id: string): Promise<ComplianceRecord> {
const record = await this.complianceRepository.findById(id);
if (!record) {
throw new NotFoundException(`Compliance record ${id} not found`);
}
return record;
}
async update(id: string, dto: UpdateComplianceRecordDto): Promise<ComplianceRecord> {
await this.findById(id);
const nextExpiry = dto.expiryDate;
const updated = await this.complianceRepository.update(id, {
...dto,
// Re-derive status when expiry changes and the caller didn't set it explicitly.
status: dto.status ?? (nextExpiry ? this.deriveStatus(nextExpiry) : undefined),
});
return updated!;
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.complianceRepository.softDelete(id);
}
/**
* Flat list of compliance items that are overdue or due within 30 days.
* Combines the compliance_records table with the vehicle expiry columns
* (insurance / registration / next inspection) and assigned-driver license
* expiry. `new Date()` is fine here — this is the NestJS API runtime.
*/
async getAlerts(): Promise<ComplianceAlert[]> {
const now = new Date();
const alerts: ComplianceAlert[] = [];
const vehicles = await this.vehicleRepo.find({ where: { deletedAt: IsNull() } });
const vehicleById = new Map(vehicles.map((v) => [v.id, v]));
const plateOf = (v?: Vehicle) => v?.plateNumber ?? v?.code ?? undefined;
// 1. Compliance records
const records = await this.complianceRepository.findWithFilters();
for (const record of records) {
const computed = this.computeSeverity(record.expiryDate, now);
if (!computed) continue;
const vehicle = vehicleById.get(record.vehicleId);
alerts.push({
vehicleId: record.vehicleId,
vehiclePlate: plateOf(vehicle),
kind: record.type,
label: record.documentNumber
? `${record.type} · ${record.documentNumber}`
: record.type,
expiryDate: record.expiryDate,
daysUntil: computed.daysUntil,
severity: computed.severity,
});
}
// 2. Vehicle-level expiry columns
const vehicleFields: { field: keyof Vehicle; kind: string; label: string }[] = [
{ field: 'insuranceExpiry', kind: 'INSURANCE', label: 'Insurance' },
{ field: 'registrationExpiry', kind: 'REGISTRATION', label: 'Registration' },
{ field: 'nextInspectionDate', kind: 'INSPECTION', label: 'Inspection' },
];
for (const vehicle of vehicles) {
for (const { field, kind, label } of vehicleFields) {
const value = vehicle[field] as string | undefined;
if (!value) continue;
const computed = this.computeSeverity(value, now);
if (!computed) continue;
alerts.push({
vehicleId: vehicle.id,
vehiclePlate: plateOf(vehicle),
kind,
label,
expiryDate: value,
daysUntil: computed.daysUntil,
severity: computed.severity,
});
}
}
// 3. Assigned-driver license expiry
const driverIds = [
...new Set(vehicles.map((v) => v.assignedDriverId).filter((id): id is string => !!id)),
];
if (driverIds.length > 0) {
const drivers = await this.driverRepo.find({ where: { id: In(driverIds) } });
const driverById = new Map(drivers.map((d) => [d.id, d]));
for (const vehicle of vehicles) {
if (!vehicle.assignedDriverId) continue;
const driver = driverById.get(vehicle.assignedDriverId);
if (!driver?.licenseExpiryDate) continue;
const expiry =
driver.licenseExpiryDate instanceof Date
? driver.licenseExpiryDate.toISOString().slice(0, 10)
: String(driver.licenseExpiryDate);
const computed = this.computeSeverity(expiry, now);
if (!computed) continue;
alerts.push({
vehicleId: vehicle.id,
vehiclePlate: plateOf(vehicle),
kind: 'DRIVER_LICENSE',
label: `Driver License · ${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
expiryDate: expiry,
daysUntil: computed.daysUntil,
severity: computed.severity,
});
}
}
return alerts.sort((a, b) => a.daysUntil - b.daysUntil);
}
private computeSeverity(
expiryDate: string,
now: Date,
): { daysUntil: number; severity: AlertSeverity } | null {
const daysUntil = Math.ceil((new Date(expiryDate).getTime() - now.getTime()) / MS_PER_DAY);
if (daysUntil < 0) return { daysUntil, severity: 'OVERDUE' };
if (daysUntil <= DUE_SOON_DAYS) return { daysUntil, severity: 'DUE_SOON' };
return null;
}
private deriveStatus(expiryDate: string): ComplianceStatus {
const daysUntil = Math.ceil(
(new Date(expiryDate).getTime() - Date.now()) / MS_PER_DAY,
);
if (daysUntil < 0) return ComplianceStatus.EXPIRED;
if (daysUntil <= DUE_SOON_DAYS) return ComplianceStatus.EXPIRING;
return ComplianceStatus.VALID;
}
}

View File

@@ -0,0 +1,55 @@
import { IsUUID, IsString, IsDateString, IsOptional, IsEnum } from 'class-validator';
import { ComplianceType, ComplianceStatus } from '../entities/compliance-record.entity';
export class CreateComplianceRecordDto {
@IsUUID()
vehicleId!: string;
@IsEnum(ComplianceType)
type!: ComplianceType;
@IsOptional()
@IsString()
documentNumber?: string;
@IsOptional()
@IsDateString()
issuedDate?: string;
@IsDateString()
expiryDate!: string;
@IsOptional()
@IsEnum(ComplianceStatus)
status?: ComplianceStatus;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateComplianceRecordDto {
@IsOptional()
@IsEnum(ComplianceType)
type?: ComplianceType;
@IsOptional()
@IsString()
documentNumber?: string;
@IsOptional()
@IsDateString()
issuedDate?: string;
@IsOptional()
@IsDateString()
expiryDate?: string;
@IsOptional()
@IsEnum(ComplianceStatus)
status?: ComplianceStatus;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,46 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
export enum ComplianceType {
INSPECTION = 'INSPECTION',
INSURANCE = 'INSURANCE',
ROADWORTHINESS = 'ROADWORTHINESS',
PERMIT = 'PERMIT',
TAX = 'TAX',
}
export enum ComplianceStatus {
VALID = 'VALID',
EXPIRING = 'EXPIRING',
EXPIRED = 'EXPIRED',
}
@Entity({ name: 'compliance_records', schema: 'freight' })
@Index(['vehicleId', 'expiryDate'])
export class ComplianceRecord extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { eager: false, nullable: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'type', type: 'varchar' })
type!: ComplianceType;
@Column({ name: 'document_number', type: 'varchar', nullable: true })
documentNumber?: string;
@Column({ name: 'issued_date', type: 'date', nullable: true })
issuedDate?: string;
@Column({ name: 'expiry_date', type: 'date' })
expiryDate!: string;
@Column({ name: 'status', type: 'varchar', default: ComplianceStatus.VALID })
status!: ComplianceStatus;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string;
}

View File

@@ -8,9 +8,13 @@ import {
Body,
Query,
ParseUUIDPipe,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { DriversService } from './drivers.service';
import { CreateDriverDto } from './dto/create-driver.dto';
import { UpdateDriverDto } from './dto/update-driver.dto';
@@ -19,7 +23,7 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service';
@ApiTags('drivers')
@ApiBearerAuth()
@Controller('drivers')
@FleetView()
@BookingStaff(FREIGHT_PERMS.drivers.view)
export class DriversController {
constructor(
private readonly driversService: DriversService,
@@ -27,7 +31,7 @@ export class DriversController {
) {}
@Post()
@FleetManage()
@BookingStaff(FREIGHT_PERMS.drivers.create)
@ApiOperation({ summary: 'Create a new driver' })
create(@Body() createDriverDto: CreateDriverDto) {
return this.driversService.create(createDriverDto);
@@ -65,8 +69,33 @@ export class DriversController {
return this.fleetHistory.getDriverHistory(id);
}
@Post(':id/documents')
@BookingStaff(FREIGHT_PERMS.drivers.update)
@ApiConsumes('multipart/form-data')
@UseInterceptors(AnyFilesInterceptor())
@ApiOperation({ summary: 'Upload driver documents (code driver_docs)' })
uploadDocuments(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.driversService.uploadDocuments(id, files ?? []);
}
@Get(':id/documents')
@ApiOperation({ summary: "List a driver's documents" })
listDocuments(@Param('id', ParseUUIDPipe) id: string) {
return this.driversService.listDocuments(id);
}
@Delete(':id/documents/:fileId')
@BookingStaff(FREIGHT_PERMS.drivers.update)
@ApiOperation({ summary: 'Delete a driver document' })
removeDocument(@Param('fileId', ParseUUIDPipe) fileId: string) {
return this.driversService.removeDocument(fileId);
}
@Patch(':id')
@FleetManage()
@BookingStaff(FREIGHT_PERMS.drivers.update)
@ApiOperation({ summary: 'Update a driver' })
update(
@Param('id', ParseUUIDPipe) id: string,
@@ -76,7 +105,7 @@ export class DriversController {
}
@Delete(':id')
@FleetManage()
@BookingStaff(FREIGHT_PERMS.drivers.delete)
@ApiOperation({ summary: 'Delete a driver' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.driversService.remove(id);

View File

@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Driver } from './entities/driver.entity';
import { DriversService } from './drivers.service';
import { DriversController } from './drivers.controller';
import { FilesModule } from '../files/files.module';
@Module({
imports: [TypeOrmModule.forFeature([Driver])],
imports: [TypeOrmModule.forFeature([Driver]), FilesModule],
providers: [DriversService],
controllers: [DriversController],
exports: [DriversService],

View File

@@ -6,6 +6,11 @@ import { UpdateDriverDto } from './dto/update-driver.dto';
import { Driver, DriverStatus } from './entities/driver.entity';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
import { FleetEventType } from '../fleet-history/entities/fleet-event.entity';
import { FilesService } from '../files/files.service';
/** Resource + code the driver-documents upload area is stored under. */
const DRIVER_DOCS_RESOURCE = 'driver';
const DRIVER_DOCS_CODE = 'driver_docs';
@Injectable()
export class DriversService {
@@ -13,8 +18,37 @@ export class DriversService {
@InjectRepository(Driver)
private readonly driverRepo: Repository<Driver>,
private readonly history: FleetHistoryService,
private readonly filesService: FilesService,
) {}
/** Upload one or more driver documents (code "driver_docs"). */
async uploadDocuments(driverId: string, files: Express.Multer.File[]) {
const driver = await this.driverRepo.findOneBy({ id: driverId });
if (!driver) throw new NotFoundException(`Driver ${driverId} not found`);
if (!files?.length) throw new BadRequestException('No files provided');
return Promise.all(
files.map((file) =>
this.filesService.upload({
resourceId: driverId,
resource: DRIVER_DOCS_RESOURCE,
code: DRIVER_DOCS_CODE,
file,
}),
),
);
}
/** List a driver's uploaded documents (code "driver_docs"). */
async listDocuments(driverId: string) {
const all = await this.filesService.findByResource(driverId, DRIVER_DOCS_RESOURCE);
return all.filter((f) => f.code === DRIVER_DOCS_CODE);
}
/** Delete a single driver document by file id. */
async removeDocument(fileId: string): Promise<void> {
await this.filesService.remove(fileId);
}
async create(dto: CreateDriverDto): Promise<Driver> {
if (dto.faydaVerified !== true) {
throw new BadRequestException(

View File

@@ -119,6 +119,11 @@ export class FilesService {
return record;
}
/** Soft-delete a stored file row by id (object bytes are left in MinIO). */
async remove(id: string): Promise<void> {
await this.filesRepository.softDelete(id);
}
findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
return this.filesRepository.findByResource(resourceId, resource);
}

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
@@ -66,13 +66,37 @@ export class FirstMileInvoiceService {
return null;
}
// Reject mixed-currency truck sets — a single invoice can only be one
// currency, and amounts across currencies can't be summed.
const billableTrucks = (record.vehicleAssignments ?? []).filter(
(a) => Number(a.distanceKm) > 0,
);
const currencies = [
...new Set(
billableTrucks
.map((a) => (a.vehicle as { currency?: string } | undefined)?.currency)
.filter((c): c is string => Boolean(c)),
),
];
if (currencies.length > 1) {
throw new BadRequestException(
`Cannot generate invoice: assigned trucks use mixed currencies (${currencies.join(', ')}). Assign trucks that share one currency.`,
);
}
// Currency follows the truck (price/km is quoted per vehicle), falling back
// to the booking's currency, then ETB.
const truckCurrency =
(record.vehicle as { currency?: string } | undefined)?.currency ||
(record.vehicleAssignments?.[0]?.vehicle as { currency?: string } | undefined)?.currency;
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: fm.booking!.paymentCurrency || 'ETB',
currency: truckCurrency || fm.booking!.paymentCurrency || 'ETB',
lines: [
{
chargeType: 'DELIVERY',

View File

@@ -14,7 +14,8 @@ import {
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
@@ -27,7 +28,7 @@ import { FirstMileInvoiceService } from './first-mile-invoice.service';
@ApiTags('first-mile')
@ApiBearerAuth()
@Controller('first-mile')
@TrainSchedulingView()
@BookingStaff(FREIGHT_PERMS.firstMile.view)
export class FirstMileController {
constructor(
private readonly firstMileService: FirstMileService,
@@ -63,27 +64,28 @@ export class FirstMileController {
}
@Get('acceptitem/:id')
@BookingStaff(FREIGHT_PERMS.firstMile.accept)
@ApiOperation({ summary: 'Get a first-mile accep by ID' })
acceptItem(@Param('id', ParseUUIDPipe) id: string) {
return this.firstMileService.acceptBooking(id);
}
@Post('accept/:reference')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.firstMile.accept)
@ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' })
acceptBooking(@Param('reference') reference: string) {
return this.firstMileService.acceptBookingByReference(reference);
}
@Post()
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.firstMile.create)
@ApiOperation({ summary: 'Create a first-mile leg' })
create(@Body() dto: CreateFirstMileDto) {
return this.firstMileService.create(dto);
}
@Patch(':id')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.firstMile.update)
@ApiOperation({ summary: 'Update a first-mile leg' })
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
// No invoice side-effects — invoices are generated only via the explicit
@@ -92,7 +94,7 @@ export class FirstMileController {
}
@Post(':id/invoice')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.firstMile.generateInvoice)
@ApiOperation({ summary: 'Generate the first-mile delivery-fee invoice' })
async generateInvoice(@Param('id', ParseUUIDPipe) id: string) {
const record = await this.firstMileService.findById(id);
@@ -106,7 +108,7 @@ export class FirstMileController {
}
@Post(':id/vehicles')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.firstMile.assignVehicles)
@ApiOperation({ summary: 'Set the vehicles assigned to a first-mile pickup (multi-truck)' })
async setVehicles(
@Param('id', ParseUUIDPipe) id: string,
@@ -116,7 +118,7 @@ export class FirstMileController {
}
@Post(':id/distances')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.firstMile.setDistances)
@ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' })
async setDistances(
@Param('id', ParseUUIDPipe) id: string,
@@ -126,7 +128,7 @@ export class FirstMileController {
}
@Delete(':id')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.firstMile.delete)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a first-mile leg' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -640,10 +640,24 @@ export class FirstMileService {
{ distanceKm: d.distanceKm },
);
}
const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0);
// Billing is per truck: amount = Σ (truck distance × truck price/km). The
// per-vehicle rate + currency live on the vehicle, so we ignore the legacy
// FIRST_MILE flat rate and any client-sent amount. `remainingPayment` param
// kept only for signature back-compat.
void remainingPayment;
const assignments = await this.dataSource.manager.find(FirstMileVehicleAssignment, {
where: { firstMileId: id },
relations: { vehicle: true },
});
const total = assignments.reduce((s, a) => s + (Number(a.distanceKm) || 0), 0);
const amount = assignments.reduce(
(s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0),
0,
);
await this.firstMileRepository.update(id, {
exactKm: total,
...(remainingPayment != null ? { remainingPayment } : {}),
remainingPayment: amount,
} as any);
return this.findById(id);
}

View File

@@ -1,26 +1,40 @@
import { Controller, Post, Get, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ApiBearerAuth, ApiTags, ApiOperation } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { FuelService } from './fuel.service';
import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto';
// Stats feed the Financial Reports + Fleet Dashboard pages, so their viewers may
// read them without full fuel access.
const FUEL_STATS_PERMS = [
FREIGHT_PERMS.fuel.view,
FREIGHT_PERMS.fleetReports.view,
FREIGHT_PERMS.fleetDashboard.view,
];
@ApiTags('Fuel Management')
@ApiBearerAuth()
@Controller('fuel')
export class FuelController {
constructor(private readonly fuelService: FuelService) {}
@Post('purchases')
@BookingStaff(FREIGHT_PERMS.fuel.create)
@ApiOperation({ summary: 'Record fuel purchase' })
async recordFuelPurchase(@Body() dto: CreateFuelPurchaseDto) {
return this.fuelService.recordFuelPurchase(dto);
}
@Get('purchases')
@BookingStaff(FREIGHT_PERMS.fuel.view)
@ApiOperation({ summary: 'Get all fuel purchases' })
async getAllFuelPurchases() {
return this.fuelService.getAllFuelPurchases();
}
@Get('purchases/:vehicleId')
@BookingStaff(FREIGHT_PERMS.fuel.view)
@ApiOperation({ summary: 'Get fuel purchases for vehicle' })
async getFuelPurchases(
@Param('vehicleId') vehicleId: string,
@@ -35,6 +49,7 @@ export class FuelController {
}
@Get('consumption/:vehicleId/:month')
@BookingStaff(FREIGHT_PERMS.fuel.view)
@ApiOperation({ summary: 'Get monthly fuel consumption' })
async getMonthlyConsumption(
@Param('vehicleId') vehicleId: string,
@@ -44,12 +59,14 @@ export class FuelController {
}
@Get('stats')
@BookingStaff(FUEL_STATS_PERMS)
@ApiOperation({ summary: 'Get fleet-wide fuel statistics' })
async getFleetFuelStats(@Query('months') months: number = 12) {
return this.fuelService.getFleetFuelStats(months);
}
@Get('stats/:vehicleId')
@BookingStaff(FUEL_STATS_PERMS)
@ApiOperation({ summary: 'Get fuel statistics for vehicle' })
async getVehicleFuelStats(
@Param('vehicleId') vehicleId: string,

View File

@@ -0,0 +1,24 @@
import { IsOptional, IsString, IsUUID } from 'class-validator';
export class RegisterDeviceDto {
@IsString()
imei!: string;
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsUUID()
vehicleId?: string;
}
export class UpdateDeviceDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsUUID()
vehicleId?: string | null;
}

View File

@@ -0,0 +1,55 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
/**
* A physical GPS tracker (GT06). Identified by IMEI, optionally bound to a
* vehicle. Carries the denormalized latest fix so the live map reads one row
* per device without scanning position history.
*/
@Entity({ name: 'gps_devices', schema: 'freight' })
@Index(['vehicleId'])
export class GpsDevice extends BaseEntity {
@Column({ name: 'imei', type: 'varchar', length: 20, unique: true })
imei!: string;
@Column({ name: 'name', type: 'varchar', nullable: true })
name?: string | null;
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string | null;
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
/** ONLINE once a packet arrives; OFFLINE when stale (derived on read). */
@Column({ name: 'status', type: 'varchar', length: 16, default: 'REGISTERED' })
status!: string;
@Column({ name: 'last_seen_at', type: 'timestamptz', nullable: true })
lastSeenAt?: Date | null;
// ── Denormalized latest fix ──
@Column({ name: 'last_lat', type: 'numeric', precision: 10, scale: 6, nullable: true })
lastLat?: number | null;
@Column({ name: 'last_lng', type: 'numeric', precision: 10, scale: 6, nullable: true })
lastLng?: number | null;
@Column({ name: 'last_speed', type: 'numeric', precision: 6, scale: 2, nullable: true })
lastSpeed?: number | null;
@Column({ name: 'last_course', type: 'int', nullable: true })
lastCourse?: number | null;
@Column({ name: 'last_fix_at', type: 'timestamptz', nullable: true })
lastFixAt?: Date | null;
@Column({ name: 'voltage_level', type: 'int', nullable: true })
voltageLevel?: number | null;
@Column({ name: 'gsm_level', type: 'int', nullable: true })
gsmLevel?: number | null;
}

View File

@@ -0,0 +1,43 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
/** One GPS fix from a tracker (append-only history). */
@Entity({ name: 'gps_positions', schema: 'freight' })
@Index(['deviceId', 'gpsTime'])
@Index(['vehicleId', 'gpsTime'])
export class GpsPosition extends BaseEntity {
@Column({ name: 'device_id', type: 'uuid' })
deviceId!: string;
@Column({ name: 'imei', type: 'varchar', length: 20 })
imei!: string;
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string | null;
@Column({ name: 'lat', type: 'numeric', precision: 10, scale: 6 })
lat!: number;
@Column({ name: 'lng', type: 'numeric', precision: 10, scale: 6 })
lng!: number;
@Column({ name: 'speed', type: 'numeric', precision: 6, scale: 2, default: 0 })
speed!: number;
@Column({ name: 'course', type: 'int', default: 0 })
course!: number;
@Column({ name: 'satellites', type: 'int', default: 0 })
satellites!: number;
@Column({ name: 'positioned', type: 'boolean', default: false })
positioned!: boolean;
/** Fix time reported by the device (UTC). */
@Column({ name: 'gps_time', type: 'timestamptz' })
gpsTime!: Date;
/** Non-zero when the fix came in via an alarm packet. */
@Column({ name: 'alarm', type: 'int', default: 0 })
alarm!: number;
}

View File

@@ -0,0 +1,66 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { GpsTrackingService } from './gps-tracking.service';
import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto';
@ApiTags('gps-tracking')
@ApiBearerAuth()
@Controller('gps')
@FleetView()
export class GpsTrackingController {
constructor(private readonly gps: GpsTrackingService) {}
@Get('positions/latest')
@ApiOperation({ summary: 'Latest fix per device (live map feed)' })
latest() {
return this.gps.latest();
}
@Get('positions/:vehicleId/history')
@ApiOperation({ summary: 'Position history for a vehicle' })
history(
@Param('vehicleId', ParseUUIDPipe) vehicleId: string,
@Query('limit') limit?: string,
) {
return this.gps.history(vehicleId, limit ? parseInt(limit, 10) : undefined);
}
@Get('devices')
@ApiOperation({ summary: 'List GPS trackers' })
listDevices() {
return this.gps.listDevices();
}
@Post('devices')
@FleetManage()
@ApiOperation({ summary: 'Register a GPS tracker' })
register(@Body() dto: RegisterDeviceDto) {
return this.gps.registerDevice(dto);
}
@Patch('devices/:id')
@FleetManage()
@ApiOperation({ summary: 'Update a GPS tracker (name / assigned vehicle)' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDeviceDto) {
return this.gps.updateDevice(id, dto);
}
@Delete('devices/:id')
@FleetManage()
@ApiOperation({ summary: 'Delete a GPS tracker' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.gps.removeDevice(id);
}
}

View File

@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { GpsDevice } from './entities/gps-device.entity';
import { GpsPosition } from './entities/gps-position.entity';
import { GpsDeviceRepository, GpsPositionRepository } from './gps-tracking.repository';
import { GpsTrackingService } from './gps-tracking.service';
import { GpsTrackingController } from './gps-tracking.controller';
import { Gt06Server } from './gt06/gt06.server';
@Module({
imports: [TypeOrmModule.forFeature([GpsDevice, GpsPosition])],
controllers: [GpsTrackingController],
providers: [GpsDeviceRepository, GpsPositionRepository, GpsTrackingService, Gt06Server],
exports: [GpsTrackingService],
})
export class GpsTrackingModule {}

View File

@@ -0,0 +1,29 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { GpsDevice } from './entities/gps-device.entity';
import { GpsPosition } from './entities/gps-position.entity';
@Injectable()
export class GpsDeviceRepository extends BaseRepository<GpsDevice> {
constructor(
@InjectRepository(GpsDevice) repository: Repository<GpsDevice>,
) {
super(repository);
}
findByImei(imei: string): Promise<GpsDevice | null> {
return this.repository.findOne({ where: { imei } });
}
}
@Injectable()
export class GpsPositionRepository extends BaseRepository<GpsPosition> {
constructor(
@InjectRepository(GpsPosition) repository: Repository<GpsPosition>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,124 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { GpsDeviceRepository, GpsPositionRepository } from './gps-tracking.repository';
import { GpsDevice } from './entities/gps-device.entity';
import { Gt06Gps, Gt06Status } from './gt06/gt06.codec';
/** A device is considered ONLINE if seen within this window. */
const ONLINE_WINDOW_MS = 5 * 60 * 1000;
@Injectable()
export class GpsTrackingService {
private readonly logger = new Logger(GpsTrackingService.name);
constructor(
private readonly devices: GpsDeviceRepository,
private readonly positions: GpsPositionRepository,
) {}
private isOnline(d: GpsDevice): boolean {
return Boolean(d.lastSeenAt && Date.now() - new Date(d.lastSeenAt).getTime() < ONLINE_WINDOW_MS);
}
/** Find the device for an IMEI, auto-registering it on first contact. */
private async ensureDevice(imei: string): Promise<GpsDevice> {
const existing = await this.devices.findByImei(imei);
if (existing) return existing;
this.logger.log(`Auto-registering new GPS tracker ${imei}`);
return this.devices.create({ imei, status: 'REGISTERED', lastSeenAt: new Date() });
}
// ── Ingestion (called by the TCP server) ──
async handleLogin(imei: string): Promise<void> {
const device = await this.ensureDevice(imei);
await this.devices.update(device.id, { lastSeenAt: new Date(), status: 'ONLINE' });
}
async handleHeartbeat(imei: string, status: Gt06Status): Promise<void> {
const device = await this.ensureDevice(imei);
await this.devices.update(device.id, {
lastSeenAt: new Date(),
status: 'ONLINE',
voltageLevel: status.voltageLevel,
gsmLevel: status.gsmLevel,
});
}
async handleFix(imei: string, gps: Gt06Gps, alarm = 0, status?: Gt06Status): Promise<void> {
const device = await this.ensureDevice(imei);
const now = new Date();
await this.devices.update(device.id, {
lastSeenAt: now,
status: 'ONLINE',
lastLat: gps.latitude,
lastLng: gps.longitude,
lastSpeed: gps.speed,
lastCourse: gps.course,
lastFixAt: new Date(gps.time),
...(status ? { voltageLevel: status.voltageLevel, gsmLevel: status.gsmLevel } : {}),
});
await this.positions.create({
deviceId: device.id,
imei,
vehicleId: device.vehicleId ?? null,
lat: gps.latitude,
lng: gps.longitude,
speed: gps.speed,
course: gps.course,
satellites: gps.satellites,
positioned: gps.positioned,
gpsTime: new Date(gps.time),
alarm,
});
}
// ── Queries / management (REST) ──
private decorate(d: GpsDevice) {
return { ...d, online: this.isOnline(d) };
}
async listDevices() {
const rows = await this.devices.findAll({ relations: { vehicle: true }, order: { createdAt: 'DESC' } });
return rows.map((d) => this.decorate(d));
}
/** Live map feed — devices that have at least one fix. */
async latest() {
const rows = await this.devices.findAll({ relations: { vehicle: true } });
return rows.filter((d) => d.lastLat != null && d.lastLng != null).map((d) => this.decorate(d));
}
async history(vehicleId: string, limit = 200) {
return this.positions.findAll({
where: { vehicleId },
order: { gpsTime: 'DESC' },
take: Math.min(limit, 1000),
});
}
async registerDevice(dto: { imei: string; name?: string; vehicleId?: string | null }) {
const existing = await this.devices.findByImei(dto.imei);
if (existing) throw new BadRequestException(`A device with IMEI ${dto.imei} already exists`);
return this.devices.create({
imei: dto.imei,
name: dto.name ?? null,
vehicleId: dto.vehicleId ?? null,
status: 'REGISTERED',
});
}
async updateDevice(id: string, dto: { name?: string; vehicleId?: string | null }) {
const updated = await this.devices.update(id, {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
});
if (!updated) throw new NotFoundException(`GPS device ${id} not found`);
return updated;
}
async removeDevice(id: string): Promise<void> {
await this.devices.softDelete(id);
}
}

View File

@@ -0,0 +1,207 @@
/**
* GT06 GPS-tracker protocol codec.
*
* Frame: 0x78 0x78 | len(1) | protocol(1) | content(N) | serial(2) | crc(2) | 0x0D 0x0A
* `len` counts protocol..crc (= 5 + N). CRC-ITU (CRC-16/X.25) is computed over
* len..serial (inclusive) and equals the 2 crc bytes.
*/
const START = 0x7878;
const STOP = 0x0d0a;
export const GT06_PROTOCOL = {
LOGIN: 0x01,
LOCATION: 0x12,
HEARTBEAT: 0x13,
STRING: 0x15,
ALARM: 0x16,
ADDRESS_BY_PHONE: 0x1a,
SERVER_COMMAND: 0x80,
} as const;
/** CRC-16/X.25 (a.k.a. CRC-ITU) used by GT06 — reflected, poly 0x8408, init/xorout 0xFFFF. */
export function crcItu(bytes: Buffer): number {
let fcs = 0xffff;
for (const b of bytes) {
fcs ^= b;
for (let i = 0; i < 8; i++) {
fcs = fcs & 1 ? (fcs >> 1) ^ 0x8408 : fcs >> 1;
}
}
return (~fcs) & 0xffff;
}
export interface Gt06Gps {
time: string; // ISO (UTC)
satellites: number;
latitude: number;
longitude: number;
speed: number; // km/h
course: number; // 0-360
positioned: boolean;
}
export interface Gt06Lbs {
mcc: number;
mnc: number;
lac: number;
cellId: number;
}
export interface Gt06Status {
terminalInfo: number;
voltageLevel: number;
gsmLevel: number;
alarm: number; // former byte of alarm/language
charging: boolean;
accOn: boolean;
gpsTracking: boolean;
oilCut: boolean;
}
export type Gt06Packet =
| { type: 'login'; protocol: number; serial: number; imei: string }
| { type: 'location'; protocol: number; serial: number; gps: Gt06Gps; lbs: Gt06Lbs }
| { type: 'heartbeat'; protocol: number; serial: number; status: Gt06Status }
| { type: 'alarm'; protocol: number; serial: number; gps: Gt06Gps; lbs: Gt06Lbs; status: Gt06Status }
| { type: 'unknown'; protocol: number; serial: number };
/** Terminal ID (8 BCD bytes) → 15-digit IMEI (drops the leading pad nibble). */
function decodeImei(buf: Buffer): string {
return buf.toString('hex').replace(/^0/, '');
}
function decodeDateTime(buf: Buffer, off: number): string {
const year = 2000 + buf[off];
const month = buf[off + 1];
const day = buf[off + 2];
const hour = buf[off + 3];
const min = buf[off + 4];
const sec = buf[off + 5];
return new Date(Date.UTC(year, month - 1, day, hour, min, sec)).toISOString();
}
/** Convert a GT06 lat/long raw uint32 to decimal degrees (magnitude only). */
function rawToDegrees(raw: number): number {
return raw / 30000 / 60;
}
function decodeGps(buf: Buffer, off: number): Gt06Gps {
const time = decodeDateTime(buf, off);
const lenSat = buf[off + 6];
const satellites = lenSat & 0x0f;
const latRaw = buf.readUInt32BE(off + 7);
const lonRaw = buf.readUInt32BE(off + 11);
const speed = buf[off + 15];
const cs = buf.readUInt16BE(off + 16);
const hi = (cs >> 8) & 0xff;
const positioned = Boolean(hi & 0x10); // BYTE_1 Bit4
const isWest = Boolean(hi & 0x08); // BYTE_1 Bit3 (1 = West)
const isNorth = Boolean(hi & 0x04); // BYTE_1 Bit2 (1 = North)
const course = cs & 0x03ff; // BYTE_1 Bit1-0 + BYTE_2
let latitude = rawToDegrees(latRaw);
let longitude = rawToDegrees(lonRaw);
if (!isNorth) latitude = -latitude;
if (isWest) longitude = -longitude;
return { time, satellites, latitude, longitude, speed, course, positioned };
}
function decodeStatus(buf: Buffer, off: number): Gt06Status {
const terminalInfo = buf[off];
const voltageLevel = buf[off + 1];
const gsmLevel = buf[off + 2];
const alarm = buf[off + 3]; // alarm/language former byte
return {
terminalInfo,
voltageLevel,
gsmLevel,
alarm,
oilCut: Boolean(terminalInfo & 0x80),
gpsTracking: Boolean(terminalInfo & 0x40),
charging: Boolean(terminalInfo & 0x04),
accOn: Boolean(terminalInfo & 0x02),
};
}
function decodeLbs(buf: Buffer, off: number): Gt06Lbs {
return {
mcc: buf.readUInt16BE(off),
mnc: buf[off + 2],
lac: buf.readUInt16BE(off + 3),
cellId: buf.readUIntBE(off + 5, 3),
};
}
function decodeFrame(frame: Buffer): Gt06Packet | null {
// frame = 78 78 len ...content... serial(2) crc(2) 0D 0A
const len = frame[2];
const protocol = frame[3];
const serialOff = 3 + (len - 4); // after protocol + content, before serial(2)+crc(2)
const serial = frame.readUInt16BE(serialOff);
const contentOff = 4; // start of content (after protocol)
switch (protocol) {
case GT06_PROTOCOL.LOGIN:
return { type: 'login', protocol, serial, imei: decodeImei(frame.subarray(contentOff, contentOff + 8)) };
case GT06_PROTOCOL.LOCATION:
return { type: 'location', protocol, serial, gps: decodeGps(frame, contentOff), lbs: decodeLbs(frame, contentOff + 18) };
case GT06_PROTOCOL.HEARTBEAT:
return { type: 'heartbeat', protocol, serial, status: decodeStatus(frame, contentOff) };
case GT06_PROTOCOL.ALARM: {
const gps = decodeGps(frame, contentOff);
// content: date(6)+lenSat(1)+lat(4)+lng(4)+speed(1)+course(2)=18, lbsLen(1), lbs(8), status(1+1+1+2)
const lbs = decodeLbs(frame, contentOff + 18 + 1);
const status = decodeStatus(frame, contentOff + 18 + 1 + 8);
return { type: 'alarm', protocol, serial, gps, lbs, status };
}
default:
return { type: 'unknown', protocol, serial };
}
}
/**
* Pull all complete frames out of a stream buffer. Returns the decoded packets
* (skipping CRC-failed ones) and the trailing bytes that form a partial frame.
*/
export function parseStream(buffer: Buffer): { packets: Gt06Packet[]; rest: Buffer } {
const packets: Gt06Packet[] = [];
let i = 0;
while (i + 5 <= buffer.length) {
if (buffer.readUInt16BE(i) !== START) {
i += 1; // resync
continue;
}
const len = buffer[i + 2];
const frameLen = 2 + 1 + len + 2; // start + lenByte + (protocol..crc) + stop
if (i + frameLen > buffer.length) break; // incomplete
const frame = buffer.subarray(i, i + frameLen);
if (frame.readUInt16BE(frameLen - 2) === STOP) {
// CRC over len..serial (frame[2 .. frameLen-4]); crc bytes are frameLen-4..frameLen-3.
const crcCalc = crcItu(frame.subarray(2, frameLen - 4));
const crcRecv = frame.readUInt16BE(frameLen - 4);
if (crcCalc === crcRecv) {
const pkt = decodeFrame(frame);
if (pkt) packets.push(pkt);
}
i += frameLen;
} else {
i += 1; // bad frame, resync
}
}
return { packets, rest: buffer.subarray(i) };
}
/** Build a server → terminal ACK (login/heartbeat/alarm) echoing the serial. */
export function buildAck(protocol: number, serial: number): Buffer {
const body = Buffer.alloc(3); // protocol + serial(2)
body[0] = protocol;
body.writeUInt16BE(serial, 1);
const len = body.length + 2; // + crc(2)
const forCrc = Buffer.concat([Buffer.from([len]), body]);
const crc = crcItu(forCrc);
return Buffer.concat([
Buffer.from([0x78, 0x78, len]),
body,
Buffer.from([(crc >> 8) & 0xff, crc & 0xff, 0x0d, 0x0a]),
]);
}

View File

@@ -0,0 +1,97 @@
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
import * as net from 'net';
import { GpsTrackingService } from '../gps-tracking.service';
import { buildAck, GT06_PROTOCOL, parseStream } from './gt06.codec';
interface Session {
buffer: Buffer;
imei: string | null;
}
const MAX_BUFFER = 64 * 1024;
/**
* Raw TCP listener for GT06 GPS trackers. Trackers open a socket, send a login
* (IMEI), then stream location/heartbeat/alarm packets; we decode, persist via
* {@link GpsTrackingService}, and ACK login/heartbeat/alarm so the device keeps
* the connection alive. Disabled when GT06_TCP_PORT=0.
*/
@Injectable()
export class Gt06Server implements OnApplicationBootstrap, OnModuleDestroy {
private readonly logger = new Logger(Gt06Server.name);
private server?: net.Server;
private readonly sessions = new Map<net.Socket, Session>();
constructor(private readonly gps: GpsTrackingService) {}
onApplicationBootstrap(): void {
const port = Number(process.env.GT06_TCP_PORT ?? 5023);
if (!port) {
this.logger.log('GT06 TCP listener disabled (GT06_TCP_PORT=0)');
return;
}
const host = process.env.GT06_TCP_HOST ?? '0.0.0.0';
this.server = net.createServer((socket) => this.onConnection(socket));
this.server.on('error', (err) => this.logger.error(`GT06 server error: ${String(err)}`));
this.server.listen(port, host, () => this.logger.log(`GT06 GPS tracker listener on ${host}:${port}`));
}
onModuleDestroy(): void {
for (const socket of this.sessions.keys()) socket.destroy();
this.sessions.clear();
this.server?.close();
}
private onConnection(socket: net.Socket): void {
this.sessions.set(socket, { buffer: Buffer.alloc(0), imei: null });
socket.on('data', (chunk) => void this.onData(socket, chunk));
socket.on('error', () => this.sessions.delete(socket));
socket.on('close', () => this.sessions.delete(socket));
}
private async onData(socket: net.Socket, chunk: Buffer): Promise<void> {
const session = this.sessions.get(socket);
if (!session) return;
session.buffer = Buffer.concat([session.buffer, chunk]);
if (session.buffer.length > MAX_BUFFER) session.buffer = Buffer.alloc(0); // drop garbage
const { packets, rest } = parseStream(session.buffer);
session.buffer = rest;
for (const pkt of packets) {
try {
await this.handle(socket, session, pkt);
} catch (err) {
this.logger.error(`Failed to handle GT06 packet (${pkt.type}): ${String(err)}`);
}
}
}
private async handle(
socket: net.Socket,
session: Session,
pkt: ReturnType<typeof parseStream>['packets'][number],
): Promise<void> {
switch (pkt.type) {
case 'login':
session.imei = pkt.imei;
await this.gps.handleLogin(pkt.imei);
socket.write(buildAck(GT06_PROTOCOL.LOGIN, pkt.serial));
break;
case 'heartbeat':
if (session.imei) await this.gps.handleHeartbeat(session.imei, pkt.status);
socket.write(buildAck(GT06_PROTOCOL.HEARTBEAT, pkt.serial));
break;
case 'location':
if (session.imei) await this.gps.handleFix(session.imei, pkt.gps);
break;
case 'alarm':
if (session.imei) await this.gps.handleFix(session.imei, pkt.gps, pkt.status.alarm, pkt.status);
socket.write(buildAck(GT06_PROTOCOL.ALARM, pkt.serial));
break;
default:
break;
}
}
}

View File

@@ -0,0 +1,48 @@
import { IsUUID, IsString, IsDateString, IsNumber, IsOptional, IsEnum } from 'class-validator';
import { IncidentType, IncidentSeverity, IncidentStatus } from '../entities/incident.entity';
export class CreateIncidentDto {
@IsOptional()
@IsUUID()
vehicleId?: string;
@IsOptional()
@IsUUID()
driverId?: string;
@IsOptional()
@IsUUID()
bookingId?: string;
@IsEnum(IncidentType)
type!: IncidentType;
@IsEnum(IncidentSeverity)
severity!: IncidentSeverity;
@IsDateString()
occurredAt!: string;
@IsOptional()
@IsString()
location?: string;
@IsString()
description!: string;
@IsOptional()
@IsNumber()
damageEstimate?: number;
@IsOptional()
@IsEnum(IncidentStatus)
status?: IncidentStatus;
@IsOptional()
@IsString()
insuranceClaimNumber?: string;
@IsOptional()
@IsString()
reportedBy?: string;
}

View File

@@ -0,0 +1,52 @@
import { IsUUID, IsString, IsDateString, IsNumber, IsOptional, IsEnum } from 'class-validator';
import { IncidentType, IncidentSeverity, IncidentStatus } from '../entities/incident.entity';
export class UpdateIncidentDto {
@IsOptional()
@IsUUID()
vehicleId?: string;
@IsOptional()
@IsUUID()
driverId?: string;
@IsOptional()
@IsUUID()
bookingId?: string;
@IsOptional()
@IsEnum(IncidentType)
type?: IncidentType;
@IsOptional()
@IsEnum(IncidentSeverity)
severity?: IncidentSeverity;
@IsOptional()
@IsDateString()
occurredAt?: string;
@IsOptional()
@IsString()
location?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsNumber()
damageEstimate?: number;
@IsOptional()
@IsEnum(IncidentStatus)
status?: IncidentStatus;
@IsOptional()
@IsString()
insuranceClaimNumber?: string;
@IsOptional()
@IsString()
reportedBy?: string;
}

View File

@@ -0,0 +1,76 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { Driver } from '../../drivers/entities/driver.entity';
export enum IncidentType {
ACCIDENT = 'ACCIDENT',
BREAKDOWN = 'BREAKDOWN',
TRAFFIC_VIOLATION = 'TRAFFIC_VIOLATION',
THEFT = 'THEFT',
OTHER = 'OTHER',
}
export enum IncidentSeverity {
MINOR = 'MINOR',
MODERATE = 'MODERATE',
MAJOR = 'MAJOR',
CRITICAL = 'CRITICAL',
}
export enum IncidentStatus {
REPORTED = 'REPORTED',
UNDER_REVIEW = 'UNDER_REVIEW',
CLAIM_FILED = 'CLAIM_FILED',
RESOLVED = 'RESOLVED',
CLOSED = 'CLOSED',
}
@Entity({ name: 'incidents', schema: 'freight' })
@Index(['driverId', 'occurredAt'])
@Index(['vehicleId', 'occurredAt'])
export class Incident extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string;
@ManyToOne(() => Vehicle, { eager: false, nullable: true })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle;
@Column({ name: 'driver_id', type: 'uuid', nullable: true })
driverId?: string;
@ManyToOne(() => Driver, { eager: false, nullable: true })
@JoinColumn({ name: 'driver_id' })
driver?: Driver;
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string;
@Column({ name: 'type', type: 'varchar' })
type!: IncidentType;
@Column({ name: 'severity', type: 'varchar' })
severity!: IncidentSeverity;
@Column({ name: 'occurred_at', type: 'timestamptz' })
occurredAt!: Date;
@Column({ name: 'location', type: 'varchar', nullable: true })
location?: string;
@Column({ name: 'description', type: 'text' })
description!: string;
@Column({ name: 'damage_estimate', type: 'numeric', precision: 14, scale: 2, nullable: true })
damageEstimate?: number;
@Column({ name: 'status', type: 'varchar', default: IncidentStatus.REPORTED })
status!: IncidentStatus;
@Column({ name: 'insurance_claim_number', type: 'varchar', nullable: true })
insuranceClaimNumber?: string;
@Column({ name: 'reported_by', type: 'varchar', nullable: true })
reportedBy?: string;
}

View File

@@ -0,0 +1,69 @@
import {
Controller,
Post,
Get,
Patch,
Delete,
Body,
Param,
Query,
} from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { IncidentsService } from './incidents.service';
import { CreateIncidentDto } from './dto/create-incident.dto';
import { UpdateIncidentDto } from './dto/update-incident.dto';
import { IncidentStatus, IncidentType } from './entities/incident.entity';
@ApiTags('Accident & Incident Management')
@Controller('incidents')
export class IncidentsController {
constructor(private readonly incidentsService: IncidentsService) {}
@Post()
@ApiOperation({ summary: 'Report an incident' })
async create(@Body() dto: CreateIncidentDto) {
return this.incidentsService.create(dto);
}
@Get()
@ApiOperation({ summary: 'List incidents (optionally filtered)' })
async findAll(
@Query('vehicleId') vehicleId?: string,
@Query('driverId') driverId?: string,
@Query('status') status?: IncidentStatus,
@Query('type') type?: IncidentType,
) {
return this.incidentsService.findAll({ vehicleId, driverId, status, type });
}
@Get('driver/:driverId/stats')
@ApiOperation({ summary: 'Get incident statistics for a driver' })
async statsForDriver(@Param('driverId') driverId: string) {
return this.incidentsService.statsForDriver(driverId);
}
@Get('driver/:driverId')
@ApiOperation({ summary: 'List incidents for a driver (incident history)' })
async findByDriver(@Param('driverId') driverId: string) {
return this.incidentsService.findByDriver(driverId);
}
@Get(':id')
@ApiOperation({ summary: 'Get an incident by id' })
async findById(@Param('id') id: string) {
return this.incidentsService.findById(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update an incident' })
async update(@Param('id') id: string, @Body() dto: UpdateIncidentDto) {
return this.incidentsService.update(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete an incident' })
async remove(@Param('id') id: string) {
await this.incidentsService.remove(id);
return { success: true };
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Incident } from './entities/incident.entity';
import { IncidentsService } from './incidents.service';
import { IncidentsRepository } from './incidents.repository';
import { IncidentsController } from './incidents.controller';
@Module({
imports: [TypeOrmModule.forFeature([Incident])],
providers: [IncidentsService, IncidentsRepository],
controllers: [IncidentsController],
exports: [IncidentsService],
})
export class IncidentsModule {}

View File

@@ -0,0 +1,15 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository } from 'typeorm';
import { Incident } from './entities/incident.entity';
@Injectable()
export class IncidentsRepository extends BaseRepository<Incident> {
constructor(
@InjectRepository(Incident)
incidentRepository: Repository<Incident>,
) {
super(incidentRepository);
}
}

View File

@@ -0,0 +1,95 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { IncidentsRepository } from './incidents.repository';
import {
Incident,
IncidentStatus,
IncidentType,
} from './entities/incident.entity';
import { CreateIncidentDto } from './dto/create-incident.dto';
import { UpdateIncidentDto } from './dto/update-incident.dto';
export interface IncidentFilter {
vehicleId?: string;
driverId?: string;
status?: IncidentStatus;
type?: IncidentType;
}
export interface DriverIncidentStats {
total: number;
byType: Record<string, number>;
lastIncidentAt: Date | null;
}
@Injectable()
export class IncidentsService {
constructor(private readonly incidentsRepository: IncidentsRepository) {}
async create(dto: CreateIncidentDto): Promise<Incident> {
return this.incidentsRepository.create({
...dto,
occurredAt: new Date(dto.occurredAt),
});
}
async findAll(filter: IncidentFilter = {}): Promise<Incident[]> {
const where: FindOptionsWhere<Incident> = {};
if (filter.vehicleId) where.vehicleId = filter.vehicleId;
if (filter.driverId) where.driverId = filter.driverId;
if (filter.status) where.status = filter.status;
if (filter.type) where.type = filter.type;
return this.incidentsRepository.findAll({
where,
order: { occurredAt: 'DESC' },
});
}
async findByDriver(driverId: string): Promise<Incident[]> {
return this.incidentsRepository.findAll({
where: { driverId },
order: { occurredAt: 'DESC' },
});
}
async findById(id: string): Promise<Incident> {
const incident = await this.incidentsRepository.findById(id);
if (!incident) {
throw new NotFoundException(`Incident ${id} not found`);
}
return incident;
}
async update(id: string, dto: UpdateIncidentDto): Promise<Incident> {
await this.findById(id);
const updated = await this.incidentsRepository.update(id, {
...dto,
occurredAt: dto.occurredAt ? new Date(dto.occurredAt) : undefined,
});
return updated!;
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.incidentsRepository.softDelete(id);
}
async statsForDriver(driverId: string): Promise<DriverIncidentStats> {
const incidents = await this.incidentsRepository.findAll({
where: { driverId },
order: { occurredAt: 'DESC' },
});
const byType: Record<string, number> = {};
for (const incident of incidents) {
byType[incident.type] = (byType[incident.type] || 0) + 1;
}
return {
total: incidents.length,
byType,
lastIncidentAt: incidents.length > 0 ? incidents[0].occurredAt : null,
};
}
}

View File

@@ -1,5 +1,18 @@
import { PartialType } from '@nestjs/mapped-types';
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsISO8601, IsOptional } from 'class-validator';
import { CreateLastMileDto } from './create-last-mile.dto';
export class UpdateLastMileDto extends PartialType(CreateLastMileDto) {}
export class UpdateLastMileDto extends PartialType(CreateLastMileDto) {
/** Truck-detention clock start (vehicle arrived at destination). Overrides the auto-stamp. */
@ApiPropertyOptional({ description: 'Vehicle arrival time (ISO 8601) — detention clock start.' })
@IsOptional()
@IsISO8601()
arrivedAt?: string;
/** Truck-detention clock end (cargo cleared / vehicle returned). Overrides the auto-stamp. */
@ApiPropertyOptional({ description: 'Delivery/return time (ISO 8601) — detention clock end.' })
@IsOptional()
@IsISO8601()
deliveredAt?: string;
}

View File

@@ -30,6 +30,15 @@ export class LastMile extends BaseEntity {
@Column({ name: 'status', type: 'varchar', length: 30, default: 'PAYMENT_PENDING' })
status!: LastMileStatus;
// Truck-detention window. arrivedAt = vehicle reached destination (IN_TRANSIT);
// deliveredAt = cargo cleared / vehicle returned (DELIVERED). Detention accrues
// between them beyond the rule's grace hours (default 3h), per truck per day.
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
arrivedAt?: Date | null;
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
deliveredAt?: Date | null;
@Column({ name: 'advanced_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
advancedPayment!: number;

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
@@ -63,6 +63,30 @@ export class LastMileInvoiceService {
return null;
}
// Reject mixed-currency truck sets — a single invoice can only be one
// currency, and amounts across currencies can't be summed.
const billableTrucks = (record.vehicleAssignments ?? []).filter(
(a) => Number(a.distanceKm) > 0,
);
const currencies = [
...new Set(
billableTrucks
.map((a) => (a.vehicle as { currency?: string } | undefined)?.currency)
.filter((c): c is string => Boolean(c)),
),
];
if (currencies.length > 1) {
throw new BadRequestException(
`Cannot generate invoice: assigned trucks use mixed currencies (${currencies.join(', ')}). Assign trucks that share one currency.`,
);
}
// Currency follows the truck (price/km is quoted per vehicle), falling back
// to the booking's currency, then ETB.
const truckCurrency =
(record.vehicle as { currency?: string } | undefined)?.currency ||
(record.vehicleAssignments?.[0]?.vehicle as { currency?: string } | undefined)?.currency;
// Generate invoice with remainingPayment as totalAmount
const input: GenerateInvoiceInput = {
source: 'last_mile' as Freight.InvoiceSource,
@@ -70,7 +94,7 @@ export class LastMileInvoiceService {
type: 'DELIVERY_FEE',
companyId: lm.booking!.companyId,
companyProfileId: lm.booking!.companyProfileId || '',
currency: lm.booking!.paymentCurrency || 'ETB',
currency: truckCurrency || lm.booking!.paymentCurrency || 'ETB',
lines: [
{
chargeType: 'DELIVERY',

View File

@@ -14,7 +14,8 @@ import {
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
@@ -27,7 +28,7 @@ import { LastMileInvoiceService } from './last-mile-invoice.service';
@ApiTags('last-mile')
@ApiBearerAuth()
@Controller('last-mile')
@TrainSchedulingView()
@BookingStaff(FREIGHT_PERMS.lastMile.view)
export class LastMileController {
constructor(
private readonly lastMileService: LastMileService,
@@ -63,21 +64,21 @@ export class LastMileController {
}
@Post('accept/:reference')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.lastMile.accept)
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
acceptBooking(@Param('reference') reference: string) {
return this.lastMileService.acceptBookingByReference(reference);
}
@Post()
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.lastMile.create)
@ApiOperation({ summary: 'Create a last-mile leg' })
create(@Body() dto: CreateLastMileDto) {
return this.lastMileService.create(dto);
}
@Patch(':id')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.lastMile.update)
@ApiOperation({ summary: 'Update a last-mile leg' })
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
// No invoice side-effects here — invoices are generated only via the
@@ -86,7 +87,7 @@ export class LastMileController {
}
@Delete(':id')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.lastMile.delete)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a last-mile leg' })
remove(@Param('id', ParseUUIDPipe) id: string) {
@@ -95,7 +96,7 @@ export class LastMileController {
@Post(':id/vehicles')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.lastMile.assignVehicles)
@ApiOperation({ summary: 'Set the vehicles assigned to a last-mile delivery (multi-truck)' })
async setVehicles(
@Param('id', ParseUUIDPipe) id: string,
@@ -105,7 +106,7 @@ export class LastMileController {
}
@Post(':id/distances')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.lastMile.setDistances)
@ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' })
async setDistances(
@Param('id', ParseUUIDPipe) id: string,
@@ -115,7 +116,7 @@ export class LastMileController {
}
@Post(':id/invoice')
@TrainSchedulingManage()
@BookingStaff(FREIGHT_PERMS.lastMile.generateInvoice)
@ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' })
async generateInvoice(@Param('id', ParseUUIDPipe) id: string) {
const record = await this.lastMileService.findById(id);

View File

@@ -282,6 +282,17 @@ export class LastMileService {
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}),
// Truck-detention clock: stamp arrival when the vehicle goes IN_TRANSIT and
// delivery when it reaches DELIVERED (first time only). Explicit dto values
// below override the auto-stamp so staff can record the real times.
...(dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT' && !existing.arrivedAt
? { arrivedAt: new Date() }
: {}),
...(dto.status === 'DELIVERED' && existing.status !== 'DELIVERED' && !existing.deliveredAt
? { deliveredAt: new Date() }
: {}),
...(dtoAny.arrivedAt !== undefined ? { arrivedAt: dtoAny.arrivedAt ? new Date(dtoAny.arrivedAt) : null } : {}),
...(dtoAny.deliveredAt !== undefined ? { deliveredAt: dtoAny.deliveredAt ? new Date(dtoAny.deliveredAt) : null } : {}),
} as any);
if (!updated) {
@@ -510,10 +521,24 @@ export class LastMileService {
{ distanceKm: d.distanceKm },
);
}
const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0);
// Billing is per truck: amount = Σ (truck distance × truck price/km). The
// per-vehicle rate + currency live on the vehicle, so we ignore the legacy
// LAST_MILE flat rate and any client-sent amount. `remainingPayment` param
// kept only for signature back-compat.
void remainingPayment;
const assignments = await this.dataSource.manager.find(LastMileVehicleAssignment, {
where: { lastMileId: id },
relations: { vehicle: true },
});
const total = assignments.reduce((s, a) => s + (Number(a.distanceKm) || 0), 0);
const amount = assignments.reduce(
(s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0),
0,
);
await this.lastMileRepository.update(id, {
exactKm: total,
...(remainingPayment != null ? { remainingPayment } : {}),
remainingPayment: amount,
} as any);
return this.findById(id);
}

View File

@@ -0,0 +1,171 @@
import {
IsUUID,
IsString,
IsDateString,
IsNumber,
IsInt,
IsOptional,
IsEnum,
Min,
} from 'class-validator';
import { WorkOrderStatus, WorkOrderPriority } from '../entities/work-order.entity';
export class CreateWorkOrderDto {
@IsUUID()
vehicleId!: string;
@IsString()
title!: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(WorkOrderStatus)
status?: WorkOrderStatus;
@IsOptional()
@IsEnum(WorkOrderPriority)
priority?: WorkOrderPriority;
@IsOptional()
@IsString()
assignedTo?: string;
@IsOptional()
@IsDateString()
openedAt?: string;
@IsOptional()
@IsDateString()
closedAt?: string;
@IsOptional()
@IsNumber()
laborCost?: number;
@IsOptional()
@IsNumber()
partsCost?: number;
}
export class UpdateWorkOrderDto {
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(WorkOrderStatus)
status?: WorkOrderStatus;
@IsOptional()
@IsEnum(WorkOrderPriority)
priority?: WorkOrderPriority;
@IsOptional()
@IsString()
assignedTo?: string;
@IsOptional()
@IsDateString()
closedAt?: string;
@IsOptional()
@IsNumber()
laborCost?: number;
@IsOptional()
@IsNumber()
partsCost?: number;
}
export class CreatePartDto {
@IsString()
name!: string;
@IsOptional()
@IsString()
sku?: string;
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsInt()
@Min(0)
quantityInStock?: number;
@IsOptional()
@IsInt()
@Min(0)
reorderLevel?: number;
@IsOptional()
@IsNumber()
unitCost?: number;
@IsOptional()
@IsString()
location?: string;
}
export class UpdatePartDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
sku?: string;
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsInt()
@Min(0)
quantityInStock?: number;
@IsOptional()
@IsInt()
@Min(0)
reorderLevel?: number;
@IsOptional()
@IsNumber()
unitCost?: number;
@IsOptional()
@IsString()
location?: string;
}
export class CreateWarrantyDto {
@IsUUID()
vehicleId!: string;
@IsString()
component!: string;
@IsOptional()
@IsString()
provider?: string;
@IsOptional()
@IsDateString()
startDate?: string;
@IsDateString()
expiryDate!: string;
@IsOptional()
@IsString()
coverageNotes?: string;
}

View File

@@ -0,0 +1,27 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, Index } from 'typeorm';
@Entity({ name: 'parts', schema: 'freight' })
@Index(['category'])
export class Part extends BaseEntity {
@Column({ name: 'name', type: 'varchar' })
name!: string;
@Column({ name: 'sku', type: 'varchar', nullable: true })
sku?: string;
@Column({ name: 'category', type: 'varchar', nullable: true })
category?: string; // includes 'TIRE' — doubles as tire inventory
@Column({ name: 'quantity_in_stock', type: 'int', default: 0 })
quantityInStock!: number;
@Column({ name: 'reorder_level', type: 'int', default: 0 })
reorderLevel!: number;
@Column({ name: 'unit_cost', type: 'numeric', precision: 14, scale: 2, nullable: true })
unitCost?: number;
@Column({ name: 'location', type: 'varchar', nullable: true })
location?: string;
}

View File

@@ -0,0 +1,29 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
@Entity({ name: 'warranties', schema: 'freight' })
@Index(['vehicleId', 'expiryDate'])
export class Warranty extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'component', type: 'varchar' })
component!: string;
@Column({ name: 'provider', type: 'varchar', nullable: true })
provider?: string;
@Column({ name: 'start_date', type: 'date', nullable: true })
startDate?: string;
@Column({ name: 'expiry_date', type: 'date' })
expiryDate!: string;
@Column({ name: 'coverage_notes', type: 'text', nullable: true })
coverageNotes?: string;
}

View File

@@ -0,0 +1,55 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
export enum WorkOrderStatus {
OPEN = 'OPEN',
IN_PROGRESS = 'IN_PROGRESS',
COMPLETED = 'COMPLETED',
CANCELLED = 'CANCELLED',
}
export enum WorkOrderPriority {
LOW = 'LOW',
MEDIUM = 'MEDIUM',
HIGH = 'HIGH',
URGENT = 'URGENT',
}
@Entity({ name: 'work_orders', schema: 'freight' })
@Index(['vehicleId', 'status'])
export class WorkOrder extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'title', type: 'varchar' })
title!: string;
@Column({ name: 'description', type: 'text', nullable: true })
description?: string;
@Column({ name: 'status', type: 'varchar', default: WorkOrderStatus.OPEN })
status!: WorkOrderStatus;
@Column({ name: 'priority', type: 'varchar', default: WorkOrderPriority.MEDIUM })
priority!: WorkOrderPriority;
@Column({ name: 'assigned_to', type: 'varchar', nullable: true })
assignedTo?: string;
@Column({ name: 'opened_at', type: 'timestamptz' })
openedAt!: Date;
@Column({ name: 'closed_at', type: 'timestamptz', nullable: true })
closedAt?: Date;
@Column({ name: 'labor_cost', type: 'numeric', precision: 14, scale: 2, nullable: true })
laborCost?: number;
@Column({ name: 'parts_cost', type: 'numeric', precision: 14, scale: 2, nullable: true })
partsCost?: number;
}

View File

@@ -0,0 +1,99 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { WorkOrderRepository } from './work-order.repository';
import { PartRepository } from './part.repository';
import { WarrantyRepository } from './warranty.repository';
import { WorkOrder, WorkOrderStatus } from './entities/work-order.entity';
import { Part } from './entities/part.entity';
import { Warranty } from './entities/warranty.entity';
import {
CreateWorkOrderDto,
UpdateWorkOrderDto,
CreatePartDto,
UpdatePartDto,
CreateWarrantyDto,
} from './dto/create-maintenance-depth.dto';
@Injectable()
export class MaintenanceDepthService {
constructor(
private readonly workOrderRepository: WorkOrderRepository,
private readonly partRepository: PartRepository,
private readonly warrantyRepository: WarrantyRepository,
) {}
// ---- Work Orders ----
async createWorkOrder(dto: CreateWorkOrderDto): Promise<WorkOrder> {
return this.workOrderRepository.create({
...dto,
openedAt: dto.openedAt ? new Date(dto.openedAt) : new Date(),
closedAt: dto.closedAt ? new Date(dto.closedAt) : undefined,
});
}
async findWorkOrders(filters: { vehicleId?: string; status?: WorkOrderStatus }) {
return this.workOrderRepository.findFiltered(filters);
}
async findWorkOrderById(id: string): Promise<WorkOrder> {
const workOrder = await this.workOrderRepository.findById(id);
if (!workOrder) throw new NotFoundException(`Work order ${id} not found`);
return workOrder;
}
async updateWorkOrder(id: string, dto: UpdateWorkOrderDto): Promise<WorkOrder> {
await this.findWorkOrderById(id);
const updated = await this.workOrderRepository.update(id, {
...dto,
closedAt: dto.closedAt ? new Date(dto.closedAt) : undefined,
});
return updated!;
}
async deleteWorkOrder(id: string): Promise<{ id: string; deleted: boolean }> {
await this.findWorkOrderById(id);
await this.workOrderRepository.softDelete(id);
return { id, deleted: true };
}
// ---- Parts / Tires ----
async createPart(dto: CreatePartDto): Promise<Part> {
return this.partRepository.create({ ...dto });
}
async findParts(filters: { category?: string; lowStock?: boolean }) {
return this.partRepository.findFiltered(filters);
}
async updatePart(id: string, dto: UpdatePartDto): Promise<Part> {
const part = await this.partRepository.findById(id);
if (!part) throw new NotFoundException(`Part ${id} not found`);
const updated = await this.partRepository.update(id, { ...dto });
return updated!;
}
async deletePart(id: string): Promise<{ id: string; deleted: boolean }> {
const part = await this.partRepository.findById(id);
if (!part) throw new NotFoundException(`Part ${id} not found`);
await this.partRepository.softDelete(id);
return { id, deleted: true };
}
// ---- Warranties ----
async createWarranty(dto: CreateWarrantyDto): Promise<Warranty> {
return this.warrantyRepository.create({ ...dto });
}
async findWarranties(filters: { vehicleId?: string }) {
return this.warrantyRepository.findFiltered(filters);
}
async deleteWarranty(id: string): Promise<{ id: string; deleted: boolean }> {
const warranty = await this.warrantyRepository.findById(id);
if (!warranty) throw new NotFoundException(`Warranty ${id} not found`);
await this.warrantyRepository.softDelete(id);
return { id, deleted: true };
}
}

View File

@@ -1,52 +1,173 @@
import { Controller, Post, Get, Patch, Body, Param } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags, ApiOperation } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { MaintenanceService } from './maintenance.service';
import { MaintenanceDepthService } from './maintenance-depth.service';
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
import {
CreateWorkOrderDto,
UpdateWorkOrderDto,
CreatePartDto,
UpdatePartDto,
CreateWarrantyDto,
} from './dto/create-maintenance-depth.dto';
import { WorkOrderStatus } from './entities/work-order.entity';
@ApiTags('Maintenance Management')
@ApiBearerAuth()
@Controller('maintenance')
export class MaintenanceController {
constructor(private readonly maintenanceService: MaintenanceService) {}
constructor(
private readonly maintenanceService: MaintenanceService,
private readonly maintenanceDepthService: MaintenanceDepthService,
) {}
@Post('schedules')
@BookingStaff(FREIGHT_PERMS.maintenance.create)
@ApiOperation({ summary: 'Schedule maintenance' })
async scheduleMaintenanceAsync(@Body() dto: CreateMaintenanceScheduleDto) {
return this.maintenanceService.scheduleMaintenanceAsync(dto);
}
@Post('costs')
@BookingStaff(FREIGHT_PERMS.maintenance.create)
@ApiOperation({ summary: 'Record maintenance cost' })
async recordCost(@Body() dto: CreateMaintenanceCostDto) {
return this.maintenanceService.recordMaintenanceCost(dto);
}
@Patch('schedules/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.update)
@ApiOperation({ summary: 'Update maintenance schedule' })
async updateSchedule(@Param('id') id: string, @Body() dto: UpdateMaintenanceScheduleDto) {
return this.maintenanceService.updateMaintenanceSchedule(id, dto);
}
@Get('upcoming/:vehicleId')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: 'Get upcoming maintenance' })
async getUpcoming(@Param('vehicleId') vehicleId: string) {
return this.maintenanceService.getUpcomingMaintenance(vehicleId);
}
@Get('history/:vehicleId')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: 'Get maintenance history' })
async getHistory(@Param('vehicleId') vehicleId: string) {
return this.maintenanceService.getMaintenanceHistory(vehicleId);
}
@Get('stats')
@BookingStaff([FREIGHT_PERMS.maintenance.view, FREIGHT_PERMS.fleetReports.view, FREIGHT_PERMS.fleetDashboard.view])
@ApiOperation({ summary: 'Get fleet-wide maintenance statistics' })
async getFleetStats() {
return this.maintenanceService.getFleetMaintenanceStats();
}
@Get('stats/:vehicleId')
@BookingStaff([FREIGHT_PERMS.maintenance.view, FREIGHT_PERMS.fleetReports.view, FREIGHT_PERMS.fleetDashboard.view])
@ApiOperation({ summary: 'Get maintenance statistics' })
async getStats(@Param('vehicleId') vehicleId: string) {
return this.maintenanceService.getVehicleMaintenanceStats(vehicleId);
}
// ---- Work Orders ----
@Post('work-orders')
@BookingStaff(FREIGHT_PERMS.maintenance.create)
@ApiOperation({ summary: 'Create work order' })
async createWorkOrder(@Body() dto: CreateWorkOrderDto) {
return this.maintenanceDepthService.createWorkOrder(dto);
}
@Get('work-orders')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: 'List work orders' })
async listWorkOrders(
@Query('vehicleId') vehicleId?: string,
@Query('status') status?: WorkOrderStatus,
) {
return this.maintenanceDepthService.findWorkOrders({ vehicleId, status });
}
@Get('work-orders/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: 'Get work order' })
async getWorkOrder(@Param('id') id: string) {
return this.maintenanceDepthService.findWorkOrderById(id);
}
@Patch('work-orders/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.update)
@ApiOperation({ summary: 'Update work order' })
async updateWorkOrder(@Param('id') id: string, @Body() dto: UpdateWorkOrderDto) {
return this.maintenanceDepthService.updateWorkOrder(id, dto);
}
@Delete('work-orders/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.delete)
@ApiOperation({ summary: 'Delete work order' })
async deleteWorkOrder(@Param('id') id: string) {
return this.maintenanceDepthService.deleteWorkOrder(id);
}
// ---- Parts / Tires ----
@Post('parts')
@BookingStaff(FREIGHT_PERMS.maintenance.create)
@ApiOperation({ summary: 'Create part' })
async createPart(@Body() dto: CreatePartDto) {
return this.maintenanceDepthService.createPart(dto);
}
@Get('parts')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: 'List parts / tire inventory' })
async listParts(
@Query('category') category?: string,
@Query('lowStock') lowStock?: string,
) {
return this.maintenanceDepthService.findParts({
category,
lowStock: lowStock === 'true',
});
}
@Patch('parts/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.update)
@ApiOperation({ summary: 'Update part' })
async updatePart(@Param('id') id: string, @Body() dto: UpdatePartDto) {
return this.maintenanceDepthService.updatePart(id, dto);
}
@Delete('parts/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.delete)
@ApiOperation({ summary: 'Delete part' })
async deletePart(@Param('id') id: string) {
return this.maintenanceDepthService.deletePart(id);
}
// ---- Warranties ----
@Post('warranties')
@BookingStaff(FREIGHT_PERMS.maintenance.create)
@ApiOperation({ summary: 'Create warranty' })
async createWarranty(@Body() dto: CreateWarrantyDto) {
return this.maintenanceDepthService.createWarranty(dto);
}
@Get('warranties')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: 'List warranties' })
async listWarranties(@Query('vehicleId') vehicleId?: string) {
return this.maintenanceDepthService.findWarranties({ vehicleId });
}
@Delete('warranties/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.delete)
@ApiOperation({ summary: 'Delete warranty' })
async deleteWarranty(@Param('id') id: string) {
return this.maintenanceDepthService.deleteWarranty(id);
}
}

View File

@@ -2,14 +2,30 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
import { MaintenanceCost } from './entities/maintenance-cost.entity';
import { WorkOrder } from './entities/work-order.entity';
import { Part } from './entities/part.entity';
import { Warranty } from './entities/warranty.entity';
import { MaintenanceService } from './maintenance.service';
import { MaintenanceDepthService } from './maintenance-depth.service';
import { MaintenanceRepository } from './maintenance.repository';
import { WorkOrderRepository } from './work-order.repository';
import { PartRepository } from './part.repository';
import { WarrantyRepository } from './warranty.repository';
import { MaintenanceController } from './maintenance.controller';
@Module({
imports: [TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost])],
providers: [MaintenanceService, MaintenanceRepository],
imports: [
TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost, WorkOrder, Part, Warranty]),
],
providers: [
MaintenanceService,
MaintenanceDepthService,
MaintenanceRepository,
WorkOrderRepository,
PartRepository,
WarrantyRepository,
],
controllers: [MaintenanceController],
exports: [MaintenanceService],
exports: [MaintenanceService, MaintenanceDepthService],
})
export class MaintenanceModule {}

View File

@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository } from 'typeorm';
import { Part } from './entities/part.entity';
@Injectable()
export class PartRepository extends BaseRepository<Part> {
constructor(
@InjectRepository(Part)
private readonly partRepository: Repository<Part>,
) {
super(partRepository);
}
async findFiltered(filters: { category?: string; lowStock?: boolean }) {
const qb = this.partRepository.createQueryBuilder('part');
if (filters.category) {
qb.andWhere('part.category = :category', { category: filters.category });
}
if (filters.lowStock) {
qb.andWhere('part.quantityInStock <= part.reorderLevel');
}
qb.orderBy('part.name', 'ASC');
return qb.getMany();
}
}

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository, FindOptionsWhere } from 'typeorm';
import { Warranty } from './entities/warranty.entity';
@Injectable()
export class WarrantyRepository extends BaseRepository<Warranty> {
constructor(
@InjectRepository(Warranty)
private readonly warrantyRepository: Repository<Warranty>,
) {
super(warrantyRepository);
}
async findFiltered(filters: { vehicleId?: string }) {
const where: FindOptionsWhere<Warranty> = {};
if (filters.vehicleId) where.vehicleId = filters.vehicleId;
return this.warrantyRepository.find({
where,
order: { expiryDate: 'ASC' },
});
}
}

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository, FindOptionsWhere } from 'typeorm';
import { WorkOrder, WorkOrderStatus } from './entities/work-order.entity';
@Injectable()
export class WorkOrderRepository extends BaseRepository<WorkOrder> {
constructor(
@InjectRepository(WorkOrder)
private readonly workOrderRepository: Repository<WorkOrder>,
) {
super(workOrderRepository);
}
async findFiltered(filters: { vehicleId?: string; status?: WorkOrderStatus }) {
const where: FindOptionsWhere<WorkOrder> = {};
if (filters.vehicleId) where.vehicleId = filters.vehicleId;
if (filters.status) where.status = filters.status;
return this.workOrderRepository.find({
where,
order: { openedAt: 'DESC' },
});
}
}

View File

@@ -0,0 +1,193 @@
import {
IsUUID,
IsString,
IsDateString,
IsNumber,
IsInt,
IsOptional,
IsEnum,
IsBoolean,
} from 'class-validator';
import { VendorType } from '../entities/vendor.entity';
import { AcquisitionType, AcquisitionStatus } from '../entities/asset-acquisition.entity';
import { DisposalMethod } from '../entities/asset-disposal.entity';
export class CreateVendorDto {
@IsString()
name!: string;
@IsOptional()
@IsEnum(VendorType)
type?: VendorType;
@IsOptional()
@IsString()
contactPerson?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateVendorDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsEnum(VendorType)
type?: VendorType;
@IsOptional()
@IsString()
contactPerson?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class CreateAcquisitionDto {
@IsOptional()
@IsUUID()
vehicleId?: string;
@IsOptional()
@IsUUID()
vendorId?: string;
@IsEnum(AcquisitionType)
acquisitionType!: AcquisitionType;
@IsDateString()
acquisitionDate!: string;
@IsOptional()
@IsNumber()
cost?: number;
@IsOptional()
@IsInt()
usefulLifeMonths?: number;
@IsOptional()
@IsNumber()
salvageValue?: number;
@IsOptional()
@IsDateString()
leaseStart?: string;
@IsOptional()
@IsDateString()
leaseEnd?: string;
@IsOptional()
@IsNumber()
monthlyPayment?: number;
@IsOptional()
@IsEnum(AcquisitionStatus)
status?: AcquisitionStatus;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateAcquisitionDto {
@IsOptional()
@IsUUID()
vehicleId?: string;
@IsOptional()
@IsUUID()
vendorId?: string;
@IsOptional()
@IsEnum(AcquisitionType)
acquisitionType?: AcquisitionType;
@IsOptional()
@IsDateString()
acquisitionDate?: string;
@IsOptional()
@IsNumber()
cost?: number;
@IsOptional()
@IsInt()
usefulLifeMonths?: number;
@IsOptional()
@IsNumber()
salvageValue?: number;
@IsOptional()
@IsDateString()
leaseStart?: string;
@IsOptional()
@IsDateString()
leaseEnd?: string;
@IsOptional()
@IsNumber()
monthlyPayment?: number;
@IsOptional()
@IsEnum(AcquisitionStatus)
status?: AcquisitionStatus;
@IsOptional()
@IsString()
notes?: string;
}
export class CreateDisposalDto {
@IsUUID()
vehicleId!: string;
@IsDateString()
disposalDate!: string;
@IsEnum(DisposalMethod)
method!: DisposalMethod;
@IsOptional()
@IsNumber()
salePrice?: number;
@IsOptional()
@IsString()
buyer?: string;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,64 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { Vendor } from './vendor.entity';
export enum AcquisitionType {
PURCHASE = 'PURCHASE',
LEASE = 'LEASE',
RENTAL = 'RENTAL',
}
export enum AcquisitionStatus {
ACTIVE = 'ACTIVE',
LEASE_EXPIRING = 'LEASE_EXPIRING',
DISPOSED = 'DISPOSED',
}
@Entity({ name: 'asset_acquisitions', schema: 'freight' })
@Index(['vehicleId', 'acquisitionDate'])
export class AssetAcquisition extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string;
@ManyToOne(() => Vehicle, { eager: false, nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle;
@Column({ name: 'vendor_id', type: 'uuid', nullable: true })
vendorId?: string;
@ManyToOne(() => Vendor, { eager: false, nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'vendor_id' })
vendor?: Vendor;
@Column({ name: 'acquisition_type', type: 'varchar' })
acquisitionType!: AcquisitionType;
@Column({ name: 'acquisition_date', type: 'date' })
acquisitionDate!: string;
@Column({ name: 'cost', type: 'numeric', precision: 14, scale: 2, nullable: true })
cost?: number;
@Column({ name: 'useful_life_months', type: 'int', nullable: true })
usefulLifeMonths?: number;
@Column({ name: 'salvage_value', type: 'numeric', precision: 14, scale: 2, nullable: true })
salvageValue?: number;
@Column({ name: 'lease_start', type: 'date', nullable: true })
leaseStart?: string;
@Column({ name: 'lease_end', type: 'date', nullable: true })
leaseEnd?: string;
@Column({ name: 'monthly_payment', type: 'numeric', precision: 14, scale: 2, nullable: true })
monthlyPayment?: number;
@Column({ name: 'status', type: 'varchar', default: AcquisitionStatus.ACTIVE })
status!: AcquisitionStatus;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string;
}

View File

@@ -0,0 +1,31 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, Index } from 'typeorm';
export enum DisposalMethod {
SALE = 'SALE',
SCRAP = 'SCRAP',
RETURN_LEASE = 'RETURN_LEASE',
TRADE_IN = 'TRADE_IN',
}
@Entity({ name: 'asset_disposals', schema: 'freight' })
@Index(['vehicleId', 'disposalDate'])
export class AssetDisposal extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@Column({ name: 'disposal_date', type: 'date' })
disposalDate!: string;
@Column({ name: 'method', type: 'varchar' })
method!: DisposalMethod;
@Column({ name: 'sale_price', type: 'numeric', precision: 14, scale: 2, nullable: true })
salePrice?: number;
@Column({ name: 'buyer', nullable: true })
buyer?: string;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string;
}

View File

@@ -0,0 +1,34 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column } from 'typeorm';
export enum VendorType {
DEALER = 'DEALER',
LEASING = 'LEASING',
PARTS = 'PARTS',
SERVICE = 'SERVICE',
OTHER = 'OTHER',
}
@Entity({ name: 'vendors', schema: 'freight' })
export class Vendor extends BaseEntity {
@Column({ name: 'name' })
name!: string;
@Column({ name: 'type', type: 'varchar', nullable: true })
type?: VendorType;
@Column({ name: 'contact_person', nullable: true })
contactPerson?: string;
@Column({ name: 'phone', nullable: true })
phone?: string;
@Column({ name: 'email', nullable: true })
email?: string;
@Column({ name: 'address', nullable: true })
address?: string;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -0,0 +1,98 @@
import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ProcurementService } from './procurement.service';
import {
CreateVendorDto,
UpdateVendorDto,
CreateAcquisitionDto,
UpdateAcquisitionDto,
CreateDisposalDto,
} from './dto/procurement.dto';
@ApiTags('Procurement & Asset Lifecycle')
@Controller('procurement')
export class ProcurementController {
constructor(private readonly procurementService: ProcurementService) {}
// ---- Vendors ----
@Post('vendors')
@ApiOperation({ summary: 'Create a vendor' })
async createVendor(@Body() dto: CreateVendorDto) {
return this.procurementService.createVendor(dto);
}
@Get('vendors')
@ApiOperation({ summary: 'List vendors' })
async listVendors() {
return this.procurementService.listVendors();
}
@Patch('vendors/:id')
@ApiOperation({ summary: 'Update a vendor' })
async updateVendor(@Param('id') id: string, @Body() dto: UpdateVendorDto) {
return this.procurementService.updateVendor(id, dto);
}
@Delete('vendors/:id')
@ApiOperation({ summary: 'Delete a vendor' })
async deleteVendor(@Param('id') id: string) {
return this.procurementService.deleteVendor(id);
}
// ---- Acquisitions ----
@Post('acquisitions')
@ApiOperation({ summary: 'Create an asset acquisition' })
async createAcquisition(@Body() dto: CreateAcquisitionDto) {
return this.procurementService.createAcquisition(dto);
}
@Get('acquisitions')
@ApiOperation({ summary: 'List asset acquisitions (optionally filtered by vehicleId)' })
async listAcquisitions(@Query('vehicleId') vehicleId?: string) {
return this.procurementService.listAcquisitions(vehicleId);
}
@Get('acquisitions/:id')
@ApiOperation({ summary: 'Get an asset acquisition by id' })
async getAcquisition(@Param('id') id: string) {
return this.procurementService.getAcquisition(id);
}
@Patch('acquisitions/:id')
@ApiOperation({ summary: 'Update an asset acquisition' })
async updateAcquisition(@Param('id') id: string, @Body() dto: UpdateAcquisitionDto) {
return this.procurementService.updateAcquisition(id, dto);
}
@Delete('acquisitions/:id')
@ApiOperation({ summary: 'Delete an asset acquisition' })
async deleteAcquisition(@Param('id') id: string) {
return this.procurementService.deleteAcquisition(id);
}
// ---- Disposals ----
@Post('disposals')
@ApiOperation({ summary: 'Create an asset disposal' })
async createDisposal(@Body() dto: CreateDisposalDto) {
return this.procurementService.createDisposal(dto);
}
@Get('disposals')
@ApiOperation({ summary: 'List asset disposals' })
async listDisposals() {
return this.procurementService.listDisposals();
}
@Delete('disposals/:id')
@ApiOperation({ summary: 'Delete an asset disposal' })
async deleteDisposal(@Param('id') id: string) {
return this.procurementService.deleteDisposal(id);
}
// ---- Lifecycle ----
@Get('lifecycle/:vehicleId')
@ApiOperation({ summary: 'Get asset lifecycle (acquisition, disposal, depreciation) for a vehicle' })
async lifecycle(@Param('vehicleId') vehicleId: string) {
return this.procurementService.lifecycle(vehicleId);
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Vendor } from './entities/vendor.entity';
import { AssetAcquisition } from './entities/asset-acquisition.entity';
import { AssetDisposal } from './entities/asset-disposal.entity';
import { ProcurementService } from './procurement.service';
import { ProcurementRepository } from './procurement.repository';
import { ProcurementController } from './procurement.controller';
@Module({
imports: [TypeOrmModule.forFeature([Vendor, AssetAcquisition, AssetDisposal])],
providers: [ProcurementService, ProcurementRepository],
controllers: [ProcurementController],
exports: [ProcurementService],
})
export class ProcurementModule {}

View File

@@ -0,0 +1,102 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { DeepPartial, Repository } from 'typeorm';
import { Vendor } from './entities/vendor.entity';
import { AssetAcquisition } from './entities/asset-acquisition.entity';
import { AssetDisposal } from './entities/asset-disposal.entity';
@Injectable()
export class ProcurementRepository extends BaseRepository<AssetAcquisition> {
constructor(
@InjectRepository(AssetAcquisition)
private readonly acquisitionRepository: Repository<AssetAcquisition>,
@InjectRepository(Vendor)
private readonly vendorRepository: Repository<Vendor>,
@InjectRepository(AssetDisposal)
private readonly disposalRepository: Repository<AssetDisposal>,
) {
super(acquisitionRepository);
}
// ---- Vendors ----
async createVendor(data: DeepPartial<Vendor>): Promise<Vendor> {
const vendor = this.vendorRepository.create(data);
return this.vendorRepository.save(vendor);
}
async findVendors(): Promise<Vendor[]> {
return this.vendorRepository.find({ order: { createdAt: 'DESC' } });
}
async updateVendor(id: string, data: DeepPartial<Vendor>): Promise<Vendor | null> {
await this.vendorRepository.update(id, data as never);
return this.vendorRepository.findOneBy({ id });
}
async softDeleteVendor(id: string): Promise<void> {
await this.vendorRepository.softDelete(id);
}
// ---- Acquisitions ----
async createAcquisition(data: DeepPartial<AssetAcquisition>): Promise<AssetAcquisition> {
const acquisition = this.acquisitionRepository.create(data);
return this.acquisitionRepository.save(acquisition);
}
async findAcquisitions(vehicleId?: string): Promise<AssetAcquisition[]> {
return this.acquisitionRepository.find({
where: vehicleId ? { vehicleId } : {},
relations: ['vehicle', 'vendor'],
order: { acquisitionDate: 'DESC' },
});
}
async findAcquisitionById(id: string): Promise<AssetAcquisition | null> {
return this.acquisitionRepository.findOne({
where: { id },
relations: ['vehicle', 'vendor'],
});
}
async updateAcquisition(
id: string,
data: DeepPartial<AssetAcquisition>,
): Promise<AssetAcquisition | null> {
await this.acquisitionRepository.update(id, data as never);
return this.findAcquisitionById(id);
}
async softDeleteAcquisition(id: string): Promise<void> {
await this.acquisitionRepository.softDelete(id);
}
async findLatestAcquisitionByVehicle(vehicleId: string): Promise<AssetAcquisition | null> {
return this.acquisitionRepository.findOne({
where: { vehicleId },
relations: ['vehicle', 'vendor'],
order: { acquisitionDate: 'DESC' },
});
}
// ---- Disposals ----
async createDisposal(data: DeepPartial<AssetDisposal>): Promise<AssetDisposal> {
const disposal = this.disposalRepository.create(data);
return this.disposalRepository.save(disposal);
}
async findDisposals(): Promise<AssetDisposal[]> {
return this.disposalRepository.find({ order: { disposalDate: 'DESC' } });
}
async softDeleteDisposal(id: string): Promise<void> {
await this.disposalRepository.softDelete(id);
}
async findLatestDisposalByVehicle(vehicleId: string): Promise<AssetDisposal | null> {
return this.disposalRepository.findOne({
where: { vehicleId },
order: { disposalDate: 'DESC' },
});
}
}

View File

@@ -0,0 +1,143 @@
import { Injectable } from '@nestjs/common';
import { ProcurementRepository } from './procurement.repository';
import { Vendor } from './entities/vendor.entity';
import { AssetAcquisition } from './entities/asset-acquisition.entity';
import { AssetDisposal } from './entities/asset-disposal.entity';
import {
CreateVendorDto,
UpdateVendorDto,
CreateAcquisitionDto,
UpdateAcquisitionDto,
CreateDisposalDto,
} from './dto/procurement.dto';
export interface DepreciationResult {
method: 'STRAIGHT_LINE';
cost: number;
salvageValue: number;
usefulLifeMonths: number;
monthsElapsed: number;
monthlyDepreciation: number;
bookValue: number;
}
export interface LifecycleResult {
vehicleId: string;
acquisition: AssetAcquisition | null;
disposal: AssetDisposal | null;
depreciation: DepreciationResult | null;
}
@Injectable()
export class ProcurementService {
constructor(private readonly procurementRepository: ProcurementRepository) {}
// ---- Vendors ----
async createVendor(dto: CreateVendorDto): Promise<Vendor> {
return this.procurementRepository.createVendor(dto);
}
async listVendors(): Promise<Vendor[]> {
return this.procurementRepository.findVendors();
}
async updateVendor(id: string, dto: UpdateVendorDto): Promise<Vendor | null> {
return this.procurementRepository.updateVendor(id, dto);
}
async deleteVendor(id: string): Promise<{ success: boolean }> {
await this.procurementRepository.softDeleteVendor(id);
return { success: true };
}
// ---- Acquisitions ----
async createAcquisition(dto: CreateAcquisitionDto): Promise<AssetAcquisition> {
return this.procurementRepository.createAcquisition(dto);
}
async listAcquisitions(vehicleId?: string): Promise<AssetAcquisition[]> {
return this.procurementRepository.findAcquisitions(vehicleId);
}
async getAcquisition(id: string): Promise<AssetAcquisition | null> {
return this.procurementRepository.findAcquisitionById(id);
}
async updateAcquisition(id: string, dto: UpdateAcquisitionDto): Promise<AssetAcquisition | null> {
return this.procurementRepository.updateAcquisition(id, dto);
}
async deleteAcquisition(id: string): Promise<{ success: boolean }> {
await this.procurementRepository.softDeleteAcquisition(id);
return { success: true };
}
// ---- Disposals ----
async createDisposal(dto: CreateDisposalDto): Promise<AssetDisposal> {
return this.procurementRepository.createDisposal(dto);
}
async listDisposals(): Promise<AssetDisposal[]> {
return this.procurementRepository.findDisposals();
}
async deleteDisposal(id: string): Promise<{ success: boolean }> {
await this.procurementRepository.softDeleteDisposal(id);
return { success: true };
}
// ---- Lifecycle ----
async lifecycle(vehicleId: string): Promise<LifecycleResult> {
const acquisition = await this.procurementRepository.findLatestAcquisitionByVehicle(vehicleId);
const disposal = await this.procurementRepository.findLatestDisposalByVehicle(vehicleId);
return {
vehicleId,
acquisition,
disposal,
depreciation: this.computeStraightLineDepreciation(acquisition),
};
}
/**
* Straight-line depreciation. Requires a cost and a positive useful life.
* monthlyDep = (cost - salvageValue) / usefulLifeMonths
* bookValue = cost - monthlyDep * monthsElapsedSinceAcquisition, floored at salvageValue.
*/
private computeStraightLineDepreciation(
acquisition: AssetAcquisition | null,
): DepreciationResult | null {
if (!acquisition) return null;
const cost = acquisition.cost != null ? Number(acquisition.cost) : null;
const usefulLifeMonths =
acquisition.usefulLifeMonths != null ? Number(acquisition.usefulLifeMonths) : null;
if (cost == null || usefulLifeMonths == null || usefulLifeMonths <= 0) {
return null;
}
const salvageValue = acquisition.salvageValue != null ? Number(acquisition.salvageValue) : 0;
const monthlyDepreciation = (cost - salvageValue) / usefulLifeMonths;
const acquiredAt = new Date(acquisition.acquisitionDate);
const now = new Date();
const monthsElapsed = Math.max(
0,
(now.getFullYear() - acquiredAt.getFullYear()) * 12 +
(now.getMonth() - acquiredAt.getMonth()),
);
const bookValue = Math.max(cost - monthlyDepreciation * monthsElapsed, salvageValue);
return {
method: 'STRAIGHT_LINE',
cost,
salvageValue,
usefulLifeMonths,
monthsElapsed,
monthlyDepreciation,
bookValue,
};
}
}

View File

@@ -1766,9 +1766,9 @@ export class TrainSchedulingService {
performedBy: 'DOCUMENT_GENERATION',
});
const html = this.buildImportLoadListHtml(loadList);
// Generic render — NOT the release-order fallback (would mislabel this as a
// gate-clearance / release order when Chromium is unavailable).
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Import marshalling / load list');
// Styled table-aware fallback (marshalling grid) when Chromium is unavailable —
// NOT the release-order fallback (would mislabel this as a gate-clearance order).
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Import marshalling / load list');
const reference = loadList.trainNumber ?? loadList.trainScheduleId;
return {
filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`,
@@ -1786,8 +1786,8 @@ export class TrainSchedulingService {
}
const html = this.buildExportLoadListHtml(schedule);
// Generic render — NOT the release-order fallback (see importLoadListDocument).
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Export marshalling / load list');
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Export marshalling / load list');
const reference = schedule.trainNumber ?? schedule.id;
return {
filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`,

View File

@@ -65,4 +65,12 @@ export class CreateVehicleDto {
@IsOptional()
@IsUUID()
locationId?: string;
@IsOptional()
@IsNumber()
pricePerKm?: number;
@IsOptional()
@IsString()
currency?: string;
}

View File

@@ -88,4 +88,29 @@ export class Vehicle extends BaseEntity {
@Column({ name: 'location_id', type: 'uuid', nullable: true })
locationId?: string;
// --- Haulage pricing ---
@Column({ name: 'price_per_km', type: 'numeric', precision: 14, scale: 2, nullable: true })
pricePerKm?: number;
/** Currency for pricePerKm: ETB | USD */
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'ETB' })
currency?: string;
// --- Compliance / expiry tracking ---
@Column({ name: 'vin', type: 'varchar', nullable: true })
vin?: string;
/** Owned | Leased | Rented */
@Column({ name: 'ownership', type: 'varchar', nullable: true })
ownership?: string;
@Column({ name: 'insurance_expiry', type: 'date', nullable: true })
insuranceExpiry?: string;
@Column({ name: 'registration_expiry', type: 'date', nullable: true })
registrationExpiry?: string;
@Column({ name: 'next_inspection_date', type: 'date', nullable: true })
nextInspectionDate?: string;
}

View File

@@ -10,7 +10,8 @@ import {
ParseUUIDPipe,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { VehiclesService } from './vehicles.service';
import { CreateVehicleDto } from './dto/create-vehicle.dto';
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
@@ -19,7 +20,7 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service';
@ApiTags('vehicles')
@ApiBearerAuth()
@Controller('vehicles')
@FleetView()
@BookingStaff(FREIGHT_PERMS.vehicles.view)
export class VehiclesController {
constructor(
private readonly vehiclesService: VehiclesService,
@@ -27,7 +28,7 @@ export class VehiclesController {
) {}
@Post()
@FleetManage()
@BookingStaff(FREIGHT_PERMS.vehicles.create)
@ApiOperation({ summary: 'Create a new vehicle' })
create(@Body() createVehicleDto: CreateVehicleDto) {
return this.vehiclesService.create(createVehicleDto);
@@ -68,7 +69,7 @@ export class VehiclesController {
}
@Patch(':id')
@FleetManage()
@BookingStaff(FREIGHT_PERMS.vehicles.update)
@ApiOperation({ summary: 'Update a vehicle' })
update(
@Param('id', ParseUUIDPipe) id: string,
@@ -78,7 +79,7 @@ export class VehiclesController {
}
@Delete(':id')
@FleetManage()
@BookingStaff(FREIGHT_PERMS.vehicles.delete)
@ApiOperation({ summary: 'Delete a vehicle' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.vehiclesService.remove(id);

View File

@@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Matches, Min, ValidateNested } from 'class-validator';
import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity';
import { FEE_RULE_BASES, FEE_RULE_TYPES, FeeRuleBasis, FeeRuleType } from '../entities/warehouse-fee-rule.entity';
export class FeeRuleTierDto {
@ApiProperty({ example: 4 })
@@ -82,11 +82,30 @@ export class CreateFeeRuleDto {
@Min(0)
freeDays!: number;
@ApiProperty()
@ApiProperty({ description: 'Day-based fees: rate/day. Double handling: flat rate per basis unit.' })
@IsNumber()
@Min(0)
ratePerDay!: number;
@ApiPropertyOptional({ description: 'Truck detention only: grace window in hours (default 3).' })
@IsOptional()
@IsInt()
@Min(0)
freeHours?: number;
@ApiPropertyOptional({ description: 'Truck detention only: scope by vehicle type (TRUCK | VAN | TRAILER | …). Null = any.' })
@IsOptional()
@IsString()
vehicleType?: string;
@ApiPropertyOptional({
enum: FEE_RULE_BASES,
description: 'Double-handling charge basis: PER_CONTAINER | PER_TON | PER_ITEM.',
})
@IsOptional()
@IsEnum(FEE_RULE_BASES)
basis?: FeeRuleBasis;
@ApiPropertyOptional({ type: [FeeRuleTierDto] })
@IsOptional()
@IsArray()

View File

@@ -1,9 +1,23 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const;
export const FEE_RULE_TYPES = [
'STORAGE_FEE',
'DEMURRAGE_FEE',
'DOUBLE_HANDLING_FEE',
'TRUCK_DETENTION_FEE',
] as const;
export type FeeRuleType = (typeof FEE_RULE_TYPES)[number];
/**
* Charge basis for a DOUBLE_HANDLING_FEE rule (flat rate × the chosen quantity):
* - PER_CONTAINER: booking container count
* - PER_TON: cargo total in tonnes (bulk cargo)
* - PER_ITEM: cargo total item count (break-bulk cargo, e.g. machinery)
*/
export const FEE_RULE_BASES = ['PER_CONTAINER', 'PER_TON', 'PER_ITEM'] as const;
export type FeeRuleBasis = (typeof FEE_RULE_BASES)[number];
export interface WarehouseFeeTier {
fromDay: number;
toDay: number | null;
@@ -41,6 +55,11 @@ export class WarehouseFeeRule extends BaseEntity {
@Column({ name: 'container_type', type: 'varchar', length: 40, nullable: true })
containerType?: string | null;
// Truck detention only: scope by vehicle type (TRUCK | VAN | TRAILER | TANKER
// | FLATBED | …). Null = any truck type.
@Column({ name: 'vehicle_type', type: 'varchar', length: 20, nullable: true })
vehicleType?: string | null;
@Column({ name: 'facility_id', type: 'uuid', nullable: true })
facilityId?: string | null;
@@ -57,9 +76,20 @@ export class WarehouseFeeRule extends BaseEntity {
@Column({ name: 'free_days', type: 'int', default: 0 })
freeDays!: number;
// Truck detention only: grace window in HOURS before detention accrues
// (contract default 3h). Null/0 → the 3-hour default.
@Column({ name: 'free_hours', type: 'int', nullable: true })
freeHours?: number | null;
@Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 })
ratePerDay!: number;
// Double-handling only: PER_CONTAINER | PER_TON | PER_MACHINERY. The flat rate
// (rate_per_day, reused as rate-per-unit) is multiplied by the basis quantity;
// free days and tiers do not apply. Null for the day-based fee types.
@Column({ name: 'basis', type: 'varchar', length: 20, nullable: true })
basis?: FeeRuleBasis | null;
@Column({ name: 'tiers', type: 'jsonb', default: () => "'[]'" })
tiers!: WarehouseFeeTier[];

View File

@@ -3,7 +3,7 @@ import { ExchangeService } from '@edr/api-common';
import { DataSource } from 'typeorm';
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
import { FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
interface ItemAttributes {
@@ -14,8 +14,12 @@ interface ItemAttributes {
tradeDirection: string | null;
cargoTypeCode: string | null;
containerTypeCode: string | null;
/** Vehicle type of the truck (truck detention scoping); null otherwise. */
vehicleType: string | null;
inventoryQuantity: number;
bookingContainerCount: number;
/** Booking cargo total in the cargo's unit of measure: tonnes (PER_TON) or item count (PER_ITEM). */
cargoQuantity: number;
facilityId: string | null;
warehouseId: string | null;
yardId: string | null;
@@ -24,6 +28,8 @@ interface ItemAttributes {
export interface FeePreview {
ruleType: FeeRuleType;
/** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */
basis: FeeRuleBasis | null;
ruleId: string | null;
ruleName: string | null;
freeDays: number;
@@ -48,6 +54,16 @@ export interface FeePreview {
ratePerDay: number;
amount: number;
}>;
/** Truck detention: per-vehicle-type breakdown — each truck-type group billed by its own matching rule. */
groups?: Array<{
vehicleType: string | null;
truckCount: number;
chargeableDays: number;
ratePerDay: number;
amount: number;
ruleId: string | null;
ruleName: string | null;
}>;
}
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -132,7 +148,8 @@ export class WarehouseFeeService {
b.trade_direction AS "tradeDirection",
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount"
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount",
COALESCE(b.cargo_total_weight_vgm, 0) AS "cargoQuantity"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
@@ -189,6 +206,7 @@ export class WarehouseFeeService {
if (!check(rule.tradeDirection, item.tradeDirection, { allowBoth: true })) return null;
if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null;
if (!check(rule.containerType, item.containerTypeCode)) return null;
if (!check(rule.vehicleType, item.vehicleType)) return null;
if (!check(rule.facilityId, item.facilityId)) return null;
if (!check(rule.warehouseId, item.warehouseId)) return null;
if (!check(rule.yardId, item.yardId)) return null;
@@ -283,6 +301,10 @@ export class WarehouseFeeService {
now: Date,
billingCurrency: string,
): Promise<FeePreview> {
// Double handling is a flat charge (rate × basis quantity), not day-based.
if (ruleType === 'DOUBLE_HANDLING_FEE') {
return this.computeDoubleHandling(rule, item, now, billingCurrency);
}
const start = item.arrivedAt ? new Date(item.arrivedAt) : null;
const endDate = item.gateClearedAt ?? item.releaseDate ?? now;
const endIsOpen = !item.gateClearedAt && !item.releaseDate;
@@ -321,6 +343,7 @@ export class WarehouseFeeService {
return {
ruleType,
basis: null,
ruleId: rule?.id ?? null,
ruleName: rule?.name ?? null,
freeDays,
@@ -340,13 +363,68 @@ export class WarehouseFeeService {
};
}
/**
* Double handling — a flat one-time charge, not time-based. Amount = rate ×
* the basis quantity: PER_CONTAINER (booking container count), or PER_TON /
* PER_ITEM (the booking cargo total in the cargo's unit of measure — tonnes
* for bulk, item count for break-bulk). No free days, no elapsed days, no tiers.
*/
private async computeDoubleHandling(
rule: WarehouseFeeRule | null,
item: ItemAttributes,
now: Date,
billingCurrency: string,
): Promise<FeePreview> {
const basis: FeeRuleBasis = rule?.basis ?? 'PER_CONTAINER';
const rate = Number(rule?.ratePerDay ?? 0);
const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null;
const targetCurrency = this.normalizeCurrency(billingCurrency);
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1));
const containerCount = isContainer
? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity))
: 1;
// PER_TON (tonnes) and PER_ITEM (piece count) both read the cargo total,
// which is stored in the cargo's own unit of measure.
const cargoQuantity = Math.max(0, Number(item.cargoQuantity) || 0);
// Double handling applies to IMPORT only — no charge for export/domestic.
const isImport = (item.tradeDirection ?? '').toUpperCase() === 'IMPORT';
const quantity = !isImport ? 0 : basis === 'PER_CONTAINER' ? containerCount : cargoQuantity;
const sourceAmount = Math.round(rate * quantity * 100) / 100;
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
const convertedRate = ruleCurrency ? await this.convertAmount(rate, ruleCurrency, targetCurrency) : 0;
return {
ruleType: 'DOUBLE_HANDLING_FEE',
basis,
ruleId: rule?.id ?? null,
ruleName: rule?.name ?? null,
freeDays: 0,
ratePerDay: convertedRate,
currency: targetCurrency,
ruleCurrency,
billingCurrency: targetCurrency,
startDate: null,
endDate: now.toISOString(),
endIsOpen: false,
elapsedDays: 0,
chargeableDays: 0,
containerCount,
billableUnits: quantity,
amount,
tiers: [],
};
}
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
const item = await this.loadItem(inventoryId);
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
const now = new Date();
const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE'];
// Truck detention is a per-truck last-mile charge, not a per-inventory fee —
// it is computed separately via previewTruckDetention(), not here.
const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE', 'DOUBLE_HANDLING_FEE'];
return Promise.all(
byType.map((type) =>
this.compute(
@@ -359,4 +437,197 @@ export class WarehouseFeeService {
),
);
}
/**
* Truck detention preview for an EDR last-mile leg. The vehicle should be
* returned within the rule's grace window (default 3h) of arriving; beyond
* that, detention accrues per truck per day (flat rate/day or progressive
* tiers by detention day) until it is delivered/returned (or now, if open).
*/
async previewTruckDetention(lastMileId: string, billingCurrency = 'USD'): Promise<FeePreview> {
const [leg] = await this.dataSource.query(
`SELECT lm.arrived_at AS "arrivedAt",
lm.delivered_at AS "deliveredAt",
b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection"
FROM freight.last_mile lm
LEFT JOIN freight.bookings b ON b.id = lm.booking_id
WHERE lm.id = $1 AND lm.deleted_at IS NULL`,
[lastMileId],
);
if (!leg) throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
// Truck detention applies to IMPORT only — no charge for export/domestic.
if ((leg.tradeDirection ?? '').toUpperCase() !== 'IMPORT') {
const cur = this.normalizeCurrency(billingCurrency);
return {
ruleType: 'TRUCK_DETENTION_FEE',
basis: null,
ruleId: null,
ruleName: null,
freeDays: 0,
ratePerDay: 0,
currency: cur,
ruleCurrency: null,
billingCurrency: cur,
startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null,
endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : new Date()).toISOString(),
endIsOpen: !leg.deliveredAt,
elapsedDays: 0,
chargeableDays: 0,
containerCount: 0,
billableUnits: 0,
amount: 0,
tiers: [],
groups: [],
};
}
// Group the leg's vehicles by type so each truck type is billed by its own
// matching rule (rates differ by truck type). Falls back to one untyped group.
const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> =
await this.dataSource.query(
`SELECT v.vehicle_type AS "vehicleType", count(*)::int AS "truckCount"
FROM freight.last_mile_vehicle_assignments va
JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL
WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL
GROUP BY v.vehicle_type`,
[lastMileId],
);
const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }];
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE');
const now = new Date();
const targetCurrency = this.normalizeCurrency(billingCurrency);
const computed = await Promise.all(
groups.map(async (g) => {
const item: ItemAttributes = {
arrivedAt: null,
gateClearedAt: null,
releaseDate: null,
freightType: leg.freightType ?? null,
tradeDirection: leg.tradeDirection ?? null,
cargoTypeCode: null,
containerTypeCode: null,
vehicleType: g.vehicleType ?? null,
inventoryQuantity: 1,
bookingContainerCount: 1,
cargoQuantity: 0,
facilityId: null,
warehouseId: null,
yardId: null,
zoneId: null,
};
const rule = this.bestRule(detentionRules, item);
const c = await this.computeTruckDetention(
rule,
{ arrivedAt: leg.arrivedAt, deliveredAt: leg.deliveredAt, truckCount: g.truckCount },
now,
billingCurrency,
);
return { vehicleType: g.vehicleType ?? null, truckCount: Math.max(1, Math.round(Number(g.truckCount) || 1)), c };
}),
);
const totalAmount = Math.round(computed.reduce((s, x) => s + x.c.amount, 0) * 100) / 100;
const totalTrucks = computed.reduce((s, x) => s + x.truckCount, 0);
const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0);
const chargeableDays = computed[0]?.c.chargeableDays ?? 0;
const single = computed.length === 1 ? computed[0].c : null;
const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? null;
return {
ruleType: 'TRUCK_DETENTION_FEE',
basis: null,
ruleId: single?.ruleId ?? null,
ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck-type rules' : anyRuleName,
freeDays: 0,
ratePerDay: single?.ratePerDay ?? 0,
currency: targetCurrency,
ruleCurrency: single?.ruleCurrency ?? null,
billingCurrency: targetCurrency,
startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null,
endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : now).toISOString(),
endIsOpen: !leg.deliveredAt,
elapsedDays: chargeableDays,
chargeableDays,
containerCount: totalTrucks,
billableUnits: totalBillable,
amount: totalAmount,
tiers: single ? single.tiers : [],
groups: computed.map((x) => ({
vehicleType: x.vehicleType,
truckCount: x.truckCount,
chargeableDays: x.c.chargeableDays,
ratePerDay: x.c.ratePerDay,
amount: x.c.amount,
ruleId: x.c.ruleId,
ruleName: x.c.ruleName,
})),
};
}
private async computeTruckDetention(
rule: WarehouseFeeRule | null,
row: { arrivedAt: Date | string | null; deliveredAt: Date | string | null; truckCount: number | string },
now: Date,
billingCurrency: string,
): Promise<FeePreview> {
const graceHours = rule?.freeHours && Number(rule.freeHours) > 0 ? Number(rule.freeHours) : 3;
const truckCount = Math.max(1, Math.round(Number(row.truckCount) || 1));
const start = row.arrivedAt ? new Date(row.arrivedAt) : null;
const end = row.deliveredAt ? new Date(row.deliveredAt) : now;
const endIsOpen = !row.deliveredAt;
let chargeableDays = 0;
if (start) {
const detentionMs = end.getTime() - start.getTime() - graceHours * 60 * 60 * 1000;
chargeableDays = detentionMs > 0 ? Math.ceil(detentionMs / MS_PER_DAY) : 0;
}
const ratePerDay = Number(rule?.ratePerDay ?? 0);
const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null;
const targetCurrency = this.normalizeCurrency(billingCurrency);
const hasTiers = Boolean(rule?.tiers?.length);
const tiered = this.calculateTieredAmount(rule?.tiers, chargeableDays, truckCount);
const billableUnits = hasTiers ? tiered.billableUnits : chargeableDays * truckCount;
const sourceAmount = hasTiers ? tiered.sourceAmount : Math.round(billableUnits * ratePerDay * 100) / 100;
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
const sourceRatePerDay = hasTiers ? tiered.weightedRatePerDay : ratePerDay;
const convertedRatePerDay = ruleCurrency
? await this.convertAmount(sourceRatePerDay, ruleCurrency, targetCurrency)
: 0;
const convertedTiers = ruleCurrency
? await Promise.all(
tiered.tiers.map(async (tier) => ({
...tier,
ratePerDay: await this.convertAmount(tier.ratePerDay, ruleCurrency, targetCurrency),
amount: await this.convertAmount(tier.amount, ruleCurrency, targetCurrency),
})),
)
: [];
return {
ruleType: 'TRUCK_DETENTION_FEE',
basis: null,
ruleId: rule?.id ?? null,
ruleName: rule?.name ?? null,
freeDays: 0,
ratePerDay: convertedRatePerDay,
currency: targetCurrency,
ruleCurrency,
billingCurrency: targetCurrency,
startDate: start ? start.toISOString() : null,
endDate: end.toISOString(),
endIsOpen,
elapsedDays: chargeableDays,
chargeableDays,
containerCount: truckCount, // reused as the per-truck count
billableUnits,
amount,
tiers: hasTiers ? convertedTiers : [],
};
}
}

View File

@@ -100,6 +100,27 @@ export class WarehouseInventoryController {
return this.inventoryService.loadedExport();
}
@Get('loadable-trains')
@ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' })
loadableTrains() {
return this.inventoryService.loadableTrains();
}
@Get('train/:scheduleId/loadable-items')
@ApiOperation({ summary: 'Container/cargo inventory assigned to a train, with allocated wagons' })
trainLoadableItems(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.inventoryService.trainLoadableItems(scheduleId);
}
@Post('train/:scheduleId/load')
@ApiOperation({ summary: 'Load selected inventory items onto their allocated wagons for a train' })
loadItemsOntoTrain(
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
@Body() dto: { inventoryIds: string[]; performedBy?: string },
) {
return this.inventoryService.loadItemsOntoTrain(scheduleId, dto.inventoryIds ?? [], dto.performedBy);
}
@Post('bulk-dispatch-export')
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) {
@@ -333,6 +354,12 @@ export class WarehouseInventoryController {
return this.handoverService.list(bookingId);
}
@Get('bookings/:bookingId/container-items')
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.containerItems(bookingId);
}
@Post(':id/deliver')
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {

View File

@@ -273,6 +273,45 @@ export interface BulkDispatchResult {
results: { inventoryId: string; status: string; reason?: string }[];
}
/** An EXPORT train (pre-dispatch schedule) that has inventory waiting to be loaded. */
export interface LoadableTrainRow {
scheduleId: string;
trainNumber: string | null;
origin: string | null;
destination: string | null;
status: string;
departureTime: string | Date | null;
/** Received/ready inventory not yet loaded onto this train. */
readyCount: number;
/** Inventory already loaded onto this train. */
loadedCount: number;
}
/** A warehouse-inventory item (container/cargo) assigned to a train, with its allocated wagon. */
export interface TrainLoadableItemRow {
id: string;
bookingId: string | null;
bookingReference: string | null;
customerName: string | null;
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
grnNumber: string | null;
inspectionStatus: string | null;
status: string;
wagonId: string | null;
wagonNumber: string | null;
sequenceNo: number | null;
/** True only when the item is READY_FOR_LOADING and has an allocated wagon. */
loadable: boolean;
}
export interface TrainLoadResult {
loadedCount: number;
skippedCount: number;
results: { inventoryId: string; status: string; reason?: string }[];
}
export interface AutoUnloadArrivedResult {
unloadedCount: number;
skippedCount: number;
@@ -1070,6 +1109,169 @@ export class WarehouseInventoryService {
return this.exportInventoryByStatus('LOADED');
}
// ── Per-train loading (Load to Train tab) ─────────────────────────────────
// Loading follows wagon allocation: staff pick an allocated EXPORT train, see
// the arrived containers/cargoes assigned to it, and load the ready ones onto
// their already-allocated wagons. Reuses the single-item load() machinery.
/** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */
async loadableTrains(): Promise<LoadableTrainRow[]> {
const rows: Array<
LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null }
> = await this.dataSource.query(
`SELECT ts.id AS "scheduleId",
ts.train_number AS "trainNumber",
oy.code AS "origin",
dy.code AS "destination",
oy.country AS "originCountry",
dy.country AS "destinationCountry",
ts.status AS "status",
ts.scheduled_departure_date AS "departureTime",
(SELECT count(*) FROM freight.train_schedule_bookings tsb
JOIN freight.warehouse_inventory inv
ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount",
(SELECT count(*) FROM freight.train_schedule_bookings tsb
JOIN freight.warehouse_inventory inv
ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
AND inv.status = 'LOADED') AS "loadedCount"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.deleted_at IS NULL
AND ts.status = ANY($1)
AND EXISTS (
SELECT 1 FROM freight.train_schedule_bookings tsb2
JOIN freight.warehouse_inventory inv2
ON inv2.booking_id = tsb2.booking_id AND inv2.deleted_at IS NULL
WHERE tsb2.train_schedule_id = ts.id AND tsb2.deleted_at IS NULL
AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
)
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
[['DRAFT', 'SCHEDULED']],
);
return rows
.filter(
(r) =>
deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'EXPORT',
)
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => ({
...rest,
readyCount: Number(rest.readyCount) || 0,
loadedCount: Number(rest.loadedCount) || 0,
}));
}
/**
* Container/cargo inventory items assigned to a train, with the wagon each is
* allocated to. Covers the arrived-but-not-loaded set (RECEIVED..READY_FOR_LOADING)
* plus already-LOADED items, so the "Received" and "Loaded" stage tabs both fill.
*/
async trainLoadableItems(scheduleId: string): Promise<TrainLoadableItemRow[]> {
const rows: Array<Omit<TrainLoadableItemRow, 'loadable'>> = await this.dataSource.query(
`SELECT inv.id AS "id",
inv.booking_id AS "bookingId",
b.reference AS "bookingReference",
company.name AS "customerName",
ct.container_number AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
inv.weight AS "weight",
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber",
inv.inspection_status AS "inspectionStatus",
inv.status AS "status",
wl.wagon_id AS "wagonId",
wl.wagon_number AS "wagonNumber",
wl.sequence_no AS "sequenceNo"
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
LEFT JOIN LATERAL (
SELECT w.id AS wagon_id, w.wagon_number, tsw.sequence_no
FROM freight.wagon_booking_allocations wba
JOIN freight.train_set_wagons tsw
ON tsw.id = wba.train_set_wagon_id
AND tsw.train_set_id = ts.train_set_id
AND tsw.deleted_at IS NULL
JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL
WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL
ORDER BY tsw.sequence_no ASC NULLS LAST
LIMIT 1
) wl ON true
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`,
[scheduleId],
);
return rows.map((r) => ({
...r,
loadable: r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId),
}));
}
/**
* Load the selected inventory items onto their allocated wagons for the given
* train. Each item must be assigned to this train, READY_FOR_LOADING, and have
* an allocated wagon; others are skipped with a reason. When every inventory
* item of a booking is loaded, its train_schedule_bookings.loading_status flips
* to LOADED so the train's confirm-loading/dispatch step reflects reality.
*/
async loadItemsOntoTrain(
scheduleId: string,
inventoryIds: string[],
performedBy?: string,
): Promise<TrainLoadResult> {
const result: TrainLoadResult = { loadedCount: 0, skippedCount: 0, results: [] };
const items = await this.trainLoadableItems(scheduleId);
const byId = new Map(items.map((i) => [i.id, i]));
const affectedBookingIds = new Set<string>();
for (const inventoryId of inventoryIds) {
const skip = (reason: string) => {
result.skippedCount += 1;
result.results.push({ inventoryId, status: 'SKIPPED', reason });
};
const item = byId.get(inventoryId);
if (!item) { skip('Not assigned to this train'); continue; }
if (item.status === 'LOADED') { skip('Already loaded'); continue; }
if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; }
if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; }
try {
await this.load(inventoryId, { wagonId: item.wagonId, loadedBy: performedBy });
result.loadedCount += 1;
result.results.push({ inventoryId, status: 'LOADED' });
if (item.bookingId) affectedBookingIds.add(item.bookingId);
} catch (error) {
skip(error instanceof Error ? error.message : 'Load failed');
}
}
// Flip a booking's train loading_status to LOADED once no un-loaded inventory remains.
for (const bookingId of affectedBookingIds) {
await this.dataSource.query(
`UPDATE freight.train_schedule_bookings tsb
SET loading_status = 'LOADED', updated_at = NOW()
WHERE tsb.train_schedule_id = $1 AND tsb.booking_id = $2 AND tsb.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM freight.warehouse_inventory inv
WHERE inv.booking_id = $2 AND inv.deleted_at IS NULL
AND inv.status NOT IN ('LOADED', 'DISPATCHED')
)`,
[scheduleId, bookingId],
);
}
return result;
}
/** Shared query for the import queues — IMPORT inventory at the given statuses, inspection columns. */
private async importQueueByStatuses(statuses: string[]): Promise<ImportUnloadedRow[]> {
const rows: Array<
@@ -2308,6 +2510,95 @@ export class WarehouseInventoryService {
};
}
/**
* Per-container (or bulk) items of a booking with their lifecycle stage and
* reference sources — drives the container-level detail datatable (stage tabs,
* multiselect load-to-truck, per-item actions).
*/
async containerItems(bookingId: string): Promise<
Array<{
containerNumber: string;
goods: string | null;
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
grnNumber: string | null;
truckAssignmentId: string | null;
truckPlate: string | null;
truckArrived: boolean;
truckLeft: boolean;
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;
}>
> {
const rows: Array<{
containerNumber: string;
goods: string | null;
received: boolean;
grnNumber: string | null;
truckAssignmentId: string | null;
truckPlate: string | null;
truckArrived: boolean;
truckLeft: boolean;
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;
delivered: boolean;
}> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods,
bcu.received_to_port AS received,
bcu.grn_number AS "grnNumber",
ctc.assignment_id AS "truckAssignmentId",
a.plate_number AS "truckPlate",
(a.arrived_at IS NOT NULL) AS "truckArrived",
(a.departed_at IS NOT NULL) AS "truckLeft",
b.reference AS "bookingReference",
b.contract_id AS "contractId",
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
COALESCE(inv.status = 'DELIVERED', false) AS delivered
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
JOIN freight.bookings b ON b.id = bc.booking_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
LEFT JOIN freight.customer_truck_containers ctc
ON ctc.container_number = bcu.container_number
AND ctc.booking_id = b.id AND ctc.deleted_at IS NULL
LEFT JOIN freight.customer_truck_assignments a
ON a.id = ctc.assignment_id AND a.deleted_at IS NULL
LEFT JOIN freight.containers cont ON cont.container_number = bcu.container_number
LEFT JOIN freight.warehouse_inventory inv
ON inv.container_id = cont.id AND inv.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
ORDER BY bcu.container_number`,
[bookingId],
);
return rows.map((r) => ({
containerNumber: r.containerNumber,
goods: r.goods,
stage: r.delivered
? 'DELIVERED'
: r.truckLeft
? 'LEFT'
: r.truckAssignmentId
? 'LOADED'
: r.grnNumber
? 'GRN'
: r.received
? 'RECEIVED'
: 'PENDING',
grnNumber: r.grnNumber,
truckAssignmentId: r.truckAssignmentId,
truckPlate: r.truckPlate,
truckArrived: r.truckArrived,
truckLeft: r.truckLeft,
bookingReference: r.bookingReference,
contractId: r.contractId,
hasLastMile: r.hasLastMile,
}));
}
/**
* Per-truck exit paper: one paper covering the containers loaded on a specific
* customer truck (used when multiple trucks leave separately). Gated on the

View File

@@ -18,6 +18,15 @@ export class WarehouseInvoiceController {
return this.invoiceService.generateForInventory(id, dto);
}
@Post('last-mile/:id/generate-truck-detention-invoice')
@ApiOperation({ summary: 'Generate a truck-detention invoice for a last-mile leg (per truck per day)' })
generateTruckDetention(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: GenerateInvoiceDto,
) {
return this.invoiceService.generateTruckDetentionInvoice(id, dto);
}
@Get('warehouse-inventory/:id/fee-invoices')
@ApiOperation({ summary: 'List fee invoices for an inventory item' })
listForInventory(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -182,25 +182,38 @@ export class WarehouseInvoiceService {
const items = previews
.filter((p) => p.amount > 0)
.map((p) => {
const feeType: WarehouseFeeType =
p.ruleType === "STORAGE_FEE"
? "STORAGE_FEE"
: isContainer
? "CONTAINER_DEMURRAGE"
: "BULK_DEMURRAGE";
const days = `${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)`;
const tierSuffix = p.tiers.length ? " using tiered tariff" : ` after ${p.freeDays} free`;
let feeType: WarehouseFeeType;
let description: string;
switch (p.ruleType) {
case "STORAGE_FEE":
feeType = "STORAGE_FEE";
description = `Storage fee - ${days}${tierSuffix}`;
break;
case "DOUBLE_HANDLING_FEE": {
feeType = "DOUBLE_HANDLING";
const unit =
p.basis === "PER_TON"
? "ton(s)"
: p.basis === "PER_ITEM"
? "item(s)"
: "container(s)";
description = `Double handling - ${p.billableUnits} ${unit}`;
break;
}
case "TRUCK_DETENTION_FEE":
feeType = "TRUCK_DETENTION";
description = `Truck detention - ${days}${tierSuffix}`;
break;
default:
feeType = isContainer ? "CONTAINER_DEMURRAGE" : "BULK_DEMURRAGE";
description = `${isContainer ? "Container" : "Bulk"} demurrage - ${days}${tierSuffix}`;
}
return {
feeRuleId: p.ruleId,
feeType,
description:
p.ruleType === "STORAGE_FEE"
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length
? " using tiered tariff"
: ` after ${p.freeDays} free`
}`
: `${isContainer ? "Container" : "Bulk"} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length
? " using tiered tariff"
: ` after ${p.freeDays} free`
}`,
description,
quantity: p.billableUnits,
unitRate: p.ratePerDay,
amount: p.amount,
@@ -256,6 +269,104 @@ export class WarehouseInvoiceService {
return detail;
}
/**
* Generate a truck-detention invoice for a last-mile leg. Unlike warehouse fees
* (per inventory item), detention is a per-truck charge on the last-mile leg, so
* it becomes a `last_mile` invoice with its own `TRUCK_DETENTION_FEE` type — kept
* separate from the delivery-fee invoice. Returns the global Invoice.
*/
async generateTruckDetentionInvoice(
lastMileId: string,
opts: { billingCurrency?: "ETB" | "USD"; confirmZero?: boolean } = {},
): Promise<Invoice> {
const [lm] = await this.dataSource.query(
`SELECT lm.id,
b.company_id AS "companyId",
b.company_profile_id AS "companyProfileId",
b.payment_currency AS "paymentCurrency"
FROM freight.last_mile lm
LEFT JOIN freight.bookings b ON b.id = lm.booking_id
WHERE lm.id = $1 AND lm.deleted_at IS NULL`,
[lastMileId],
);
if (!lm) throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
if (!lm.companyId) {
throw new BadRequestException(
"Cannot invoice truck detention: the last-mile leg has no billable company (no associated booking).",
);
}
const existing = await this.billing.findPayable(
"last_mile" as Freight.InvoiceSource,
lastMileId,
"TRUCK_DETENTION_FEE",
);
if (existing) {
throw new ConflictException(
"An active truck detention invoice already exists for this last-mile leg. Cancel it before generating a new one.",
);
}
const billingCurrency: "ETB" | "USD" =
opts.billingCurrency ?? (lm.paymentCurrency === "ETB" ? "ETB" : "USD");
const preview = await this.feeService.previewTruckDetention(lastMileId, billingCurrency);
if (preview.amount <= 0 && !opts.confirmZero) {
throw new BadRequestException(
"No truck detention is currently payable for this last-mile leg.",
);
}
// One line per truck-type group (each billed by its own matching rule). Groups
// with no matching rule bill 0 and are dropped. Falls back to a single line.
const groups = preview.groups && preview.groups.length ? preview.groups : null;
const lines: InvoiceLineInput[] = groups
? groups
.filter((g) => g.amount > 0)
.map((g) => ({
chargeType: "TRUCK_DETENTION",
description: `Truck detention${g.vehicleType ? ` (${g.vehicleType})` : ""} - ${g.chargeableDays} day(s) x ${g.truckCount} truck(s)`,
quantity: g.truckCount * g.chargeableDays,
unitRate: g.ratePerDay,
amount: g.amount,
currency: preview.currency,
metadata: {
feeRuleId: g.ruleId ?? null,
chargeableDays: g.chargeableDays,
vehicleType: g.vehicleType ?? null,
},
}))
: [
{
chargeType: "TRUCK_DETENTION",
description: `Truck detention - ${preview.chargeableDays} day(s) x ${preview.containerCount} truck(s)`,
quantity: preview.billableUnits,
unitRate: preview.ratePerDay,
amount: preview.amount,
currency: preview.currency,
metadata: {
feeRuleId: preview.ruleId ?? null,
chargeableDays: preview.chargeableDays ?? null,
},
},
];
if (lines.length === 0) {
throw new BadRequestException(
"No truck detention is currently payable for this last-mile leg.",
);
}
return this.billing.generateInvoice({
source: "last_mile" as Freight.InvoiceSource,
sourceId: lastMileId,
type: "TRUCK_DETENTION_FEE",
companyId: lm.companyId,
companyProfileId: lm.companyProfileId || "",
currency: billingCurrency,
lines,
status: Freight.InvoiceStatus.Issued,
});
}
// ── Reads ────────────────────────────────────────────────────────────────
async findById(id: string): Promise<WarehouseFeeInvoiceDetail> {
const invoice = await this.loadWarehouseInvoice(id);

View File

@@ -26,6 +26,8 @@ export const WAREHOUSE_FEE_TYPES = [
'BULK_DEMURRAGE',
'STORAGE_FEE',
'HANDLING_FEE',
'DOUBLE_HANDLING',
'TRUCK_DETENTION',
] as const;
export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number];

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { PdfRenderService } from '../billing/documents/pdf-render.service';
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
const MIN_VALID_PDF_BYTES = 2_000;
@@ -32,6 +33,19 @@ export class WarehouseReleaseDocumentService {
return this.pdf.htmlToPdfBuffer(html, { label });
}
/**
* Render a "summary tiles + one table + notice + signatures" document (the
* marshalling / load-list layout) with a STYLED table-aware fallback for when
* Chromium is unavailable — so the manifest draws as a real gridded document
* instead of a flat plain-text dump.
*/
renderTabularDocument(html: string, label = 'Document'): Promise<Buffer> {
return this.pdf.htmlToPdfBuffer(html, {
label,
fallback: (preparedHtml) => buildTabularFallbackPdf(preparedHtml),
});
}
/**
* Render document HTML with a STYLED hand-built fallback (the release layout,
* but with a custom title + section heading) for when Chromium is unavailable.

View File

@@ -85,4 +85,13 @@ export class WarehouseRulesController {
) {
return this.feeService.previewForInventory(id, billingCurrency);
}
@Get('last-mile/:id/truck-detention-preview')
@ApiOperation({ summary: 'Preview truck detention for a last-mile leg (per truck per day after grace)' })
truckDetentionPreview(
@Param('id', ParseUUIDPipe) id: string,
@Query('billingCurrency') billingCurrency?: string,
) {
return this.feeService.previewTruckDetention(id, billingCurrency);
}
}