diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 27737c84c..134bfd885 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -18,6 +18,7 @@ "seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts", "seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts", "seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts", + "seed:warehouse-export-receive-ready": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-export-receive-ready.ts", "seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts", "seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts", "seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts", diff --git a/apps/edr-freight-api/src/migrations/1828000000000-AddGrnNumberToWarehouseInventory.ts b/apps/edr-freight-api/src/migrations/1828000000000-AddGrnNumberToWarehouseInventory.ts new file mode 100644 index 000000000..c57a43aaa --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1828000000000-AddGrnNumberToWarehouseInventory.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddGrnNumberToWarehouseInventory1828000000000 implements MigrationInterface { + name = 'AddGrnNumberToWarehouseInventory1828000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory + ADD COLUMN IF NOT EXISTS grn_number VARCHAR(100) NULL + `); + + await queryRunner.query(` + UPDATE freight.warehouse_inventory + SET grn_number = substring(notes FROM 'GRN Number: ([^\\n\\r]+)') + WHERE grn_number IS NULL + AND notes IS NOT NULL + AND notes ~ 'GRN Number: ' + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_grn_number + ON freight.warehouse_inventory(grn_number) + WHERE grn_number IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_grn_number`); + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory + DROP COLUMN IF EXISTS grn_number + `); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index 815841e54..290b6f0c2 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -110,6 +110,9 @@ export class WarehouseInventory extends BaseEntity { @Column({ name: 'volume', type: 'numeric', precision: 12, scale: 3, nullable: true }) volume?: number | null; + @Column({ name: 'grn_number', type: 'varchar', length: 100, nullable: true }) + grnNumber?: string | null; + @Column({ name: 'status', type: 'varchar', length: 32, default: 'RECEIVED' }) status!: WarehouseInventoryStatus; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 68f536b33..6b2bd8c28 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -273,6 +273,16 @@ export class WarehouseInventoryController { return res.send(buffer); } + @Get(':id/grn-document') + @ApiOperation({ summary: 'View goods received note PDF' }) + async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.inventoryService.grnDocument(id); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + 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) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 618553df3..001897b3f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -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['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, '''); + 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 ` + + + + Goods Received Note + + + +
+
+
Ethio-Djibouti Railway S.C.
+

Goods Received Note

+
Warehouse receiving confirmation
+
+
+ GRN Number + ${esc(data.grnNumber)} + Received: ${esc(receivedAt)} +
+
+
+
+ This Goods Received Note confirms that the listed goods were received into EDR warehouse custody at the stated location. +
+
Receiving Particulars
+ + + ${rows.map(([label, value]) => ``).join('')} + +
${esc(label)}${esc(value)}
+
Receipt Clause
+
+ This document records warehouse receipt only. Loading, dispatch, release, delivery, customs, and fee clearance remain subject to their respective operational approvals. +
+
+
Warehouse receiver name / signature / date
+
Driver or customer representative name / signature / date
+
+ +`; + } + private buildReleaseDocumentHtml(data: { reference: string; issuedAt: Date; @@ -2721,7 +3011,7 @@ export class WarehouseInventoryService { - Warehouse Gate Clearance / Release Order + Warehouse Release / Exit Paper