From 9f5c22c1ee58f7eb915c3bc90bc3e067b82bccc1 Mon Sep 17 00:00:00 2001 From: hagiye Date: Tue, 30 Jun 2026 07:19:12 +0300 Subject: [PATCH 01/54] Goods recieved notes and Booking delivery --- apps/edr-freight-api/package.json | 1 + ...000000-AddGrnNumberToWarehouseInventory.ts | 34 ++ .../entities/warehouse-inventory.entity.ts | 3 + .../warehouse-inventory.controller.ts | 10 + .../warehouses/warehouse-inventory.service.ts | 439 ++++++++++++++++-- .../seed-warehouse-export-receive-ready.ts | 142 ++++++ .../warehouses/InventoryDetailModal.tsx | 12 + .../warehouses/ReceiveInventoryModal.tsx | 174 +++++-- .../warehouses/ReleaseOrderModal.tsx | 201 ++++++-- .../warehouses/WarehouseInventoryTable.tsx | 68 ++- .../backoffice/src/constants/URLS.ts | 1 + .../backoffice/src/constants/apiConfig.ts | 4 +- .../warehouses/ExportWarehouseFlowPage.tsx | 38 +- .../backoffice/src/services/api.ts | 6 +- .../src/services/warehouse.service.ts | 4 + .../backoffice/src/types/warehouse.ts | 7 + .../portal/src/constants/apiConfig.ts | 4 +- .../MyPortalPage/components/BookingRow.tsx | 8 + .../BookingDetailPage/ReadonlyBookingView.tsx | 26 +- .../delivery/ApproveDeliveryButton.tsx | 73 +++ .../portal/src/services/api.ts | 7 + 21 files changed, 1126 insertions(+), 136 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1828000000000-AddGrnNumberToWarehouseInventory.ts create mode 100644 apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx 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 + + +
+
+
+
Ethio-Djibouti Railway S.C.
+

${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}

+
+
+ Document no. + ${esc(model.documentNumber)} + Issued: ${esc(date(model.issuedAt))} +
+
+
${esc(sealText)}
+
${summaryRows}
+ + + + + ${showCategory ? `` : ""} + + + + + + + ${itemRows} + +
Description${esc(model.categoryHeader)}QtyRateAmount
+
${totalRows}
+ +
+ +`; + } + + safeFilename(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]+/g, "-"); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts new file mode 100644 index 000000000..447bc2516 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts @@ -0,0 +1,160 @@ +import { existsSync } from "fs"; + +import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common"; + +const MIN_VALID_PDF_BYTES = 2_000; + +const PDF_PRINT_STYLES = ` +`; + +export interface PdfRenderOptions { + /** Label used in logs to identify the document kind. */ + label?: string; + /** + * Degraded renderer used when Chromium is unavailable. Receives the + * print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-` + * header). When omitted, a generic single-page fallback is produced. + */ + fallback?: (preparedHtml: string) => Buffer; +} + +/** + * Generic HTML → PDF renderer shared by every document producer (invoices, + * receipts, warehouse release orders). Renders via headless Chromium when + * available and degrades to a caller-supplied (or generic) hand-built PDF + * otherwise. This is pure infrastructure — it knows nothing about invoices. + */ +@Injectable() +export class PdfRenderService { + private readonly logger = new Logger(PdfRenderService.name); + + async htmlToPdfBuffer(html: string, opts: PdfRenderOptions = {}): Promise { + const label = opts.label ?? "document"; + const preparedHtml = this.injectPdfPrintStyles(html); + const executablePath = this.resolveExecutablePath(); + + try { + const puppeteer = await import("puppeteer"); + const launchOptions: import("puppeteer").LaunchOptions = { + headless: true, + args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"], + ...(executablePath ? { executablePath } : {}), + }; + + const browser = await puppeteer.default.launch(launchOptions); + try { + const page = await browser.newPage(); + await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 }); + await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 }); + await page.emulateMediaType("print"); + await new Promise((resolve) => setTimeout(resolve, 250)); + + const pdf = await page.pdf({ + format: "A4", + printBackground: true, + margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" }, + }); + + const buffer = Buffer.from(pdf); + if (!this.isValidPdf(buffer)) { + throw new Error(`Puppeteer produced invalid ${label} PDF (${buffer.length} bytes)`); + } + this.logger.log( + `${label} PDF rendered (${buffer.length} bytes) via ${executablePath ?? "bundled Chromium"}`, + ); + return buffer; + } finally { + await browser.close(); + } + } catch (error) { + this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`); + const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml); + if (this.isValidPdf(fallback)) { + this.logger.warn( + `Using ${label} PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`, + ); + return fallback; + } + throw new InternalServerErrorException( + `${label} PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.`, + ); + } + } + + private injectPdfPrintStyles(html: string): string { + if (html.includes("edr-pdf-print-fix")) return html; + if (html.includes("")) { + return html.replace("", `${PDF_PRINT_STYLES}`); + } + return `${PDF_PRINT_STYLES}${html}`; + } + + private resolveExecutablePath(): string | undefined { + const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim(); + if (fromEnv && existsSync(fromEnv)) return fromEnv; + + const candidates = [ + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome", + ]; + return candidates.find((path) => existsSync(path)); + } + + isValidPdf(buffer: Buffer): boolean { + return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString("ascii") === "%PDF-"; + } + + /** Minimal valid one-page PDF carrying a plain-text rendering of the document. */ + private genericFallbackPdf(html: string): Buffer { + const text = html + .replace(//gi, "") + .replace(//gi, "") + .replace(/<[^>]+>/g, " ") + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/[^\x20-\x7e]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 900); + + const escape = (value: string) => value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)"); + const lines = (text.match(/.{1,90}/g) ?? ["Document"]).slice(0, 40); + const stream = + "BT\n/F1 10 Tf\n36 800 Td\n12 TL\n" + + lines.map((line, i) => `${i === 0 ? "" : "T*\n"}(${escape(line)}) Tj\n`).join("") + + "ET"; + + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + `<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`, + ]; + + let pdf = "%PDF-1.4\n"; + const offsets: number[] = []; + objects.forEach((object, index) => { + offsets.push(Buffer.byteLength(pdf, "latin1")); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) pdf += "% pad\n"; + const xrefOffset = Buffer.byteLength(pdf, "latin1"); + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + for (const offset of offsets) pdf += `${String(offset).padStart(10, "0")} 00000 n \n`; + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + return Buffer.from(pdf, "latin1"); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts new file mode 100644 index 000000000..d36788600 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts @@ -0,0 +1,44 @@ +/** + * Shared per-day sequential invoice numbering, used by every billing source + * (freight `FRT-…`, warehouse fees `WHF-…`, …) so the format and the + * `MAX(seq)+1` allocation live in one place instead of being copy-pasted per + * service. + * + * Produces `-YYYYMMDD-00001`: the sequence is the max existing suffix for + * the day + 1. Run inside the caller's transaction (pass that transaction's + * manager) so concurrent generation within a transaction stays consistent. + */ + +/** Anything exposing TypeORM's `.query` — an `EntityManager` or `DataSource`. */ +export interface SqlRunner { + query(sql: string, params?: unknown[]): Promise>; +} + +export interface InvoiceNumberOptions { + /** Schema-qualified table to scan, e.g. `freight.invoices`. */ + table: string; + /** Document code prefix, e.g. `FRT` or `WHF`. */ + code: string; + /** Column holding the number; defaults to `invoice_number`. */ + column?: string; + /** Clock injection point (tests); defaults to now. */ + now?: Date; +} + +export async function nextDailyInvoiceNumber( + runner: SqlRunner, + opts: InvoiceNumberOptions, +): Promise { + const now = opts.now ?? new Date(); + const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`; + const prefix = `${opts.code}-${ymd}-`; + const column = opts.column ?? "invoice_number"; + + const [row] = await runner.query( + `SELECT COALESCE(MAX(CAST(split_part(${column}, '-', 3) AS int)), 0) AS seq + FROM ${opts.table} WHERE ${column} LIKE $1`, + [`${prefix}%`], + ); + const next = Number(row?.seq ?? 0) + 1; + return `${prefix}${String(next).padStart(5, "0")}`; +} diff --git a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts new file mode 100644 index 000000000..ab1e27b1a --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts @@ -0,0 +1,36 @@ +/** + * Shared payment/settlement math for invoices. Both the global + * `BillingService.recordPayment` and the warehouse fee invoice flow apply a + * payment the same way — accumulate `paidAmount`, derive the outstanding + * `balanceAmount`, and decide whether the invoice is now fully settled. Keeping + * it here means the two flows can never drift on rounding or the + * partial-vs-full threshold. + */ + +/** Round to 2 decimals, avoiding binary float drift. */ +export const round2 = (n: number): number => Math.round(n * 100) / 100; + +export interface SettlementResult { + /** New cumulative amount paid. */ + paidAmount: number; + /** Remaining balance (0 once fully paid). */ + balanceAmount: number; + /** True once the balance reaches zero. */ + fullyPaid: boolean; +} + +/** + * Apply a single payment of `amount` to an invoice with `totalAmount` already + * carrying `currentPaid`. Caller is responsible for validating `amount > 0` and + * the invoice being in a payable state. + */ +export function applySettlement( + totalAmount: number, + currentPaid: number, + amount: number, +): SettlementResult { + const total = Number(totalAmount); + const paidAmount = round2(Number(currentPaid) + Number(amount)); + const balanceAmount = Math.max(0, round2(total - paidAmount)); + return { paidAmount, balanceAmount, fullyPaid: paidAmount >= total }; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 1fe184662..904728251 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -1,6 +1,12 @@ import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { + InvoiceDocumentModel, + InvoiceDocumentService, +} from '../billing/documents/invoice-document.service'; +import { nextDailyInvoiceNumber } from '../billing/invoice-numbering.util'; +import { applySettlement } from '../billing/invoice-settlement.util'; import { NotificationsService } from '../notifications/notifications.service'; import { WarehouseFeeInvoice, @@ -11,7 +17,6 @@ import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity'; import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository'; import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository'; import { WarehouseFeeService } from './warehouse-fee.service'; -import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; interface GenerateOptions { confirmZero?: boolean; @@ -56,7 +61,7 @@ export class WarehouseInvoiceService { private readonly invoiceRepository: WarehouseFeeInvoiceRepository, private readonly itemRepository: WarehouseFeeInvoiceItemRepository, private readonly feeService: WarehouseFeeService, - private readonly documents: WarehouseReleaseDocumentService, + private readonly invoiceDocuments: InvoiceDocumentService, private readonly notifications: NotificationsService, ) {} @@ -161,18 +166,12 @@ export class WarehouseInvoiceService { return saved; } - /** WHF-YYYYMMDD-00001 — sequential per day. */ - private async nextInvoiceNumber(): Promise { - const now = new Date(); - const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`; - const prefix = `WHF-${ymd}-`; - const [row] = await this.dataSource.query( - `SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq - FROM freight.warehouse_fee_invoices WHERE invoice_number LIKE $1`, - [`${prefix}%`], - ); - const next = Number(row?.seq ?? 0) + 1; - return `${prefix}${String(next).padStart(5, '0')}`; + /** WHF-YYYYMMDD-00001 — sequential per day (shared billing numbering). */ + private nextInvoiceNumber(): Promise { + return nextDailyInvoiceNumber(this.dataSource, { + table: 'freight.warehouse_fee_invoices', + code: 'WHF', + }); } // ── Reads ──────────────────────────────────────────────────────────────── @@ -186,12 +185,7 @@ export class WarehouseInvoiceService { async document(id: string): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); - const details = await this.getInvoiceDocumentDetails(invoice); - const html = this.buildInvoiceDocumentHtml(invoice, 'INVOICE', details); - return { - filename: `warehouse-invoice-${this.safeFilename(invoice.invoiceNumber)}.pdf`, - buffer: await this.documents.htmlToPdfBuffer(html), - }; + return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'INVOICE')); } async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> { @@ -199,11 +193,69 @@ export class WarehouseInvoiceService { if (Number(invoice.paidAmount) <= 0) { throw new BadRequestException('A receipt is available only after payment is recorded.'); } - const details = await this.getInvoiceDocumentDetails(invoice); - const html = this.buildInvoiceDocumentHtml(invoice, 'RECEIPT', details); + return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT')); + } + + /** Map a warehouse fee invoice (with display details + items) onto the shared document model. */ + private toDocumentModel( + invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] }, + kind: 'INVOICE' | 'RECEIPT', + ): InvoiceDocumentModel { + const items = invoice.items as Array<{ + description?: string; + feeType?: string; + quantity?: number; + unitRate?: number; + amount?: number; + currency?: string; + chargeableDays?: number | null; + }>; + const lastPayment = [...(invoice.payments ?? [])].pop(); + const date = (value: unknown) => + value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null; + return { - filename: `warehouse-receipt-${this.safeFilename(invoice.invoiceNumber)}.pdf`, - buffer: await this.documents.htmlToPdfBuffer(html), + kind, + title: 'Warehouse Fee', + documentNumber: invoice.invoiceNumber, + issuedAt: invoice.issuedAt ?? invoice.createdAt, + status: invoice.status, + currency: invoice.currency, + summary: [ + { label: 'Status', value: invoice.status.replace(/_/g, ' ') }, + { label: 'Invoice type', value: invoice.invoiceType.replace(/_/g, ' ') }, + { label: 'Booking reference', value: invoice.bookingReference ?? null }, + { label: 'Customer', value: invoice.customerName ?? null }, + { label: 'Inventory reference', value: invoice.inventoryReference ?? null }, + { label: 'Inventory info', value: invoice.inventoryInfo ?? null }, + { label: 'Clearance', value: invoice.clearanceStatus ?? null }, + { label: 'Warehouse', value: invoice.warehouseName ?? null }, + { + label: 'Yard / Zone', + value: [invoice.yardName, invoice.zoneName].filter(Boolean).join(' / ') || null, + }, + { label: 'Period', value: `${date(invoice.periodStart) ?? '-'} - ${date(invoice.periodEnd) ?? '-'}` }, + { + label: 'Payment', + value: lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt) ?? '-'}` : null, + }, + ], + categoryHeader: 'Fee type', + lines: items.map((item) => ({ + description: item.description ?? null, + category: item.feeType ?? null, + quantity: item.quantity ?? item.chargeableDays ?? 0, + unitRate: item.unitRate, + amount: item.amount, + currency: item.currency ?? invoice.currency, + })), + totals: [ + { label: 'Subtotal', amount: Number(invoice.subtotalAmount) }, + { label: 'Tax', amount: Number(invoice.taxAmount) }, + { label: 'Total', amount: Number(invoice.totalAmount), grand: true }, + { label: 'Paid', amount: Number(invoice.paidAmount) }, + { label: 'Balance', amount: Number(invoice.balanceAmount) }, + ], }; } @@ -237,10 +289,11 @@ export class WarehouseInvoiceService { if (invoice.status === 'PAID') throw new BadRequestException('Invoice is already fully paid.'); if (!(dto.amount > 0)) throw new BadRequestException('Payment amount must be greater than zero.'); - const paidAmount = Number(invoice.paidAmount) + dto.amount; - const total = Number(invoice.totalAmount); - const balance = Math.max(0, Math.round((total - paidAmount) * 100) / 100); - const fullyPaid = paidAmount >= total; + const { paidAmount, balanceAmount, fullyPaid } = applySettlement( + invoice.totalAmount, + invoice.paidAmount, + dto.amount, + ); const payments = [ ...(invoice.payments ?? []), @@ -248,8 +301,8 @@ export class WarehouseInvoiceService { ]; const updated = await this.invoiceRepository.update(id, { - paidAmount: Math.round(paidAmount * 100) / 100, - balanceAmount: balance, + paidAmount, + balanceAmount, status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID', paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null, payments, @@ -460,131 +513,4 @@ export class WarehouseInvoiceService { await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`); } - - private buildInvoiceDocumentHtml( - invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] }, - kind: 'INVOICE' | 'RECEIPT', - details: InvoiceDocumentDetails, - ): string { - const esc = (value: unknown) => - String(value ?? '-') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - const money = (amount: unknown, currency = invoice.currency) => - `${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`; - const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-'); - const items = invoice.items as Array<{ - id?: string; - description?: string; - feeType?: string; - quantity?: number; - unitRate?: number; - amount?: number; - currency?: string; - chargeableDays?: number | null; - }>; - const lastPayment = [...(invoice.payments ?? [])].pop(); - const sealText = kind === 'RECEIPT' || invoice.status === 'PAID' ? 'EDR PAID' : 'EDR'; - - return ` - - - - Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'} - - - -
-
-
-
Ethio-Djibouti Railway S.C.
-

Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}

