fix: warehouse query

This commit is contained in:
ghost2023
2026-07-02 16:32:35 +03:00
parent b75a3ab54b
commit df60c4750e
2 changed files with 250 additions and 130 deletions

View File

@@ -1,10 +1,4 @@
import { import { Injectable, Logger, NotFoundException } from "@nestjs/common";
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { FindOptionsWhere } from "typeorm"; import { FindOptionsWhere } from "typeorm";
import { InjectDataSource } from "@nestjs/typeorm"; import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } from "typeorm"; import { DataSource } from "typeorm";
@@ -98,8 +92,6 @@ export class FirstMileService {
firstMilePickupAddress?: string | null; firstMilePickupAddress?: string | null;
serviceType?: { includesFirstMile?: boolean | null } | null; serviceType?: { includesFirstMile?: boolean | null } | null;
}): Promise<FirstMile | null> { }): Promise<FirstMile | null> {
const label = booking.reference ?? booking.id;
if (booking.paymentStatus !== "PAID") { if (booking.paymentStatus !== "PAID") {
return null; return null;
} }

View File

@@ -1,29 +1,39 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import {
import { OnEvent } from '@nestjs/event-emitter'; BadRequestException,
import { Freight } from '@edr/types'; ConflictException,
import { DataSource } from 'typeorm'; Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";
import { Freight } from "@edr/types";
import { DataSource } from "typeorm";
import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service'; import {
import { Invoice } from '../billing/entities/invoice.entity'; BillingService,
import { InvoiceLine } from '../billing/entities/invoice-line.entity'; InvoiceEventPayload,
InvoiceLineInput,
} from "../billing/billing.service";
import { Invoice } from "../billing/entities/invoice.entity";
import { InvoiceLine } from "../billing/entities/invoice-line.entity";
import { import {
InvoiceDocumentModel, InvoiceDocumentModel,
InvoiceDocumentService, InvoiceDocumentService,
} from '../billing/documents/invoice-document.service'; } from "../billing/documents/invoice-document.service";
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from "../notifications/notifications.service";
import { WarehouseFeeService } from './warehouse-fee.service'; import { WarehouseFeeService } from "./warehouse-fee.service";
import { import {
WarehouseFeeInvoiceView, WarehouseFeeInvoiceView,
WarehouseFeeType, WarehouseFeeType,
WarehouseInvoiceItemView, WarehouseInvoiceItemView,
WarehouseInvoiceStatus, WarehouseInvoiceStatus,
WarehouseInvoiceType, WarehouseInvoiceType,
} from './warehouse-invoice.types'; } from "./warehouse-invoice.types";
interface GenerateOptions { interface GenerateOptions {
confirmZero?: boolean; confirmZero?: boolean;
performedBy?: string; performedBy?: string;
billingCurrency?: 'ETB' | 'USD'; billingCurrency?: "ETB" | "USD";
} }
export interface PayInvoiceDto { export interface PayInvoiceDto {
@@ -45,7 +55,10 @@ const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Overdue, Freight.InvoiceStatus.Overdue,
]; ];
/** Global statuses considered an "active" invoice for per-inventory dedup. */ /** Global statuses considered an "active" invoice for per-inventory dedup. */
const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [...BLOCKING_STATUSES, Freight.InvoiceStatus.Paid]; const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [
...BLOCKING_STATUSES,
Freight.InvoiceStatus.Paid,
];
export interface InvoiceDocumentDetails { export interface InvoiceDocumentDetails {
bookingReference: string | null; bookingReference: string | null;
@@ -120,10 +133,13 @@ export class WarehouseInvoiceService {
private readonly invoiceDocuments: InvoiceDocumentService, private readonly invoiceDocuments: InvoiceDocumentService,
private readonly feeService: WarehouseFeeService, private readonly feeService: WarehouseFeeService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
) {} ) { }
// ── Generation ─────────────────────────────────────────────────────────── // ── Generation ───────────────────────────────────────────────────────────
async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise<WarehouseFeeInvoiceDetail> { async generateForInventory(
inventoryId: string,
opts: GenerateOptions = {},
): Promise<WarehouseFeeInvoiceDetail> {
const [item] = await this.dataSource.query( const [item] = await this.dataSource.query(
`SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", `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", inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt",
@@ -136,43 +152,47 @@ export class WarehouseInvoiceService {
WHERE inv.id = $1 AND inv.deleted_at IS NULL`, WHERE inv.id = $1 AND inv.deleted_at IS NULL`,
[inventoryId], [inventoryId],
); );
if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`); if (!item)
throw new NotFoundException(`Inventory item ${inventoryId} not found`);
// Routing through the global invoice requires a billable company + profile, // Routing through the global invoice requires a billable company + profile,
// both of which come from the inventory's booking. // both of which come from the inventory's booking.
if (!item.companyId || !item.companyProfileId) { if (!item.companyId || !item.companyProfileId) {
throw new BadRequestException( throw new BadRequestException(
'Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).', "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. // Dedup: only one active (non-cancelled) invoice per inventory item.
if (await this.hasActiveInvoice(inventoryId)) { if (await this.hasActiveInvoice(inventoryId)) {
throw new ConflictException( throw new ConflictException(
'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.', "An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.",
); );
} }
const billingCurrency = opts.billingCurrency === 'ETB' ? 'ETB' : 'USD'; const billingCurrency = opts.billingCurrency === "ETB" ? "ETB" : "USD";
const previews = await this.feeService.previewForInventory(inventoryId, billingCurrency); const previews = await this.feeService.previewForInventory(
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER'; inventoryId,
billingCurrency,
);
const isContainer = (item.freightType ?? "").toUpperCase() === "CONTAINER";
const items = previews const items = previews
.filter((p) => p.amount > 0) .filter((p) => p.amount > 0)
.map((p) => { .map((p) => {
const feeType: WarehouseFeeType = const feeType: WarehouseFeeType =
p.ruleType === 'STORAGE_FEE' p.ruleType === "STORAGE_FEE"
? 'STORAGE_FEE' ? "STORAGE_FEE"
: isContainer : isContainer
? 'CONTAINER_DEMURRAGE' ? "CONTAINER_DEMURRAGE"
: 'BULK_DEMURRAGE'; : "BULK_DEMURRAGE";
return { return {
feeRuleId: p.ruleId, feeRuleId: p.ruleId,
feeType, feeType,
description: description:
p.ruleType === 'STORAGE_FEE' p.ruleType === "STORAGE_FEE"
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free` ? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`
: `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`, : `${isContainer ? "Container" : "Bulk"} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`,
quantity: p.billableUnits, quantity: p.billableUnits,
unitRate: p.ratePerDay, unitRate: p.ratePerDay,
amount: p.amount, amount: p.amount,
@@ -184,13 +204,19 @@ export class WarehouseInvoiceService {
const total = items.reduce((s, i) => s + i.amount, 0); const total = items.reduce((s, i) => s + i.amount, 0);
if (total <= 0 && !opts.confirmZero) { if (total <= 0 && !opts.confirmZero) {
throw new BadRequestException('No payable warehouse fee found for this item.'); throw new BadRequestException(
"No payable warehouse fee found for this item.",
);
} }
const hasDemurrage = items.some((i) => i.feeType !== 'STORAGE_FEE'); const hasDemurrage = items.some((i) => i.feeType !== "STORAGE_FEE");
const hasStorage = items.some((i) => i.feeType === 'STORAGE_FEE'); const hasStorage = items.some((i) => i.feeType === "STORAGE_FEE");
const invoiceType: WarehouseInvoiceType = const invoiceType: WarehouseInvoiceType =
hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE'; hasDemurrage && hasStorage
? "MIXED_WAREHOUSE_FEES"
: hasStorage
? "STORAGE_FEE"
: "DEMURRAGE";
const lines: InvoiceLineInput[] = items.map((it) => ({ const lines: InvoiceLineInput[] = items.map((it) => ({
chargeType: it.feeType, chargeType: it.feeType,
@@ -232,18 +258,23 @@ export class WarehouseInvoiceService {
} }
listForInventory(inventoryId: string): Promise<WarehouseFeeInvoiceView[]> { listForInventory(inventoryId: string): Promise<WarehouseFeeInvoiceView[]> {
return this.queryViews('AND i.source_id = $1', [inventoryId]); return this.queryViews("AND i.source_id = $1", [inventoryId]);
} }
listForBooking(bookingId: string): Promise<WarehouseFeeInvoiceView[]> { listForBooking(bookingId: string): Promise<WarehouseFeeInvoiceView[]> {
return this.queryViews('AND inv.booking_id = $1', [bookingId]); return this.queryViews("AND inv.booking_id = $1", [bookingId]);
} }
async findAll( async findAll(
filter: Partial< filter: Partial<
Pick< Pick<
WarehouseFeeInvoiceView, WarehouseFeeInvoiceView,
'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId' | "status"
| "invoiceType"
| "warehouseId"
| "facilityId"
| "customerId"
| "bookingId"
> >
>, >,
): Promise<WarehouseFeeInvoiceView[]> { ): Promise<WarehouseFeeInvoiceView[]> {
@@ -254,41 +285,56 @@ export class WarehouseInvoiceService {
conditions.push(sql(`$${params.length}`)); conditions.push(sql(`$${params.length}`));
}; };
if (filter.status) add((p) => `i.status::text = ${p}`, this.toGlobalStatus(filter.status as WarehouseInvoiceStatus)); 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.invoiceType) add((p) => `i.type = ${p}`, filter.invoiceType);
if (filter.customerId) add((p) => `i.company_id = ${p}`, filter.customerId); if (filter.customerId) add((p) => `i.company_id = ${p}`, filter.customerId);
if (filter.warehouseId) add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId); if (filter.warehouseId)
if (filter.facilityId) add((p) => `w.facility_id = ${p}`, filter.facilityId); 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); if (filter.bookingId) add((p) => `inv.booking_id = ${p}`, filter.bookingId);
return this.queryViews(conditions.map((c) => `AND ${c}`).join(' '), params); return this.queryViews(conditions.map((c) => `AND ${c}`).join(" "), params);
} }
async document(id: string): Promise<{ filename: string; buffer: Buffer }> { async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id); const invoice = await this.findById(id);
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'INVOICE')); return this.invoiceDocuments.render(
this.toDocumentModel(invoice, "INVOICE"),
);
} }
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> { async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id); const invoice = await this.findById(id);
if (Number(invoice.paidAmount) <= 0) { if (Number(invoice.paidAmount) <= 0) {
throw new BadRequestException('A receipt is available only after payment is recorded.'); throw new BadRequestException(
"A receipt is available only after payment is recorded.",
);
} }
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT')); return this.invoiceDocuments.render(
this.toDocumentModel(invoice, "RECEIPT"),
);
} }
// ── State changes ──────────────────────────────────────────────────────── // ── State changes ────────────────────────────────────────────────────────
async cancel(id: string): Promise<WarehouseFeeInvoiceDetail> { async cancel(id: string): Promise<WarehouseFeeInvoiceDetail> {
const invoice = await this.loadWarehouseInvoice(id); const invoice = await this.loadWarehouseInvoice(id);
if (invoice.status === Freight.InvoiceStatus.Paid) { if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException('A paid invoice cannot be cancelled.'); throw new BadRequestException("A paid invoice cannot be cancelled.");
} }
await this.billing.cancelInvoice(id); await this.billing.cancelInvoice(id);
return this.findById(id); return this.findById(id);
} }
/** Record a payment against the invoice (delegates settlement to billing). */ /** Record a payment against the invoice (delegates settlement to billing). */
async pay(id: string, dto: PayInvoiceDto): Promise<WarehouseFeeInvoiceDetail> { async pay(
id: string,
dto: PayInvoiceDto,
): Promise<WarehouseFeeInvoiceDetail> {
// Guard that this is a warehouse invoice before recording (404 otherwise). // Guard that this is a warehouse invoice before recording (404 otherwise).
await this.loadWarehouseInvoice(id); await this.loadWarehouseInvoice(id);
await this.billing.recordPayment(id, { await this.billing.recordPayment(id, {
@@ -297,7 +343,10 @@ export class WarehouseInvoiceService {
reference: dto.reference ?? null, reference: dto.reference ?? null,
metadata: metadata:
dto.driverName || dto.driverPhone dto.driverName || dto.driverPhone
? { driverName: dto.driverName ?? null, driverPhone: dto.driverPhone ?? null } ? {
driverName: dto.driverName ?? null,
driverPhone: dto.driverPhone ?? null,
}
: null, : null,
}); });
const detail = await this.findById(id); const detail = await this.findById(id);
@@ -313,16 +362,20 @@ export class WarehouseInvoiceService {
* counter settlement leaves it null. Skipping null-`paymentId` events avoids * counter settlement leaves it null. Skipping null-`paymentId` events avoids
* double-notifying a counter payment that already sent its SMS. * double-notifying a counter payment that already sent its SMS.
*/ */
@OnEvent('warehouse.invoice.paid') @OnEvent("warehouse.invoice.paid")
async onWarehouseInvoicePaid(payload: InvoiceEventPayload): Promise<void> { async onWarehouseInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
if (!payload.paymentId) return; if (!payload.paymentId) return;
const detail = await this.findById(payload.invoiceId); const detail = await this.findById(payload.invoiceId);
await this.notifyWarehouseFeePayment(detail, { amount: Number(detail.totalAmount) }); await this.notifyWarehouseFeePayment(detail, {
amount: Number(detail.totalAmount),
});
} }
// ── Release blocking ────────────────────────────────────────────────────── // ── Release blocking ──────────────────────────────────────────────────────
/** Returns the first unpaid invoice that blocks terminal release, or null. */ /** Returns the first unpaid invoice that blocks terminal release, or null. */
async findBlockingInvoice(inventoryId: string): Promise<WarehouseFeeInvoiceView | null> { async findBlockingInvoice(
inventoryId: string,
): Promise<WarehouseFeeInvoiceView | null> {
const blocking = await this.queryViews( const blocking = await this.queryViews(
`AND i.source_id = $1 AND i.status::text = ANY($2::text[])`, `AND i.source_id = $1 AND i.status::text = ANY($2::text[])`,
[inventoryId, BLOCKING_STATUSES], [inventoryId, BLOCKING_STATUSES],
@@ -331,21 +384,31 @@ export class WarehouseInvoiceService {
} }
async assertClearanceAllowed(inventoryId: string): Promise<void> { async assertClearanceAllowed(inventoryId: string): Promise<void> {
const invoices = await this.queryViews('AND i.source_id = $1', [inventoryId]); const invoices = await this.queryViews("AND i.source_id = $1", [
const blocking = invoices.find((inv) => inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID'); inventoryId,
]);
const blocking = invoices.find(
(inv) => inv.status === "ISSUED" || inv.status === "PARTIALLY_PAID",
);
if (blocking) { if (blocking) {
throw new BadRequestException( throw new BadRequestException(
`Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`, `Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`,
); );
} }
if (invoices.some((inv) => inv.status === 'PAID')) return; if (invoices.some((inv) => inv.status === "PAID")) return;
const previews = await this.feeService.previewForInventory(inventoryId, 'USD'); const previews = await this.feeService.previewForInventory(
const payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0); inventoryId,
"USD",
);
const payableAmount = previews.reduce(
(sum, fee) => sum + Number(fee.amount || 0),
0,
);
if (payableAmount > 0) { if (payableAmount > 0) {
throw new BadRequestException( throw new BadRequestException(
'Generate and fully pay the warehouse demurrage/storage invoice before terminal release.', "Generate and fully pay the warehouse demurrage/storage invoice before terminal release.",
); );
} }
} }
@@ -353,7 +416,9 @@ export class WarehouseInvoiceService {
// ── Internal: loading & projection ───────────────────────────────────────── // ── Internal: loading & projection ─────────────────────────────────────────
/** Load a global invoice (+lines) and assert it is a warehouse fee invoice. */ /** Load a global invoice (+lines) and assert it is a warehouse fee invoice. */
private async loadWarehouseInvoice(id: string): Promise<Invoice & { lines: InvoiceLine[] }> { private async loadWarehouseInvoice(
id: string,
): Promise<Invoice & { lines: InvoiceLine[] }> {
const invoice = await this.billing.findById(id); const invoice = await this.billing.findById(id);
if (invoice.source !== SOURCE) { if (invoice.source !== SOURCE) {
throw new NotFoundException(`Invoice ${id} not found`); throw new NotFoundException(`Invoice ${id} not found`);
@@ -376,7 +441,10 @@ export class WarehouseInvoiceService {
* Project warehouse-source global invoices into the historical view, joined to * Project warehouse-source global invoices into the historical view, joined to
* their inventory item for the typed FKs. Powers every list/filter read. * their inventory item for the typed FKs. Powers every list/filter read.
*/ */
private async queryViews(extraWhere: string, params: unknown[]): Promise<WarehouseFeeInvoiceView[]> { private async queryViews(
extraWhere: string,
params: unknown[],
): Promise<WarehouseFeeInvoiceView[]> {
const rows = await this.dataSource.query( const rows = await this.dataSource.query(
`SELECT i.id, i.invoice_number AS "invoiceNumber", i.company_id AS "companyId", `SELECT i.id, i.invoice_number AS "invoiceNumber", i.company_id AS "companyId",
i.source_id AS "sourceId", i.type, i.status, i.source_id AS "sourceId", i.type, i.status,
@@ -389,7 +457,7 @@ export class WarehouseInvoiceService {
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart", inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart",
w.facility_id AS "facilityId" w.facility_id AS "facilityId"
FROM freight.invoices i 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.warehouse_inventory inv ON inv.id::text = i.source_id AND inv.deleted_at IS NULL
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
WHERE i.source = $${params.length + 1} AND i.deleted_at IS NULL ${extraWhere} WHERE i.source = $${params.length + 1} AND i.deleted_at IS NULL ${extraWhere}
ORDER BY i.created_at DESC`, ORDER BY i.created_at DESC`,
@@ -409,7 +477,10 @@ export class WarehouseInvoiceService {
} }
/** Reshape a global invoice (+ derived inventory context) into the warehouse view. */ /** Reshape a global invoice (+ derived inventory context) into the warehouse view. */
private buildView(inv: ViewSource, ctx: InventoryContext): WarehouseFeeInvoiceView { private buildView(
inv: ViewSource,
ctx: InventoryContext,
): WarehouseFeeInvoiceView {
const status = this.toWarehouseStatus(inv.status); const status = this.toWarehouseStatus(inv.status);
return { return {
id: inv.id, id: inv.id,
@@ -436,7 +507,7 @@ export class WarehouseInvoiceService {
issuedAt: inv.issuedAt ?? null, issuedAt: inv.issuedAt ?? null,
dueDate: inv.dueAt ?? null, dueDate: inv.dueAt ?? null,
paidAt: inv.paidAt ?? null, paidAt: inv.paidAt ?? null,
cancelledAt: status === 'CANCELLED' ? inv.updatedAt : null, cancelledAt: status === "CANCELLED" ? inv.updatedAt : null,
payments: (inv.payments ?? []).map((p) => ({ payments: (inv.payments ?? []).map((p) => ({
amount: Number(p.amount), amount: Number(p.amount),
method: p.method ?? null, method: p.method ?? null,
@@ -458,7 +529,7 @@ export class WarehouseInvoiceService {
return { return {
feeRuleId: meta.feeRuleId ?? null, feeRuleId: meta.feeRuleId ?? null,
feeType: line.chargeType as WarehouseFeeType, feeType: line.chargeType as WarehouseFeeType,
description: line.description ?? '', description: line.description ?? "",
quantity: Number(line.quantity), quantity: Number(line.quantity),
unitRate: Number(line.unitRate), unitRate: Number(line.unitRate),
amount: Number(line.amount), amount: Number(line.amount),
@@ -468,32 +539,36 @@ export class WarehouseInvoiceService {
}; };
} }
private toWarehouseStatus(status: Freight.InvoiceStatus | string): WarehouseInvoiceStatus { private toWarehouseStatus(
status: Freight.InvoiceStatus | string,
): WarehouseInvoiceStatus {
switch (status) { switch (status) {
case Freight.InvoiceStatus.Draft: case Freight.InvoiceStatus.Draft:
return 'DRAFT'; return "DRAFT";
case Freight.InvoiceStatus.PartiallyPaid: case Freight.InvoiceStatus.PartiallyPaid:
return 'PARTIALLY_PAID'; return "PARTIALLY_PAID";
case Freight.InvoiceStatus.Paid: case Freight.InvoiceStatus.Paid:
return 'PAID'; return "PAID";
case Freight.InvoiceStatus.Cancelled: case Freight.InvoiceStatus.Cancelled:
case Freight.InvoiceStatus.Refunded: case Freight.InvoiceStatus.Refunded:
return 'CANCELLED'; return "CANCELLED";
default: default:
// Issued / Pending / Overdue → an issued, still-owed invoice. // Issued / Pending / Overdue → an issued, still-owed invoice.
return 'ISSUED'; return "ISSUED";
} }
} }
private toGlobalStatus(status: WarehouseInvoiceStatus): Freight.InvoiceStatus { private toGlobalStatus(
status: WarehouseInvoiceStatus,
): Freight.InvoiceStatus {
switch (status) { switch (status) {
case 'DRAFT': case "DRAFT":
return Freight.InvoiceStatus.Draft; return Freight.InvoiceStatus.Draft;
case 'PARTIALLY_PAID': case "PARTIALLY_PAID":
return Freight.InvoiceStatus.PartiallyPaid; return Freight.InvoiceStatus.PartiallyPaid;
case 'PAID': case "PAID":
return Freight.InvoiceStatus.Paid; return Freight.InvoiceStatus.Paid;
case 'CANCELLED': case "CANCELLED":
return Freight.InvoiceStatus.Cancelled; return Freight.InvoiceStatus.Cancelled;
default: default:
return Freight.InvoiceStatus.Issued; return Freight.InvoiceStatus.Issued;
@@ -503,39 +578,54 @@ export class WarehouseInvoiceService {
/** Map a warehouse fee invoice view onto the shared document model. */ /** Map a warehouse fee invoice view onto the shared document model. */
private toDocumentModel( private toDocumentModel(
invoice: WarehouseFeeInvoiceDetail, invoice: WarehouseFeeInvoiceDetail,
kind: 'INVOICE' | 'RECEIPT', kind: "INVOICE" | "RECEIPT",
): InvoiceDocumentModel { ): InvoiceDocumentModel {
const lastPayment = [...(invoice.payments ?? [])].pop(); const lastPayment = [...(invoice.payments ?? [])].pop();
const date = (value: unknown) => const date = (value: unknown) =>
value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null; value
? new Date(value as string | Date).toLocaleDateString("en-GB")
: null;
return { return {
kind, kind,
title: 'Warehouse Fee', title: "Warehouse Fee",
documentNumber: invoice.invoiceNumber, documentNumber: invoice.invoiceNumber,
issuedAt: invoice.issuedAt ?? invoice.createdAt, issuedAt: invoice.issuedAt ?? invoice.createdAt,
status: invoice.status, status: invoice.status,
currency: invoice.currency, currency: invoice.currency,
summary: [ summary: [
{ label: 'Status', value: invoice.status.replace(/_/g, ' ') }, { 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', label: "Invoice type",
value: [invoice.yardName, invoice.zoneName].filter(Boolean).join(' / ') || null, value: invoice.invoiceType.replace(/_/g, " "),
}, },
{ label: 'Period', value: `${date(invoice.periodStart) ?? '-'} - ${date(invoice.periodEnd) ?? '-'}` }, { label: "Booking reference", value: invoice.bookingReference ?? null },
{ label: "Customer", value: invoice.customerName ?? null },
{ {
label: 'Payment', label: "Inventory reference",
value: lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt) ?? '-'}` : null, 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', categoryHeader: "Fee type",
lines: invoice.items.map((item) => ({ lines: invoice.items.map((item) => ({
description: item.description ?? null, description: item.description ?? null,
category: item.feeType ?? null, category: item.feeType ?? null,
@@ -545,17 +635,19 @@ export class WarehouseInvoiceService {
currency: item.currency ?? invoice.currency, currency: item.currency ?? invoice.currency,
})), })),
totals: [ totals: [
{ label: 'Subtotal', amount: Number(invoice.subtotalAmount) }, { label: "Subtotal", amount: Number(invoice.subtotalAmount) },
{ label: 'Tax', amount: Number(invoice.taxAmount) }, { label: "Tax", amount: Number(invoice.taxAmount) },
{ label: 'Total', amount: Number(invoice.totalAmount), grand: true }, { label: "Total", amount: Number(invoice.totalAmount), grand: true },
{ label: 'Paid', amount: Number(invoice.paidAmount) }, { label: "Paid", amount: Number(invoice.paidAmount) },
{ label: 'Balance', amount: Number(invoice.balanceAmount) }, { label: "Balance", amount: Number(invoice.balanceAmount) },
], ],
}; };
} }
/** Warehouse-specific display details, derived from the linked inventory item. */ /** Warehouse-specific display details, derived from the linked inventory item. */
private async getInvoiceDocumentDetails(invoice: ViewSource): Promise<InvoiceDocumentDetails> { private async getInvoiceDocumentDetails(
invoice: ViewSource,
): Promise<InvoiceDocumentDetails> {
const [row] = await this.dataSource.query( const [row] = await this.dataSource.query(
`SELECT b.reference AS "bookingReference", `SELECT b.reference AS "bookingReference",
company.name AS "customerName", company.name AS "customerName",
@@ -591,12 +683,12 @@ export class WarehouseInvoiceService {
[invoice.sourceId], [invoice.sourceId],
); );
const fullyPaid = this.toWarehouseStatus(invoice.status) === 'PAID'; const fullyPaid = this.toWarehouseStatus(invoice.status) === "PAID";
const clearanceStatus = row?.releaseDate const clearanceStatus = row?.releaseDate
? 'RELEASE ISSUED' ? "RELEASE ISSUED"
: fullyPaid : fullyPaid
? 'FEE PAID - READY FOR RELEASE' ? "FEE PAID - READY FOR RELEASE"
: 'PENDING PAYMENT'; : "PENDING PAYMENT";
return { return {
bookingReference: row?.bookingReference ?? null, bookingReference: row?.bookingReference ?? null,
@@ -613,7 +705,9 @@ export class WarehouseInvoiceService {
}; };
} }
private async getInventoryContext(inventoryId: string): Promise<InventoryContext> { private async getInventoryContext(
inventoryId: string,
): Promise<InventoryContext> {
const [row] = await this.dataSource.query( const [row] = await this.dataSource.query(
`SELECT inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", `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", inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart",
@@ -701,55 +795,89 @@ export class WarehouseInvoiceService {
}; };
} }
private async sendSms(recipient: string | null | undefined, message: string, context: string): Promise<void> { private async sendSms(
recipient: string | null | undefined,
message: string,
context: string,
): Promise<void> {
const phone = recipient?.trim(); const phone = recipient?.trim();
if (!phone) return; if (!phone) return;
try { try {
await this.notifications.directSend('sms', phone, message); await this.notifications.directSend("sms", phone, message);
} catch (error) { } catch (error) {
this.logger.error(`Failed to send ${context} SMS to ${phone}: ${String(error)}`); this.logger.error(
`Failed to send ${context} SMS to ${phone}: ${String(error)}`,
);
} }
} }
private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoiceView): Promise<void> { private async notifyWarehouseFeeIssued(
const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId); invoice: WarehouseFeeInvoiceView,
const customerName = contacts.customerName?.trim() || 'Customer'; ): Promise<void> {
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; 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; const cargo = contacts.containerNumber || contacts.cargoDescription;
const cargoText = cargo ? ` Cargo: ${cargo}.` : ''; const cargoText = cargo ? ` Cargo: ${cargo}.` : "";
const message = const message =
`Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, ' ').toLowerCase()} fee ` + `Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ` +
`${invoice.invoiceNumber} is due.${bookingReference}${cargoText} Amount: ` + `${invoice.invoiceNumber} is due.${bookingReference}${cargoText} Amount: ` +
`${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Please pay before cargo pickup.`; `${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Please pay before cargo pickup.`;
await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`); await this.sendSms(
contacts.customerPhone,
message,
`warehouse fee invoice ${invoice.invoiceNumber}`,
);
} }
private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoiceView, dto: PayInvoiceDto): Promise<void> { private async notifyWarehouseFeePayment(
const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId); invoice: WarehouseFeeInvoiceView,
const customerName = contacts.customerName?.trim() || 'Customer'; dto: PayInvoiceDto,
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; ): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(
invoice.inventoryId,
);
const customerName = contacts.customerName?.trim() || "Customer";
const bookingReference = contacts.bookingReference
? ` Booking: ${contacts.bookingReference}.`
: "";
const statusText = const statusText =
invoice.status === 'PAID' invoice.status === "PAID"
? 'fully paid and ready for pickup release' ? "fully paid and ready for pickup release"
: `partially paid. Balance: ${Number(invoice.balanceAmount).toLocaleString()} ${invoice.currency}`; : `partially paid. Balance: ${Number(invoice.balanceAmount).toLocaleString()} ${invoice.currency}`;
const customerMessage = const customerMessage =
`Dear ${customerName}, payment of ${Number(dto.amount).toLocaleString()} ${invoice.currency} ` + `Dear ${customerName}, payment of ${Number(dto.amount).toLocaleString()} ${invoice.currency} ` +
`was recorded for warehouse fee ${invoice.invoiceNumber}.${bookingReference} Status: ${statusText}.`; `was recorded for warehouse fee ${invoice.invoiceNumber}.${bookingReference} Status: ${statusText}.`;
await this.sendSms(contacts.customerPhone, customerMessage, `warehouse fee payment ${invoice.invoiceNumber}`); await this.sendSms(
contacts.customerPhone,
customerMessage,
`warehouse fee payment ${invoice.invoiceNumber}`,
);
if (invoice.status !== 'PAID') return; if (invoice.status !== "PAID") return;
const driverPhone = dto.driverPhone?.trim() || contacts.driverPhone; const driverPhone = dto.driverPhone?.trim() || contacts.driverPhone;
const driverName = dto.driverName?.trim() || contacts.driverName || 'Driver'; const driverName =
dto.driverName?.trim() || contacts.driverName || "Driver";
const cargo = contacts.containerNumber || contacts.cargoDescription; const cargo = contacts.containerNumber || contacts.cargoDescription;
const driverMessage = const driverMessage =
`Dear ${driverName}, warehouse demurrage/storage fee ${invoice.invoiceNumber} is paid.` + `Dear ${driverName}, warehouse demurrage/storage fee ${invoice.invoiceNumber} is paid.` +
(contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '') + (contacts.bookingReference
(cargo ? ` Cargo: ${cargo}.` : '') + ? ` Booking: ${contacts.bookingReference}.`
' Proceed with pickup after gate verification.'; : "") +
(cargo ? ` Cargo: ${cargo}.` : "") +
" Proceed with pickup after gate verification.";
await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`); await this.sendSms(
driverPhone,
driverMessage,
`warehouse pickup driver ${invoice.invoiceNumber}`,
);
} }
} }