diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts index 9c62aeae5..6d7084a96 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts index 9d4e3eb4f..681b30284 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 988efea54..e13d77de0 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -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 ` @@ -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; 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 45c471db1..1fe184662 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,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 { + 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 { + 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 { + 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 { + 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', diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index 7cbac0e6d..fc65f2ab4 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import { Alert, Button, Group, Modal, Stack, Text, TextInput } from '@mantine/core'; -import { Info } from 'lucide-react'; +import { Alert, Button, Group, Modal, NumberInput, Select, Stack, Text, TextInput } from '@mantine/core'; +import { Info, Scale } from 'lucide-react'; import { useMutation } from '@tanstack/react-query'; @@ -17,23 +17,115 @@ interface ReleaseOrderModalProps { item: WarehouseInventoryItem | null; } +const REGISTERED_FIRST_LAST_MILE_TRUCKS = [ + ['03-ET A45843', '43495'], ['03-ET A45866', '43470'], ['03-ET A45853', '43508'], ['03-ET A45849', '43414'], + ['03-ET A45845', '43492'], ['03-ET A45820', '43487'], ['03-ET A45842', '43478'], ['03-ET A45841', '43515'], + ['03-ET A45832', '43504'], ['03-ET A45856', '43510'], ['03-ET A45865', '43490'], ['03-ET A45855', '43485'], + ['03-ET A45840', '43499'], ['03-ET A45867', '43493'], ['03-ET A45833', '43466'], ['03-ET A45858', '43496'], + ['03-ET A45819', '43474'], ['03-ET A45834', '43502'], ['03-ET A45868', '43469'], ['03-ET A45831', '43488'], + ['03-ET A45828', '43479'], ['03-ET A45850', '43505'], ['03-ET A45823', '43480'], ['03-ET A45838', '43472'], + ['03-ET A45854', '43500'], ['03-ET A45839', '43486'], ['03-ET A45861', '43513'], ['03-ET A45830', '43501'], + ['03-ET A45826', '43498'], ['03-ET A45836', '43467'], ['03-ET A45822', '43512'], ['03-ET A45821', '43210'], + ['03-ET A45837', '43475'], ['03-ET A45860', '43497'], ['03-ET A45863', '43477'], ['03-ET A45825', '43483'], + ['03-ET A45829', '43473'], ['03-ET A45824', '43491'], ['03-ET A45857', '43481'], ['03-ET A45851', '43509'], + ['03-ET A45827', '43468'], ['03-ET A45859', '43887'], ['03-ET A45846', '43471'], ['03-ET A45847', '43511'], + ['03-ET A45852', '43484'], ['03-ET A45844', '43476'], ['03-ET A45835', '43482'], ['03-ET A45864', '43503'], + ['03-ET A45848', '43494'], ['03-ET A45862', '43465'], ['03-ET A39105', '41218'], ['03-ET A39098', '41220'], + ['03-ET A29900', '41865'], ['03-ET A39097', '41226'], ['03-ET A39104', '41225'], ['03-ET A39103', '41223'], + ['03-ET A39106', '41221'], ['03-ET A39107', '41215'], ['03-ET A39094', '41222'], ['03-ET A39099', '41216'], + ['03-ET A39092', '41224'], ['03-ET A31801', '41214'], +].map(([powerPlate, trailerPlate], index) => ({ + value: powerPlate, + label: `${index + 1}. ${powerPlate} / ${trailerPlate}`, + trailerPlate, +})); + +const toIsoDateTime = (value: string) => { + if (!value) return undefined; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); +}; + export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) { const { toast } = useToast(); const releaseMutation = useMutation(api.warehouses.release.mutationOptions()); const [reference, setReference] = useState(''); + const [truckPlateNumber, setTruckPlateNumber] = useState(''); + const [trailerPlateNumber, setTrailerPlateNumber] = useState(''); + const [driverName, setDriverName] = useState(''); + const [driverLicense, setDriverLicense] = useState(''); + const [driverPhone, setDriverPhone] = useState(''); + const [truckType, setTruckType] = useState(''); + const [containerNumber, setContainerNumber] = useState(''); + const [gateInTime, setGateInTime] = useState(''); + const [tareWeight, setTareWeight] = useState(''); + const [grossWeight, setGrossWeight] = useState(''); + const [netWeight, setNetWeight] = useState(''); + const [gateOutTime, setGateOutTime] = useState(''); const [downloading, setDownloading] = useState(false); useEffect(() => { - if (opened) setReference(item?.releaseOrderReference ?? ''); + if (opened) { + setReference(item?.releaseOrderReference ?? ''); + setTruckPlateNumber(''); + setTrailerPlateNumber(''); + setDriverName(''); + setDriverLicense(''); + setDriverPhone(''); + setTruckType(''); + setContainerNumber(''); + setGateInTime(''); + setTareWeight(''); + setGrossWeight(''); + setNetWeight(item?.weight != null ? Number(item.weight) : ''); + setGateOutTime(''); + } }, [opened, item]); + const computedNetWeight = + tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null; + const weightMismatch = + computedNetWeight != null && netWeight !== '' && Math.abs(Number(netWeight) - computedNetWeight) > 0.001; + const handleSubmit = async () => { if (!item) return; + if (!truckPlateNumber.trim() || !driverName.trim()) { + toast({ variant: 'destructive', title: 'Truck plate and driver name are required' }); + return; + } + if (tareWeight === '' || grossWeight === '') { + toast({ variant: 'destructive', title: 'Tare and gross weight are required' }); + return; + } + if (weightMismatch) { + toast({ + variant: 'destructive', + title: 'Weight mismatch', + description: 'Gate clearance is blocked. Reassign the item to warehouse if it cannot exit.', + }); + return; + } const pdfWindow = window.open('', '_blank'); try { const released = await releaseMutation.mutateAsync({ id: item.id, - payload: { reference: reference.trim() || undefined }, + payload: { + reference: reference.trim() || undefined, + bookingId: item.bookingId ?? undefined, + customerId: undefined, + truckPlateNumber: truckPlateNumber.trim(), + trailerPlateNumber: trailerPlateNumber.trim() || undefined, + driverName: driverName.trim(), + driverLicense: driverLicense.trim() || undefined, + driverPhone: driverPhone.trim() || undefined, + truckType: truckType.trim() || undefined, + containerNumber: containerNumber.trim() || undefined, + gateInTime: toIsoDateTime(gateInTime), + tareWeight: Number(tareWeight), + grossWeight: Number(grossWeight), + netWeight: netWeight === '' ? computedNetWeight ?? undefined : Number(netWeight), + gateOutTime: toIsoDateTime(gateOutTime), + }, }); setDownloading(true); const response = await warehouseService.downloadReleaseDocument(item.id); @@ -56,12 +148,12 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr }; return ( - + } color="orange" variant="light"> - Creates the warehouse release document with booking, customer, cargo and location details. The - printed paper authorizes the goods to leave the warehouse gate. + Save the exit inspection before generating the exit paper. Gate clearance is blocked when + recorded net weight does not equal gross weight minus tare weight. setReference(e.currentTarget.value)} /> +