-
-
- Document no. - ${esc(invoice.invoiceNumber)} - Issued: ${esc(date(invoice.issuedAt ?? invoice.createdAt))} -
-
-
${esc(sealText)}
-
-
Status${esc(invoice.status.replace(/_/g, ' '))}
-
Invoice type${esc(invoice.invoiceType.replace(/_/g, ' '))}
-
Booking reference${esc(details.bookingReference)}
-
Customer${esc(details.customerName)}
-
Inventory reference${esc(details.inventoryReference)}
-
Inventory info${esc(details.inventoryInfo)}
-
Clearance${esc(details.clearanceStatus)}
-
Warehouse${esc(details.warehouseName)}
-
Yard / Zone${esc([details.yardName, details.zoneName].filter(Boolean).join(' / ') || null)}
-
Period${esc(date(invoice.periodStart))} - ${esc(date(invoice.periodEnd))}
-
Payment${esc(lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt)}` : '-')}
-
- - - - - - - - - - - - ${items - .map( - (item) => ` - - - - - - `, - ) - .join('')} - -
DescriptionFee typeQtyRateAmount
${esc(item.description)}${esc((item.feeType ?? '').replace(/_/g, ' '))}${esc(item.quantity ?? item.chargeableDays ?? 0)}${esc(money(item.unitRate, item.currency ?? invoice.currency))}${esc(money(item.amount, item.currency ?? invoice.currency))}
-
-
Subtotal${esc(money(invoice.subtotalAmount))}
-
Tax${esc(money(invoice.taxAmount))}
-
Total${esc(money(invoice.totalAmount))}
-
Paid${esc(money(invoice.paidAmount))}
-
Balance${esc(money(invoice.balanceAmount))}
-
- -
- -`; - } - - private safeFilename(value: string): string { - return value.replace(/[^a-zA-Z0-9_-]+/g, '-'); - } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts index a77a46c29..f8c0dd355 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -1,101 +1,23 @@ -import { existsSync } from 'fs'; +import { Injectable } from '@nestjs/common'; -import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; +import { PdfRenderService } from '../billing/documents/pdf-render.service'; const MIN_VALID_PDF_BYTES = 2_000; -const RELEASE_DOCUMENT_PRINT_STYLES = ` -`; - @Injectable() export class WarehouseReleaseDocumentService { - private readonly logger = new Logger(WarehouseReleaseDocumentService.name); + constructor(private readonly pdf: PdfRenderService) {} - async htmlToPdfBuffer(html: string): Promise { - const preparedHtml = this.injectPdfPrintStyles(html); - const executablePath = this.resolveExecutablePath(); - - try { - const puppeteer = await import('puppeteer'); - const launchOptions: import('puppeteer').LaunchOptions = { - headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], - ...(executablePath ? { executablePath } : {}), - }; - - const browser = await puppeteer.default.launch(launchOptions); - try { - const page = await browser.newPage(); - await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 }); - await page.setContent(preparedHtml, { waitUntil: 'load', timeout: 60_000 }); - await page.emulateMediaType('print'); - await new Promise((resolve) => setTimeout(resolve, 250)); - - const pdf = await page.pdf({ - format: 'A4', - printBackground: true, - margin: { top: '16mm', bottom: '18mm', left: '14mm', right: '14mm' }, - }); - - const buffer = Buffer.from(pdf); - if (!this.isValidPdf(buffer)) { - throw new Error(`Puppeteer produced invalid release PDF (${buffer.length} bytes)`); - } - this.logger.log( - `Warehouse release PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`, - ); - return buffer; - } finally { - await browser.close(); - } - } catch (error) { - this.logger.error( - `Warehouse release PDF failed (executable=${executablePath ?? 'default'}): ${error}`, - ); - const fallback = this.htmlToBasicPdfBuffer(preparedHtml); - if (this.isValidPdf(fallback)) { - this.logger.warn( - `Using basic warehouse release PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`, - ); - return fallback; - } - throw new InternalServerErrorException( - 'Warehouse release PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.', - ); - } - } - - private injectPdfPrintStyles(html: string): string { - if (html.includes('warehouse-release-document-print-fix')) return html; - if (html.includes('')) { - return html.replace('', `${RELEASE_DOCUMENT_PRINT_STYLES}`); - } - return `${RELEASE_DOCUMENT_PRINT_STYLES}${html}`; - } - - private resolveExecutablePath(): string | undefined { - const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim(); - if (fromEnv && existsSync(fromEnv)) return fromEnv; - - const candidates = [ - '/usr/bin/chromium', - '/usr/bin/chromium-browser', - '/usr/bin/google-chrome-stable', - '/usr/bin/google-chrome', - ]; - return candidates.find((path) => existsSync(path)); - } - - private isValidPdf(buffer: Buffer): boolean { - return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString('ascii') === '%PDF-'; + /** + * Render the gate-clearance release document to PDF via the shared renderer, + * falling back to the release-specific hand-built layout when Chromium is + * unavailable. + */ + htmlToPdfBuffer(html: string): Promise { + return this.pdf.htmlToPdfBuffer(html, { + label: 'Warehouse release', + fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml), + }); } private htmlToBasicPdfBuffer(html: string): Buffer { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index d880a3554..a7ce68319 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { DocumentsModule } from '../billing/documents/documents.module'; import { FilesModule } from '../files/files.module'; import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module'; import { LastMileModule } from '../last-mile/last-mile.module'; @@ -70,6 +71,7 @@ import { WarehousesService } from './warehouses.service'; WarehouseFeeInvoice, WarehouseFeeInvoiceItem, ]), + DocumentsModule, FilesModule, InterchangeDocumentsModule, forwardRef(() => LastMileModule), From 2bbb37207e5313a67006407d19db962a9936a1c8 Mon Sep 17 00:00:00 2001 From: yonastewabe Date: Tue, 30 Jun 2026 15:16:47 +0300 Subject: [PATCH 21/54] feat: implement automated environment synchronization and conditional CI/CD deployment workflows --- .github/workflows/deploy.yml | 2 +- docker-compose.yaml | 24 +++--- infrastructure/docker/Dockerfile.web | 4 - .../deploy/sync-env-from-server-jenkins.sh | 74 +++++++++++++++++++ scripts/deploy/sync-env-from-server.sh | 25 +------ 5 files changed, 90 insertions(+), 39 deletions(-) create mode 100644 scripts/deploy/sync-env-from-server-jenkins.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 78c15cba1..fcd560a95 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -50,7 +50,7 @@ jobs: SERVICES=() - NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$" + NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$|^scripts/deploy/sync-env-from-server-jenkins[.]sh$" GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$" diff --git a/docker-compose.yaml b/docker-compose.yaml index a045125bb..5ea74843b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -39,14 +39,14 @@ services: args: TURBO_FILTER: "@edr/freight-portal" APP_PATH: apps/edr-freight-web/portal - VITE_API_URL: ${VITE_API_URL:-https://edrfreightapi.triaplc.com/api} - VITE_BASE_API_URL: ${VITE_BASE_API_URL:-https://edrfreightapi.triaplc.com} - VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-/_um} + VITE_API_URL: ${VITE_API_URL:?VITE_API_URL must be set} + VITE_BASE_API_URL: ${VITE_BASE_API_URL:?VITE_BASE_API_URL must be set} + VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:?VITE_USER_MANAGEMENT_BASE must be set} secrets: - npmrc ports: - "${FREIGHT_PORTAL_PORT:-5173}:80" - + freight-backoffice: build: context: . @@ -54,14 +54,14 @@ services: args: TURBO_FILTER: "@edr/freight-backoffice" APP_PATH: apps/edr-freight-web/backoffice - VITE_API_URL: ${VITE_API_URL:-https://edrfreightapi.triaplc.com/api} - VITE_BASE_API_URL: ${VITE_BASE_API_URL:-https://edrfreightapi.triaplc.com} - VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-/_um} + VITE_API_URL: ${VITE_API_URL:?VITE_API_URL must be set} + VITE_BASE_API_URL: ${VITE_BASE_API_URL:?VITE_BASE_API_URL must be set} + VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:?VITE_USER_MANAGEMENT_BASE must be set} secrets: - npmrc ports: - "${FREIGHT_BACKOFFICE_PORT:-5183}:80" - + passenger-portal: build: context: . @@ -69,14 +69,14 @@ services: args: APP_PACKAGE: "@edr/passenger-portal" APP_PATH: apps/edr-passenger-web/portal - NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:4000} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:?NEXT_PUBLIC_API_URL must be set} secrets: - npmrc ports: - "${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174}" env_file: - apps/edr-passenger-web/portal/.env - + passenger-backoffice: build: context: . @@ -84,14 +84,14 @@ services: args: APP_PACKAGE: "@edr/passenger-backoffice" APP_PATH: apps/edr-passenger-web/backoffice - NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:4000} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:?NEXT_PUBLIC_API_URL must be set} secrets: - npmrc ports: - "${PASSENGER_BACKOFFICE_PORT:-5184}:${PASSENGER_BACKOFFICE_PORT:-5184}" env_file: - apps/edr-passenger-web/backoffice/.env - + payment-api: build: context: . diff --git a/infrastructure/docker/Dockerfile.web b/infrastructure/docker/Dockerfile.web index 1ccdeb81f..65c26a694 100644 --- a/infrastructure/docker/Dockerfile.web +++ b/infrastructure/docker/Dockerfile.web @@ -2,10 +2,6 @@ ARG TURBO_FILTER=@edr/freight-portal ARG APP_PATH=apps/edr-freight-web/portal -ARG VITE_API_URL=https://edrfreightapi.triaplc.com/api -ARG VITE_BASE_API_URL=https://edrfreightapi.triaplc.com -ARG VITE_USER_MANAGEMENT_BASE=/_um -ARG NEXT_PUBLIC_API_URL=http://localhost:4000 FROM node:24.15.0-alpine AS base RUN apk add --no-cache libc6-compat diff --git a/scripts/deploy/sync-env-from-server-jenkins.sh b/scripts/deploy/sync-env-from-server-jenkins.sh new file mode 100644 index 000000000..74b9a3f13 --- /dev/null +++ b/scripts/deploy/sync-env-from-server-jenkins.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Sync .env files from the self-hosted runner filesystem into the repo. +# Jenkins variant — exports variables as KEY=VALUE lines into $CI_ENV_FILE, +# which the Jenkinsfile loads with readProperties + withEnv. Jenkins has no +# equivalent of GitHub Actions' $GITHUB_ENV, and each `sh` step runs in its +# own process, so this file is the hand-off point between stages. +# +# Usage: +# PROJECT=edr-freight BRANCH=main CI_ENV_FILE=/tmp/passenger-api.env \ +# ./scripts/deploy/sync-env-from-server-jenkins.sh passenger-api +# +# Server layout (one file per service): +# /home/user/environmen///freight-api.env +# /home/user/environmen///freight-portal.env + +set -euo pipefail + +DEPLOY_USER="${DEPLOY_USER:-tria}" +BRANCH="${BRANCH:?BRANCH is required}" +BRANCH_SLUG="${BRANCH_SLUG:-$(echo "${BRANCH}" | tr "[:upper:]" "[:lower:]" | sed -E "s/[^a-z0-9]+/-/g; s/^-+//; s/-+$//")}" +ENV_ROOT="${ENV_ROOT:-/home/${DEPLOY_USER}/environment/edr/${BRANCH_SLUG}/${PROJECT:?PROJECT is required}}" +CI_ENV_FILE="${CI_ENV_FILE:?CI_ENV_FILE is required (e.g. \${WORKSPACE}/.ci-env/.env)}" + +if [[ ! -d "${ENV_ROOT}" ]]; then + echo "Environment directory not found: ${ENV_ROOT}" >&2 + exit 1 +fi +echo "Using environment directory: ${ENV_ROOT}" + +mkdir -p "$(dirname "${CI_ENV_FILE}")" +: > "${CI_ENV_FILE}" + +declare -A SERVICE_ENV_TARGET=( + ["freight-api"]="apps/edr-freight-api/.env" + ["freight-portal"]="apps/edr-freight-web/portal/.env" + ["freight-backoffice"]="apps/edr-freight-web/backoffice/.env" + ["passenger-api"]="apps/edr-passenger-api/.env" + ["passenger-portal"]="apps/edr-passenger-web/portal/.env" + ["passenger-backoffice"]="apps/edr-passenger-web/backoffice/.env" + ["payment-api"]="apps/edr-payment-api/.env" +) + +for service in "$@"; do + src="${ENV_ROOT}/${service}.env" + dest="${SERVICE_ENV_TARGET[${service}]:-}" + + if [[ -z "${dest}" ]]; then + echo "Unknown service: ${service}" >&2 + exit 1 + fi + + if [[ ! -f "${src}" ]]; then + echo "Missing env file: ${src}" >&2 + exit 1 + fi + + mkdir -p "$(dirname "${dest}")" + cp "${src}" "${dest}" + echo "Synced ${src} -> ${dest}" + + port_value=$(sed -n -E 's/^[[:space:]]*PORT[[:space:]]*=[[:space:]]*"?([^"#]+)"?[[:space:]]*(#.*)?$/\1/p' "${src}" | head -n1 | tr -d '[:space:]') + if [[ -z "${port_value}" ]]; then + echo "Missing required PORT in env file: ${src}" >&2 + exit 1 + fi + + service_var=$(echo "${service}" | tr '[:lower:]-' '[:upper:]_') + echo "${service_var}_PORT=${port_value}" >> "${CI_ENV_FILE}" + echo "Exported ${service_var}_PORT from ${src}" + + # Forward NEXT_PUBLIC_* and VITE_* vars so docker compose build can inject them as build args. + grep -E '^[[:space:]]*(NEXT_PUBLIC_|VITE_)[A-Za-z0-9_]+=' "${src}" \ + | sed -E 's/^[[:space:]]*//' >> "${CI_ENV_FILE}" || true +done \ No newline at end of file diff --git a/scripts/deploy/sync-env-from-server.sh b/scripts/deploy/sync-env-from-server.sh index 795ab25b2..025c518b5 100644 --- a/scripts/deploy/sync-env-from-server.sh +++ b/scripts/deploy/sync-env-from-server.sh @@ -7,7 +7,6 @@ # Server layout (one file per service): # /home/user/environmen///freight-api.env # /home/user/environmen///freight-portal.env -# /home/user/environmen///freight-web.build.env (optional, exports VITE_API_URL etc.) set -euo pipefail @@ -62,26 +61,8 @@ for service in "$@"; do echo "${service_var}_PORT=${port_value}" >> "${GITHUB_ENV}" echo "Exported ${service_var}_PORT from ${src}" - # Forward NEXT_PUBLIC_* vars so docker compose build can inject them as build args. - grep -E '^[[:space:]]*NEXT_PUBLIC_[A-Za-z0-9_]+=' "${src}" \ + # Forward NEXT_PUBLIC_* and VITE_* vars so docker compose build can inject them as build args. + grep -E '^[[:space:]]*(NEXT_PUBLIC_|VITE_)[A-Za-z0-9_]+=' "${src}" \ | sed -E 's/^[[:space:]]*//' >> "${GITHUB_ENV}" || true fi -done - -# Optional build-time variables (VITE_API_URL, etc.) -# Set BUILD_ENV_FILE=freight-web.build.env or passenger-web.build.env per workflow. -build_env_file="${BUILD_ENV_FILE:-web.build.env}" -build_env="${ENV_ROOT}/${build_env_file}" -if [[ -f "${build_env}" ]]; then - echo "Loading build variables from ${build_env}" - set -a - # shellcheck disable=SC1090 - source "${build_env}" - set +a - - if [[ -n "${GITHUB_ENV:-}" ]]; then - grep -E '^[[:space:]]*(export[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*=' "${build_env}" \ - | sed -E 's/^[[:space:]]*export[[:space:]]+//' >> "${GITHUB_ENV}" - echo "Wrote build variables to GITHUB_ENV" - fi -fi +done \ No newline at end of file From 18e18bd15e0a1354b31c9f69c1123e8cbacf54d6 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Tue, 30 Jun 2026 15:24:09 +0300 Subject: [PATCH 22/54] Update Dockerfile.web --- infrastructure/docker/Dockerfile.web | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/infrastructure/docker/Dockerfile.web b/infrastructure/docker/Dockerfile.web index fc5e9ab7e..d5f77061e 100644 --- a/infrastructure/docker/Dockerfile.web +++ b/infrastructure/docker/Dockerfile.web @@ -5,10 +5,6 @@ ARG APP_PATH=apps/edr-freight-web/portal FROM node:24.15.0-alpine AS base RUN apk add --no-cache libc6-compat -# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit -# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds. -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable WORKDIR /app @@ -35,6 +31,12 @@ ENV VITE_API_URL=${VITE_API_URL} ENV VITE_BASE_API_URL=${VITE_BASE_API_URL} ENV VITE_USER_MANAGEMENT_BASE=${VITE_USER_MANAGEMENT_BASE} ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} + +RUN if [ -z "$VITE_API_URL" ] || [ -z "$VITE_BASE_API_URL" ] || [ -z "$VITE_USER_MANAGEMENT_BASE" ]; then \ + echo "ERROR: VITE_API_URL, VITE_BASE_API_URL, and VITE_USER_MANAGEMENT_BASE must all be set" && \ + exit 1; \ + fi + COPY --from=installer /app/ . COPY --from=pruner /app/out/full/ . From 90f200fdc10b82ac4c445248fd5f8a98a0da271a Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Tue, 30 Jun 2026 15:26:23 +0300 Subject: [PATCH 23/54] Update Dockerfile.passenger-web --- infrastructure/docker/Dockerfile.passenger-web | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/infrastructure/docker/Dockerfile.passenger-web b/infrastructure/docker/Dockerfile.passenger-web index 9d9adac08..61b58736b 100644 --- a/infrastructure/docker/Dockerfile.passenger-web +++ b/infrastructure/docker/Dockerfile.passenger-web @@ -10,12 +10,10 @@ # --build-arg PORT=5174 \ # -f infrastructure/docker/Dockerfile.passenger-web . # - ARG APP_PACKAGE=@edr/passenger-portal ARG APP_PATH=apps/edr-passenger-web/portal ARG PORT=5174 ARG NEXT_PUBLIC_API_URL - FROM node:24.15.0-alpine AS base RUN apk add --no-cache libc6-compat # Store pnpm's content-addressable store under PNPM_HOME so the BuildKit @@ -24,34 +22,35 @@ ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable WORKDIR /app - FROM base AS pruner ARG APP_PACKAGE COPY . . RUN pnpm dlx turbo prune "${APP_PACKAGE}" --docker - FROM base AS installer COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --frozen-lockfile - FROM base AS builder ARG APP_PACKAGE ARG APP_PATH ARG NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} + +RUN if [ -z "$NEXT_PUBLIC_API_URL" ]; then \ + echo "ERROR: NEXT_PUBLIC_API_URL must be set" && \ + exit 1; \ + fi + COPY --from=installer /app/ . COPY --from=pruner /app/out/full/ . RUN pnpm turbo build --filter="${APP_PACKAGE}..." - FROM base AS deployer ARG APP_PACKAGE COPY --from=builder /app/ . RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm deploy --filter="${APP_PACKAGE}" --prod --legacy /deploy - FROM node:24.15.0-alpine AS runner ARG APP_PATH ARG PORT=5174 From 9f2f1b5138a910e1331b81037cb91de7d5da2ba7 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Tue, 30 Jun 2026 15:28:39 +0300 Subject: [PATCH 24/54] Update docker-compose.yaml --- docker-compose.yaml | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 5ea74843b..db3da060a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -20,7 +20,6 @@ services: - apps/edr-freight-api/.env extra_hosts: - "paymentcallback.triaplc.com:10.18.7.179" - passenger-api: build: context: . @@ -31,7 +30,6 @@ services: - apps/edr-passenger-api/.env extra_hosts: - "paymentcallback.triaplc.com:10.18.7.179" - freight-portal: build: context: . @@ -39,14 +37,13 @@ services: args: TURBO_FILTER: "@edr/freight-portal" APP_PATH: apps/edr-freight-web/portal - VITE_API_URL: ${VITE_API_URL:?VITE_API_URL must be set} - VITE_BASE_API_URL: ${VITE_BASE_API_URL:?VITE_BASE_API_URL must be set} - VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:?VITE_USER_MANAGEMENT_BASE must be set} + VITE_API_URL: ${VITE_API_URL:-} + VITE_BASE_API_URL: ${VITE_BASE_API_URL:-} + VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-} secrets: - npmrc ports: - "${FREIGHT_PORTAL_PORT:-5173}:80" - freight-backoffice: build: context: . @@ -54,14 +51,13 @@ services: args: TURBO_FILTER: "@edr/freight-backoffice" APP_PATH: apps/edr-freight-web/backoffice - VITE_API_URL: ${VITE_API_URL:?VITE_API_URL must be set} - VITE_BASE_API_URL: ${VITE_BASE_API_URL:?VITE_BASE_API_URL must be set} - VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:?VITE_USER_MANAGEMENT_BASE must be set} + VITE_API_URL: ${VITE_API_URL:-} + VITE_BASE_API_URL: ${VITE_BASE_API_URL:-} + VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-} secrets: - npmrc ports: - "${FREIGHT_BACKOFFICE_PORT:-5183}:80" - passenger-portal: build: context: . @@ -69,14 +65,13 @@ services: args: APP_PACKAGE: "@edr/passenger-portal" APP_PATH: apps/edr-passenger-web/portal - NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:?NEXT_PUBLIC_API_URL must be set} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-} secrets: - npmrc ports: - "${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174}" env_file: - apps/edr-passenger-web/portal/.env - passenger-backoffice: build: context: . @@ -84,7 +79,7 @@ services: args: APP_PACKAGE: "@edr/passenger-backoffice" APP_PATH: apps/edr-passenger-web/backoffice - NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:?NEXT_PUBLIC_API_URL must be set} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-} secrets: - npmrc ports: @@ -105,7 +100,6 @@ services: - "${PAYMENT_API_PORT:-3008}:${PAYMENT_API_PORT:-3008}" env_file: - apps/edr-payment-api/.env - secrets: npmrc: file: .npmrc From a667f5b2df910706de6558b7bf96c522720fb495 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 30 Jun 2026 15:51:47 +0300 Subject: [PATCH 25/54] add test payment event --- .../payments/payment-events.consumer.ts | 5 + .../outbox/dto/test-payment-event.dto.ts | 92 +++++++++++++++++++ .../src/modules/outbox/outbox.module.ts | 7 ++ .../modules/outbox/test-events.controller.ts | 88 ++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 apps/edr-payment-api/src/modules/outbox/dto/test-payment-event.dto.ts create mode 100644 apps/edr-payment-api/src/modules/outbox/test-events.controller.ts diff --git a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts index 291402240..f13191c48 100644 --- a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts +++ b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts @@ -29,6 +29,11 @@ export class PaymentEventsConsumer { }, }) async handle(event: PaymentEvent): Promise { + // Logged the instant RabbitMQ delivers the message, before any DB work — proves the + // payment -> passenger broker connection works even if processing later fails/hangs. + this.logger.log( + `RECEIVED ${event.eventType} (${event.eventId}) ref=${event.referenceId} via RabbitMQ`, + ); try { const result = await this.paymentsService.handlePaymentEvent( event as unknown as PaymentEventDto, diff --git a/apps/edr-payment-api/src/modules/outbox/dto/test-payment-event.dto.ts b/apps/edr-payment-api/src/modules/outbox/dto/test-payment-event.dto.ts new file mode 100644 index 000000000..700d64717 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/dto/test-payment-event.dto.ts @@ -0,0 +1,92 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsEnum, + IsIn, + IsInt, + IsOptional, + IsPositive, + IsString, +} from "class-validator"; +import { + PaymentEventType, + PaymentReferenceType, + PaymentService, + ProviderMethod, +} from "@edr/types"; + +/** + * Body for the dev-only POST /test/payment-event endpoint. Every field is optional — the + * controller fills sensible defaults so an empty `{}` publishes a `payment.succeeded` to the + * passenger queue. Set `referenceId` to a real bookingId to exercise the consumer's side effects + * (seat confirm / ticket issue); leave it blank to only prove RabbitMQ delivery. + */ +export class TestPaymentEventDto { + @ApiPropertyOptional({ + enum: ["payment.succeeded", "payment.failed"], + default: "payment.succeeded", + }) + @IsOptional() + @IsIn(["payment.succeeded", "payment.failed"]) + eventType?: PaymentEventType; + + @ApiPropertyOptional({ enum: PaymentService, default: PaymentService.PASSENGER }) + @IsOptional() + @IsEnum(PaymentService) + service?: PaymentService; + + @ApiPropertyOptional({ + enum: PaymentReferenceType, + default: PaymentReferenceType.BOOKING, + }) + @IsOptional() + @IsEnum(PaymentReferenceType) + referenceType?: PaymentReferenceType; + + @ApiPropertyOptional({ + description: "Domain order id (e.g. bookingId). Defaults to a random uuid.", + }) + @IsOptional() + @IsString() + referenceId?: string; + + @ApiPropertyOptional({ description: "Defaults to a random uuid." }) + @IsOptional() + @IsString() + intentId?: string; + + @ApiPropertyOptional({ description: "Defaults to test-." }) + @IsOptional() + @IsString() + merchantOrderId?: string; + + @ApiPropertyOptional({ enum: ProviderMethod, default: ProviderMethod.WAAFI }) + @IsOptional() + @IsEnum(ProviderMethod) + provider?: ProviderMethod; + + @ApiPropertyOptional({ default: 10000, description: "Amount in minor units." }) + @IsOptional() + @IsInt() + @IsPositive() + amountMinor?: number; + + @ApiPropertyOptional({ default: "ETB" }) + @IsOptional() + @IsString() + currency?: string; + + @ApiPropertyOptional({ description: "Only used for payment.succeeded." }) + @IsOptional() + @IsString() + providerTxnId?: string; + + @ApiPropertyOptional({ description: "Only used for payment.failed." }) + @IsOptional() + @IsString() + failureCode?: string; + + @ApiPropertyOptional({ description: "Only used for payment.failed." }) + @IsOptional() + @IsString() + failureMessage?: string; +} diff --git a/apps/edr-payment-api/src/modules/outbox/outbox.module.ts b/apps/edr-payment-api/src/modules/outbox/outbox.module.ts index eae5515e8..5d8d0cc8a 100644 --- a/apps/edr-payment-api/src/modules/outbox/outbox.module.ts +++ b/apps/edr-payment-api/src/modules/outbox/outbox.module.ts @@ -11,6 +11,12 @@ import { OutboxRepository } from "./outbox.repository"; import { HttpPaymentEventPublisher } from "./publisher/http-payment-event-publisher"; import { PAYMENT_EVENT_PUBLISHER } from "./publisher/payment-event-publisher"; import { RabbitMqPaymentEventPublisher } from "./publisher/rabbitmq-payment-event-publisher"; +import { TestEventsController } from "./test-events.controller"; + +// Dev-only harness to publish a synthetic payment event straight to the broker. +// Never registered in production, so the endpoint cannot exist there. +const testControllers = + process.env.NODE_ENV !== "production" ? [TestEventsController] : []; const rabbitImports = isRabbitPublisher() ? [ @@ -42,6 +48,7 @@ const rabbitImports = isRabbitPublisher() HttpModule, ...rabbitImports, ], + controllers: testControllers, providers: [ OutboxRepository, OutboxRelayService, diff --git a/apps/edr-payment-api/src/modules/outbox/test-events.controller.ts b/apps/edr-payment-api/src/modules/outbox/test-events.controller.ts new file mode 100644 index 000000000..d4396a754 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/test-events.controller.ts @@ -0,0 +1,88 @@ +import { randomUUID } from "node:crypto"; +import { Body, Controller, Inject, Logger, Post } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { + PaymentEvent, + PaymentReferenceType, + PaymentService, + ProviderMethod, + paymentRoutingKey, +} from "@edr/types"; +import { + PAYMENT_EVENT_PUBLISHER, + PaymentEventPublisher, +} from "./publisher/payment-event-publisher"; +import { TestPaymentEventDto } from "./dto/test-payment-event.dto"; + +/** + * DEV-ONLY test harness. Publishes a synthetic payment event through the real + * PaymentEventPublisher (RabbitMQ in dev), so the passenger/freight consumer receives it + * exactly as in production — without creating an intent or going through a booking + provider + * flow. Registered only when NODE_ENV !== "production" (see OutboxModule); never reachable in prod. + * + * Quick check (no body): POST /test/payment-event -> publishes payment.passenger.succeeded. + * Real side effects: pass a real bookingId as `referenceId`. + */ +@ApiTags("Dev test (non-production)") +@Controller("test") +export class TestEventsController { + private readonly logger = new Logger(TestEventsController.name); + + constructor( + @Inject(PAYMENT_EVENT_PUBLISHER) + private readonly publisher: PaymentEventPublisher, + ) {} + + @Post("payment-event") + @ApiOperation({ + summary: + "DEV ONLY: publish a synthetic payment event to the broker (passenger/freight consumes it)", + description: + "Bypasses intents/booking. Empty body publishes a payment.succeeded for PASSENGER. " + + "Set referenceId to a real bookingId to trigger the consumer's seat/ticket side effects.", + }) + async publishTestEvent( + @Body() dto: TestPaymentEventDto, + ): Promise<{ published: true; routingKey: string; event: PaymentEvent }> { + const eventType = dto.eventType ?? "payment.succeeded"; + const service = dto.service ?? PaymentService.PASSENGER; + const now = new Date().toISOString(); + + const base = { + version: 1 as const, + eventId: randomUUID(), + occurredAt: now, + service, + intentId: dto.intentId ?? randomUUID(), + referenceType: dto.referenceType ?? PaymentReferenceType.BOOKING, + referenceId: dto.referenceId ?? randomUUID(), + merchantOrderId: dto.merchantOrderId ?? `test-${randomUUID().slice(0, 8)}`, + provider: dto.provider ?? ProviderMethod.WAAFI, + amountMinor: dto.amountMinor ?? 10_000, + currency: dto.currency ?? "ETB", + }; + + const event: PaymentEvent = + eventType === "payment.failed" + ? { + ...base, + eventType: "payment.failed", + failureCode: dto.failureCode ?? "TEST_DECLINED", + failureMessage: dto.failureMessage ?? "Synthetic test failure", + } + : { + ...base, + eventType: "payment.succeeded", + providerTxnId: dto.providerTxnId ?? `TEST-${randomUUID().slice(0, 8)}`, + paidAt: now, + }; + + await this.publisher.publish(event); + + const routingKey = paymentRoutingKey(event.service, event.eventType); + this.logger.log( + `published TEST ${event.eventType} (${event.eventId}) ref=${event.referenceId} -> ${routingKey}`, + ); + return { published: true, routingKey, event }; + } +} From fa3138f2aca8fd6a906ced36b0e4d06b8e065473 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 30 Jun 2026 12:54:04 +0000 Subject: [PATCH 26/54] refactor: migrate the warehouse invoice to use the central one --- apps/edr-freight-api/package.json | 2 +- ...29000000000-CentralizeWarehouseInvoices.ts | 222 +++++++ .../src/modules/billing/billing.service.ts | 29 +- .../warehouse-fee-invoice-item.entity.ts | 50 -- .../entities/warehouse-fee-invoice.entity.ts | 107 ---- .../warehouse-fee-invoice-item.repository.ts | 13 - .../warehouse-fee-invoice.repository.ts | 13 - .../warehouses/warehouse-invoice.service.ts | 600 ++++++++++++------ .../warehouses/warehouse-invoice.types.ts | 88 +++ .../modules/warehouses/warehouses.module.ts | 10 +- apps/edr-freight-api/tsconfig.json | 1 + 11 files changed, 744 insertions(+), 391 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts delete mode 100644 apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts delete mode 100644 apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts delete mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts delete mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 71e2fd60a..9edf388b9 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -6,7 +6,7 @@ "scripts": { "clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"", "predev": "pnpm run clean", - "dev": "nest start --watch", + "dev": "nest start --watch --clearScreen false", "prebuild": "pnpm run clean", "build": "nest build", "start": "node dist/main.js", diff --git a/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts b/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts new file mode 100644 index 000000000..dd246cb7d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts @@ -0,0 +1,222 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Fold warehouse fee invoices into the central billing system. + * + * Warehouse fee invoices are no longer a standalone aggregate: each becomes a + * global `freight.invoices` row (`source = 'warehouse'`, `source_id = + * inventory_id`) with its items as `freight.invoice_lines`. The warehouse + * service is now a thin layer over `BillingService`. This migration backfills the + * existing rows (preserving ids, numbers, status, amounts and payment history), + * then drops the two legacy tables. + * + * Rows that cannot be billed centrally — no company to bill (`company_id` / + * `company_profile_id` underivable from the customer or the booking) — are not + * migrated; they could never have been charged through the gateway and are + * dropped with the table. + */ +export class CentralizeWarehouseInvoices1829000000000 implements MigrationInterface { + name = 'CentralizeWarehouseInvoices1829000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // 1. Invoice headers. Keep the same id so items still link, and so any + // external reference to the invoice id stays valid. + await queryRunner.query(` + INSERT INTO freight.invoices ( + id, invoice_number, company_id, company_profile_id, + subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount, + currency, status, source, source_id, type, + issued_at, paid_at, payments, payment_id, due_at, + created_at, updated_at, deleted_at + ) + SELECT + fee.id, + fee.invoice_number, + COALESCE(fee.customer_id, b.company_id), + COALESCE( + b.company_profile_id, + (SELECT cp.id + FROM freight.company_profiles cp + WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id) + AND cp.deleted_at IS NULL + ORDER BY cp.created_at ASC + LIMIT 1) + ), + fee.subtotal_amount, fee.tax_amount, fee.total_amount, fee.paid_amount, fee.balance_amount, + fee.currency, + fee.status::freight.invoices_status_enum, + 'warehouse', + fee.inventory_id, + fee.invoice_type, + fee.issued_at, + fee.paid_at, + COALESCE(fee.payments, '[]'::jsonb), + NULL, + COALESCE(fee.due_date, fee.issued_at, fee.created_at), + fee.created_at, fee.updated_at, fee.deleted_at + FROM freight.warehouse_fee_invoices fee + LEFT JOIN freight.bookings b ON b.id = fee.booking_id + WHERE COALESCE(fee.customer_id, b.company_id) IS NOT NULL + AND COALESCE( + b.company_profile_id, + (SELECT cp.id + FROM freight.company_profiles cp + WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id) + AND cp.deleted_at IS NULL + ORDER BY cp.created_at ASC + LIMIT 1) + ) IS NOT NULL + ON CONFLICT (id) DO NOTHING; + `); + + // 2. Invoice lines — only for items whose parent invoice migrated. Warehouse + // fee fields (fee_rule_id / chargeable_days / free_days) move into the + // line's jsonb metadata. + await queryRunner.query(` + INSERT INTO freight.invoice_lines ( + id, invoice_id, charge_type, description, quantity, unit_rate, amount, + currency, metadata, created_at, updated_at, deleted_at + ) + SELECT + item.id, + item.invoice_id, + item.fee_type, + item.description, + item.quantity, + item.unit_rate, + item.amount, + item.currency, + jsonb_build_object( + 'feeRuleId', item.fee_rule_id, + 'chargeableDays', item.chargeable_days, + 'freeDays', item.free_days + ), + item.created_at, item.updated_at, item.deleted_at + FROM freight.warehouse_fee_invoice_items item + JOIN freight.invoices i ON i.id = item.invoice_id AND i.source = 'warehouse' + ON CONFLICT (id) DO NOTHING; + `); + + // 3. Drop the legacy tables (items first — FK to invoices). + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoice_items;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoices;`); + } + + public async down(queryRunner: QueryRunner): Promise { + // Recreate the legacy tables … + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoices ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + invoice_number varchar(40) NOT NULL, + booking_id uuid, + customer_id uuid, + inventory_id uuid NOT NULL, + facility_id uuid, + warehouse_id uuid, + yard_id uuid, + zone_id uuid, + invoice_type varchar(32) NOT NULL DEFAULT 'MIXED_WAREHOUSE_FEES', + status varchar(20) NOT NULL DEFAULT 'DRAFT', + subtotal_amount numeric(14,2) NOT NULL DEFAULT 0, + tax_amount numeric(14,2) NOT NULL DEFAULT 0, + total_amount numeric(14,2) NOT NULL DEFAULT 0, + paid_amount numeric(14,2) NOT NULL DEFAULT 0, + balance_amount numeric(14,2) NOT NULL DEFAULT 0, + currency varchar(8) NOT NULL DEFAULT 'USD', + period_start timestamptz, + period_end timestamptz, + issued_at timestamptz, + due_date timestamptz, + paid_at timestamptz, + cancelled_at timestamptz, + payments jsonb NOT NULL DEFAULT '[]', + notes text, + CONSTRAINT "PK_warehouse_fee_invoices" PRIMARY KEY (id), + CONSTRAINT "UQ_warehouse_fee_invoices_invoice_number" UNIQUE (invoice_number) + ); + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_booking_id" ON freight.warehouse_fee_invoices (booking_id);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_inventory_id" ON freight.warehouse_fee_invoices (inventory_id);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_status" ON freight.warehouse_fee_invoices (status);`, + ); + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoice_items ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + invoice_id uuid NOT NULL, + fee_rule_id uuid, + fee_type varchar(32) NOT NULL, + description varchar(255) NOT NULL, + quantity numeric(12,2) NOT NULL DEFAULT 1, + unit_rate numeric(14,2) NOT NULL DEFAULT 0, + amount numeric(14,2) NOT NULL DEFAULT 0, + currency varchar(8) NOT NULL DEFAULT 'USD', + chargeable_days int, + free_days int, + CONSTRAINT "PK_warehouse_fee_invoice_items" PRIMARY KEY (id), + CONSTRAINT "FK_warehouse_fee_invoice_items_invoice" + FOREIGN KEY (invoice_id) REFERENCES freight.warehouse_fee_invoices (id) ON DELETE CASCADE + ); + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoice_items_invoice_id" ON freight.warehouse_fee_invoice_items (invoice_id);`, + ); + + // … then copy the warehouse-source invoices back, deriving the typed FKs and + // period from the linked inventory item. + await queryRunner.query(` + INSERT INTO freight.warehouse_fee_invoices ( + id, created_at, updated_at, deleted_at, invoice_number, + booking_id, customer_id, inventory_id, facility_id, warehouse_id, yard_id, zone_id, + invoice_type, status, subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount, + currency, period_start, period_end, issued_at, due_date, paid_at, cancelled_at, payments, notes + ) + SELECT + i.id, i.created_at, i.updated_at, i.deleted_at, i.invoice_number, + inv.booking_id, i.company_id, i.source_id, w.facility_id, inv.warehouse_id, inv.yard_id, inv.zone_id, + i.type, i.status::text, i.subtotal_amount, i.tax_amount, i.total_amount, i.paid_amount, i.balance_amount, + i.currency, inv.arrived_at, i.issued_at, i.issued_at, i.due_at, i.paid_at, + CASE WHEN i.status::text = 'CANCELLED' THEN i.updated_at ELSE NULL END, + i.payments, NULL + FROM freight.invoices i + LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id + LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id + WHERE i.source = 'warehouse' + ON CONFLICT (id) DO NOTHING; + `); + await queryRunner.query(` + INSERT INTO freight.warehouse_fee_invoice_items ( + id, created_at, updated_at, deleted_at, invoice_id, fee_rule_id, fee_type, + description, quantity, unit_rate, amount, currency, chargeable_days, free_days + ) + SELECT + l.id, l.created_at, l.updated_at, l.deleted_at, l.invoice_id, + NULLIF(l.metadata->>'feeRuleId', '')::uuid, + l.charge_type, + COALESCE(l.description, ''), + l.quantity, l.unit_rate, l.amount, l.currency, + NULLIF(l.metadata->>'chargeableDays', '')::int, + NULLIF(l.metadata->>'freeDays', '')::int + FROM freight.invoice_lines l + JOIN freight.invoices i ON i.id = l.invoice_id AND i.source = 'warehouse' + ON CONFLICT (id) DO NOTHING; + `); + + // Remove the migrated rows from the central tables. + await queryRunner.query(` + DELETE FROM freight.invoice_lines + WHERE invoice_id IN (SELECT id FROM freight.invoices WHERE source = 'warehouse'); + `); + await queryRunner.query(`DELETE FROM freight.invoices WHERE source = 'warehouse';`); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index edacc2c79..e4389e7cf 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,3 +1,4 @@ +import { Freight, PaymentReferenceType } from "@edr/types"; import { BadRequestException, forwardRef, @@ -7,22 +8,21 @@ import { NotFoundException, } from "@nestjs/common"; import { EventEmitter2 } from "@nestjs/event-emitter"; -import { Freight, PaymentReferenceType } from "@edr/types"; import { DataSource, EntityManager, In } from "typeorm"; -import { Invoice, InvoicePayment } from "./entities/invoice.entity"; -import { InvoiceLine } from "./entities/invoice-line.entity"; -import { InvoiceRepository } from "./invoice.repository"; -import { InvoiceLineRepository } from "./invoice-line.repository"; -import { nextDailyInvoiceNumber } from "./invoice-numbering.util"; -import { applySettlement, round2 } from "./invoice-settlement.util"; +import { CompaniesService } from "../companies/companies.service"; +import { PaymentService } from "../payment/payment.service"; +import { InitiateResponseDto } from "../payment/payments.dto"; import { InvoiceDocumentModel, InvoiceDocumentService, } from "./documents/invoice-document.service"; -import { PaymentService } from "../payment/payment.service"; -import { InitiateResponseDto } from "../payment/payments.dto"; -import { CompaniesService } from "../companies/companies.service"; +import { InvoiceLine } from "./entities/invoice-line.entity"; +import { Invoice, InvoicePayment } from "./entities/invoice.entity"; +import { InvoiceLineRepository } from "./invoice-line.repository"; +import { nextDailyInvoiceNumber } from "./invoice-numbering.util"; +import { applySettlement, round2 } from "./invoice-settlement.util"; +import { InvoiceRepository } from "./invoice.repository"; /** Options forwarded to the payment gateway when settling an invoice. */ export interface PayInvoiceOptions { @@ -96,6 +96,11 @@ export interface GenerateInvoiceInput { * (default PENDING) stamps `issuedAt`. */ status?: Freight.InvoiceStatus; + /** + * Document number prefix for this source (e.g. `WHF` for warehouse fees); + * defaults to `FRT`. The daily sequence is allocated per prefix. + */ + numberCode?: string; } /** Payload broadcast on `${source}.invoice.`. */ @@ -269,9 +274,9 @@ export class BillingService { // ── Generation ─────────────────────────────────────────────────────────────── - /** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */ + /** `-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */ private nextInvoiceNumber(mg: EntityManager): Promise { - return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code: "FRT" }); + return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code:"INV" }); } /** diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts deleted file mode 100644 index 8b14dcea3..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; - -import { WarehouseFeeInvoice } from './warehouse-fee-invoice.entity'; - -export const WAREHOUSE_FEE_TYPES = [ - 'CONTAINER_DEMURRAGE', - 'BULK_DEMURRAGE', - 'STORAGE_FEE', - 'HANDLING_FEE', -] as const; -export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number]; - -@Entity({ schema: 'freight', name: 'warehouse_fee_invoice_items' }) -@Index(['invoiceId']) -export class WarehouseFeeInvoiceItem extends BaseEntity { - @Column({ name: 'invoice_id', type: 'uuid' }) - invoiceId!: string; - - @ManyToOne(() => WarehouseFeeInvoice, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'invoice_id' }) - invoice?: WarehouseFeeInvoice; - - @Column({ name: 'fee_rule_id', type: 'uuid', nullable: true }) - feeRuleId?: string | null; - - @Column({ name: 'fee_type', type: 'varchar', length: 32 }) - feeType!: WarehouseFeeType; - - @Column({ name: 'description', type: 'varchar', length: 255 }) - description!: string; - - @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 2, default: 1 }) - quantity!: number; - - @Column({ name: 'unit_rate', type: 'numeric', precision: 14, scale: 2, default: 0 }) - unitRate!: number; - - @Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - amount!: number; - - @Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' }) - currency!: string; - - @Column({ name: 'chargeable_days', type: 'int', nullable: true }) - chargeableDays?: number | null; - - @Column({ name: 'free_days', type: 'int', nullable: true }) - freeDays?: number | null; -} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts deleted file mode 100644 index e57d626d5..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index } from 'typeorm'; - -export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const; -export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number]; - -export const WAREHOUSE_INVOICE_STATUSES = [ - 'DRAFT', - 'ISSUED', - 'PARTIALLY_PAID', - 'PAID', - 'CANCELLED', -] as const; -export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number]; - -/** A single recorded payment against a warehouse fee invoice (history). */ -export interface WarehouseInvoicePayment { - amount: number; - method?: string | null; - reference?: string | null; - paidAt: string; -} - -/** - * Batch 6 — invoice generated from Batch 5 demurrage/storage fee calculation. - * Owns warehouse fees; links to booking/customer/inventory/location so it can - * connect to the existing payment module without duplicating it. - */ -@Entity({ schema: 'freight', name: 'warehouse_fee_invoices' }) -@Index(['invoiceNumber'], { unique: true }) -@Index(['bookingId']) -@Index(['inventoryId']) -@Index(['status']) -export class WarehouseFeeInvoice extends BaseEntity { - @Column({ name: 'invoice_number', type: 'varchar', length: 40, unique: true }) - invoiceNumber!: string; - - @Column({ name: 'booking_id', type: 'uuid', nullable: true }) - bookingId?: string | null; - - @Column({ name: 'customer_id', type: 'uuid', nullable: true }) - customerId?: string | null; - - @Column({ name: 'inventory_id', type: 'uuid' }) - inventoryId!: string; - - @Column({ name: 'facility_id', type: 'uuid', nullable: true }) - facilityId?: string | null; - - @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) - warehouseId?: string | null; - - @Column({ name: 'yard_id', type: 'uuid', nullable: true }) - yardId?: string | null; - - @Column({ name: 'zone_id', type: 'uuid', nullable: true }) - zoneId?: string | null; - - @Column({ name: 'invoice_type', type: 'varchar', length: 32, default: 'MIXED_WAREHOUSE_FEES' }) - invoiceType!: WarehouseInvoiceType; - - @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) - status!: WarehouseInvoiceStatus; - - @Column({ name: 'subtotal_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - subtotalAmount!: number; - - @Column({ name: 'tax_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - taxAmount!: number; - - @Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - totalAmount!: number; - - @Column({ name: 'paid_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - paidAmount!: number; - - @Column({ name: 'balance_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - balanceAmount!: number; - - @Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' }) - currency!: string; - - /** Charge window covered by this invoice — used to allow a later invoice for a new period. */ - @Column({ name: 'period_start', type: 'timestamptz', nullable: true }) - periodStart?: Date | null; - - @Column({ name: 'period_end', type: 'timestamptz', nullable: true }) - periodEnd?: Date | null; - - @Column({ name: 'issued_at', type: 'timestamptz', nullable: true }) - issuedAt?: Date | null; - - @Column({ name: 'due_date', type: 'timestamptz', nullable: true }) - dueDate?: Date | null; - - @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) - paidAt?: Date | null; - - @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) - cancelledAt?: Date | null; - - @Column({ name: 'payments', type: 'jsonb', default: () => "'[]'" }) - payments!: WarehouseInvoicePayment[]; - - @Column({ name: 'notes', type: 'text', nullable: true }) - notes?: string | null; -} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts deleted file mode 100644 index 5b5df396e..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { BaseRepository } from '@edr/api-common'; -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; - -import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity'; - -@Injectable() -export class WarehouseFeeInvoiceItemRepository extends BaseRepository { - constructor(@InjectRepository(WarehouseFeeInvoiceItem) repository: Repository) { - super(repository); - } -} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts deleted file mode 100644 index 97328f46d..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { BaseRepository } from '@edr/api-common'; -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; - -import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity'; - -@Injectable() -export class WarehouseFeeInvoiceRepository extends BaseRepository { - constructor(@InjectRepository(WarehouseFeeInvoice) repository: Repository) { - super(repository); - } -} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 904728251..9b349181d 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -1,22 +1,23 @@ import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Freight } from '@edr/types'; import { DataSource } from 'typeorm'; +import { BillingService, InvoiceLineInput } from '../billing/billing.service'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { InvoiceDocumentModel, InvoiceDocumentService, } from '../billing/documents/invoice-document.service'; -import { nextDailyInvoiceNumber } from '../billing/invoice-numbering.util'; -import { applySettlement } from '../billing/invoice-settlement.util'; import { NotificationsService } from '../notifications/notifications.service'; +import { WarehouseFeeService } from './warehouse-fee.service'; import { - WarehouseFeeInvoice, + WarehouseFeeInvoiceView, + WarehouseFeeType, + WarehouseInvoiceItemView, WarehouseInvoiceStatus, WarehouseInvoiceType, -} from './entities/warehouse-fee-invoice.entity'; -import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity'; -import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository'; -import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository'; -import { WarehouseFeeService } from './warehouse-fee.service'; +} from './warehouse-invoice.types'; interface GenerateOptions { confirmZero?: boolean; @@ -32,9 +33,20 @@ export interface PayInvoiceDto { driverPhone?: string; } -/** Invoices that still owe money and therefore block terminal release. */ -const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID']; -const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', 'PAID']; +/** Warehouse fee invoices live in the global billing system under this source. */ +const SOURCE = Freight.InvoiceSource.Warehouse; +/** Document number prefix kept for warehouse fee invoices (e.g. `WHF-20260630-00001`). */ +const NUMBER_CODE = 'WHF'; + +/** Global statuses that still owe money and therefore block terminal release. */ +const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [ + Freight.InvoiceStatus.Issued, + Freight.InvoiceStatus.Pending, + Freight.InvoiceStatus.PartiallyPaid, + Freight.InvoiceStatus.Overdue, +]; +/** Global statuses considered an "active" invoice for per-inventory dedup. */ +const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [...BLOCKING_STATUSES, Freight.InvoiceStatus.Paid]; export interface InvoiceDocumentDetails { bookingReference: string | null; @@ -50,28 +62,75 @@ export interface InvoiceDocumentDetails { zoneName: string | null; } -export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial; +export type WarehouseFeeInvoiceDetail = WarehouseFeeInvoiceView & + Partial & { items: WarehouseInvoiceItemView[] }; +/** The warehouse-specific columns derived from the linked inventory item. */ +interface InventoryContext { + bookingId: string | null; + facilityId: string | null; + warehouseId: string | null; + yardId: string | null; + zoneId: string | null; + periodStart: Date | null; +} + +/** Source fields a view is projected from — satisfied by the global {@link Invoice}. */ +interface ViewSource { + id: string; + invoiceNumber: string; + companyId: string; + sourceId: string; + type: string; + status: Freight.InvoiceStatus | string; + subtotalAmount: number | string; + taxAmount: number | string; + totalAmount: number | string; + paidAmount: number | string; + balanceAmount: number | string; + currency: string; + issuedAt?: Date | null; + dueAt?: Date | null; + paidAt?: Date | null; + createdAt: Date; + updatedAt: Date; + payments?: Array<{ + amount: number | string; + method?: string | null; + reference?: string | null; + paidAt: string; + }> | null; +} + +/** + * Thin warehouse layer over the central {@link BillingService}. Warehouse fee + * invoices are global `Invoice` rows (`source = warehouse`, `sourceId = + * inventoryId`); this service owns only the warehouse-specific concerns — + * computing fees, per-inventory dedup, release-blocking, SMS notifications, the + * sealed PDF, and reshaping the global invoice back into the historical + * `WarehouseFeeInvoice` JSON the portal/backoffice expect. All money, numbering, + * status, and payment math live in billing. + */ @Injectable() export class WarehouseInvoiceService { private readonly logger = new Logger(WarehouseInvoiceService.name); constructor( private readonly dataSource: DataSource, - private readonly invoiceRepository: WarehouseFeeInvoiceRepository, - private readonly itemRepository: WarehouseFeeInvoiceItemRepository, - private readonly feeService: WarehouseFeeService, + private readonly billing: BillingService, private readonly invoiceDocuments: InvoiceDocumentService, + private readonly feeService: WarehouseFeeService, private readonly notifications: NotificationsService, ) {} // ── Generation ─────────────────────────────────────────────────────────── - async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise { + async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise { const [item] = await this.dataSource.query( `SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt", w.facility_id AS "facilityId", - b.company_id AS "customerId", b.freight_type AS "freightType" + b.company_id AS "companyId", b.company_profile_id AS "companyProfileId", + b.freight_type AS "freightType" FROM freight.warehouse_inventory inv LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id LEFT JOIN freight.bookings b ON b.id = inv.booking_id @@ -80,9 +139,16 @@ export class WarehouseInvoiceService { ); if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`); + // Routing through the global invoice requires a billable company + profile, + // both of which come from the inventory's booking. + if (!item.companyId || !item.companyProfileId) { + throw new BadRequestException( + 'Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).', + ); + } + // Dedup: only one active (non-cancelled) invoice per inventory item. - const active = await this.invoiceRepository.findAll({ where: { inventoryId } }); - if (active.some((inv) => ACTIVE_STATUSES.includes(inv.status))) { + if (await this.hasActiveInvoice(inventoryId)) { throw new ConflictException( 'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.', ); @@ -117,9 +183,7 @@ export class WarehouseInvoiceService { }; }); - const subtotal = items.reduce((s, i) => s + i.amount, 0); - const total = subtotal; // tax model can be layered on later - + const total = items.reduce((s, i) => s + i.amount, 0); if (total <= 0 && !opts.confirmZero) { throw new BadRequestException('No payable warehouse fee found for this item.'); } @@ -129,58 +193,77 @@ export class WarehouseInvoiceService { const invoiceType: WarehouseInvoiceType = hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE'; - const currency = billingCurrency; - const now = new Date(); - const periodEnd = previews[0] ? new Date(previews[0].endDate) : now; + const lines: InvoiceLineInput[] = items.map((it) => ({ + chargeType: it.feeType, + description: it.description, + quantity: it.quantity, + unitRate: it.unitRate, + amount: it.amount, + currency: it.currency, + metadata: { + feeRuleId: it.feeRuleId ?? null, + chargeableDays: it.chargeableDays ?? null, + freeDays: it.freeDays ?? null, + }, + })); - const invoice = await this.invoiceRepository.create({ - invoiceNumber: await this.nextInvoiceNumber(), - bookingId: item.bookingId ?? null, - customerId: item.customerId ?? null, - inventoryId, - facilityId: item.facilityId ?? null, - warehouseId: item.warehouseId ?? null, - yardId: item.yardId ?? null, - zoneId: item.zoneId ?? null, - invoiceType, - status: 'ISSUED', - subtotalAmount: subtotal, - taxAmount: 0, - totalAmount: total, - paidAmount: 0, - balanceAmount: total, - currency, - periodStart: item.arrivedAt ?? null, - periodEnd, - issuedAt: now, - payments: [], - notes: opts.performedBy ? `Generated by ${opts.performedBy}` : null, + const invoice = await this.billing.generateInvoice({ + source: SOURCE, + sourceId: inventoryId, + type: invoiceType, + companyId: item.companyId, + companyProfileId: item.companyProfileId, + currency: billingCurrency, + lines, + status: Freight.InvoiceStatus.Issued, + numberCode: NUMBER_CODE, }); - for (const it of items) { - await this.itemRepository.create({ invoiceId: invoice.id, ...it }); - } - - const saved = await this.findById(invoice.id); - await this.notifyWarehouseFeeIssued(saved); - return saved; - } - - /** WHF-YYYYMMDD-00001 — sequential per day (shared billing numbering). */ - private nextInvoiceNumber(): Promise { - return nextDailyInvoiceNumber(this.dataSource, { - table: 'freight.warehouse_fee_invoices', - code: 'WHF', - }); + const detail = await this.findById(invoice.id); + await this.notifyWarehouseFeeIssued(detail); + return detail; } // ── Reads ──────────────────────────────────────────────────────────────── - async findById(id: string): Promise { - const invoice = await this.invoiceRepository.findById(id); - if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); - const items = await this.itemRepository.findAll({ where: { invoiceId: id } }); + async findById(id: string): Promise { + const invoice = await this.loadWarehouseInvoice(id); + const ctx = await this.getInventoryContext(invoice.sourceId); const details = await this.getInvoiceDocumentDetails(invoice); - return { ...invoice, ...details, items } as WarehouseFeeInvoiceWithDisplay & { items: unknown[] }; + const items = invoice.lines.map((l) => this.lineToItem(l)); + return { ...this.buildView(invoice, ctx), ...details, items }; + } + + listForInventory(inventoryId: string): Promise { + return this.queryViews('AND i.source_id = $1', [inventoryId]); + } + + listForBooking(bookingId: string): Promise { + return this.queryViews('AND inv.booking_id = $1', [bookingId]); + } + + async findAll( + filter: Partial< + Pick< + WarehouseFeeInvoiceView, + 'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId' + > + >, + ): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + const add = (sql: (p: string) => string, value: unknown) => { + params.push(value); + conditions.push(sql(`$${params.length}`)); + }; + + if (filter.status) add((p) => `i.status::text = ${p}`, this.toGlobalStatus(filter.status as WarehouseInvoiceStatus)); + if (filter.invoiceType) add((p) => `i.type = ${p}`, filter.invoiceType); + if (filter.customerId) add((p) => `i.company_id = ${p}`, filter.customerId); + if (filter.warehouseId) add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId); + if (filter.facilityId) add((p) => `w.facility_id = ${p}`, filter.facilityId); + if (filter.bookingId) add((p) => `inv.booking_id = ${p}`, filter.bookingId); + + return this.queryViews(conditions.map((c) => `AND ${c}`).join(' '), params); } async document(id: string): Promise<{ filename: string; buffer: Buffer }> { @@ -196,20 +279,219 @@ export class WarehouseInvoiceService { return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT')); } - /** Map a warehouse fee invoice (with display details + items) onto the shared document model. */ + // ── State changes ──────────────────────────────────────────────────────── + async cancel(id: string): Promise { + const invoice = await this.loadWarehouseInvoice(id); + if (invoice.status === Freight.InvoiceStatus.Paid) { + throw new BadRequestException('A paid invoice cannot be cancelled.'); + } + await this.billing.cancelInvoice(id); + return this.findById(id); + } + + /** Record a payment against the invoice (delegates settlement to billing). */ + async pay(id: string, dto: PayInvoiceDto): Promise { + // Guard that this is a warehouse invoice before recording (404 otherwise). + await this.loadWarehouseInvoice(id); + await this.billing.recordPayment(id, { + amount: dto.amount, + method: dto.method ?? null, + reference: dto.reference ?? null, + metadata: + dto.driverName || dto.driverPhone + ? { driverName: dto.driverName ?? null, driverPhone: dto.driverPhone ?? null } + : null, + }); + const detail = await this.findById(id); + await this.notifyWarehouseFeePayment(detail, dto); + return detail; + } + + // ── Release blocking ────────────────────────────────────────────────────── + /** Returns the first unpaid invoice that blocks terminal release, or null. */ + async findBlockingInvoice(inventoryId: string): Promise { + const blocking = await this.queryViews( + `AND i.source_id = $1 AND i.status::text = ANY($2::text[])`, + [inventoryId, BLOCKING_STATUSES], + ); + return blocking[0] ?? null; + } + + async assertClearanceAllowed(inventoryId: string): Promise { + const invoices = await this.queryViews('AND i.source_id = $1', [inventoryId]); + const blocking = invoices.find((inv) => inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID'); + if (blocking) { + throw new BadRequestException( + `Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`, + ); + } + + if (invoices.some((inv) => inv.status === 'PAID')) return; + + const previews = await this.feeService.previewForInventory(inventoryId, 'USD'); + const payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0); + if (payableAmount > 0) { + throw new BadRequestException( + 'Generate and fully pay the warehouse demurrage/storage invoice before terminal release.', + ); + } + } + + // ── Internal: loading & projection ───────────────────────────────────────── + + /** Load a global invoice (+lines) and assert it is a warehouse fee invoice. */ + private async loadWarehouseInvoice(id: string): Promise { + const invoice = await this.billing.findById(id); + if (invoice.source !== SOURCE) { + throw new NotFoundException(`Invoice ${id} not found`); + } + return invoice; + } + + private async hasActiveInvoice(inventoryId: string): Promise { + const [row] = await this.dataSource.query( + `SELECT 1 + FROM freight.invoices + WHERE source = $1 AND source_id = $2 AND status::text = ANY($3::text[]) AND deleted_at IS NULL + LIMIT 1`, + [SOURCE, inventoryId, ACTIVE_STATUSES], + ); + return Boolean(row); + } + + /** + * Project warehouse-source global invoices into the historical view, joined to + * their inventory item for the typed FKs. Powers every list/filter read. + */ + private async queryViews(extraWhere: string, params: unknown[]): Promise { + const rows = await this.dataSource.query( + `SELECT i.id, i.invoice_number AS "invoiceNumber", i.company_id AS "companyId", + i.source_id AS "sourceId", i.type, i.status, + i.subtotal_amount AS "subtotalAmount", i.tax_amount AS "taxAmount", + i.total_amount AS "totalAmount", i.paid_amount AS "paidAmount", + i.balance_amount AS "balanceAmount", i.currency, i.payments, + i.issued_at AS "issuedAt", i.due_at AS "dueAt", i.paid_at AS "paidAt", + i.created_at AS "createdAt", i.updated_at AS "updatedAt", + inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", + inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart", + w.facility_id AS "facilityId" + FROM freight.invoices i + LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id AND inv.deleted_at IS NULL + LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id + WHERE i.source = $${params.length + 1} AND i.deleted_at IS NULL ${extraWhere} + ORDER BY i.created_at DESC`, + [...params, SOURCE], + ); + + return (rows as Array).map((row) => + this.buildView(row, { + bookingId: row.bookingId ?? null, + facilityId: row.facilityId ?? null, + warehouseId: row.warehouseId ?? null, + yardId: row.yardId ?? null, + zoneId: row.zoneId ?? null, + periodStart: row.periodStart ?? null, + }), + ); + } + + /** Reshape a global invoice (+ derived inventory context) into the warehouse view. */ + private buildView(inv: ViewSource, ctx: InventoryContext): WarehouseFeeInvoiceView { + const status = this.toWarehouseStatus(inv.status); + return { + id: inv.id, + invoiceNumber: inv.invoiceNumber, + bookingId: ctx.bookingId, + customerId: inv.companyId ?? null, + inventoryId: inv.sourceId, + facilityId: ctx.facilityId, + warehouseId: ctx.warehouseId, + yardId: ctx.yardId, + zoneId: ctx.zoneId, + invoiceType: inv.type as WarehouseInvoiceType, + status, + subtotalAmount: Number(inv.subtotalAmount), + taxAmount: Number(inv.taxAmount), + totalAmount: Number(inv.totalAmount), + paidAmount: Number(inv.paidAmount), + balanceAmount: Number(inv.balanceAmount), + currency: inv.currency, + periodStart: ctx.periodStart, + // No standalone period column once centralized: the charge window ends at + // issuance, so `issuedAt` is the period end. + periodEnd: inv.issuedAt ?? null, + issuedAt: inv.issuedAt ?? null, + dueDate: inv.dueAt ?? null, + paidAt: inv.paidAt ?? null, + cancelledAt: status === 'CANCELLED' ? inv.updatedAt : null, + payments: (inv.payments ?? []).map((p) => ({ + amount: Number(p.amount), + method: p.method ?? null, + reference: p.reference ?? null, + paidAt: p.paidAt, + })), + notes: null, + createdAt: inv.createdAt, + updatedAt: inv.updatedAt, + }; + } + + private lineToItem(line: InvoiceLine): WarehouseInvoiceItemView { + const meta = (line.metadata ?? {}) as { + feeRuleId?: string | null; + chargeableDays?: number | null; + freeDays?: number | null; + }; + return { + feeRuleId: meta.feeRuleId ?? null, + feeType: line.chargeType as WarehouseFeeType, + description: line.description ?? '', + quantity: Number(line.quantity), + unitRate: Number(line.unitRate), + amount: Number(line.amount), + currency: line.currency, + chargeableDays: meta.chargeableDays ?? null, + freeDays: meta.freeDays ?? null, + }; + } + + private toWarehouseStatus(status: Freight.InvoiceStatus | string): WarehouseInvoiceStatus { + switch (status) { + case Freight.InvoiceStatus.Draft: + return 'DRAFT'; + case Freight.InvoiceStatus.PartiallyPaid: + return 'PARTIALLY_PAID'; + case Freight.InvoiceStatus.Paid: + return 'PAID'; + case Freight.InvoiceStatus.Cancelled: + case Freight.InvoiceStatus.Refunded: + return 'CANCELLED'; + default: + // Issued / Pending / Overdue → an issued, still-owed invoice. + return 'ISSUED'; + } + } + + private toGlobalStatus(status: WarehouseInvoiceStatus): Freight.InvoiceStatus { + switch (status) { + case 'DRAFT': + return Freight.InvoiceStatus.Draft; + case 'PARTIALLY_PAID': + return Freight.InvoiceStatus.PartiallyPaid; + case 'PAID': + return Freight.InvoiceStatus.Paid; + case 'CANCELLED': + return Freight.InvoiceStatus.Cancelled; + default: + return Freight.InvoiceStatus.Issued; + } + } + + /** Map a warehouse fee invoice view onto the shared document model. */ private toDocumentModel( - invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] }, + invoice: WarehouseFeeInvoiceDetail, kind: 'INVOICE' | 'RECEIPT', ): InvoiceDocumentModel { - const items = invoice.items as Array<{ - description?: string; - feeType?: string; - quantity?: number; - unitRate?: number; - amount?: number; - currency?: string; - chargeableDays?: number | null; - }>; const lastPayment = [...(invoice.payments ?? [])].pop(); const date = (value: unknown) => value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null; @@ -241,7 +523,7 @@ export class WarehouseInvoiceService { }, ], categoryHeader: 'Fee type', - lines: items.map((item) => ({ + lines: invoice.items.map((item) => ({ description: item.description ?? null, category: item.feeType ?? null, quantity: item.quantity ?? item.chargeableDays ?? 0, @@ -259,92 +541,14 @@ export class WarehouseInvoiceService { }; } - listForInventory(inventoryId: string): Promise { - return this.invoiceRepository.findAll({ where: { inventoryId }, order: { createdAt: 'DESC' } }); - } - - listForBooking(bookingId: string): Promise { - return this.invoiceRepository.findAll({ where: { bookingId }, order: { createdAt: 'DESC' } }); - } - - findAll(filter: Partial>): Promise { - const where = Object.fromEntries(Object.entries(filter).filter(([, v]) => v != null)); - return this.invoiceRepository.findAll({ where, order: { createdAt: 'DESC' } }); - } - - // ── State changes ──────────────────────────────────────────────────────── - async cancel(id: string): Promise { - const invoice = await this.invoiceRepository.findById(id); - if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); - if (invoice.status === 'PAID') throw new BadRequestException('A paid invoice cannot be cancelled.'); - const updated = await this.invoiceRepository.update(id, { status: 'CANCELLED', cancelledAt: new Date() }); - return updated as WarehouseFeeInvoice; - } - - /** Record a payment against the invoice and sync status (links to existing payment flow). */ - async pay(id: string, dto: PayInvoiceDto): Promise { - const invoice = await this.invoiceRepository.findById(id); - if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); - if (invoice.status === 'CANCELLED') throw new BadRequestException('Cannot pay a cancelled invoice.'); - if (invoice.status === 'PAID') throw new BadRequestException('Invoice is already fully paid.'); - if (!(dto.amount > 0)) throw new BadRequestException('Payment amount must be greater than zero.'); - - const { paidAmount, balanceAmount, fullyPaid } = applySettlement( - invoice.totalAmount, - invoice.paidAmount, - dto.amount, - ); - - const payments = [ - ...(invoice.payments ?? []), - { amount: dto.amount, method: dto.method ?? null, reference: dto.reference ?? null, paidAt: new Date().toISOString() }, - ]; - - const updated = await this.invoiceRepository.update(id, { - paidAmount, - balanceAmount, - status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID', - paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null, - payments, - }); - const paidInvoice = updated as WarehouseFeeInvoice; - await this.notifyWarehouseFeePayment(paidInvoice, dto); - return paidInvoice; - } - - // ── Release blocking ────────────────────────────────────────────────────── - /** Returns the first unpaid invoice that blocks terminal release, or null. */ - async findBlockingInvoice(inventoryId: string): Promise { - const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } }); - return invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)) ?? null; - } - - async assertClearanceAllowed(inventoryId: string): Promise { - const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } }); - const blocking = invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)); - if (blocking) { - throw new BadRequestException( - `Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`, - ); - } - - if (invoices.some((inv) => inv.status === 'PAID')) return; - - const previews = await this.feeService.previewForInventory(inventoryId, 'USD'); - const payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0); - if (payableAmount > 0) { - throw new BadRequestException( - 'Generate and fully pay the warehouse demurrage/storage invoice before terminal release.', - ); - } - } - - private async getInvoiceDocumentDetails(invoice: WarehouseFeeInvoice): Promise { + /** Warehouse-specific display details, derived from the linked inventory item. */ + private async getInvoiceDocumentDetails(invoice: ViewSource): Promise { const [row] = await this.dataSource.query( `SELECT b.reference AS "bookingReference", company.name AS "customerName", COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference", inv.status AS "inventoryStatus", + inv.release_date AS "releaseDate", COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription", CONCAT_WS( @@ -355,16 +559,10 @@ export class WarehouseInvoiceService { ) AS "inventoryInfo", wh.name AS "warehouseName", yard.name AS "yardName", - zone.name AS "zoneName", - CASE - WHEN inv.release_date IS NOT NULL THEN 'RELEASE ISSUED' - WHEN $2 = 'PAID' THEN 'FEE PAID - READY FOR RELEASE' - ELSE 'PENDING PAYMENT' - END AS "clearanceStatus" - FROM freight.warehouse_fee_invoices fee - LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL - LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL - LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id) + zone.name AS "zoneName" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL LEFT JOIN freight.booking_container booking_container ON ( booking_container.booking_id = b.id @@ -372,14 +570,21 @@ export class WarehouseInvoiceService { ) 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.warehouses wh ON wh.id = fee.warehouse_id - LEFT JOIN freight.warehouse_yards yard ON yard.id = fee.yard_id - LEFT JOIN freight.warehouse_zones zone ON zone.id = fee.zone_id - WHERE fee.id = $1 + 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 + WHERE inv.id = $1 AND inv.deleted_at IS NULL LIMIT 1`, - [invoice.id, invoice.status], + [invoice.sourceId], ); + const fullyPaid = this.toWarehouseStatus(invoice.status) === 'PAID'; + const clearanceStatus = row?.releaseDate + ? 'RELEASE ISSUED' + : fullyPaid + ? 'FEE PAID - READY FOR RELEASE' + : 'PENDING PAYMENT'; + return { bookingReference: row?.bookingReference ?? null, customerName: row?.customerName ?? null, @@ -391,11 +596,33 @@ export class WarehouseInvoiceService { warehouseName: row?.warehouseName ?? null, yardName: row?.yardName ?? null, zoneName: row?.zoneName ?? null, - clearanceStatus: row?.clearanceStatus ?? (invoice.status === 'PAID' ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT'), + clearanceStatus, }; } - private async getInvoiceNotificationContacts(invoice: WarehouseFeeInvoice): Promise<{ + private async getInventoryContext(inventoryId: string): Promise { + const [row] = await this.dataSource.query( + `SELECT inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", + inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart", + w.facility_id AS "facilityId" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id + WHERE inv.id = $1 AND inv.deleted_at IS NULL + LIMIT 1`, + [inventoryId], + ); + return { + bookingId: row?.bookingId ?? null, + facilityId: row?.facilityId ?? null, + warehouseId: row?.warehouseId ?? null, + yardId: row?.yardId ?? null, + zoneId: row?.zoneId ?? null, + periodStart: row?.periodStart ?? null, + }; + } + + // ── Notifications ────────────────────────────────────────────────────────── + private async getInvoiceNotificationContacts(inventoryId: string): Promise<{ bookingReference: string | null; customerName: string | null; customerPhone: string | null; @@ -417,10 +644,9 @@ export class WarehouseInvoiceService { COALESCE(last_driver.phone_number, first_driver.phone_number) AS "driverPhone", COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription" - FROM freight.warehouse_fee_invoices fee - LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL - LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL - LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id) + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL LEFT JOIN freight.booking_container booking_container ON ( booking_container.booking_id = b.id @@ -446,9 +672,9 @@ export class WarehouseInvoiceService { ) latest_first_mile ON true LEFT JOIN freight.vehicles first_vehicle ON first_vehicle.id = latest_first_mile.vehicle_id LEFT JOIN freight.drivers first_driver ON first_driver.id = first_vehicle.assigned_driver_id - WHERE fee.id = $1 + WHERE inv.id = $1 AND inv.deleted_at IS NULL LIMIT 1`, - [invoice.id], + [inventoryId], ); return { @@ -472,8 +698,8 @@ export class WarehouseInvoiceService { } } - private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoice): Promise { - const contacts = await this.getInvoiceNotificationContacts(invoice); + private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoiceView): Promise { + const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId); const customerName = contacts.customerName?.trim() || 'Customer'; const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; const cargo = contacts.containerNumber || contacts.cargoDescription; @@ -486,8 +712,8 @@ export class WarehouseInvoiceService { await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`); } - private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoice, dto: PayInvoiceDto): Promise { - const contacts = await this.getInvoiceNotificationContacts(invoice); + private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoiceView, dto: PayInvoiceDto): Promise { + const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId); const customerName = contacts.customerName?.trim() || 'Customer'; const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; const statusText = diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts new file mode 100644 index 000000000..e201241ba --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts @@ -0,0 +1,88 @@ +/** + * Public shapes for warehouse fee invoices. + * + * Warehouse fee invoices are no longer a standalone table — they are global + * `Invoice` rows (`source = "warehouse"`, `sourceId = inventoryId`) owned by the + * central {@link BillingService}. These types preserve the warehouse-facing API + * contract: `WarehouseInvoiceService` reshapes the global invoice (+ lines + + * inventory context) back into the historical `WarehouseFeeInvoice` JSON so the + * portal/backoffice stay untouched. + */ + +export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const; +export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number]; + +export const WAREHOUSE_INVOICE_STATUSES = [ + 'DRAFT', + 'ISSUED', + 'PARTIALLY_PAID', + 'PAID', + 'CANCELLED', +] as const; +export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number]; + +export const WAREHOUSE_FEE_TYPES = [ + 'CONTAINER_DEMURRAGE', + 'BULK_DEMURRAGE', + 'STORAGE_FEE', + 'HANDLING_FEE', +] as const; +export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number]; + +/** A single recorded payment against a warehouse fee invoice (history). */ +export interface WarehouseInvoicePayment { + amount: number; + method?: string | null; + reference?: string | null; + paidAt: string; +} + +/** A billed warehouse fee line, projected from a global `InvoiceLine`. */ +export interface WarehouseInvoiceItemView { + feeRuleId: string | null; + feeType: WarehouseFeeType; + description: string; + quantity: number; + unitRate: number; + amount: number; + currency: string; + chargeableDays: number | null; + freeDays: number | null; +} + +/** + * The warehouse-facing invoice header — same field set the old + * `WarehouseFeeInvoice` entity exposed, projected from a global `Invoice`. The + * typed FKs (`bookingId`/`facilityId`/`warehouseId`/`yardId`/`zoneId`) and the + * charge `period` are derived from the linked inventory item; `customerId` is the + * billed company; `invoiceType` is the invoice `type`. + */ +export interface WarehouseFeeInvoiceView { + id: string; + invoiceNumber: string; + bookingId: string | null; + customerId: string | null; + inventoryId: string; + facilityId: string | null; + warehouseId: string | null; + yardId: string | null; + zoneId: string | null; + invoiceType: WarehouseInvoiceType; + status: WarehouseInvoiceStatus; + subtotalAmount: number; + taxAmount: number; + totalAmount: number; + paidAmount: number; + balanceAmount: number; + currency: string; + periodStart: Date | null; + periodEnd: Date | null; + issuedAt: Date | null; + dueDate: Date | null; + paidAt: Date | null; + cancelledAt: Date | null; + payments: WarehouseInvoicePayment[]; + notes: string | null; + createdAt: Date; + updatedAt: Date; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index a7ce68319..b871d2a36 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BillingModule } from '../billing/billing.module'; import { DocumentsModule } from '../billing/documents/documents.module'; import { FilesModule } from '../files/files.module'; import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module'; @@ -11,8 +12,6 @@ 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'; -import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity'; import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; import { WarehouseInventory } from './entities/warehouse-inventory.entity'; @@ -39,8 +38,6 @@ import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.r import { WarehouseAllocationService } from './warehouse-allocation.service'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; import { WarehouseFeeService } from './warehouse-fee.service'; -import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository'; -import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository'; import { WarehouseInvoiceController } from './warehouse-invoice.controller'; import { WarehouseInvoiceService } from './warehouse-invoice.service'; import { WarehouseRulesController } from './warehouse-rules.controller'; @@ -68,9 +65,8 @@ import { WarehousesService } from './warehouses.service'; WarehouseInspectionReport, WarehouseAllocationRule, WarehouseFeeRule, - WarehouseFeeInvoice, - WarehouseFeeInvoiceItem, ]), + BillingModule, DocumentsModule, FilesModule, InterchangeDocumentsModule, @@ -104,8 +100,6 @@ import { WarehousesService } from './warehouses.service'; WarehouseInspectionRepository, WarehouseAllocationRuleRepository, WarehouseFeeRuleRepository, - WarehouseFeeInvoiceRepository, - WarehouseFeeInvoiceItemRepository, WarehousesService, WarehouseYardsService, WarehouseZonesService, diff --git a/apps/edr-freight-api/tsconfig.json b/apps/edr-freight-api/tsconfig.json index 467c474ee..52598cb95 100644 --- a/apps/edr-freight-api/tsconfig.json +++ b/apps/edr-freight-api/tsconfig.json @@ -7,6 +7,7 @@ "noEmit": false, "incremental": true, "tsBuildInfoFile": "./.tsbuildinfo", + "preserveWatchOutput": true, "module": "node16", "moduleResolution": "node16" }, From 0056dec9248f73ce820b1de3cf54d74ec817a549 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 30 Jun 2026 13:28:14 +0000 Subject: [PATCH 27/54] style: clean up the invoice and setup event for warehouse. --- .../modules/billing/billing.service.spec.ts | 2 +- .../src/modules/billing/billing.service.ts | 16 +++---- .../src/modules/payment/payment.controller.ts | 13 +----- .../src/modules/payment/payment.service.ts | 46 ++----------------- .../warehouses/warehouse-invoice.service.ts | 21 +++++++-- 5 files changed, 30 insertions(+), 68 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index e52dfafa1..61597264b 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -89,7 +89,7 @@ describe("BillingService.generateInvoice", () => { expect(invoice.sourceId).toBe("booking-1"); expect(invoice.totalAmount).toBe(1500); expect(invoice.issuedAt).toBeInstanceOf(Date); - expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/); + expect(invoice.invoiceNumber).toMatch(/^INV-\d{8}-00001$/); expect(savedLines).toHaveLength(2); }); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index e4389e7cf..f1b58ad5e 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -96,11 +96,6 @@ export interface GenerateInvoiceInput { * (default PENDING) stamps `issuedAt`. */ status?: Freight.InvoiceStatus; - /** - * Document number prefix for this source (e.g. `WHF` for warehouse fees); - * defaults to `FRT`. The daily sequence is allocated per prefix. - */ - numberCode?: string; } /** Payload broadcast on `${source}.invoice.`. */ @@ -665,11 +660,12 @@ export class BillingService { const result = await this.payment.initiate({ referenceId: sourceId, source: invoice.source, - // Gateway reference type derives from the invoice source by convention - // (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and - // the domain never supplies it. New sources add their uppercased value to - // the PaymentReferenceType enum. - referenceType: invoice.source.toUpperCase() as PaymentReferenceType, + // Freight payments settle under the generic SHIPMENT reference — how the + // payment service attributes them to the freight API. The payment ↔ invoice + // link is the intent id (`paymentId`); per-source post-payment reactions live + // in the domain via `${source}.invoice.paid`. Neither billing nor the payment + // service branches on a domain-specific reference type. + referenceType: PaymentReferenceType.SHIPMENT, orderRef: invoice.invoiceNumber, amountMinor: Math.round(Number(invoice.totalAmount)), currency: invoice.currency, diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index b1b269665..50856c3d7 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -6,8 +6,6 @@ import { ParseUUIDPipe, Query, Res, - Body, - Post, } from "@nestjs/common"; import { ApiTags, @@ -18,9 +16,9 @@ import { } from "@nestjs/swagger"; import { Response } from "express"; import { Public } from "@edr/api-common"; -import { BookingView, FreightAdmin } from "../../common/booking-guards"; +import { BookingView } from "../../common/booking-guards"; import { PaymentService } from "./payment.service"; -import { IntentStatusDto, RefundDto } from "./payments.dto"; +import { IntentStatusDto } from "./payments.dto"; @ApiTags("Payment") @Controller("payments") @@ -73,13 +71,6 @@ export class PaymentController { return this.paymentService.getIntentByBookingId(bookingId); } - @Post("refund") - @FreightAdmin() - @ApiOperation({ summary: "Refund a paid booking (staff/admin only)" }) - refund(@Body() dto: RefundDto) { - return this.paymentService.refund(dto); - } - @Get("receipt/:orderId") @Public() @ApiOperation({ summary: "Generate a payment receipt HTML page" }) diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 738a6d118..d92af7a3e 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -7,7 +7,6 @@ import { Logger, NotFoundException, } from "@nestjs/common"; -import { DataSource } from "typeorm"; import { PaymentEntity } from "./entities/payment.entity"; import { PaymentRepository } from "./payment.repository"; import { PaymentClientService } from "./payment-client.service"; @@ -16,7 +15,6 @@ import { BillingService } from "../billing/billing.service"; import * as fs from "fs"; import * as path from "path"; import * as Handlebars from "handlebars"; -import { Booking } from "../bookings/entities/booking.entity"; import { ClientAction, ProviderPaymentStatus } from "@edr/payment-providers"; import { @@ -29,7 +27,6 @@ import { InitiateResponseDto, IntentStatusDto, PaymentPlatformDto, - RefundDto, } from "./payments.dto"; /** Everything the gateway needs to open an intent. Amount/currency are supplied by @@ -96,7 +93,6 @@ export class PaymentService { private readonly logger = new Logger(PaymentService.name); constructor( - private readonly datasource: DataSource, private readonly paymentRepo: PaymentRepository, private readonly paymentClient: PaymentClientService, @Inject(forwardRef(() => BillingService)) @@ -404,34 +400,6 @@ export class PaymentService { ); } - async refund(dto: RefundDto) { - const intent = await this.paymentRepo.findOneBy({ - refId: dto.bookingId, - type: "booking", - }); - if (!intent || intent.status !== "success") { - throw new BadRequestException("No successful payment to refund"); - } - - // NOTE: refunding still mutates the booking directly — left intact pending - // the refund redesign. TODO: route refunds through billing.refundPayable + - // a `${source}.invoice.refunded` reaction, like settlement. - await this.datasource.transaction(async (mg) => { - await mg.update( - PaymentEntity, - { id: intent.id }, - { status: "refunded", refundedAt: new Date() }, - ); - await mg.update( - Booking, - { id: dto.bookingId }, - { paymentStatus: "FAILED", status: "CANCELLED" }, - ); - }); - - return { refunded: true, bookingId: dto.bookingId }; - } - async getActivePaymentByOrderIdAndMethod( orderId: string, method: PaymentEntity["method"], @@ -527,16 +495,10 @@ export class PaymentService { `Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`, ); - // When the intent references a booking, flip the booking itself paid. - // refId holds the booking id (the domain reference the intent opened with). - if (intent.referenceType === PaymentReferenceType.BOOKING) { - await this.datasource.manager.update( - Booking, - { id: intent.refId }, - { status: "PAID", paymentStatus: "PAID" }, - ); - } - // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); + // The payment service stays domain-agnostic: it settles the intent and + // lets billing settle the invoice (markIntentSucceeded → settleByPaymentId), + // which emits `${source}.invoice.paid`. Per-source advances (booking → PAID, + // warehouse → release, …) live in the domain services that listen for it. return { processed: true, alreadyFinalized }; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 9b349181d..6f7219781 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -1,8 +1,9 @@ import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; import { Freight } from '@edr/types'; import { DataSource } from 'typeorm'; -import { BillingService, InvoiceLineInput } from '../billing/billing.service'; +import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service'; import { Invoice } from '../billing/entities/invoice.entity'; import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { @@ -35,8 +36,6 @@ export interface PayInvoiceDto { /** Warehouse fee invoices live in the global billing system under this source. */ const SOURCE = Freight.InvoiceSource.Warehouse; -/** Document number prefix kept for warehouse fee invoices (e.g. `WHF-20260630-00001`). */ -const NUMBER_CODE = 'WHF'; /** Global statuses that still owe money and therefore block terminal release. */ const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [ @@ -216,7 +215,6 @@ export class WarehouseInvoiceService { currency: billingCurrency, lines, status: Freight.InvoiceStatus.Issued, - numberCode: NUMBER_CODE, }); const detail = await this.findById(invoice.id); @@ -307,6 +305,21 @@ export class WarehouseInvoiceService { return detail; } + /** + * Notify on online (gateway) settlement — the domain side-effect of a warehouse + * fee being paid through billing's payment flow. The counter {@link pay} path + * notifies inline (and carries driver details from the request), so this only + * handles gateway payments: those stamp the invoice `paymentId`, whereas a + * counter settlement leaves it null. Skipping null-`paymentId` events avoids + * double-notifying a counter payment that already sent its SMS. + */ + @OnEvent('warehouse.invoice.paid') + async onWarehouseInvoicePaid(payload: InvoiceEventPayload): Promise { + if (!payload.paymentId) return; + const detail = await this.findById(payload.invoiceId); + await this.notifyWarehouseFeePayment(detail, { amount: Number(detail.totalAmount) }); + } + // ── Release blocking ────────────────────────────────────────────────────── /** Returns the first unpaid invoice that blocks terminal release, or null. */ async findBlockingInvoice(inventoryId: string): Promise { From 6e81090f8c3e2f91688e6343c5e693042fdcad13 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Tue, 30 Jun 2026 16:45:51 +0300 Subject: [PATCH 28/54] Added optimization for search result and fix sea map --- .../src/modules/search/search.service.ts | 284 ++++++++---------- .../src/modules/segments/segments.service.ts | 90 ++++++ .../portal/src/app/booking/seats/page.tsx | 24 +- .../portal/src/components/AppHeader.tsx | 4 - 4 files changed, 229 insertions(+), 173 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index e14db6b0a..08687d291 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -9,6 +9,30 @@ import { Currency } from '@prisma/client'; const POINTS_TO_MINOR = 10; +// Shape returned by the heavy schedule include used throughout this service +type ScheduleWithIncludes = { + id: string; + routeId: string | null; + departureAt: Date; + arrivalAt: Date; + status: string; + train: any; + originStation: any; + destinationStation: any; + stopTimes: Array<{ stationId: string; sequence: number; plannedArrivalAt: Date | null; plannedDepartureAt: Date | null; station: any }>; + coachAssignments: Array<{ coach: { id: string; seats: any[]; coachType: { id: string; name: string; code: string; seatClasses: any[] } | null } }>; +}; + +const SCHEDULE_INCLUDE = { + train: true, + originStation: true, + destinationStation: true, + stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + coachAssignments: { + include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, + }, +} as const; + @Injectable() export class SearchService { constructor( @@ -124,7 +148,7 @@ export class SearchService { if (windowStart < now) windowStart.setTime(now.getTime()); const windowEnd = new Date(requestedDate); - windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); // exclusive upper bound + windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); const totalPassengers = adultCount + (childCount ?? 0); @@ -139,30 +163,16 @@ export class SearchService { ], stopTimes: { some: { stationId: originStationId } }, }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, - }, - }, + include: SCHEDULE_INCLUDE, orderBy: { departureAt: 'asc' }, }); - const results: any[] = []; - for (const schedule of schedules) { - const result = await this.buildScheduleResult( - schedule, - originStationId, - destinationStationId, - totalPassengers, - nationality, - ); - if (result) results.push(result); - } - return results; + const results = await Promise.all( + schedules.map(schedule => + this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality) + ) + ); + return results.filter(Boolean); } private async searchSchedules( @@ -185,29 +195,18 @@ export class SearchService { departureAt: { gte: date < now ? now : date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } }, }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, - }, - }, + include: SCHEDULE_INCLUDE, }); - const results: any[] = []; - for (const schedule of schedules) { - const result = await this.buildScheduleResult(schedule, originStationId, destinationStationId, totalPassengers, nationality); - if (result) results.push(result); - } - return results; + const results = await Promise.all( + schedules.map(schedule => + this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality) + ) + ); + return results.filter(Boolean); } // ── Transit search ───────────────────────────────────────────────────────── - // Finds pairs of schedules (leg1: origin→transit, leg2: transit→destination) - // where the passenger has between MIN_CONNECTION and MAX_CONNECTION minutes - // to change trains at the transit station. private readonly MIN_CONNECTION_MINUTES = 30; private readonly MAX_CONNECTION_MINUTES = 360; @@ -219,82 +218,67 @@ export class SearchService { childCount?: number, nationality?: string, ) { - // Find all stations that can serve as transit points: - // they must be a stop after origin on some schedule AND - // a stop before destination on another schedule on the same day. const [y, m, d] = dateStr.split('-').map(Number); const dayStart = new Date(y, m - 1, d, 0, 0, 0, 0); const dayEnd = new Date(y, m - 1, d + 1, 0, 0, 0, 0); + const leg2WindowEnd = new Date(dayEnd.getTime() + this.MAX_CONNECTION_MINUTES * 60_000); const totalPassengers = adultCount + (childCount ?? 0); - // Load all schedules on this date that pass through origin - const leg1Schedules = await this.prisma.trainSchedule.findMany({ - where: { - status: { in: ['SCHEDULED', 'BOARDING'] }, - departureAt: { gte: dayStart, lt: dayEnd }, - stopTimes: { some: { stationId: originStationId } }, - }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, + // Load leg1 and all potential leg2 candidates in one parallel round-trip + // instead of firing a separate DB query per transit stop. + const [leg1Schedules, allCandidates] = await Promise.all([ + this.prisma.trainSchedule.findMany({ + where: { + status: { in: ['SCHEDULED', 'BOARDING'] }, + departureAt: { gte: dayStart, lt: dayEnd }, + stopTimes: { some: { stationId: originStationId } }, }, - }, - }); + include: SCHEDULE_INCLUDE, + }), + this.prisma.trainSchedule.findMany({ + where: { + status: { in: ['SCHEDULED', 'BOARDING'] }, + departureAt: { gte: dayStart, lt: leg2WindowEnd }, + }, + include: SCHEDULE_INCLUDE, + }), + ]); const results: any[] = []; - for (const leg1 of leg1Schedules) { - const originStop = leg1.stopTimes.find((s: any) => s.stationId === originStationId); + for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) { + const originStop = leg1.stopTimes.find(s => s.stationId === originStationId); if (!originStop) continue; - // Every stop after origin on leg1 is a candidate transit station const candidateTransitStops = leg1.stopTimes.filter( - (s: any) => s.sequence > originStop.sequence, + s => s.sequence > originStop.sequence, ); for (const transitStop of candidateTransitStops) { - // leg1 must NOT already contain the final destination - const leg1HasDest = leg1.stopTimes.some((s: any) => s.stationId === destinationStationId); - if (leg1HasDest) continue; // direct route exists — already returned by searchSchedules + const leg1HasDest = leg1.stopTimes.some(s => s.stationId === destinationStationId); + if (leg1HasDest) continue; const transitStationId = transitStop.stationId; const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt; - // Find leg2 schedules departing from the transit station within the connection window, - // and reaching the final destination. Search up to the next calendar day to handle - // overnight connections. const connWindowStart = new Date(new Date(leg1ArrivalAt).getTime() + this.MIN_CONNECTION_MINUTES * 60_000); const connWindowEnd = new Date(new Date(leg1ArrivalAt).getTime() + this.MAX_CONNECTION_MINUTES * 60_000); - const leg2Schedules = await this.prisma.trainSchedule.findMany({ - where: { - status: { in: ['SCHEDULED', 'BOARDING'] }, - departureAt: { gte: connWindowStart, lte: connWindowEnd }, - stopTimes: { some: { stationId: transitStationId } }, - }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, - }, - }, + // Filter from pre-loaded candidates in memory — no extra DB query + const leg2Schedules = (allCandidates as ScheduleWithIncludes[]).filter(s => { + const dep = new Date(s.departureAt).getTime(); + return dep >= connWindowStart.getTime() + && dep <= connWindowEnd.getTime() + && s.stopTimes.some(st => st.stationId === transitStationId); }); for (const leg2 of leg2Schedules) { - const leg2TransitStop = leg2.stopTimes.find((s: any) => s.stationId === transitStationId); - const leg2DestStop = leg2.stopTimes.find((s: any) => s.stationId === destinationStationId); + const leg2TransitStop = leg2.stopTimes.find(s => s.stationId === transitStationId); + const leg2DestStop = leg2.stopTimes.find(s => s.stationId === destinationStationId); if (!leg2TransitStop || !leg2DestStop) continue; if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue; - // Build individual leg result objects (reuse existing per-schedule logic) const [leg1Result, leg2Result] = await Promise.all([ this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality), this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality), @@ -326,7 +310,6 @@ export class SearchService { displayCurrency, combinedMinFareMinor, combinedMinFareDisplay, - // Convenience top-level fields so round-trip filter can read them uniformly departureAt: leg1Result.departureAt, arrivalAt: leg2Result.arrivalAt, totalDurationMinutes: @@ -339,19 +322,37 @@ export class SearchService { return results; } - // Builds the same result shape as searchSchedules for a single schedule+leg, - // extracted so both direct and transit paths share identical output. private async buildScheduleResult( - schedule: any, + schedule: ScheduleWithIncludes, originStationId: string, destinationStationId: string, totalPassengers: number, nationality?: string, ) { - const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId); - const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId); + const originStop = schedule.stopTimes.find(s => s.stationId === originStationId); + const destStop = schedule.stopTimes.find(s => s.stationId === destinationStationId); if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null; + // Collect all valid seat IDs upfront for a single batch availability check + const allValidSeatIds = schedule.coachAssignments.flatMap(a => + a.coach.seats + .filter((s: any) => s.status !== 'BLOCKED' && s.seatNumber?.trim()) + .map((s: any) => s.id as string) + ); + + // Run availability batch and fare calculation in parallel + const [freeSeats, faresByClass] = await Promise.all([ + this.segmentsService.getFreeSeatIds( + schedule.id, + allValidSeatIds, + schedule.stopTimes, + originStop.sequence, + destStop.sequence, + ), + this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality), + ]); + + // Compute per-class availability using the pre-computed free seat set const availabilityByClass: Record = {}; for (const assignment of schedule.coachAssignments) { const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard']; @@ -362,8 +363,7 @@ export class SearchService { let count = 0; for (const seat of assignment.coach.seats) { if (seat.bedPosition !== bedPosition || seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; - const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence); - if (free) count++; + if (freeSeats.has(seat.id)) count++; } if (count > 0) { const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition)); @@ -374,15 +374,13 @@ export class SearchService { let available = 0; for (const seat of assignment.coach.seats) { if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; - const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence); - if (free) available++; + if (freeSeats.has(seat.id)) available++; } for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available; } } - const faresByClass = await this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality); - const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass); + const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass); const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; @@ -400,8 +398,8 @@ export class SearchService { durationMinutes: Math.round((new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000), status: schedule.status, stops: schedule.stopTimes - .filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) - .map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })), + .filter(st => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) + .map(st => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })), availabilityByClass, hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers), displayCurrency, @@ -500,46 +498,31 @@ export class SearchService { } private async calculateFaresForSegment( - schedule: any, + schedule: ScheduleWithIncludes, originStationId: string, destinationStationId: string, nationality?: string, ): Promise> { const displayCurrency = resolveCurrencyFromNationality(nationality); - const seatClassIds: string[] = Array.from( - new Set( - schedule.coachAssignments - .flatMap((a: any) => a.coach.coachType?.seatClasses || []) - .map((sc: any) => sc.id) - .filter((id: any) => id) - ) - ); - - if (seatClassIds.length === 0) { - console.log(`No seat classes assigned to schedule ${schedule.id}`); - return []; + // Use seat class data already loaded in the schedule include — avoids an extra seatClass.findMany + const seatClassMap = new Map(); + for (const a of schedule.coachAssignments) { + for (const sc of (a.coach.coachType?.seatClasses ?? [])) { + if (sc.isActive && !seatClassMap.has(sc.id)) seatClassMap.set(sc.id, sc); + } } + const seatClasses = Array.from(seatClassMap.values()) + .sort((a: any, b: any) => a.baseFareMinor - b.baseFareMinor); - const seatClasses = await this.prisma.seatClass.findMany({ - where: { - isActive: true, - id: { in: seatClassIds } - }, - orderBy: { baseFareMinor: 'asc' }, - }); - - if (seatClasses.length === 0) { - console.log(`No active seat classes for schedule ${schedule.id}`); - return []; - } + if (seatClasses.length === 0) return []; if (schedule.routeId) { const results = await Promise.all( seatClasses.map(async (sc) => { try { const fare = await this.fareEngine.calculate({ - routeId: schedule.routeId, + routeId: schedule.routeId!, originStationId, destinationStationId, seatClassId: sc.id, @@ -552,8 +535,7 @@ export class SearchService { displayCurrency: fare.billingCurrency as Currency, displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate), }; - } catch (error) { - console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message); + } catch { return null; } }), @@ -562,36 +544,32 @@ export class SearchService { const validResults = results.filter( (r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null, ); - if (validResults.length > 0) { - return validResults; - } + if (validResults.length > 0) return validResults; } - const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } }); - const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } }); + // Fallback: use station codes from already-loaded stopTimes when available + const originStop = schedule.stopTimes.find(st => st.stationId === originStationId); + const destStop = schedule.stopTimes.find(st => st.stationId === destinationStationId); + const originCode = originStop?.station?.code; + const destCode = destStop?.station?.code; - if (originStation && destStation) { - const segmentRoute = `${originStation.code}-${destStation.code}`; + if (originCode && destCode) { + const segmentRoute = `${originCode}-${destCode}`; const now = new Date(); const fareRules = await this.prisma.fareRule.findMany({ where: { route: segmentRoute, - seatClassId: { in: seatClassIds }, + seatClassId: { in: seatClasses.map((sc: any) => sc.id) }, validFrom: { lte: now }, - OR: [ - { validUntil: null }, - { validUntil: { gte: now } }, - ], + OR: [{ validUntil: null }, { validUntil: { gte: now } }], }, }); if (fareRules.length > 0) { - console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`); - const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name])); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency); return fareRules.map(rule => ({ - seatClassName: seatClassMap[rule.seatClassId] || 'Unknown', + seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown', baseFareMinor: rule.baseFareMinor, displayCurrency, displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate), @@ -599,20 +577,20 @@ export class SearchService { } } - console.log(`No fares found via engine or rules for ${originStationId} to ${destinationStationId}`); return []; } - private async buildCoachTypeDetails( - schedule: any, + // buildCoachTypeDetails is pure in-memory — no async needed + private buildCoachTypeDetails( + schedule: ScheduleWithIncludes, faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>, - ): Promise; - }>> { + }> { const coachTypeMap = new Map< string, { coachType: any; classNames: Set; coachId: string } @@ -682,14 +660,6 @@ export class SearchService { return fare.baseFarePerPassengerMinor; } - private getDefaultFareForClass(_className: string): never { - throw new Error('getDefaultFareForClass should not be called — use resolveScheduleFare instead'); - } - - private defaultFare(_seatClassName: string): never { - throw new Error('defaultFare should not be called — use resolveScheduleFare instead'); - } - private selectBestFareRule( candidates: any[], scheduleId: string, diff --git a/apps/edr-passenger-api/src/modules/segments/segments.service.ts b/apps/edr-passenger-api/src/modules/segments/segments.service.ts index 2eef0302e..16b486bbe 100644 --- a/apps/edr-passenger-api/src/modules/segments/segments.service.ts +++ b/apps/edr-passenger-api/src/modules/segments/segments.service.ts @@ -146,6 +146,96 @@ export class SegmentsService { return true; } + /** + * Batch availability check for multiple seats on a single schedule. + * Replaces N×isSeatFreeForLeg calls with 2 queries total. + * Returns a Set of seat IDs that are free for [reqFrom, reqTo). + */ + async getFreeSeatIds( + scheduleId: string, + seatIds: string[], + stopTimesForSeqLookup: ReadonlyArray<{ stationId: string; sequence: number }>, + reqFrom: number, + reqTo: number, + ): Promise> { + if (seatIds.length === 0) return new Set(); + + const seqOf = (stationId: string) => + stopTimesForSeqLookup.find(s => s.stationId === stationId)?.sequence; + + const seatIdSet = new Set(seatIds); + const now = new Date(); + + const [allHolds, bookedLegs] = await Promise.all([ + this.prisma.seatHold.findMany({ + where: { scheduleId, expiresAt: { gt: now } }, + select: { seatIds: true, createdBy: true }, + }), + this.prisma.journeySegment.findMany({ + where: { + scheduleId, + seatId: { in: seatIds }, + journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, + }, + select: { seatId: true, journeyId: true, departureStationId: true, arrivalStationId: true }, + }), + ]); + + // Determine which seats are blocked by active holds + const holdBlockedSeats = new Set(); + for (const hold of allHolds) { + let holdFrom: number | undefined; + let holdTo: number | undefined; + try { + if (hold.createdBy) { + const meta = JSON.parse(hold.createdBy as string); + holdFrom = seqOf(meta.originStationId); + holdTo = seqOf(meta.destinationStationId); + } + } catch { /* ignore */ } + + for (const sid of hold.seatIds) { + if (!seatIdSet.has(sid)) continue; + // Conservative block if leg can't be resolved; otherwise check overlap + if (holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo)) { + holdBlockedSeats.add(sid); + } + } + } + + // Build full journey ranges per seat (group multi-leg journeys) + const journeyRangesBySeat = new Map>(); + for (const leg of bookedLegs) { + if (!leg.seatId || !leg.journeyId || !leg.departureStationId || !leg.arrivalStationId) continue; + const depSeq = seqOf(leg.departureStationId); + const arrSeq = seqOf(leg.arrivalStationId); + if (depSeq === undefined || arrSeq === undefined) continue; + + let rangeMap = journeyRangesBySeat.get(leg.seatId); + if (!rangeMap) { rangeMap = new Map(); journeyRangesBySeat.set(leg.seatId, rangeMap); } + + const existing = rangeMap.get(leg.journeyId); + rangeMap.set(leg.journeyId, existing + ? { from: Math.min(existing.from, depSeq), to: Math.max(existing.to, arrSeq) } + : { from: depSeq, to: arrSeq }); + } + + const freeSeats = new Set(); + for (const seatId of seatIds) { + if (holdBlockedSeats.has(seatId)) continue; + let blocked = false; + const rangeMap = journeyRangesBySeat.get(seatId); + if (rangeMap) { + for (const { from, to } of rangeMap.values()) { + if (from < reqTo && reqFrom < to) { blocked = true; break; } + } + } + if (!blocked) freeSeats.add(seatId); + } + + return freeSeats; + } + /** Legacy wrapper used by EnhancedSeatsService.getOverlappingReservations */ async getOverlappingReservations( scheduleId: string, diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index 696dcb45b..a02f00eec 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -259,20 +259,14 @@ export default function SeatsPage() { })), }); - const coachesWithSeats = coaches.filter( - (c: any) => c.seats && c.seats.length > 0, - ); - - if (!currentSchedule?.selectedSeatClass) { - console.log( - "✅ No filter applied, returning all coaches:", - coachesWithSeats.length, - ); - return coachesWithSeats; - } + const coachesWithSeats = coaches.filter((c: any) => { + // Bed coaches store occupants in rooms.beds, not seats + if (c.rooms?.length > 0) return c.rooms.some((r: any) => r.beds?.length > 0); + return c.seats && c.seats.length > 0; + }); console.log( - "✅ No seat class filter - returning all coaches with seats:", + "✅ Returning all coaches with seats/beds:", coachesWithSeats.length, ); return coachesWithSeats; @@ -333,6 +327,8 @@ export default function SeatsPage() { return seatLabel && !seatLabel.startsWith("-"); }); const isBedCoach = + selectedCoachData?.isBedCoach === true || + seats.some((s: any) => s.bedPosition) || selectedCoachData?.seatClass?.toLowerCase().includes("bed") || selectedCoachData?.mode?.toLowerCase().includes("bed"); @@ -1071,6 +1067,8 @@ export default function SeatsPage() { const allSelected = selectedSeats.length === passengers.length; const isBedCoach = + selectedCoachData?.isBedCoach === true || + selectedCoachData?.rooms?.length > 0 || selectedCoachData?.seatClass?.toLowerCase().includes("bed") || selectedCoachData?.mode?.toLowerCase().includes("bed"); @@ -1314,6 +1312,8 @@ export default function SeatsPage() { } const isBed = + coach.isBedCoach === true || + coach.rooms?.length > 0 || coach.seatClass?.toLowerCase().includes("bed") || coach.mode?.toLowerCase().includes("bed"); diff --git a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx index 3b6c19336..6422e12fa 100644 --- a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx +++ b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx @@ -4,7 +4,6 @@ import { Menu, X, Moon, Sun, HelpCircle } from "lucide-react"; import Link from "next/link"; import Image from "next/image"; import { useEffect, useState } from "react"; -import { LanguageSwitcher } from "./LanguageSwitcher"; export default function AppHeader() { const [isOpen, setIsOpen] = useState(false); @@ -72,9 +71,6 @@ export default function AppHeader() { - {/* Language Switcher */} - - {/* Theme Toggler */} + + + {/* Start button and loading state */} + {!isScanning && !isInitializing && (
)} - ) : ( + )} + + {/* Loading state */} + {isInitializing && (
-
-
{/* Quick Stats */} diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index f8ee29091..b25a40498 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -9,7 +9,7 @@ import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { fleetApi, apiClient } from '@/lib/api'; -type Tab = 'types' | 'coaches'; +type Tab = 'types' | 'coaches' | 'utilization'; const getBedLabel = (bedPosition: string | null): string => { if (bedPosition === 'upper') return 'U'; @@ -163,6 +163,12 @@ export default function CoachesPage() { queryFn: () => fleetApi.getCoaches({}), }); + const { data: utilizationData, isLoading: utilizationLoading } = useQuery({ + queryKey: ['coach-utilization'], + queryFn: () => apiClient.get('/fleet/coaches/utilization'), + enabled: activeTab === 'utilization', + }); + // Coach Type Mutations const createCoachTypeMutation = useMutation({ mutationFn: (data: any) => apiClient.post('/fleet/coach-types', data), @@ -547,6 +553,16 @@ export default function CoachesPage() { > Coaches +
{/* Coach Types Tab */} @@ -594,6 +610,44 @@ export default function CoachesPage() { /> )} + + {/* Utilization Tab */} + {activeTab === 'utilization' && (() => { + const rows = Array.isArray(utilizationData) ? utilizationData : (utilizationData as any)?.data || []; + return ( +
+ {r.sequence} }, + { key: 'number', label: 'Coach', render: (r: any) => {r.number} }, + { key: 'coachType', label: 'Type', render: (r: any) => {r.coachType || 'N/A'} }, + { key: 'totalSeats', label: 'Total Seats', render: (r: any) => {r.totalSeats} }, + { key: 'availableSeats', label: 'Available', render: (r: any) => {r.availableSeats} }, + { key: 'bookedSeats', label: 'Booked', render: (r: any) => {r.bookedSeats} }, + { key: 'blockedSeats', label: 'Blocked', render: (r: any) => {r.blockedSeats} }, + { key: 'maintenanceSeats', label: 'Maintenance', render: (r: any) => {r.maintenanceSeats} }, + { + key: 'utilizationRate', label: 'Utilization', + render: (r: any) => ( +
+
+
+
+ {r.utilizationRate}% +
+ ), + }, + { key: 'totalAssignments', label: 'Assignments', render: (r: any) => {r.totalAssignments} }, + { key: 'totalBookings', label: 'Total Bookings', render: (r: any) => {r.totalBookings} }, + ]} + data={rows} + actions={[]} + loading={utilizationLoading} + emptyMessage="No coach utilization data available" + /> +
+ ); + })()}
{/* Delete Confirmation */} diff --git a/apps/edr-passenger-web/backoffice/src/app/fare-management/page.tsx b/apps/edr-passenger-web/backoffice/src/app/fare-management/page.tsx index 218fbd832..fdd61ef19 100644 --- a/apps/edr-passenger-web/backoffice/src/app/fare-management/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/fare-management/page.tsx @@ -2,187 +2,121 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plus, Settings, Play, Square, Trash2, TestTube, History, Download } from 'lucide-react'; +import { Plus, Edit, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { apiClient } from '@/lib/api-client'; +import { formatDateTime } from '@/lib/utils'; -interface FareConfiguration { - id: string; - name: string; - description?: string; - effective_date: string; - expiry_date?: string; - is_active: boolean; - is_default: boolean; - created_by?: string; - approved_by?: string; - approved_at?: string; - created_at: string; - updated_at: string; - rate_rules_count: number; - components_count: number; - age_rules_count: number; -} - -interface SystemStatus { - configurableFaresEnabled: boolean; - rolloutPercentage: number; - totalConfigurations: number; - activeConfiguration: string | null; - activeConfigurationName: string | null; - systemReady: boolean; -} - -interface FareTestResult { - baseFareMinor: number; - componentsTotal: number; - finalTotalMinor: number; - breakdown?: Array<{ - description: string; - runningTotal: number; - }>; -} - -export default function ConfigurableFarePage() { - const [showCreateModal, setShowCreateModal] = useState(false); - const [showTestModal, setShowTestModal] = useState(false); - const [selectedConfig, setSelectedConfig] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; config: FareConfiguration | null }>({ isOpen: false, config: null }); +export default function FareManagementPage() { + const [filters, setFilters] = useState({ scheduleId: '' }); + const [showModal, setShowModal] = useState(false); + const [editingRule, setEditingRule] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; rule: any | null; error?: string }>({ isOpen: false, rule: null }); + const [formError, setFormError] = useState(null); const queryClient = useQueryClient(); - // Queries - const { data: configurations = [], isLoading: configsLoading } = useQuery({ - queryKey: ['fare-configurations'], - queryFn: () => apiClient.get('/admin/fare-configurations'), - }); - - const { data: systemStatus } = useQuery({ - queryKey: ['fare-system-status'], - queryFn: () => apiClient.get('/admin/fare-migration/status'), - }); - - // Mutations - const activateMutation = useMutation({ - mutationFn: (id: string) => apiClient.post(`/admin/fare-configurations/${id}/activate`), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); - queryClient.invalidateQueries({ queryKey: ['fare-system-status'] }); + const { data: fareRules, isLoading } = useQuery({ + queryKey: ['fare-rules', filters], + queryFn: async () => { + const params = new URLSearchParams(); + if (filters.scheduleId) params.append('scheduleId', filters.scheduleId); + const res = await apiClient.get(`/schedules/fares?${params}`); + return Array.isArray(res) ? res : (res as any)?.items || (res as any)?.data || []; }, }); + const { data: schedulesData } = useQuery({ + queryKey: ['schedules'], + queryFn: () => apiClient.get('/schedules'), + }); + + const { data: seatClassesData } = useQuery({ + queryKey: ['seat-classes'], + queryFn: () => apiClient.get('/fleet/classes'), + }); + + const schedules = Array.isArray(schedulesData) ? schedulesData : (schedulesData as any)?.items || []; + const seatClasses = Array.isArray(seatClassesData) ? seatClassesData : (seatClassesData as any)?.items || (seatClassesData as any)?.data || []; + + const createMutation = useMutation({ + mutationFn: (data: any) => apiClient.post('/schedules/fares', data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['fare-rules'] }); setShowModal(false); setEditingRule(null); setFormError(null); }, + onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to save fare rule'), + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: any }) => apiClient.patch(`/schedules/fares/${id}`, data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['fare-rules'] }); setShowModal(false); setEditingRule(null); setFormError(null); }, + onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to update fare rule'), + }); + const deleteMutation = useMutation({ - mutationFn: (id: string) => apiClient.delete(`/admin/fare-configurations/${id}`), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); - setDeleteConfirm({ isOpen: false, config: null }); - }, + mutationFn: (id: string) => apiClient.delete(`/schedules/fares/${id}`), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['fare-rules'] }); setDeleteConfirm({ isOpen: false, rule: null }); }, + onError: (e: any) => setDeleteConfirm(prev => ({ ...prev, error: e?.response?.data?.message || e?.message || 'Failed to delete' })), }); - const toggleSystemMutation = useMutation({ - mutationFn: (enabled: boolean) => - enabled - ? apiClient.post('/admin/fare-configurations/system/enable-configurable-fares', { rolloutPercentage: 100 }) - : apiClient.post('/admin/fare-configurations/system/disable-configurable-fares'), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['fare-system-status'] }); - }, - }); + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setFormError(null); + const fd = new FormData(e.currentTarget); + const payload: any = { + seatClassId: fd.get('seatClassId') as string, + baseFareMinor: Math.round(parseFloat(fd.get('baseFareMinor') as string) * 100), + validFrom: new Date(fd.get('validFrom') as string).toISOString(), + }; + const scheduleId = fd.get('scheduleId') as string; + const nationality = fd.get('nationality') as string; + const passengerCategory = fd.get('passengerCategory') as string; + const validUntil = fd.get('validUntil') as string; + if (scheduleId) payload.scheduleId = scheduleId; + if (nationality) payload.nationality = nationality; + if (passengerCategory) payload.passengerCategory = passengerCategory; + if (validUntil) payload.validUntil = new Date(validUntil).toISOString(); - const setupSystemMutation = useMutation({ - mutationFn: () => apiClient.post('/admin/fare-migration/complete-setup', { - activateNewFormula: true, - enableFeature: true - }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); - queryClient.invalidateQueries({ queryKey: ['fare-system-status'] }); - }, - }); - - const handleActivate = async (config: FareConfiguration) => { - await activateMutation.mutateAsync(config.id); - }; - - const handleDelete = (config: FareConfiguration) => { - setDeleteConfirm({ isOpen: true, config }); - }; - - const confirmDelete = async () => { - if (deleteConfirm.config) { - await deleteMutation.mutateAsync(deleteConfirm.config.id); + if (editingRule) { + await updateMutation.mutateAsync({ id: editingRule.id, data: payload }); + } else { + await createMutation.mutateAsync(payload); } }; - const handleTest = (config: FareConfiguration) => { - setSelectedConfig(config); - setShowTestModal(true); - }; - const columns = [ { - key: 'name', - label: 'Configuration Name', - sortable: true, - render: (config: FareConfiguration) => ( -
-
{config.name}
- {config.description && ( -
{config.description}
- )} -
- ), + key: 'seatClass', label: 'Seat Class', + render: (r: any) => {r.seatClass?.name || r.seatClassId}, }, { - key: 'status', - label: 'Status', - render: (config: FareConfiguration) => ( -
- - {config.is_active ? 'Active' : 'Inactive'} - - {config.is_default && ( - Default - )} -
- ), + key: 'schedule', label: 'Schedule', + render: (r: any) => r.trip + ? {r.trip.originStation?.name} → {r.trip.destinationStation?.name}
{r.trip.departureAt ? new Date(r.trip.departureAt).toLocaleDateString() : ''}
+ : All schedules, }, { - key: 'rules', - label: 'Rules Count', - render: (config: FareConfiguration) => ( -
-
{config.rate_rules_count} rate rules
-
{config.components_count} components
-
{config.age_rules_count} age rules
-
- ), + key: 'passengerCategory', label: 'Category', + render: (r: any) => r.passengerCategory + ? {r.passengerCategory} + : All, }, { - key: 'dates', - label: 'Validity Period', - render: (config: FareConfiguration) => ( -
-
From: {new Date(config.effective_date).toLocaleDateString()}
- {config.expiry_date && ( -
Until: {new Date(config.expiry_date).toLocaleDateString()}
- )} -
- ), + key: 'nationality', label: 'Nationality', + render: (r: any) => {r.nationality || 'All'}, }, { - key: 'created_at', - label: 'Created', - sortable: true, - render: (config: FareConfiguration) => ( -
-
{new Date(config.created_at).toLocaleDateString()}
- {config.created_by && ( -
by {config.created_by}
- )} + key: 'baseFareMinor', label: 'Base Fare (ETB)', + render: (r: any) => {(r.baseFareMinor / 100).toFixed(2)}, + }, + { + key: 'validity', label: 'Validity', + render: (r: any) => ( +
+
From: {formatDateTime(r.validFrom)}
+ {r.validUntil &&
Until: {formatDateTime(r.validUntil)}
} + {!r.validUntil &&
No expiry
}
), }, @@ -190,24 +124,12 @@ export default function ConfigurableFarePage() { const actions = [ { - label: 'Activate', - onClick: handleActivate, - variant: 'secondary' as const, - icon: Play, - show: (config: FareConfiguration) => !config.is_active, + label: 'Edit', icon: Edit, variant: 'secondary' as const, + onClick: (r: any) => { setEditingRule(r); setFormError(null); setShowModal(true); }, }, { - label: 'Test', - onClick: handleTest, - variant: 'secondary' as const, - icon: TestTube, - }, - { - label: 'Delete', - onClick: handleDelete, - variant: 'danger' as const, - icon: Trash2, - show: (config: FareConfiguration) => !config.is_active, + label: 'Delete', icon: Trash2, variant: 'danger' as const, + onClick: (r: any) => setDeleteConfirm({ isOpen: true, rule: r }), }, ]; @@ -215,321 +137,107 @@ export default function ConfigurableFarePage() {
-

Configurable Fare Management

-

- Manage dynamic fare configurations with flexible rules, components, and pricing -

-
-
- setupSystemMutation.mutate()} - loading={setupSystemMutation.isPending} - disabled={systemStatus?.systemReady} - > - {systemStatus?.systemReady ? 'System Ready' : 'Setup System'} - - setShowCreateModal(true)} - > - New Configuration - +

Fare Management

+

Configure fare rules by seat class, passenger category, and nationality

+ { setEditingRule(null); setFormError(null); setShowModal(true); }}>Add Fare Rule
- {/* System Status */} -
-
-
-
-
System Status
-
- {systemStatus?.systemReady ? 'Ready' : 'Setup Required'} -
-
- - {systemStatus?.configurableFaresEnabled ? 'Enabled' : 'Disabled'} - -
-
- -
-
Total Configurations
-
{systemStatus?.totalConfigurations || 0}
-
- -
-
Rollout Percentage
-
{systemStatus?.rolloutPercentage || 0}%
-
- -
-
Active Configuration
-
- {systemStatus?.activeConfigurationName || 'None'} -
-
-
- - {/* System Controls */}
-
-
-

