fix conflict

This commit is contained in:
yaschalew
2026-07-02 09:28:02 +03:00
183 changed files with 10335 additions and 5286 deletions

View File

@@ -1,5 +1,6 @@
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import { FreightAdmin } from "../../common/booking-guards";
import { BillingService } from "./billing.service";
@@ -21,4 +22,26 @@ export class BillingController {
findById(@Param("id", ParseUUIDPipe) id: string) {
return this.billingService.findById(id);
}
@Get("invoices/:id/document")
@ApiOperation({ summary: "Download the sealed invoice PDF" })
async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.billingService.document(id);
sendPdf(res, filename, buffer);
}
@Get("invoices/:id/receipt")
@ApiOperation({ summary: "Download the sealed payment receipt PDF" })
async receipt(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.billingService.receipt(id);
sendPdf(res, filename, buffer);
}
}
/** Stream a generated PDF as a file download. */
export function sendPdf(res: Response, filename: string, buffer: Buffer): void {
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.setHeader("Content-Length", buffer.length);
res.send(buffer);
}

View File

@@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { BillingController } from "./billing.controller";
import { PortalBillingController } from "./portal-billing.controller";
import { BillingService } from "./billing.service";
import { DocumentsModule } from "./documents/documents.module";
import { Invoice } from "./entities/invoice.entity";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { InvoiceRepository } from "./invoice.repository";
@@ -16,6 +17,7 @@ import { CompaniesModule } from "../companies/companies.module";
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
forwardRef(() => PaymentModule),
CompaniesModule,
DocumentsModule,
],
controllers: [BillingController, PortalBillingController],
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],

View File

@@ -76,6 +76,7 @@ describe("BillingService.generateInvoice", () => {
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
});
@@ -88,7 +89,7 @@ describe("BillingService.generateInvoice", () => {
expect(invoice.sourceId).toBe("booking-1");
expect(invoice.totalAmount).toBe(1500);
expect(invoice.issuedAt).toBeInstanceOf(Date);
expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/);
expect(invoice.invoiceNumber).toMatch(/^INV-\d{8}-00001$/);
expect(savedLines).toHaveLength(2);
});
@@ -134,6 +135,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -171,6 +173,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -180,6 +183,89 @@ describe("BillingService.markInvoiceAsPaid", () => {
});
});
describe("BillingService.recordPayment", () => {
function serviceFor(invoice: Record<string, unknown> | null) {
const mg = {
findOne: jest.fn().mockResolvedValue(invoice),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
return { service, mg, events };
}
const openInvoice = (overrides: Record<string, unknown> = {}) => ({
id: "inv-1",
status: Freight.InvoiceStatus.Issued,
source: "warehouse",
sourceId: "inv-item-1",
totalAmount: 1000,
paidAmount: 0,
balanceAmount: 1000,
payments: [],
paidAt: null,
...overrides,
});
it("moves to PARTIALLY_PAID and emits no event on a partial payment", async () => {
const { service, mg, events } = serviceFor(openInvoice());
const updated = await service.recordPayment("inv-1", { amount: 400, method: "CASH" });
expect(updated.status).toBe(Freight.InvoiceStatus.PartiallyPaid);
expect(updated.paidAmount).toBe(400);
expect(updated.balanceAmount).toBe(600);
expect(updated.payments).toHaveLength(1);
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
expect.objectContaining({
status: Freight.InvoiceStatus.PartiallyPaid,
paidAmount: 400,
balanceAmount: 600,
}),
);
expect(events.emit).not.toHaveBeenCalled();
});
it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => {
const { service, mg, events } = serviceFor(openInvoice({ paidAmount: 400, balanceAmount: 600 }));
const updated = await service.recordPayment("inv-1", { amount: 600 });
expect(updated.status).toBe(Freight.InvoiceStatus.Paid);
expect(updated.balanceAmount).toBe(0);
expect(updated.paidAt).toBeInstanceOf(Date);
expect(mg.update).toHaveBeenCalled();
expect(events.emit).toHaveBeenCalledWith(
"warehouse.invoice.paid",
expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }),
);
});
it("rejects a non-positive amount", async () => {
const { service, mg } = serviceFor(openInvoice());
await expect(service.recordPayment("inv-1", { amount: 0 })).rejects.toThrow();
expect(mg.update).not.toHaveBeenCalled();
});
it("rejects payment against a cancelled invoice", async () => {
const { service, mg } = serviceFor(
openInvoice({ status: Freight.InvoiceStatus.Cancelled }),
);
await expect(service.recordPayment("inv-1", { amount: 100 })).rejects.toThrow();
expect(mg.update).not.toHaveBeenCalled();
});
});
describe("BillingService.settlePayable", () => {
it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => {
const open = {
@@ -200,6 +286,7 @@ describe("BillingService.settlePayable", () => {
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
const settled = await service.settlePayable(
@@ -234,6 +321,7 @@ describe("BillingService.settlePayable", () => {
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
const settled = await service.settlePayable(

View File

@@ -1,15 +1,28 @@
import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { Freight, PaymentReferenceType } from "@edr/types";
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { DataSource, EntityManager, In } from "typeorm";
import { Invoice } from "./entities/invoice.entity";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { InvoiceRepository } from "./invoice.repository";
import { InvoiceLineRepository } from "./invoice-line.repository";
import { CompaniesService } from "../companies/companies.service";
import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto } from "../payment/payments.dto";
import { CompaniesService } from "../companies/companies.service";
import {
InvoiceDocumentModel,
InvoiceDocumentService,
} from "./documents/invoice-document.service";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
import { InvoiceLineRepository } from "./invoice-line.repository";
import { nextDailyInvoiceNumber } from "./invoice-numbering.util";
import { applySettlement, round2 } from "./invoice-settlement.util";
import { InvoiceRepository } from "./invoice.repository";
/** Options forwarded to the payment gateway when settling an invoice. */
export interface PayInvoiceOptions {
@@ -20,13 +33,26 @@ export interface PayInvoiceOptions {
failureUrl?: string;
}
/** A single manual/offline settlement to record against an invoice. */
export interface RecordPaymentInput {
/** Amount settled by this payment; must be > 0. */
amount: number;
method?: string | null;
reference?: string | null;
/** When the settlement occurred; defaults to now. */
paidAt?: Date;
metadata?: Record<string, unknown> | null;
}
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
const DEFAULT_DUE_DAYS = 14;
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Draft,
Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.PartiallyPaid,
Freight.InvoiceStatus.Overdue,
];
@@ -56,7 +82,11 @@ export interface GenerateInvoiceInput {
companyProfileId: string;
lines: InvoiceLineInput[];
currency?: string;
/** Explicit total; defaults to the sum of line amounts. */
/** Explicit pre-tax subtotal; defaults to the sum of line amounts. */
subtotalAmount?: number;
/** Tax applied on top of the subtotal; defaults to 0. */
taxAmount?: number;
/** Explicit total; defaults to `subtotalAmount + taxAmount`. */
totalAmount?: number;
/** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */
dueAt?: Date;
@@ -95,6 +125,7 @@ export class BillingService {
@Inject(forwardRef(() => PaymentService))
private readonly payment: PaymentService,
private readonly companies: CompaniesService,
private readonly invoiceDocuments: InvoiceDocumentService,
) { }
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -115,6 +146,89 @@ export class BillingService {
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
}
// ── Documents (central PDF) ──────────────────────────────────────────────────
/** Sealed PDF invoice for any source, rendered by the shared document service. */
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
return this.invoiceDocuments.render(
this.toDocumentModel(invoice, "INVOICE"),
);
}
/** Sealed PDF receipt; available once any payment has been recorded. */
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
if (Number(invoice.paidAmount) <= 0) {
throw new BadRequestException(
"A receipt is available only after payment is recorded.",
);
}
return this.invoiceDocuments.render(
this.toDocumentModel(invoice, "RECEIPT"),
);
}
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
private toDocumentModel(
invoice: Invoice & { lines: InvoiceLine[] },
kind: "INVOICE" | "RECEIPT",
): InvoiceDocumentModel {
const title = invoice.source
? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1)
: "EDR";
const totals: InvoiceDocumentModel["totals"] = [
{ label: "Subtotal", amount: Number(invoice.subtotalAmount) },
];
if (Number(invoice.taxAmount) > 0) {
totals.push({ label: "Tax", amount: Number(invoice.taxAmount) });
}
totals.push({
label: "Total",
amount: Number(invoice.totalAmount),
grand: true,
});
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
return {
kind,
title,
documentNumber: invoice.invoiceNumber,
issuedAt: invoice.issuedAt ?? invoice.createdAt,
status: invoice.status,
currency: invoice.currency,
summary: [
{ label: "Status", value: invoice.status },
{ label: "Type", value: invoice.type },
{ label: "Reference", value: invoice.sourceId },
{ label: "Currency", value: invoice.currency },
{
label: "Issued",
value: invoice.issuedAt
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
: null,
},
{
label: "Due",
value: invoice.dueAt
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
: null,
},
],
categoryHeader: "Charge type",
lines: invoice.lines.map((l) => ({
description: l.description ?? l.chargeType,
category: l.chargeType,
quantity: l.quantity,
unitRate: l.unitRate,
amount: l.amount,
currency: l.currency,
})),
totals,
};
}
// ── Customer-scoped reads (portal) ───────────────────────────────────────────
/** Resolve the customer's company id from their IAM user id (null if none). */
@@ -127,19 +241,33 @@ export class BillingService {
}
}
/** Every invoice billed to a company, newest first, with billing relations. */
findByCompany(companyId: string): Promise<Invoice[]> {
/**
* Every invoice billed to a company, newest first, with billing relations.
* Optionally narrow to a single source record (e.g. a booking's invoices) via
* `{ source, sourceId }`.
*/
findByCompany(
companyId: string,
filter: { source?: string; sourceId?: string } = {},
): Promise<Invoice[]> {
return this.invoices.findAll({
where: { companyId },
where: {
companyId,
...(filter.source ? { source: filter.source } : {}),
...(filter.sourceId ? { sourceId: filter.sourceId } : {}),
},
relations: { company: true, companyProfile: true },
order: { createdAt: "DESC" },
});
}
/** Invoices for the signed-in customer; empty when they have no company. */
async findForUser(userId: string): Promise<Invoice[]> {
async findForUser(
userId: string,
filter: { source?: string; sourceId?: string } = {},
): Promise<Invoice[]> {
const companyId = await this.resolveCompanyId(userId);
return companyId ? this.findByCompany(companyId) : [];
return companyId ? this.findByCompany(companyId, filter) : [];
}
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
@@ -173,20 +301,32 @@ export class BillingService {
);
}
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
async documentForUser(
id: string,
userId: string,
): Promise<{ filename: string; buffer: Buffer }> {
await this.findByIdForUser(id, userId);
return this.document(id);
}
/** Sealed receipt PDF for one of the customer's own invoices (ownership-checked). */
async receiptForUser(
id: string,
userId: string,
): Promise<{ filename: string; buffer: Buffer }> {
await this.findByIdForUser(id, userId);
return this.receipt(id);
}
// ── Generation ───────────────────────────────────────────────────────────────
/** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */
private async nextInvoiceNumber(mg: EntityManager): Promise<string> {
const now = new Date();
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
const prefix = `FRT-${ymd}-`;
const [row] = await mg.query(
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
FROM freight.invoices WHERE invoice_number LIKE $1`,
[`${prefix}%`],
);
const next = Number(row?.seq ?? 0) + 1;
return `${prefix}${String(next).padStart(5, "0")}`;
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
return nextDailyInvoiceNumber(mg, {
table: "freight.invoices",
code: "INV",
});
}
/**
@@ -230,8 +370,11 @@ export class BillingService {
};
});
const totalAmount =
input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0);
const subtotalAmount =
input.subtotalAmount ??
lines.reduce((sum, l) => sum + Number(l.amount), 0);
const taxAmount = input.taxAmount ?? 0;
const totalAmount = input.totalAmount ?? round2(subtotalAmount + taxAmount);
const dueAt =
input.dueAt ??
@@ -250,7 +393,12 @@ export class BillingService {
type: input.type,
companyId: input.companyId,
companyProfileId: input.companyProfileId,
totalAmount,
subtotalAmount: round2(subtotalAmount),
taxAmount: round2(taxAmount),
totalAmount: round2(totalAmount),
paidAmount: 0,
balanceAmount: round2(totalAmount),
payments: [],
currency,
status,
issuedAt: issued ? new Date() : null,
@@ -293,6 +441,83 @@ export class BillingService {
);
}
/**
* Record a (possibly partial) settlement against an invoice and sync its
* status. Appends to the `payments` ledger, recomputes `paidAmount` /
* `balanceAmount`, and moves the invoice to PARTIALLY_PAID or — once the
* balance reaches zero — PAID, stamping `paidAt` and emitting
* `${source}.invoice.paid`. Use this for manual/offline settlement (e.g. cash
* at the warehouse counter); gateway settlement goes through
* {@link markInvoiceAsPaid}.
*
* Throws when the invoice is missing, cancelled, refunded, already fully paid,
* or when `amount` is not positive. Pass `manager` to enlist in a caller's
* transaction.
*/
async recordPayment(
invoiceId: string,
input: RecordPaymentInput,
manager?: EntityManager,
): Promise<Invoice> {
if (!(input.amount > 0)) {
throw new BadRequestException(
"Payment amount must be greater than zero.",
);
}
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } });
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
if (invoice.status === Freight.InvoiceStatus.Cancelled) {
throw new BadRequestException("Cannot pay a cancelled invoice.");
}
if (invoice.status === Freight.InvoiceStatus.Refunded) {
throw new BadRequestException("Cannot pay a refunded invoice.");
}
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException("Invoice is already fully paid.");
}
const at = input.paidAt ?? new Date();
const { paidAmount, balanceAmount, fullyPaid } = applySettlement(
invoice.totalAmount,
invoice.paidAmount,
input.amount,
);
const status = fullyPaid
? Freight.InvoiceStatus.Paid
: Freight.InvoiceStatus.PartiallyPaid;
const entry: InvoicePayment = {
amount: round2(input.amount),
method: input.method ?? null,
reference: input.reference ?? null,
paidAt: at.toISOString(),
metadata: input.metadata ?? null,
};
const payments = [...(invoice.payments ?? []), entry];
await mg.update(Invoice, { id: invoice.id }, {
paidAmount,
balanceAmount,
status,
payments,
paidAt: fullyPaid ? at : (invoice.paidAt ?? null),
} as never);
const updated = {
...invoice,
paidAmount,
balanceAmount,
status,
payments,
paidAt: fullyPaid ? at : (invoice.paidAt ?? null),
} as Invoice;
if (fullyPaid) this.emitInvoiceEvent("paid", updated);
return updated;
}
/**
* Mark an invoice refunded and emit `${source}.invoice.refunded`.
* No-op when already refunded.
@@ -455,6 +680,72 @@ export class BillingService {
return this.markInvoiceAsRefunded(invoice.id, mg);
}
/**
* Expire a source's currently-open invoice (its pay window closed before
* settlement), then emit `${source}.invoice.expired`. Resolves the open invoice
* and transitions it to EXPIRED — a terminal, non-payable status (kept out of
* `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice
* (already paid/cancelled/expired).
*
* Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in
* the batch engine) to enlist in its DB transaction.
*/
async expirePayable(
source: Freight.InvoiceSource,
sourceId: string,
manager?: EntityManager,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: In(OPEN_STATUSES) },
order: { issuedAt: "DESC" },
});
if (!invoice) return null;
return this.transition(
invoice.id,
Freight.InvoiceStatus.Expired,
"expired",
{},
mg,
);
}
/**
* Sync a source's open invoice `dueAt` to its real pay-window deadline. The
* booking invoice is generated before the pay window opens (at booking
* creation/approval), so its printed due date is refreshed when the batch engine
* sets `paymentDeadline`. No-op when the source has no open invoice.
*/
async syncPayableDueDate(
source: Freight.InvoiceSource,
sourceId: string,
dueAt: Date,
manager?: EntityManager,
): Promise<void> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: In(OPEN_STATUSES) },
order: { issuedAt: "DESC" },
});
if (!invoice) return;
await mg.update(Invoice, { id: invoice.id }, { dueAt });
}
async updateStatus(
invoiceId: string,
status: Freight.InvoiceStatus,
manager?: EntityManager,
): Promise<void> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { id: invoiceId, status: In(OPEN_STATUSES) },
order: { issuedAt: "DESC" },
});
if (!invoice) return;
await mg.update(Invoice, { id: invoice.id }, { status });
}
// ── Payment initiation & settlement (the gateway boundary) ───────────────────
/**
@@ -481,17 +772,20 @@ export class BillingService {
): Promise<InitiateResponseDto> {
const invoice = await this.findPayable(source, sourceId);
if (!invoice) {
throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`);
throw new NotFoundException(
`No open invoice to charge for ${source}:${sourceId}`,
);
}
const result = await this.payment.initiate({
referenceId: sourceId,
source: invoice.source,
// Gateway reference type derives from the invoice source by convention
// (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and
// the domain never supplies it. New sources add their uppercased value to
// the PaymentReferenceType enum.
referenceType: invoice.source.toUpperCase() as PaymentReferenceType,
// Freight payments settle under the generic SHIPMENT reference — how the
// payment service attributes them to the freight API. The payment ↔ invoice
// link is the intent id (`paymentId`); per-source post-payment reactions live
// in the domain via `${source}.invoice.paid`. Neither billing nor the payment
// service branches on a domain-specific reference type.
referenceType: PaymentReferenceType.SHIPMENT,
orderRef: invoice.invoiceNumber,
amountMinor: Math.round(Number(invoice.totalAmount)),
currency: invoice.currency,

View File

@@ -0,0 +1,16 @@
import { Module } from "@nestjs/common";
import { InvoiceDocumentService } from "./invoice-document.service";
import { PdfRenderService } from "./pdf-render.service";
/**
* Standalone document infrastructure — generic HTML→PDF plus the shared
* invoice/receipt renderer. Has no domain dependencies, so any module (billing,
* warehouses, …) can import it to print invoices without coupling to the
* billing payment graph.
*/
@Module({
providers: [PdfRenderService, InvoiceDocumentService],
exports: [PdfRenderService, InvoiceDocumentService],
})
export class DocumentsModule {}

View File

