+
+ Currency (ETB vs USD) is set per-booking via paymentCurrency. Pick an ETB booking to test Telebirr, a USD booking to test Card.
+
+
+
+
+
+
Reference
β
+
Amount
β
+
Currency
β
+
Status
β
+
Pay status
β
+
+
+
+
+
+
+
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]) => `
${esc(label)}
${esc(value)}
`).join('')}
+
+
+
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