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