+
+ 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 580173cc1..a0a119861 100644
--- a/apps/edr-freight-api/package.json
+++ b/apps/edr-freight-api/package.json
@@ -6,7 +6,7 @@
"scripts": {
"clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"",
"predev": "pnpm run clean",
- "dev": "nest start --watch",
+ "dev": "nest start --watch --clearScreen false",
"prebuild": "pnpm run clean",
"build": "nest build",
"start": "node dist/main.js",
@@ -17,9 +17,24 @@
"type-check": "tsc --noEmit",
"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",
+ "seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
+ "seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
+ "auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
- "seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
+ "seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
+ "seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
+ "iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
+ "iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
+ "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
+ "iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
+ "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js",
+ "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
+ "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
},
"dependencies": {
"@edr/api-common": "workspace:*",
@@ -38,13 +53,15 @@
"@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
- "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.4.tgz",
+ "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
"axios": "^1.16.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
+ "cross-env": "^10.1.0",
"dotenv": "^17.4.2",
+ "dotenv-cli": "^11.0.0",
"handlebars": "^4.7.9",
"libphonenumber-js": "^1.13.6",
"minio": "7.1.3",
@@ -69,13 +86,15 @@
"@types/node": "^20.14.0",
"@types/pg": "^8.6.7",
"@types/supertest": "^6.0.2",
+ "@types/vorpal": "^1.12.8",
"jest": "^29.7.0",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
- "typescript": "^5.5.4"
+ "typescript": "^5.5.4",
+ "vorpal": "^1.12.0"
},
"jest": {
"moduleFileExtensions": [
diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts
index 3b69d2812..f40e4f40f 100644
--- a/apps/edr-freight-api/src/app.module.ts
+++ b/apps/edr-freight-api/src/app.module.ts
@@ -2,6 +2,7 @@ import { Module, OnApplicationBootstrap } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import { ScheduleModule } from "@nestjs/schedule";
+import { EventEmitterModule } from "@nestjs/event-emitter";
import { DataSource, DataSourceOptions } from "typeorm";
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
@@ -13,7 +14,7 @@ import telebirrConfig from "./config/telebirr.config";
import rabbitmqConfig from "./config/rabbitmq.config";
import { BookingsModule } from "./modules/bookings/bookings.module";
-import { BookingOrdersModule } from "./modules/booking-orders/booking-orders.module";
+import { ContractsModule } from "./modules/contracts/contracts.module";
import { SignaturesModule } from "./modules/signatures/signatures.module";
import { FilesModule } from "./modules/files/files.module";
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
@@ -52,8 +53,12 @@ import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
+import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
+import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
+import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
+import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
//New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module";
import { WagonsModule } from './modules/wagons/wagons.module';
@@ -64,9 +69,12 @@ import { WarehousesModule } from './modules/warehouses/warehouses.module';
import { OverviewModule } from './modules/overview/overview.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module';
import { DriversModule } from './modules/drivers/drivers.module';
+import { FuelModule } from './modules/fuel/fuel.module';
+import { MaintenanceModule } from './modules/maintenance/maintenance.module';
import { FirstMileModule } from './modules/first-mile/first-mile.module';
import { LastMileModule } from './modules/last-mile/last-mile.module';
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
+import { ImportOperationsModule } from './modules/import-operations/import-operations.module';
@Module({
imports: [
@@ -75,7 +83,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
}),
ScheduleModule.forRoot(),
- // EventEmitterModule.forRoot(),
+ EventEmitterModule.forRoot(),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
@@ -95,7 +103,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
permissions: EDR_FREIGHT_PERMISSIONS,
}),
BookingsModule,
- BookingOrdersModule,
+ ContractsModule,
SignaturesModule,
FilesModule,
ConsignmentsModule,
@@ -127,9 +135,12 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
OverviewModule,
VehiclesModule,
DriversModule,
+ FuelModule,
+ MaintenanceModule,
FirstMileModule,
LastMileModule,
InterchangeDocumentsModule,
+ ImportOperationsModule,
],
providers: [
EdrOrgSeeder,
@@ -139,12 +150,16 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder,
DemoFreightDataSeeder,
+ GovCompaniesSeeder,
IndodeFacilitySeeder,
Batch14TestDataSeeder,
Batch5TestDataSeeder,
Batch7TestDataSeeder,
Batch8TestDataSeeder,
WarehouseDemoSeeder,
+ ExportDjiboutiInterchangeDemoSeeder,
+ MarshallingDemoTrainsSeeder,
+ ApprovedFirstLastMileDemoBookingsSeeder,
],
})
export class AppModule implements OnApplicationBootstrap {
@@ -161,8 +176,11 @@ export class AppModule implements OnApplicationBootstrap {
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
+ private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
+ private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
+ private readonly govCompaniesSeeder: GovCompaniesSeeder,
) { }
async onApplicationBootstrap() {
@@ -179,6 +197,8 @@ export class AppModule implements OnApplicationBootstrap {
await this.batch7TestDataSeeder.run();
await this.batch8TestDataSeeder.run();
await this.warehouseDemoSeeder.run();
+ await this.exportDjiboutiInterchangeDemoSeeder.run();
+ await this.marshallingDemoTrainsSeeder.run();
// Idempotent demo data: β₯100 wagons/type, approval chains, 4 staff users.
// Each block self-guards on an empty-table check, so this is safe every boot.
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
@@ -187,5 +207,8 @@ export class AppModule implements OnApplicationBootstrap {
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
// rules are disabled inside the seeder). Kept running for the staff users.
await this.demoFreightDataSeeder.run();
+ // Government entities (with importer/exporter profiles) that government
+ // bookings bill to. Idempotent β keyed by fixed IDs.
+ await this.govCompaniesSeeder.run();
}
}
diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts
index 0e7375b19..47d8f5b3c 100644
--- a/apps/edr-freight-api/src/config/database.config.ts
+++ b/apps/edr-freight-api/src/config/database.config.ts
@@ -116,8 +116,10 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
freightMigrationsGlob,
],
migrationsRun: true,
+ migrationsTransactionMode: "each",
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
synchronize: false,
- logging: process.env.NODE_ENV === "development",
+ logging:
+ process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"],
};
});
diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts
new file mode 100644
index 000000000..05061ed9a
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts
@@ -0,0 +1,264 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+
+import { ContractsRepository } from '../modules/contracts/contracts.repository';
+import { Contract } from '../modules/contracts/entities/contract.entity';
+import { ContractRoute } from '../modules/contracts/entities/contract-route.entity';
+import {
+ ContractSignature,
+ ContractSignerRole,
+} from '../modules/contracts/entities/contract-signature.entity';
+import { ContractPricingBreakdown } from '../modules/contracts/contract-pricing.service';
+import { ContractTemplateResolver } from './contract-template.resolver';
+import { getTemplateMeta } from './contract-template.registry';
+import { ContractViewModel } from './contract-view-model.builder';
+
+/**
+ * Signature row for the contract PDF. Mirrors the booking builder's
+ * `ContractSignatureView` but widens `role` to the contract's signer roles
+ * (CUSTOMER | STAFF | DIRECTOR | CEO).
+ */
+export interface ContractDocumentSignatureView {
+ role: ContractSignerRole;
+ signerDisplayName: string;
+ signedAt: string;
+ signatureImageUrl?: string | null;
+}
+
+/** A single unit-rate row on the contract PDF β price per unit, NO total. */
+export interface ContractUnitRateRow {
+ label: string;
+ unitPrice: number;
+ unit: string;
+ currency: string;
+}
+
+/**
+ * Pricing schedule for a Contract document: a unit-rate schedule (one price per
+ * unit, e.g. "X ETB / container") with NO quantities and NO grand total. Shaped
+ * to stay structurally compatible with the renderer's expectations of
+ * {@link ContractViewModel.pricing} (it reads `currency`).
+ */
+export interface ContractUnitRateSchedule {
+ displayMode: 'UNIT_RATES';
+ unitRates: ContractUnitRateRow[];
+ currency: string;
+ equipmentReturn?: string;
+ originLabel: string;
+ destinationLabel: string;
+}
+
+/** Map a stored contract unit to a human PDF suffix ("/ container", "/ ton", β¦). */
+function unitLabel(unit: string): string {
+ switch (unit) {
+ case 'per_container':
+ return 'container';
+ case 'per_ton':
+ return 'ton';
+ case 'per_item':
+ return 'item';
+ case 'per_km':
+ return 'km';
+ default:
+ return 'unit';
+ }
+}
+
+/**
+ * Builds the contract PDF view-model from the {@link Contract} aggregate (the new
+ * source of truth) β mirrors {@link ContractViewModelBuilder} but every field is
+ * sourced from the contract, its routes, cargo scope and unit-rate breakdown.
+ * The legacy booking-based builder remains untouched for the migration window.
+ */
+@Injectable()
+export class ContractDocumentViewModelBuilder {
+ constructor(
+ private readonly contractsRepository: ContractsRepository,
+ private readonly templateResolver: ContractTemplateResolver,
+ ) {}
+
+ async build(
+ contractId: string,
+ ): Promise<{ contract: Contract; view: ContractViewModel }> {
+ const contract = await this.contractsRepository.findByIdWithRelations(contractId);
+ if (!contract) {
+ throw new NotFoundException(`Contract ${contractId} not found`);
+ }
+
+ const templateKey =
+ contract.contractTemplateKey ?? this.templateResolver.resolve(this.toResolverInput(contract));
+ const template = getTemplateMeta(templateKey);
+ const pricing = this.buildPricing(contract);
+ const signatures = await this.loadSignatures(contractId);
+
+ const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
+ const hasStaff = signatures.some((s) => s.role === 'STAFF');
+ const hasContractFile = Boolean(
+ contract.files?.some((f) => f.code === 'contract'),
+ );
+
+ const view: ContractViewModel = {
+ bookingId: contract.id,
+ reference: contract.reference,
+ status: contract.status,
+ templateKey,
+ template,
+ contractDate: new Date().toLocaleDateString('en-GB', {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ }),
+ contractYear: new Date().getFullYear(),
+ client: {
+ companyName: contract.company?.name ?? 'Client',
+ companyAddress: this.valueOrDash(contract.company?.address),
+ companyLocation: this.valueOrDash(contract.company?.country),
+ phone: this.valueOrDash(contract.company?.phone),
+ email: this.valueOrDash(contract.company?.email),
+ tinNumber: this.valueOrDash(contract.company?.tin),
+ vatNumber: this.valueOrDash(contract.company?.vatNumber),
+ fanNumber: this.valueOrDash(contract.company?.fanNumber),
+ businessLicense: this.valueOrDash(
+ contract.company?.companyProfiles?.[0]?.businessLicense,
+ ),
+ },
+ provider: {
+ name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
+ address: 'Addis Ababa, Ethiopia',
+ phone: '+251 11 872 0000',
+ email: 'info@edr.gov.et',
+ tinNumber: 'β',
+ },
+ schedule: this.buildSchedule(contract),
+ pricing: pricing as unknown as ContractViewModel['pricing'],
+ // Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking
+ // view-model's narrower CUSTOMER|STAFF role union.
+ signatures: signatures as unknown as ContractViewModel['signatures'],
+ canSignCustomer: contract.status === 'CONTRACT_READY' && !hasCustomer,
+ canSignStaff:
+ contract.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
+ hasContractDocument: hasContractFile,
+ hasCustomerSignature: hasCustomer,
+ hasStaffSignature: hasStaff,
+ };
+
+ return { contract, view };
+ }
+
+ private async loadSignatures(
+ contractId: string,
+ ): Promise {
+ const rows = await this.contractsRepository.findSignatures(contractId);
+ return rows.map((s) => this.toSignatureView(s));
+ }
+
+ toSignatureView(row: ContractSignature): ContractDocumentSignatureView {
+ return {
+ role: row.role,
+ signerDisplayName: row.signerDisplayName,
+ signedAt: this.formatDate(row.signedAt),
+ signatureImageUrl: row.signatureFile?.url ?? null,
+ };
+ }
+
+ /** Unit-rate schedule from the contract's frozen pricing breakdown β NO totals. */
+ private buildPricing(contract: Contract): ContractUnitRateSchedule {
+ const breakdown = contract.pricingBreakdown as ContractPricingBreakdown | null;
+ const currency = breakdown?.currency ?? contract.paymentCurrency;
+ const lineItems = breakdown?.lineItems ?? [];
+ const firstRoute = this.firstRoute(contract);
+
+ return {
+ displayMode: 'UNIT_RATES',
+ unitRates: lineItems.map((line) => ({
+ label: line.label,
+ unitPrice: line.unitPrice,
+ unit: unitLabel(line.unit),
+ currency,
+ })),
+ currency,
+ equipmentReturn: contract.equipmentReturn ?? 'β',
+ originLabel: this.yardLabel(firstRoute?.originYard),
+ destinationLabel: this.yardLabel(firstRoute?.destinationYard),
+ };
+ }
+
+ private buildSchedule(contract: Contract): ContractViewModel['schedule'] {
+ const firstRoute = this.firstRoute(contract);
+ const cargoScope = (contract.cargoScope ?? [])[0];
+ const cargoName =
+ cargoScope?.cargoType?.cargoTypeName ||
+ cargoScope?.cargoFreeText ||
+ (cargoScope?.containerSize
+ ? `${cargoScope.containerSize} container`
+ : 'Container cargo');
+
+ return {
+ originLabel: this.yardLabel(firstRoute?.originYard),
+ destinationLabel: this.yardLabel(firstRoute?.destinationYard),
+ tradeDirection: this.valueOrDash(contract.tradeDirection),
+ freightType: this.valueOrDash(contract.freightType),
+ serviceType: this.valueOrDash(
+ contract.serviceType?.serviceName ?? contract.serviceType?.code,
+ ),
+ // Estimated shipment date was removed from the contract wizard; the
+ // binding scheduled date is set per-booking, not on the contract.
+ scheduledDate: this.formatDate(null),
+ contractType: this.valueOrDash(contract.contractType),
+ cargoDescription: this.valueOrDash(cargoName),
+ totalWeightVgm: 'β',
+ equipmentReturn: this.valueOrDash(contract.equipmentReturn),
+ hazardousLabel: contract.isHazardous ? 'Yes' : 'No',
+ firstMilePickupAddress: this.valueOrDash(contract.firstMilePickupAddress),
+ lastMileDeliveryAddress: this.valueOrDash(contract.lastMileDeliveryAddress),
+ };
+ }
+
+ /** The contract's primary route (lowest sortOrder), used for origin/destination labels. */
+ private firstRoute(contract: Contract): ContractRoute | undefined {
+ const routes = [...(contract.routes ?? [])].sort(
+ (a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
+ );
+ return routes[0];
+ }
+
+ /**
+ * The template resolver reads a Booking; a contract carries equivalent fields
+ * under a different shape (cargoType lives on cargoScope). Build a minimal,
+ * structurally-compatible adapter rather than widening the resolver signature.
+ */
+ private toResolverInput(
+ contract: Contract,
+ ): Parameters[0] {
+ const cargoType = (contract.cargoScope ?? []).find((c) => c.cargoType)?.cargoType;
+ return {
+ tradeDirection: contract.tradeDirection,
+ freightType: contract.freightType,
+ paymentCurrency: contract.paymentCurrency,
+ cargoType: cargoType ?? undefined,
+ serviceType: contract.serviceType,
+ } as Parameters[0];
+ }
+
+ private yardLabel(yard?: { label?: string; code?: string } | null): string {
+ return this.valueOrDash(yard?.label ?? yard?.code);
+ }
+
+ private formatDate(value?: Date | string | null): string {
+ if (!value) return 'β';
+ const date = value instanceof Date ? value : new Date(value);
+ if (Number.isNaN(date.getTime())) return 'β';
+ return date.toLocaleDateString('en-GB', {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ });
+ }
+
+ private valueOrDash(value?: string | number | null): string {
+ if (value === undefined || value === null || value === '') return 'β';
+ return String(value);
+ }
+}
+
+// Re-export for callers that want the role union without importing the entity.
+export type { ContractSignerRole };
diff --git a/apps/edr-freight-api/src/contracts/contract-pdf.service.ts b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts
index 9e0acc6fb..db2f7693e 100644
--- a/apps/edr-freight-api/src/contracts/contract-pdf.service.ts
+++ b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts
@@ -80,8 +80,15 @@ export class ContractPdfService {
this.logger.error(
`Puppeteer PDF failed (executable=${executablePath ?? 'default'}): ${err}`,
);
+ const fallback = this.htmlToBasicPdfBuffer(preparedHtml);
+ if (this.isValidPdf(fallback)) {
+ this.logger.warn(
+ `Using basic PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
+ );
+ return fallback;
+ }
throw new InternalServerErrorException(
- 'Contract PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
+ 'PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
);
}
}
@@ -113,4 +120,85 @@ export class ContractPdfService {
buffer.subarray(0, 5).toString('ascii') === '%PDF-'
);
}
+
+ private htmlToBasicPdfBuffer(html: string): Buffer {
+ const text = this.htmlToPlainText(html);
+ const lines = this.wrapLines(text, 92).slice(0, 72);
+ const body = lines
+ .map((line, index) => {
+ const prefix = index === 0 ? '50 790 Td' : '0 -12 Td';
+ return `${prefix} (${this.escapePdfText(line)}) Tj`;
+ })
+ .join('\n');
+ const stream = `BT\n/F1 10 Tf\n12 TL\n${body}\nET`;
+
+ const objects = [
+ '<< /Type /Catalog /Pages 2 0 R >>',
+ '<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
+ '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
+ '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
+ `<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`,
+ ];
+
+ let pdf = '%PDF-1.4\n';
+ const offsets: number[] = [0];
+ objects.forEach((object, index) => {
+ offsets.push(Buffer.byteLength(pdf, 'latin1'));
+ pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
+ });
+ while (Buffer.byteLength(pdf, 'latin1') < MIN_VALID_PDF_BYTES) {
+ pdf += '% fallback padding\n';
+ }
+ const xrefOffset = Buffer.byteLength(pdf, 'latin1');
+ pdf += `xref\n0 ${objects.length + 1}\n`;
+ pdf += '0000000000 65535 f \n';
+ for (const offset of offsets.slice(1)) {
+ pdf += `${String(offset).padStart(10, '0')} 00000 n \n`;
+ }
+ pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
+ return Buffer.from(pdf, 'latin1');
+ }
+
+ private htmlToPlainText(html: string): string {
+ return html
+ .replace(/
+