|
|
|
|
@@ -52,6 +52,7 @@ const isLoadableWagonStatus = (status: string | null | undefined) =>
|
|
|
|
|
LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status));
|
|
|
|
|
|
|
|
|
|
const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
|
|
|
|
|
const HANDOVER_DOCUMENT_MARKER = '[Handover Document]';
|
|
|
|
|
|
|
|
|
|
export interface InventoryInquiryResult {
|
|
|
|
|
id: string;
|
|
|
|
|
@@ -249,6 +250,7 @@ export interface ReadyToLoadRow {
|
|
|
|
|
containerNumber: string | null;
|
|
|
|
|
cargoType: string | null;
|
|
|
|
|
weight: number | null;
|
|
|
|
|
grnNumber: string | null;
|
|
|
|
|
origin: string | null;
|
|
|
|
|
destination: string | null;
|
|
|
|
|
inspectionStatus: string | null;
|
|
|
|
|
@@ -295,6 +297,7 @@ export interface ImportUnloadedRow {
|
|
|
|
|
containerNumber: string | null;
|
|
|
|
|
cargoType: string | null;
|
|
|
|
|
weight: number | null;
|
|
|
|
|
grnNumber: string | null;
|
|
|
|
|
trainSchedule: string | null;
|
|
|
|
|
inspectionStatus: string | null;
|
|
|
|
|
pickupOption: string;
|
|
|
|
|
@@ -302,6 +305,8 @@ export interface ImportUnloadedRow {
|
|
|
|
|
currentStatus: string;
|
|
|
|
|
releaseDate: string | null;
|
|
|
|
|
releaseOrderReference: string | null;
|
|
|
|
|
handoverDocumentReference: string | null;
|
|
|
|
|
handoverDocumentDate: string | null;
|
|
|
|
|
deliveredAt: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -415,7 +420,10 @@ export class WarehouseInventoryService {
|
|
|
|
|
|
|
|
|
|
const search = filter.search?.trim();
|
|
|
|
|
const where: FindManyOptions<WarehouseInventory>['where'] = search
|
|
|
|
|
? { ...base, notes: ILike(`%${search}%`) }
|
|
|
|
|
? [
|
|
|
|
|
{ ...base, notes: ILike(`%${search}%`) },
|
|
|
|
|
{ ...base, grnNumber: ILike(`%${search}%`) },
|
|
|
|
|
]
|
|
|
|
|
: base;
|
|
|
|
|
|
|
|
|
|
const items = await this.inventoryRepository.findAll({
|
|
|
|
|
@@ -766,6 +774,7 @@ export class WarehouseInventoryService {
|
|
|
|
|
const [booking] = await manager.query(
|
|
|
|
|
`SELECT b.reference AS "reference",
|
|
|
|
|
b.payment_status AS "paymentStatus",
|
|
|
|
|
b.freight_type AS "freightType",
|
|
|
|
|
b.cargo_total_weight_vgm AS "weight",
|
|
|
|
|
company.name AS "customer",
|
|
|
|
|
company.tin AS "customerTin",
|
|
|
|
|
@@ -847,6 +856,12 @@ export class WarehouseInventoryService {
|
|
|
|
|
const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } });
|
|
|
|
|
if (existing) { skip('Already received'); continue; }
|
|
|
|
|
|
|
|
|
|
const containerQuantity = Number(booking.containerQuantity ?? 0);
|
|
|
|
|
if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) {
|
|
|
|
|
skip('Container booking has no container quantity');
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
|
|
|
|
|
const truckEntrance = dto.truckEntrance
|
|
|
|
|
@@ -867,8 +882,9 @@ export class WarehouseInventoryService {
|
|
|
|
|
yardId: dto.yardId,
|
|
|
|
|
zoneId: dto.zoneId,
|
|
|
|
|
bookingId,
|
|
|
|
|
quantity: Number(booking.containerQuantity) || 1,
|
|
|
|
|
quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1,
|
|
|
|
|
weight: Number(booking.weight) || 0,
|
|
|
|
|
grnNumber,
|
|
|
|
|
status: 'RECEIVED',
|
|
|
|
|
arrivedAt: now,
|
|
|
|
|
notes: receiveNote,
|
|
|
|
|
@@ -962,6 +978,7 @@ export class WarehouseInventoryService {
|
|
|
|
|
ct.container_number AS "containerNumber",
|
|
|
|
|
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
|
|
|
|
inv.weight AS "weight",
|
|
|
|
|
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
|
|
|
|
|
oy.code AS "origin",
|
|
|
|
|
dy.code AS "destination",
|
|
|
|
|
oy.country AS "originCountry",
|
|
|
|
|
@@ -1021,6 +1038,7 @@ export class WarehouseInventoryService {
|
|
|
|
|
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
|
|
|
|
|
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
|
|
|
|
inv.weight AS "weight",
|
|
|
|
|
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
|
|
|
|
|
ts.train_number AS "trainSchedule",
|
|
|
|
|
inv.inspection_status AS "inspectionStatus",
|
|
|
|
|
CASE WHEN b.last_mile_delivery_address IS NOT NULL
|
|
|
|
|
@@ -1029,6 +1047,8 @@ export class WarehouseInventoryService {
|
|
|
|
|
inv.status AS "currentStatus",
|
|
|
|
|
inv.release_date AS "releaseDate",
|
|
|
|
|
inv.release_order_reference AS "releaseOrderReference",
|
|
|
|
|
substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference",
|
|
|
|
|
substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate",
|
|
|
|
|
inv.delivered_at AS "deliveredAt",
|
|
|
|
|
oy.country AS "originCountry",
|
|
|
|
|
dy.country AS "destinationCountry"
|
|
|
|
|
@@ -1669,6 +1689,7 @@ export class WarehouseInventoryService {
|
|
|
|
|
quantity,
|
|
|
|
|
weight,
|
|
|
|
|
volume: dto.volume ?? null,
|
|
|
|
|
grnNumber,
|
|
|
|
|
status: 'RECEIVED',
|
|
|
|
|
arrivedAt: now,
|
|
|
|
|
notes: receiveNote,
|
|
|
|
|
@@ -1933,24 +1954,31 @@ export class WarehouseInventoryService {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date();
|
|
|
|
|
const reference = dto.reference?.trim() || null;
|
|
|
|
|
const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
|
|
|
|
|
const releaseDate = isTruckLeaving
|
|
|
|
|
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
|
|
|
|
|
: item.releaseDate ?? null;
|
|
|
|
|
const reference = dto.reference?.trim() || (await this.generateReleaseReference(item));
|
|
|
|
|
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'),
|
|
|
|
|
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
|
|
|
|
|
});
|
|
|
|
|
await this.activityLog.record(
|
|
|
|
|
{
|
|
|
|
|
activityType: 'INVENTORY_RELEASED',
|
|
|
|
|
inventoryId: id,
|
|
|
|
|
warehouseId: item.warehouseId,
|
|
|
|
|
description: reference
|
|
|
|
|
? `Release order ${reference} sent to customer`
|
|
|
|
|
: 'Release order sent to customer',
|
|
|
|
|
description: isTruckLeaving
|
|
|
|
|
? reference
|
|
|
|
|
? `Exit paper ${reference} generated`
|
|
|
|
|
: 'Exit paper generated'
|
|
|
|
|
: reference
|
|
|
|
|
? `Truck arrival ${reference} registered`
|
|
|
|
|
: 'Truck arrival registered',
|
|
|
|
|
performedBy: dto.performedBy,
|
|
|
|
|
},
|
|
|
|
|
manager,
|
|
|
|
|
@@ -2039,6 +2067,106 @@ export class WarehouseInventoryService {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
|
|
|
|
|
async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
|
|
|
|
const [row] = await this.dataSource.query(
|
|
|
|
|
`SELECT inv.id,
|
|
|
|
|
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
|
|
|
|
|
COALESCE(inv.arrived_at, inv.created_at) AS "receivedAt",
|
|
|
|
|
inv.quantity,
|
|
|
|
|
inv.weight,
|
|
|
|
|
inv.volume,
|
|
|
|
|
inv.status,
|
|
|
|
|
inv.notes,
|
|
|
|
|
b.id AS "bookingId",
|
|
|
|
|
b.reference AS "bookingReference",
|
|
|
|
|
b.status AS "bookingStatus",
|
|
|
|
|
b.freight_type AS "freightType",
|
|
|
|
|
b.trade_direction AS "tradeDirection",
|
|
|
|
|
b.cargo_total_weight_vgm AS "bookingDeclaredWeight",
|
|
|
|
|
company.name AS "customerName",
|
|
|
|
|
company.tin AS "customerTin",
|
|
|
|
|
service_type.service_name AS "serviceType",
|
|
|
|
|
origin_yard.label AS "originYardLabel",
|
|
|
|
|
origin_yard.code AS "originYardCode",
|
|
|
|
|
destination_yard.label AS "destinationYardLabel",
|
|
|
|
|
destination_yard.code AS "destinationYardCode",
|
|
|
|
|
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
|
|
|
|
|
booking_container."containerSummary" AS "bookingContainerSummary",
|
|
|
|
|
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"
|
|
|
|
|
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.service_types service_type ON service_type.id = b.service_type_id
|
|
|
|
|
LEFT JOIN freight.yards origin_yard ON origin_yard.id = b.origin_yard_id
|
|
|
|
|
LEFT JOIN freight.yards destination_yard ON destination_yard.id = b.destination_yard_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 LATERAL (
|
|
|
|
|
SELECT MIN(bc.container_number) AS container_number,
|
|
|
|
|
STRING_AGG(
|
|
|
|
|
CONCAT_WS(' ', bc.quantity::text, COALESCE(ct.label, ct.code, 'container')),
|
|
|
|
|
', '
|
|
|
|
|
ORDER BY COALESCE(ct.label, ct.code, bc.container_type_id::text)
|
|
|
|
|
) AS "containerSummary"
|
|
|
|
|
FROM freight.booking_container bc
|
|
|
|
|
LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id
|
|
|
|
|
WHERE bc.booking_id = b.id
|
|
|
|
|
AND bc.deleted_at IS NULL
|
|
|
|
|
) booking_container ON true
|
|
|
|
|
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)
|
|
|
|
|
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.grnNumber) {
|
|
|
|
|
throw new BadRequestException('GRN number is missing for this inventory item');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const html = this.buildGrnDocumentHtml({
|
|
|
|
|
grnNumber: row.grnNumber,
|
|
|
|
|
receivedAt: row.receivedAt ? new Date(row.receivedAt) : new Date(),
|
|
|
|
|
bookingReference: row.bookingReference ?? row.bookingId ?? 'N/A',
|
|
|
|
|
bookingStatus: row.bookingStatus ?? null,
|
|
|
|
|
customerName: row.customerName ?? null,
|
|
|
|
|
customerTin: row.customerTin ?? null,
|
|
|
|
|
serviceType: row.serviceType ?? null,
|
|
|
|
|
freightType: row.freightType ?? null,
|
|
|
|
|
tradeDirection: row.tradeDirection ?? null,
|
|
|
|
|
route: [row.originYardLabel ?? row.originYardCode, row.destinationYardLabel ?? row.destinationYardCode]
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join(' to ') || null,
|
|
|
|
|
containerNumber: row.containerNumber ?? null,
|
|
|
|
|
bookingContainerSummary: row.bookingContainerSummary ?? null,
|
|
|
|
|
cargoDescription: row.cargoDescription ?? null,
|
|
|
|
|
quantity: Number(row.quantity ?? 0),
|
|
|
|
|
weight: Number(row.weight ?? 0),
|
|
|
|
|
volume: row.volume == null ? null : Number(row.volume),
|
|
|
|
|
bookingDeclaredWeight: Number(row.bookingDeclaredWeight ?? 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,
|
|
|
|
|
receiveSummary: this.extractReceiveSummary(row.notes),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
|
|
|
|
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async approveDeliveryForBooking(
|
|
|
|
|
bookingId: string,
|
|
|
|
|
userId?: string,
|
|
|
|
|
@@ -2121,8 +2249,17 @@ export class WarehouseInventoryService {
|
|
|
|
|
b.status AS "bookingStatus",
|
|
|
|
|
b.freight_type AS "freightType",
|
|
|
|
|
b.trade_direction AS "tradeDirection",
|
|
|
|
|
b.scheduled_date AS "scheduledDate",
|
|
|
|
|
b.cargo_total_weight_vgm AS "bookingDeclaredWeight",
|
|
|
|
|
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
|
|
|
|
|
company.name AS "customerName",
|
|
|
|
|
service_type.service_name AS "serviceType",
|
|
|
|
|
origin_yard.label AS "originYardLabel",
|
|
|
|
|
origin_yard.code AS "originYardCode",
|
|
|
|
|
destination_yard.label AS "destinationYardLabel",
|
|
|
|
|
destination_yard.code AS "destinationYardCode",
|
|
|
|
|
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
|
|
|
|
|
booking_container."containerSummary" AS "bookingContainerSummary",
|
|
|
|
|
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
|
|
|
|
|
wh.name AS "warehouseName",
|
|
|
|
|
wh.code AS "warehouseCode",
|
|
|
|
|
@@ -2134,14 +2271,25 @@ export class WarehouseInventoryService {
|
|
|
|
|
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.service_types service_type ON service_type.id = b.service_type_id
|
|
|
|
|
LEFT JOIN freight.yards origin_yard ON origin_yard.id = b.origin_yard_id
|
|
|
|
|
LEFT JOIN freight.yards destination_yard ON destination_yard.id = b.destination_yard_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 LATERAL (
|
|
|
|
|
SELECT MIN(bc.container_number) AS container_number,
|
|
|
|
|
STRING_AGG(
|
|
|
|
|
CONCAT_WS(' ', bc.quantity::text, COALESCE(ct.label, ct.code, 'container')),
|
|
|
|
|
', '
|
|
|
|
|
ORDER BY COALESCE(ct.label, ct.code, bc.container_type_id::text)
|
|
|
|
|
) AS "containerSummary"
|
|
|
|
|
FROM freight.booking_container bc
|
|
|
|
|
LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id
|
|
|
|
|
WHERE bc.booking_id = b.id
|
|
|
|
|
AND bc.deleted_at IS NULL
|
|
|
|
|
) booking_container ON true
|
|
|
|
|
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
|
|
|
|
|
@@ -2158,18 +2306,37 @@ export class WarehouseInventoryService {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const bookingReference = row.bookingReference || row.bookingId || 'N/A';
|
|
|
|
|
const reference =
|
|
|
|
|
this.extractHandoverDocumentLine(row.notes, 'Handover Reference') ||
|
|
|
|
|
`HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`;
|
|
|
|
|
const generatedAtValue = this.extractHandoverDocumentLine(row.notes, 'Generated At');
|
|
|
|
|
const generatedAt = generatedAtValue ? new Date(generatedAtValue) : new Date();
|
|
|
|
|
const handedOverAt = Number.isNaN(generatedAt.getTime()) ? new Date() : generatedAt;
|
|
|
|
|
if (!generatedAtValue) {
|
|
|
|
|
await this.inventoryRepository.update(id, {
|
|
|
|
|
notes: this.replaceHandoverDocumentNote(row.notes, this.buildHandoverDocumentNote(reference, handedOverAt)),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const html = this.buildHandoverDocumentHtml({
|
|
|
|
|
reference: `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`,
|
|
|
|
|
handedOverAt: new Date(row.handoverDate ?? Date.now()),
|
|
|
|
|
reference,
|
|
|
|
|
handedOverAt,
|
|
|
|
|
bookingReference,
|
|
|
|
|
bookingStatus: row.bookingStatus ?? null,
|
|
|
|
|
customerName: row.customerName ?? null,
|
|
|
|
|
serviceType: row.serviceType ?? null,
|
|
|
|
|
freightType: row.freightType ?? null,
|
|
|
|
|
tradeDirection: row.tradeDirection ?? null,
|
|
|
|
|
route: [row.originYardLabel ?? row.originYardCode, row.destinationYardLabel ?? row.destinationYardCode]
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join(' to ') || null,
|
|
|
|
|
scheduledDate: row.scheduledDate ? new Date(row.scheduledDate) : null,
|
|
|
|
|
containerNumber: row.containerNumber ?? null,
|
|
|
|
|
bookingContainerSummary: row.bookingContainerSummary ?? null,
|
|
|
|
|
cargoDescription: row.cargoDescription ?? null,
|
|
|
|
|
quantity: Number(row.quantity ?? 0),
|
|
|
|
|
weight: Number(row.weight ?? 0),
|
|
|
|
|
bookingDeclaredWeight: Number(row.bookingDeclaredWeight ?? 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,
|
|
|
|
|
@@ -2178,11 +2345,12 @@ export class WarehouseInventoryService {
|
|
|
|
|
releaseOrderReference: row.releaseOrderReference ?? null,
|
|
|
|
|
releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
|
|
|
|
|
trainSchedule: row.trainSchedule ?? null,
|
|
|
|
|
lastMileDeliveryAddress: row.lastMileDeliveryAddress ?? null,
|
|
|
|
|
customerApproval: this.extractCustomerDeliveryApproval(row.notes),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
filename: `handover-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
|
|
|
|
filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
|
|
|
|
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
@@ -2666,6 +2834,128 @@ export class WarehouseInventoryService {
|
|
|
|
|
return this.findById(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildGrnDocumentHtml(data: {
|
|
|
|
|
grnNumber: string;
|
|
|
|
|
receivedAt: Date;
|
|
|
|
|
bookingReference: string;
|
|
|
|
|
bookingStatus: string | null;
|
|
|
|
|
customerName: string | null;
|
|
|
|
|
customerTin: string | null;
|
|
|
|
|
serviceType: string | null;
|
|
|
|
|
freightType: string | null;
|
|
|
|
|
tradeDirection: string | null;
|
|
|
|
|
route: string | null;
|
|
|
|
|
containerNumber: string | null;
|
|
|
|
|
bookingContainerSummary: string | null;
|
|
|
|
|
cargoDescription: string | null;
|
|
|
|
|
quantity: number;
|
|
|
|
|
weight: number;
|
|
|
|
|
volume: number | null;
|
|
|
|
|
bookingDeclaredWeight: number;
|
|
|
|
|
warehouse: string | null;
|
|
|
|
|
yard: string | null;
|
|
|
|
|
zone: string | null;
|
|
|
|
|
inventoryStatus: string | null;
|
|
|
|
|
receiveSummary: string | null;
|
|
|
|
|
}): string {
|
|
|
|
|
const esc = (value: unknown) =>
|
|
|
|
|
String(value ?? '-')
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
.replace(/'/g, ''');
|
|
|
|
|
const receivedAt = data.receivedAt.toLocaleString('en-GB', {
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
month: 'short',
|
|
|
|
|
day: '2-digit',
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
});
|
|
|
|
|
const rows: Array<[string, unknown]> = [
|
|
|
|
|
['Booking Reference', data.bookingReference],
|
|
|
|
|
['Customer / Consignee', data.customerName],
|
|
|
|
|
['Customer TIN', data.customerTin],
|
|
|
|
|
['Booking Status', data.bookingStatus],
|
|
|
|
|
['Service Type', data.serviceType],
|
|
|
|
|
['Freight Type', data.freightType],
|
|
|
|
|
['Trade Direction', data.tradeDirection],
|
|
|
|
|
['Route', data.route],
|
|
|
|
|
['Container Number', data.containerNumber],
|
|
|
|
|
['Booking Containers', data.bookingContainerSummary],
|
|
|
|
|
['Cargo / Goods Description', data.cargoDescription],
|
|
|
|
|
['Quantity', data.quantity],
|
|
|
|
|
['Received Weight', `${data.weight.toLocaleString()} kg`],
|
|
|
|
|
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null],
|
|
|
|
|
['Volume', data.volume == null ? null : data.volume.toLocaleString()],
|
|
|
|
|
['Warehouse', data.warehouse],
|
|
|
|
|
['Yard', data.yard],
|
|
|
|
|
['Zone', data.zone],
|
|
|
|
|
['Inventory Status', data.inventoryStatus],
|
|
|
|
|
...(data.receiveSummary ? [['Receive Details', data.receiveSummary] as [string, string]] : []),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return `<!doctype html>
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8" />
|
|
|
|
|
<title>Goods Received Note</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 210px; gap: 24px; border-top: 5px solid #0f766e; padding-top: 18px; }
|
|
|
|
|
.brand { font-size: 12px; color: #064c27; text-transform: uppercase; letter-spacing: .13em; font-weight: 800; }
|
|
|
|
|
h1 { margin: 8px 0 0; font-size: 31px; 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: #0f766e; margin: 16px 0 22px; }
|
|
|
|
|
.notice { width: 76%; margin: 0 0 18px; padding: 13px 18px; background: #f0fdfa; border: 1px solid #5eead4; border-left: 5px solid #0f766e; font-size: 13px; line-height: 1.45; }
|
|
|
|
|
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #0f766e; 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; white-space: pre-line; }
|
|
|
|
|
.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 1fr; gap: 34px; align-items: start; margin-top: 42px; }
|
|
|
|
|
.line { border-top: 1.4px solid #061323; padding-top: 7px; font-size: 10.8px; color: #24384f; min-height: 42px; }
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<div class="top">
|
|
|
|
|
<div>
|
|
|
|
|
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
|
|
|
|
<h1>Goods Received Note</h1>
|
|
|
|
|
<div class="subtitle">Warehouse receiving confirmation</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="ref">
|
|
|
|
|
GRN Number
|
|
|
|
|
<strong>${esc(data.grnNumber)}</strong>
|
|
|
|
|
Received: ${esc(receivedAt)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="rule"></div>
|
|
|
|
|
<div class="notice">
|
|
|
|
|
This Goods Received Note confirms that the listed goods were received into EDR warehouse custody at the stated location.
|
|
|
|
|
</div>
|
|
|
|
|
<div class="section-title">Receiving 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">Receipt Clause</div>
|
|
|
|
|
<div class="clause">
|
|
|
|
|
This document records warehouse receipt only. Loading, dispatch, release, delivery, customs, and fee clearance remain subject to their respective operational approvals.
|
|
|
|
|
</div>
|
|
|
|
|
<div class="signatures">
|
|
|
|
|
<div class="line">Warehouse receiver name / signature / date</div>
|
|
|
|
|
<div class="line">Driver or customer representative name / signature / date</div>
|
|
|
|
|
</div>
|
|
|
|
|
</body>
|
|
|
|
|
</html>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildReleaseDocumentHtml(data: {
|
|
|
|
|
reference: string;
|
|
|
|
|
issuedAt: Date;
|
|
|
|
|
@@ -2721,7 +3011,7 @@ export class WarehouseInventoryService {
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8" />
|
|
|
|
|
<title>Warehouse Gate Clearance / Release Order</title>
|
|
|
|
|
<title>Warehouse Release / Exit Paper</title>
|
|
|
|
|
<style>
|
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
|
@page { size: A4; margin: 12mm 15mm 14mm; }
|
|
|
|
|
@@ -2752,8 +3042,8 @@ export class WarehouseInventoryService {
|
|
|
|
|
<div class="top">
|
|
|
|
|
<div>
|
|
|
|
|
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
|
|
|
|
<h1>Warehouse Gate Clearance / Release Order</h1>
|
|
|
|
|
<div class="subtitle">Official warehouse release and exit authorization</div>
|
|
|
|
|
<h1>Warehouse Release / Exit Paper</h1>
|
|
|
|
|
<div class="subtitle">Official gate clearance and warehouse exit authorization</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="ref">
|
|
|
|
|
Document / Release No.
|
|
|
|
|
@@ -2763,7 +3053,7 @@ export class WarehouseInventoryService {
|
|
|
|
|
</div>
|
|
|
|
|
<div class="rule"></div>
|
|
|
|
|
<div class="notice">
|
|
|
|
|
This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
|
|
|
|
|
This Exit Paper confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
|
|
|
|
|
</div>
|
|
|
|
|
<div class="section-title">Release Particulars</div>
|
|
|
|
|
<table>
|
|
|
|
|
@@ -2792,12 +3082,17 @@ export class WarehouseInventoryService {
|
|
|
|
|
bookingReference: string;
|
|
|
|
|
bookingStatus: string | null;
|
|
|
|
|
customerName: string | null;
|
|
|
|
|
serviceType: string | null;
|
|
|
|
|
freightType: string | null;
|
|
|
|
|
tradeDirection: string | null;
|
|
|
|
|
route: string | null;
|
|
|
|
|
scheduledDate: Date | null;
|
|
|
|
|
containerNumber: string | null;
|
|
|
|
|
bookingContainerSummary: string | null;
|
|
|
|
|
cargoDescription: string | null;
|
|
|
|
|
quantity: number;
|
|
|
|
|
weight: number;
|
|
|
|
|
bookingDeclaredWeight: number;
|
|
|
|
|
warehouse: string | null;
|
|
|
|
|
yard: string | null;
|
|
|
|
|
zone: string | null;
|
|
|
|
|
@@ -2806,6 +3101,7 @@ export class WarehouseInventoryService {
|
|
|
|
|
releaseOrderReference: string | null;
|
|
|
|
|
releaseDate: Date | null;
|
|
|
|
|
trainSchedule: string | null;
|
|
|
|
|
lastMileDeliveryAddress: string | null;
|
|
|
|
|
customerApproval: {
|
|
|
|
|
approvedAt: string;
|
|
|
|
|
signerDisplayName: string;
|
|
|
|
|
@@ -2835,13 +3131,18 @@ export class WarehouseInventoryService {
|
|
|
|
|
['Booking Reference', data.bookingReference],
|
|
|
|
|
['Customer / Consignee', data.customerName],
|
|
|
|
|
['Booking Status', data.bookingStatus],
|
|
|
|
|
['Service Type', data.serviceType],
|
|
|
|
|
['Freight Type', data.freightType],
|
|
|
|
|
['Trade Direction', data.tradeDirection],
|
|
|
|
|
['Route', data.route],
|
|
|
|
|
['Scheduled Date', fmt(data.scheduledDate)],
|
|
|
|
|
['Train Schedule', data.trainSchedule],
|
|
|
|
|
['Container Number', data.containerNumber],
|
|
|
|
|
['Booking Containers', data.bookingContainerSummary],
|
|
|
|
|
['Cargo / Goods Description', data.cargoDescription],
|
|
|
|
|
['Quantity', data.quantity],
|
|
|
|
|
['Declared Weight', `${data.weight.toLocaleString()} kg`],
|
|
|
|
|
['Inventory Weight', `${data.weight.toLocaleString()} kg`],
|
|
|
|
|
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null],
|
|
|
|
|
['Warehouse', data.warehouse],
|
|
|
|
|
['Yard', data.yard],
|
|
|
|
|
['Zone', data.zone],
|
|
|
|
|
@@ -2849,6 +3150,7 @@ export class WarehouseInventoryService {
|
|
|
|
|
['Inspection Status', data.inspectionStatus],
|
|
|
|
|
['Release Order', data.releaseOrderReference],
|
|
|
|
|
['Release Date', fmt(data.releaseDate)],
|
|
|
|
|
['Last-mile Delivery Address', data.lastMileDeliveryAddress],
|
|
|
|
|
];
|
|
|
|
|
const approval = data.customerApproval;
|
|
|
|
|
|
|
|
|
|
@@ -2898,7 +3200,8 @@ export class WarehouseInventoryService {
|
|
|
|
|
</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.
|
|
|
|
|
This handover document is separate from the warehouse Exit Paper. It records the booking, route, cargo, container,
|
|
|
|
|
inspection, release, and customer approval details for the goods being handed to the customer.
|
|
|
|
|
</div>
|
|
|
|
|
<div class="section-title">Handover Particulars</div>
|
|
|
|
|
<table>
|
|
|
|
|
@@ -2911,7 +3214,9 @@ export class WarehouseInventoryService {
|
|
|
|
|
<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>
|
|
|
|
|
<tr><th>Booking Containers</th><td>${esc(data.bookingContainerSummary)}</td></tr>
|
|
|
|
|
<tr><th>Inventory Weight</th><td>${esc(`${data.weight.toLocaleString()} kg`)}</td></tr>
|
|
|
|
|
<tr><th>Booking Declared Weight</th><td>${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null)}</td></tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
<div class="section-title">Handover Clause</div>
|
|
|
|
|
@@ -3156,6 +3461,21 @@ export class WarehouseInventoryService {
|
|
|
|
|
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async generateReleaseReference(item: WarehouseInventory): Promise<string> {
|
|
|
|
|
let bookingReference = item.booking?.reference;
|
|
|
|
|
if (!bookingReference && item.bookingId) {
|
|
|
|
|
const [booking]: Array<{ reference: string | null }> = await this.dataSource.query(
|
|
|
|
|
`SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1`,
|
|
|
|
|
[item.bookingId],
|
|
|
|
|
);
|
|
|
|
|
bookingReference = booking?.reference ?? undefined;
|
|
|
|
|
}
|
|
|
|
|
if (bookingReference) {
|
|
|
|
|
return `REL-${String(bookingReference).replace(/^BK-?/i, '')}`;
|
|
|
|
|
}
|
|
|
|
|
return `REL-${new Date().toISOString().slice(0, 10).replace(/-/g, '')}-${item.id.replace(/-/g, '').slice(0, 8).toUpperCase()}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildExitInspectionNote(dto: ReleaseOrderDto): string | null {
|
|
|
|
|
const hasExitInspection =
|
|
|
|
|
Boolean(dto.truckPlateNumber?.trim()) ||
|
|
|
|
|
@@ -3179,17 +3499,27 @@ export class WarehouseInventoryService {
|
|
|
|
|
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');
|
|
|
|
|
if (dto.tareWeight === undefined) {
|
|
|
|
|
throw new BadRequestException('Tare weight is required for truck arrival');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
const grossWeight = dto.grossWeight === undefined ? null : Number(dto.grossWeight);
|
|
|
|
|
const computedNetWeight =
|
|
|
|
|
grossWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
|
|
|
|
|
const submittedNetWeight =
|
|
|
|
|
dto.netWeight === undefined || computedNetWeight == null ? 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.');
|
|
|
|
|
if (grossWeight != null && !dto.gateOutTime) {
|
|
|
|
|
throw new BadRequestException('Gate out time is required for truck exit');
|
|
|
|
|
}
|
|
|
|
|
if (grossWeight != null && computedNetWeight != null && submittedNetWeight != null) {
|
|
|
|
|
if (Math.abs(submittedNetWeight - computedNetWeight) > 0.001) {
|
|
|
|
|
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if ((dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) && grossWeight == null) {
|
|
|
|
|
throw new BadRequestException('Gross weight is required for truck exit');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rows = [
|
|
|
|
|
@@ -3205,14 +3535,27 @@ export class WarehouseInventoryService {
|
|
|
|
|
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`,
|
|
|
|
|
grossWeight == null ? null : `Gross Weight: ${grossWeight} kg`,
|
|
|
|
|
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} kg`,
|
|
|
|
|
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return rows.filter(Boolean).join('\n');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null {
|
|
|
|
|
const trimmed = notes?.trim();
|
|
|
|
|
if (!exitInspectionNote) return trimmed || null;
|
|
|
|
|
if (!trimmed) return exitInspectionNote;
|
|
|
|
|
|
|
|
|
|
const marker = '[Exit Inspection]';
|
|
|
|
|
const index = trimmed.lastIndexOf(marker);
|
|
|
|
|
if (index < 0) {
|
|
|
|
|
return `${trimmed}\n\n${exitInspectionNote}`;
|
|
|
|
|
}
|
|
|
|
|
return [trimmed.slice(0, index).trim(), exitInspectionNote].filter(Boolean).join('\n\n');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private extractExitInspectionNote(notes?: string | null): string | null {
|
|
|
|
|
if (!notes) return null;
|
|
|
|
|
const marker = '[Exit Inspection]';
|
|
|
|
|
@@ -3221,6 +3564,40 @@ export class WarehouseInventoryService {
|
|
|
|
|
return notes.slice(index + marker.length).trim() || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private extractReceiveSummary(notes?: string | null): string | null {
|
|
|
|
|
if (!notes?.trim()) return null;
|
|
|
|
|
const withoutExit = notes.split('\n\n[Exit Inspection]')[0] ?? notes;
|
|
|
|
|
const withoutHandover = withoutExit.split(`\n\n${HANDOVER_DOCUMENT_MARKER}`)[0] ?? withoutExit;
|
|
|
|
|
return this.stripCustomerDeliveryApproval(withoutHandover)?.trim() || withoutHandover.trim() || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildHandoverDocumentNote(reference: string, generatedAt: Date): string {
|
|
|
|
|
return [
|
|
|
|
|
HANDOVER_DOCUMENT_MARKER,
|
|
|
|
|
`Handover Reference: ${reference}`,
|
|
|
|
|
`Generated At: ${generatedAt.toISOString()}`,
|
|
|
|
|
].join('\n');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private replaceHandoverDocumentNote(notes: string | null | undefined, handoverDocumentNote: string): string {
|
|
|
|
|
const trimmed = notes?.trim();
|
|
|
|
|
if (!trimmed) return handoverDocumentNote;
|
|
|
|
|
const index = trimmed.lastIndexOf(HANDOVER_DOCUMENT_MARKER);
|
|
|
|
|
if (index < 0) {
|
|
|
|
|
return `${trimmed}\n\n${handoverDocumentNote}`;
|
|
|
|
|
}
|
|
|
|
|
return [trimmed.slice(0, index).trim(), handoverDocumentNote].filter(Boolean).join('\n\n');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private extractHandoverDocumentLine(notes: string | null | undefined, label: string): string | null {
|
|
|
|
|
if (!notes) return null;
|
|
|
|
|
const index = notes.lastIndexOf(HANDOVER_DOCUMENT_MARKER);
|
|
|
|
|
if (index < 0) return null;
|
|
|
|
|
const section = notes.slice(index + HANDOVER_DOCUMENT_MARKER.length);
|
|
|
|
|
const match = section.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
|
|
|
|
return match?.[1]?.trim() || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildReceiveNote(input: {
|
|
|
|
|
grnNumber: string;
|
|
|
|
|
direction?: string | null;
|
|
|
|
|
|