diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index a9965c74a..f9107ed23 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -3,6 +3,10 @@ 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 @@ -14,6 +18,7 @@ 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 @@ -23,7 +28,8 @@ RUN pnpm turbo build --filter="@edr/freight-api..." FROM base AS deployer COPY --from=builder /app/ . -RUN pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy FROM node:24.15.0-alpine AS runner RUN apk add --no-cache libc6-compat @@ -34,4 +40,4 @@ RUN addgroup --system --gid 1001 nodejs \ COPY --from=deployer --chown=nestjs:nodejs /deploy . USER nestjs EXPOSE 3001 -CMD ["sh", "-c", "pnpm run migrate && node dist/main.js"] +CMD ["node", "dist/main.js"] diff --git a/apps/edr-freight-api/html/payment-tester.html b/apps/edr-freight-api/html/payment-tester.html new file mode 100644 index 000000000..1bb1a5c80 --- /dev/null +++ b/apps/edr-freight-api/html/payment-tester.html @@ -0,0 +1,452 @@ + + + + + +EDR Freight β€” Payment Tester (Telebirr ETB + Card USD) + + + +
+

πŸš‚ EDR Freight β€” Payment Tester

+ Telebirr (ETB) + Card (USD) +
+ +
+ +
+

API connection

+
+
+ + +
+
+ + +
+
+
+ + not checked +
+
+ + +
+

1 Β· Choose a booking

+
+
+ + +
+ +
+
+ + Currency (ETB vs USD) is set per-booking via paymentCurrency. Pick an ETB booking to test Telebirr, a USD booking to test Card. +
+
+ + +
+ + +
+

2 Β· Initiate payment

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
+ Telebirr β†’ forces method TELEBIRR. Card β†’ forces method CARD. + Each calls POST {base}/payments/initiate and follows the returned clientAction (REDIRECT url for web). +
+

+
+ + +
+

3 Β· Track intent & receipt

+
+ + + no intent yet +
+
+ + Receipt = GET {base}/payments/receipt/{merchantOrderId} +
+
+ + +
+
+

Last response

+
β€”
+
+
+

Request log

