mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 05:30:55 +00:00
Merge branch 'dev' into freight/feat/invoice
This commit is contained in:
@@ -34,6 +34,9 @@ export class FirstMile extends BaseEntity {
|
||||
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
remainingPayment!: number;
|
||||
|
||||
// @Column({ type: 'boolean', default: false })
|
||||
// isPostPaymentCompleted!: boolean;
|
||||
|
||||
@Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
estimatedKm?: number | null;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FindOptionsWhere } from 'typeorm';
|
||||
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { DriversService } from '../drivers/drivers.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { VehiclesService } from '../vehicles/vehicles.service';
|
||||
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
||||
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
||||
@@ -36,7 +36,7 @@ export class FirstMileService {
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
private readonly driversService: DriversService,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
private readonly smsClient: SmsClientService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -251,16 +251,19 @@ export class FirstMileService {
|
||||
|
||||
const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking;
|
||||
|
||||
await this.notificationsService.notifyDriverVehicleAssignment({
|
||||
driverPhone: driver.phoneNumber,
|
||||
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
|
||||
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
|
||||
bookingReference: booking?.reference ?? record.bookingId,
|
||||
pickupAddress: booking?.firstMilePickupAddress,
|
||||
destinationYard: booking?.originYard?.label,
|
||||
const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim();
|
||||
const message =
|
||||
`Dear ${driverName}, you have been assigned to a first-mile pickup. ` +
|
||||
`Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` +
|
||||
(booking?.firstMilePickupAddress ? `Pickup: ${booking.firstMilePickupAddress}. ` : '') +
|
||||
(booking?.originYard?.label ? `Destination: ${booking.originYard.label}.` : '');
|
||||
|
||||
void this.smsClient.sendSms({
|
||||
to: driver.phoneNumber,
|
||||
message,
|
||||
});
|
||||
|
||||
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
|
||||
this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ export class LastMile extends BaseEntity {
|
||||
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
remainingPayment!: number;
|
||||
|
||||
// @Column({ type: 'boolean', default: false })
|
||||
// isPostPaymentCompleted!: boolean;
|
||||
|
||||
@Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
estimatedKm?: number | null;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FindOptionsWhere } from 'typeorm';
|
||||
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { DriversService } from '../drivers/drivers.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { VehiclesService } from '../vehicles/vehicles.service';
|
||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||
@@ -32,12 +32,11 @@ export class LastMileService {
|
||||
private readonly logger = new Logger(LastMileService.name);
|
||||
|
||||
constructor(
|
||||
|
||||
private readonly lastMileRepository: LastMileRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
private readonly driversService: DriversService,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
private readonly smsClient: SmsClientService,
|
||||
) {}
|
||||
|
||||
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
|
||||
@@ -185,16 +184,19 @@ export class LastMileService {
|
||||
};
|
||||
const booking = (record as LastMile & { booking?: BookingWithYards }).booking;
|
||||
|
||||
await this.notificationsService.notifyDriverVehicleAssignment({
|
||||
driverPhone: driver.phoneNumber,
|
||||
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
|
||||
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
|
||||
bookingReference: booking?.reference ?? record.bookingId,
|
||||
pickupAddress: booking?.destinationYard?.label,
|
||||
destinationYard: booking?.lastMileDeliveryAddress,
|
||||
const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim();
|
||||
const message =
|
||||
`Dear ${driverName}, you have been assigned to a last-mile delivery. ` +
|
||||
`Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` +
|
||||
(booking?.destinationYard?.label ? `Pickup: ${booking.destinationYard.label}. ` : '') +
|
||||
(booking?.lastMileDeliveryAddress ? `Destination: ${booking.lastMileDeliveryAddress}.` : '');
|
||||
|
||||
void this.smsClient.sendSms({
|
||||
to: driver.phoneNumber,
|
||||
message,
|
||||
});
|
||||
|
||||
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
|
||||
this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
|
||||
}
|
||||
|
||||
@@ -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 ?? "";
|
||||
|
||||
|
||||
@@ -472,6 +472,7 @@ export class PaymentService {
|
||||
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
||||
notify: true,
|
||||
});
|
||||
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
|
||||
return { processed: true, alreadyFinalized };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
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}`);
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
Reference in New Issue
Block a user