diff --git a/apps/edr-freight-api/src/contracts/templates/last-mile.hbs b/apps/edr-freight-api/src/contracts/templates/last-mile.hbs new file mode 100644 index 000000000..9437bb18b --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/last-mile.hbs @@ -0,0 +1,114 @@ + + + + + Last-Mile Delivery Contract — {{bookingReference}} + + + +
+
+
EDR
+
+

Ethio-Djibouti Standard Gauge Railway Share Company

+

Last-Mile Delivery Contract

+
+
+ +

Last-Mile Delivery Contract

+

Booking {{bookingReference}} — {{companyName}}

+ +

Shipment Details

+ + + + {{#if containerCount}} + + + {{/if}} + {{#if cargoDescription}} + + {{/if}} + {{#if deliveryAddress}} + + {{/if}} + {{#if trainDepartureDate}} + + {{/if}} + + + +
Client{{companyName}}
Booking Reference{{bookingReference}}
Number of Containers{{containerCount}}
Containers{{containerList}}
Cargo Description{{cargoDescription}}
Delivery Address{{deliveryAddress}}
Train Departure from Djibouti{{trainDepartureDate}}
Last-Mile Delivery Date{{deliveryDate}}
Request Date{{requestDate}}
Approval Date{{approvalDate}}
+ +

Rates

+ + + + + + {{#each rateLines}} + + {{/each}} + {{#if estimatedKm}} + + {{/if}} + + + + +
DescriptionAmount{{#if currency}} ({{currency}}){{/if}}
{{description}}{{amount}}
Estimated distance{{estimatedKm}} km
Advance payable on signing{{advanceAmount}}{{#if currency}} {{currency}}{{/if}}
+ +

Terms

+
+

1. The Service Provider shall deliver the goods identified above from the arrival yard to the Client's delivery address on or about the last-mile delivery date stated above.

+

2. The Client shall pay the advance stated above upon signing this contract. The final delivery fee is computed on completion per the Service Provider's published last-mile rates and actual distance.

+

3. The Client shall ensure access and receipt of the goods at the delivery address. Waiting time and truck detention beyond free time may incur additional charges per the applicable tariff.

+

4. This contract is governed by the laws applicable to the Ethio-Djibouti Standard Gauge Railway Share Company's freight services.

+
+ +

Signatures

+
+
+

Client

+ {{#if signature}} + Customer signature +
{{signature.signerDisplayName}} — signed {{signature.signedAt}}
+ {{#if signature.consentText}}{{/if}} + {{else}} +

Awaiting customer signature.

+
Name, signature & date
+ {{/if}} +
+
+

Service Provider

+

Ethio-Djibouti Standard Gauge Railway Share Company

+
Authorized representative
+
+
+
+ + diff --git a/apps/edr-freight-api/src/migrations/3290000000000-LastMileRequestContract.ts b/apps/edr-freight-api/src/migrations/3290000000000-LastMileRequestContract.ts new file mode 100644 index 000000000..d4971f903 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3290000000000-LastMileRequestContract.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Last-mile contract fields on freight.last_mile_requests: the customer-chosen + * delivery date, the chief-approved advance amount (held until the invoice is + * generated at signing time), the rate snapshot rendered into the contract, + * and the customer signature bookkeeping. The signed PDF and signature image + * live in freight.files (resource 'last_mile_requests'). + */ +export class LastMileRequestContract3290000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_requests + ADD COLUMN IF NOT EXISTS requested_delivery_date date, + ADD COLUMN IF NOT EXISTS approved_advance_amount numeric(14,2), + ADD COLUMN IF NOT EXISTS contract_summary jsonb, + ADD COLUMN IF NOT EXISTS contract_generated_at timestamptz, + ADD COLUMN IF NOT EXISTS customer_signed_at timestamptz, + ADD COLUMN IF NOT EXISTS signer_display_name varchar(160), + ADD COLUMN IF NOT EXISTS consent_text text + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_requests + DROP COLUMN IF EXISTS requested_delivery_date, + DROP COLUMN IF EXISTS approved_advance_amount, + DROP COLUMN IF EXISTS contract_summary, + DROP COLUMN IF EXISTS contract_generated_at, + DROP COLUMN IF EXISTS customer_signed_at, + DROP COLUMN IF EXISTS signer_display_name, + DROP COLUMN IF EXISTS consent_text + `); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/dto/sign-last-mile-contract.dto.ts b/apps/edr-freight-api/src/modules/last-mile-requests/dto/sign-last-mile-contract.dto.ts new file mode 100644 index 000000000..9ebd25d38 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/sign-last-mile-contract.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class SignLastMileContractDto { + @ApiPropertyOptional({ + description: + 'Signature PNG as base64 (data URI or raw). Omitted = reuse the saved profile signature.', + }) + @IsOptional() + @IsString() + signatureImageBase64?: string; + + @ApiProperty({ description: 'Name shown under the signature.' }) + @IsString() + @IsNotEmpty() + @MaxLength(160) + signerDisplayName!: string; + + @ApiPropertyOptional({ description: 'The consent statement the customer agreed to.' }) + @IsOptional() + @IsString() + @MaxLength(500) + consentText?: string; +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/dto/submit-last-mile-request.dto.ts b/apps/edr-freight-api/src/modules/last-mile-requests/dto/submit-last-mile-request.dto.ts index b84595f7c..2978f8381 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/dto/submit-last-mile-request.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/submit-last-mile-request.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { ArrayNotEmpty, ArrayUnique, IsArray, IsString } from 'class-validator'; +import { ArrayNotEmpty, ArrayUnique, IsArray, IsDateString, IsString } from 'class-validator'; export class SubmitLastMileRequestDto { @ApiProperty({ @@ -12,4 +12,12 @@ export class SubmitLastMileRequestDto { @ArrayUnique() @IsString({ each: true }) containerNumbers!: string[]; + + @ApiProperty({ + description: + 'Requested last-mile delivery date (ISO date), chosen by the customer against the train departure from Djibouti.', + example: '2026-08-15', + }) + @IsDateString() + deliveryDate!: string; } diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/entities/last-mile-request.entity.ts b/apps/edr-freight-api/src/modules/last-mile-requests/entities/last-mile-request.entity.ts index 0077d7f0a..b85c2ad5c 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/entities/last-mile-request.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/entities/last-mile-request.entity.ts @@ -65,6 +65,47 @@ export class LastMileRequest extends BaseEntity { @Column({ name: 'resulting_last_mile_id', type: 'uuid', nullable: true }) resultingLastMileId?: string | null; + /** Customer-chosen last-mile delivery date (guided by the train's Djibouti departure). */ + @Column({ name: 'requested_delivery_date', type: 'date', nullable: true }) + requestedDeliveryDate?: string | null; + + /** Chief-approved advance — invoiced only after the customer signs the LM contract. */ + @Column({ + name: 'approved_advance_amount', + type: 'numeric', + precision: 14, + scale: 2, + nullable: true, + transformer: { + to: (v?: number | null) => v, + from: (v?: string | null) => (v == null ? null : Number(v)), + }, + }) + approvedAdvanceAmount?: number | null; + + /** Rate snapshot taken at approval, rendered into the contract document. */ + @Column({ name: 'contract_summary', type: 'jsonb', nullable: true }) + contractSummary?: { + estimatedKm: number | null; + mode: string | null; + currency: string | null; + total: number | null; + lines: Array<{ description: string; amount: number }>; + advanceAmount: number; + } | null; + + @Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true }) + contractGeneratedAt?: Date | null; + + @Column({ name: 'customer_signed_at', type: 'timestamptz', nullable: true }) + customerSignedAt?: Date | null; + + @Column({ name: 'signer_display_name', type: 'varchar', length: 160, nullable: true }) + signerDisplayName?: string | null; + + @Column({ name: 'consent_text', type: 'text', nullable: true }) + consentText?: string | null; + @ManyToOne(() => LastMile, { nullable: true, eager: false }) @JoinColumn({ name: 'resulting_last_mile_id' }) resultingLastMile?: LastMile | null; diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-contract.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-contract.service.ts new file mode 100644 index 000000000..c30112617 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-contract.service.ts @@ -0,0 +1,318 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; +import Handlebars from 'handlebars'; +import { Readable } from 'stream'; +import { DataSource } from 'typeorm'; +import { LastMileRequestStatus } from '@edr/types'; + +import { ContractPdfService } from '../../contracts/contract-pdf.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingsService } from '../bookings/bookings.service'; +import { FilesService } from '../files/files.service'; +import { FileRecord } from '../files/entities/file.entity'; +import { MinioService } from '../minio/minio.service'; +import { SignaturesService } from '../signatures/signatures.service'; +import { SignLastMileContractDto } from './dto/sign-last-mile-contract.dto'; +import { LastMileRequest } from './entities/last-mile-request.entity'; +import { LastMileRequestsRepository } from './last-mile-requests.repository'; +import { LastMileRequestsService } from './last-mile-requests.service'; + +const FILE_RESOURCE = 'last_mile_requests'; + +/** + * The LM contract in front of the advance payment: generated when the chief + * approves the request, viewed and signed by the customer in the portal, and + * only then invoiced (LastMileRequestsService.generateAdvanceInvoice). Single + * signer (customer), so the signature lives on the request row itself — no + * signature-rows table like bookings/CRSP contracts need for multi-role. + */ +@Injectable() +export class LastMileContractService { + private readonly logger = new Logger(LastMileContractService.name); + private compiledTemplate: Handlebars.TemplateDelegate | null = null; + + constructor( + private readonly requestsRepository: LastMileRequestsRepository, + private readonly requestsService: LastMileRequestsService, + private readonly bookingsService: BookingsService, + private readonly filesService: FilesService, + private readonly minioService: MinioService, + private readonly pdfService: ContractPdfService, + private readonly signaturesService: SignaturesService, + private readonly dataSource: DataSource, + ) {} + + async getContractView(id: string, viewerUserId?: string | null) { + const request = await this.requireApprovedRequest(id); + const booking = await this.requireBooking(request); + const view = await this.buildViewModel(request, booking); + const html = this.render(view); + const savedSignature = viewerUserId + ? ((await this.signaturesService.getForUser(viewerUserId)) ?? undefined) + : undefined; + return { + requestId: request.id, + bookingId: request.bookingId, + bookingReference: booking.reference, + status: request.status, + html, + customerSignedAt: request.customerSignedAt ?? null, + signerDisplayName: request.signerDisplayName ?? null, + canSign: !request.customerSignedAt, + savedSignature, + }; + } + + async streamContract(id: string) { + const request = await this.requireApprovedRequest(id); + const booking = await this.requireBooking(request); + const record = await this.upsertContractPdf(request, booking); + return this.filesService.streamById(record.id); + } + + async sign( + id: string, + dto: SignLastMileContractDto, + signerUserId: string | null, + ): Promise { + const request = await this.requireApprovedRequest(id); + if (request.customerSignedAt) { + throw new BadRequestException('This last-mile contract is already signed'); + } + const booking = await this.requireBooking(request); + + if (signerUserId) { + const companyId = await this.bookingsService.resolveCustomerCompanyId(signerUserId); + if (companyId && booking.companyId && companyId !== booking.companyId) { + throw new BadRequestException('This request does not belong to your company'); + } + } + + // Drawn signature wins; otherwise fall back to the saved profile signature + // (same contract-signing convention as modules/contracts). + let imageBase64 = dto.signatureImageBase64; + if (!imageBase64 && signerUserId) { + const saved = await this.signaturesService.getForUser(signerUserId); + if (saved?.signatureImageUrl?.startsWith('data:')) { + imageBase64 = saved.signatureImageUrl; + } + } + if (!imageBase64) { + throw new BadRequestException( + 'No signature image provided and no saved signature on your profile', + ); + } + + const buffer = this.decodeSignatureImage(imageBase64); + const sigFile = this.toUploadFile( + `signature-customer-${booking.reference ?? request.id}.png`, + 'image/png', + buffer, + ); + const fileRecord = await this.filesService.upsertByCode({ + resourceId: request.id, + resource: FILE_RESOURCE, + code: 'signature_customer', + file: sigFile, + }); + + await this.requestsRepository.update(id, { + customerSignedAt: new Date(), + signerDisplayName: dto.signerDisplayName, + consentText: dto.consentText ?? null, + } as Partial); + + // Best-effort: keep the reusable profile signature fresh for next time. + if (signerUserId && dto.signatureImageBase64) { + try { + await this.signaturesService.upsertForUser({ + userId: signerUserId, + signerDisplayName: dto.signerDisplayName, + signatureImageBase64: dto.signatureImageBase64, + }); + } catch (err) { + this.logger.warn(`Could not save reusable signature for user ${signerUserId}: ${err}`); + } + } + + const signed = (await this.requestsRepository.findById(id, { + relations: { booking: { company: true } }, + }))!; + + // Render + store the signed PDF, then invoice the advance. PDF failure must + // not block the invoice — the document re-renders on view/download. + try { + await this.upsertContractPdf(signed, booking, fileRecord); + } catch (err) { + this.logger.warn( + `Signed LM contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`, + ); + } + await this.requestsService.generateAdvanceInvoice(signed); + + return signed; + } + + private async upsertContractPdf( + request: LastMileRequest, + booking: Booking, + signatureRecord?: FileRecord, + ): Promise { + const view = await this.buildViewModel(request, booking, signatureRecord); + const html = this.render(view); + const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html); + const companyName = booking.company?.name ?? 'Customer'; + const fileName = `LM_${companyName.replace(/[^A-Za-z0-9._-]+/g, '_')}.pdf`; + const file = this.toUploadFile(fileName, 'application/pdf', pdfBuffer); + return this.filesService.upsertByCode({ + resourceId: request.id, + resource: FILE_RESOURCE, + code: 'contract', + file, + }); + } + + private async buildViewModel( + request: LastMileRequest, + booking: Booking, + signatureRecord?: FileRecord, + ) { + const summary = request.contractSummary; + const containers = request.requestedContainerNumbers ?? []; + const cargoDescription = + booking.cargoFreeText || booking.cargoType?.cargoTypeName || null; + + const departedRows: Array<{ departedAt: Date | null }> = await this.dataSource.query( + `SELECT departed_from_djibouti_at AS "departedAt" + FROM freight.import_djibouti_operations + WHERE train_schedule_id = $1 AND deleted_at IS NULL + LIMIT 1`, + [request.trainScheduleId], + ); + + return { + companyName: booking.company?.name ?? 'Customer', + bookingReference: booking.reference ?? request.bookingId, + containerCount: containers.length || null, + containerList: containers.join(', '), + cargoDescription, + deliveryAddress: booking.lastMileDeliveryAddress ?? null, + trainDepartureDate: this.formatDate(departedRows[0]?.departedAt), + deliveryDate: this.formatDate(request.requestedDeliveryDate) ?? '—', + requestDate: this.formatDate(request.submittedAt ?? request.reminderSentAt ?? request.createdAt) ?? '—', + approvalDate: this.formatDate(request.reviewedAt) ?? '—', + currency: summary?.currency ?? booking.paymentCurrency ?? 'ETB', + rateLines: (summary?.lines ?? []).map((l) => ({ + description: l.description, + amount: this.formatAmount(l.amount), + })), + estimatedKm: summary?.estimatedKm ?? null, + advanceAmount: this.formatAmount( + summary?.advanceAmount ?? request.approvedAdvanceAmount ?? 0, + ), + signature: request.customerSignedAt + ? { + signerDisplayName: request.signerDisplayName ?? '', + signedAt: this.formatDate(request.customerSignedAt) ?? '', + consentText: request.consentText ?? null, + imageUrl: await this.signatureImageDataUri(request, signatureRecord), + } + : null, + }; + } + + /** Signature PNG as a data URI so the PDF renderer needs no MinIO access. */ + private async signatureImageDataUri( + request: LastMileRequest, + signatureRecord?: FileRecord, + ): Promise { + try { + const record = + signatureRecord ?? + (await this.filesService.findByCode(request.id, FILE_RESOURCE, 'signature_customer')); + if (!record.url) return null; + const objectName = this.minioService.getObjectNameFromUrl(record.url); + const stream = await this.minioService.getFileStream(objectName); + const buffer = await this.streamToBuffer(stream); + return `data:image/png;base64,${buffer.toString('base64')}`; + } catch { + return null; + } + } + + private render(view: Record): string { + if (!this.compiledTemplate) { + const source = fs.readFileSync( + path.join(__dirname, '..', '..', 'contracts', 'templates', 'last-mile.hbs'), + 'utf-8', + ); + this.compiledTemplate = Handlebars.compile(source); + } + return this.compiledTemplate(view); + } + + private async requireApprovedRequest(id: string): Promise { + const request = await this.requestsService.findById(id); + if (request.status !== LastMileRequestStatus.Approved) { + throw new BadRequestException( + `The last-mile contract is available once the request is approved (current status: ${request.status})`, + ); + } + return request; + } + + private async requireBooking(request: LastMileRequest): Promise { + const booking = await this.dataSource.manager.findOne(Booking, { + where: { id: request.bookingId }, + relations: { company: true, cargoType: true }, + }); + if (!booking) throw new BadRequestException(`Booking ${request.bookingId} not found`); + return booking; + } + + private formatDate(value?: Date | string | null): string | null { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date.toISOString().slice(0, 10); + } + + private formatAmount(value: number): string { + return Number(value).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + } + + private toUploadFile(name: string, mimetype: string, buffer: Buffer): Express.Multer.File { + return { + fieldname: 'file', + originalname: name, + encoding: '7bit', + mimetype, + size: buffer.length, + buffer, + stream: Readable.from(buffer), + destination: '', + filename: '', + path: '', + }; + } + + private decodeSignatureImage(base64: string): Buffer { + const raw = base64.includes(',') ? base64.split(',')[1]! : base64; + return Buffer.from(raw, 'base64'); + } + + private streamToBuffer(stream: Readable): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks))); + }); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts index d02d00adb..4c4ac88e5 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts @@ -1,5 +1,6 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query, Res } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { Response } from 'express'; import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; @@ -9,14 +10,19 @@ import { BookingStaff } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { ApproveLastMileRequestDto } from './dto/approve-last-mile-request.dto'; import { RejectLastMileRequestDto } from './dto/reject-last-mile-request.dto'; +import { SignLastMileContractDto } from './dto/sign-last-mile-contract.dto'; import { SubmitLastMileRequestDto } from './dto/submit-last-mile-request.dto'; +import { LastMileContractService } from './last-mile-contract.service'; import { LastMileRequestsService } from './last-mile-requests.service'; @ApiTags('last-mile-requests') @ApiBearerAuth() @Controller('last-mile-requests') export class LastMileRequestsController { - constructor(private readonly requestsService: LastMileRequestsService) {} + constructor( + private readonly requestsService: LastMileRequestsService, + private readonly contractService: LastMileContractService, + ) {} @Get() @BookingStaff(FREIGHT_PERMS.lastMile.requestView) @@ -52,6 +58,36 @@ export class LastMileRequestsController { return this.requestsService.priceEstimate(id); } + // Customer-facing like :id/submit — the service ownership-checks against the + // resolved company; staff may also open it (read-only view). + @Get(':id/contract/view') + @ApiOperation({ summary: 'LM contract view model + rendered HTML + saved signature' }) + contractView(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { + return this.contractService.getContractView(id, user?.id ?? null); + } + + @Get(':id/contract/document') + @ApiOperation({ summary: 'Download the LM contract PDF (LM_.pdf)' }) + async contractDocument( + @Param('id', ParseUUIDPipe) id: string, + @Res() res: Response, + ): Promise { + const { stream, record } = await this.contractService.streamContract(id); + res.setHeader('Content-Type', record.mimeType ?? 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${record.name}"`); + stream.pipe(res); + } + + @Post(':id/contract/sign') + @ApiOperation({ summary: 'Customer agrees and signs the LM contract — then the advance invoice is issued' }) + signContract( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SignLastMileContractDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.contractService.sign(id, dto, user?.id ?? null); + } + @Get(':id') @BookingStaff(FREIGHT_PERMS.lastMile.requestView) @ApiOperation({ summary: 'Get a last-mile confirmation request by ID' }) @@ -69,12 +105,12 @@ export class LastMileRequestsController { @Body() dto: SubmitLastMileRequestDto, @CurrentUser() user: TCurrentUser, ) { - return this.requestsService.submit(id, user?.id ?? null, dto.containerNumbers); + return this.requestsService.submit(id, user?.id ?? null, dto.containerNumbers, dto.deliveryDate); } @Post(':id/approve') @BookingStaff(FREIGHT_PERMS.lastMile.requestApprove) - @ApiOperation({ summary: 'Truck & Machinery chief approves the request — generates the advance invoice' }) + @ApiOperation({ summary: 'Truck & Machinery chief approves the request — LM contract becomes signable; the advance invoice follows the customer signature' }) approve( @Param('id', ParseUUIDPipe) id: string, @Body() dto: ApproveLastMileRequestDto, diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.module.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.module.ts index 944954e65..c58e3d1c8 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.module.ts @@ -1,11 +1,16 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { BillingModule } from '../billing/billing.module'; import { BookingsModule } from '../bookings/bookings.module'; +import { FilesModule } from '../files/files.module'; import { LastMileModule } from '../last-mile/last-mile.module'; +import { MinioModule } from '../minio/minio.module'; import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; +import { SignaturesModule } from '../signatures/signatures.module'; import { LastMileRequest } from './entities/last-mile-request.entity'; +import { LastMileContractService } from './last-mile-contract.service'; import { LastMileRequestsController } from './last-mile-requests.controller'; import { LastMileRequestsRepository } from './last-mile-requests.repository'; import { LastMileRequestsService } from './last-mile-requests.service'; @@ -17,9 +22,17 @@ import { LastMileRequestsService } from './last-mile-requests.service'; forwardRef(() => BookingsModule), LastMileModule, NotificationInboxModule, + FilesModule, + MinioModule, + SignaturesModule, ], controllers: [LastMileRequestsController], - providers: [LastMileRequestsRepository, LastMileRequestsService], + providers: [ + LastMileRequestsRepository, + LastMileRequestsService, + LastMileContractService, + ContractPdfService, + ], exports: [LastMileRequestsService], }) export class LastMileRequestsModule {} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts index 0ef297216..901e7b9b5 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -252,7 +252,12 @@ export class LastMileRequestsService { }); } - async submit(id: string, userId: string | null, containerNumbers: string[]): Promise { + async submit( + id: string, + userId: string | null, + containerNumbers: string[], + deliveryDate: string, + ): Promise { const request = await this.findById(id); if (request.status !== LastMileRequestStatus.AwaitingConfirmation) { throw new BadRequestException(`Request is already ${request.status.toLowerCase()}`); @@ -274,6 +279,7 @@ export class LastMileRequestsService { await this.requestsRepository.update(id, { requestedContainerNumbers: selected, + requestedDeliveryDate: deliveryDate, status: LastMileRequestStatus.Submitted, submittedByUserId: userId, submittedAt: new Date(), @@ -308,12 +314,53 @@ export class LastMileRequestsService { advancedPayment: 0, }); + // No invoice yet: the advance is invoiced by LastMileContractService.sign() + // once the customer has signed the LM contract — doc first, then payment. + // Snapshot the rate estimate now so the contract shows the numbers the + // chief actually approved against, immune to later rate edits. + const estimate = await this.priceEstimate(id); + + await this.requestsRepository.update(id, { + status: LastMileRequestStatus.Approved, + reviewedByStaffId: staffId, + reviewedAt: new Date(), + resultingLastMileId: lastMile.id, + approvedAdvanceAmount: advanceAmount, + contractSummary: { ...estimate, advanceAmount }, + contractGeneratedAt: new Date(), + } as Partial); + + if (booking.companyId) { + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.CONTRACT_STATUS, + title: 'Last-mile contract ready — view and sign', + body: `Your last-mile request for booking ${booking.reference ?? booking.id} was approved. Review and sign the last-mile contract to receive your advance invoice.`, + link: `/bookings/${booking.id}/last-mile-contract?requestId=${id}`, + data: { bookingId: booking.id, requestId: id, lastMileId: lastMile.id }, + priority: NotificationPriority.HIGH, + }); + } + + return this.findById(id); + } + + /** The advance invoice, deferred from approve() until the LM contract is signed. */ + async generateAdvanceInvoice(request: LastMileRequest): Promise { + const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId)); + if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`); + const advanceAmount = request.approvedAdvanceAmount; + if (!advanceAmount || !request.resultingLastMileId) { + throw new BadRequestException('Request has no approved advance to invoice'); + } + await this.billing.generateInvoice({ // 'last_mile' (not the InvoiceSource.LastMile enum value "lastmile") to // match the existing source string LastMileInvoiceService/LastMileService // already query by (findBySourceIds/findPayable/attachInvoices). source: 'last_mile' as Freight.InvoiceSource, - sourceId: lastMile.id, + sourceId: request.resultingLastMileId, type: 'LAST_MILE_ADVANCE', companyId: booking.companyId, companyProfileId: booking.companyProfileId || '', @@ -328,27 +375,18 @@ export class LastMileRequestsService { totalAmount: advanceAmount, }); - await this.requestsRepository.update(id, { - status: LastMileRequestStatus.Approved, - reviewedByStaffId: staffId, - reviewedAt: new Date(), - resultingLastMileId: lastMile.id, - } as Partial); - if (booking.companyId) { void this.notifications.notify({ recipients: { companyId: booking.companyId }, audience: NotificationAudience.PORTAL, type: NotificationType.INVOICE_ISSUED, - title: 'Last-mile request approved — payment due', - body: `Your last-mile request for booking ${booking.reference ?? booking.id} was approved. Pay the advance invoice to proceed.`, + title: 'Last-mile contract signed — payment due', + body: `Thank you for signing the last-mile contract for booking ${booking.reference ?? booking.id}. Pay the advance invoice to proceed.`, link: '/billing/invoices', - data: { bookingId: booking.id, requestId: id, lastMileId: lastMile.id }, + data: { bookingId: booking.id, requestId: request.id, lastMileId: request.resultingLastMileId }, priority: NotificationPriority.HIGH, }); } - - return this.findById(id); } async reject(id: string, staffId: string | null, reason: string): Promise { diff --git a/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx index 3c4781fbe..eabdcbc20 100644 --- a/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx @@ -94,6 +94,20 @@ export function LastMileRequestsPanel() { const invalidate = () => qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.ROOT }); + const downloadContract = async (r: LastMileRequest) => { + try { + const { data: blob } = await lastMileRequestsService.contractDocument(r.id); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `LM_${r.booking?.company?.name?.replace(/[^A-Za-z0-9._-]+/g, "_") ?? r.id}.pdf`; + a.click(); + URL.revokeObjectURL(url); + } catch { + toast({ title: "Contract PDF not available", variant: "destructive" }); + } + }; + const approve = useMutation({ mutationFn: () => lastMileRequestsService.approve(approveTarget!.id, Number(advanceAmount)), @@ -141,8 +155,16 @@ export function LastMileRequestsPanel() { id: "containers", header: () => Requested Containers, cell: ({ row }) => { - const nums = row.original.requestedContainerNumbers; - return {nums?.length ? nums.join(", ") : "—"}; + const r = row.original; + const nums = r.requestedContainerNumbers; + return ( + + {nums?.length ? nums.join(", ") : "—"} + {r.requestedDeliveryDate && ( + Delivery: {r.requestedDeliveryDate} + )} + + ); }, }, { @@ -162,6 +184,24 @@ export function LastMileRequestsPanel() { ); }, }, + { + id: "contract", + header: () => LM Contract, + cell: ({ row }) => { + const r = row.original; + if (r.status !== "APPROVED") return ; + return ( + + + {r.customerSignedAt ? "Signed" : "Awaiting signature"} + + + + ); + }, + }, ...(canApprove ? [ { diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index 49aeb17cd..d205aae26 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from "react"; -import { Loader2 } from "lucide-react"; +import { Loader2, Plus, Trash2 } from "lucide-react"; import { + ActionIcon, Modal, Button, TextInput, @@ -41,6 +42,37 @@ type FormRow = | { kind: "pair"; fields: [FormFieldDef, FormFieldDef] } | { kind: "single"; field: FormFieldDef }; +/** One editable distance tier of a tierList field (raw input strings). */ +type TierRow = { minKm: string; maxKm: string; rateValue: string }; + +const emptyTier = (fromKm = ""): TierRow => ({ minKm: fromKm, maxKm: "", rateValue: "" }); + +/** + * Validate a tier set before submit: every tier complete, ranges sane, no + * overlaps, and only the last tier open-ended. Returns the error message, or + * null when the set is valid. + */ +const validateTiers = (rows: TierRow[]): string | null => { + if (!rows.length) return "Add at least one tier."; + for (const row of rows) { + if (row.minKm === "" || row.rateValue === "") { + return "Every tier needs a From km and a Rate value."; + } + if (row.maxKm !== "" && Number(row.maxKm) <= Number(row.minKm)) { + return "Each tier's To km must be greater than its From km."; + } + } + const sorted = [...rows].sort((a, b) => Number(a.minKm) - Number(b.minKm)); + for (let i = 1; i < sorted.length; i += 1) { + const prev = sorted[i - 1]; + if (prev.maxKm === "") return "Only the last tier can leave To km empty."; + if (Number(sorted[i].minKm) < Number(prev.maxKm)) { + return `Tiers overlap around ${sorted[i].minKm} km — each distance must fall in exactly one tier.`; + } + } + return null; +}; + const isShortField = (field: FormFieldDef) => field.type === "text" || field.type === "number" || @@ -54,7 +86,7 @@ const buildFormRows = (fields: FormFieldDef[]): FormRow[] => { while (index < fields.length) { const field = fields[index]; - if (field.type === "textarea" || field.type === "boolean") { + if (field.type === "textarea" || field.type === "boolean" || field.type === "tierList") { rows.push({ kind: "single", field }); index += 1; continue; @@ -85,6 +117,8 @@ const buildInitialValues = ( : record?.[field.name]; if (field.type === "multiselect") { values[field.name] = Array.isArray(raw) ? raw.map(String) : []; + } else if (field.type === "tierList") { + values[field.name] = [emptyTier("0")]; } else if (raw !== undefined && raw !== null) { if (field.type === "date" && typeof raw === "string") { values[field.name] = raw.slice(0, 10); @@ -241,6 +275,19 @@ const RuleEngineFormDialog = ({ if (field.type === "multiselect") { // Always the full replacement list — the API syncs the relation to it. payload[field.name] = Array.isArray(raw) ? raw : []; + } else if (field.type === "tierList") { + const rows = (Array.isArray(raw) ? raw : []) as TierRow[]; + const error = validateTiers(rows); + if (error) { + setFieldErrors((current) => ({ ...current, [field.name]: error })); + blocked = true; + } else { + payload[field.name] = rows.map((row) => ({ + minKm: Number(row.minKm), + maxKm: row.maxKm === "" ? null : Number(row.maxKm), + rateValue: Number(row.rateValue), + })); + } } else if (field.type === "number") { if (raw === "" || raw === undefined) continue; payload[field.name] = Number(raw); @@ -313,6 +360,101 @@ const RuleEngineFormDialog = ({ const label = ; + if (field.type === "tierList") { + const rows = Array.isArray(values[field.name]) + ? (values[field.name] as TierRow[]) + : []; + const setRows = (next: TierRow[]) => setField(field.name, next); + const setRow = (index: number, key: keyof TierRow, value: string) => { + if (value.trim().startsWith("-")) return; + setRows(rows.map((row, i) => (i === index ? { ...row, [key]: value } : row))); + }; + return ( + + + {label} + + {field.description ? ( + + {field.description} + + ) : null} + + {rows.map((row, index) => ( + + setRow(index, "minKm", e.currentTarget.value)} + size="md" + radius="md" + styles={inputStyles} + style={{ flex: 1 }} + /> + setRow(index, "maxKm", e.currentTarget.value)} + size="md" + radius="md" + styles={inputStyles} + style={{ flex: 1 }} + /> + setRow(index, "rateValue", e.currentTarget.value)} + size="md" + radius="md" + styles={inputStyles} + style={{ flex: 1 }} + /> + setRows(rows.filter((_, i) => i !== index))} + > + + + + ))} + + + + {fieldErrors[field.name] ? ( + + {fieldErrors[field.name]} + + ) : null} + + + ); + } + if (field.type === "multiselect") { const options = field.optionsFromValues ? field.optionsFromValues(values) diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 6b055e82e..cb45a34ae 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -714,6 +714,7 @@ export const URL_CONSTANTS = { PRICE_ESTIMATE: (id: string) => `/last-mile-requests/${id}/price-estimate`, APPROVE: (id: string) => `/last-mile-requests/${id}/approve`, REJECT: (id: string) => `/last-mile-requests/${id}/reject`, + CONTRACT_DOCUMENT: (id: string) => `/last-mile-requests/${id}/contract/document`, }, DRIVERS: { diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index d39892746..3a0a36ce4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -306,7 +306,34 @@ const RuleEngineResourcePage = () => { const formFields = useMemo(() => { if (!config) return []; - return config.formFields.map((field) => { + // Last-mile container bands: creating uses the multi-row tier list (one + // rate per tier); editing an existing band row keeps the single + // From/To/value fields (a rate row IS one band). + const bandFields = config.formFields.filter((field) => { + if (config.slug !== "rates") return true; + if (field.type === "tierList") return !editing; + if (editing) return true; + return field.name !== "minKm" && field.name !== "maxKm"; + }); + return bandFields.map((field) => { + // On create, the tier rows carry the per-band rate values — the single + // last-mile "Rate value" field then only applies to bulk mode. + if ( + config.slug === "rates" && + !editing && + field.name === "rateValue" && + field.showWhen?.field === "appliesTo" && + field.showWhen.equals.includes("LAST_MILE") + ) { + return { + ...field, + showWhen: undefined, + showIf: (values: Record) => + values.appliesTo === "LAST_MILE" && values.lastMileMode === "BULK", + }; + } + return field; + }).map((field) => { if (isPriorityRules && field.name === "minWagonCount") { return { ...field, @@ -624,6 +651,31 @@ const RuleEngineResourcePage = () => { ); return; } + // Container-mode create: the tier list becomes one rate row per tier, + // created sequentially so an overlap/duplicate rejection stops the batch + // with its own toast instead of half-failing in parallel. + const tiers = ( + payload as { + tiers?: Array<{ minKm: number; maxKm: number | null; rateValue: number }>; + } + ).tiers; + if (!editing?.id && Array.isArray(tiers)) { + const { tiers: _omitted, ...base } = payload as Record; + void _omitted; + void (async () => { + try { + for (const tier of tiers) { + await create.mutateAsync({ ...base, ...tier }); + } + setFormOpen(false); + setEditing(null); + } catch { + // The create mutation already toasted the failure; keep the dialog + // open so the admin can fix the tier set and retry. + } + })(); + return; + } } else if (isPriorityRules) { // Label is required by the backend but hidden in the UI for now. payload = { ...values, label: String(Date.now()) }; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 516a16046..a51b542c2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -17,7 +17,7 @@ export type ColumnFormat = | "entityLabel" | "rateLabel"; -export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "select" | "multiselect" | "textarea" | "radio"; +export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "select" | "multiselect" | "textarea" | "radio" | "tierList"; export interface ResourceColumn { id: string; @@ -1022,6 +1022,19 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ showWhen: { field: "appliesTo", equals: ["LAST_MILE"] }, getInitialValue: (record) => String(record.currency ?? "ETB"), }, + // ── Distance tiers (create only — the page swaps this for the single + // From/To/value fields when editing an existing band row). Each tier + // becomes its own rate row, so every band keeps edit/delete/approval. ── + { + name: "tiers", + label: "Distance tiers", + type: "tierList", + required: true, + description: + "One rate per distance range. To km is exclusive (0–30 then 30+); leave the last tier's To km empty for no upper limit.", + showIf: (v) => + v.appliesTo === "LAST_MILE" && v.lastMileMode === "CONTAINER", + }, // ── Container type — Container freight, container-kind intercity, and // the empty-container return surcharge (20ft vs 40ft price differently) ─ { diff --git a/apps/edr-freight-web/backoffice/src/services/last-mile-requests.service.ts b/apps/edr-freight-web/backoffice/src/services/last-mile-requests.service.ts index 28ada14eb..bdc33100e 100644 --- a/apps/edr-freight-web/backoffice/src/services/last-mile-requests.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/last-mile-requests.service.ts @@ -28,6 +28,9 @@ export interface LastMileRequest { reviewedAt?: string | null; rejectionReason?: string | null; resultingLastMileId?: string | null; + requestedDeliveryDate?: string | null; + customerSignedAt?: string | null; + signerDisplayName?: string | null; createdAt: string; updatedAt: string; } @@ -58,4 +61,6 @@ export const lastMileRequestsService = { api.post(LMR.APPROVE(id), { advanceAmount }), reject: (id: string, reason: string) => api.post(LMR.REJECT(id), { reason }), + contractDocument: (id: string) => + api.get(LMR.CONTRACT_DOCUMENT(id), { responseType: 'blob' }), }; diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 9cda9e9cb..aefb94594 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -47,6 +47,7 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage"; import BookingsListPage from "./pages/bookings/BookingsListPage"; import EditBookingPage from "./pages/bookings/EditBookingPage"; import LastMileConfirmPage from "./pages/bookings/last-mile-confirm/LastMileConfirmPage"; +import LastMileContractPage from "./pages/bookings/last-mile-contract/LastMileContractPage"; import ContractDetailPage from "./pages/contracts/ContractDetailPage"; import ContractViewPage from "./pages/contracts/ContractViewPage"; import ContractsList from "./pages/contracts/ContractsList"; @@ -323,6 +324,10 @@ const App = () => { path="/bookings/:id/last-mile-confirm" element={} /> + } + /> } diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index dcc4d23c7..88b310e84 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -221,5 +221,8 @@ export const URL_CONSTANTS = { LAST_MILE_REQUESTS: { BY_ID: (id: string) => `/last-mile-requests/${id}`, SUBMIT: (id: string) => `/last-mile-requests/${id}/submit`, + CONTRACT_VIEW: (id: string) => `/last-mile-requests/${id}/contract/view`, + CONTRACT_DOCUMENT: (id: string) => `/last-mile-requests/${id}/contract/document`, + CONTRACT_SIGN: (id: string) => `/last-mile-requests/${id}/contract/sign`, }, }; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/last-mile-confirm/LastMileConfirmPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/last-mile-confirm/LastMileConfirmPage.tsx index 0a0e02d02..cfd93ab99 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/last-mile-confirm/LastMileConfirmPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/last-mile-confirm/LastMileConfirmPage.tsx @@ -1,4 +1,5 @@ import { Button, Center, Checkbox, Loader, Stack, Text } from "@mantine/core"; +import { DatePickerInput } from "@mantine/dates"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; @@ -31,6 +32,7 @@ export default function LastMileConfirmPage() { const queryClient = useQueryClient(); const [selected, setSelected] = useState([]); + const [deliveryDate, setDeliveryDate] = useState(null); const { data: request, @@ -51,7 +53,7 @@ export default function LastMileConfirmPage() { const containerNumbers = booking?.containerNumbers ?? []; const submitMutation = useMutation({ - mutationFn: () => lastMileRequestsService.submit(requestId!, selected), + mutationFn: () => lastMileRequestsService.submit(requestId!, selected, deliveryDate!), onSuccess: () => { toast.success("Last-mile confirmation submitted"); queryClient.invalidateQueries({ queryKey: ["last-mile-request", requestId] }); @@ -101,6 +103,23 @@ export default function LastMileConfirmPage() { Reason: {request.rejectionReason} )} + {request.status === "APPROVED" && ( + <> + + {request.customerSignedAt + ? "You have signed the last-mile contract." + : "Review and sign the last-mile contract to receive your advance invoice."} + + + + )} ); @@ -146,9 +165,24 @@ export default function LastMileConfirmPage() { ))} + + setDeliveryDate( + date ? new Date(date).toISOString().slice(0, 10) : null, + ) + } + minDate={new Date()} + required + /> + + + ); + } + + return ( +
+
+
+ +
+ + + {data.canSign && ( + + )} +
+
+ + {data.customerSignedAt && ( +

+ Signed by {data.signerDisplayName} on{" "} + {new Date(data.customerSignedAt).toLocaleDateString()}. +

+ )} + +