mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 13:28:11 +00:00
Notification for demurrage and invoice fee
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user