@@ -0,0 +1,179 @@
import { Injectable } from "@nestjs/common";
import { PdfRenderService } from "./pdf-render.service";
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
/** One billed line on the document (charge type / fee type agnostic). */
export interface InvoiceDocumentLine {
description: string | null;
/** Optional categorisation column (e.g. "Fee type" / "Charge type"). */
category?: string | null;
quantity?: number | null;
unitRate?: number | null;
amount?: number | null;
currency?: string | null;
}
/** A labelled total row in the totals box; mark `grand` for the headline total. */
export interface InvoiceDocumentTotal {
label: string;
amount: number;
grand?: boolean;
}
/**
* Source-agnostic description of a printable invoice/receipt. Each billing
* source maps its own entity onto this shape; the renderer owns the layout so
* every EDR invoice document looks identical regardless of source.
*/
export interface InvoiceDocumentModel {
kind: InvoiceDocumentKind;
/** Document heading, e.g. "Warehouse Fee Invoice" / "Freight Invoice". */
title: string;
documentNumber: string;
issuedAt?: Date | string | null;
status: string;
currency: string;
/** Free-form summary grid (label/value pairs). */
summary: Array<{ label: string; value: string | null }>;
/** Header for the line-item category column; column hidden when omitted. */
categoryHeader?: string;
lines: InvoiceDocumentLine[];
totals: InvoiceDocumentTotal[];
/** Override the round seal text; defaults from kind/status. */
sealText?: string;
}
/**
* Central invoice/receipt PDF renderer shared by every billing source. Turns a
* {@link InvoiceDocumentModel} into the sealed EDR document HTML and renders it
* via {@link PdfRenderService}. Previously this layout lived (warehouse-only) in
* `WarehouseInvoiceService`; it now serves all invoices.
*/
@Injectable()
export class InvoiceDocumentService {
constructor(private readonly pdf: PdfRenderService) {}
async render(
model: InvoiceDocumentModel,
): Promise<{ filename: string; buffer: Buffer }> {
const html = this.buildHtml(model);
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
return {
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
buffer: await this.pdf.htmlToPdfBuffer(html, { label: `${model.title} ${kindLabel}` }),
};
}
buildHtml(model: InvoiceDocumentModel): string {
const esc = (value: unknown) =>
String(value ?? "-")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const money = (amount: unknown, currency = model.currency) =>
`${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
const date = (value: unknown) =>
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
const showCategory = Boolean(model.categoryHeader);
const sealText =
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
const summaryRows = model.summary
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
.join("");
const itemRows = model.lines
.map(
(item) => `<tr>
<td>${esc(item.description)}</td>
${showCategory ? `<td>${esc((item.category ?? "").replace(/_/g, " "))}</td>` : ""}
<td class="num">${esc(item.quantity ?? 0)}</td>
<td class="num">${esc(money(item.unitRate, item.currency ?? model.currency))}</td>
<td class="num">${esc(money(item.amount, item.currency ?? model.currency))}</td>
</tr>`,
)
.join("");
const totalRows = model.totals
.map(
(total) =>
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount))}</strong></div>`,
)
.join("");
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</title>
<style>
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
.doc { padding: 16px 8px; position: relative; }
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 16px; }
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
h1 { margin: 8px 0 0; font-size: 30px; }
.meta { text-align: right; font-size: 12px; color: #475569; }
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
th { text-align: left; background: #f8fafc; color: #475569; }
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; font-size: 12px; }
td.num, th.num { text-align: right; }
.totals { margin-left: auto; width: 330px; margin-top: 18px; }
.total-row { display: flex; justify-content: space-between; border-bottom: 1px solid #e2e8f0; padding: 8px 0; font-size: 13px; }
.grand { font-size: 16px; font-weight: 800; }
.footer { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
</style>
</head>
<body>
<div class="doc">
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</h1>
</div>
<div class="meta">
Document no.
<strong>${esc(model.documentNumber)}</strong>
Issued: ${esc(date(model.issuedAt))}
</div>
</div>
<div class="seal">${esc(sealText)}</div>
<div class="summary">${summaryRows}</div>
<table>
<thead>
<tr>
<th>Description</th>
${showCategory ? `<th>${esc(model.categoryHeader)}</th>` : ""}
<th class="num">Qty</th>
<th class="num">Rate</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
${itemRows}
</tbody>
</table>
<div class="totals">${totalRows}</div>
<div class="footer">
<div class="line">Prepared by EDR finance</div>
<div class="line">Authorized seal / signature</div>
</div>
</div>
</body>
</html>`;
}
safeFilename(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]+/g, "-");
}
}

View File

@@ -0,0 +1,160 @@
import { existsSync } from "fs";
import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
const MIN_VALID_PDF_BYTES = 2_000;
const PDF_PRINT_STYLES = `
<style id="edr-pdf-print-fix">
@media print {
html, body {
background: #fff !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
</style>`;
export interface PdfRenderOptions {
/** Label used in logs to identify the document kind. */
label?: string;
/**
* Degraded renderer used when Chromium is unavailable. Receives the
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
* header). When omitted, a generic single-page fallback is produced.
*/
fallback?: (preparedHtml: string) => Buffer;
}
/**
* Generic HTML → PDF renderer shared by every document producer (invoices,
* receipts, warehouse release orders). Renders via headless Chromium when
* available and degrades to a caller-supplied (or generic) hand-built PDF
* otherwise. This is pure infrastructure — it knows nothing about invoices.
*/
@Injectable()
export class PdfRenderService {
private readonly logger = new Logger(PdfRenderService.name);
async htmlToPdfBuffer(html: string, opts: PdfRenderOptions = {}): Promise<Buffer> {
const label = opts.label ?? "document";
const preparedHtml = this.injectPdfPrintStyles(html);
const executablePath = this.resolveExecutablePath();
try {
const puppeteer = await import("puppeteer");
const launchOptions: import("puppeteer").LaunchOptions = {
headless: true,
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
...(executablePath ? { executablePath } : {}),
};
const browser = await puppeteer.default.launch(launchOptions);
try {
const page = await browser.newPage();
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
await page.emulateMediaType("print");
await new Promise((resolve) => setTimeout(resolve, 250));
const pdf = await page.pdf({
format: "A4",
printBackground: true,
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
});
const buffer = Buffer.from(pdf);
if (!this.isValidPdf(buffer)) {
throw new Error(`Puppeteer produced invalid ${label} PDF (${buffer.length} bytes)`);
}
this.logger.log(
`${label} PDF rendered (${buffer.length} bytes) via ${executablePath ?? "bundled Chromium"}`,
);
return buffer;
} finally {
await browser.close();
}
} catch (error) {
this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`);
const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml);
if (this.isValidPdf(fallback)) {
this.logger.warn(
`Using ${label} PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
);
return fallback;
}
throw new InternalServerErrorException(
`${label} PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.`,
);
}
}
private injectPdfPrintStyles(html: string): string {
if (html.includes("edr-pdf-print-fix")) return html;
if (html.includes("</head>")) {
return html.replace("</head>", `${PDF_PRINT_STYLES}</head>`);
}
return `${PDF_PRINT_STYLES}${html}`;
}
private resolveExecutablePath(): string | undefined {
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
if (fromEnv && existsSync(fromEnv)) return fromEnv;
const candidates = [
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
];
return candidates.find((path) => existsSync(path));
}
isValidPdf(buffer: Buffer): boolean {
return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString("ascii") === "%PDF-";
}
/** Minimal valid one-page PDF carrying a plain-text rendering of the document. */
private genericFallbackPdf(html: string): Buffer {
const text = html
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/[^\x20-\x7e]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 900);
const escape = (value: string) => value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
const lines = (text.match(/.{1,90}/g) ?? ["Document"]).slice(0, 40);
const stream =
"BT\n/F1 10 Tf\n36 800 Td\n12 TL\n" +
lines.map((line, i) => `${i === 0 ? "" : "T*\n"}(${escape(line)}) Tj\n`).join("") +
"ET";
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`,
];
let pdf = "%PDF-1.4\n";
const offsets: number[] = [];
objects.forEach((object, index) => {
offsets.push(Buffer.byteLength(pdf, "latin1"));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) pdf += "% pad\n";
const xrefOffset = Buffer.byteLength(pdf, "latin1");
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (const offset of offsets) pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, "latin1");
}
}

View File

@@ -5,6 +5,16 @@ import { PaymentEntity } from "../../payment/entities/payment.entity";
import { Company } from "../../companies/entities/company.entity";
import { CompanyProfile } from "../../companies/entities/company-profile.entity";
/** A single recorded settlement against an invoice (payment ledger entry). */
export interface InvoicePayment {
amount: number;
method?: string | null;
reference?: string | null;
/** ISO timestamp of when the settlement was recorded. */
paidAt: string;
metadata?: Record<string, unknown> | null;
}
@Entity({ schema: "freight", name: "invoices" })
@Index(["companyId"])
@Index(["companyProfileId"])
@@ -28,9 +38,24 @@ export class Invoice extends BaseEntity {
@JoinColumn({ name: "company_profile_id" })
companyProfile?: CompanyProfile;
/** Sum of line amounts before tax; defaults to `totalAmount` for tax-free invoices. */
@Column({ name: "subtotal_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
subtotalAmount!: number;
@Column({ name: "tax_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
taxAmount!: number;
@Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 })
totalAmount!: number;
/** Cumulative amount settled so far (supports partial payment). */
@Column({ name: "paid_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
paidAmount!: number;
/** Outstanding balance = `totalAmount - paidAmount` (0 once fully paid). */
@Column({ name: "balance_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
balanceAmount!: number;
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
currency!: string;
@@ -62,6 +87,14 @@ export class Invoice extends BaseEntity {
@Column({ name: "issued_at", type: "timestamptz", nullable: true })
issuedAt?: Date | null;
/** Set when the invoice is fully settled. */
@Column({ name: "paid_at", type: "timestamptz", nullable: true })
paidAt?: Date | null;
/** Ledger of individual settlements (manual or gateway), newest last. */
@Column({ name: "payments", type: "jsonb", default: () => "'[]'" })
payments!: InvoicePayment[];
/** The ID of the payment that generated this invoice. */
@Column({ name: "payment_id", type: "uuid", nullable: true })
paymentId?: string | null;

View File

@@ -0,0 +1,44 @@
/**
* Shared per-day sequential invoice numbering, used by every billing source
* (freight `FRT-…`, warehouse fees `WHF-…`, …) so the format and the
* `MAX(seq)+1` allocation live in one place instead of being copy-pasted per
* service.
*
* Produces `<CODE>-YYYYMMDD-00001`: the sequence is the max existing suffix for
* the day + 1. Run inside the caller's transaction (pass that transaction's
* manager) so concurrent generation within a transaction stays consistent.
*/
/** Anything exposing TypeORM's `.query` — an `EntityManager` or `DataSource`. */
export interface SqlRunner {
query(sql: string, params?: unknown[]): Promise<Array<{ seq: number | string }>>;
}
export interface InvoiceNumberOptions {
/** Schema-qualified table to scan, e.g. `freight.invoices`. */
table: string;
/** Document code prefix, e.g. `FRT` or `WHF`. */
code: string;
/** Column holding the number; defaults to `invoice_number`. */
column?: string;
/** Clock injection point (tests); defaults to now. */
now?: Date;
}
export async function nextDailyInvoiceNumber(
runner: SqlRunner,
opts: InvoiceNumberOptions,
): Promise<string> {
const now = opts.now ?? new Date();
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
const prefix = `${opts.code}-${ymd}-`;
const column = opts.column ?? "invoice_number";
const [row] = await runner.query(
`SELECT COALESCE(MAX(CAST(split_part(${column}, '-', 3) AS int)), 0) AS seq
FROM ${opts.table} WHERE ${column} LIKE $1`,
[`${prefix}%`],
);
const next = Number(row?.seq ?? 0) + 1;
return `${prefix}${String(next).padStart(5, "0")}`;
}

View File

@@ -0,0 +1,36 @@
/**
* Shared payment/settlement math for invoices. Both the global
* `BillingService.recordPayment` and the warehouse fee invoice flow apply a
* payment the same way — accumulate `paidAmount`, derive the outstanding
* `balanceAmount`, and decide whether the invoice is now fully settled. Keeping
* it here means the two flows can never drift on rounding or the
* partial-vs-full threshold.
*/
/** Round to 2 decimals, avoiding binary float drift. */
export const round2 = (n: number): number => Math.round(n * 100) / 100;
export interface SettlementResult {
/** New cumulative amount paid. */
paidAmount: number;
/** Remaining balance (0 once fully paid). */
balanceAmount: number;
/** True once the balance reaches zero. */
fullyPaid: boolean;
}
/**
* Apply a single payment of `amount` to an invoice with `totalAmount` already
* carrying `currentPaid`. Caller is responsible for validating `amount > 0` and
* the invoice being in a payable state.
*/
export function applySettlement(
totalAmount: number,
currentPaid: number,
amount: number,
): SettlementResult {
const total = Number(totalAmount);
const paidAmount = round2(Number(currentPaid) + Number(amount));
const balanceAmount = Math.max(0, round2(total - paidAmount));
return { paidAmount, balanceAmount, fullyPaid: paidAmount >= total };
}

View File

@@ -5,14 +5,18 @@ import {
Param,
ParseUUIDPipe,
Post,
Query,
Res,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import { CurrentUser } from "@edr/api-common";
import {
type AuthUserPayload,
resolveAuthUserId,
} from "../../common/resolve-auth-user-id";
import { sendPdf } from "./billing.controller";
import { BillingService } from "./billing.service";
import { PayInvoiceDto } from "./dto/pay-invoice.dto";
@@ -29,8 +33,15 @@ export class PortalBillingController {
@Get("my-invoices")
@ApiOperation({ summary: "List the signed-in customer's invoices" })
findMine(@CurrentUser() user: AuthUserPayload) {
return this.billingService.findForUser(resolveAuthUserId(user));
findMine(
@CurrentUser() user: AuthUserPayload,
@Query("source") source?: string,
@Query("sourceId") sourceId?: string,
) {
return this.billingService.findForUser(resolveAuthUserId(user), {
source,
sourceId,
});
}
@Get("my-invoices/:id")
@@ -42,6 +53,34 @@ export class PortalBillingController {
return this.billingService.findByIdForUser(id, resolveAuthUserId(user));
}
@Get("my-invoices/:id/document")
@ApiOperation({ summary: "Download one of the customer's invoice PDFs" })
async document(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
@Res() res: Response,
) {
const { filename, buffer } = await this.billingService.documentForUser(
id,
resolveAuthUserId(user),
);
sendPdf(res, filename, buffer);
}
@Get("my-invoices/:id/receipt")
@ApiOperation({ summary: "Download one of the customer's payment receipt PDFs" })
async receipt(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
@Res() res: Response,
) {
const { filename, buffer } = await this.billingService.receiptForUser(
id,
resolveAuthUserId(user),
);
sendPdf(res, filename, buffer);
}
@Post("my-invoices/:id/pay")
@ApiOperation({ summary: "Initiate payment for one of the customer's invoices" })
pay(

View File

@@ -1,20 +1,20 @@
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
import { DataSource } from 'typeorm';
import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";
import { Freight } from "@edr/types";
import { DataSource } from "typeorm";
import {
BillingService,
GenerateInvoiceInput,
InvoiceEventPayload,
InvoiceLineInput,
} from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { FirstMileService } from '../first-mile/first-mile.service';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { PriceLineItemDto } from './dto/generate-price-response.dto';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
} from "../billing/billing.service";
import { Invoice } from "../billing/entities/invoice.entity";
import { FirstMileService } from "../first-mile/first-mile.service";
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
import { BookingsRepository } from "./bookings.repository";
import { Booking } from "./entities/booking.entity";
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
interface StoredPricingBreakdown {
@@ -23,6 +23,12 @@ interface StoredPricingBreakdown {
currency?: string;
}
export interface InvoiceOptions {
dueDate?: Date;
invoiceType?: string;
invoiceStatus?: Freight.InvoiceStatus;
}
/** Round to 2 decimals, avoiding binary float drift. */
const round2 = (n: number): number => Math.round(n * 100) / 100;
@@ -56,11 +62,14 @@ export class BookingInvoiceService {
* bill (e.g. government bookings whose `companyId` is null, which the invoices
* FK requires), or no priced amount.
*/
async ensureInvoiceForBooking(booking: Booking): Promise<Invoice | null> {
async ensureInvoiceForBooking(
booking: Booking,
invoiceOptions: InvoiceOptions = {},
): Promise<Invoice> {
const existing = await this.billing.findPayable(
Freight.InvoiceSource.Booking,
booking.id,
Freight.InvoiceType.Prepaid,
"PREPAID",
);
if (existing) return existing;
@@ -68,16 +77,9 @@ export class BookingInvoiceService {
this.logger.warn(
`Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`,
);
return null;
}
const input = this.buildInput(booking);
if (!input) {
this.logger.warn(
`Skipping invoice for booking ${booking.reference} (${booking.id}): no priced amount.`,
);
return null;
}
const input = this.buildInput(booking, invoiceOptions);
return this.billing.generateInvoice(input);
}
@@ -87,10 +89,10 @@ export class BookingInvoiceService {
* reactions live here (not in the payment process): each invoice type advances
* the booking its own way. Only PREPAID exists today.
*/
@OnEvent('booking.invoice.paid')
@OnEvent("booking.invoice.paid")
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
switch (payload.type) {
case Freight.InvoiceType.Prepaid:
case "PREPAID":
await this.advanceBookingOnPayment(payload.sourceId);
break;
default:
@@ -100,6 +102,8 @@ export class BookingInvoiceService {
}
}
updateStatus = this.billing.updateStatus;
/**
* Advance a booking once its prepaid invoice settles — the domain side-effect
* of payment, relocated out of the payment service: the booking becomes PAID
@@ -114,16 +118,18 @@ export class BookingInvoiceService {
private async advanceBookingOnPayment(bookingId: string): Promise<void> {
const booking = await this.bookingsRepository.findById(bookingId);
if (!booking) {
this.logger.warn(`Cannot advance unknown booking ${bookingId} on payment.`);
this.logger.warn(
`Cannot advance unknown booking ${bookingId} on payment.`,
);
return;
}
if (booking.paymentStatus === 'PAID') return;
if (booking.paymentStatus === "PAID") return;
await this.dataSource.transaction(async (mg) => {
await mg.update(
Booking,
{ id: bookingId },
{ paymentStatus: 'PAID', status: 'PAID' },
{ paymentStatus: "PAID", status: "PAID" },
);
await this.firstMile.acceptBooking(bookingId);
});
@@ -138,9 +144,13 @@ export class BookingInvoiceService {
}
/** Map a booking's pricing snapshot into a generic invoice request. */
private buildInput(booking: Booking): GenerateInvoiceInput | null {
const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown;
const currency = breakdown.currency ?? booking.paymentCurrency ?? 'ETB';
private buildInput(
booking: Booking,
invoiceOptions: InvoiceOptions = {},
): GenerateInvoiceInput {
const breakdown = (booking.pricingBreakdown ??
{}) as StoredPricingBreakdown;
const currency = breakdown.currency ?? booking.paymentCurrency ?? "ETB";
const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({
chargeType: l.code,
@@ -155,10 +165,10 @@ export class BookingInvoiceService {
// Fall back to a single freight line when no breakdown was snapshotted.
if (lines.length === 0) {
const amount = Number(booking.totalAmount);
if (!Number.isFinite(amount) || amount <= 0) return null;
if (!Number.isFinite(amount) || amount <= 0) throw new Error("No price");
lines.push({
chargeType: 'FREIGHT',
description: 'Rail freight',
chargeType: "FREIGHT",
description: "Rail freight",
quantity: 1,
unitRate: amount,
amount,
@@ -166,7 +176,9 @@ export class BookingInvoiceService {
});
}
const subtotal = round2(lines.reduce((sum, l) => sum + Number(l.amount), 0));
const subtotal = round2(
lines.reduce((sum, l) => sum + Number(l.amount), 0),
);
let totalAmount = subtotal;
// Honor a staff price override: bill the adjusted total, recording the delta
@@ -176,8 +188,8 @@ export class BookingInvoiceService {
const delta = round2(Number(adjusted) - subtotal);
if (delta !== 0) {
lines.push({
chargeType: 'ADJUSTMENT',
description: 'Staff price adjustment',
chargeType: "ADJUSTMENT",
description: "Staff price adjustment",
quantity: 1,
unitRate: delta,
amount: delta,
@@ -190,12 +202,14 @@ export class BookingInvoiceService {
return {
source: Freight.InvoiceSource.Booking,
sourceId: booking.id,
type: Freight.InvoiceType.Prepaid,
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency,
lines,
totalAmount,
dueAt: invoiceOptions.dueDate,
type: invoiceOptions.invoiceType ?? "PREPAID",
status: invoiceOptions.invoiceStatus ?? Freight.InvoiceStatus.Draft,
};
}
}

View File

@@ -4,53 +4,56 @@ import {
Inject,
Injectable,
Logger,
} from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
} from "@nestjs/common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { isRoadService } from './road.util';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { FilesService } from '../files/files.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { BookingContractService } from './booking-contract.service';
import { BookingInvoiceService } from './booking-invoice.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
import { clearanceCodesForBooking } from './clearance.util';
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import { PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity';
import { BookingsService } from './bookings.service';
import { assertCanApproveBookingStep } from "../../common/freight-permission.util";
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { eatDay } from "../train-scheduling/batch-window.util";
import { isRoadService } from "./road.util";
import { RuleEngineService } from "../rule-engine/rule-engine.service";
import { FilesService } from "../files/files.service";
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
import { BookingContractService } from "./booking-contract.service";
import { BookingPricingService } from "./booking-pricing.service";
import { BookingsRepository } from "./bookings.repository";
import { assertBookingStatus } from "./booking-status.util";
import { clearanceCodesForBooking } from "./clearance.util";
import {
computeNextStep,
type BookingNextStep,
} from "./booking-next-step.util";
import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto";
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
import { Booking } from "./entities/booking.entity";
import { BookingsService } from "./bookings.service";
import { BookingInvoiceService } from "./booking-invoice.service";
import { Freight } from "@edr/types";
@Injectable()
export class BookingTransitionService {
private readonly logger = new Logger(BookingTransitionService.name);
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly ruleEngineService: RuleEngineService,
private readonly pricingService: BookingPricingService,
private readonly contractService: BookingContractService,
private readonly invoiceService: BookingInvoiceService,
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
@Inject(forwardRef(() => BookingsService))
private readonly bookingsService: BookingsService,
) {}
private readonly invoiceService: BookingInvoiceService,
) { }
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]);
if (Number(booking.totalAmount) <= 0) {
throw new BadRequestException(
'Generate a price before submitting (POST /bookings/:id/generate-price)',
"Generate a price before submitting (POST /bookings/:id/generate-price)",
);
}
@@ -69,7 +72,8 @@ export class BookingTransitionService {
totalAmount?: number;
} | null;
const unchanged = this.pricingService.pricesMatch(stored, computed);
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
const priorityScore =
await this.pricingService.computeSubmitPriorityScore(booking);
if (unchanged) {
await this.pricingService.createPricingSnapshots(
@@ -79,7 +83,7 @@ export class BookingTransitionService {
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'SUBMITTED',
status: "SUBMITTED",
priorityScore,
} as never);
@@ -109,7 +113,7 @@ export class BookingTransitionService {
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
status: 'PRICE_CHANGED_PENDING_CONFIRM',
status: "PRICE_CHANGED_PENDING_CONFIRM",
} as never);
const updatedBooking = await this.bookingsService.findById(bookingId);
@@ -121,16 +125,17 @@ export class BookingTransitionService {
totalAmount: computed.totalAmount,
currency: computed.currency,
lineItems: computed.lineItems,
message: 'Price has changed since preview. Confirm to submit with the updated price.',
message:
"Price has changed since preview. Confirm to submit with the updated price.",
};
}
async confirmSubmit(bookingId: string): Promise<SubmitBookingResponseDto> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['PRICE_CHANGED_PENDING_CONFIRM']);
assertBookingStatus(booking, ["PRICE_CHANGED_PENDING_CONFIRM"]);
if (Number(booking.totalAmount) <= 0) {
throw new BadRequestException('No price to confirm');
throw new BadRequestException("No price to confirm");
}
const computed = await this.pricingService.computePriceForBooking(booking);
@@ -149,9 +154,10 @@ export class BookingTransitionService {
computed.appliedModifiers,
);
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
const priorityScore =
await this.pricingService.computeSubmitPriorityScore(booking);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'SUBMITTED',
status: "SUBMITTED",
priorityScore,
totalAmount: computed.totalAmount,
pricingBreakdown: {
@@ -173,7 +179,7 @@ export class BookingTransitionService {
totalAmount: Number(finalBooking.totalAmount),
currency: finalBooking.paymentCurrency,
lineItems: computed.lineItems,
message: 'Booking submitted with confirmed price.',
message: "Booking submitted with confirmed price.",
};
}
@@ -183,17 +189,17 @@ export class BookingTransitionService {
actorId: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED']);
assertBookingStatus(booking, ["SUBMITTED"]);
await this.bookingsRepository.createReviewNote(
bookingId,
note,
'CHANGES_REQUESTED',
"CHANGES_REQUESTED",
actorId,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'CHANGES_REQUESTED',
status: "CHANGES_REQUESTED",
} as never);
return this.bookingsService.findById(updated!.id);
}
@@ -203,7 +209,7 @@ export class BookingTransitionService {
if ((booking.approvalSteps?.length ?? 0) > 0) return;
await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
freightType: booking.freightType as "CONTAINER" | "BULK",
cargoTypeId: booking.cargoTypeId,
});
}
@@ -217,14 +223,14 @@ export class BookingTransitionService {
// Only SUBMITTED bookings are acceptable. A booking that still needs
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
// is therefore never offered for accept until a partner moves it to SUBMITTED.
assertBookingStatus(booking, ['SUBMITTED']);
assertBookingStatus(booking, ["SUBMITTED"]);
// The backoffice must define how long the accepted contract stays valid.
// Without a window the contract has no end date and cannot be relied on, so
// accept is blocked until a positive number of days is supplied.
if (!Number.isInteger(validityDays) || validityDays < 1) {
throw new BadRequestException(
'A contract validity (in days) is required to accept this booking.',
"A contract validity (in days) is required to accept this booking.",
);
}
@@ -234,12 +240,12 @@ export class BookingTransitionService {
validUntil.setDate(validUntil.getDate() + validityDays);
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
freightType: booking.freightType as "CONTAINER" | "BULK",
cargoTypeId: booking.cargoTypeId,
});
const updated = await this.bookingsRepository.update(bookingId, {
status: 'PENDING_APPROVAL',
status: "PENDING_APPROVAL",
approvedByStaffId: actorId,
approvedByStaffAt: validFrom,
contractValidityDays: validityDays,
@@ -255,17 +261,17 @@ export class BookingTransitionService {
actorId: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']);
assertBookingStatus(booking, ["SUBMITTED", "PENDING_APPROVAL"]);
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
'REJECTION',
"REJECTION",
actorId,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'REJECTED',
status: "REJECTED",
} as never);
return this.bookingsService.findById(updated!.id);
}
@@ -283,8 +289,8 @@ export class BookingTransitionService {
let booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE',
"PENDING_APPROVAL",
"APPROVED_PENDING_SIGNATURE",
]);
if ((booking.approvalSteps?.length ?? 0) === 0) {
@@ -296,14 +302,17 @@ export class BookingTransitionService {
bookingId,
stepId,
);
if (!step || step.status !== 'PENDING') {
throw new BadRequestException('Approval step not found or already actioned');
if (!step || step.status !== "PENDING") {
throw new BadRequestException(
"Approval step not found or already actioned",
);
}
const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
const next =
await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
if (!next || next.id !== step.id) {
throw new BadRequestException(
'Approval steps must be completed in order',
"Approval steps must be completed in order",
);
}
@@ -315,29 +324,36 @@ export class BookingTransitionService {
const blocksRole = step.blocksRole;
if (blocksRole && blocksRole === requiredRole) {
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
throw new BadRequestException(
`Role ${requiredRole} is blocked for this step`,
);
}
await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
await this.bookingsRepository.completeApprovalStep(
step.id,
actorId,
"APPROVED",
);
const updates: Record<string, unknown> = {};
const now = new Date();
if (requiredRole === 'LINE_STAFF') {
updates.status = 'APPROVED_PENDING_SIGNATURE';
if (requiredRole === "LINE_STAFF") {
updates.status = "APPROVED_PENDING_SIGNATURE";
updates.approvedByStaffId = actorId;
updates.approvedByStaffAt = now;
} else if (requiredRole === 'DIRECTOR') {
} else if (requiredRole === "DIRECTOR") {
updates.signedByDirectorId = actorId;
updates.signedByDirectorAt = now;
} else if (requiredRole === 'CEO') {
} else if (requiredRole === "CEO") {
updates.signedByCeoId = actorId;
updates.signedByCeoAt = now;
}
const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId);
const allDone =
await this.bookingsRepository.allApprovalStepsComplete(bookingId);
if (allDone) {
updates.status = 'APPROVED';
updates.status = "APPROVED";
}
if (Object.keys(updates).length > 0) {
@@ -359,90 +375,64 @@ export class BookingTransitionService {
reason: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
assertBookingStatus(booking, [
"PENDING_APPROVAL",
"APPROVED_PENDING_SIGNATURE",
]);
const step = await this.bookingsRepository.findApprovalStepById(
bookingId,
stepId,
);
if (!step) throw new BadRequestException('Approval step not found');
if (!step) throw new BadRequestException("Approval step not found");
await this.bookingsRepository.completeApprovalStep(
step.id,
actorId,
'REJECTED',
"REJECTED",
reason,
);
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
'REJECTION',
"REJECTION",
actorId,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'REJECTED',
status: "REJECTED",
} as never);
return this.bookingsService.findById(updated!.id);
}
async customerSign(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['CONTRACT_READY']);
assertBookingStatus(booking, ["CONTRACT_READY"]);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'SIGNED_CUSTOMER',
status: "SIGNED_CUSTOMER",
customerSignedAt: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
}
async marketingApprove(bookingId: string, actorId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'FULLY_EXECUTED',
fullyExecutedAt: new Date(),
marketingApprovedById: actorId,
marketingApprovedAt: new Date(),
lockedAt: new Date(),
} as never);
const executed = await this.bookingsService.findById(updated!.id);
// Billable state reached — generate the invoice payment will settle.
// Non-blocking: a billing hiccup must not undo the execution.
await this.invoiceService
.ensureInvoiceForBooking(executed)
.catch((err) =>
this.logger.error(
`Failed to generate invoice for booking ${executed.reference}: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
return executed;
}
async startTransit(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['PAID']);
assertBookingStatus(booking, ["PAID"]);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'IN_TRANSIT',
status: "IN_TRANSIT",
} as never);
return this.bookingsService.findById(updated!.id);
}
async complete(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['IN_TRANSIT']);
assertBookingStatus(booking, ["IN_TRANSIT"]);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'COMPLETED',
status: "COMPLETED",
endDate: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
@@ -451,22 +441,22 @@ export class BookingTransitionService {
async cancel(bookingId: string, reason: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
'DRAFT',
'SUBMITTED',
'PRICE_CHANGED_PENDING_CONFIRM',
'CHANGES_REQUESTED',
'PENDING_APPROVAL',
'CONTRACT_READY',
"DRAFT",
"SUBMITTED",
"PRICE_CHANGED_PENDING_CONFIRM",
"CHANGES_REQUESTED",
"PENDING_APPROVAL",
"CONTRACT_READY",
]);
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
'REJECTION',
"REJECTION",
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'CANCELLED',
status: "CANCELLED",
} as never);
return this.bookingsService.findById(updated!.id);
}
@@ -479,20 +469,20 @@ export class BookingTransitionService {
async reject(bookingId: string, reason?: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
'DRAFT',
'SUBMITTED',
'PRICE_CHANGED_PENDING_CONFIRM',
'PENDING_CONSOLIDATION',
"DRAFT",
"SUBMITTED",
"PRICE_CHANGED_PENDING_CONFIRM",
"PENDING_CONSOLIDATION",
]);
await this.bookingsRepository.createReviewNote(
bookingId,
reason?.trim() || 'Customer rejected the price estimate.',
'REJECTION',
reason?.trim() || "Customer rejected the price estimate.",
"REJECTION",
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'REJECTED',
status: "REJECTED",
} as never);
return this.bookingsService.findById(updated!.id);
}
@@ -513,10 +503,10 @@ export class BookingTransitionService {
fileKey: string;
label: string;
required: boolean;
uploadedBy: 'customer' | 'gl';
uploadedBy: "customer" | "gl";
settingCode: string;
file: { id: string; name: string; url: string } | null;
reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null;
reviewStatus: "PENDING" | "APPROVED" | "QUERIED" | null;
note: string | null;
}>;
allApproved: boolean;
@@ -525,20 +515,21 @@ export class BookingTransitionService {
const { inputCode, outputCode, includesCustoms } =
clearanceCodesForBooking(booking);
const files = await this.filesService.findByResource(bookingId, 'bookings');
const files = await this.filesService.findByResource(bookingId, "bookings");
const fileByCode = new Map(files.map((f) => [f.code, f]));
const reviews = await this.bookingsRepository.findDocumentReviews(bookingId);
const reviews =
await this.bookingsRepository.findDocumentReviews(bookingId);
const reviewByKey = new Map(
reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]),
);
const documents: Awaited<
ReturnType<BookingTransitionService['getClearanceView']>
>['documents'] = [];
ReturnType<BookingTransitionService["getClearanceView"]>
>["documents"] = [];
const pushSetting = async (
code: string | null,
uploadedBy: 'customer' | 'gl',
uploadedBy: "customer" | "gl",
) => {
if (!code) return;
let setting;
@@ -556,28 +547,26 @@ export class BookingTransitionService {
required: field.isRequired,
uploadedBy,
settingCode: code,
file: file
? { id: file.id, name: file.name, url: file.url }
: null,
file: file ? { id: file.id, name: file.name, url: file.url } : null,
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
});
}
};
await pushSetting(inputCode, 'customer');
await pushSetting(outputCode, 'gl');
await pushSetting(inputCode, "customer");
await pushSetting(outputCode, "gl");
// Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set.
for (const f of files) {
if (!f.code?.startsWith('custom_')) continue;
if (!f.code?.startsWith("custom_")) continue;
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
documents.push({
fileKey: f.code,
label: f.name,
required: false,
uploadedBy: 'customer',
settingCode: 'custom',
uploadedBy: "customer",
settingCode: "custom",
file: { id: f.id, name: f.name, url: f.url },
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
@@ -611,13 +600,15 @@ export class BookingTransitionService {
}
const required = (setting.fields ?? []).filter((f) => f.isRequired);
if (required.length === 0) return true;
const reviews = await this.bookingsRepository.findDocumentReviews(booking.id);
const reviews = await this.bookingsRepository.findDocumentReviews(
booking.id,
);
return required.every((field) =>
reviews.some(
(r) =>
r.settingCode === inputCode &&
r.fileKey === field.fileKey &&
r.status === 'APPROVED',
r.status === "APPROVED",
),
);
}
@@ -632,33 +623,38 @@ export class BookingTransitionService {
files: Express.Multer.File[],
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW']);
assertBookingStatus(booking, [
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
]);
const { inputCode } = clearanceCodesForBooking(booking);
if (!inputCode) {
throw new BadRequestException('This booking has no document-clearance step');
throw new BadRequestException(
"This booking has no document-clearance step",
);
}
if (files.length === 0) {
throw new BadRequestException('No documents uploaded');
throw new BadRequestException("No documents uploaded");
}
// First submission (nothing in review yet): every required input field must
// be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer
// is only fixing queried/pending docs, so the already-uploaded required docs
// stay in place and we don't re-gate on the full required set.
if (booking.status === 'AWAITING_DOCUMENTS') {
if (booking.status === "AWAITING_DOCUMENTS") {
await this.assertRequiredInputsPresent(bookingId, inputCode, files);
}
for (const file of files) {
const record = await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
resource: "bookings",
code: file.fieldname,
file,
});
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
const settingCode = file.fieldname.startsWith('custom_')
? 'custom'
const settingCode = file.fieldname.startsWith("custom_")
? "custom"
: inputCode;
await this.bookingsRepository.upsertDocumentReviewPending({
bookingId,
@@ -669,7 +665,7 @@ export class BookingTransitionService {
}
await this.bookingsRepository.update(bookingId, {
status: 'DOCUMENTS_UNDER_REVIEW',
status: "DOCUMENTS_UNDER_REVIEW",
} as never);
return this.bookingsService.findById(bookingId);
}
@@ -694,7 +690,10 @@ export class BookingTransitionService {
const required = (setting.fields ?? []).filter((f) => f.isRequired);
if (required.length === 0) return;
const existing = await this.filesService.findByResource(bookingId, 'bookings');
const existing = await this.filesService.findByResource(
bookingId,
"bookings",
);
const presentKeys = new Set<string>([
...existing.map((f) => f.code),
...files.map((f) => f.fieldname),
@@ -702,7 +701,7 @@ export class BookingTransitionService {
const missing = required.filter((f) => !presentKeys.has(f.fileKey));
if (missing.length > 0) {
const labels = missing.map((f) => f.fileLabel).join(', ');
const labels = missing.map((f) => f.fileLabel).join(", ");
throw new BadRequestException(
`Please upload all required documents before submitting: ${labels}`,
);
@@ -713,22 +712,27 @@ export class BookingTransitionService {
async reviewDocument(
bookingId: string,
fileKey: string,
status: 'APPROVED' | 'QUERIED',
status: "APPROVED" | "QUERIED",
staffId: string,
note?: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
const { inputCode, outputCode } = clearanceCodesForBooking(booking);
const existing = await this.bookingsRepository.findDocumentReviews(bookingId);
const existing =
await this.bookingsRepository.findDocumentReviews(bookingId);
const match = existing.find((r) => r.fileKey === fileKey);
const settingCode =
match?.settingCode ??
(fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));
(fileKey.startsWith("custom_")
? "custom"
: (inputCode ?? outputCode ?? "custom"));
if (status === 'QUERIED' && !note?.trim()) {
throw new BadRequestException('A note is required when querying a document');
if (status === "QUERIED" && !note?.trim()) {
throw new BadRequestException(
"A note is required when querying a document",
);
}
await this.bookingsRepository.setDocumentReviewStatus(
@@ -739,11 +743,11 @@ export class BookingTransitionService {
staffId,
note,
);
if (status === 'QUERIED') {
if (status === "QUERIED") {
await this.bookingsRepository.createReviewNote(
bookingId,
`Document "${fileKey}" queried: ${note}`,
'CHANGES_REQUESTED',
"CHANGES_REQUESTED",
staffId,
);
}
@@ -756,18 +760,20 @@ export class BookingTransitionService {
files: Express.Multer.File[],
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
const { outputCode } = clearanceCodesForBooking(booking);
if (!outputCode) {
throw new BadRequestException('This booking has no customs output documents');
throw new BadRequestException(
"This booking has no customs output documents",
);
}
if (files.length === 0) {
throw new BadRequestException('No documents uploaded');
throw new BadRequestException("No documents uploaded");
}
for (const file of files) {
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
resource: "bookings",
code: file.fieldname,
file,
});
@@ -781,19 +787,23 @@ export class BookingTransitionService {
*/
async finalizeClearance(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
const approved = await this.isClearanceFullyApproved(booking);
if (!approved) {
throw new BadRequestException(
'All required documents must be approved before clearance can be finalized',
"All required documents must be approved before clearance can be finalized",
);
}
const { outputCode } = clearanceCodesForBooking(booking);
if (outputCode) {
const setting = await this.fileUploadSettingsService.getByCode(outputCode);
const files = await this.filesService.findByResource(bookingId, 'bookings');
const setting =
await this.fileUploadSettingsService.getByCode(outputCode);
const files = await this.filesService.findByResource(
bookingId,
"bookings",
);
const uploaded = new Set(files.map((f) => f.code));
const missing = (setting.fields ?? []).filter(
(f) => f.isRequired && !uploaded.has(f.fileKey),
@@ -802,13 +812,13 @@ export class BookingTransitionService {
throw new BadRequestException(
`Upload all required customs output documents first: ${missing
.map((m) => m.fileLabel)
.join(', ')}`,
.join(", ")}`,
);
}
}
await this.bookingsRepository.update(bookingId, {
status: 'CLEARANCE_READY',
status: "CLEARANCE_READY",
} as never);
return this.bookingsService.findById(bookingId);
}
@@ -827,11 +837,14 @@ export class BookingTransitionService {
scheduledDate: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED']);
assertBookingStatus(booking, [
"CLEARANCE_READY",
"OPERATION_CHANGES_REQUESTED",
]);
const date = new Date(scheduledDate);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException('A valid schedule date is required');
throw new BadRequestException("A valid schedule date is required");
}
// The binding shipment day must have at least one OPEN departure on the
@@ -844,12 +857,12 @@ export class BookingTransitionService {
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
"No departures available on the selected day for this route",
);
}
await this.bookingsRepository.update(bookingId, {
status: 'OPERATION_REQUEST_PENDING',
status: "OPERATION_REQUEST_PENDING",
scheduledDate: date,
} as never);
return this.bookingsService.findById(bookingId);
@@ -865,27 +878,27 @@ export class BookingTransitionService {
*/
async reviewOperationRequest(
bookingId: string,
decision: 'ACCEPT' | 'REQUEST_CHANGES',
decision: "ACCEPT" | "REQUEST_CHANGES",
actorId: string,
options: { note?: string } = {},
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']);
assertBookingStatus(booking, ["OPERATION_REQUEST_PENDING"]);
if (decision === 'REQUEST_CHANGES') {
if (decision === "REQUEST_CHANGES") {
if (!options.note?.trim()) {
throw new BadRequestException(
'A note is required when requesting changes',
"A note is required when requesting changes",
);
}
await this.bookingsRepository.createReviewNote(
bookingId,
options.note,
'CHANGES_REQUESTED',
"CHANGES_REQUESTED",
actorId,
);
await this.bookingsRepository.update(bookingId, {
status: 'OPERATION_CHANGES_REQUESTED',
status: "OPERATION_CHANGES_REQUESTED",
} as never);
return this.bookingsService.findById(bookingId);
}
@@ -907,9 +920,17 @@ export class BookingTransitionService {
private async acceptOperationRequest(booking: Booking): Promise<Booking> {
const now = new Date();
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
this.logger.log(
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`,
);
await this.invoiceService.updateStatus(
invoice.id,
Freight.InvoiceStatus.Pending,
);
if (isRoadService(booking.serviceType)) {
await this.bookingsRepository.update(booking.id, {
status: 'ROAD_DISPATCH_PENDING',
status: "ROAD_DISPATCH_PENDING",
fullyExecutedAt: now,
lockedAt: booking.lockedAt ?? now,
} as never);
@@ -917,7 +938,7 @@ export class BookingTransitionService {
}
await this.bookingsRepository.update(booking.id, {
status: 'FULLY_EXECUTED',
status: "FULLY_EXECUTED",
fullyExecutedAt: now,
lockedAt: booking.lockedAt ?? now,
} as never);
@@ -932,21 +953,23 @@ export class BookingTransitionService {
return this.bookingsService.findById(booking.id);
}
async enrichBookingResponse(booking: Booking): Promise<Booking & {
latestChangeRequestNote?: string | null;
contractSummary?: string | null;
nextStep: BookingNextStep | null;
}> {
async enrichBookingResponse(booking: Booking): Promise<
Booking & {
latestChangeRequestNote?: string | null;
contractSummary?: string | null;
nextStep: BookingNextStep | null;
}
> {
const note = await this.bookingsRepository.findLatestReviewNote(
booking.id,
'CHANGES_REQUESTED',
"CHANGES_REQUESTED",
);
const summary =
booking.contractSummary ??
this.contractService.buildContractSummary(booking);
const nextPending =
booking.status === 'PENDING_APPROVAL' ||
booking.status === 'APPROVED_PENDING_SIGNATURE'
booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE"
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
: null;
const nextStep = computeNextStep(booking, nextPending);
@@ -957,4 +980,4 @@ export class BookingTransitionService {
nextStep,
};
}
}
}

View File

@@ -14,12 +14,12 @@ import {
UnauthorizedException,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
} from "@nestjs/common";
import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { AnyFilesInterceptor } from "@nestjs/platform-express";
import {
ApiBearerAuth,
ApiBody,
@@ -27,20 +27,20 @@ import {
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import type { Response } from 'express';
} from "@nestjs/swagger";
import type { Response } from "express";
import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingTransitionService } from './booking-transition.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingsService } from './bookings.service';
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
import { CreateBookingDto } from './dto/create-booking.dto';
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import { BookingContractService } from "./booking-contract.service";
import { BookingPricingService } from "./booking-pricing.service";
import { BookingTransitionService } from "./booking-transition.service";
import { BookingReferenceDataService } from "./booking-reference-data.service";
import { BookingsService } from "./bookings.service";
import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto";
import { CreateBookingDto } from "./dto/create-booking.dto";
import { BookingListSummaryDto } from "./dto/booking-list-summary.dto";
import { FilterBookingDto } from "./dto/filter-booking.dto";
import { GeneratePriceResponseDto } from "./dto/generate-price-response.dto";
import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto";
import {
AcceptIntakeDto,
ApproveStepDto,
@@ -52,18 +52,21 @@ import {
RequestOperationDto,
OperationReviewDto,
StaffRejectDto,
} from './dto/request-changes.dto';
import { ContractViewDto } from './dto/contract-view.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
} from "./dto/request-changes.dto";
import { ContractViewDto } from "./dto/contract-view.dto";
import { SignContractDto } from "./dto/sign-contract.dto";
import { UpdateBookingDto } from "./dto/update-booking.dto";
import {
type AuthUserPayload,
resolveAuthUserId,
} from '../../common/resolve-auth-user-id';
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
} from "../../common/resolve-auth-user-id";
import {
assertFreightPermission,
hasFreightPermission,
} from "../../common/freight-permission.util";
@ApiTags('bookings')
@Controller('bookings')
@ApiTags("bookings")
@Controller("bookings")
@ApiBearerAuth()
export class BookingsController {
constructor(
@@ -72,12 +75,12 @@ export class BookingsController {
private readonly pricingService: BookingPricingService,
private readonly transitionService: BookingTransitionService,
private readonly contractService: BookingContractService,
) {}
) { }
@Post()
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Create a new freight booking (DRAFT)" })
@ApiBody({ type: CreateBookingDto })
async create(
@Body() dto: CreateBookingDto,
@@ -87,15 +90,24 @@ export class BookingsController {
if (dto.isGovernment) {
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
}
const result = await this.bookingsService.create(dto, files ?? [], user?.id);
const result = await this.bookingsService.create(
dto,
files ?? [],
user?.id,
);
// Staff-created commercial bookings skip the draft stage: auto generate-price + submit.
const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
const isStaff = hasFreightPermission(
user,
FREIGHT_PERMS.bookings.staffAccept,
);
if (isStaff && !dto.isGovernment) {
try {
await this.pricingService.generatePrice(result.booking.id);
await this.transitionService.submit(result.booking.id);
const submitted = await this.bookingsService.findById(result.booking.id);
const submitted = await this.bookingsService.findById(
result.booking.id,
);
return { booking: submitted, warnings: result.warnings };
} catch {
// If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually.
@@ -105,16 +117,16 @@ export class BookingsController {
return result;
}
@Patch(':id')
@Patch(":id")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: 'Update booking',
description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.',
summary: "Update booking",
description: "Allowed when status is DRAFT or CHANGES_REQUESTED.",
})
@ApiBody({ type: UpdateBookingDto })
update(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateBookingDto,
@UploadedFiles() files: Express.Multer.File[],
) {
@@ -122,7 +134,7 @@ export class BookingsController {
}
@Get()
@ApiOperation({ summary: 'List freight bookings (paginated)' })
@ApiOperation({ summary: "List freight bookings (paginated)" })
async findAll(
@Query() filter: FilterBookingDto,
@CurrentUser() user: TCurrentUser,
@@ -139,7 +151,7 @@ export class BookingsController {
return this.bookingsService.findClearanceQueue(filter);
}
const userId = user?.id;
if (!userId) throw new UnauthorizedException('Authentication required');
if (!userId) throw new UnauthorizedException("Authentication required");
const companyId =
await this.bookingsService.resolveCustomerCompanyId(userId);
// No linked company yet → no bookings to show (avoids leaking all bookings).
@@ -165,27 +177,29 @@ export class BookingsController {
return this.bookingsService.findAll(filter, companyId);
}
@Get('by-company/:companyId/customer-view')
@ApiOperation({ summary: 'List bookings for a company (customer-view shape, backoffice)' })
@Get("by-company/:companyId/customer-view")
@ApiOperation({
summary: "List bookings for a company (customer-view shape, backoffice)",
})
findByCompanyCustomerView(
@Param('companyId', ParseUUIDPipe) companyId: string,
@Param("companyId", ParseUUIDPipe) companyId: string,
) {
return this.bookingsService.findCustomerBookings(companyId);
}
@Get('list-summary')
@ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' })
@Get("list-summary")
@ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" })
@ApiOkResponse({ type: BookingListSummaryDto })
findListSummary(@Query() filter: FilterBookingDto) {
return this.bookingsService.getListSummary(filter);
}
@Get('my')
@Get("my")
@ApiOperation({
summary: "List the current customer's bookings ready for payment",
description:
'Bookings owned by the authenticated user\'s company that are payable ' +
'(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.',
"Bookings owned by the authenticated user's company that are payable " +
"(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.",
})
findMyPayable(
@CurrentUser() user: AuthUserPayload,
@@ -194,32 +208,32 @@ export class BookingsController {
return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter);
}
@Get('queues/:queue')
@Get("queues/:queue")
@ApiOperation({
summary: 'List bookings for a dashboard queue',
description: 'Queues: intake, approval, signatures, marketing, finance',
summary: "List bookings for a dashboard queue",
description: "Queues: intake, approval, signatures, marketing, finance",
})
findQueue(
@Param('queue') queue: string,
@Param("queue") queue: string,
@Query() filter: FilterBookingDto,
@Query('excludeBulk') excludeBulk?: string,
@Query("excludeBulk") excludeBulk?: string,
) {
return this.bookingsService.findQueue(queue, filter, {
excludeBulk: excludeBulk === 'true',
excludeBulk: excludeBulk === "true",
});
}
@Get('reference-data')
@ApiOperation({ summary: 'Booking form catalog' })
@Get("reference-data")
@ApiOperation({ summary: "Booking form catalog" })
@ApiOkResponse({ type: BookingReferenceDataDto })
getReferenceData(): Promise<BookingReferenceDataDto> {
return this.bookingReferenceDataService.getReferenceData();
}
@Get('by-reference/:reference')
@ApiOperation({ summary: 'Get booking by reference' })
@Get("by-reference/:reference")
@ApiOperation({ summary: "Get booking by reference" })
async findByReference(
@Param('reference') reference: string,
@Param("reference") reference: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findByReference(reference);
@@ -233,10 +247,10 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id')
@ApiOperation({ summary: 'Get booking by ID' })
@Get(":id")
@ApiOperation({ summary: "Get booking by ID" })
async findOne(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
@@ -254,15 +268,15 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/tracking')
@Get(":id/tracking")
@ApiOperation({
summary: 'Shipment tracking timeline for a booking',
summary: "Shipment tracking timeline for a booking",
description:
"Returns the booking's consignment (once dispatched) and its ordered " +
'tracking events. Scoped to the customer\'s own company.',
"tracking events. Scoped to the customer's own company.",
})
async findTracking(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
@@ -276,66 +290,66 @@ export class BookingsController {
return this.bookingsService.getBookingTracking(id);
}
@Delete(':id')
@Delete(":id")
@HttpCode(204)
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
remove(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Soft-delete DRAFT booking" })
remove(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.remove(id);
}
@Post(':id/documents')
@Post(":id/documents")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' })
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" })
async uploadDocuments(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
const booking = await this.bookingsService.uploadDocuments(id, files ?? []);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/generate-price')
@Post(":id/generate-price")
@ApiOperation({
summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)',
summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)",
description:
'Computes and stores a price preview on the booking. Does not create rate snapshots.',
"Computes and stores a price preview on the booking. Does not create rate snapshots.",
})
@ApiOkResponse({ type: GeneratePriceResponseDto })
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
generatePrice(@Param("id", ParseUUIDPipe) id: string) {
return this.pricingService.generatePrice(id);
}
@Post(':id/submit')
@Post(":id/submit")
@ApiOperation({
summary: 'Customer submit booking',
summary: "Customer submit booking",
description:
'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.',
"Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.",
})
@ApiOkResponse({ type: SubmitBookingResponseDto })
submit(@Param('id', ParseUUIDPipe) id: string) {
submit(@Param("id", ParseUUIDPipe) id: string) {
return this.transitionService.submit(id);
}
@Post(':id/confirm-submit')
@Post(":id/confirm-submit")
@ApiOperation({
summary: 'Confirm submit after price change',
summary: "Confirm submit after price change",
description:
'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.',
"Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.",
})
@ApiOkResponse({ type: SubmitBookingResponseDto })
confirmSubmit(@Param('id', ParseUUIDPipe) id: string) {
confirmSubmit(@Param("id", ParseUUIDPipe) id: string) {
return this.transitionService.confirmSubmit(id);
}
@Post(':id/reject')
@Post(":id/reject")
@ApiOperation({
summary: 'Customer reject price estimate',
summary: "Customer reject price estimate",
description:
'Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.',
"Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.",
})
async reject(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RejectBookingDto,
) {
const booking = await this.transitionService.reject(id, dto.reason);
@@ -344,22 +358,23 @@ export class BookingsController {
// ── Document clearance (post counter-sign) ────────────────────────────────
@Get(':id/clearance')
@Get(":id/clearance")
@ApiOperation({
summary: 'Document-clearance grid (required docs + upload + GL review status)',
summary:
"Document-clearance grid (required docs + upload + GL review status)",
})
getClearance(@Param('id', ParseUUIDPipe) id: string) {
getClearance(@Param("id", ParseUUIDPipe) id: string) {
return this.transitionService.getClearanceView(id);
}
@Post(':id/clearance/documents')
@Post(":id/clearance/documents")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: 'Customer uploads clearance documents (fieldname = document key)',
summary: "Customer uploads clearance documents (fieldname = document key)",
})
async submitClearanceDocuments(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
const booking = await this.transitionService.submitClearanceDocuments(
@@ -369,14 +384,14 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/proceed')
@Post(":id/clearance/proceed")
@ApiOperation({
summary:
'Customer requests operation with a schedule day ' +
'(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)',
"Customer requests operation with a schedule day " +
"(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)",
})
async proceedToOperation(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RequestOperationDto,
) {
const booking = await this.transitionService.requestOperation(
@@ -386,15 +401,15 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operation/review')
@Post(":id/operation/review")
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary:
'Operations reviews an operation request: ACCEPT (→ batch pool), ' +
'REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)',
"Operations reviews an operation request: ACCEPT (→ batch pool), " +
"REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)",
})
async reviewOperationRequest(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: OperationReviewDto,
@CurrentUser() user: AuthUserPayload,
) {
@@ -407,11 +422,13 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/review')
@Post(":id/clearance/review")
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
@ApiOperation({ summary: 'GL reviews a clearance document (Approve | Query)' })
@ApiOperation({
summary: "GL reviews a clearance document (Approve | Query)",
})
async reviewClearanceDocument(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ReviewDocumentDto,
@CurrentUser() user: AuthUserPayload,
) {
@@ -425,13 +442,13 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/output-documents')
@Post(":id/clearance/output-documents")
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…)' })
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "GL uploads customs output documents (IM4/EX3/…)" })
async uploadClearanceOutput(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
const booking = await this.transitionService.uploadClearanceOutputDocuments(
@@ -441,21 +458,22 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/finalize')
@Post(":id/clearance/finalize")
@BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance)
@ApiOperation({
summary: 'GL finalizes clearance (requires 100% approved) → CLEARANCE_READY',
summary:
"GL finalizes clearance (requires 100% approved) → CLEARANCE_READY",
})
async finalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.transitionService.finalizeClearance(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/staff/request-changes')
@Post(":id/staff/request-changes")
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
@ApiOperation({ summary: 'Staff return booking for customer updates' })
@ApiOperation({ summary: "Staff return booking for customer updates" })
async requestChanges(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RequestChangesDto,
@CurrentUser() user: AuthUserPayload,
) {
@@ -467,14 +485,14 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/staff/accept')
@Post(":id/staff/accept")
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({
summary:
'Staff accept intake → set contract validity window + start approval chain',
"Staff accept intake → set contract validity window + start approval chain",
})
async acceptIntake(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: AcceptIntakeDto,
@CurrentUser() user: AuthUserPayload,
) {
@@ -486,11 +504,11 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/staff/reject')
@Post(":id/staff/reject")
@BookingStaff(FREIGHT_PERMS.bookings.reject)
@ApiOperation({ summary: 'Staff final reject' })
@ApiOperation({ summary: "Staff final reject" })
async staffReject(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: StaffRejectDto,
@CurrentUser() user: AuthUserPayload,
) {
@@ -502,11 +520,13 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/government-expedite')
@Post(":id/government-expedite")
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })
@ApiOperation({
summary: "Expedite government booking to PAID / ELIGIBLE for scheduling",
})
async governmentExpedite(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingsService.governmentExpedite(
@@ -516,16 +536,16 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/approval-steps/:stepId/approve')
@Post(":id/approval-steps/:stepId/approve")
@BookingStaff([
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.approveDirector,
FREIGHT_PERMS.bookings.approveCeo,
])
@ApiOperation({ summary: 'Approve one approval step in sequence' })
@ApiOperation({ summary: "Approve one approval step in sequence" })
async approveStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@Param("id", ParseUUIDPipe) id: string,
@Param("stepId", ParseUUIDPipe) stepId: string,
@Body() dto: ApproveStepDto,
@CurrentUser() user: TCurrentUser,
) {
@@ -539,12 +559,12 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/approval-steps/:stepId/reject')
@Post(":id/approval-steps/:stepId/reject")
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
@ApiOperation({ summary: 'Reject at approval step' })
@ApiOperation({ summary: "Reject at approval step" })
async rejectStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@Param("id", ParseUUIDPipe) id: string,
@Param("stepId", ParseUUIDPipe) stepId: string,
@Body() dto: RejectStepDto,
@CurrentUser() user: AuthUserPayload,
) {
@@ -557,53 +577,53 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/contract/generate')
@Post(":id/contract/generate")
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
@ApiOperation({ summary: 'Generate contract PDF from template' })
async generateContract(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Generate contract PDF from template" })
async generateContract(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.contractService.generateContract(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/contract/view')
@Get(":id/contract/view")
@ApiOkResponse({ type: ContractViewDto })
@ApiOperation({ summary: 'Contract HTML view for portal and backoffice' })
@ApiOperation({ summary: "Contract HTML view for portal and backoffice" })
getContractView(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Request() req: { user?: { id?: string; sub?: string } },
) {
const userId = req.user?.id ?? req.user?.sub;
return this.contractService.getContractView(id, userId);
}
@Get(':id/contract/document')
@ApiOperation({ summary: 'Download contract PDF' })
@Get(":id/contract/document")
@ApiOperation({ summary: "Download contract PDF" })
async downloadContractDocument(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Res() res: Response,
): Promise<void> {
const { stream, record } = await this.contractService.streamContract(id);
res.setHeader('Content-Type', record.mimeType ?? 'application/pdf');
res.setHeader("Content-Type", record.mimeType ?? "application/pdf");
res.setHeader(
'Content-Disposition',
"Content-Disposition",
`attachment; filename="${record.name}"`,
);
stream.pipe(res);
}
@Get(':id/contract')
@ApiOperation({ summary: 'Download contract file (alias)' })
@Get(":id/contract")
@ApiOperation({ summary: "Download contract file (alias)" })
async downloadContract(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Res() res: Response,
): Promise<void> {
return this.downloadContractDocument(id, res);
}
@Post(':id/contract/sign')
@ApiOperation({ summary: 'Apply digital signature (customer or staff)' })
@Post(":id/contract/sign")
@ApiOperation({ summary: "Apply digital signature (customer or staff)" })
async signContract(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
) {
@@ -615,28 +635,28 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/contract/signatures')
@ApiOperation({ summary: 'List contract signatures' })
getContractSignatures(@Param('id', ParseUUIDPipe) id: string) {
@Get(":id/contract/signatures")
@ApiOperation({ summary: "List contract signatures" })
getContractSignatures(@Param("id", ParseUUIDPipe) id: string) {
return this.contractService.getSignatures(id);
}
@Get(':id/summary')
@ApiOperation({ summary: 'Contract summary string for dashboard' })
getSummary(@Param('id', ParseUUIDPipe) id: string) {
@Get(":id/summary")
@ApiOperation({ summary: "Contract summary string for dashboard" })
getSummary(@Param("id", ParseUUIDPipe) id: string) {
return this.contractService.getSummary(id);
}
@Post(':id/customer/sign')
@Post(":id/customer/sign")
@ApiOperation({
summary: 'Customer digital signature (deprecated — use POST contract/sign)',
summary: "Customer digital signature (deprecated — use POST contract/sign)",
})
async customerSign(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
) {
const payload: SignContractDto = { ...dto, role: 'CUSTOMER' };
const payload: SignContractDto = { ...dto, role: "CUSTOMER" };
const booking = await this.contractService.signContract(id, payload, {
signerUserId: req.user?.id ?? req.user?.sub,
ipAddress: req.ip,
@@ -644,20 +664,21 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/marketing/approve')
@Post(":id/marketing/approve")
@BookingStaff(FREIGHT_PERMS.bookings.signStaff)
@ApiOperation({
summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)',
summary:
"Staff contract signature and fully execute (use contract/sign STAFF preferred)",
})
async marketingApprove(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@CurrentUser() user: AuthUserPayload,
@Request() req: { ip?: string },
) {
const payload: SignContractDto = {
...dto,
role: 'STAFF',
role: "STAFF",
};
const booking = await this.contractService.signContract(id, payload, {
signerUserId: resolveAuthUserId(user),
@@ -666,48 +687,48 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operations/start-transit')
@Post(":id/operations/start-transit")
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Mark in transit' })
async startTransit(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Mark in transit" })
async startTransit(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.transitionService.startTransit(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operations/complete')
@Post(":id/operations/complete")
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Mark completed' })
async complete(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Mark completed" })
async complete(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.transitionService.complete(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/cancel')
@Post(":id/cancel")
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
@ApiOperation({ summary: 'Cancel booking' })
@ApiOperation({ summary: "Cancel booking" })
async cancel(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CancelBookingDto,
) {
const booking = await this.transitionService.cancel(id, dto.reason);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/consolidation')
@ApiOperation({ summary: 'Request freight consolidation' })
requestConsolidation(@Param('id', ParseUUIDPipe) id: string) {
@Post(":id/consolidation")
@ApiOperation({ summary: "Request freight consolidation" })
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.requestConsolidation(id);
}
@Delete(':id/consolidation')
@ApiOperation({ summary: 'Remove consolidation pairing' })
removeConsolidation(@Param('id', ParseUUIDPipe) id: string) {
@Delete(":id/consolidation")
@ApiOperation({ summary: "Remove consolidation pairing" })
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.removeConsolidation(id);
}
@Get(':id/consolidation')
@ApiOperation({ summary: 'Get consolidation details' })
getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) {
@Get(":id/consolidation")
@ApiOperation({ summary: "Get consolidation details" })
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.getConsolidationDetails(id);
}
}

View File

@@ -1,44 +1,44 @@
import { Module, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
import { Module, forwardRef } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { TypeOrmModule } from "@nestjs/typeorm";
import { ExchangeModule, ExchangeOptions } from "@edr/api-common";
// import { CustomersModule } from '../customers/customers.module';
import { CompaniesModule } from '../companies/companies.module';
import { FilesModule } from '../files/files.module';
import { MinioModule } from '../minio/minio.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { BillingModule } from '../billing/billing.module';
import { FirstMileModule } from '../first-mile/first-mile.module';
import { BookingContractService } from './booking-contract.service';
import { BookingInvoiceService } from './booking-invoice.service';
import { BookingPaymentController } from './booking-payment.controller';
import { BookingPaymentService } from './booking-payment.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingTransitionService } from './booking-transition.service';
import { BookingsController } from './bookings.controller';
import { PayController } from './pay.controller';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { BookingsService } from './bookings.service';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingDocumentReview } from './entities/booking-document-review.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
import { BookingReviewNote } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity';
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
import { ContractRendererService } from '../../contracts/contract-renderer.service';
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { CompaniesModule } from "../companies/companies.module";
import { FilesModule } from "../files/files.module";
import { MinioModule } from "../minio/minio.module";
import { RuleEngineModule } from "../rule-engine/rule-engine.module";
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module";
import { SignaturesModule } from "../signatures/signatures.module";
import { BillingModule } from "../billing/billing.module";
import { FirstMileModule } from "../first-mile/first-mile.module";
import { BookingContractService } from "./booking-contract.service";
import { BookingInvoiceService } from "./booking-invoice.service";
import { BookingPaymentController } from "./booking-payment.controller";
import { BookingPaymentService } from "./booking-payment.service";
import { BookingPricingService } from "./booking-pricing.service";
import { BookingReferenceDataService } from "./booking-reference-data.service";
import { BookingTransitionService } from "./booking-transition.service";
import { BookingsController } from "./bookings.controller";
import { PayController } from "./pay.controller";
import { BookingsRepository } from "./bookings.repository";
import { ConsolidationService } from "./consolidation.service";
import { BookingsService } from "./bookings.service";
import { BookingApprovalStep } from "./entities/booking-approval-step.entity";
import { BookingCargoModifier } from "./entities/booking-cargo-modifier.entity";
import { BookingDocumentReview } from "./entities/booking-document-review.entity";
import { BookingContainer } from "./entities/booking-container.entity";
import { BookingRateSnapshot } from "./entities/booking-rate-snapshot.entity";
import { BookingContractSignature } from "./entities/booking-contract-signature.entity";
import { BookingReviewNote } from "./entities/booking-review-note.entity";
import { Booking } from "./entities/booking.entity";
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
import { ContractPdfService } from "../../contracts/contract-pdf.service";
import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder";
import { ContractRendererService } from "../../contracts/contract-renderer.service";
import { ContractTemplateResolver } from "../../contracts/contract-template.resolver";
import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder";
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
@Module({
imports: [
@@ -66,7 +66,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
ExchangeModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): ExchangeOptions =>
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
config.get<ExchangeOptions>("app.cbeExchange") ?? {},
}),
],
controllers: [BookingsController, PayController, BookingPaymentController],
@@ -86,6 +86,11 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
ContractRendererService,
ContractPdfService,
],
exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService],
exports: [
BookingsService,
BookingsRepository,
BookingPricingService,
BookingInvoiceService,
],
})
export class BookingsModule {}
export class BookingsModule { }

View File

@@ -6,8 +6,6 @@ import {
ParseUUIDPipe,
Query,
Res,
Body,
Post,
} from "@nestjs/common";
import {
ApiTags,
@@ -18,9 +16,9 @@ import {
} from "@nestjs/swagger";
import { Response } from "express";
import { Public } from "@edr/api-common";
import { BookingView, FreightAdmin } from "../../common/booking-guards";
import { BookingView } from "../../common/booking-guards";
import { PaymentService } from "./payment.service";
import { IntentStatusDto, RefundDto } from "./payments.dto";
import { IntentStatusDto } from "./payments.dto";
@ApiTags("Payment")
@Controller("payments")
@@ -73,13 +71,6 @@ export class PaymentController {
return this.paymentService.getIntentByBookingId(bookingId);
}
@Post("refund")
@FreightAdmin()
@ApiOperation({ summary: "Refund a paid booking (staff/admin only)" })
refund(@Body() dto: RefundDto) {
return this.paymentService.refund(dto);
}
@Get("receipt/:orderId")
@Public()
@ApiOperation({ summary: "Generate a payment receipt HTML page" })

File diff suppressed because it is too large Load Diff

View File

@@ -110,6 +110,7 @@ describe('BookingBatchService — PAID reconcile', () => {
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
);
});

View File

@@ -4,22 +4,24 @@ import {
Logger,
NotFoundException,
OnModuleInit,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { Cron, SchedulerRegistry } from '@nestjs/schedule';
import { DataSource } from 'typeorm';
} from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { Cron, SchedulerRegistry } from "@nestjs/schedule";
import { DataSource } from "typeorm";
import { Freight } from "@edr/types";
import { Booking } from '../bookings/entities/booking.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
import { BillingService } from "../billing/billing.service";
import { Booking } from "../bookings/entities/booking.entity";
import { BookingsRepository } from "../bookings/bookings.repository";
import { Locomotive } from "../locomotives/entities/locomotive.entity";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity";
import { TrainSchedulesRepository } from "../train-schedules/train-schedules.repository";
import { TrainScheduleBookingsRepository } from "../train-schedules/train-schedule-bookings.repository";
import { TrainSchedulingGlobalRules } from "./entities/train-scheduling-global-rules.entity";
import { BookingNotifierService } from "./booking-notifier.service";
import { TrainSchedulingService } from "./train-scheduling.service";
import { eatDay, groupBookingsIntoBoardWindows } from "./batch-window.util";
import {
BATCH_CRON,
BATCH_TIMEZONE,
@@ -27,13 +29,13 @@ import {
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_WAGONS_PER_BOOKING,
PAYMENT_WINDOW_MS,
} from './booking-batch.constants';
} from "./booking-batch.constants";
import {
bookingTrainLengthMeters,
deriveTrainCapacityFromLocomotive,
wagonTypeDimensionsFromEntity,
} from './train-capacity.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
} from "./train-capacity.util";
import { WagonType } from "../wagon-types/entities/wagon-type.entity";
/** A train's remaining capacity along the three physical limits the batch enforces. */
interface Capacity {
@@ -53,12 +55,12 @@ interface RouteDayGroup {
type WagonLengths = { container: number; bulk: number };
export type BatchBoardBookingState =
| 'ALLOCATED'
| 'SELECTED_FOR_BATCH'
| 'READY'
| 'WAITING'
| 'PENDING_CONTRACT'
| 'EXPIRED';
| "ALLOCATED"
| "SELECTED_FOR_BATCH"
| "READY"
| "WAITING"
| "PENDING_CONTRACT"
| "EXPIRED";
export interface BatchBoardBooking {
id: string;
@@ -73,10 +75,10 @@ export interface BatchBoardBooking {
}
export type BookingAllocationStatus =
| 'NOT_ATTEMPTED'
| 'ASSIGNED'
| 'DEFERRED'
| 'FAILED';
| "NOT_ATTEMPTED"
| "ASSIGNED"
| "DEFERRED"
| "FAILED";
export interface BatchBoardBookingDetail extends BatchBoardBooking {
fullyExecutedAt: string | null;
@@ -114,9 +116,9 @@ export interface BatchBoardScheduleDetail {
scheduleDate: string | null;
status: string;
bookingWindowStatus: string;
locomotive: BatchBoardSchedule['locomotive'];
capacity: BatchBoardSchedule['capacity'];
counts: BatchBoardSchedule['counts'];
locomotive: BatchBoardSchedule["locomotive"];
capacity: BatchBoardSchedule["capacity"];
counts: BatchBoardSchedule["counts"];
windows: BatchWindowGroup[];
pendingContract: BatchWindowGroup;
allocationViolations: string[];
@@ -179,7 +181,8 @@ export class BookingBatchService implements OnModuleInit {
private readonly notifier: BookingNotifierService,
private readonly scheduler: SchedulerRegistry,
private readonly trainSchedulingService: TrainSchedulingService,
) {}
private readonly billing: BillingService,
) { }
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
async onModuleInit(): Promise<void> {
@@ -195,10 +198,10 @@ export class BookingBatchService implements OnModuleInit {
}
const reserved = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.select('DISTINCT b.train_schedule_id', 'scheduleId')
.createQueryBuilder("b")
.select("DISTINCT b.train_schedule_id", "scheduleId")
.where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
.andWhere('b.train_schedule_id IS NOT NULL')
.andWhere("b.train_schedule_id IS NOT NULL")
.getRawMany<{ scheduleId: string }>();
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
}
@@ -276,7 +279,7 @@ export class BookingBatchService implements OnModuleInit {
/** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
const open = await this.trainSchedulesRepository.findAll({
where: { bookingWindowStatus: 'OPEN' },
where: { bookingWindowStatus: "OPEN" },
});
const groups = new Map<string, RouteDayGroup>();
for (const s of open) {
@@ -310,25 +313,29 @@ export class BookingBatchService implements OnModuleInit {
if (!booking?.trainScheduleId) return;
const isBatchPaid =
booking.status === 'SELECTED_FOR_BATCH' ||
booking.status === 'AWAITING_PAYMENT' ||
booking.status === 'PAID' ||
booking.paymentStatus === 'PAID';
booking.status === "SELECTED_FOR_BATCH" ||
booking.status === "AWAITING_PAYMENT" ||
booking.status === "PAID" ||
booking.paymentStatus === "PAID";
if (!isBatchPaid) return;
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
if (
booking.status === "SELECTED_FOR_BATCH" ||
booking.status === "AWAITING_PAYMENT"
) {
await this.dataSource
.getRepository(Booking)
.update(bookingId, { paymentStatus: 'PAID', status: 'PAID' });
} else if (booking.paymentStatus !== 'PAID') {
.update(bookingId, { paymentStatus: "PAID", status: "PAID" });
} else if (booking.paymentStatus !== "PAID") {
await this.dataSource
.getRepository(Booking)
.update(bookingId, { paymentStatus: 'PAID' });
.update(bookingId, { paymentStatus: "PAID" });
}
const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
const linked =
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
if (!linked) {
await this.allocate(booking.trainScheduleId, booking, 'paid');
await this.allocate(booking.trainScheduleId, booking, "paid");
this.logger.log(
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
);
@@ -338,7 +345,7 @@ export class BookingBatchService implements OnModuleInit {
booking.trainScheduleId,
);
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
await this.setWindow(booking.trainScheduleId, 'FULL');
await this.setWindow(booking.trainScheduleId, "FULL");
}
const result = await this.trainSchedulingService.tryAutoWagonAllocation(
@@ -349,7 +356,11 @@ export class BookingBatchService implements OnModuleInit {
`Wagon allocation for ${booking.reference ?? bookingId}: ${result.assignedBookingIds.length} assigned`,
);
}
if (result.issues.some((i) => i.bookingId === bookingId && i.status !== 'ASSIGNED')) {
if (
result.issues.some(
(i) => i.bookingId === bookingId && i.status !== "ASSIGNED",
)
) {
const issue = result.issues.find((i) => i.bookingId === bookingId);
this.logger.warn(
`Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`,
@@ -364,9 +375,10 @@ export class BookingBatchService implements OnModuleInit {
/** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */
async reconcilePaidUnlinked(scheduleId: string): Promise<void> {
const unlinked = await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId);
const unlinked =
await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId);
for (const booking of unlinked) {
await this.allocate(scheduleId, booking, 'paid');
await this.allocate(scheduleId, booking, "paid");
this.logger.log(
`Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
);
@@ -375,7 +387,7 @@ export class BookingBatchService implements OnModuleInit {
// ---- cron entry point -----------------------------------------------------
@Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE })
@Cron(BATCH_CRON, { name: "booking-batch-fill", timeZone: BATCH_TIMEZONE })
async runBatchFill(): Promise<void> {
const groups = await this.openRouteDayGroups();
this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
@@ -405,7 +417,7 @@ export class BookingBatchService implements OnModuleInit {
destinationStation: true,
route: true,
},
order: { scheduledDepartureDate: 'ASC' },
order: { scheduledDepartureDate: "ASC" },
});
const wagonLengths = await this.loadWagonLengths();
@@ -413,7 +425,7 @@ export class BookingBatchService implements OnModuleInit {
const board: BatchBoardSchedule[] = [];
for (const s of schedules) {
if (s.status === 'ARRIVED' || s.status === 'CANCELLED') continue;
if (s.status === "ARRIVED" || s.status === "CANCELLED") continue;
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
const linkedIds = new Set(links.map((l) => l.bookingId));
@@ -425,13 +437,15 @@ export class BookingBatchService implements OnModuleInit {
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
company: b.isGovernment
? (b.governmentInstitution ?? 'Government')
: (b.company?.name ?? '—'),
? (b.governmentInstitution ?? "Government")
: (b.company?.name ?? "—"),
isGovernment: Boolean(b.isGovernment),
wagons: need.wagons,
weightTons: need.weightTons,
lengthMeters: need.lengthMeters,
paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null,
paymentDeadline: b.paymentDeadline
? b.paymentDeadline.toISOString()
: null,
state: this.boardState(b, linkedIds.has(b.id)),
};
});
@@ -442,11 +456,15 @@ export class BookingBatchService implements OnModuleInit {
}
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
async getBatchBoardDetail(scheduleId: string): Promise<BatchBoardScheduleDetail> {
const s = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!s) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
if (s.status === 'ARRIVED' || s.status === 'CANCELLED') {
throw new BadRequestException('Schedule is no longer active');
async getBatchBoardDetail(
scheduleId: string,
): Promise<BatchBoardScheduleDetail> {
const s =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!s)
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
if (s.status === "ARRIVED" || s.status === "CANCELLED") {
throw new BadRequestException("Schedule is no longer active");
}
const wagonLengths = await this.loadWagonLengths();
@@ -456,12 +474,18 @@ export class BookingBatchService implements OnModuleInit {
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
let allocationPreview: Awaited<
ReturnType<TrainSchedulingService['previewAllocationForSchedule']>
ReturnType<TrainSchedulingService["previewAllocationForSchedule"]>
>;
try {
allocationPreview = await this.trainSchedulingService.previewAllocationForSchedule(s.id);
allocationPreview =
await this.trainSchedulingService.previewAllocationForSchedule(s.id);
} catch {
allocationPreview = { assignedBookingIds: [], deferred: [], issues: [], violations: [] };
allocationPreview = {
assignedBookingIds: [],
deferred: [],
issues: [],
violations: [],
};
}
const allocationByBooking = new Map(
allocationPreview.issues.map((i) => [i.bookingId, i]),
@@ -474,17 +498,23 @@ export class BookingBatchService implements OnModuleInit {
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
company: b.isGovernment
? (b.governmentInstitution ?? 'Government')
: (b.company?.name ?? '—'),
? (b.governmentInstitution ?? "Government")
: (b.company?.name ?? "—"),
isGovernment: Boolean(b.isGovernment),
wagons: need.wagons,
weightTons: need.weightTons,
lengthMeters: need.lengthMeters,
paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null,
paymentDeadline: b.paymentDeadline
? b.paymentDeadline.toISOString()
: null,
state: this.boardState(b, linkedIds.has(b.id)),
fullyExecutedAt: b.fullyExecutedAt ? b.fullyExecutedAt.toISOString() : null,
selectedForBatchAt: b.selectedForBatchAt ? b.selectedForBatchAt.toISOString() : null,
allocationStatus: alloc?.status ?? 'NOT_ATTEMPTED',
fullyExecutedAt: b.fullyExecutedAt
? b.fullyExecutedAt.toISOString()
: null,
selectedForBatchAt: b.selectedForBatchAt
? b.selectedForBatchAt.toISOString()
: null,
allocationStatus: alloc?.status ?? "NOT_ATTEMPTED",
allocationIssue: alloc?.issue ?? null,
};
});
@@ -514,11 +544,11 @@ export class BookingBatchService implements OnModuleInit {
const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => {
const counts = emptyCounts();
for (const b of bookingsInWindow) {
if (b.state === 'ALLOCATED') counts.allocated += 1;
else if (b.state === 'SELECTED_FOR_BATCH') counts.selectedForBatch += 1;
else if (b.state === 'READY') counts.ready += 1;
else if (b.state === 'WAITING') counts.waiting += 1;
else if (b.state === 'EXPIRED') counts.expired += 1;
if (b.state === "ALLOCATED") counts.allocated += 1;
else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1;
else if (b.state === "READY") counts.ready += 1;
else if (b.state === "WAITING") counts.waiting += 1;
else if (b.state === "EXPIRED") counts.expired += 1;
else counts.pendingContract += 1;
}
return counts;
@@ -526,7 +556,7 @@ export class BookingBatchService implements OnModuleInit {
const windows: BatchWindowGroup[] = [];
for (const [key, bucket] of windowBuckets) {
if (key === 'pending-contract' || !bucket.window) continue;
if (key === "pending-contract" || !bucket.window) continue;
const w = bucket.window;
windows.push({
key: w.key,
@@ -539,44 +569,51 @@ export class BookingBatchService implements OnModuleInit {
bookings: bucket.items,
});
}
windows.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
windows.sort(
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(),
);
const pendingBookings = windowBuckets.get('pending-contract')?.items ?? [];
const pendingBookings = windowBuckets.get("pending-contract")?.items ?? [];
return {
scheduleId: s.id,
trainNumber: s.trainNumber ?? null,
routeName: s.route?.name ?? null,
origin: s.originStation?.label ?? s.originStation?.code ?? null,
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
destination:
s.destinationStation?.label ?? s.destinationStation?.code ?? null,
scheduleDate: s.scheduledDepartureDate
? s.scheduledDepartureDate.toISOString()
: null,
status: s.status,
bookingWindowStatus: s.bookingWindowStatus,
locomotive: loco
? {
code: loco.code,
name: loco.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
code: loco.code,
name: loco.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: this.computeBoardCapacity(items, loco),
counts: {
allocated: items.filter((i) => i.state === 'ALLOCATED').length,
selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length,
ready: items.filter((i) => i.state === 'READY').length,
waiting: items.filter((i) => i.state === 'WAITING').length,
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length,
expired: items.filter((i) => i.state === 'EXPIRED').length,
allocated: items.filter((i) => i.state === "ALLOCATED").length,
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
.length,
ready: items.filter((i) => i.state === "READY").length,
waiting: items.filter((i) => i.state === "WAITING").length,
pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT")
.length,
expired: items.filter((i) => i.state === "EXPIRED").length,
},
windows,
pendingContract: {
key: 'pending-contract',
label: 'Pending contract',
date: '',
dateLabel: '',
start: '',
end: '',
key: "pending-contract",
label: "Pending contract",
date: "",
dateLabel: "",
start: "",
end: "",
counts: countFor(pendingBookings),
bookings: pendingBookings,
},
@@ -597,17 +634,21 @@ export class BookingBatchService implements OnModuleInit {
lengthMeters: number;
}>,
loco: Locomotive | null,
): BatchBoardSchedule['capacity'] {
const allocated = items.filter((i) => i.state === 'ALLOCATED');
): BatchBoardSchedule["capacity"] {
const allocated = items.filter((i) => i.state === "ALLOCATED");
const committed = items.filter(
(i) => i.state === 'ALLOCATED' || i.state === 'SELECTED_FOR_BATCH',
(i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH",
);
return {
allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
allocatedLengthMeters:
Math.round(allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100) / 100,
Math.round(
allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100,
) / 100,
maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null,
usedWeightTons: Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / 100,
usedWeightTons:
Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) /
100,
maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null,
};
}
@@ -623,51 +664,66 @@ export class BookingBatchService implements OnModuleInit {
trainNumber: s.trainNumber ?? null,
routeName: s.route?.name ?? null,
origin: s.originStation?.label ?? s.originStation?.code ?? null,
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
destination:
s.destinationStation?.label ?? s.destinationStation?.code ?? null,
scheduleDate: s.scheduledDepartureDate
? s.scheduledDepartureDate.toISOString()
: null,
status: s.status,
bookingWindowStatus: s.bookingWindowStatus,
locomotive: loco
? {
code: loco.code,
name: loco.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
code: loco.code,
name: loco.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: this.computeBoardCapacity(items, loco),
counts: {
allocated: items.filter((i) => i.state === 'ALLOCATED').length,
selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length,
ready: items.filter((i) => i.state === 'READY').length,
waiting: items.filter((i) => i.state === 'WAITING').length,
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length,
expired: items.filter((i) => i.state === 'EXPIRED').length,
allocated: items.filter((i) => i.state === "ALLOCATED").length,
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
.length,
ready: items.filter((i) => i.state === "READY").length,
waiting: items.filter((i) => i.state === "WAITING").length,
pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT")
.length,
expired: items.filter((i) => i.state === "EXPIRED").length,
},
bookings: items.slice(0, 3),
};
}
private boardState(booking: Booking, linked: boolean): BatchBoardBookingState {
if (linked) return 'ALLOCATED';
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
return 'SELECTED_FOR_BATCH';
private boardState(
booking: Booking,
linked: boolean,
): BatchBoardBookingState {
if (linked) return "ALLOCATED";
if (
booking.status === "SELECTED_FOR_BATCH" ||
booking.status === "AWAITING_PAYMENT"
) {
return "SELECTED_FOR_BATCH";
}
if (booking.status === 'EXPIRED') return 'EXPIRED';
if (booking.status === 'FULLY_EXECUTED' && booking.fullyExecutedAt) return 'READY';
if (booking.status === 'PAID') return 'WAITING';
return 'PENDING_CONTRACT';
if (booking.status === "EXPIRED") return "EXPIRED";
if (booking.status === "FULLY_EXECUTED" && booking.fullyExecutedAt)
return "READY";
if (booking.status === "PAID") return "WAITING";
return "PENDING_CONTRACT";
}
// ---- core fill ------------------------------------------------------------
/** Fill one schedule from its priority-ordered pool until full. */
async fillSchedule(scheduleId: string): Promise<void> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule || schedule.bookingWindowStatus !== 'OPEN') return;
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule || schedule.bookingWindowStatus !== "OPEN") return;
const locomotive = schedule.trainSet?.locomotive;
if (!schedule.trainSetId || !locomotive) {
this.logger.warn(`Schedule ${scheduleId} has no locomotive/train set — skipped.`);
this.logger.warn(
`Schedule ${scheduleId} has no locomotive/train set — skipped.`,
);
return;
}
@@ -677,7 +733,7 @@ export class BookingBatchService implements OnModuleInit {
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
let budget = await this.remainingCapacity(schedule, limits, wagonLengths);
if (budget.wagons <= 0) {
await this.setWindow(scheduleId, 'FULL');
await this.setWindow(scheduleId, "FULL");
return;
}
@@ -689,7 +745,12 @@ export class BookingBatchService implements OnModuleInit {
if (!this.fits(need, budget)) {
if (booking.isGovernment) {
budget = await this.preemptForGovernment(scheduleId, need, budget, wagonLengths);
budget = await this.preemptForGovernment(
scheduleId,
need,
budget,
wagonLengths,
);
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
} else {
continue; // skip a booking that exceeds weight/length/wagons, try the next
@@ -697,7 +758,7 @@ export class BookingBatchService implements OnModuleInit {
}
if (booking.isGovernment) {
await this.allocate(scheduleId, booking, 'gov');
await this.allocate(scheduleId, booking, "gov");
} else {
await this.reserve(booking, scheduleId);
armed = true;
@@ -706,7 +767,7 @@ export class BookingBatchService implements OnModuleInit {
if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board
}
if (budget.wagons <= 0) await this.setWindow(scheduleId, 'FULL');
if (budget.wagons <= 0) await this.setWindow(scheduleId, "FULL");
if (armed) this.armSettle(scheduleId);
void this.triggerWagonAllocation(scheduleId);
}
@@ -732,13 +793,14 @@ export class BookingBatchService implements OnModuleInit {
const scheduleIds = bookable
.filter(
(s) =>
s.bookingWindowStatus === 'OPEN' &&
s.bookingWindowStatus === "OPEN" &&
s.scheduleDate != null &&
eatDay(new Date(s.scheduleDate)) === day,
)
.sort(
(a, b) =>
new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(),
new Date(a.scheduleDate).getTime() -
new Date(b.scheduleDate).getTime(),
)
.map((s) => s.id);
@@ -750,15 +812,22 @@ export class BookingBatchService implements OnModuleInit {
// Live per-schedule budget + arm flag, in departure order.
const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = [];
for (const id of scheduleIds) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !schedule.trainSetId || !locomotive) {
this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`);
this.logger.warn(
`Schedule ${id} has no locomotive/train set — skipped.`,
);
continue;
}
const limits = await this.capacityLimits(locomotive, rules);
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
const budget = await this.remainingCapacity(
schedule,
limits,
wagonLengths,
);
trains.push({ id, budget, armed: false });
}
if (trains.length === 0) return [];
@@ -779,7 +848,12 @@ export class BookingBatchService implements OnModuleInit {
// Government booking fits nowhere on its own — try to preempt commercial
// on each train (earliest first) until one frees enough room.
for (const t of trains) {
t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths);
t.budget = await this.preemptForGovernment(
t.id,
need,
t.budget,
wagonLengths,
);
if (this.fits(need, t.budget)) {
target = t;
break;
@@ -794,7 +868,7 @@ export class BookingBatchService implements OnModuleInit {
}
if (booking.isGovernment) {
await this.allocate(target.id, booking, 'gov');
await this.allocate(target.id, booking, "gov");
} else {
await this.reserve(booking, target.id);
target.armed = true;
@@ -803,7 +877,7 @@ export class BookingBatchService implements OnModuleInit {
}
for (const t of trains) {
if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL');
if (t.budget.wagons <= 0) await this.setWindow(t.id, "FULL");
if (t.armed) this.armSettle(t.id);
void this.triggerWagonAllocation(t.id);
}
@@ -813,18 +887,20 @@ export class BookingBatchService implements OnModuleInit {
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
async settleDueReservations(scheduleId: string): Promise<void> {
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
const reserved =
await this.bookingsRepository.findReservedForSchedule(scheduleId);
const now = Date.now();
let anySettled = false;
for (const booking of reserved) {
const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID';
const paid =
booking.paymentStatus === "PAID" || booking.status === "PAID";
const expired = booking.paymentDeadline
? booking.paymentDeadline.getTime() <= now
: false;
if (paid) {
await this.allocate(scheduleId, booking, 'paid');
await this.allocate(scheduleId, booking, "paid");
anySettled = true;
} else if (expired) {
await this.expire(booking);
@@ -840,17 +916,19 @@ export class BookingBatchService implements OnModuleInit {
/** Allocate paid reservations, expire the rest, then top up. */
async settleBatch(scheduleId: string): Promise<void> {
this.removeTimeout(scheduleId);
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
const reserved =
await this.bookingsRepository.findReservedForSchedule(scheduleId);
const now = Date.now();
for (const booking of reserved) {
const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID';
const paid =
booking.paymentStatus === "PAID" || booking.status === "PAID";
const expired = booking.paymentDeadline
? booking.paymentDeadline.getTime() <= now
: true;
if (paid) {
await this.allocate(scheduleId, booking, 'paid');
await this.allocate(scheduleId, booking, "paid");
} else if (expired) {
await this.expire(booking);
}
@@ -862,11 +940,13 @@ export class BookingBatchService implements OnModuleInit {
}
private triggerWagonAllocation(scheduleId: string): void {
void this.trainSchedulingService.tryAutoWagonAllocation(scheduleId).catch((err) =>
this.logger.warn(
`Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`,
),
);
void this.trainSchedulingService
.tryAutoWagonAllocation(scheduleId)
.catch((err) =>
this.logger.warn(
`Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`,
),
);
}
// ---- staff override actions ----------------------------------------------
@@ -878,18 +958,20 @@ export class BookingBatchService implements OnModuleInit {
.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (!booking.trainScheduleId) {
throw new BadRequestException('Booking has no target schedule to allocate to');
throw new BadRequestException(
"Booking has no target schedule to allocate to",
);
}
await this.dataSource
.getRepository(Booking)
.update(bookingId, { paymentStatus: 'PAID' });
await this.allocate(booking.trainScheduleId, booking, 'paid');
.update(bookingId, { paymentStatus: "PAID" });
await this.allocate(booking.trainScheduleId, booking, "paid");
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
booking.trainScheduleId,
);
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
await this.setWindow(booking.trainScheduleId, 'FULL');
await this.setWindow(booking.trainScheduleId, "FULL");
}
void this.triggerWagonAllocation(booking.trainScheduleId!);
}
@@ -898,7 +980,10 @@ export class BookingBatchService implements OnModuleInit {
* Re-point a booking to another OPEN same-route schedule (keeps approval/contract + priority).
* Used for EXPIRED or full-schedule bookings — no re-approval.
*/
async moveToSchedule(bookingId: string, newScheduleId: string): Promise<void> {
async moveToSchedule(
bookingId: string,
newScheduleId: string,
): Promise<void> {
const booking = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId } });
@@ -907,15 +992,20 @@ export class BookingBatchService implements OnModuleInit {
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: newScheduleId } });
if (!schedule) throw new NotFoundException(`Train schedule ${newScheduleId} not found`);
if (schedule.bookingWindowStatus !== 'OPEN') {
throw new BadRequestException('Target schedule is not accepting bookings');
if (!schedule)
throw new NotFoundException(`Train schedule ${newScheduleId} not found`);
if (schedule.bookingWindowStatus !== "OPEN") {
throw new BadRequestException(
"Target schedule is not accepting bookings",
);
}
if (
schedule.originStationId !== booking.originYardId ||
schedule.destinationStationId !== booking.destinationYardId
) {
throw new BadRequestException('Target schedule is not on the booking route');
throw new BadRequestException(
"Target schedule is not on the booking route",
);
}
await this.dataSource.transaction(async (manager) => {
@@ -927,15 +1017,15 @@ export class BookingBatchService implements OnModuleInit {
);
}
const restoredStatus =
booking.status === 'EXPIRED'
booking.status === "EXPIRED"
? booking.isGovernment
? 'APPROVED'
: 'FULLY_EXECUTED'
? "APPROVED"
: "FULLY_EXECUTED"
: booking.status;
await manager.getRepository(Booking).update(bookingId, {
trainScheduleId: newScheduleId,
status: restoredStatus,
schedulingStatus: 'ELIGIBLE',
schedulingStatus: "ELIGIBLE",
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
@@ -949,7 +1039,8 @@ export class BookingBatchService implements OnModuleInit {
.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
await this.expire(booking);
if (booking.trainScheduleId) await this.fillSchedule(booking.trainScheduleId);
if (booking.trainScheduleId)
await this.fillSchedule(booking.trainScheduleId);
}
// ---- mutations ------------------------------------------------------------
@@ -967,11 +1058,18 @@ export class BookingBatchService implements OnModuleInit {
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
await this.bookingsRepository.update(booking.id, {
trainScheduleId: scheduleId,
status: 'SELECTED_FOR_BATCH',
status: "SELECTED_FOR_BATCH",
selectedForBatchAt: now,
paymentDeadline: deadline,
} as never);
booking.trainScheduleId = scheduleId;
// The invoice was generated at booking creation/approval, before this pay
// window opened — refresh its printed due date to the real deadline.
await this.billing.syncPayableDueDate(
Freight.InvoiceSource.Booking,
booking.id,
deadline,
);
await this.notifier.payNow(booking, deadline);
}
@@ -979,13 +1077,14 @@ export class BookingBatchService implements OnModuleInit {
private async allocate(
scheduleId: string,
booking: Booking,
reason: 'paid' | 'gov',
reason: "paid" | "gov",
): Promise<void> {
await this.dataSource.transaction(async (manager) => {
const exists = await this.trainScheduleBookingsRepository.existsForBooking(
booking.id,
manager,
);
const exists =
await this.trainScheduleBookingsRepository.existsForBooking(
booking.id,
manager,
);
if (!exists) {
await this.trainScheduleBookingsRepository.createMany(
[{ trainScheduleId: scheduleId, bookingId: booking.id }],
@@ -993,8 +1092,8 @@ export class BookingBatchService implements OnModuleInit {
);
}
await manager.getRepository(Booking).update(booking.id, {
status: reason === 'paid' ? 'PAID' : booking.status,
schedulingStatus: 'SCHEDULED',
status: reason === "paid" ? "PAID" : booking.status,
schedulingStatus: "SCHEDULED",
scheduledAt: new Date(),
paymentDeadline: null,
selectedForBatchAt: null,
@@ -1012,12 +1111,16 @@ export class BookingBatchService implements OnModuleInit {
private async expire(booking: Booking): Promise<void> {
await this.bookingsRepository.update(booking.id, {
trainScheduleId: null,
status: 'EXPIRED',
schedulingStatus: 'ELIGIBLE',
status: "EXPIRED",
schedulingStatus: "ELIGIBLE",
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
booking.trainScheduleId = null;
// Pay window closed before settlement → expire the booking's open invoice too
// (emits `booking.invoice.expired`). Domain owns the reaction; billing stays
// source-agnostic.
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id);
this.notifier.expired(booking);
}
@@ -1035,7 +1138,9 @@ export class BookingBatchService implements OnModuleInit {
await this.bookingsRepository.findReservedForSchedule(scheduleId)
).filter((b) => !b.isGovernment);
const allocatedCommercial =
await this.bookingsRepository.findAllocatedCommercialForSchedule(scheduleId);
await this.bookingsRepository.findAllocatedCommercialForSchedule(
scheduleId,
);
// lowest priority first; reserved are cheaper to free than allocated
const candidates = [...reservedCommercial, ...allocatedCommercial].sort(
@@ -1052,11 +1157,18 @@ export class BookingBatchService implements OnModuleInit {
manager,
);
await manager.getRepository(Booking).update(victim.id, {
status: 'EXPIRED',
schedulingStatus: 'ELIGIBLE',
status: "EXPIRED",
schedulingStatus: "ELIGIBLE",
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
// Displaced → EXPIRED: close its open invoice too, so a dead booking
// can't still be paid (mirrors `expire()`; enlisted in this txn).
await this.billing.expirePayable(
Freight.InvoiceSource.Booking,
victim.id,
manager,
);
});
this.notifier.displaced(victim);
freed = this.add(freed, this.needFor(victim, wagonLengths));
@@ -1074,7 +1186,10 @@ export class BookingBatchService implements OnModuleInit {
(sum, c) => sum + Number(c.quantity ?? 0),
0,
);
return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers || DEFAULT_WAGONS_PER_BOOKING);
return Math.max(
DEFAULT_WAGONS_PER_BOOKING,
fromContainers || DEFAULT_WAGONS_PER_BOOKING,
);
}
/** What one booking consumes along all three capacity axes. */
@@ -1161,7 +1276,7 @@ export class BookingBatchService implements OnModuleInit {
Array<{ lengthMeters: number; capacityTons: number }>
> {
const types = await this.dataSource.getRepository(WagonType).find({
where: [{ code: 'NW5' }, { code: 'CW3' }],
where: [{ code: "NW5" }, { code: "CW3" }],
});
if (types.length) return types.map(wagonTypeDimensionsFromEntity);
return [
@@ -1172,17 +1287,23 @@ export class BookingBatchService implements OnModuleInit {
private async loadWagonLengths(): Promise<WagonLengths> {
const types = await this.dataSource.getRepository(WagonType).find({
where: [{ code: 'NW5' }, { code: 'CW3' }],
where: [{ code: "NW5" }, { code: "CW3" }],
});
const byCode = new Map(types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]));
const byCode = new Map(
types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]),
);
return {
container: byCode.get('NW5')?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
bulk: byCode.get('CW3')?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
container:
byCode.get("NW5")?.lengthMeters ??
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
bulk: byCode.get("CW3")?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
};
}
private async loadGlobalRules(): Promise<TrainSchedulingGlobalRules | null> {
return this.dataSource.getRepository(TrainSchedulingGlobalRules).findOne({ where: {} });
return this.dataSource
.getRepository(TrainSchedulingGlobalRules)
.findOne({ where: {} });
}
/** Remaining capacity = hard caps minus what allocated + reserved bookings already use. */
@@ -1194,7 +1315,9 @@ export class BookingBatchService implements OnModuleInit {
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id);
const reserved = await this.bookingsRepository.findReservedForSchedule(
schedule.id,
);
const used = [...allocated, ...reserved].reduce<Capacity>(
(acc, b) => this.add(acc, this.needFor(b, wagonLengths)),
{ wagons: 0, weightTons: 0, lengthMeters: 0 },
@@ -1207,7 +1330,9 @@ export class BookingBatchService implements OnModuleInit {
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id);
const reserved = await this.bookingsRepository.findReservedForSchedule(
schedule.id,
);
const used =
allocated.reduce((s, b) => s + this.wagonsFor(b), 0) +
reserved.reduce((s, b) => s + this.wagonsFor(b), 0);
@@ -1216,7 +1341,7 @@ export class BookingBatchService implements OnModuleInit {
private async setWindow(
scheduleId: string,
status: 'OPEN' | 'FULL' | 'CLOSED',
status: "OPEN" | "FULL" | "CLOSED",
): Promise<void> {
await this.dataSource
.getRepository(TrainSchedule)
@@ -1233,7 +1358,9 @@ export class BookingBatchService implements OnModuleInit {
this.removeTimeout(scheduleId);
const handle = setTimeout(() => {
void this.settleBatch(scheduleId).catch((err) =>
this.logger.error(`settleBatch ${scheduleId} failed: ${(err as Error).message}`),
this.logger.error(
`settleBatch ${scheduleId} failed: ${(err as Error).message}`,
),
);
}, PAYMENT_WINDOW_MS);
this.scheduler.addTimeout(this.timeoutName(scheduleId), handle);
@@ -1242,7 +1369,7 @@ export class BookingBatchService implements OnModuleInit {
private removeTimeout(scheduleId: string): void {
const name = this.timeoutName(scheduleId);
try {
if (this.scheduler.doesExist('timeout', name)) {
if (this.scheduler.doesExist("timeout", name)) {
this.scheduler.deleteTimeout(name);
}
} catch {

View File

@@ -1,6 +1,7 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingModule } from '../billing/billing.module';
import { BookingsModule } from '../bookings/bookings.module';
import { Container } from '../container-management/entities/container.entity';
import { LocomotivesModule } from '../locomotives/locomotives.module';
@@ -42,6 +43,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
ImportDjiboutiOperation,
]),
forwardRef(() => BookingsModule),
BillingModule,
NotificationsModule,
LocomotivesModule,
WagonTypesModule,

View File

@@ -1,50 +0,0 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { WarehouseFeeInvoice } from './warehouse-fee-invoice.entity';
export const WAREHOUSE_FEE_TYPES = [
'CONTAINER_DEMURRAGE',
'BULK_DEMURRAGE',
'STORAGE_FEE',
'HANDLING_FEE',
] as const;
export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number];
@Entity({ schema: 'freight', name: 'warehouse_fee_invoice_items' })
@Index(['invoiceId'])
export class WarehouseFeeInvoiceItem extends BaseEntity {
@Column({ name: 'invoice_id', type: 'uuid' })
invoiceId!: string;
@ManyToOne(() => WarehouseFeeInvoice, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'invoice_id' })
invoice?: WarehouseFeeInvoice;
@Column({ name: 'fee_rule_id', type: 'uuid', nullable: true })
feeRuleId?: string | null;
@Column({ name: 'fee_type', type: 'varchar', length: 32 })
feeType!: WarehouseFeeType;
@Column({ name: 'description', type: 'varchar', length: 255 })
description!: string;
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 2, default: 1 })
quantity!: number;
@Column({ name: 'unit_rate', type: 'numeric', precision: 14, scale: 2, default: 0 })
unitRate!: number;
@Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
amount!: number;
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' })
currency!: string;
@Column({ name: 'chargeable_days', type: 'int', nullable: true })
chargeableDays?: number | null;
@Column({ name: 'free_days', type: 'int', nullable: true })
freeDays?: number | null;
}

View File

@@ -1,107 +0,0 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const;
export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number];
export const WAREHOUSE_INVOICE_STATUSES = [
'DRAFT',
'ISSUED',
'PARTIALLY_PAID',
'PAID',
'CANCELLED',
] as const;
export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number];
/** A single recorded payment against a warehouse fee invoice (history). */
export interface WarehouseInvoicePayment {
amount: number;
method?: string | null;
reference?: string | null;
paidAt: string;
}
/**
* Batch 6 — invoice generated from Batch 5 demurrage/storage fee calculation.
* Owns warehouse fees; links to booking/customer/inventory/location so it can
* connect to the existing payment module without duplicating it.
*/
@Entity({ schema: 'freight', name: 'warehouse_fee_invoices' })
@Index(['invoiceNumber'], { unique: true })
@Index(['bookingId'])
@Index(['inventoryId'])
@Index(['status'])
export class WarehouseFeeInvoice extends BaseEntity {
@Column({ name: 'invoice_number', type: 'varchar', length: 40, unique: true })
invoiceNumber!: string;
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string | null;
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
customerId?: string | null;
@Column({ name: 'inventory_id', type: 'uuid' })
inventoryId!: string;
@Column({ name: 'facility_id', type: 'uuid', nullable: true })
facilityId?: string | null;
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
warehouseId?: string | null;
@Column({ name: 'yard_id', type: 'uuid', nullable: true })
yardId?: string | null;
@Column({ name: 'zone_id', type: 'uuid', nullable: true })
zoneId?: string | null;
@Column({ name: 'invoice_type', type: 'varchar', length: 32, default: 'MIXED_WAREHOUSE_FEES' })
invoiceType!: WarehouseInvoiceType;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
status!: WarehouseInvoiceStatus;
@Column({ name: 'subtotal_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
subtotalAmount!: number;
@Column({ name: 'tax_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
taxAmount!: number;
@Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
totalAmount!: number;
@Column({ name: 'paid_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
paidAmount!: number;
@Column({ name: 'balance_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
balanceAmount!: number;
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' })
currency!: string;
/** Charge window covered by this invoice — used to allow a later invoice for a new period. */
@Column({ name: 'period_start', type: 'timestamptz', nullable: true })
periodStart?: Date | null;
@Column({ name: 'period_end', type: 'timestamptz', nullable: true })
periodEnd?: Date | null;
@Column({ name: 'issued_at', type: 'timestamptz', nullable: true })
issuedAt?: Date | null;
@Column({ name: 'due_date', type: 'timestamptz', nullable: true })
dueDate?: Date | null;
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
paidAt?: Date | null;
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
cancelledAt?: Date | null;
@Column({ name: 'payments', type: 'jsonb', default: () => "'[]'" })
payments!: WarehouseInvoicePayment[];
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
}

View File

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

View File

@@ -1,13 +0,0 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity';
@Injectable()
export class WarehouseFeeInvoiceItemRepository extends BaseRepository<WarehouseFeeInvoiceItem> {
constructor(@InjectRepository(WarehouseFeeInvoiceItem) repository: Repository<WarehouseFeeInvoiceItem>) {
super(repository);
}
}

View File

@@ -1,13 +0,0 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
@Injectable()
export class WarehouseFeeInvoiceRepository extends BaseRepository<WarehouseFeeInvoice> {
constructor(@InjectRepository(WarehouseFeeInvoice) repository: Repository<WarehouseFeeInvoice>) {
super(repository);
}
}

View File

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

View File

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

View File

@@ -1,17 +1,24 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
import { DataSource } from 'typeorm';
import { NotificationsService } from '../notifications/notifications.service';
import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
import {
WarehouseFeeInvoice,
InvoiceDocumentModel,
InvoiceDocumentService,
} from '../billing/documents/invoice-document.service';
import { NotificationsService } from '../notifications/notifications.service';
import { WarehouseFeeService } from './warehouse-fee.service';
import {
WarehouseFeeInvoiceView,
WarehouseFeeType,
WarehouseInvoiceItemView,
WarehouseInvoiceStatus,
WarehouseInvoiceType,
} from './entities/warehouse-fee-invoice.entity';
import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity';
import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository';
import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository';
import { WarehouseFeeService } from './warehouse-fee.service';
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
} from './warehouse-invoice.types';
interface GenerateOptions {
confirmZero?: boolean;
@@ -27,9 +34,18 @@ export interface PayInvoiceDto {
driverPhone?: string;
}
/** Invoices that still owe money and therefore block terminal release. */
const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID'];
const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', 'PAID'];
/** Warehouse fee invoices live in the global billing system under this source. */
const SOURCE = Freight.InvoiceSource.Warehouse;
/** Global statuses that still owe money and therefore block terminal release. */
const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.PartiallyPaid,
Freight.InvoiceStatus.Overdue,
];
/** Global statuses considered an "active" invoice for per-inventory dedup. */
const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [...BLOCKING_STATUSES, Freight.InvoiceStatus.Paid];
export interface InvoiceDocumentDetails {
bookingReference: string | null;
@@ -45,28 +61,75 @@ export interface InvoiceDocumentDetails {
zoneName: string | null;
}
export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial<InvoiceDocumentDetails>;
export type WarehouseFeeInvoiceDetail = WarehouseFeeInvoiceView &
Partial<InvoiceDocumentDetails> & { items: WarehouseInvoiceItemView[] };
/** The warehouse-specific columns derived from the linked inventory item. */
interface InventoryContext {
bookingId: string | null;
facilityId: string | null;
warehouseId: string | null;
yardId: string | null;
zoneId: string | null;
periodStart: Date | null;
}
/** Source fields a view is projected from — satisfied by the global {@link Invoice}. */
interface ViewSource {
id: string;
invoiceNumber: string;
companyId: string;
sourceId: string;
type: string;
status: Freight.InvoiceStatus | string;
subtotalAmount: number | string;
taxAmount: number | string;
totalAmount: number | string;
paidAmount: number | string;
balanceAmount: number | string;
currency: string;
issuedAt?: Date | null;
dueAt?: Date | null;
paidAt?: Date | null;
createdAt: Date;
updatedAt: Date;
payments?: Array<{
amount: number | string;
method?: string | null;
reference?: string | null;
paidAt: string;
}> | null;
}
/**
* Thin warehouse layer over the central {@link BillingService}. Warehouse fee
* invoices are global `Invoice` rows (`source = warehouse`, `sourceId =
* inventoryId`); this service owns only the warehouse-specific concerns —
* computing fees, per-inventory dedup, release-blocking, SMS notifications, the
* sealed PDF, and reshaping the global invoice back into the historical
* `WarehouseFeeInvoice` JSON the portal/backoffice expect. All money, numbering,
* status, and payment math live in billing.
*/
@Injectable()
export class WarehouseInvoiceService {
private readonly logger = new Logger(WarehouseInvoiceService.name);
constructor(
private readonly dataSource: DataSource,
private readonly invoiceRepository: WarehouseFeeInvoiceRepository,
private readonly itemRepository: WarehouseFeeInvoiceItemRepository,
private readonly billing: BillingService,
private readonly invoiceDocuments: InvoiceDocumentService,
private readonly feeService: WarehouseFeeService,
private readonly documents: WarehouseReleaseDocumentService,
private readonly notifications: NotificationsService,
) {}
// ── Generation ───────────────────────────────────────────────────────────
async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise<WarehouseFeeInvoice> {
async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise<WarehouseFeeInvoiceDetail> {
const [item] = await this.dataSource.query(
`SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt",
w.facility_id AS "facilityId",
b.company_id AS "customerId", b.freight_type AS "freightType"
b.company_id AS "companyId", b.company_profile_id AS "companyProfileId",
b.freight_type AS "freightType"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
@@ -75,9 +138,16 @@ export class WarehouseInvoiceService {
);
if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`);
// Routing through the global invoice requires a billable company + profile,
// both of which come from the inventory's booking.
if (!item.companyId || !item.companyProfileId) {
throw new BadRequestException(
'Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).',
);
}
// Dedup: only one active (non-cancelled) invoice per inventory item.
const active = await this.invoiceRepository.findAll({ where: { inventoryId } });
if (active.some((inv) => ACTIVE_STATUSES.includes(inv.status))) {
if (await this.hasActiveInvoice(inventoryId)) {
throw new ConflictException(
'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.',
);
@@ -112,9 +182,7 @@ export class WarehouseInvoiceService {
};
});
const subtotal = items.reduce((s, i) => s + i.amount, 0);
const total = subtotal; // tax model can be layered on later
const total = items.reduce((s, i) => s + i.amount, 0);
if (total <= 0 && !opts.confirmZero) {
throw new BadRequestException('No payable warehouse fee found for this item.');
}
@@ -124,74 +192,81 @@ export class WarehouseInvoiceService {
const invoiceType: WarehouseInvoiceType =
hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE';
const currency = billingCurrency;
const now = new Date();
const periodEnd = previews[0] ? new Date(previews[0].endDate) : now;
const lines: InvoiceLineInput[] = items.map((it) => ({
chargeType: it.feeType,
description: it.description,
quantity: it.quantity,
unitRate: it.unitRate,
amount: it.amount,
currency: it.currency,
metadata: {
feeRuleId: it.feeRuleId ?? null,
chargeableDays: it.chargeableDays ?? null,
freeDays: it.freeDays ?? null,
},
}));
const invoice = await this.invoiceRepository.create({
invoiceNumber: await this.nextInvoiceNumber(),
bookingId: item.bookingId ?? null,
customerId: item.customerId ?? null,
inventoryId,
facilityId: item.facilityId ?? null,
warehouseId: item.warehouseId ?? null,
yardId: item.yardId ?? null,
zoneId: item.zoneId ?? null,
invoiceType,
status: 'ISSUED',
subtotalAmount: subtotal,
taxAmount: 0,
totalAmount: total,
paidAmount: 0,
balanceAmount: total,
currency,
periodStart: item.arrivedAt ?? null,
periodEnd,
issuedAt: now,
payments: [],
notes: opts.performedBy ? `Generated by ${opts.performedBy}` : null,
const invoice = await this.billing.generateInvoice({
source: SOURCE,
sourceId: inventoryId,
type: invoiceType,
companyId: item.companyId,
companyProfileId: item.companyProfileId,
currency: billingCurrency,
lines,
status: Freight.InvoiceStatus.Issued,
});
for (const it of items) {
await this.itemRepository.create({ invoiceId: invoice.id, ...it });
}
const saved = await this.findById(invoice.id);
await this.notifyWarehouseFeeIssued(saved);
return saved;
}
/** WHF-YYYYMMDD-00001 — sequential per day. */
private async nextInvoiceNumber(): Promise<string> {
const now = new Date();
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
const prefix = `WHF-${ymd}-`;
const [row] = await this.dataSource.query(
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
FROM freight.warehouse_fee_invoices WHERE invoice_number LIKE $1`,
[`${prefix}%`],
);
const next = Number(row?.seq ?? 0) + 1;
return `${prefix}${String(next).padStart(5, '0')}`;
const detail = await this.findById(invoice.id);
await this.notifyWarehouseFeeIssued(detail);
return detail;
}
// ── Reads ────────────────────────────────────────────────────────────────
async findById(id: string): Promise<WarehouseFeeInvoiceWithDisplay & { items: unknown[] }> {
const invoice = await this.invoiceRepository.findById(id);
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const items = await this.itemRepository.findAll({ where: { invoiceId: id } });
async findById(id: string): Promise<WarehouseFeeInvoiceDetail> {
const invoice = await this.loadWarehouseInvoice(id);
const ctx = await this.getInventoryContext(invoice.sourceId);
const details = await this.getInvoiceDocumentDetails(invoice);
return { ...invoice, ...details, items } as WarehouseFeeInvoiceWithDisplay & { items: unknown[] };
const items = invoice.lines.map((l) => this.lineToItem(l));
return { ...this.buildView(invoice, ctx), ...details, items };
}
listForInventory(inventoryId: string): Promise<WarehouseFeeInvoiceView[]> {
return this.queryViews('AND i.source_id = $1', [inventoryId]);
}
listForBooking(bookingId: string): Promise<WarehouseFeeInvoiceView[]> {
return this.queryViews('AND inv.booking_id = $1', [bookingId]);
}
async findAll(
filter: Partial<
Pick<
WarehouseFeeInvoiceView,
'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId'
>
>,
): Promise<WarehouseFeeInvoiceView[]> {
const conditions: string[] = [];
const params: unknown[] = [];
const add = (sql: (p: string) => string, value: unknown) => {
params.push(value);
conditions.push(sql(`$${params.length}`));
};
if (filter.status) add((p) => `i.status::text = ${p}`, this.toGlobalStatus(filter.status as WarehouseInvoiceStatus));
if (filter.invoiceType) add((p) => `i.type = ${p}`, filter.invoiceType);
if (filter.customerId) add((p) => `i.company_id = ${p}`, filter.customerId);
if (filter.warehouseId) add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId);
if (filter.facilityId) add((p) => `w.facility_id = ${p}`, filter.facilityId);
if (filter.bookingId) add((p) => `inv.booking_id = ${p}`, filter.bookingId);
return this.queryViews(conditions.map((c) => `AND ${c}`).join(' '), params);
}
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
const details = await this.getInvoiceDocumentDetails(invoice);
const html = this.buildInvoiceDocumentHtml(invoice, 'INVOICE', details);
return {
filename: `warehouse-invoice-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
buffer: await this.documents.htmlToPdfBuffer(html),
};
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'INVOICE'));
}
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
@@ -199,76 +274,65 @@ export class WarehouseInvoiceService {
if (Number(invoice.paidAmount) <= 0) {
throw new BadRequestException('A receipt is available only after payment is recorded.');
}
const details = await this.getInvoiceDocumentDetails(invoice);
const html = this.buildInvoiceDocumentHtml(invoice, 'RECEIPT', details);
return {
filename: `warehouse-receipt-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
buffer: await this.documents.htmlToPdfBuffer(html),
};
}
listForInventory(inventoryId: string): Promise<WarehouseFeeInvoice[]> {
return this.invoiceRepository.findAll({ where: { inventoryId }, order: { createdAt: 'DESC' } });
}
listForBooking(bookingId: string): Promise<WarehouseFeeInvoice[]> {
return this.invoiceRepository.findAll({ where: { bookingId }, order: { createdAt: 'DESC' } });
}
findAll(filter: Partial<Pick<WarehouseFeeInvoice, 'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId'>>): Promise<WarehouseFeeInvoice[]> {
const where = Object.fromEntries(Object.entries(filter).filter(([, v]) => v != null));
return this.invoiceRepository.findAll({ where, order: { createdAt: 'DESC' } });
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT'));
}
// ── State changes ────────────────────────────────────────────────────────
async cancel(id: string): Promise<WarehouseFeeInvoice> {
const invoice = await this.invoiceRepository.findById(id);
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
if (invoice.status === 'PAID') throw new BadRequestException('A paid invoice cannot be cancelled.');
const updated = await this.invoiceRepository.update(id, { status: 'CANCELLED', cancelledAt: new Date() });
return updated as WarehouseFeeInvoice;
async cancel(id: string): Promise<WarehouseFeeInvoiceDetail> {
const invoice = await this.loadWarehouseInvoice(id);
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException('A paid invoice cannot be cancelled.');
}
await this.billing.cancelInvoice(id);
return this.findById(id);
}
/** Record a payment against the invoice and sync status (links to existing payment flow). */
async pay(id: string, dto: PayInvoiceDto): Promise<WarehouseFeeInvoice> {
const invoice = await this.invoiceRepository.findById(id);
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
if (invoice.status === 'CANCELLED') throw new BadRequestException('Cannot pay a cancelled invoice.');
if (invoice.status === 'PAID') throw new BadRequestException('Invoice is already fully paid.');
if (!(dto.amount > 0)) throw new BadRequestException('Payment amount must be greater than zero.');
const paidAmount = Number(invoice.paidAmount) + dto.amount;
const total = Number(invoice.totalAmount);
const balance = Math.max(0, Math.round((total - paidAmount) * 100) / 100);
const fullyPaid = paidAmount >= total;
const payments = [
...(invoice.payments ?? []),
{ amount: dto.amount, method: dto.method ?? null, reference: dto.reference ?? null, paidAt: new Date().toISOString() },
];
const updated = await this.invoiceRepository.update(id, {
paidAmount: Math.round(paidAmount * 100) / 100,
balanceAmount: balance,
status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID',
paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null,
payments,
/** Record a payment against the invoice (delegates settlement to billing). */
async pay(id: string, dto: PayInvoiceDto): Promise<WarehouseFeeInvoiceDetail> {
// Guard that this is a warehouse invoice before recording (404 otherwise).
await this.loadWarehouseInvoice(id);
await this.billing.recordPayment(id, {
amount: dto.amount,
method: dto.method ?? null,
reference: dto.reference ?? null,
metadata:
dto.driverName || dto.driverPhone
? { driverName: dto.driverName ?? null, driverPhone: dto.driverPhone ?? null }
: null,
});
const paidInvoice = updated as WarehouseFeeInvoice;
await this.notifyWarehouseFeePayment(paidInvoice, dto);
return paidInvoice;
const detail = await this.findById(id);
await this.notifyWarehouseFeePayment(detail, dto);
return detail;
}
/**
* Notify on online (gateway) settlement — the domain side-effect of a warehouse
* fee being paid through billing's payment flow. The counter {@link pay} path
* notifies inline (and carries driver details from the request), so this only
* handles gateway payments: those stamp the invoice `paymentId`, whereas a
* counter settlement leaves it null. Skipping null-`paymentId` events avoids
* double-notifying a counter payment that already sent its SMS.
*/
@OnEvent('warehouse.invoice.paid')
async onWarehouseInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
if (!payload.paymentId) return;
const detail = await this.findById(payload.invoiceId);
await this.notifyWarehouseFeePayment(detail, { amount: Number(detail.totalAmount) });
}
// ── Release blocking ──────────────────────────────────────────────────────
/** Returns the first unpaid invoice that blocks terminal release, or null. */
async findBlockingInvoice(inventoryId: string): Promise<WarehouseFeeInvoice | null> {
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
return invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)) ?? null;
async findBlockingInvoice(inventoryId: string): Promise<WarehouseFeeInvoiceView | null> {
const blocking = await this.queryViews(
`AND i.source_id = $1 AND i.status::text = ANY($2::text[])`,
[inventoryId, BLOCKING_STATUSES],
);
return blocking[0] ?? null;
}
async assertClearanceAllowed(inventoryId: string): Promise<void> {
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
const blocking = invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status));
const invoices = await this.queryViews('AND i.source_id = $1', [inventoryId]);
const blocking = invoices.find((inv) => inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
if (blocking) {
throw new BadRequestException(
`Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`,
@@ -286,12 +350,218 @@ export class WarehouseInvoiceService {
}
}
private async getInvoiceDocumentDetails(invoice: WarehouseFeeInvoice): Promise<InvoiceDocumentDetails> {
// ── Internal: loading & projection ─────────────────────────────────────────
/** Load a global invoice (+lines) and assert it is a warehouse fee invoice. */
private async loadWarehouseInvoice(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
const invoice = await this.billing.findById(id);
if (invoice.source !== SOURCE) {
throw new NotFoundException(`Invoice ${id} not found`);
}
return invoice;
}
private async hasActiveInvoice(inventoryId: string): Promise<boolean> {
const [row] = await this.dataSource.query(
`SELECT 1
FROM freight.invoices
WHERE source = $1 AND source_id = $2 AND status::text = ANY($3::text[]) AND deleted_at IS NULL
LIMIT 1`,
[SOURCE, inventoryId, ACTIVE_STATUSES],
);
return Boolean(row);
}
/**
* Project warehouse-source global invoices into the historical view, joined to
* their inventory item for the typed FKs. Powers every list/filter read.
*/
private async queryViews(extraWhere: string, params: unknown[]): Promise<WarehouseFeeInvoiceView[]> {
const rows = await this.dataSource.query(
`SELECT i.id, i.invoice_number AS "invoiceNumber", i.company_id AS "companyId",
i.source_id AS "sourceId", i.type, i.status,
i.subtotal_amount AS "subtotalAmount", i.tax_amount AS "taxAmount",
i.total_amount AS "totalAmount", i.paid_amount AS "paidAmount",
i.balance_amount AS "balanceAmount", i.currency, i.payments,
i.issued_at AS "issuedAt", i.due_at AS "dueAt", i.paid_at AS "paidAt",
i.created_at AS "createdAt", i.updated_at AS "updatedAt",
inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart",
w.facility_id AS "facilityId"
FROM freight.invoices i
LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id AND inv.deleted_at IS NULL
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
WHERE i.source = $${params.length + 1} AND i.deleted_at IS NULL ${extraWhere}
ORDER BY i.created_at DESC`,
[...params, SOURCE],
);
return (rows as Array<ViewSource & InventoryContext>).map((row) =>
this.buildView(row, {
bookingId: row.bookingId ?? null,
facilityId: row.facilityId ?? null,
warehouseId: row.warehouseId ?? null,
yardId: row.yardId ?? null,
zoneId: row.zoneId ?? null,
periodStart: row.periodStart ?? null,
}),
);
}
/** Reshape a global invoice (+ derived inventory context) into the warehouse view. */
private buildView(inv: ViewSource, ctx: InventoryContext): WarehouseFeeInvoiceView {
const status = this.toWarehouseStatus(inv.status);
return {
id: inv.id,
invoiceNumber: inv.invoiceNumber,
bookingId: ctx.bookingId,
customerId: inv.companyId ?? null,
inventoryId: inv.sourceId,
facilityId: ctx.facilityId,
warehouseId: ctx.warehouseId,
yardId: ctx.yardId,
zoneId: ctx.zoneId,
invoiceType: inv.type as WarehouseInvoiceType,
status,
subtotalAmount: Number(inv.subtotalAmount),
taxAmount: Number(inv.taxAmount),
totalAmount: Number(inv.totalAmount),
paidAmount: Number(inv.paidAmount),
balanceAmount: Number(inv.balanceAmount),
currency: inv.currency,
periodStart: ctx.periodStart,
// No standalone period column once centralized: the charge window ends at
// issuance, so `issuedAt` is the period end.
periodEnd: inv.issuedAt ?? null,
issuedAt: inv.issuedAt ?? null,
dueDate: inv.dueAt ?? null,
paidAt: inv.paidAt ?? null,
cancelledAt: status === 'CANCELLED' ? inv.updatedAt : null,
payments: (inv.payments ?? []).map((p) => ({
amount: Number(p.amount),
method: p.method ?? null,
reference: p.reference ?? null,
paidAt: p.paidAt,
})),
notes: null,
createdAt: inv.createdAt,
updatedAt: inv.updatedAt,
};
}
private lineToItem(line: InvoiceLine): WarehouseInvoiceItemView {
const meta = (line.metadata ?? {}) as {
feeRuleId?: string | null;
chargeableDays?: number | null;
freeDays?: number | null;
};
return {
feeRuleId: meta.feeRuleId ?? null,
feeType: line.chargeType as WarehouseFeeType,
description: line.description ?? '',
quantity: Number(line.quantity),
unitRate: Number(line.unitRate),
amount: Number(line.amount),
currency: line.currency,
chargeableDays: meta.chargeableDays ?? null,
freeDays: meta.freeDays ?? null,
};
}
private toWarehouseStatus(status: Freight.InvoiceStatus | string): WarehouseInvoiceStatus {
switch (status) {
case Freight.InvoiceStatus.Draft:
return 'DRAFT';
case Freight.InvoiceStatus.PartiallyPaid:
return 'PARTIALLY_PAID';
case Freight.InvoiceStatus.Paid:
return 'PAID';
case Freight.InvoiceStatus.Cancelled:
case Freight.InvoiceStatus.Refunded:
return 'CANCELLED';
default:
// Issued / Pending / Overdue → an issued, still-owed invoice.
return 'ISSUED';
}
}
private toGlobalStatus(status: WarehouseInvoiceStatus): Freight.InvoiceStatus {
switch (status) {
case 'DRAFT':
return Freight.InvoiceStatus.Draft;
case 'PARTIALLY_PAID':
return Freight.InvoiceStatus.PartiallyPaid;
case 'PAID':
return Freight.InvoiceStatus.Paid;
case 'CANCELLED':
return Freight.InvoiceStatus.Cancelled;
default:
return Freight.InvoiceStatus.Issued;
}
}
/** Map a warehouse fee invoice view onto the shared document model. */
private toDocumentModel(
invoice: WarehouseFeeInvoiceDetail,
kind: 'INVOICE' | 'RECEIPT',
): InvoiceDocumentModel {
const lastPayment = [...(invoice.payments ?? [])].pop();
const date = (value: unknown) =>
value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null;
return {
kind,
title: 'Warehouse Fee',
documentNumber: invoice.invoiceNumber,
issuedAt: invoice.issuedAt ?? invoice.createdAt,
status: invoice.status,
currency: invoice.currency,
summary: [
{ label: 'Status', value: invoice.status.replace(/_/g, ' ') },
{ label: 'Invoice type', value: invoice.invoiceType.replace(/_/g, ' ') },
{ label: 'Booking reference', value: invoice.bookingReference ?? null },
{ label: 'Customer', value: invoice.customerName ?? null },
{ label: 'Inventory reference', value: invoice.inventoryReference ?? null },
{ label: 'Inventory info', value: invoice.inventoryInfo ?? null },
{ label: 'Clearance', value: invoice.clearanceStatus ?? null },
{ label: 'Warehouse', value: invoice.warehouseName ?? null },
{
label: 'Yard / Zone',
value: [invoice.yardName, invoice.zoneName].filter(Boolean).join(' / ') || null,
},
{ label: 'Period', value: `${date(invoice.periodStart) ?? '-'} - ${date(invoice.periodEnd) ?? '-'}` },
{
label: 'Payment',
value: lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt) ?? '-'}` : null,
},
],
categoryHeader: 'Fee type',
lines: invoice.items.map((item) => ({
description: item.description ?? null,
category: item.feeType ?? null,
quantity: item.quantity ?? item.chargeableDays ?? 0,
unitRate: item.unitRate,
amount: item.amount,
currency: item.currency ?? invoice.currency,
})),
totals: [
{ label: 'Subtotal', amount: Number(invoice.subtotalAmount) },
{ label: 'Tax', amount: Number(invoice.taxAmount) },
{ label: 'Total', amount: Number(invoice.totalAmount), grand: true },
{ label: 'Paid', amount: Number(invoice.paidAmount) },
{ label: 'Balance', amount: Number(invoice.balanceAmount) },
],
};
}
/** Warehouse-specific display details, derived from the linked inventory item. */
private async getInvoiceDocumentDetails(invoice: ViewSource): Promise<InvoiceDocumentDetails> {
const [row] = await this.dataSource.query(
`SELECT b.reference AS "bookingReference",
company.name AS "customerName",
COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference",
inv.status AS "inventoryStatus",
inv.release_date AS "releaseDate",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
CONCAT_WS(
@@ -302,16 +572,10 @@ export class WarehouseInvoiceService {
) AS "inventoryInfo",
wh.name AS "warehouseName",
yard.name AS "yardName",
zone.name AS "zoneName",
CASE
WHEN inv.release_date IS NOT NULL THEN 'RELEASE ISSUED'
WHEN $2 = 'PAID' THEN 'FEE PAID - READY FOR RELEASE'
ELSE 'PENDING PAYMENT'
END AS "clearanceStatus"
FROM freight.warehouse_fee_invoices fee
LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL
LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id)
zone.name AS "zoneName"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
@@ -319,14 +583,21 @@ export class WarehouseInvoiceService {
)
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
LEFT JOIN freight.warehouses wh ON wh.id = fee.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = fee.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = fee.zone_id
WHERE fee.id = $1
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[invoice.id, invoice.status],
[invoice.sourceId],
);
const fullyPaid = this.toWarehouseStatus(invoice.status) === 'PAID';
const clearanceStatus = row?.releaseDate
? 'RELEASE ISSUED'
: fullyPaid
? 'FEE PAID - READY FOR RELEASE'
: 'PENDING PAYMENT';
return {
bookingReference: row?.bookingReference ?? null,
customerName: row?.customerName ?? null,
@@ -338,11 +609,33 @@ export class WarehouseInvoiceService {
warehouseName: row?.warehouseName ?? null,
yardName: row?.yardName ?? null,
zoneName: row?.zoneName ?? null,
clearanceStatus: row?.clearanceStatus ?? (invoice.status === 'PAID' ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT'),
clearanceStatus,
};
}
private async getInvoiceNotificationContacts(invoice: WarehouseFeeInvoice): Promise<{
private async getInventoryContext(inventoryId: string): Promise<InventoryContext> {
const [row] = await this.dataSource.query(
`SELECT inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart",
w.facility_id AS "facilityId"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[inventoryId],
);
return {
bookingId: row?.bookingId ?? null,
facilityId: row?.facilityId ?? null,
warehouseId: row?.warehouseId ?? null,
yardId: row?.yardId ?? null,
zoneId: row?.zoneId ?? null,
periodStart: row?.periodStart ?? null,
};
}
// ── Notifications ──────────────────────────────────────────────────────────
private async getInvoiceNotificationContacts(inventoryId: string): Promise<{
bookingReference: string | null;
customerName: string | null;
customerPhone: string | null;
@@ -364,10 +657,9 @@ export class WarehouseInvoiceService {
COALESCE(last_driver.phone_number, first_driver.phone_number) AS "driverPhone",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription"
FROM freight.warehouse_fee_invoices fee
LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL
LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id)
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
@@ -393,9 +685,9 @@ export class WarehouseInvoiceService {
) latest_first_mile ON true
LEFT JOIN freight.vehicles first_vehicle ON first_vehicle.id = latest_first_mile.vehicle_id
LEFT JOIN freight.drivers first_driver ON first_driver.id = first_vehicle.assigned_driver_id
WHERE fee.id = $1
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[invoice.id],
[inventoryId],
);
return {
@@ -419,8 +711,8 @@ export class WarehouseInvoiceService {
}
}
private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoice): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(invoice);
private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoiceView): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId);
const customerName = contacts.customerName?.trim() || 'Customer';
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
const cargo = contacts.containerNumber || contacts.cargoDescription;
@@ -433,8 +725,8 @@ export class WarehouseInvoiceService {
await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`);
}
private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoice, dto: PayInvoiceDto): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(invoice);
private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoiceView, dto: PayInvoiceDto): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId);
const customerName = contacts.customerName?.trim() || 'Customer';
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
const statusText =
@@ -460,131 +752,4 @@ export class WarehouseInvoiceService {
await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`);
}
private buildInvoiceDocumentHtml(
invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] },
kind: 'INVOICE' | 'RECEIPT',
details: InvoiceDocumentDetails,
): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const money = (amount: unknown, currency = invoice.currency) =>
`${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
const items = invoice.items as Array<{
id?: string;
description?: string;
feeType?: string;
quantity?: number;
unitRate?: number;
amount?: number;
currency?: string;
chargeableDays?: number | null;
}>;
const lastPayment = [...(invoice.payments ?? [])].pop();
const sealText = kind === 'RECEIPT' || invoice.status === 'PAID' ? 'EDR PAID' : 'EDR';
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}</title>
<style>
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
.doc { padding: 16px 8px; position: relative; }
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 16px; }
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
h1 { margin: 8px 0 0; font-size: 30px; }
.meta { text-align: right; font-size: 12px; color: #475569; }
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
th { text-align: left; background: #f8fafc; color: #475569; }
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; font-size: 12px; }
td.num, th.num { text-align: right; }
.totals { margin-left: auto; width: 330px; margin-top: 18px; }
.total-row { display: flex; justify-content: space-between; border-bottom: 1px solid #e2e8f0; padding: 8px 0; font-size: 13px; }
.grand { font-size: 16px; font-weight: 800; }
.footer { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
</style>
</head>
<body>
<div class="doc">
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}</h1>
</div>
<div class="meta">
Document no.
<strong>${esc(invoice.invoiceNumber)}</strong>
Issued: ${esc(date(invoice.issuedAt ?? invoice.createdAt))}
</div>
</div>
<div class="seal">${esc(sealText)}</div>
<div class="summary">
<div><span>Status</span>${esc(invoice.status.replace(/_/g, ' '))}</div>
<div><span>Invoice type</span>${esc(invoice.invoiceType.replace(/_/g, ' '))}</div>
<div><span>Booking reference</span>${esc(details.bookingReference)}</div>
<div><span>Customer</span>${esc(details.customerName)}</div>
<div><span>Inventory reference</span>${esc(details.inventoryReference)}</div>
<div><span>Inventory info</span>${esc(details.inventoryInfo)}</div>
<div><span>Clearance</span>${esc(details.clearanceStatus)}</div>
<div><span>Warehouse</span>${esc(details.warehouseName)}</div>
<div><span>Yard / Zone</span>${esc([details.yardName, details.zoneName].filter(Boolean).join(' / ') || null)}</div>
<div><span>Period</span>${esc(date(invoice.periodStart))} - ${esc(date(invoice.periodEnd))}</div>
<div><span>Payment</span>${esc(lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt)}` : '-')}</div>
</div>
<table>
<thead>
<tr>
<th>Description</th>
<th>Fee type</th>
<th class="num">Qty</th>
<th class="num">Rate</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
${items
.map(
(item) => `<tr>
<td>${esc(item.description)}</td>
<td>${esc((item.feeType ?? '').replace(/_/g, ' '))}</td>
<td class="num">${esc(item.quantity ?? item.chargeableDays ?? 0)}</td>
<td class="num">${esc(money(item.unitRate, item.currency ?? invoice.currency))}</td>
<td class="num">${esc(money(item.amount, item.currency ?? invoice.currency))}</td>
</tr>`,
)
.join('')}
</tbody>
</table>
<div class="totals">
<div class="total-row"><span>Subtotal</span><strong>${esc(money(invoice.subtotalAmount))}</strong></div>
<div class="total-row"><span>Tax</span><strong>${esc(money(invoice.taxAmount))}</strong></div>
<div class="total-row grand"><span>Total</span><strong>${esc(money(invoice.totalAmount))}</strong></div>
<div class="total-row"><span>Paid</span><strong>${esc(money(invoice.paidAmount))}</strong></div>
<div class="total-row"><span>Balance</span><strong>${esc(money(invoice.balanceAmount))}</strong></div>
</div>
<div class="footer">
<div class="line">Prepared by EDR warehouse finance</div>
<div class="line">Authorized seal / signature</div>
</div>
</div>
</body>
</html>`;
}
private safeFilename(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]+/g, '-');
}
}

View File

@@ -0,0 +1,88 @@
/**
* Public shapes for warehouse fee invoices.
*
* Warehouse fee invoices are no longer a standalone table — they are global
* `Invoice` rows (`source = "warehouse"`, `sourceId = inventoryId`) owned by the
* central {@link BillingService}. These types preserve the warehouse-facing API
* contract: `WarehouseInvoiceService` reshapes the global invoice (+ lines +
* inventory context) back into the historical `WarehouseFeeInvoice` JSON so the
* portal/backoffice stay untouched.
*/
export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const;
export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number];
export const WAREHOUSE_INVOICE_STATUSES = [
'DRAFT',
'ISSUED',
'PARTIALLY_PAID',
'PAID',
'CANCELLED',
] as const;
export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number];
export const WAREHOUSE_FEE_TYPES = [
'CONTAINER_DEMURRAGE',
'BULK_DEMURRAGE',
'STORAGE_FEE',
'HANDLING_FEE',
] as const;
export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number];
/** A single recorded payment against a warehouse fee invoice (history). */
export interface WarehouseInvoicePayment {
amount: number;
method?: string | null;
reference?: string | null;
paidAt: string;
}
/** A billed warehouse fee line, projected from a global `InvoiceLine`. */
export interface WarehouseInvoiceItemView {
feeRuleId: string | null;
feeType: WarehouseFeeType;
description: string;
quantity: number;
unitRate: number;
amount: number;
currency: string;
chargeableDays: number | null;
freeDays: number | null;
}
/**
* The warehouse-facing invoice header — same field set the old
* `WarehouseFeeInvoice` entity exposed, projected from a global `Invoice`. The
* typed FKs (`bookingId`/`facilityId`/`warehouseId`/`yardId`/`zoneId`) and the
* charge `period` are derived from the linked inventory item; `customerId` is the
* billed company; `invoiceType` is the invoice `type`.
*/
export interface WarehouseFeeInvoiceView {
id: string;
invoiceNumber: string;
bookingId: string | null;
customerId: string | null;
inventoryId: string;
facilityId: string | null;
warehouseId: string | null;
yardId: string | null;
zoneId: string | null;
invoiceType: WarehouseInvoiceType;
status: WarehouseInvoiceStatus;
subtotalAmount: number;
taxAmount: number;
totalAmount: number;
paidAmount: number;
balanceAmount: number;
currency: string;
periodStart: Date | null;
periodEnd: Date | null;
issuedAt: Date | null;
dueDate: Date | null;
paidAt: Date | null;
cancelledAt: Date | null;
payments: WarehouseInvoicePayment[];
notes: string | null;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -1,101 +1,23 @@
import { existsSync } from 'fs';
import { Injectable } from '@nestjs/common';
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { PdfRenderService } from '../billing/documents/pdf-render.service';
const MIN_VALID_PDF_BYTES = 2_000;
const RELEASE_DOCUMENT_PRINT_STYLES = `
<style id="warehouse-release-document-print-fix">
@media print {
html, body {
background: #fff !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
</style>`;
@Injectable()
export class WarehouseReleaseDocumentService {
private readonly logger = new Logger(WarehouseReleaseDocumentService.name);
constructor(private readonly pdf: PdfRenderService) {}
async htmlToPdfBuffer(html: string): Promise<Buffer> {
const preparedHtml = this.injectPdfPrintStyles(html);
const executablePath = this.resolveExecutablePath();
try {
const puppeteer = await import('puppeteer');
const launchOptions: import('puppeteer').LaunchOptions = {
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
...(executablePath ? { executablePath } : {}),
};
const browser = await puppeteer.default.launch(launchOptions);
try {
const page = await browser.newPage();
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
await page.setContent(preparedHtml, { waitUntil: 'load', timeout: 60_000 });
await page.emulateMediaType('print');
await new Promise((resolve) => setTimeout(resolve, 250));
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '16mm', bottom: '18mm', left: '14mm', right: '14mm' },
});
const buffer = Buffer.from(pdf);
if (!this.isValidPdf(buffer)) {
throw new Error(`Puppeteer produced invalid release PDF (${buffer.length} bytes)`);
}
this.logger.log(
`Warehouse release PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`,
);
return buffer;
} finally {
await browser.close();
}
} catch (error) {
this.logger.error(
`Warehouse release PDF failed (executable=${executablePath ?? 'default'}): ${error}`,
);
const fallback = this.htmlToBasicPdfBuffer(preparedHtml);
if (this.isValidPdf(fallback)) {
this.logger.warn(
`Using basic warehouse release PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
);
return fallback;
}
throw new InternalServerErrorException(
'Warehouse release PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
);
}
}
private injectPdfPrintStyles(html: string): string {
if (html.includes('warehouse-release-document-print-fix')) return html;
if (html.includes('</head>')) {
return html.replace('</head>', `${RELEASE_DOCUMENT_PRINT_STYLES}</head>`);
}
return `${RELEASE_DOCUMENT_PRINT_STYLES}${html}`;
}
private resolveExecutablePath(): string | undefined {
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
if (fromEnv && existsSync(fromEnv)) return fromEnv;
const candidates = [
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/bin/google-chrome-stable',
'/usr/bin/google-chrome',
];
return candidates.find((path) => existsSync(path));
}
private isValidPdf(buffer: Buffer): boolean {
return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString('ascii') === '%PDF-';
/**
* Render the gate-clearance release document to PDF via the shared renderer,
* falling back to the release-specific hand-built layout when Chromium is
* unavailable.
*/
htmlToPdfBuffer(html: string): Promise<Buffer> {
return this.pdf.htmlToPdfBuffer(html, {
label: 'Warehouse release',
fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml),
});
}
private htmlToBasicPdfBuffer(html: string): Buffer {

View File

@@ -3,6 +3,8 @@ import { ConfigService } from '@nestjs/config';
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingModule } from '../billing/billing.module';
import { DocumentsModule } from '../billing/documents/documents.module';
import { FilesModule } from '../files/files.module';
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
import { LastMileModule } from '../last-mile/last-mile.module';
@@ -10,8 +12,6 @@ import { NotificationsModule } from '../notifications/notifications.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity';
import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
@@ -38,8 +38,6 @@ import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.r
import { WarehouseAllocationService } from './warehouse-allocation.service';
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
import { WarehouseFeeService } from './warehouse-fee.service';
import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository';
import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository';
import { WarehouseInvoiceController } from './warehouse-invoice.controller';
import { WarehouseInvoiceService } from './warehouse-invoice.service';
import { WarehouseRulesController } from './warehouse-rules.controller';
@@ -67,9 +65,9 @@ import { WarehousesService } from './warehouses.service';
WarehouseInspectionReport,
WarehouseAllocationRule,
WarehouseFeeRule,
WarehouseFeeInvoice,
WarehouseFeeInvoiceItem,
]),
BillingModule,
DocumentsModule,
FilesModule,
InterchangeDocumentsModule,
forwardRef(() => LastMileModule),
@@ -102,8 +100,6 @@ import { WarehousesService } from './warehouses.service';
WarehouseInspectionRepository,
WarehouseAllocationRuleRepository,
WarehouseFeeRuleRepository,
WarehouseFeeInvoiceRepository,
WarehouseFeeInvoiceItemRepository,
WarehousesService,
WarehouseYardsService,
WarehouseZonesService,