+
+
+
+
+ + + + diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index f8b2b27fe..71e2fd60a 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/1810000000002-CreateLastMileContainerAllocations.ts b/apps/edr-freight-api/src/migrations/1810000000002-CreateLastMileContainerAllocations.ts new file mode 100644 index 000000000..b2026b753 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1810000000002-CreateLastMileContainerAllocations.ts @@ -0,0 +1,78 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Create the freight.last_mile_container_allocations table β€” container allocation + * records linking last-mile deliveries with containers and vehicles. + */ +export class CreateLastMileContainerAllocations1810000000002 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.last_mile_container_allocations'); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: 'freight.last_mile_container_allocations', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'last_mile_id', type: 'uuid', isNullable: false }, + { name: 'container_id', type: 'uuid', isNullable: false }, + { name: 'vehicle_id', type: 'uuid', isNullable: true }, + { + name: 'container_type', + type: 'text', + isNullable: false, + }, + { + name: 'quantity', + type: 'integer', + default: 1, + isNullable: false, + }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'freight.last_mile_container_allocations', + new TableForeignKey({ + columnNames: ['last_mile_id'], + referencedTableName: 'freight.last_mile', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'freight.last_mile_container_allocations', + new TableForeignKey({ + columnNames: ['vehicle_id'], + referencedTableName: 'freight.vehicles', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.query( + `CREATE INDEX "IDX_last_mile_container_allocations_last_mile_id" ON "freight"."last_mile_container_allocations" ("last_mile_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_last_mile_container_allocations_vehicle_id" ON "freight"."last_mile_container_allocations" ("vehicle_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.last_mile_container_allocations'); + if (exists) { + await queryRunner.dropTable('freight.last_mile_container_allocations'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts b/apps/edr-freight-api/src/migrations/1810000000004-AddPostPaymentCompletedColumn.ts similarity index 93% rename from apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts rename to apps/edr-freight-api/src/migrations/1810000000004-AddPostPaymentCompletedColumn.ts index c755d6356..aeedae2b1 100644 --- a/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts +++ b/apps/edr-freight-api/src/migrations/1810000000004-AddPostPaymentCompletedColumn.ts @@ -1,7 +1,7 @@ import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; -export class AddPostPaymentCompletedColumn1719667261000 implements MigrationInterface { - name = 'AddPostPaymentCompletedColumn1719667261000'; +export class AddPostPaymentCompletedColumn1810000000004 implements MigrationInterface { + name = 'AddPostPaymentCompletedColumn1810000000004'; public async up(queryRunner: QueryRunner): Promise { const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries'); diff --git a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts index 5c42cad65..65a3e764b 100644 --- a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts +++ b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts @@ -15,19 +15,25 @@ export class CreateInvoices1821000000002 implements MigrationInterface { name = "CreateInvoices1821000000002"; public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(` - CREATE TYPE freight.invoices_status_enum AS ENUM ( - 'DRAFT', - 'PENDING', - 'PAID', - 'OVERDUE', - 'CANCELLED', - 'REFUNDED' - ); - `); + const typeExists = await queryRunner.query( + `SELECT 1 FROM pg_type WHERE typname = 'invoices_status_enum' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'freight');`, + ); + + if (!typeExists.length) { + await queryRunner.query(` + CREATE TYPE freight.invoices_status_enum AS ENUM ( + 'DRAFT', + 'PENDING', + 'PAID', + 'OVERDUE', + 'CANCELLED', + 'REFUNDED' + ); + `); + } await queryRunner.query(` - CREATE TABLE freight.invoices ( + CREATE TABLE IF NOT EXISTS freight.invoices ( id uuid NOT NULL DEFAULT uuid_generate_v4(), invoice_number varchar(64) NOT NULL, company_id uuid NOT NULL, @@ -96,6 +102,8 @@ export class CreateInvoices1821000000002 implements MigrationInterface { public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP TABLE IF EXISTS freight.invoice_lines;`); await queryRunner.query(`DROP TABLE IF EXISTS freight.invoices;`); - await queryRunner.query(`DROP TYPE IF EXISTS freight.invoices_status_enum;`); + await queryRunner.query( + `DROP TYPE IF EXISTS freight.invoices_status_enum;`, + ); } } diff --git a/apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts b/apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts new file mode 100644 index 000000000..70b0832f5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts @@ -0,0 +1,80 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Create the freight.booking_container_allocations table β€” container-to-vehicle + * allocation mapping for flexible routing of containers across available vehicles. + */ +export class CreateBookingContainerAllocations1825000000000 implements MigrationInterface { + name = 'CreateBookingContainerAllocations1825000000000'; + + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.booking_container_allocations'); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: 'freight.booking_container_allocations', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'booking_id', type: 'uuid', isNullable: false }, + { name: 'container_id', type: 'uuid', isNullable: false }, + { name: 'vehicle_id', type: 'uuid', isNullable: true }, + { + name: 'container_type', + type: 'text', + isNullable: false, + }, + { + name: 'quantity', + type: 'integer', + default: 1, + isNullable: false, + }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'freight.booking_container_allocations', + new TableForeignKey({ + columnNames: ['booking_id'], + referencedTableName: 'freight.bookings', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'freight.booking_container_allocations', + new TableForeignKey({ + columnNames: ['vehicle_id'], + referencedTableName: 'freight.vehicles', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.query( + `CREATE INDEX "IDX_booking_container_allocations_booking_id" ON "freight"."booking_container_allocations" ("booking_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_booking_container_allocations_vehicle_id" ON "freight"."booking_container_allocations" ("vehicle_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.booking_container_allocations'); + if (exists) { + await queryRunner.dropTable('freight.booking_container_allocations'); + } + } +} 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/migrations/1830000000000-CreateFirstMileContainerAllocations.ts b/apps/edr-freight-api/src/migrations/1830000000000-CreateFirstMileContainerAllocations.ts new file mode 100644 index 000000000..b91e88633 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000000-CreateFirstMileContainerAllocations.ts @@ -0,0 +1,74 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Create freight.first_mile_container_allocations table β€” tracks + * container allocations per first-mile shipment with optional vehicle assignment. + */ +export class CreateFirstMileContainerAllocations1830000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.first_mile_container_allocations'); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: 'freight.first_mile_container_allocations', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'first_mile_id', type: 'uuid', isNullable: false }, + { name: 'container_id', type: 'uuid', isNullable: false }, + { name: 'vehicle_id', type: 'uuid', isNullable: true }, + { name: 'container_type', type: 'text', isNullable: false }, + { + name: 'quantity', + type: 'int', + default: 1, + isNullable: false, + }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'freight.first_mile_container_allocations', + new TableForeignKey({ + columnNames: ['first_mile_id'], + referencedTableName: 'freight.first_mile', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'freight.first_mile_container_allocations', + new TableForeignKey({ + columnNames: ['vehicle_id'], + referencedTableName: 'freight.vehicles', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.query( + `CREATE INDEX "IDX_first_mile_container_allocations_first_mile_id" ON "freight"."first_mile_container_allocations" ("first_mile_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_first_mile_container_allocations_vehicle_id" ON "freight"."first_mile_container_allocations" ("vehicle_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.first_mile_container_allocations'); + if (exists) { + await queryRunner.dropTable('freight.first_mile_container_allocations'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts b/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts new file mode 100644 index 000000000..cfb9887c3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts @@ -0,0 +1,20 @@ +import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingsService } from './bookings.service'; +import { AllocateContainersDto } from './dto/allocate-containers.dto'; + +@ApiTags('bookings') +@Controller('bookings') +@ApiBearerAuth() +export class BookingAllocationController { + constructor(private readonly bookingsService: BookingsService) {} + + @Post(':bookingId/allocate-containers') + @ApiOperation({ summary: 'Allocate containers to vehicles' }) + async allocateContainers( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: AllocateContainersDto, + ) { + return this.bookingsService.allocateContainers(bookingId, dto.allocations); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 8be7325ba..5d7e3b2c9 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -32,6 +32,7 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingContractSignature } from './entities/booking-contract-signature.entity'; import { BookingReviewNote } from './entities/booking-review-note.entity'; import { Booking } from './entities/booking.entity'; +import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder'; import { ContractRendererService } from '../../contracts/contract-renderer.service'; @@ -50,6 +51,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu BookingRateSnapshot, BookingReviewNote, BookingContractSignature, + BookingContainerAllocation, ]), BillingModule, forwardRef(() => FirstMileModule), diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 50d15cc64..a4636cbf7 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -43,6 +43,7 @@ import { FreightType, } from './entities/booking.entity'; import { Booking } from './entities/booking.entity'; +import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; import { FileRecord } from '../files/entities/file.entity'; /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ @@ -1337,4 +1338,35 @@ export class BookingsService { createdAt: b.createdAt, })); } + + async allocateContainers( + bookingId: string, + allocations: Array<{ containerId: string; vehicleId: string }>, + ) { + const booking = await this.findById(bookingId); + if (!booking) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + + await this.dataSource.transaction(async (manager) => { + for (const allocation of allocations) { + await manager.delete(BookingContainerAllocation, { + bookingId, + containerId: allocation.containerId, + }); + await manager.insert(BookingContainerAllocation, { + bookingId, + containerId: allocation.containerId, + vehicleId: allocation.vehicleId, + containerType: 'CONTAINER', + quantity: 1, + }); + } + }); + + return { + success: true, + allocated: allocations.length, + }; + } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/allocate-containers.dto.ts new file mode 100644 index 000000000..8b9b7da39 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/allocate-containers.dto.ts @@ -0,0 +1,8 @@ +export class ContainerAllocationDto { + containerId!: string; + vehicleId!: string; +} + +export class AllocateContainersDto { + allocations!: ContainerAllocationDto[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-allocation.entity.ts new file mode 100644 index 000000000..8cb186e09 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-allocation.entity.ts @@ -0,0 +1,32 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Booking } from './booking.entity'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ schema: 'freight', name: 'booking_container_allocations' }) +@Index(['bookingId']) +@Index(['vehicleId']) +export class BookingContainerAllocation extends BaseEntity { + @ManyToOne(() => Booking, (b) => b.containerAllocations) + @JoinColumn({ name: 'booking_id' }) + booking!: Booking; + + @Column('uuid', { name: 'booking_id' }) + bookingId!: string; + + @Column('uuid', { name: 'container_id' }) + containerId!: string; + + @ManyToOne(() => Vehicle) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column('uuid', { name: 'vehicle_id', nullable: true }) + vehicleId?: string; + + @Column('text') + containerType!: string; // CONTAINER, BULK_DRY, etc + + @Column('integer', { default: 1 }) + quantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index b9573d680..19aa3a199 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -13,6 +13,7 @@ import { FileRecord } from '../../files/entities/file.entity'; import { BookingApprovalStep } from './booking-approval-step.entity'; import { BookingCargoModifier } from './booking-cargo-modifier.entity'; import { BookingContainer } from './booking-container.entity'; +import { BookingContainerAllocation } from './booking-container-allocation.entity'; import { BookingRateSnapshot } from './booking-rate-snapshot.entity'; import { BookingReviewNote } from './booking-review-note.entity'; @@ -444,6 +445,9 @@ export class Booking extends BaseEntity { @OneToMany(() => BookingContainer, (bc) => bc.booking) bookingContainers?: BookingContainer[]; + @OneToMany(() => BookingContainerAllocation, (ca) => ca.booking) + containerAllocations?: BookingContainerAllocation[]; + @OneToMany(() => BookingCargoModifier, (m) => m.booking) cargoModifiers?: BookingCargoModifier[]; diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts new file mode 100644 index 000000000..b750f1147 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts @@ -0,0 +1,8 @@ +export class FirstMileContainerAllocationDto { + containerId!: string; + vehicleId!: string; +} + +export class AllocateFirstMileContainersDto { + allocations!: FirstMileContainerAllocationDto[]; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts new file mode 100644 index 000000000..b0fa54c32 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts @@ -0,0 +1,36 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { FirstMile } from './first-mile.entity'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ name: 'first_mile_container_allocations', schema: 'freight' }) +@Index(['firstMileId']) +@Index(['vehicleId']) +export class FirstMileContainerAllocation extends BaseEntity { + @Column({ name: 'first_mile_id', type: 'uuid' }) + firstMileId!: string; + + @ManyToOne(() => FirstMile, (firstMile) => firstMile.containerAllocations, { + nullable: false, + eager: false, + }) + @JoinColumn({ name: 'first_mile_id' }) + firstMile?: FirstMile; + + @Column({ name: 'container_id', type: 'uuid' }) + containerId!: string; + + @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) + vehicleId?: string | null; + + @ManyToOne(() => Vehicle, { nullable: true, eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle | null; + + @Column({ name: 'container_type', type: 'text' }) + containerType!: string; + + @Column({ name: 'quantity', type: 'int', default: 1 }) + quantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts index e810d23cc..253d2d4c8 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts @@ -1,8 +1,9 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Booking } from '../../bookings/entities/booking.entity'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { FirstMileContainerAllocation } from './first-mile-container-allocation.entity'; export const FIRST_MILE_STATUSES = [ 'PAYMENT_PENDING', @@ -34,6 +35,7 @@ export class FirstMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; + // TODO: uncomment after migration creates column // @Column({ type: 'boolean', default: false }) // isPostPaymentCompleted!: boolean; @@ -49,4 +51,11 @@ export class FirstMile extends BaseEntity { @ManyToOne(() => Vehicle, { nullable: true, eager: false }) @JoinColumn({ name: 'vehicle_id' }) vehicle?: Vehicle | null; + + @OneToMany( + () => FirstMileContainerAllocation, + (containerAllocation) => containerAllocation.firstMile, + { eager: false }, + ) + containerAllocations!: FirstMileContainerAllocation[]; } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts new file mode 100644 index 000000000..c63a4c9e1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts @@ -0,0 +1,106 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Freight } from '@edr/types'; + +import { + BillingService, + InvoiceEventPayload, +} from '../billing/billing.service'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { FirstMileRepository } from './first-mile.repository'; +import { FirstMile } from './entities/first-mile.entity'; + +/** + * Owns the first-mile ⇄ invoice mapping β€” the one place that knows how a + * first-mile record turns into invoices, which type to use, and how it + * advances when paid. First-mile records are billable entities, so they + * generate their own invoices directly via {@link BillingService}. + */ +@Injectable() +export class FirstMileInvoiceService { + private readonly logger = new Logger(FirstMileInvoiceService.name); + + constructor( + private readonly billing: BillingService, + private readonly firstMileRepo: FirstMileRepository, + ) {} + + /** + * Ensure the first-mile record has its invoice, generating one from the + * remaining payment if absent. Called when a first-mile record reaches a + * billable state. Idempotent β€” returns the existing open invoice instead + * of a duplicate. Returns `null` (and logs) when the record is not billable: + * no company to bill. + */ + async ensureInvoiceFor(record: FirstMile): Promise { + const existing = await this.billing.findPayable( + 'first_mile' as Freight.InvoiceSource, + record.id, + 'DELIVERY_FEE', + ); + if (existing) return existing; + + if (!record.bookingId) { + this.logger.warn( + `Skipping invoice for first-mile record ${record.id}: no booking to reference.`, + ); + return null; + } + + // Fetch the booking to get the companyId and companyProfileId + const fm = record.booking ? record : (await this.firstMileRepo.findById(record.bookingId, { relations: { booking: true } })); + if (!fm) return null; + if (!fm.booking?.companyId) { + this.logger.warn( + `Skipping invoice for first-mile record ${record.id}: no company to bill.`, + ); + return null; + } + + const totalAmount = record.remainingPayment || 0; + if (!Number.isFinite(totalAmount) || totalAmount <= 0) { + this.logger.warn( + `Skipping invoice for first-mile record ${record.id}: no remaining payment.`, + ); + return null; + } + + return this.billing.generateInvoice({ + source: 'first_mile' as Freight.InvoiceSource, + sourceId: record.id, + type: 'DELIVERY_FEE', + companyId: fm.booking!.companyId, + companyProfileId: fm.booking!.companyProfileId || '', + currency: 'ETB', + lines: [ + { + chargeType: 'DELIVERY', + description: 'First-mile delivery', + quantity: 1, + unitRate: totalAmount, + amount: totalAmount, + }, + ], + totalAmount, + }); + } + + /** + * React to a first-mile invoice being paid β€” the settlement branch point. + * Mark the first-mile record as having completed post-payment processing. + */ + @OnEvent('first_mile.invoice.paid') + async onPaid(payload: InvoiceEventPayload): Promise { + if (payload.type === 'DELIVERY_FEE') { + const record = await this.firstMileRepo.findById(payload.sourceId); + if (!record) { + this.logger.warn( + `Cannot mark unknown first-mile record ${payload.sourceId} as paid.`, + ); + return; + } + + this.logger.log(`First-mile invoice paid for record ${payload.sourceId}.`); + } + } +} diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 78c3d43ff..6bb307a4b 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -17,15 +17,20 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; +import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto'; import { FirstMileStatus } from './entities/first-mile.entity'; import { FirstMileService } from './first-mile.service'; +import { FirstMileInvoiceService } from './first-mile-invoice.service'; @ApiTags('first-mile') @ApiBearerAuth() @Controller('first-mile') @TrainSchedulingView() export class FirstMileController { - constructor(private readonly firstMileService: FirstMileService) {} + constructor( + private readonly firstMileService: FirstMileService, + private readonly firstMileInvoiceService: FirstMileInvoiceService, + ) {} @Get() @ApiOperation({ summary: 'List first-mile legs' }) @@ -72,8 +77,13 @@ export class FirstMileController { @Patch(':id') @TrainSchedulingManage() @ApiOperation({ summary: 'Update a first-mile leg' }) - update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) { - return this.firstMileService.update(id, dto); + async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) { + const record = await this.firstMileService.update(id, dto); + // Auto-generate invoice if distance or payment was updated + if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) { + await this.firstMileInvoiceService.ensureInvoiceFor(record); + } + return record; } @Delete(':id') @@ -83,4 +93,14 @@ export class FirstMileController { remove(@Param('id', ParseUUIDPipe) id: string) { return this.firstMileService.remove(id); } + + @Post(':firstMileId/allocate-containers') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Allocate containers to vehicles for a first-mile leg' }) + allocateContainers( + @Param('firstMileId', ParseUUIDPipe) firstMileId: string, + @Body() dto: AllocateFirstMileContainersDto, + ) { + return this.firstMileService.allocateContainers(firstMileId, dto.allocations); + } } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts index bf6815af7..a69c920f1 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts @@ -1,25 +1,29 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BillingModule } from '../billing/billing.module'; import { BookingsModule } from '../bookings/bookings.module'; import { DriversModule } from '../drivers/drivers.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { VehiclesModule } from '../vehicles/vehicles.module'; import { FirstMile } from './entities/first-mile.entity'; +import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; import { FirstMileController } from './first-mile.controller'; +import { FirstMileInvoiceService } from './first-mile-invoice.service'; import { FirstMileRepository } from './first-mile.repository'; import { FirstMileService } from './first-mile.service'; @Module({ imports: [ - TypeOrmModule.forFeature([FirstMile]), + TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]), + BillingModule, forwardRef(() => BookingsModule), VehiclesModule, DriversModule, NotificationsModule, ], controllers: [FirstMileController], - providers: [FirstMileRepository, FirstMileService], - exports: [FirstMileRepository, FirstMileService], + providers: [FirstMileRepository, FirstMileService, FirstMileInvoiceService], + exports: [FirstMileRepository, FirstMileService, FirstMileInvoiceService], }) export class FirstMileModule {} diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 45c2658db..08cd9ab10 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,5 +1,7 @@ import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; @@ -8,6 +10,7 @@ import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; import { FirstMile, FirstMileStatus } from './entities/first-mile.entity'; +import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; import { FirstMileRepository } from './first-mile.repository'; type FirstMileListFilter = { @@ -32,6 +35,7 @@ export class FirstMileService { private readonly logger = new Logger(FirstMileService.name); constructor( + @InjectDataSource() private readonly dataSource: DataSource, private readonly firstMileRepository: FirstMileRepository, private readonly bookingsRepository: BookingsRepository, private readonly vehiclesService: VehiclesService, @@ -273,4 +277,35 @@ export class FirstMileService { await this.findById(id); await this.firstMileRepository.softDelete(id); } + + async allocateContainers( + firstMileId: string, + allocations: Array<{ containerId: string; vehicleId: string }>, + ) { + const firstMile = await this.findById(firstMileId); + if (!firstMile) { + throw new NotFoundException(`First-mile record ${firstMileId} not found`); + } + + await this.dataSource.transaction(async (manager) => { + for (const allocation of allocations) { + await manager.delete(FirstMileContainerAllocation, { + firstMileId, + containerId: allocation.containerId, + }); + await manager.insert(FirstMileContainerAllocation, { + firstMileId, + containerId: allocation.containerId, + vehicleId: allocation.vehicleId, + containerType: 'CONTAINER', + quantity: 1, + }); + } + }); + + return { + success: true, + allocated: allocations.length, + }; + } } diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts new file mode 100644 index 000000000..de86ac883 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts @@ -0,0 +1,8 @@ +export class LastMileContainerAllocationDto { + containerId!: string; + vehicleId!: string; +} + +export class AllocateLastMileContainersDto { + allocations!: LastMileContainerAllocationDto[]; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts new file mode 100644 index 000000000..8a61c73bf --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts @@ -0,0 +1,32 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { LastMile } from './last-mile.entity'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ schema: 'freight', name: 'last_mile_container_allocations' }) +@Index(['lastMileId']) +@Index(['vehicleId']) +export class LastMileContainerAllocation extends BaseEntity { + @ManyToOne(() => LastMile, (lm) => lm.containerAllocations) + @JoinColumn({ name: 'last_mile_id' }) + lastMile!: LastMile; + + @Column('uuid', { name: 'last_mile_id' }) + lastMileId!: string; + + @Column('uuid', { name: 'container_id' }) + containerId!: string; + + @ManyToOne(() => Vehicle) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle | null; + + @Column('uuid', { name: 'vehicle_id', nullable: true }) + vehicleId?: string | null; + + @Column('text') + containerType!: string; + + @Column('integer', { default: 1 }) + quantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index ad4b789f4..1747e308c 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -1,8 +1,9 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Booking } from '../../bookings/entities/booking.entity'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { LastMileContainerAllocation } from './last-mile-container-allocation.entity'; export const LAST_MILE_STATUSES = [ 'PAYMENT_PENDING', @@ -34,6 +35,7 @@ export class LastMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; + // TODO: uncomment after migration creates column // @Column({ type: 'boolean', default: false }) // isPostPaymentCompleted!: boolean; @@ -49,4 +51,7 @@ export class LastMile extends BaseEntity { @ManyToOne(() => Vehicle, { nullable: true, eager: false }) @JoinColumn({ name: 'vehicle_id' }) vehicle?: Vehicle | null; + + @OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile) + containerAllocations?: LastMileContainerAllocation[]; } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts new file mode 100644 index 000000000..c304a89e8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts @@ -0,0 +1,97 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Freight } from '@edr/types'; + +import { + BillingService, + GenerateInvoiceInput, + InvoiceEventPayload, +} from '../billing/billing.service'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { LastMileRepository } from './last-mile.repository'; +import { LastMile } from './entities/last-mile.entity'; + +/** + * Owns the last-mile ⇄ invoice mapping β€” the one place that knows how a last-mile + * record turns into invoices, which type to use, and how it advances when paid. + * Last-mile records are billable business entities for delivery fees, so they + * generate their own invoices directly via {@link BillingService}. All last-mile-specific + * type branching lives here, at the two points it belongs: invoice creation and + * settlement (the paid handler). + */ +@Injectable() +export class LastMileInvoiceService { + private readonly logger = new Logger(LastMileInvoiceService.name); + + constructor( + private readonly billing: BillingService, + private readonly lastMileRepo: LastMileRepository, + ) {} + + /** + * Ensure the last-mile record has its invoice, generating one from the + * remainingPayment if absent. Called when a last-mile record reaches a + * billable state. Idempotent β€” returns the existing open invoice instead + * of a duplicate. Returns `null` (and logs) when the record is not billable: + * no company to bill (invoices FK requires a companyId). + */ + async ensureInvoiceFor(record: LastMile): Promise { + // Check if invoice already exists + const existing = await this.billing.findPayable( + 'last_mile' as Freight.InvoiceSource, + record.id, + 'DELIVERY_FEE', + ); + if (existing) return existing; + + // Can't bill without company + const lm = record.booking ? record : (await this.lastMileRepo.findById(record.id, { relations: { booking: true } })); + if (!lm) return null; + if (!lm.booking?.companyId) { + this.logger.warn( + `Skipping invoice for last-mile record ${record.id}: no company to bill.`, + ); + return null; + } + + // Generate invoice with remainingPayment as totalAmount + const input: GenerateInvoiceInput = { + source: 'last_mile' as Freight.InvoiceSource, + sourceId: record.id, + type: 'DELIVERY_FEE', + companyId: lm.booking!.companyId, + companyProfileId: lm.booking!.companyProfileId || '', + currency: 'ETB', + lines: [ + { + chargeType: 'DELIVERY', + description: 'Last-mile delivery', + quantity: 1, + unitRate: record.remainingPayment || 0, + amount: record.remainingPayment || 0, + }, + ], + totalAmount: record.remainingPayment || 0, + }; + + return this.billing.generateInvoice(input); + } + + /** + * React to a last-mile invoice being paid β€” the settlement branch point. + * Advances the last-mile record to mark post-payment as completed. + */ + @OnEvent('last_mile.invoice.paid') + async onPaid(payload: InvoiceEventPayload): Promise { + if (payload.type === 'DELIVERY_FEE') { + const record = await this.lastMileRepo.findById(payload.sourceId); + if (record) { + this.logger.log(`Last-mile invoice paid for record ${payload.sourceId}.`); + } else { + this.logger.warn( + `Cannot mark last-mile record ${payload.sourceId} as paid: not found.`, + ); + } + } + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index e8abf52c6..929d97a3e 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -17,15 +17,20 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; +import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto'; import { LastMileStatus } from './entities/last-mile.entity'; import { LastMileService } from './last-mile.service'; +import { LastMileInvoiceService } from './last-mile-invoice.service'; @ApiTags('last-mile') @ApiBearerAuth() @Controller('last-mile') @TrainSchedulingView() export class LastMileController { - constructor(private readonly lastMileService: LastMileService) {} + constructor( + private readonly lastMileService: LastMileService, + private readonly lastMileInvoiceService: LastMileInvoiceService, + ) {} @Get() @ApiOperation({ summary: 'List last-mile legs' }) @@ -72,8 +77,13 @@ export class LastMileController { @Patch(':id') @TrainSchedulingManage() @ApiOperation({ summary: 'Update a last-mile leg' }) - update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) { - return this.lastMileService.update(id, dto); + async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) { + const record = await this.lastMileService.update(id, dto); + // Auto-generate invoice if distance or payment was updated + if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) { + await this.lastMileInvoiceService.ensureInvoiceFor(record); + } + return record; } @Delete(':id') @@ -83,4 +93,14 @@ export class LastMileController { remove(@Param('id', ParseUUIDPipe) id: string) { return this.lastMileService.remove(id); } + + @Post(':id/allocate-containers') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Allocate containers to vehicles' }) + async allocateContainers( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AllocateLastMileContainersDto, + ) { + return this.lastMileService.allocateContainers(id, dto.allocations); + } } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts index e4b99a18c..32b688069 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts @@ -1,25 +1,29 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BillingModule } from '../billing/billing.module'; import { BookingsModule } from '../bookings/bookings.module'; import { DriversModule } from '../drivers/drivers.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { VehiclesModule } from '../vehicles/vehicles.module'; import { LastMile } from './entities/last-mile.entity'; +import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileController } from './last-mile.controller'; +import { LastMileInvoiceService } from './last-mile-invoice.service'; import { LastMileRepository } from './last-mile.repository'; import { LastMileService } from './last-mile.service'; @Module({ imports: [ - TypeOrmModule.forFeature([LastMile]), + TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation]), + BillingModule, forwardRef(() => BookingsModule), VehiclesModule, DriversModule, NotificationsModule, ], controllers: [LastMileController], - providers: [LastMileRepository, LastMileService], - exports: [LastMileRepository, LastMileService], + providers: [LastMileRepository, LastMileService, LastMileInvoiceService], + exports: [LastMileRepository, LastMileService, LastMileInvoiceService], }) export class LastMileModule {} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 77a8a2fea..5faad49b9 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere } from 'typeorm'; +import { DataSource, FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; @@ -8,6 +8,7 @@ import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; +import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileRepository } from './last-mile.repository'; type LastMileListFilter = { @@ -37,6 +38,7 @@ export class LastMileService { private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly smsClient: SmsClientService, + private readonly dataSource: DataSource, ) {} async acceptBooking(bookingReference: string): Promise { @@ -206,4 +208,35 @@ export class LastMileService { await this.findById(id); await this.lastMileRepository.softDelete(id); } + + async allocateContainers( + lastMileId: string, + allocations: Array<{ containerId: string; vehicleId: string }>, + ) { + const lastMile = await this.findById(lastMileId); + if (!lastMile) { + throw new NotFoundException(`Last-mile record ${lastMileId} not found`); + } + + await this.dataSource.transaction(async (manager) => { + for (const allocation of allocations) { + await manager.delete(LastMileContainerAllocation, { + lastMileId, + containerId: allocation.containerId, + }); + await manager.insert(LastMileContainerAllocation, { + lastMileId, + containerId: allocation.containerId, + vehicleId: allocation.vehicleId, + containerType: 'CONTAINER', + quantity: 1, + }); + } + }); + + return { + success: true, + allocated: allocations.length, + }; + } } 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 71371989b..347fedc1e 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -462,16 +462,29 @@ export class PaymentService { failureCode?: string; failureMessage?: string; }): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> { + console.log(`Received payment event: ${JSON.stringify(event)}`); if (event.eventType === "payment.succeeded") { const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); if (!intent) { return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; } + console.log(`Processing payment succeeded event for intent: }`,intent); const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { providerTxnId: event.providerTxnId, paidAt: event.paidAt ? new Date(event.paidAt) : undefined, notify: true, }); + console.log(`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}`); return { processed: true, alreadyFinalized }; } 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