Notification for demurrage and invoice fee

This commit is contained in:
hagiye
2026-06-27 05:33:58 +03:00
parent 11803763b0
commit d726927453
8 changed files with 486 additions and 14 deletions

View File

@@ -33,4 +33,14 @@ export class PayInvoiceBodyDto {
@IsOptional()
@IsString()
reference?: string;
@ApiPropertyOptional({ description: 'Pickup driver name to notify after payment' })
@IsOptional()
@IsString()
driverName?: string;
@ApiPropertyOptional({ description: 'Pickup driver phone to notify after payment' })
@IsOptional()
@IsString()
driverPhone?: string;
}

View File

@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, IsString } from 'class-validator';
import { IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator';
/** Records a DO / release order being sent to the customer for import pickup. */
export class ReleaseOrderDto {
@@ -17,4 +17,77 @@ export class ReleaseOrderDto {
@IsOptional()
@IsString()
performedBy?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
bookingId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
customerId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
truckPlateNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
trailerPlateNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
driverName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
driverLicense?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
driverPhone?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
truckType?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
containerNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
gateInTime?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
tareWeight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
grossWeight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
netWeight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
gateOutTime?: string;
}

View File

@@ -1883,11 +1883,13 @@ export class WarehouseInventoryService {
const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date();
const reference = dto.reference?.trim() || null;
const exitInspectionNote = this.buildExitInspectionNote(dto);
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(id, {
releaseDate,
releaseOrderReference: reference,
notes: [item.notes?.trim(), exitInspectionNote].filter(Boolean).join('\n\n'),
});
await this.activityLog.record(
{
@@ -1914,6 +1916,7 @@ export class WarehouseInventoryService {
inv.quantity,
inv.weight,
inv.status,
inv.notes,
b.id AS "bookingId",
b.reference AS "bookingReference",
b.status AS "bookingStatus",
@@ -1974,6 +1977,7 @@ export class WarehouseInventoryService {
zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null,
inventoryStatus: row?.status ?? null,
clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT',
exitInspectionSummary: this.extractExitInspectionNote(row?.notes),
});
return {
@@ -2479,6 +2483,7 @@ export class WarehouseInventoryService {
zone: string | null;
inventoryStatus: string | null;
clearanceStatus: string;
exitInspectionSummary?: string | null;
}): string {
const esc = (value: unknown) =>
String(value ?? '-')
@@ -2509,6 +2514,7 @@ export class WarehouseInventoryService {
['Zone', data.zone],
['Inventory Status', data.inventoryStatus],
['Clearance Status', data.clearanceStatus],
...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []),
];
return `<!doctype html>
@@ -2783,6 +2789,71 @@ export class WarehouseInventoryService {
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
}
private buildExitInspectionNote(dto: ReleaseOrderDto): string | null {
const hasExitInspection =
Boolean(dto.truckPlateNumber?.trim()) ||
Boolean(dto.trailerPlateNumber?.trim()) ||
Boolean(dto.driverName?.trim()) ||
Boolean(dto.driverLicense?.trim()) ||
Boolean(dto.driverPhone?.trim()) ||
Boolean(dto.truckType?.trim()) ||
Boolean(dto.containerNumber?.trim()) ||
dto.tareWeight !== undefined ||
dto.grossWeight !== undefined ||
dto.netWeight !== undefined ||
Boolean(dto.gateInTime) ||
Boolean(dto.gateOutTime);
if (!hasExitInspection) return null;
if (!dto.truckPlateNumber?.trim()) {
throw new BadRequestException('Truck plate number is required for exit inspection');
}
if (!dto.driverName?.trim()) {
throw new BadRequestException('Driver name is required for exit inspection');
}
if (dto.tareWeight === undefined || dto.grossWeight === undefined) {
throw new BadRequestException('Tare weight and gross weight are required for exit inspection');
}
const tareWeight = Number(dto.tareWeight);
const grossWeight = Number(dto.grossWeight);
const computedNetWeight = Number((grossWeight - tareWeight).toFixed(3));
const submittedNetWeight = dto.netWeight === undefined ? computedNetWeight : Number(dto.netWeight);
if (Math.abs(submittedNetWeight - computedNetWeight) > 0.001) {
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
}
const rows = [
'[Exit Inspection]',
dto.bookingId?.trim() ? `Booking ID: ${dto.bookingId.trim()}` : null,
dto.customerId?.trim() ? `Customer ID: ${dto.customerId.trim()}` : null,
`Truck Plate: ${dto.truckPlateNumber.trim()}`,
dto.trailerPlateNumber?.trim() ? `Trailer Plate: ${dto.trailerPlateNumber.trim()}` : null,
`Driver: ${dto.driverName.trim()}`,
dto.driverLicense?.trim() ? `Driver License: ${dto.driverLicense.trim()}` : null,
dto.driverPhone?.trim() ? `Driver Phone: ${dto.driverPhone.trim()}` : null,
dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null,
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
`Tare Weight: ${tareWeight} kg`,
`Gross Weight: ${grossWeight} kg`,
`Net Weight: ${computedNetWeight} kg`,
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
];
return rows.filter(Boolean).join('\n');
}
private extractExitInspectionNote(notes?: string | null): string | null {
if (!notes) return null;
const marker = '[Exit Inspection]';
const index = notes.lastIndexOf(marker);
if (index < 0) return null;
return notes.slice(index + marker.length).trim() || null;
}
private buildReceiveNote(input: {
grnNumber: string;
direction?: string | null;

View File

@@ -1,6 +1,7 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { NotificationsService } from '../notifications/notifications.service';
import {
WarehouseFeeInvoice,
WarehouseInvoiceStatus,
@@ -22,6 +23,8 @@ export interface PayInvoiceDto {
amount: number;
method?: string;
reference?: string;
driverName?: string;
driverPhone?: string;
}
/** Invoices that still owe money and therefore block terminal release. */
@@ -46,12 +49,15 @@ export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial<Invoi
@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 documents: WarehouseReleaseDocumentService,
private readonly notifications: NotificationsService,
) {}
// ── Generation ───────────────────────────────────────────────────────────
@@ -150,7 +156,9 @@ export class WarehouseInvoiceService {
await this.itemRepository.create({ invoiceId: invoice.id, ...it });
}
return this.findById(invoice.id);
const saved = await this.findById(invoice.id);
await this.notifyWarehouseFeeIssued(saved);
return saved;
}
/** WHF-YYYYMMDD-00001 — sequential per day. */
@@ -246,7 +254,9 @@ export class WarehouseInvoiceService {
paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null,
payments,
});
return updated as WarehouseFeeInvoice;
const paidInvoice = updated as WarehouseFeeInvoice;
await this.notifyWarehouseFeePayment(paidInvoice, dto);
return paidInvoice;
}
// ── Release blocking ──────────────────────────────────────────────────────
@@ -332,6 +342,125 @@ export class WarehouseInvoiceService {
};
}
private async getInvoiceNotificationContacts(invoice: WarehouseFeeInvoice): Promise<{
bookingReference: string | null;
customerName: string | null;
customerPhone: string | null;
driverName: string | null;
driverPhone: string | null;
containerNumber: string | null;
cargoDescription: string | null;
}> {
const [row] = await this.dataSource.query(
`SELECT b.reference AS "bookingReference",
company.name AS "customerName",
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
COALESCE(
NULLIF(TRIM(CONCAT(COALESCE(last_driver.first_name, ''), ' ', COALESCE(last_driver.last_name, ''))), ''),
last_vehicle.assigned_driver_name,
NULLIF(TRIM(CONCAT(COALESCE(first_driver.first_name, ''), ' ', COALESCE(first_driver.last_name, ''))), ''),
first_vehicle.assigned_driver_name
) AS "driverName",
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)
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
AND booking_container.deleted_at IS NULL
)
LEFT JOIN 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 LATERAL (
SELECT lm.vehicle_id
FROM freight.last_mile lm
WHERE lm.booking_id = b.id AND lm.deleted_at IS NULL
ORDER BY lm.created_at DESC
LIMIT 1
) latest_last_mile ON true
LEFT JOIN freight.vehicles last_vehicle ON last_vehicle.id = latest_last_mile.vehicle_id
LEFT JOIN freight.drivers last_driver ON last_driver.id = last_vehicle.assigned_driver_id
LEFT JOIN LATERAL (
SELECT fm.vehicle_id
FROM freight.first_mile fm
WHERE fm.booking_id = b.id AND fm.deleted_at IS NULL
ORDER BY fm.created_at DESC
LIMIT 1
) 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
LIMIT 1`,
[invoice.id],
);
return {
bookingReference: row?.bookingReference ?? null,
customerName: row?.customerName ?? null,
customerPhone: row?.customerPhone ?? null,
driverName: row?.driverName ?? null,
driverPhone: row?.driverPhone ?? null,
containerNumber: row?.containerNumber ?? null,
cargoDescription: row?.cargoDescription ?? null,
};
}
private async sendSms(recipient: string | null | undefined, message: string, context: string): Promise<void> {
const phone = recipient?.trim();
if (!phone) return;
try {
await this.notifications.directSend('sms', phone, message);
} catch (error) {
this.logger.error(`Failed to send ${context} SMS to ${phone}: ${String(error)}`);
}
}
private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoice): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(invoice);
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 message =
`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}`);
}
private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoice, dto: PayInvoiceDto): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(invoice);
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'
: `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}`);
if (invoice.status !== 'PAID') return;
const driverPhone = dto.driverPhone?.trim() || contacts.driverPhone;
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.';
await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`);
}
private buildInvoiceDocumentHtml(
invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] },
kind: 'INVOICE' | 'RECEIPT',