Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/last_mile_invoice

This commit is contained in:
natib21
2026-06-29 12:15:53 +00:00
30 changed files with 2236 additions and 183 deletions

View File

@@ -20,6 +20,7 @@ export class PaymentClientService {
private readonly baseUrl = (
// process.env.PAYMENT_API_URL ??
"https://paymentcallback.triaplc.com"
// "http://localhost:3003"
).replace(/\/$/, "");
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";

View File

@@ -289,20 +289,21 @@ export class PaymentService {
providerTxnId?: string;
paidAt?: Date;
}): Promise<{ alreadyFinalized: boolean }> {
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
if (!intent) throw new NotFoundException("PaymentIntent not found");
if (intent.status === "success") return { alreadyFinalized: true };
// const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
// if (!intent) throw new NotFoundException("PaymentIntent not found");
// if (intent.status === "success") return { alreadyFinalized: true };
const paidAt = input.paidAt ?? new Date();
// Every booking is a real shipment now (contracts are a separate aggregate),
// so payment always settles the booking to PAID and enters allocation.
await this.datasource.transaction(async (mg) => {
await mg.update(
PaymentEntity,
{ id: intent.id },
{ status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
);
// await mg.update(
// PaymentEntity,
// // { id: intent.id },
// {id:input.intentId},
// { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
// );
await mg.update(
Booking,
{ id: input.bookingId },
@@ -398,34 +399,46 @@ export class PaymentService {
failureCode?: string;
failureMessage?: string;
}): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> {
if (event.eventType === "payment.succeeded") {
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
if (!intent) {
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
}
const { alreadyFinalized } = await this.finalizePaymentSuccess({
intentId: intent.id,
const { alreadyFinalized } = await this.finalizePaymentSuccess({
intentId:event.intentId,
bookingId: event.referenceId,
providerTxnId: event.providerTxnId,
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
});
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
return { processed: true, alreadyFinalized };
}
// console.log(`Received payment event: ${JSON.stringify(event)}`);
// if (event.eventType === "payment.succeeded") {
// console.log(`Received payment.succeeded event for booking ${event.referenceId}, intent ${event.intentId}`);
// const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
// if (!intent) {
// return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
// }
// console.log(`Processing payment.succeeded event for booking ${event.referenceId}, intent ${intent.id}`);
// const { alreadyFinalized } = await this.finalizePaymentSuccess({
// intentId: intent.id,
// bookingId: event.referenceId,
// providerTxnId: event.providerTxnId,
// paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
// });
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
// return { processed: true, alreadyFinalized };
// }
if (event.eventType === "payment.failed") {
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
if (!intent) {
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
}
await this.markPaymentFailed({
intentId: intent.id,
failureCode: event.failureCode,
failureMessage: event.failureMessage,
});
return { processed: true };
}
// if (event.eventType === "payment.failed") {
// const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
// if (!intent) {
// return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
// }
// await this.markPaymentFailed({
// intentId: intent.id,
// failureCode: event.failureCode,
// failureMessage: event.failureMessage,
// });
// return { processed: true };
// }
return { processed: false, reason: `Unknown event type: ${event.eventType}` };
// return { processed: false, reason: `Unknown event type: ${event.eventType}` };
}
private toLocalStatus(status: ProviderPaymentStatus): PaymentEntity["status"] {

View File

@@ -32,6 +32,9 @@ export interface ImportTrainItemRow {
bookingReference: string | null;
customerId: string | null;
customerName: string | null;
wagonNumber: string | null;
sequenceNo: number | null;
allocatedWeightTons: number | null;
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
@@ -59,6 +62,9 @@ export interface ExportTrainItemRow {
bookingReference: string | null;
customerId: string | null;
customerName: string | null;
wagonNumber: string | null;
sequenceNo: number | null;
allocatedWeightTons: number | null;
itemType: 'CONTAINER' | 'CARGO';
itemId: string | null;
inventoryId: string | null;
@@ -265,6 +271,9 @@ export class SchedulingReadFacade {
b.reference AS "bookingReference",
b.company_id AS "customerId",
company.name AS "customerName",
w.wagon_number AS "wagonNumber",
tsw.sequence_no AS "sequenceNo",
wba.allocated_weight_tons AS "allocatedWeightTons",
(SELECT c.container_number FROM freight.containers c
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
@@ -279,11 +288,24 @@ export class SchedulingReadFacade {
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.wagon_booking_allocations wba ON wba.booking_id = b.id
AND wba.deleted_at IS NULL
AND EXISTS (
SELECT 1
FROM freight.train_set_wagons tsw_match
WHERE tsw_match.id = wba.train_set_wagon_id
AND tsw_match.train_set_id = ts.train_set_id
AND tsw_match.deleted_at IS NULL
)
LEFT JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
AND tsw.train_set_id = ts.train_set_id
AND tsw.deleted_at IS NULL
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
ORDER BY b.reference ASC NULLS LAST`,
ORDER BY tsw.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST`,
[scheduleId],
);
return rows;
@@ -401,6 +423,9 @@ export class SchedulingReadFacade {
b.reference,
b.company_id,
company.name AS customer_name,
w.wagon_number,
tsw.sequence_no,
wba.allocated_weight_tons,
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS cargo_type,
b.cargo_total_weight_vgm AS booking_weight,
oy.code AS origin,
@@ -412,6 +437,19 @@ export class SchedulingReadFacade {
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.wagon_booking_allocations wba ON wba.booking_id = b.id
AND wba.deleted_at IS NULL
AND EXISTS (
SELECT 1
FROM freight.train_set_wagons tsw_match
WHERE tsw_match.id = wba.train_set_wagon_id
AND tsw_match.train_set_id = ts.train_set_id
AND tsw_match.deleted_at IS NULL
)
LEFT JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
AND tsw.train_set_id = ts.train_set_id
AND tsw.deleted_at IS NULL
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
@@ -423,6 +461,9 @@ export class SchedulingReadFacade {
a.reference AS "bookingReference",
a.company_id AS "customerId",
a.customer_name AS "customerName",
a.wagon_number AS "wagonNumber",
a.sequence_no AS "sequenceNo",
a.allocated_weight_tons AS "allocatedWeightTons",
'CONTAINER' AS "itemType",
c.id AS "itemId",
a.inventory_id AS "inventoryId",
@@ -441,6 +482,9 @@ export class SchedulingReadFacade {
a.reference AS "bookingReference",
a.company_id AS "customerId",
a.customer_name AS "customerName",
a.wagon_number AS "wagonNumber",
a.sequence_no AS "sequenceNo",
a.allocated_weight_tons AS "allocatedWeightTons",
'CARGO' AS "itemType",
cg.id AS "itemId",
a.inventory_id AS "inventoryId",
@@ -460,6 +504,9 @@ export class SchedulingReadFacade {
a.reference AS "bookingReference",
a.company_id AS "customerId",
a.customer_name AS "customerName",
a.wagon_number AS "wagonNumber",
a.sequence_no AS "sequenceNo",
a.allocated_weight_tons AS "allocatedWeightTons",
CASE WHEN a.cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType",
a.inventory_id AS "itemId",
a.inventory_id AS "inventoryId",
@@ -474,7 +521,7 @@ export class SchedulingReadFacade {
FROM assigned a
WHERE NOT EXISTS (SELECT 1 FROM freight.containers c WHERE c.booking_id = a.booking_id AND c.deleted_at IS NULL)
AND NOT EXISTS (SELECT 1 FROM freight.cargoes cg WHERE cg.booking_id = a.booking_id AND cg.deleted_at IS NULL)
ORDER BY "bookingReference" ASC NULLS LAST, "itemType" ASC`,
ORDER BY "sequenceNo" ASC NULLS LAST, "bookingReference" ASC NULLS LAST, "itemType" ASC`,
[scheduleId],
);
return rows;

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common';
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Request, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
@@ -273,6 +273,25 @@ export class WarehouseInventoryController {
return res.send(buffer);
}
@Get(':id/handover-document')
@ApiOperation({ summary: 'View import goods handover document PDF' })
async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.handoverDocument(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Post('bookings/:bookingId/approve-delivery')
@ApiOperation({ summary: "Approve delivery using the current customer's saved signature" })
approveDeliveryForBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Request() req: { user?: { id?: string; sub?: string } },
) {
return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub);
}
@Post(':id/deliver')
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {

View File

@@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
import { LastMileService } from '../last-mile/last-mile.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SignaturesService } from '../signatures/signatures.service';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
@@ -50,6 +51,8 @@ const normalizeWagonStatus = (status: string | null | undefined) =>
const isLoadableWagonStatus = (status: string | null | undefined) =>
LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status));
const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
export interface InventoryInquiryResult {
id: string;
inventoryId: string | null;
@@ -297,6 +300,9 @@ export interface ImportUnloadedRow {
pickupOption: string;
lastMileRequested: boolean;
currentStatus: string;
releaseDate: string | null;
releaseOrderReference: string | null;
deliveredAt: string | null;
}
@Injectable()
@@ -316,6 +322,7 @@ export class WarehouseInventoryService {
private readonly interchangeDocuments: InterchangeDocumentsService,
private readonly lastMileService: LastMileService,
private readonly notifications: NotificationsService,
private readonly signatures: SignaturesService,
) {}
/**
@@ -1020,6 +1027,9 @@ export class WarehouseInventoryService {
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
(b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested",
inv.status AS "currentStatus",
inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
inv.delivered_at AS "deliveredAt",
oy.country AS "originCountry",
dy.country AS "destinationCountry"
FROM freight.warehouse_inventory inv
@@ -1049,7 +1059,16 @@ export class WarehouseInventoryService {
* states), with the columns the inspection screen needs. Read-only.
*/
importUnloadedQueue(): Promise<ImportUnloadedRow[]> {
return this.importQueueByStatuses(['UNLOADED', 'DESTINATION_INSPECTION', 'UNDER_INSPECTION']);
return this.importQueueByStatuses([
'UNLOADED',
'DESTINATION_INSPECTION',
'UNDER_INSPECTION',
'ARRIVED_AT_WAREHOUSE',
'STORED',
'READY_FOR_PICKUP',
'DISPATCHED',
'DELIVERED',
]);
}
/**
@@ -2020,6 +2039,154 @@ export class WarehouseInventoryService {
}
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
async approveDeliveryForBooking(
bookingId: string,
userId?: string,
): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> {
if (!userId) {
throw new BadRequestException('Authentication is required to approve delivery');
}
const signature = await this.signatures.getForUser(userId);
if (!signature?.signatureImageUrl) {
throw new BadRequestException('Please save your signature before approving delivery');
}
const [item]: Array<{ id: string; warehouseId: string | null; notes: string | null }> =
await this.dataSource.query(
`SELECT inv.id,
inv.warehouse_id AS "warehouseId",
inv.notes
FROM freight.warehouse_inventory inv
JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
WHERE inv.booking_id = $1
AND inv.deleted_at IS NULL
AND inv.inspection_status = 'PASSED'
ORDER BY inv.updated_at DESC NULLS LAST, inv.created_at DESC
LIMIT 1`,
[bookingId],
);
if (!item) {
throw new BadRequestException('Delivery can be approved after warehouse inspection has passed');
}
const approvedAt = new Date();
const approval = {
approvedAt: approvedAt.toISOString(),
signerDisplayName: signature.signerDisplayName,
signatureImageUrl: signature.signatureImageUrl,
userId,
};
const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
const approvalNote = `${CUSTOMER_DELIVERY_APPROVAL_PREFIX}${JSON.stringify(approval)}`;
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(item.id, {
notes: this.appendNote(existingNotes, approvalNote),
});
await this.activityLog.record(
{
activityType: 'INVENTORY_RELEASED',
inventoryId: item.id,
warehouseId: item.warehouseId,
description: `Customer approved delivery as ${signature.signerDisplayName}`,
performedBy: signature.signerDisplayName,
},
manager,
);
});
return {
bookingId,
inventoryId: item.id,
approvedAt: approval.approvedAt,
signerDisplayName: signature.signerDisplayName,
};
}
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
const [row] = await this.dataSource.query(
`SELECT inv.id,
inv.booking_id AS "bookingId",
inv.quantity,
inv.weight,
inv.status,
inv.notes,
inv.inspection_status AS "inspectionStatus",
inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
COALESCE(inv.unloaded_at, inv.arrived_at, inv.created_at) AS "handoverDate",
b.reference AS "bookingReference",
b.status AS "bookingStatus",
b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection",
company.name AS "customerName",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
wh.name AS "warehouseName",
wh.code AS "warehouseCode",
yard.name AS "yardName",
yard.code AS "yardCode",
zone.name AS "zoneName",
zone.code AS "zoneCode",
ts.train_number AS "trainSchedule"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
LEFT JOIN freight.companies company ON company.id = b.company_id
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
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 freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
LEFT JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[id],
);
if (!row) {
throw new NotFoundException(`Inventory item ${id} not found`);
}
if (row.inspectionStatus !== 'PASSED') {
throw new BadRequestException('Handover document is available after inspection has passed');
}
const bookingReference = row.bookingReference || row.bookingId || 'N/A';
const html = this.buildHandoverDocumentHtml({
reference: `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`,
handedOverAt: new Date(row.handoverDate ?? Date.now()),
bookingReference,
bookingStatus: row.bookingStatus ?? null,
customerName: row.customerName ?? null,
freightType: row.freightType ?? null,
tradeDirection: row.tradeDirection ?? null,
containerNumber: row.containerNumber ?? null,
cargoDescription: row.cargoDescription ?? null,
quantity: Number(row.quantity ?? 0),
weight: Number(row.weight ?? 0),
warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null,
yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null,
zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null,
inventoryStatus: row.status ?? null,
inspectionStatus: row.inspectionStatus ?? null,
releaseOrderReference: row.releaseOrderReference ?? null,
releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
trainSchedule: row.trainSchedule ?? null,
customerApproval: this.extractCustomerDeliveryApproval(row.notes),
});
return {
filename: `handover-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
};
}
async deliver(id: string, dto: DeliverInventoryDto): Promise<WarehouseInventory> {
const item = await this.findById(id);
this.assertTransition(item.status, 'DELIVERED');
@@ -2619,6 +2786,183 @@ export class WarehouseInventoryService {
</html>`;
}
private buildHandoverDocumentHtml(data: {
reference: string;
handedOverAt: Date;
bookingReference: string;
bookingStatus: string | null;
customerName: string | null;
freightType: string | null;
tradeDirection: string | null;
containerNumber: string | null;
cargoDescription: string | null;
quantity: number;
weight: number;
warehouse: string | null;
yard: string | null;
zone: string | null;
inventoryStatus: string | null;
inspectionStatus: string | null;
releaseOrderReference: string | null;
releaseDate: Date | null;
trainSchedule: string | null;
customerApproval: {
approvedAt: string;
signerDisplayName: string;
signatureImageUrl: string;
} | null;
}): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const fmt = (date: Date | string | null) => {
if (!date) return '-';
const parsed = date instanceof Date ? date : new Date(date);
if (Number.isNaN(parsed.getTime())) return '-';
return parsed.toLocaleString('en-GB', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
const rows = [
['Booking Reference', data.bookingReference],
['Customer / Consignee', data.customerName],
['Booking Status', data.bookingStatus],
['Freight Type', data.freightType],
['Trade Direction', data.tradeDirection],
['Train Schedule', data.trainSchedule],
['Container Number', data.containerNumber],
['Cargo / Goods Description', data.cargoDescription],
['Quantity', data.quantity],
['Declared Weight', `${data.weight.toLocaleString()} kg`],
['Warehouse', data.warehouse],
['Yard', data.yard],
['Zone', data.zone],
['Inventory Status', data.inventoryStatus],
['Inspection Status', data.inspectionStatus],
['Release Order', data.releaseOrderReference],
['Release Date', fmt(data.releaseDate)],
];
const approval = data.customerApproval;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Import Goods Handover Document</title>
<style>
* { box-sizing: border-box; }
@page { size: A4; margin: 12mm 15mm 14mm; }
body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 0; background: #fff; }
.top { display: grid; grid-template-columns: 1fr 190px; gap: 26px; border-top: 5px solid #2a2a2a; padding-top: 18px; }
.brand { font-size: 12px; color: #064c27; text-transform: uppercase; letter-spacing: .13em; font-weight: 800; }
h1 { margin: 8px 0 0; max-width: 380px; font-size: 29px; line-height: .98; text-transform: uppercase; letter-spacing: .02em; }
.subtitle { margin-top: 12px; font-size: 11px; color: #3d516a; text-transform: uppercase; letter-spacing: .14em; }
.ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; }
.ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; }
.rule { height: 3px; background: #064c27; margin: 16px 0 22px; }
.notice { width: 78%; margin: 0 0 18px; padding: 13px 18px; background: #f3fff6; border: 1px solid #61d98b; border-left: 5px solid #16743d; font-size: 13px; line-height: 1.45; }
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #064c27; text-transform: uppercase; letter-spacing: .12em; }
table { width: 100%; border-collapse: collapse; }
th { width: 31%; text-align: left; color: #0f2744; background: #f8fafc; font-weight: 800; }
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12.2px; vertical-align: top; }
.clause { margin-top: 14px; border: 1px solid #b9c7d8; padding: 12px 15px; font-size: 12.2px; line-height: 1.45; }
.signatures { display: grid; grid-template-columns: 1fr 96px 1.45fr; gap: 22px; align-items: start; margin-top: 42px; }
.line { border-top: 1.4px solid #061323; padding-top: 7px; font-size: 10.8px; color: #24384f; min-height: 72px; }
.signature-img { display: block; max-width: 210px; max-height: 58px; margin: 2px 0 6px; object-fit: contain; }
.signature-meta { font-size: 11px; color: #061323; }
.seal { position: relative; width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; }
.seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; }
.seal span { position: relative; }
</style>
</head>
<body>
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Import Goods Handover Document</h1>
<div class="subtitle">EDR to customer warehouse handover</div>
</div>
<div class="ref">
Document No.
<strong>${esc(data.reference)}</strong>
Handover: ${esc(fmt(data.handedOverAt))}
</div>
</div>
<div class="rule"></div>
<div class="notice">
This document confirms EDR handed over the listed import goods to the customer after warehouse inspection passed.
</div>
<div class="section-title">Handover Particulars</div>
<table>
<tbody>
${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')}
</tbody>
</table>
<div class="section-title">Goods List</div>
<table>
<tbody>
<tr><th>1. Goods</th><td>${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}</td></tr>
<tr><th>Container</th><td>${esc(data.containerNumber)}</td></tr>
<tr><th>Weight</th><td>${esc(`${data.weight.toLocaleString()} kg`)}</td></tr>
</tbody>
</table>
<div class="section-title">Handover Clause</div>
<div class="clause">
The customer acknowledges receipt of the goods listed above. Warehouse staff shall verify identity, booking reference,
inspection status, and release records before final physical handover.
</div>
<div class="signatures">
<div class="line">Officer in charge name / signature / date</div>
<div class="seal"><span>EDR<br />Warehouse<br />Handover</span></div>
<div class="line">
${approval?.signatureImageUrl ? `<img class="signature-img" src="${esc(approval.signatureImageUrl)}" />` : ''}
<div class="signature-meta">${approval ? esc(approval.signerDisplayName) : 'Customer or driver name / signature / date'}</div>
<div class="signature-meta">${approval ? `Approved: ${esc(fmt(approval.approvedAt))}` : ''}</div>
</div>
</div>
</body>
</html>`;
}
private extractCustomerDeliveryApproval(notes?: string | null): {
approvedAt: string;
signerDisplayName: string;
signatureImageUrl: string;
} | null {
if (!notes) return null;
const line = notes
.split(/\r?\n/)
.find((entry) => entry.startsWith(CUSTOMER_DELIVERY_APPROVAL_PREFIX));
if (!line) return null;
try {
const parsed = JSON.parse(line.slice(CUSTOMER_DELIVERY_APPROVAL_PREFIX.length));
if (!parsed?.approvedAt || !parsed?.signerDisplayName || !parsed?.signatureImageUrl) return null;
return {
approvedAt: String(parsed.approvedAt),
signerDisplayName: String(parsed.signerDisplayName),
signatureImageUrl: String(parsed.signatureImageUrl),
};
} catch {
return null;
}
}
private stripCustomerDeliveryApproval(notes?: string | null): string | null {
if (!notes?.trim()) return null;
const lines = notes
.split(/\r?\n/)
.filter((entry) => !entry.startsWith(CUSTOMER_DELIVERY_APPROVAL_PREFIX));
return lines.join('\n').trim() || null;
}
private assertTransition(from: WarehouseInventoryStatus, to: WarehouseInventoryStatus): void {
if (!WAREHOUSE_INVENTORY_TRANSITIONS[from]?.includes(to)) {
throw new BadRequestException(`Invalid transition ${from}${to}`);

View File

@@ -7,6 +7,7 @@ import { FilesModule } from '../files/files.module';
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
import { LastMileModule } from '../last-mile/last-mile.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
@@ -73,6 +74,7 @@ import { WarehousesService } from './warehouses.service';
InterchangeDocumentsModule,
forwardRef(() => LastMileModule),
NotificationsModule,
SignaturesModule,
ExchangeModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): ExchangeOptions =>