diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index adb92c3b0..d41b77dce 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -103,6 +103,7 @@ import { ProcurementModule } from "./modules/procurement/procurement.module"; import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module"; import { FirstMileModule } from "./modules/first-mile/first-mile.module"; import { LastMileModule } from "./modules/last-mile/last-mile.module"; +import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-requests.module"; import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module"; import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; import { AiModule } from "./modules/ai/ai.module"; @@ -241,6 +242,7 @@ if (!process.env.APPLICATION_NAME) { GpsTrackingModule, FirstMileModule, LastMileModule, + LastMileRequestsModule, InterchangeDocumentsModule, ImportOperationsModule, VerifaydaModule, diff --git a/apps/edr-freight-api/src/common/mile-distance.util.spec.ts b/apps/edr-freight-api/src/common/mile-distance.util.spec.ts new file mode 100644 index 000000000..75801a20b --- /dev/null +++ b/apps/edr-freight-api/src/common/mile-distance.util.spec.ts @@ -0,0 +1,17 @@ +import { haversineKm } from './mile-distance.util'; + +describe('haversineKm', () => { + it('is zero for the same point', () => { + expect(haversineKm(8.9, 38.6, 8.9, 38.6)).toBe(0); + }); + + it('matches one degree of longitude at the equator (~111.19 km)', () => { + expect(haversineKm(0, 0, 0, 1)).toBeCloseTo(111.19, 1); + }); + + it('Sebeta yard → Indode yard is roughly 26 km', () => { + const km = haversineKm(8.9096, 38.636, 8.7386, 38.7913); + expect(km).toBeGreaterThan(20); + expect(km).toBeLessThan(35); + }); +}); diff --git a/apps/edr-freight-api/src/common/mile-distance.util.ts b/apps/edr-freight-api/src/common/mile-distance.util.ts new file mode 100644 index 000000000..822a13350 --- /dev/null +++ b/apps/edr-freight-api/src/common/mile-distance.util.ts @@ -0,0 +1,48 @@ +import { DataSource } from 'typeorm'; + +/** Great-circle distance in km between two WGS84 points (haversine). */ +export function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number { + const toRad = (d: number) => (d * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLng = toRad(lng2 - lng1); + const a = + Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; + return 2 * 6371 * Math.asin(Math.sqrt(a)); +} + +/** + * Estimated road-leg distance for a booking's first/last mile: straight-line km + * from the yard (freight.yard_locations) to the customer's pickup/delivery GPS + * point on the booking. FIRST = origin yard → pickup point, LAST = destination + * yard → delivery point. Null when either end has no coordinates. + * + * ponytail: haversine straight-line, not road routing — plug a routing API in + * here if real road km is ever required. + */ +export async function estimateMileKm( + dataSource: DataSource, + bookingId: string, + mile: 'FIRST' | 'LAST', +): Promise { + const [row] = await dataSource.query( + mile === 'LAST' + ? `SELECT b.last_mile_delivery_lat AS lat, b.last_mile_delivery_lng AS lng, + l.latitude AS yard_lat, l.longitude AS yard_lng + FROM freight.bookings b + LEFT JOIN freight.yard_locations l + ON l.yard_id = b.destination_yard_id AND l.deleted_at IS NULL + WHERE b.id = $1 AND b.deleted_at IS NULL` + : `SELECT b.first_mile_pickup_lat AS lat, b.first_mile_pickup_lng AS lng, + l.latitude AS yard_lat, l.longitude AS yard_lng + FROM freight.bookings b + LEFT JOIN freight.yard_locations l + ON l.yard_id = b.origin_yard_id AND l.deleted_at IS NULL + WHERE b.id = $1 AND b.deleted_at IS NULL`, + [bookingId], + ); + if (!row || row.lat == null || row.lng == null || row.yard_lat == null || row.yard_lng == null) { + return null; + } + const km = haversineKm(Number(row.yard_lat), Number(row.yard_lng), Number(row.lat), Number(row.lng)); + return Math.round(km * 100) / 100; +} diff --git a/apps/edr-freight-api/src/migrations/3250000000000-CreateLastMileRequests.ts b/apps/edr-freight-api/src/migrations/3250000000000-CreateLastMileRequests.ts new file mode 100644 index 000000000..0763e32a3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3250000000000-CreateLastMileRequests.ts @@ -0,0 +1,87 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Create freight.last_mile_requests — the pre-approval confirmation stage that + * sits in front of freight.last_mile: a train departs Djibouti, the customer + * confirms which containers go via EDR last-mile, and the Truck & Machinery + * chief approves/rejects before a freight.last_mile execution record exists. + */ +export class CreateLastMileRequests3250000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.last_mile_requests'); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: 'freight.last_mile_requests', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, default: 'gen_random_uuid()' }, + { name: 'booking_id', type: 'uuid', isNullable: false }, + { name: 'train_schedule_id', type: 'uuid', isNullable: false }, + { + name: 'status', + type: 'varchar', + length: '30', + default: `'AWAITING_CONFIRMATION'`, + isNullable: false, + }, + { name: 'requested_container_numbers', type: 'text', isArray: true, isNullable: true }, + { name: 'reminder_sent_at', type: 'timestamptz', isNullable: true }, + { name: 'submitted_by_user_id', type: 'uuid', isNullable: true }, + { name: 'submitted_at', type: 'timestamptz', isNullable: true }, + { name: 'reviewed_by_staff_id', type: 'uuid', isNullable: true }, + { name: 'reviewed_at', type: 'timestamptz', isNullable: true }, + { name: 'rejection_reason', type: 'text', isNullable: true }, + { name: 'resulting_last_mile_id', type: 'uuid', isNullable: true }, + { 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_requests', + new TableForeignKey({ + columnNames: ['booking_id'], + referencedTableName: 'freight.bookings', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + await queryRunner.createForeignKey( + 'freight.last_mile_requests', + new TableForeignKey({ + columnNames: ['train_schedule_id'], + referencedTableName: 'freight.train_schedules', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + await queryRunner.createForeignKey( + 'freight.last_mile_requests', + new TableForeignKey({ + columnNames: ['resulting_last_mile_id'], + referencedTableName: 'freight.last_mile', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + // One request per booking per departure — remind()/submit() are idempotent on this. + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "IDX_last_mile_requests_booking_schedule" ON "freight"."last_mile_requests" ("booking_id", "train_schedule_id") WHERE "deleted_at" IS NULL`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_last_mile_requests_status" ON "freight"."last_mile_requests" ("status")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.last_mile_requests'); + if (exists) { + await queryRunner.dropTable('freight.last_mile_requests'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/3270000000000-YardGpsLocation.ts b/apps/edr-freight-api/src/migrations/3270000000000-YardGpsLocation.ts new file mode 100644 index 000000000..18e71ae4c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3270000000000-YardGpsLocation.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Creates freight.yard_locations (GPS per yard, decimal degrees WGS84) and + * seeds the five facility yards: Sebeta, GMP/Indode (KALITY), Mojo, Adama, + * Dire Dawa. Also normalizes has_facility — only those five load/unload cargo. + * + * Coordinates are approximate — adjust rows directly if surveyed values arrive. + */ +export class YardGpsLocation3270000000000 implements MigrationInterface { + name = 'YardGpsLocation3270000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.yard_locations ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + yard_id uuid NOT NULL UNIQUE REFERENCES freight.yards(id) ON DELETE CASCADE, + latitude double precision NOT NULL, + longitude double precision NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + // LEGACY_DEST is the historical code for Sebeta in some environments. + await queryRunner.query(` + INSERT INTO freight.yard_locations (yard_id, latitude, longitude) + SELECT y.id, v.lat, v.lng + FROM (VALUES + ('SEBETA', 8.9096, 38.6360), + ('LEGACY_DEST', 8.9096, 38.6360), + ('KALITY', 8.7386, 38.7913), + ('MOJO', 8.5794, 39.1200), + ('ADAMA', 8.5622, 39.2440), + ('DIRE_DAWA', 9.6009, 41.8103) + ) AS v(code, lat, lng) + JOIN freight.yards y ON y.code = v.code AND y.deleted_at IS NULL + ON CONFLICT (yard_id) DO NOTHING + `); + + await queryRunner.query(` + UPDATE freight.yards + SET has_facility = (code IN ('SEBETA', 'LEGACY_DEST', 'KALITY', 'MOJO', 'ADAMA', 'DIRE_DAWA')) + WHERE deleted_at IS NULL + AND has_facility <> (code IN ('SEBETA', 'LEGACY_DEST', 'KALITY', 'MOJO', 'ADAMA', 'DIRE_DAWA')) + `); + } + + public async down(): Promise { + // Additive table + data normalization; no rollback. + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 4cd7249aa..ea4413638 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1059,16 +1059,24 @@ export class BillingService { * settlement; settleByPaymentId still performs the real transition. */ async markInvoicePaymentProcessing(paymentId: string): Promise { - await this.dataSource.getRepository(Invoice).update( - { - paymentId, - status: In([ - Freight.InvoiceStatus.Issued, - Freight.InvoiceStatus.Pending, - ]), - }, - { status: Freight.InvoiceStatus.PaymentProcessing }, - ); + const repo = this.dataSource.getRepository(Invoice); + const invoices = await repo.findBy({ + paymentId, + status: In([ + Freight.InvoiceStatus.Issued, + Freight.InvoiceStatus.Pending, + ]), + }); + for (const invoice of invoices) { + await repo.update( + { id: invoice.id, status: invoice.status }, + { status: Freight.InvoiceStatus.PaymentProcessing }, + ); + this.emitInvoiceEvent("payment-processing", { + ...invoice, + status: Freight.InvoiceStatus.PaymentProcessing, + } as Invoice); + } } /** @@ -1077,12 +1085,21 @@ export class BillingService { * retry. No-op from any other status. */ async revertInvoicePaymentProcessing(paymentId: string): Promise { - await this.dataSource - .getRepository(Invoice) - .update( - { paymentId, status: Freight.InvoiceStatus.PaymentProcessing }, + const repo = this.dataSource.getRepository(Invoice); + const invoices = await repo.findBy({ + paymentId, + status: Freight.InvoiceStatus.PaymentProcessing, + }); + for (const invoice of invoices) { + await repo.update( + { id: invoice.id, status: Freight.InvoiceStatus.PaymentProcessing }, { status: Freight.InvoiceStatus.Pending }, ); + this.emitInvoiceEvent("payment-processing-reverted", { + ...invoice, + status: Freight.InvoiceStatus.Pending, + } as Invoice); + } } // ── Payment initiation & settlement (the gateway boundary) ─────────────────── diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index 9212bcdef..cd803d719 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -115,6 +115,34 @@ export class BookingInvoiceService { } } + /** + * Success-redirect ack: the customer finished provider checkout, webhook not + * in yet. Mirror the invoice's PAYMENT_PROCESSING on the booking so the + * portal stops offering "Pay now". Display state only — settlement + * (`booking.invoice.paid`) still drives PAID. Status-guarded, so it never + * touches a booking that already advanced or was terminated. + */ + @OnEvent("booking.invoice.payment-processing") + async onBookingInvoicePaymentProcessing( + payload: InvoiceEventPayload, + ): Promise { + await this.dataSource.getRepository(Booking).update( + { id: payload.sourceId, status: "SELECTED_FOR_BATCH" }, + { status: "PAYMENT_VERIFICATION_IN_PROGRESS" }, + ); + } + + /** Payment failed after a redirect ack — the booking reads payable again. */ + @OnEvent("booking.invoice.payment-processing-reverted") + async onBookingInvoicePaymentProcessingReverted( + payload: InvoiceEventPayload, + ): Promise { + await this.dataSource.getRepository(Booking).update( + { id: payload.sourceId, status: "PAYMENT_VERIFICATION_IN_PROGRESS" }, + { status: "SELECTED_FOR_BATCH" }, + ); + } + updateStatus( invoiceId: string, status: Freight.InvoiceStatus, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 35f84e419..1c5cc7481 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1405,7 +1405,9 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoinAndSelect('booking.cargoType', 'cargoType') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) - .andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + .andWhere( + `booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', 'PAYMENT_VERIFICATION_IN_PROGRESS')`, + ) .getMany(); } 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 b11912b2c..a35978020 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 @@ -3,6 +3,7 @@ import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { attachMileFinancials } from '../../common/mile-financials.util'; +import { estimateMileKm } from '../../common/mile-distance.util'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { BookingsRepository } from "../bookings/bookings.repository"; import { DriversService } from "../drivers/drivers.service"; @@ -285,7 +286,7 @@ export class FirstMileService { status: dto.status ?? "READY_TO_TRANSIT", advancedPayment: dto.advancedPayment ?? 0, remainingPayment: dto.remainingPayment ?? 0, - estimatedKm: dto.estimatedKm ?? null, + estimatedKm: dto.estimatedKm ?? (await estimateMileKm(this.dataSource, dto.bookingId, 'FIRST')), exactKm: dto.exactKm ?? null, vehicleId: dto.vehicleId ?? null, paid: (dto as any).paid ?? false, diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts b/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts new file mode 100644 index 000000000..170eafb25 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsNumber, Min } from 'class-validator'; + +export class ApproveLastMileRequestDto { + // ponytail: flat manual advance amount — no rate model exists yet at this + // pre-distance stage (delivery-fee invoicing needs assigned-truck distance, + // which isn't known until after payment). Wire a FeeRule-based estimate + // (see double-handling/truck-detention fee rules) once one exists. + @ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 }) + @Transform(({ value }) => Number(value)) + @IsNumber() + @Min(0.01) + advanceAmount!: number; +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/dto/reject-last-mile-request.dto.ts b/apps/edr-freight-api/src/modules/last-mile-requests/dto/reject-last-mile-request.dto.ts new file mode 100644 index 000000000..10cee19c3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/reject-last-mile-request.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class RejectLastMileRequestDto { + @ApiProperty({ description: 'Why the request is rejected (e.g. no truck available)' }) + @IsString() + @IsNotEmpty() + @MaxLength(500) + reason!: 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 new file mode 100644 index 000000000..b84595f7c --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/submit-last-mile-request.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayNotEmpty, ArrayUnique, IsArray, IsString } from 'class-validator'; + +export class SubmitLastMileRequestDto { + @ApiProperty({ + type: [String], + description: + 'Container numbers the customer wants delivered via EDR last-mile — pass every booking container to select "all".', + }) + @IsArray() + @ArrayNotEmpty() + @ArrayUnique() + @IsString({ each: true }) + containerNumbers!: 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 new file mode 100644 index 000000000..0077d7f0a --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/entities/last-mile-request.entity.ts @@ -0,0 +1,71 @@ +import { BaseEntity } from '@edr/api-common'; +import { LastMileRequestStatus } from '@edr/types'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { LastMile } from '../../last-mile/entities/last-mile.entity'; + +export const LAST_MILE_REQUEST_STATUSES = [ + LastMileRequestStatus.AwaitingConfirmation, + LastMileRequestStatus.Submitted, + LastMileRequestStatus.Approved, + LastMileRequestStatus.Rejected, +] as const; + +/** + * The pre-approval confirmation stage in front of `LastMile`: fired when a + * train departs Djibouti, filled by the customer, reviewed by the Truck & + * Machinery chief. One row per (bookingId, trainScheduleId) — a booking whose + * containers arrive across several departures gets a request per departure. + */ +@Entity({ name: 'last_mile_requests', schema: 'freight' }) +@Index(['bookingId']) +@Index(['status']) +export class LastMileRequest extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { nullable: false, eager: false }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @ManyToOne(() => TrainSchedule, { nullable: false, eager: false }) + @JoinColumn({ name: 'train_schedule_id' }) + trainSchedule?: TrainSchedule; + + @Column({ name: 'status', type: 'varchar', length: 30, default: LastMileRequestStatus.AwaitingConfirmation }) + status!: LastMileRequestStatus; + + /** Customer's container selection — "all" is just every booking container listed here. */ + @Column({ name: 'requested_container_numbers', type: 'text', array: true, nullable: true }) + requestedContainerNumbers?: string[] | null; + + @Column({ name: 'reminder_sent_at', type: 'timestamptz', nullable: true }) + reminderSentAt?: Date | null; + + @Column({ name: 'submitted_by_user_id', type: 'uuid', nullable: true }) + submittedByUserId?: string | null; + + @Column({ name: 'submitted_at', type: 'timestamptz', nullable: true }) + submittedAt?: Date | null; + + @Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true }) + reviewedByStaffId?: string | null; + + @Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true }) + reviewedAt?: Date | null; + + @Column({ name: 'rejection_reason', type: 'text', nullable: true }) + rejectionReason?: string | null; + + @Column({ name: 'resulting_last_mile_id', type: 'uuid', nullable: true }) + resultingLastMileId?: 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-requests.controller.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts new file mode 100644 index 000000000..0c71a21c6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts @@ -0,0 +1,86 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { LastMileRequestStatus } from '@edr/types'; + +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 { SubmitLastMileRequestDto } from './dto/submit-last-mile-request.dto'; +import { LastMileRequestsService } from './last-mile-requests.service'; + +@ApiTags('last-mile-requests') +@ApiBearerAuth() +@Controller('last-mile-requests') +export class LastMileRequestsController { + constructor(private readonly requestsService: LastMileRequestsService) {} + + @Get() + @BookingStaff(FREIGHT_PERMS.lastMile.requestView) + @ApiOperation({ summary: 'List last-mile confirmation requests' }) + findAll( + @Query('status') status?: string, + @Query('bookingId') bookingId?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.requestsService.findAll({ + status: status as LastMileRequestStatus | undefined, + bookingId, + page: page ? parseInt(page, 10) : undefined, + pageSize: pageSize ? parseInt(pageSize, 10) : undefined, + }); + } + + @Get('free-truck-count') + @BookingStaff(FREIGHT_PERMS.lastMile.requestView) + @ApiOperation({ summary: 'Free (ACTIVE + unassigned) trucks — informational context for approval' }) + freeTruckCount() { + return this.requestsService.freeTruckCount().then((count) => ({ count })); + } + + @Get(':id') + @BookingStaff(FREIGHT_PERMS.lastMile.requestView) + @ApiOperation({ summary: 'Get a last-mile confirmation request by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.requestsService.findById(id); + } + + // No @BookingStaff — the customer (portal) fills this, not backoffice staff. + // TODO: integrate @edr/auth — @CurrentUser is a stub until then; the service + // still cross-checks the request's booking against the resolved company. + @Post(':id/submit') + @ApiOperation({ summary: "Customer confirms which containers go via EDR last-mile" }) + submit( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SubmitLastMileRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.requestsService.submit(id, user?.id ?? null, dto.containerNumbers); + } + + @Post(':id/approve') + @BookingStaff(FREIGHT_PERMS.lastMile.requestApprove) + @ApiOperation({ summary: 'Truck & Machinery chief approves the request — generates the advance invoice' }) + approve( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ApproveLastMileRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.requestsService.approve(id, user?.id ?? null, dto.advanceAmount); + } + + @Post(':id/reject') + @BookingStaff(FREIGHT_PERMS.lastMile.requestApprove) + @ApiOperation({ summary: 'Truck & Machinery chief rejects the request with a reason' }) + reject( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RejectLastMileRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.requestsService.reject(id, user?.id ?? null, dto.reason); + } +} 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 new file mode 100644 index 000000000..944954e65 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.module.ts @@ -0,0 +1,25 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { BillingModule } from '../billing/billing.module'; +import { BookingsModule } from '../bookings/bookings.module'; +import { LastMileModule } from '../last-mile/last-mile.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; +import { LastMileRequest } from './entities/last-mile-request.entity'; +import { LastMileRequestsController } from './last-mile-requests.controller'; +import { LastMileRequestsRepository } from './last-mile-requests.repository'; +import { LastMileRequestsService } from './last-mile-requests.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([LastMileRequest]), + BillingModule, + forwardRef(() => BookingsModule), + LastMileModule, + NotificationInboxModule, + ], + controllers: [LastMileRequestsController], + providers: [LastMileRequestsRepository, LastMileRequestsService], + exports: [LastMileRequestsService], +}) +export class LastMileRequestsModule {} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.repository.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.repository.ts new file mode 100644 index 000000000..536a6c022 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.repository.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseRepository } from '@edr/api-common'; + +import { LastMileRequest } from './entities/last-mile-request.entity'; + +@Injectable() +export class LastMileRequestsRepository extends BaseRepository { + constructor( + @InjectRepository(LastMileRequest) + repository: Repository, + ) { + super(repository); + } +} 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 new file mode 100644 index 000000000..e21706c9f --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -0,0 +1,337 @@ +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { DataSource, FindOptionsWhere } from 'typeorm'; +import { Freight, LastMileRequestStatus } from '@edr/types'; + +import { usesEdrMileService } from '../../common/mile-haulage.util'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { BookingsService } from '../bookings/bookings.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BillingService } from '../billing/billing.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { NotificationAudience, NotificationPriority, NotificationType } from '@edr/types'; +import { LastMileService } from '../last-mile/last-mile.service'; +import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity'; +import { LastMileRequest } from './entities/last-mile-request.entity'; +import { LastMileRequestsRepository } from './last-mile-requests.repository'; + +type ListFilter = { + status?: LastMileRequestStatus; + bookingId?: string; + page?: number; + pageSize?: number; +}; + +/** Just what remind() needs off a departed schedule — deliberately not the full + * `TrainSchedule` entity so this module never has to import train-scheduling code. */ +type DepartedSchedule = { id: string; trainNumber?: string | null }; + +@Injectable() +export class LastMileRequestsService { + private readonly logger = new Logger(LastMileRequestsService.name); + + constructor( + private readonly requestsRepository: LastMileRequestsRepository, + private readonly bookingsRepository: BookingsRepository, + private readonly bookingsService: BookingsService, + private readonly lastMileService: LastMileService, + private readonly billing: BillingService, + private readonly notifications: NotificationInboxService, + private readonly dataSource: DataSource, + ) {} + + /** Container numbers on the booking (upper-cased) — mirrors LastMileService's own helper. */ + private async bookingContainerNumbers(bookingId: string): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT bcu.container_number AS "containerNumber" + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`, + [bookingId], + ); + return rows.map((r) => r.containerNumber.trim().toUpperCase()); + } + + /** + * Poll for trains that have departed Djibouti and remind their eligible + * bookings. Deliberately a self-contained poller (raw SQL against + * `import_djibouti_operations`/`train_schedules`, no import of train-scheduling + * module code) rather than a hook inside `TrainSchedulingService.dispatchSchedule` + * — keeps this feature decoupled from that module entirely. `remindForDeparture` + * is idempotent per (bookingId, scheduleId), so re-scanning the same recent + * window on every tick is safe — a schedule already fully reminded is a no-op. + */ + @Cron('*/2 * * * *', { name: 'last-mile-request-departure-scan' }) + async scanDepartedSchedules(): Promise { + let schedules: DepartedSchedule[] = []; + try { + schedules = await this.dataSource.query( + `SELECT ts.id AS "id", ts.train_number AS "trainNumber" + FROM freight.import_djibouti_operations op + JOIN freight.train_schedules ts + ON ts.id = op.train_schedule_id AND ts.deleted_at IS NULL + WHERE op.deleted_at IS NULL + AND op.departed_from_djibouti_at IS NOT NULL + AND op.departed_from_djibouti_at > now() - interval '14 days'`, + ); + } catch (err) { + this.logger.warn(`Failed to scan for departed schedules: ${(err as Error).message}`); + return; + } + for (const schedule of schedules) { + await this.remindForDeparture(schedule); + } + } + + /** + * Fired for a train that has departed Djibouti (import direction). For every booking + * already loaded on this schedule that bought EDR last-mile, idempotently + * creates the AWAITING_CONFIRMATION request and reminds both the customer and + * the Truck & Machinery department. Fire-and-forget per booking — one bad + * booking must never block the rest of the departure notification. + */ + async remindForDeparture(schedule: DepartedSchedule): Promise { + let bookingIds: string[] = []; + try { + const rows: Array<{ bookingId: string }> = await this.dataSource.query( + `SELECT booking_id AS "bookingId" + FROM freight.train_schedule_bookings + WHERE train_schedule_id = $1 AND loading_status = 'LOADED' AND deleted_at IS NULL`, + [schedule.id], + ); + bookingIds = rows.map((r) => r.bookingId); + } catch (err) { + this.logger.warn(`Failed to load schedule bookings for ${schedule.id}: ${(err as Error).message}`); + return; + } + if (!bookingIds.length) return; + + for (const bookingId of bookingIds) { + try { + const booking = await this.bookingsRepository.findById(bookingId); + if (!booking) continue; + if ( + !usesEdrMileService({ + tradeDirection: booking.tradeDirection, + firstMile: booking.firstMilePickupAddress ?? null, + lastMile: booking.lastMileDeliveryAddress ?? null, + }) + ) { + continue; + } + await this.remind(booking, schedule); + } catch (err) { + this.logger.warn(`Failed to remind booking ${bookingId} for schedule ${schedule.id}: ${(err as Error).message}`); + } + } + } + + private async remind(booking: Booking, schedule: DepartedSchedule): Promise { + const [existing] = await this.requestsRepository.findAll({ + where: { bookingId: booking.id, trainScheduleId: schedule.id }, + take: 1, + }); + if (existing) return; // already reminded for this departure + + const request = await this.requestsRepository.create({ + bookingId: booking.id, + trainScheduleId: schedule.id, + status: LastMileRequestStatus.AwaitingConfirmation, + reminderSentAt: new Date(), + }); + + const trainLabel = schedule.trainNumber ? `train ${schedule.trainNumber}` : 'your train'; + if (booking.companyId) { + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.SCHEDULE_UPDATE, + title: 'Confirm your last-mile delivery', + body: `${trainLabel} carrying booking ${booking.reference ?? booking.id} has departed Djibouti. Confirm which containers go via EDR last-mile.`, + link: `/bookings/${booking.id}/last-mile-confirm?requestId=${request.id}`, + data: { bookingId: booking.id, requestId: request.id }, + priority: NotificationPriority.HIGH, + }); + } + void this.notifications.notify({ + recipients: { permissionKeys: [FREIGHT_PERMS.lastMile.requestReview] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.SCHEDULE_UPDATE, + title: 'Last-mile confirmation expected', + body: `${trainLabel} carrying booking ${booking.reference ?? booking.id} has departed Djibouti — awaiting the customer's last-mile confirmation.`, + link: `/dashboard/operations/last-mile?tab=requests`, + data: { bookingId: booking.id, requestId: request.id }, + priority: NotificationPriority.HIGH, + }); + } + + async findAll(filter: ListFilter = {}): Promise<{ + data: LastMileRequest[]; + meta: { total: number; page: number; pageSize: number; totalPages: number }; + }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 50; + const where: FindOptionsWhere = {}; + if (filter.status) where.status = filter.status; + if (filter.bookingId) where.bookingId = filter.bookingId; + + const [data, total] = await this.requestsRepository.findAndCount({ + where, + relations: { booking: { company: true } }, + order: { createdAt: 'DESC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + + return { + data, + meta: { total, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)) }, + }; + } + + async findById(id: string): Promise { + const record = await this.requestsRepository.findById(id, { + relations: { booking: { company: true } }, + }); + if (!record) throw new NotFoundException(`Last-mile request ${id} not found`); + return record; + } + + /** Free (ACTIVE + unassigned) truck count — informational only for the approval screen. */ + async freeTruckCount(): Promise { + return this.dataSource.manager.count(Vehicle, { + where: { status: VehicleStatus.ACTIVE, availability: VehicleAvailability.FREE }, + }); + } + + async submit(id: string, userId: string | null, containerNumbers: string[]): Promise { + const request = await this.findById(id); + if (request.status !== LastMileRequestStatus.AwaitingConfirmation) { + throw new BadRequestException(`Request is already ${request.status.toLowerCase()}`); + } + + if (userId) { + const companyId = await this.bookingsService.resolveCustomerCompanyId(userId); + if (companyId && request.booking?.companyId && companyId !== request.booking.companyId) { + throw new BadRequestException('This request does not belong to your company'); + } + } + + const bookingNumbers = await this.bookingContainerNumbers(request.bookingId); + const selected = containerNumbers.map((n) => n.trim().toUpperCase()); + const unknown = selected.filter((n) => !bookingNumbers.includes(n)); + if (unknown.length) { + throw new BadRequestException(`Container(s) not on this booking: ${unknown.join(', ')}`); + } + + await this.requestsRepository.update(id, { + requestedContainerNumbers: selected, + status: LastMileRequestStatus.Submitted, + submittedByUserId: userId, + submittedAt: new Date(), + } as Partial); + + void this.notifications.notify({ + recipients: { permissionKeys: [FREIGHT_PERMS.lastMile.requestReview] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title: 'Last-mile request ready for review', + body: `Booking ${request.booking?.reference ?? request.bookingId} confirmed ${selected.length} container(s) for EDR last-mile.`, + link: `/dashboard/operations/last-mile?tab=requests`, + data: { bookingId: request.bookingId, requestId: request.id }, + priority: NotificationPriority.NORMAL, + }); + + return this.findById(id); + } + + async approve(id: string, staffId: string | null, advanceAmount: number): Promise { + const request = await this.findById(id); + if (request.status !== LastMileRequestStatus.Submitted) { + throw new BadRequestException(`Only a submitted request can be approved (current status: ${request.status})`); + } + const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId)); + if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`); + + // Idempotent per booking — reuses the record if one already exists. + const lastMile = await this.lastMileService.create({ + bookingId: request.bookingId, + status: 'PAYMENT_PENDING', + advancedPayment: 0, + }); + + 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, + type: 'LAST_MILE_ADVANCE', + companyId: booking.companyId, + companyProfileId: booking.companyProfileId || '', + currency: booking.paymentCurrency || 'ETB', + lines: [ + { + chargeType: 'LAST_MILE_ADVANCE', + description: 'Last-mile delivery advance', + amount: advanceAmount, + }, + ], + 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.`, + link: '/billing/invoices', + data: { bookingId: booking.id, requestId: id, lastMileId: lastMile.id }, + priority: NotificationPriority.HIGH, + }); + } + + return this.findById(id); + } + + async reject(id: string, staffId: string | null, reason: string): Promise { + const request = await this.findById(id); + if (request.status !== LastMileRequestStatus.Submitted) { + throw new BadRequestException(`Only a submitted request can be rejected (current status: ${request.status})`); + } + const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId)); + + await this.requestsRepository.update(id, { + status: LastMileRequestStatus.Rejected, + reviewedByStaffId: staffId, + reviewedAt: new Date(), + rejectionReason: reason, + } as Partial); + + if (booking?.companyId) { + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Last-mile request rejected', + body: `Your last-mile request for booking ${booking.reference ?? booking.id} was rejected: ${reason}`, + link: `/bookings/${booking.id}`, + data: { bookingId: booking.id, requestId: id }, + priority: NotificationPriority.HIGH, + }); + } + + return this.findById(id); + } +} 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 af6c920fc..080c3bbe6 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 @@ -13,6 +13,7 @@ import { usesEdrMileService, } from '../../common/mile-haulage.util'; import { attachMileFinancials } from '../../common/mile-financials.util'; +import { estimateMileKm } from '../../common/mile-distance.util'; import { assertBulkTonnageRemains, assertTruckCountWithinContainers, @@ -426,7 +427,7 @@ export class LastMileService { status: dto.status ?? 'READY_TO_TRANSIT', advancedPayment: dto.advancedPayment ?? 0, remainingPayment: dto.remainingPayment ?? 0, - estimatedKm: dto.estimatedKm ?? null, + estimatedKm: dto.estimatedKm ?? (await estimateMileKm(this.dataSource, dto.bookingId, 'LAST')), exactKm: dto.exactKm ?? null, vehicleId: dto.vehicleId ?? null, paid: (dto as any).paid ?? false, 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 afe6009ef..316f3e8fe 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -242,11 +242,12 @@ export class PaymentService { // debited against the intent amount, so the dev shortcut would break it. // CAC bank rejects amounts below 10 (DJF bounds 10–100,000), so its dev // shortcut floor is 10, not 1. - amountMinor: isCbeBill - ? input.amountMinor - : input.method === ProviderMethod.CAC_BANK - ? 10 - : 1, + // amountMinor: isCbeBill + // ? input.amountMinor + // : input.method === ProviderMethod.CAC_BANK + // ? 10 + // : 1, + amountMinor: input.amountMinor, currency: input.currency, provider: input.method as ProviderMethod, platform: input.platform, diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-location.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-location.entity.ts new file mode 100644 index 000000000..3d33b1998 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-location.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm'; + +import { Yard } from './yard.entity'; + +/** + * GPS position of a yard (decimal degrees, WGS84). One record per yard; + * today only the five facility yards (Sebeta, GMP/Indode, Mojo, Adama, + * Dire Dawa) are seeded. + */ +@Entity({ schema: 'freight', name: 'yard_locations' }) +@Index(['yardId'], { unique: true }) +export class YardLocation extends BaseEntity { + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + @OneToOne(() => Yard, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'yard_id' }) + yard?: Yard; + + @Column({ name: 'latitude', type: 'double precision' }) + latitude!: number; + + @Column({ name: 'longitude', type: 'double precision' }) + longitude!: number; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts index 4df961aaa..07993c267 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts @@ -59,26 +59,57 @@ export class CargoTypesRepository implements ICargoTypesRepository { } async create(data: Partial): Promise { - const entity = this.repo.create(data); - return this.repo.save(entity); + const { wagonTypes, ...columns } = data; + const entity = this.repo.create(columns); + const saved = await this.repo.save(entity); + if (wagonTypes?.length) { + await this.syncWagonTypes(saved.id, wagonTypes.map((wt) => wt.id), []); + } + return (await this.findById(saved.id)) ?? saved; } async update(id: string, data: Partial): Promise { - // Relation lists can't ride a column UPDATE — sync them via entity save. const { wagonTypes, ...columns } = data; if (Object.keys(columns).length) { await this.repo.update(id, columns as never); } if (wagonTypes) { - const entity = await this.repo.findOne({ where: { id } }); - if (entity) { - entity.wagonTypes = wagonTypes; - await this.repo.save(entity); + const current = await this.repo.findOne({ + where: { id }, + relations: { wagonTypes: true }, + }); + if (current) { + await this.syncWagonTypes( + id, + wagonTypes.map((wt) => wt.id), + (current.wagonTypes ?? []).map((wt) => wt.id), + ); } } return this.findById(id); } + /** + * Diffs the wagon-type links through the relation query builder rather than + * an entity save: junction-row inserts from save() broadcast afterInsert with + * no entity attached, which the @tria-plc/auditlog subscriber (deployed + * builds) dereferences and crashes the request on. + */ + private async syncWagonTypes( + id: string, + nextIds: string[], + currentIds: string[], + ): Promise { + const toAdd = nextIds.filter((x) => !currentIds.includes(x)); + const toRemove = currentIds.filter((x) => !nextIds.includes(x)); + if (!toAdd.length && !toRemove.length) return; + await this.repo + .createQueryBuilder() + .relation(CargoType, 'wagonTypes') + .of(id) + .addAndRemove(toAdd, toRemove); + } + async softDelete(id: string): Promise { await this.repo.softDelete(id); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 2e1fd8090..d487387b8 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -27,6 +27,7 @@ import { WeightLimitRule } from './entities/weight-limit-rule.entity'; import { Yard } from './entities/yard.entity'; import { YardDistance } from './entities/yard-distance.entity'; import { YardFacility } from './entities/yard-facility.entity'; +import { YardLocation } from './entities/yard-location.entity'; import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface'; import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface'; @@ -88,6 +89,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. Yard, YardDistance, YardFacility, + YardLocation, ShippingLine, Rate, ApprovalRule, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 966e051d3..5185dcd99 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -422,7 +422,9 @@ export class BookingBatchService implements OnModuleInit { .getRepository(Booking) .createQueryBuilder("b") .select("DISTINCT b.train_schedule_id", "scheduleId") - .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + .where( + `b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', 'PAYMENT_VERIFICATION_IN_PROGRESS')`, + ) .andWhere("b.train_schedule_id IS NOT NULL") .getRawMany<{ scheduleId: string }>(); for (const { scheduleId } of reserved) this.armSettle(scheduleId); @@ -2118,7 +2120,9 @@ export class BookingBatchService implements OnModuleInit { if (linked) return "ALLOCATED"; if ( booking.status === "SELECTED_FOR_BATCH" || - booking.status === "AWAITING_PAYMENT" + booking.status === "AWAITING_PAYMENT" || + // Redirect-acked, webhook pending — still a reserved (unpaid) hold. + booking.status === "PAYMENT_VERIFICATION_IN_PROGRESS" ) { return "SELECTED_FOR_BATCH"; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index 84d029e4f..efb540066 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -300,6 +300,19 @@ export class BookingNotifierService { this.inApp(b, 'Removed from train', msg); } + /** + * The train carrying this booking was cancelled. The booking is detached and + * returns to the eligible pool — the customer must rebook or pick a new schedule. + */ + scheduleCancelled(b: Booking): void { + const msg = + `The train for booking ${b.reference ?? b.id} has been cancelled. ` + + `Your booking is not lost — please rebook or select a new schedule from the portal.`; + void this.notifyContact(b, msg, 'TRAIN CANCELLED'); + // HIGH: a cancelled train invalidates the customer's plans — must reach SMS/email. + this.inApp(b, 'Train cancelled', msg, { priority: NotificationPriority.HIGH }); + } + /** * The train carrying this booking was moved for maintenance to a new departure * date. The booking stays on the train — only the date moved. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index c60dd14a6..1a3baa6b4 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -610,7 +610,9 @@ export class BookingWindowService implements OnModuleInit { .getRepository(Booking) .createQueryBuilder('b') .select('DISTINCT b.train_schedule_id', 'scheduleId') - .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + .where( + `b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', 'PAYMENT_VERIFICATION_IN_PROGRESS')`, + ) // Deadline is the line — expire() itself reconciles against the gateway // before actually expiring, so a late in-window payment is still caught. .andWhere('b.payment_deadline <= now()') diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 44c50e185..df5c44fde 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -2653,7 +2653,9 @@ export class TrainSchedulingService { paymentDeadline: null, }) .where('train_schedule_id = :scheduleId', { scheduleId }) - .andWhere(`status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + .andWhere( + `status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', 'PAYMENT_VERIFICATION_IN_PROGRESS')`, + ) .execute(); }); @@ -4247,6 +4249,15 @@ export class TrainSchedulingService { } }); + // Best-effort customer notice (SMS + email + in-app) — the cancel itself has + // already committed, so a notification failure must never fail the cancel. + for (const sb of schedule.scheduleBookings ?? []) { + const booking = await this.bookingsRepository + .findByIdWithFiles(sb.bookingId) + .catch(() => null); + if (booking) this.bookingNotifier.scheduleCancelled(booking); + } + // Window retired (DONE) — remove the card from portal/GL lists right away. void this.emitWindowState(id); return this.getTrainScheduleById(id); diff --git a/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts index 593abb305..4c226f8ab 100644 --- a/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts +++ b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts @@ -35,13 +35,24 @@ async function main() { // Demo seeders are intentionally not AppModule providers (they'd run on every // boot), so construct them against the app's DataSource instead of via DI. const dataSource = app.get(DataSource); - await new PricingDataSeeder(dataSource).run(); - await new IndodeFacilitySeeder(dataSource).run(); - await new Batch14TestDataSeeder(dataSource).run(); - await new Batch5TestDataSeeder(dataSource).run(); - await new Batch7TestDataSeeder(dataSource).run(); - await new Batch8TestDataSeeder(dataSource).run(); - await new WarehouseDemoSeeder(dataSource).run(); + + // Each bucket is independent: a seeder that has drifted from the current + // schema shouldn't stop the rest of the demo data from landing. + const step = async (name: string, run: () => Promise) => { + try { + await run(); + } catch (error) { + console.warn(` ! ${name} skipped: ${error instanceof Error ? error.message : String(error)}`); + } + }; + + await step('PricingDataSeeder', () => new PricingDataSeeder(dataSource).run()); + await step('IndodeFacilitySeeder', () => new IndodeFacilitySeeder(dataSource).run()); + await step('Batch14TestDataSeeder', () => new Batch14TestDataSeeder(dataSource).run()); + await step('Batch5TestDataSeeder', () => new Batch5TestDataSeeder(dataSource).run()); + await step('Batch7TestDataSeeder', () => new Batch7TestDataSeeder(dataSource).run()); + await step('Batch8TestDataSeeder', () => new Batch8TestDataSeeder(dataSource).run()); + await step('WarehouseDemoSeeder', () => new WarehouseDemoSeeder(dataSource).run()); console.log('Warehouse demo data seeded.'); } finally { diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index 7ebcfb89c..22f7fe5ef 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -306,4 +306,5 @@ export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [ { key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] }, { key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief] }, { key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher] }, + { key: "truck_machinery_chief", name: { en: "Truck & Machinery Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.truckMachineryChief] }, ]; diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 0c44e704d..11cccd782 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -227,6 +227,9 @@ export const MILE_PERMISSIONS: FreightPermissionSeed[] = [ perm('d3b00001-0001-4000-8000-000000000006', 'edr_freight_app:last_mile:assign_vehicles', 'Assign last-mile vehicles'), perm('d3b00001-0001-4000-8000-000000000007', 'edr_freight_app:last_mile:set_distances', 'Set last-mile distances'), perm('d3b00001-0001-4000-8000-000000000008', 'edr_freight_app:last_mile:generate_invoice', 'Generate last-mile invoice'), + perm('d3b00001-0001-4000-8000-000000000009', 'edr_freight_app:last_mile:request_view', 'View last-mile confirmation requests'), + perm('d3b00001-0001-4000-8000-00000000000a', 'edr_freight_app:last_mile:request_review', 'Review last-mile confirmation requests (T&M dept)'), + perm('d3b00001-0001-4000-8000-00000000000b', 'edr_freight_app:last_mile:request_approve', 'Approve/reject last-mile confirmation requests'), ]; // F. Fleet — rail assets (splits the flat fleet:view/manage) @@ -528,6 +531,11 @@ export const FREIGHT_PERMS = { assignVehicles: 'edr_freight_app:last_mile:assign_vehicles', setDistances: 'edr_freight_app:last_mile:set_distances', generateInvoice: 'edr_freight_app:last_mile:generate_invoice', + // Pre-approval confirmation stage (Truck & Machinery department): view/review + // a submitted request, approve/reject it. + requestView: 'edr_freight_app:last_mile:request_view', + requestReview: 'edr_freight_app:last_mile:request_review', + requestApprove: 'edr_freight_app:last_mile:request_approve', }, locomotives: { view: 'edr_freight_app:locomotives:view', @@ -1002,6 +1010,17 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.trainScheduling.view, FREIGHT_PERMS.bookings.operations, ]), + // Truck & Machinery chief: reviews and approves/rejects last-mile + // confirmation requests (the pre-approval gate ahead of vehicle assignment), + // plus enough fleet visibility to judge truck availability. + truckMachineryChief: dedupe([ + FREIGHT_PERMS.lastMile.view, + FREIGHT_PERMS.lastMile.requestView, + FREIGHT_PERMS.lastMile.requestReview, + FREIGHT_PERMS.lastMile.requestApprove, + FREIGHT_PERMS.fleetDashboard.view, + FREIGHT_PERMS.vehicles.view, + ]), } as const; /** Derive the module bucket from the resource segment of a permission key. */ diff --git a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts index 6de3bc4de..1b5d776e9 100644 --- a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts +++ b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts @@ -29,6 +29,7 @@ const STAFF_USERS = [ { email: 'operation@edr.local', username: 'operation', roleKey: 'edr_operations_officer', positionKey: 'operation' }, { email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia', positionKey: 'ethiopian_gl' }, { email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti', positionKey: 'djibouti_gl' }, + { email: 'tm-chief@edr.local', username: 'tm_chief', roleKey: 'edr_operations_officer', positionKey: 'truck_machinery_chief' }, ] as const; @Injectable() diff --git a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts index f60d7bb75..654f4f77f 100644 --- a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -4,6 +4,11 @@ import { DataSource } from 'typeorm'; import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; import { Booking } from '../modules/bookings/entities/booking.entity'; +import { + CompanyProfile, + ProfileStatus, + ProfileType, +} from '../modules/companies/entities/company-profile.entity'; import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity'; import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; @@ -13,7 +18,8 @@ import { ServiceType } from '../modules/rule-engine/entities/service-type.entity import { Yard } from '../modules/rule-engine/entities/yard.entity'; const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_PAID_MILE'; -const COMPANY_TIN = 'PAIDMILE001'; +// companies.tin is varchar(10) — an 11-char TIN 22001s the whole seeder. +const COMPANY_TIN = 'PAIDMILE01'; const COMPANY_EMAIL = 'paid-mile-demo@edr.local'; const YARDS = [ @@ -183,6 +189,22 @@ export class PaidImportExportMileDemoSeeder { manager.getRepository(ContainerType).find(), ]); + // bookings.company_profile_id is NOT NULL — the demo company needs an + // approved importer profile of its own (no unique key to upsert on). + const profileRepo = manager.getRepository(CompanyProfile); + const companyProfile = + (await profileRepo.findOne({ + where: { companyId: company.id, type: ProfileType.importer }, + })) ?? + (await profileRepo.save( + profileRepo.create({ + companyId: company.id, + type: ProfileType.importer, + status: ProfileStatus.Active, + businessLicense: 'PMD-LIC-0001', + }), + )); + const yardByCode = new Map(yards.map((yard) => [yard.code, yard])); const containerTypeByCode = new Map( containerTypes.map((containerType) => [containerType.code, containerType]), @@ -206,6 +228,7 @@ export class PaidImportExportMileDemoSeeder { { reference: demoBooking.reference, companyId: company.id, + companyProfileId: companyProfile.id, status: 'APPROVED', scheduledDate: new Date(demoBooking.scheduledDate), estimatedShipmentDate: new Date(demoBooking.scheduledDate), diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx index 4ab2c42f9..898982f34 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx @@ -126,6 +126,15 @@ const FleetRecordActions = ({ {removeLabel} ) : null} + {onPurge ? ( + onPurge(record)} + leftSection={} + > + Delete permanently + + ) : null} ); diff --git a/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx new file mode 100644 index 000000000..6b5e15d99 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx @@ -0,0 +1,290 @@ +import { useState } from "react"; +import { + Badge, + Box, + Button, + Card, + Group, + Modal, + NumberInput, + Stack, + Text, + Textarea, +} from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { ColumnDef } from "@edr/ui-common"; +import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; + +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { + lastMileRequestsService, + type LastMileRequest, + type LastMileRequestStatus, +} from "@/services/last-mile-requests.service"; + +const STATUS_META: Record = { + AWAITING_CONFIRMATION: { label: "Awaiting Confirmation", color: "gray" }, + SUBMITTED: { label: "Submitted", color: "yellow" }, + APPROVED: { label: "Approved", color: "green" }, + REJECTED: { label: "Rejected", color: "red" }, +}; + +type StatusFilter = "ALL" | LastMileRequestStatus; + +const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [ + { value: "SUBMITTED", label: "Submitted" }, + { value: "APPROVED", label: "Approved" }, + { value: "REJECTED", label: "Rejected" }, + { value: "AWAITING_CONFIRMATION", label: "Awaiting Confirmation" }, + { value: "ALL", label: "All" }, +]; + +const fmtDate = (iso?: string | null) => + iso ? new Date(iso).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" }) : "—"; + +export function LastMileRequestsPanel() { + const { toast } = useToast(); + const qc = useQueryClient(); + const { user } = useAuth(); + const canApprove = hasPermission(user, FREIGHT_PERMS.lastMile.requestApprove); + + const [statusFilter, setStatusFilter] = useState("SUBMITTED"); + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [approveTarget, setApproveTarget] = useState(null); + const [rejectTarget, setRejectTarget] = useState(null); + const [advanceAmount, setAdvanceAmount] = useState(""); + const [rejectReason, setRejectReason] = useState(""); + + const filter = { + ...(statusFilter !== "ALL" ? { status: statusFilter } : {}), + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + }; + + const { data, isLoading } = useQuery({ + queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.list(filter), + queryFn: async () => (await lastMileRequestsService.list(filter)).data, + }); + const rows = data?.data ?? []; + const meta = data?.meta; + + const { data: freeTrucks } = useQuery({ + queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.freeTruckCount, + queryFn: async () => (await lastMileRequestsService.freeTruckCount()).data, + }); + + const invalidate = () => + qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.ROOT }); + + const approve = useMutation({ + mutationFn: () => + lastMileRequestsService.approve(approveTarget!.id, Number(advanceAmount)), + onSuccess: () => { + void invalidate(); + toast({ title: "Request approved" }); + setApproveTarget(null); + setAdvanceAmount(""); + }, + onError: (e: unknown) => { + const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast({ title: "Approve failed", description, variant: "destructive" }); + }, + }); + + const reject = useMutation({ + mutationFn: () => lastMileRequestsService.reject(rejectTarget!.id, rejectReason.trim()), + onSuccess: () => { + void invalidate(); + toast({ title: "Request rejected" }); + setRejectTarget(null); + setRejectReason(""); + }, + onError: (e: unknown) => { + const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast({ title: "Reject failed", description, variant: "destructive" }); + }, + }); + + const columns: ColumnDef[] = [ + { + id: "booking", + header: () => Booking, + cell: ({ row }) => { + const r = row.original; + return ( + + {r.booking?.reference ?? r.bookingId} + {r.booking?.company?.name ?? "—"} + + ); + }, + }, + { + id: "containers", + header: () => Requested Containers, + cell: ({ row }) => { + const nums = row.original.requestedContainerNumbers; + return {nums?.length ? nums.join(", ") : "—"}; + }, + }, + { + id: "submittedAt", + header: () => Submitted, + cell: ({ row }) => {fmtDate(row.original.submittedAt)}, + }, + { + id: "status", + header: () => Status, + cell: ({ row }) => { + const meta = STATUS_META[row.original.status]; + return ( + + {meta.label} + + ); + }, + }, + ...(canApprove + ? [ + { + id: "actions", + header: () => Actions, + cell: ({ row }: { row: { original: LastMileRequest } }) => { + const r = row.original; + if (r.status !== "SUBMITTED") return null; + return ( + + + + + ); + }, + } as ColumnDef, + ] + : []), + ]; + + return ( + + + + + + {freeTrucks?.count ?? 0} truck{freeTrucks?.count === 1 ? "" : "s"} currently free + + + {FILTER_OPTIONS.map((option) => { + const active = statusFilter === option.value; + return ( + + ); + })} + + + + + ( + + )} + /> + + + setApproveTarget(null)} + title={Approve request{approveTarget?.booking?.reference ? ` · ${approveTarget.booking.reference}` : ""}} + centered + > + + + + + + + + + + setRejectTarget(null)} + title={Reject request{rejectTarget?.booking?.reference ? ` · ${rejectTarget.booking.reference}` : ""}} + centered + > + +