System Control

-

- Enable or disable the configurable fare system globally -

-
-
- - {systemStatus?.configurableFaresEnabled ? 'System Enabled' : 'Using Legacy System'} - - toggleSystemMutation.mutate(!systemStatus?.configurableFaresEnabled)} - loading={toggleSystemMutation.isPending} - icon={systemStatus?.configurableFaresEnabled ? Square : Play} - > - {systemStatus?.configurableFaresEnabled ? 'Disable' : 'Enable'} - -
+
+
+
- {/* Configurations Table */} -
-
-

Fare Configurations

-

- Manage fare calculation configurations with custom rates, components, and age-based pricing -

-
- - -
- - {/* Delete Confirmation */} setDeleteConfirm({ isOpen: false, config: null })} - onConfirm={confirmDelete} - title="Delete Configuration" - message={`Are you sure you want to delete "${deleteConfirm.config?.name}"? This action cannot be undone.`} - confirmText="Delete" - isDanger={true} - isLoading={deleteMutation.isPending} - warning="Active configurations cannot be deleted. Deactivate first if needed." + onClose={() => setDeleteConfirm({ isOpen: false, rule: null })} + onConfirm={() => deleteMutation.mutate(deleteConfirm.rule?.id)} + title="Delete Fare Rule" + message={`Delete fare rule for ${deleteConfirm.rule?.seatClass?.name || 'this class'}?`} + confirmText="Delete" isDanger isLoading={deleteMutation.isPending} + error={deleteConfirm.error} /> - {/* Test Modal */} - {showTestModal && selectedConfig && ( - { - setShowTestModal(false); - setSelectedConfig(null); - }} - /> - )} - - {/* Create/Edit Modal */} - {showCreateModal && ( - setShowCreateModal(false)} - onSuccess={() => { - setShowCreateModal(false); - queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); - }} - /> - )} + { setShowModal(false); setEditingRule(null); }} + title={`${editingRule ? 'Edit' : 'Add'} Fare Rule`} size="lg"> +
+ {formError && ( +
{formError}
+ )} +
+
+ + +
+
+ + +
+
+ + +

