From df60c4750e93247f016502f780eeb32479810fd5 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 2 Jul 2026 16:32:35 +0300 Subject: [PATCH] fix: warehouse query --- .../modules/first-mile/first-mile.service.ts | 10 +- .../warehouses/warehouse-invoice.service.ts | 370 ++++++++++++------ 2 files changed, 250 insertions(+), 130 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 3ba1e4e75..d05964878 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,10 +1,4 @@ -import { - BadRequestException, - ConflictException, - Injectable, - Logger, - NotFoundException, -} from "@nestjs/common"; +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; import { FindOptionsWhere } from "typeorm"; import { InjectDataSource } from "@nestjs/typeorm"; import { DataSource } from "typeorm"; @@ -98,8 +92,6 @@ export class FirstMileService { firstMilePickupAddress?: string | null; serviceType?: { includesFirstMile?: boolean | null } | null; }): Promise { - const label = booking.reference ?? booking.id; - if (booking.paymentStatus !== "PAID") { return null; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 6f7219781..c5a75a8c5 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -1,29 +1,39 @@ -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 { + BadRequestException, + ConflictException, + 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 { Invoice } from '../billing/entities/invoice.entity'; -import { InvoiceLine } from '../billing/entities/invoice-line.entity'; +import { + BillingService, + InvoiceEventPayload, + InvoiceLineInput, +} from "../billing/billing.service"; +import { Invoice } from "../billing/entities/invoice.entity"; +import { InvoiceLine } from "../billing/entities/invoice-line.entity"; import { InvoiceDocumentModel, InvoiceDocumentService, -} from '../billing/documents/invoice-document.service'; -import { NotificationsService } from '../notifications/notifications.service'; -import { WarehouseFeeService } from './warehouse-fee.service'; +} 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 './warehouse-invoice.types'; +} from "./warehouse-invoice.types"; interface GenerateOptions { confirmZero?: boolean; performedBy?: string; - billingCurrency?: 'ETB' | 'USD'; + billingCurrency?: "ETB" | "USD"; } export interface PayInvoiceDto { @@ -45,7 +55,10 @@ const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [ Freight.InvoiceStatus.Overdue, ]; /** 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 { bookingReference: string | null; @@ -120,10 +133,13 @@ export class WarehouseInvoiceService { private readonly invoiceDocuments: InvoiceDocumentService, private readonly feeService: WarehouseFeeService, private readonly notifications: NotificationsService, - ) {} + ) { } // ── Generation ─────────────────────────────────────────────────────────── - async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise { + async generateForInventory( + inventoryId: string, + opts: GenerateOptions = {}, + ): Promise { 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", @@ -136,43 +152,47 @@ export class WarehouseInvoiceService { WHERE inv.id = $1 AND inv.deleted_at IS NULL`, [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, // 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).', + "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. 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.', + "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 previews = await this.feeService.previewForInventory(inventoryId, billingCurrency); - const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER'; + const billingCurrency = opts.billingCurrency === "ETB" ? "ETB" : "USD"; + const previews = await this.feeService.previewForInventory( + inventoryId, + billingCurrency, + ); + const isContainer = (item.freightType ?? "").toUpperCase() === "CONTAINER"; const items = previews .filter((p) => p.amount > 0) .map((p) => { const feeType: WarehouseFeeType = - p.ruleType === 'STORAGE_FEE' - ? 'STORAGE_FEE' + p.ruleType === "STORAGE_FEE" + ? "STORAGE_FEE" : isContainer - ? 'CONTAINER_DEMURRAGE' - : 'BULK_DEMURRAGE'; + ? "CONTAINER_DEMURRAGE" + : "BULK_DEMURRAGE"; return { feeRuleId: p.ruleId, feeType, 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` - : `${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, unitRate: p.ratePerDay, amount: p.amount, @@ -184,13 +204,19 @@ export class WarehouseInvoiceService { 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.'); + throw new BadRequestException( + "No payable warehouse fee found for this item.", + ); } - const hasDemurrage = items.some((i) => i.feeType !== 'STORAGE_FEE'); - const hasStorage = 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 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) => ({ chargeType: it.feeType, @@ -232,18 +258,23 @@ export class WarehouseInvoiceService { } listForInventory(inventoryId: string): Promise { - return this.queryViews('AND i.source_id = $1', [inventoryId]); + return this.queryViews("AND i.source_id = $1", [inventoryId]); } listForBooking(bookingId: string): Promise { - return this.queryViews('AND inv.booking_id = $1', [bookingId]); + return this.queryViews("AND inv.booking_id = $1", [bookingId]); } async findAll( filter: Partial< Pick< WarehouseFeeInvoiceView, - 'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId' + | "status" + | "invoiceType" + | "warehouseId" + | "facilityId" + | "customerId" + | "bookingId" > >, ): Promise { @@ -254,41 +285,56 @@ export class WarehouseInvoiceService { 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.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.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); + 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); - 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 }> { const invoice = await this.findById(id); 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 ──────────────────────────────────────────────────────── async cancel(id: string): Promise { const invoice = await this.loadWarehouseInvoice(id); 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); return this.findById(id); } /** Record a payment against the invoice (delegates settlement to billing). */ - async pay(id: string, dto: PayInvoiceDto): Promise { + async pay( + id: string, + dto: PayInvoiceDto, + ): Promise { // Guard that this is a warehouse invoice before recording (404 otherwise). await this.loadWarehouseInvoice(id); await this.billing.recordPayment(id, { @@ -297,7 +343,10 @@ export class WarehouseInvoiceService { reference: dto.reference ?? null, metadata: dto.driverName || dto.driverPhone - ? { driverName: dto.driverName ?? null, driverPhone: dto.driverPhone ?? null } + ? { + driverName: dto.driverName ?? null, + driverPhone: dto.driverPhone ?? null, + } : null, }); const detail = await this.findById(id); @@ -313,16 +362,20 @@ export class WarehouseInvoiceService { * counter settlement leaves it null. Skipping null-`paymentId` events avoids * double-notifying a counter payment that already sent its SMS. */ - @OnEvent('warehouse.invoice.paid') + @OnEvent("warehouse.invoice.paid") async onWarehouseInvoicePaid(payload: InvoiceEventPayload): Promise { if (!payload.paymentId) return; 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 ────────────────────────────────────────────────────── /** Returns the first unpaid invoice that blocks terminal release, or null. */ - async findBlockingInvoice(inventoryId: string): Promise { + async findBlockingInvoice( + inventoryId: string, + ): Promise { const blocking = await this.queryViews( `AND i.source_id = $1 AND i.status::text = ANY($2::text[])`, [inventoryId, BLOCKING_STATUSES], @@ -331,21 +384,31 @@ export class WarehouseInvoiceService { } async assertClearanceAllowed(inventoryId: string): Promise { - const invoices = await this.queryViews('AND i.source_id = $1', [inventoryId]); - const blocking = invoices.find((inv) => inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID'); + 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.`, ); } - 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 payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0); + const previews = await this.feeService.previewForInventory( + inventoryId, + "USD", + ); + const payableAmount = previews.reduce( + (sum, fee) => sum + Number(fee.amount || 0), + 0, + ); if (payableAmount > 0) { 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 ───────────────────────────────────────── /** Load a global invoice (+lines) and assert it is a warehouse fee invoice. */ - private async loadWarehouseInvoice(id: string): Promise { + private async loadWarehouseInvoice( + id: string, + ): Promise { const invoice = await this.billing.findById(id); if (invoice.source !== SOURCE) { 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 * their inventory item for the typed FKs. Powers every list/filter read. */ - private async queryViews(extraWhere: string, params: unknown[]): Promise { + private async queryViews( + extraWhere: string, + params: unknown[], + ): Promise { 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, @@ -389,7 +457,7 @@ export class WarehouseInvoiceService { 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.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 WHERE i.source = $${params.length + 1} AND i.deleted_at IS NULL ${extraWhere} ORDER BY i.created_at DESC`, @@ -409,7 +477,10 @@ export class WarehouseInvoiceService { } /** 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); return { id: inv.id, @@ -436,7 +507,7 @@ export class WarehouseInvoiceService { issuedAt: inv.issuedAt ?? null, dueDate: inv.dueAt ?? null, paidAt: inv.paidAt ?? null, - cancelledAt: status === 'CANCELLED' ? inv.updatedAt : null, + cancelledAt: status === "CANCELLED" ? inv.updatedAt : null, payments: (inv.payments ?? []).map((p) => ({ amount: Number(p.amount), method: p.method ?? null, @@ -458,7 +529,7 @@ export class WarehouseInvoiceService { return { feeRuleId: meta.feeRuleId ?? null, feeType: line.chargeType as WarehouseFeeType, - description: line.description ?? '', + description: line.description ?? "", quantity: Number(line.quantity), unitRate: Number(line.unitRate), 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) { case Freight.InvoiceStatus.Draft: - return 'DRAFT'; + return "DRAFT"; case Freight.InvoiceStatus.PartiallyPaid: - return 'PARTIALLY_PAID'; + return "PARTIALLY_PAID"; case Freight.InvoiceStatus.Paid: - return 'PAID'; + return "PAID"; case Freight.InvoiceStatus.Cancelled: case Freight.InvoiceStatus.Refunded: - return 'CANCELLED'; + return "CANCELLED"; default: // 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) { - case 'DRAFT': + case "DRAFT": return Freight.InvoiceStatus.Draft; - case 'PARTIALLY_PAID': + case "PARTIALLY_PAID": return Freight.InvoiceStatus.PartiallyPaid; - case 'PAID': + case "PAID": return Freight.InvoiceStatus.Paid; - case 'CANCELLED': + case "CANCELLED": return Freight.InvoiceStatus.Cancelled; default: return Freight.InvoiceStatus.Issued; @@ -503,39 +578,54 @@ export class WarehouseInvoiceService { /** Map a warehouse fee invoice view onto the shared document model. */ private toDocumentModel( invoice: WarehouseFeeInvoiceDetail, - kind: 'INVOICE' | 'RECEIPT', + kind: "INVOICE" | "RECEIPT", ): InvoiceDocumentModel { const lastPayment = [...(invoice.payments ?? [])].pop(); 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 { kind, - title: 'Warehouse Fee', + 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: "Status", value: invoice.status.replace(/_/g, " ") }, { - label: 'Yard / Zone', - value: [invoice.yardName, invoice.zoneName].filter(Boolean).join(' / ') || null, + label: "Invoice type", + 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', - value: lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt) ?? '-'}` : 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', + categoryHeader: "Fee type", lines: invoice.items.map((item) => ({ description: item.description ?? null, category: item.feeType ?? null, @@ -545,17 +635,19 @@ export class WarehouseInvoiceService { 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) }, + { 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 { + private async getInvoiceDocumentDetails( + invoice: ViewSource, + ): Promise { const [row] = await this.dataSource.query( `SELECT b.reference AS "bookingReference", company.name AS "customerName", @@ -591,12 +683,12 @@ export class WarehouseInvoiceService { [invoice.sourceId], ); - const fullyPaid = this.toWarehouseStatus(invoice.status) === 'PAID'; + const fullyPaid = this.toWarehouseStatus(invoice.status) === "PAID"; const clearanceStatus = row?.releaseDate - ? 'RELEASE ISSUED' + ? "RELEASE ISSUED" : fullyPaid - ? 'FEE PAID - READY FOR RELEASE' - : 'PENDING PAYMENT'; + ? "FEE PAID - READY FOR RELEASE" + : "PENDING PAYMENT"; return { bookingReference: row?.bookingReference ?? null, @@ -613,7 +705,9 @@ export class WarehouseInvoiceService { }; } - private async getInventoryContext(inventoryId: string): Promise { + private async getInventoryContext( + inventoryId: string, + ): Promise { 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", @@ -701,55 +795,89 @@ export class WarehouseInvoiceService { }; } - private async sendSms(recipient: string | null | undefined, message: string, context: string): Promise { + private async sendSms( + recipient: string | null | undefined, + message: string, + context: string, + ): Promise { const phone = recipient?.trim(); if (!phone) return; try { - await this.notifications.directSend('sms', phone, message); + await this.notifications.directSend("sms", phone, message); } 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 { - const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId); - const customerName = contacts.customerName?.trim() || 'Customer'; - const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; + private async notifyWarehouseFeeIssued( + invoice: WarehouseFeeInvoiceView, + ): Promise { + 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 cargoText = cargo ? ` Cargo: ${cargo}.` : ''; + const cargoText = cargo ? ` Cargo: ${cargo}.` : ""; 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: ` + `${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 { - const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId); - const customerName = contacts.customerName?.trim() || 'Customer'; - const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; + private async notifyWarehouseFeePayment( + invoice: WarehouseFeeInvoiceView, + dto: PayInvoiceDto, + ): Promise { + const contacts = await this.getInvoiceNotificationContacts( + invoice.inventoryId, + ); + const customerName = contacts.customerName?.trim() || "Customer"; + const bookingReference = contacts.bookingReference + ? ` Booking: ${contacts.bookingReference}.` + : ""; const statusText = - invoice.status === 'PAID' - ? 'fully paid and ready for pickup release' + invoice.status === "PAID" + ? "fully paid and ready for pickup release" : `partially paid. Balance: ${Number(invoice.balanceAmount).toLocaleString()} ${invoice.currency}`; const customerMessage = `Dear ${customerName}, payment of ${Number(dto.amount).toLocaleString()} ${invoice.currency} ` + `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 driverName = dto.driverName?.trim() || contacts.driverName || 'Driver'; + const driverName = + dto.driverName?.trim() || contacts.driverName || "Driver"; const cargo = contacts.containerNumber || contacts.cargoDescription; const driverMessage = `Dear ${driverName}, warehouse demurrage/storage fee ${invoice.invoiceNumber} is paid.` + - (contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '') + - (cargo ? ` Cargo: ${cargo}.` : '') + - ' Proceed with pickup after gate verification.'; + (contacts.bookingReference + ? ` Booking: ${contacts.bookingReference}.` + : "") + + (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}`, + ); } }