|
|
|
|
@@ -1,22 +1,23 @@
|
|
|
|
|
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
|
|
|
|
import { Freight } from '@edr/types';
|
|
|
|
|
import { DataSource } from 'typeorm';
|
|
|
|
|
|
|
|
|
|
import { BillingService, 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 { nextDailyInvoiceNumber } from '../billing/invoice-numbering.util';
|
|
|
|
|
import { applySettlement } from '../billing/invoice-settlement.util';
|
|
|
|
|
import { NotificationsService } from '../notifications/notifications.service';
|
|
|
|
|
import { WarehouseFeeService } from './warehouse-fee.service';
|
|
|
|
|
import {
|
|
|
|
|
WarehouseFeeInvoice,
|
|
|
|
|
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';
|
|
|
|
|
} from './warehouse-invoice.types';
|
|
|
|
|
|
|
|
|
|
interface GenerateOptions {
|
|
|
|
|
confirmZero?: boolean;
|
|
|
|
|
@@ -32,9 +33,20 @@ 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;
|
|
|
|
|
/** Document number prefix kept for warehouse fee invoices (e.g. `WHF-20260630-00001`). */
|
|
|
|
|
const NUMBER_CODE = 'WHF';
|
|
|
|
|
|
|
|
|
|
/** 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;
|
|
|
|
|
@@ -50,28 +62,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 feeService: WarehouseFeeService,
|
|
|
|
|
private readonly billing: BillingService,
|
|
|
|
|
private readonly invoiceDocuments: InvoiceDocumentService,
|
|
|
|
|
private readonly feeService: WarehouseFeeService,
|
|
|
|
|
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
|
|
|
|
|
@@ -80,9 +139,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.',
|
|
|
|
|
);
|
|
|
|
|
@@ -117,9 +183,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.');
|
|
|
|
|
}
|
|
|
|
|
@@ -129,58 +193,77 @@ 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,
|
|
|
|
|
numberCode: NUMBER_CODE,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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 (shared billing numbering). */
|
|
|
|
|
private nextInvoiceNumber(): Promise<string> {
|
|
|
|
|
return nextDailyInvoiceNumber(this.dataSource, {
|
|
|
|
|
table: 'freight.warehouse_fee_invoices',
|
|
|
|
|
code: 'WHF',
|
|
|
|
|
});
|
|
|
|
|
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 }> {
|
|
|
|
|
@@ -196,20 +279,219 @@ export class WarehouseInvoiceService {
|
|
|
|
|
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Map a warehouse fee invoice (with display details + items) onto the shared document model. */
|
|
|
|
|
// ── State changes ────────────────────────────────────────────────────────
|
|
|
|
|
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 (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 detail = await this.findById(id);
|
|
|
|
|
await this.notifyWarehouseFeePayment(detail, dto);
|
|
|
|
|
return detail;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Release blocking ──────────────────────────────────────────────────────
|
|
|
|
|
/** Returns the first unpaid invoice that blocks terminal release, or 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.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;
|
|
|
|
|
|
|
|
|
|
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.',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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: WarehouseFeeInvoiceWithDisplay & { items: unknown[] },
|
|
|
|
|
invoice: WarehouseFeeInvoiceDetail,
|
|
|
|
|
kind: 'INVOICE' | 'RECEIPT',
|
|
|
|
|
): InvoiceDocumentModel {
|
|
|
|
|
const items = invoice.items as Array<{
|
|
|
|
|
description?: string;
|
|
|
|
|
feeType?: string;
|
|
|
|
|
quantity?: number;
|
|
|
|
|
unitRate?: number;
|
|
|
|
|
amount?: number;
|
|
|
|
|
currency?: string;
|
|
|
|
|
chargeableDays?: number | null;
|
|
|
|
|
}>;
|
|
|
|
|
const lastPayment = [...(invoice.payments ?? [])].pop();
|
|
|
|
|
const date = (value: unknown) =>
|
|
|
|
|
value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null;
|
|
|
|
|
@@ -241,7 +523,7 @@ export class WarehouseInvoiceService {
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
categoryHeader: 'Fee type',
|
|
|
|
|
lines: items.map((item) => ({
|
|
|
|
|
lines: invoice.items.map((item) => ({
|
|
|
|
|
description: item.description ?? null,
|
|
|
|
|
category: item.feeType ?? null,
|
|
|
|
|
quantity: item.quantity ?? item.chargeableDays ?? 0,
|
|
|
|
|
@@ -259,92 +541,14 @@ export class WarehouseInvoiceService {
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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' } });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 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, balanceAmount, fullyPaid } = applySettlement(
|
|
|
|
|
invoice.totalAmount,
|
|
|
|
|
invoice.paidAmount,
|
|
|
|
|
dto.amount,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
balanceAmount,
|
|
|
|
|
status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID',
|
|
|
|
|
paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null,
|
|
|
|
|
payments,
|
|
|
|
|
});
|
|
|
|
|
const paidInvoice = updated as WarehouseFeeInvoice;
|
|
|
|
|
await this.notifyWarehouseFeePayment(paidInvoice, dto);
|
|
|
|
|
return paidInvoice;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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 assertClearanceAllowed(inventoryId: string): Promise<void> {
|
|
|
|
|
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
|
|
|
|
|
const blocking = invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status));
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
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.',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async getInvoiceDocumentDetails(invoice: WarehouseFeeInvoice): Promise<InvoiceDocumentDetails> {
|
|
|
|
|
/** 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(
|
|
|
|
|
@@ -355,16 +559,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
|
|
|
|
|
@@ -372,14 +570,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,
|
|
|
|
|
@@ -391,11 +596,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;
|
|
|
|
|
@@ -417,10 +644,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
|
|
|
|
|
@@ -446,9 +672,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 {
|
|
|
|
|
@@ -472,8 +698,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;
|
|
|
|
|
@@ -486,8 +712,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 =
|
|
|
|
|
|