Leave blank to apply to all passengers

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ { setShowModal(false); setEditingRule(null); }}>Cancel + + {editingRule ? 'Update' : 'Create'} Fare Rule + +
+
+
); } - -// Test Modal Component -function FareTestModal({ - configuration, - isOpen, - onClose -}: { - configuration: FareConfiguration; - isOpen: boolean; - onClose: () => void; -}) { - const [testData, setTestData] = useState({ - distanceKm: 100, - nationality: 'Ethiopian', - coachType: 'REGULAR_SEAT', - bedPosition: '', - adultCount: 2, - childCount: 1, - }); - - const testMutation = useMutation({ - mutationFn: () => apiClient.post(`/admin/fare-configurations/${configuration.id}/test`, testData), - }); - - const handleTest = () => { - testMutation.mutate(); - }; - - return ( - -
-
-
- - setTestData({ ...testData, distanceKm: +e.target.value })} - /> -
-
- - -
-
- - -
- {(testData.coachType === 'ECONOMY_BED' || testData.coachType === 'VIP_BED') && ( -
- - -
- )} -
- - setTestData({ ...testData, adultCount: +e.target.value })} - /> -
-
- - setTestData({ ...testData, childCount: +e.target.value })} - /> -
-
- - - Calculate Fare - - - {testMutation.data && ( -
-

Calculation Result

-
-
- Base Fare: - {(testMutation.data.baseFareMinor / 100).toFixed(2)} ETB -
-
- Components: - {(testMutation.data.componentsTotal / 100).toFixed(2)} ETB -
-
- Total: - {(testMutation.data.finalTotalMinor / 100).toFixed(2)} ETB -
-
- - {testMutation.data.breakdown && ( -
-
Calculation Breakdown:
-
- {testMutation.data.breakdown.map((step: any, index: number) => ( -
- {step.description} - {(step.runningTotal / 100).toFixed(2)} ETB -
- ))} -
-
- )} -
- )} - - {testMutation.error && ( -
- {(testMutation.error as any)?.response?.data?.message || 'Test failed'} -
- )} -
-
- ); -} - -// Create Configuration Form Modal -function ConfigurationFormModal({ - isOpen, - onClose, - onSuccess -}: { - isOpen: boolean; - onClose: () => void; - onSuccess: () => void; -}) { - return ( - -
-

Configuration Form

-

- This would contain a comprehensive form for creating fare configurations with rate rules, components, and age pricing. -

- - Close for Now - -
-
- ); -} \ No newline at end of file diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index d06d9e98e..32efb080d 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -49,7 +49,7 @@ export default function SchedulesPage() { const [showEditModal, setShowEditModal] = useState(false); const [editingSchedule, setEditingSchedule] = useState(null); const [selectedSchedules, setSelectedSchedules] = useState>(new Set()); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean }>( + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean; error?: string }>( { isOpen: false, item: null } ); const [error, setError] = useState(null); @@ -149,6 +149,10 @@ export default function SchedulesPage() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['schedules'] }); }, + onError: (err: any) => { + const msg = err?.response?.data?.message || err?.message || 'Failed to delete schedule'; + setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + }, }); const bulkDeleteMutation = useMutation({ @@ -159,6 +163,10 @@ export default function SchedulesPage() { queryClient.invalidateQueries({ queryKey: ['schedules'] }); setSelectedSchedules(new Set()); }, + onError: (err: any) => { + const msg = err?.response?.data?.message || err?.message || 'Failed to delete schedules'; + setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + }, }); const handleBulkSubmit = async (e: React.FormEvent) => { @@ -227,13 +235,18 @@ export default function SchedulesPage() { }; const confirmDelete = async () => { - if (deleteConfirm.isBulk) { - const ids = deleteConfirm.item as string[]; - await bulkDeleteMutation.mutateAsync(ids); - } else if (deleteConfirm.item) { - await deleteScheduleMutation.mutateAsync(deleteConfirm.item.id); + setDeleteConfirm(prev => ({ ...prev, error: undefined })); + try { + if (deleteConfirm.isBulk) { + const ids = deleteConfirm.item as string[]; + await bulkDeleteMutation.mutateAsync(ids); + } else if (deleteConfirm.item) { + await deleteScheduleMutation.mutateAsync(deleteConfirm.item.id); + } + setDeleteConfirm({ isOpen: false, item: null }); + } catch { + // error is set by onError handler } - setDeleteConfirm({ isOpen: false, item: null }); }; const handleEditClick = (schedule: Schedule) => { @@ -531,7 +544,9 @@ export default function SchedulesPage() { } confirmText="Delete" isDanger={true} - warning="This schedule may have bookings. Deleting it may impact these systems." + isLoading={deleteScheduleMutation.isPending || bulkDeleteMutation.isPending} + error={deleteConfirm.error} + warning="Schedules with existing bookings cannot be deleted." /> (null); + const [showMaintenanceModal, setShowMaintenanceModal] = useState(false); + const [maintenanceReason, setMaintenanceReason] = useState(''); const queryClient = useQueryClient(); const { data: schedulesData } = useQuery({ @@ -70,6 +72,22 @@ export default function SeatsPage() { }, }); + const maintenanceMutation = useMutation({ + mutationFn: ({ seatId, reason }: { seatId: string; reason: string }) => + seatsApi.setMaintenance(seatId, reason), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['seatmap'] }); + setShowMaintenanceModal(false); + setSelectedSeat(null); + setMaintenanceReason(''); + }, + }); + + const clearMaintenanceMutation = useMutation({ + mutationFn: (seatId: string) => seatsApi.clearMaintenance(seatId), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['seatmap'] }), + }); + const schedules = schedulesData?.items || schedulesData?.data || []; const coaches = seatMapData?.coaches || []; @@ -132,6 +150,17 @@ export default function SeatsPage() { } }; + const handleSetMaintenance = (seat: any) => { + setSelectedSeat(seat); + setShowMaintenanceModal(true); + }; + + const handleClearMaintenance = async (seat: any) => { + if (confirm('Clear maintenance status for this seat?')) { + await clearMaintenanceMutation.mutateAsync(seat.id); + } + }; + const handleBlockCoach = (coach: any) => { setSelectedCoach(coach); setShowBlockCoachModal(true); @@ -182,6 +211,7 @@ export default function SeatsPage() { }; const getSeatStatus = (seat: any) => { + if (seat.status === 'UNDER_MAINTENANCE') return 'UNDER_MAINTENANCE'; if (seat.status === 'BLOCKED' || seat.isBlocked) return 'BLOCKED'; if (seat.status === 'BOOKED' || seat.isBooked) return 'BOOKED'; if (seat.status === 'HELD') return 'HELD'; @@ -194,6 +224,7 @@ export default function SeatsPage() { case 'BOOKED': return 'bg-red-500'; case 'HELD': return 'bg-yellow-500'; case 'BLOCKED': return 'bg-gray-500'; + case 'UNDER_MAINTENANCE': return 'bg-orange-500'; default: return 'bg-gray-300'; } }; @@ -265,6 +296,8 @@ export default function SeatsPage() { handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} handleUndoRemove={handleUndoRemove} + handleSetMaintenance={handleSetMaintenance} + handleClearMaintenance={handleClearMaintenance} hideNumber={true} /> ))} @@ -358,6 +391,8 @@ export default function SeatsPage() { handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} handleUndoRemove={handleUndoRemove} + handleSetMaintenance={handleSetMaintenance} + handleClearMaintenance={handleClearMaintenance} hideNumber={true} /> ))} @@ -378,6 +413,8 @@ export default function SeatsPage() { handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} handleUndoRemove={handleUndoRemove} + handleSetMaintenance={handleSetMaintenance} + handleClearMaintenance={handleClearMaintenance} hideNumber={true} /> ))} @@ -532,6 +569,10 @@ export default function SeatsPage() {
Blocked
+
+
+ Under Maintenance +
Removed @@ -782,6 +823,44 @@ export default function SeatsPage() {
+ + { setShowMaintenanceModal(false); setSelectedSeat(null); setMaintenanceReason(''); }} + title="Set Seat Under Maintenance" + size="md" + > +
+

+ Set seat {selectedSeat?.seatNumber} to Under Maintenance +

+
+ +