From 0b1e33045912c4ea94bd0c2539d048ad5910967c Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 29 Jun 2026 13:00:55 +0000 Subject: [PATCH 01/42] add payemnt test --- apps/edr-freight-api/html/payment-tester.html | 452 ++++++++++++++++++ 1 file changed, 452 insertions(+) create mode 100644 apps/edr-freight-api/html/payment-tester.html diff --git a/apps/edr-freight-api/html/payment-tester.html b/apps/edr-freight-api/html/payment-tester.html new file mode 100644 index 000000000..1bb1a5c80 --- /dev/null +++ b/apps/edr-freight-api/html/payment-tester.html @@ -0,0 +1,452 @@ + + + + + +EDR Freight β€” Payment Tester (Telebirr ETB + Card USD) + + + +
+

πŸš‚ EDR Freight β€” Payment Tester

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

API connection

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

1 Β· Choose a booking

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

2 Β· Initiate payment

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

+
+ + +
+

3 Β· Track intent & receipt

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

Last response

+
β€”
+
+
+

Request log

+
+
+
+
+ + + + From 4c153b0e3b3f7d8d0bb7d6736f62ac654d46aeb0 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Mon, 29 Jun 2026 16:37:01 +0300 Subject: [PATCH 02/42] Update payment methods and cron job for payment cancellation --- .../migration.sql | 59 +- .../src/modules/bookings/bookings.service.ts | 26 +- .../modules/bookings/guest-booking.service.ts | 92 ++- .../fare-engine/fare-engine.service.ts | 7 +- .../modules/payments/payments.controller.ts | 26 +- .../src/modules/payments/payments.dto.ts | 6 + .../src/modules/payments/payments.service.ts | 33 +- .../src/modules/search/search.service.ts | 4 +- .../src/modules/tasks/tasks.service.ts | 137 +++-- .../src/app/booking/confirmation/page.tsx | 111 ++-- .../src/app/booking/passengers/page.tsx | 155 ++++- .../portal/src/app/booking/payment/page.tsx | 63 +- .../portal/src/app/booking/results/page.tsx | 30 +- .../portal/src/app/booking/review/page.tsx | 35 +- .../portal/src/lib/booking-store.ts | 4 + .../portal/src/lib/generate-voucher.ts | 569 ++++++++---------- 16 files changed, 793 insertions(+), 564 deletions(-) diff --git a/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql index 15b2502f5..ff5a9545e 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql @@ -1,7 +1,7 @@ -- Migration: Add Configurable Fare Management System -- Main fare configuration table -CREATE TABLE "fare_configurations" ( +CREATE TABLE IF NOT EXISTS "fare_configurations" ( "id" TEXT NOT NULL, "name" TEXT NOT NULL, "description" TEXT, @@ -19,7 +19,7 @@ CREATE TABLE "fare_configurations" ( ); -- Rate structure by nationality and coach/position -CREATE TABLE "fare_rate_rules" ( +CREATE TABLE IF NOT EXISTS "fare_rate_rules" ( "id" TEXT NOT NULL, "fare_config_id" TEXT NOT NULL, "nationality_type" TEXT NOT NULL, -- 'LOCAL' or 'INTERNATIONAL' @@ -34,7 +34,7 @@ CREATE TABLE "fare_rate_rules" ( ); -- Configurable fare components (insurance, premiums, service charges, taxes) -CREATE TABLE "fare_components" ( +CREATE TABLE IF NOT EXISTS "fare_components" ( "id" TEXT NOT NULL, "fare_config_id" TEXT NOT NULL, "component_type" TEXT NOT NULL, -- 'INSURANCE', 'PREMIUM', 'SERVICE_CHARGE', 'TAX', 'DEMAND' @@ -52,7 +52,7 @@ CREATE TABLE "fare_components" ( ); -- Age-based pricing rules -CREATE TABLE "age_pricing_rules" ( +CREATE TABLE IF NOT EXISTS "age_pricing_rules" ( "id" TEXT NOT NULL, "fare_config_id" TEXT NOT NULL, "rule_name" TEXT NOT NULL, @@ -70,7 +70,7 @@ CREATE TABLE "age_pricing_rules" ( ); -- Audit trail for configuration changes -CREATE TABLE "fare_configuration_audit" ( +CREATE TABLE IF NOT EXISTS "fare_configuration_audit" ( "id" TEXT NOT NULL, "fare_config_id" TEXT NOT NULL, "action" TEXT NOT NULL, -- 'CREATED', 'UPDATED', 'ACTIVATED', 'DEACTIVATED' @@ -81,27 +81,37 @@ CREATE TABLE "fare_configuration_audit" ( CONSTRAINT "fare_configuration_audit_pkey" PRIMARY KEY ("id") ); --- Foreign key constraints -ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; +-- Foreign key constraints (idempotent) +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_rate_rules_fare_config_id_fkey') THEN + ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_components_fare_config_id_fkey') THEN + ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'age_pricing_rules_fare_config_id_fkey') THEN + ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_configuration_audit_fare_config_id_fkey') THEN + ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; --- Indexes for performance -CREATE INDEX "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date"); -CREATE INDEX "fare_configurations_is_active_idx" ON "fare_configurations"("is_active"); -CREATE UNIQUE INDEX "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true; +-- Indexes for performance (idempotent) +CREATE INDEX IF NOT EXISTS "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date"); +CREATE INDEX IF NOT EXISTS "fare_configurations_is_active_idx" ON "fare_configurations"("is_active"); +CREATE UNIQUE INDEX IF NOT EXISTS "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true; -CREATE INDEX "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position"); -CREATE INDEX "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order"); -CREATE INDEX "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age"); +CREATE INDEX IF NOT EXISTS "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position"); +CREATE INDEX IF NOT EXISTS "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order"); +CREATE INDEX IF NOT EXISTS "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age"); --- Add legacy mode flag to existing fare tables for gradual migration -ALTER TABLE "FareRule" ADD COLUMN "migrated_to_config_id" TEXT; -ALTER TABLE "SegmentFareRule" ADD COLUMN "migrated_to_config_id" TEXT; +-- Add legacy mode flag to existing fare tables for gradual migration (idempotent) +ALTER TABLE "passenger"."FareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT; +ALTER TABLE "passenger"."SegmentFareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT; -- Add feature flag support -CREATE TABLE "system_features" ( +CREATE TABLE IF NOT EXISTS "system_features" ( "id" TEXT NOT NULL, "feature_name" TEXT NOT NULL UNIQUE, "is_enabled" BOOLEAN NOT NULL DEFAULT false, @@ -112,6 +122,7 @@ CREATE TABLE "system_features" ( CONSTRAINT "system_features_pkey" PRIMARY KEY ("id") ); --- Insert the configurable fares feature flag -INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config") -VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}'); \ No newline at end of file +-- Insert the configurable fares feature flag (idempotent) +INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config", "updated_at") +VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}', CURRENT_TIMESTAMP) +ON CONFLICT ("feature_name") DO NOTHING; \ No newline at end of file diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 7e8947034..2ca7d5ac4 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -483,8 +483,8 @@ export class BookingsService { } const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; - const taxesMinor = Math.round(combinedBaseFareMinor * 0.05); - const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; let displayTotalMinor = totalMinor; @@ -660,8 +660,8 @@ export class BookingsService { } } const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; - const taxesMinor = Math.round(combinedBase * 0.05); - const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) @@ -854,8 +854,8 @@ export class BookingsService { } } const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; - const taxesMinor = Math.round(combinedBase * 0.05); - const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) @@ -1060,7 +1060,7 @@ export class BookingsService { loyaltyRedemptionPoints?: number ) { const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; - const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence); + const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence, originStop.stationId, destStop.stationId); const adultFareMinor = baseFareMinor * adultCount; const paidChildrenCount = Math.max(0, childCount - 1); @@ -1076,8 +1076,8 @@ export class BookingsService { } const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10; - const taxesMinor = Math.round(totalBaseFareMinor * 0.05); - const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor); return { baseFareMinor, @@ -1103,6 +1103,8 @@ export class BookingsService { nationality?: string, originStopSeq?: number, destStopSeq?: number, + originStationId?: string, + destinationStationId?: string, ): Promise { const now = new Date(); @@ -1149,13 +1151,13 @@ export class BookingsService { const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality); if (bestMatch) return bestMatch.baseFareMinor; - // 3. FareEngine β€” distance Γ— rate-per-km from the schedule's route + // 3. FareEngine β€” distance Γ— rate-per-km from the booking's actual segment stations if (schedule?.routeId) { try { const fare = await this.fareEngine.calculate({ routeId: schedule.routeId, - originStationId: schedule.originStationId, - destinationStationId: schedule.destinationStationId, + originStationId: originStationId ?? schedule.originStationId, + destinationStationId: destinationStationId ?? schedule.destinationStationId, seatClassId, nationality, }); diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index cfddd77f8..6907d14a3 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -9,6 +9,9 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto'; import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; +/** Booking cutoff: reject new bookings within this many ms of departure. */ +const BOOKING_CUTOFF_MS = 30 * 60 * 1000; + function generateRef(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); @@ -74,6 +77,10 @@ export class GuestBookingService { }); if (!schedule) throw new NotFoundException('Schedule not found'); + if (Date.now() >= schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { + throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); + } + const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId); const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId); if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found'); @@ -142,7 +149,9 @@ export class GuestBookingService { dto.seatClassId, segmentRoute, fullRoute, - primaryNationality + primaryNationality, + dto.originStationId, + dto.destinationStationId, ); const adultFareMinor = baseFareMinor * adultCount; @@ -160,8 +169,8 @@ export class GuestBookingService { } } - const taxesMinor = Math.round(totalBaseFareMinor * 0.05); - const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; let displayTotalMinor = totalMinor; @@ -293,6 +302,10 @@ export class GuestBookingService { if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found'); if (!returnSchedule) throw new NotFoundException('Return schedule not found'); + if (Date.now() >= outboundSchedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { + throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); + } + const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId); const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId); const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId); @@ -350,8 +363,8 @@ export class GuestBookingService { const primaryNationality = passengersData[0]?.nationality; const [outboundBaseFare, returnBaseFare] = await Promise.all([ - this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality), - this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality), + this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId), + this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId), ]); const paidChildrenCount = Math.max(0, childCount - 1); @@ -369,8 +382,8 @@ export class GuestBookingService { } } - const taxesMinor = Math.round(combinedBaseFareMinor * 0.05); - const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB @@ -505,6 +518,10 @@ export class GuestBookingService { if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found'); if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found'); + if (Date.now() >= leg1Schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { + throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); + } + const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId); const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); @@ -551,11 +568,11 @@ export class GuestBookingService { this.getBaseFare(dto.scheduleId, dto.seatClassId, `${leg1OriginStop.station.code}-${leg1DestStop.station.code}`, `${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`, - primaryNationality), + primaryNationality, dto.originStationId, dto.transitStationId), this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId, `${leg2OriginStop.station.code}-${leg2DestStop.station.code}`, `${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`, - primaryNationality), + primaryNationality, dto.transitStationId, dto.leg2DestinationStationId), ]); const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount; @@ -569,8 +586,8 @@ export class GuestBookingService { discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); } } - const taxesMinor = Math.round(combinedBase * 0.05); - const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, combinedBase - discountMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB @@ -702,6 +719,10 @@ export class GuestBookingService { if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found'); if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found'); + if (Date.now() >= obL1Sched.departureAt.getTime() - BOOKING_CUTOFF_MS) { + throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); + } + const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId); const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId); const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId); @@ -750,10 +771,10 @@ export class GuestBookingService { const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId; const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([ - this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat), - this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat), - this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat), - this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat), + this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat, dto.originStationId, dto.transitStationId), + this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat, dto.transitStationId, dto.leg2DestinationStationId), + this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat, dto.returnOriginStationId, dto.returnTransitStationId), + this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat, dto.returnTransitStationId, dto.returnLeg2DestinationStationId), ]); const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount + @@ -951,17 +972,28 @@ export class GuestBookingService { segmentRoute?: string, fullRoute?: string, nationality?: string, + originStationId?: string, + destinationStationId?: string, ): Promise { const now = new Date(); - // 1. FareRule table β€” explicit override rules - const candidates = await this.prisma.fareRule.findMany({ - where: { - seatClassId, - validFrom: { lte: now }, - OR: [{ validUntil: null }, { validUntil: { gte: now } }], - }, - }); + // 1. FareRule table β€” explicit override rules (same priority logic as the fare engine) + const [candidates, seatClass] = await Promise.all([ + this.prisma.fareRule.findMany({ + where: { + seatClassId, + validFrom: { lte: now }, + OR: [{ validUntil: null }, { validUntil: { gte: now } }], + }, + }), + this.prisma.seatClass.findUnique({ + where: { id: seatClassId }, + select: { premiumMinor: true, insuranceFeeMinor: true }, + }), + ]); + + const premiumMinor = seatClass?.premiumMinor ?? 0; + const insuranceMinor = seatClass?.insuranceFeeMinor ?? 0; const priorities = [ { tripId: scheduleId, route: segmentRoute, nationality }, @@ -982,10 +1014,11 @@ export class GuestBookingService { const match = candidates.find( (c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality, ); - if (match) return match.baseFareMinor; + // Return base fare + seat-class surcharges so the booking total matches the quoted fare + if (match) return match.baseFareMinor + premiumMinor + insuranceMinor; } - // 2. FareEngine β€” distance Γ— rate-per-km from the schedule's route + // 2. FareEngine β€” distance Γ— rate-per-km from the booking's actual segment stations const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, select: { routeId: true, originStationId: true, destinationStationId: true }, @@ -995,12 +1028,15 @@ export class GuestBookingService { try { const fare = await this.fareEngine.calculate({ routeId: schedule.routeId, - originStationId: schedule.originStationId, - destinationStationId: schedule.destinationStationId, + // Use the booking's boarding/alighting stations so the distance reflects the + // passenger's actual segment, not the full schedule route. + originStationId: originStationId ?? schedule.originStationId, + destinationStationId: destinationStationId ?? schedule.destinationStationId, seatClassId, nationality, }); - return fare.baseFarePerPassengerMinor; + // farePerPassengerMinor already includes base + premiumMinor + insuranceFeeMinor + return fare.farePerPassengerMinor; } catch { // FareEngine throws if distanceKm is missing; fall through to error } diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index f2ce49791..847164551 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -284,13 +284,16 @@ export class FareEngineService { const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); return fareRules.map(rule => { const seatClassId = rule.seatClassId; + const taxMinor = Math.round(rule.baseFareMinor * TAX_RATE); + const totalMinor = rule.baseFareMinor + taxMinor; return { seatClassId, seatClassName: 'Unknown', baseFareMinor: rule.baseFareMinor, - totalMinor: rule.baseFareMinor, + taxMinor, + totalMinor, billingCurrency, - totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate), + totalInBillingCurrency: Math.round(totalMinor * exchangeRate), exchangeRate, source: 'FARE_RULE', }; diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 97cf2600e..280ef2752 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -31,6 +31,7 @@ import { SupportedPaymentMethodDto, PaymentMethodTypeEnum, PaymentPlatformDto, + BookingAmountResponseDto, } from "./payments.dto"; import { PassengerStaff } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; @@ -139,16 +140,33 @@ export class PaymentsController { @ApiOperation({ summary: "List payment systems supported by the platform", description: - "Returns the global catalog of accepted payment systems. Filter by `currency` (e.g. ETB, DJF, USD) to get methods that settle in that currency, and/or by `region` to match a passenger's nationality. Both filters can be combined.", + "Returns all enabled payment methods. Optionally filter by `region` to narrow to methods available for a passenger's nationality.", }) - @ApiQuery({ name: "currency", required: false, example: "DJF", description: "Settlement currency β€” ETB, DJF, USD, etc." }) @ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false }) @ApiOkResponse({ type: [SupportedPaymentMethodDto] }) getMethods( - @Query("currency") currency?: string, @Query("region") region?: PaymentRegionEnum, ) { - return this.service.getSupportedPaymentMethods(region, currency); + return this.service.getSupportedPaymentMethods(region); + } + + @Get("booking-amount") + @SetMetadata('isPublic', true) + @ApiOperation({ + summary: "Get booking amount in a specific currency", + description: + "Returns the booking total converted from ETB to the requested currency using the latest exchange rate. " + + "If currency is ETB the stored amount is returned as-is (no conversion). " + + "Amounts are returned in major currency units (e.g. 162.50 DJF, not centimes).", + }) + @ApiQuery({ name: "bookingId", required: true, description: "Booking UUID" }) + @ApiQuery({ name: "currency", required: true, example: "DJF", description: "Target currency: ETB, DJF, or USD" }) + @ApiOkResponse({ type: BookingAmountResponseDto }) + getBookingAmount( + @Query("bookingId") bookingId: string, + @Query("currency") currency: string, + ) { + return this.service.getBookingAmountByCurrency(bookingId, currency); } @Get("checkout") diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index c1b168138..410716b85 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -136,3 +136,9 @@ export class IntentStatusDto { @ApiPropertyOptional() failureCode?: string; @ApiPropertyOptional() failureMessage?: string; } + +export class BookingAmountResponseDto { + @ApiProperty({ example: 'booking-uuid' }) booking_id: string; + @ApiProperty({ example: 'DJF', description: 'Currency of the returned amount' }) currency: string; + @ApiProperty({ example: 162.5, description: 'Booking total converted to the requested currency (major units)' }) amount: number; +} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 93bae88d3..fc6262bbc 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -491,7 +491,7 @@ export class PaymentsService { }); } - getSupportedPaymentMethods(region?: PaymentRegionEnum, currency?: string) { + getSupportedPaymentMethods(region?: PaymentRegionEnum) { return this.prisma.paymentMethod.findMany({ where: { enabled: true, @@ -505,12 +505,41 @@ export class PaymentsService { }, } : {}), - ...(currency ? { currency: currency.toUpperCase() } : {}), }, orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }], }); } + async getBookingAmountByCurrency( + bookingId: string, + currency: string, + ): Promise<{ booking_id: string; currency: string; amount: number }> { + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + select: { id: true, totalMinor: true }, + }); + if (!booking) throw new NotFoundException('Booking not found'); + + const requestedCurrency = currency.toUpperCase(); + const amountInETB = booking.totalMinor / 100; + + if (requestedCurrency === 'ETB') { + return { booking_id: bookingId, currency: 'ETB', amount: amountInETB }; + } + + const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({ + where: { fromCurrency: 'ETB' as any, toCurrency: requestedCurrency as any }, + orderBy: { effectiveDate: 'desc' }, + }); + if (!exchangeRate) { + throw new NotFoundException(`Exchange rate not found for ETB β†’ ${requestedCurrency}`); + } + + const rate = Number(exchangeRate.rate); + const converted = parseFloat((amountInETB * rate).toFixed(2)); + return { booking_id: bookingId, currency: requestedCurrency, amount: converted }; + } + /** * Guard against an implausible paidAt from a provider event (e.g. a Telebirr epoch parsed as * msΓ—1000 β†’ year 58429), which Prisma/Postgres rejects and would otherwise dead-letter the diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 3a7b02682..e14db6b0a 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -474,8 +474,8 @@ export class SearchService { } const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR; - const taxesMinor = Math.round(totalBaseFareMinor * 0.05); - const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor); const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality); const displayTotalMinor = displayCurrency !== Currency.ETB diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index 70009d1d8..bc52cc613 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -3,12 +3,19 @@ import { Cron } from '@nestjs/schedule'; import { PrismaService } from '../../common/prisma.service'; import { SmsClientService } from '../notifications/sms-client.service'; -/** Minutes before departure at which each action fires. */ -const REMINDER_MINUTES = 3 * 60; // 3 h β†’ send payment reminder SMS -const DEADLINE_MINUTES = 2 * 60; // 2 h β†’ cancel unpaid booking +/** Maximum time (hours) a passenger has to pay after booking. */ +const MAX_PAYMENT_HOURS = 2; +/** Minutes before departure: cutoff for new bookings and payment deadline. */ +const CUTOFF_MINUTES = 30; -/** Half-width of the reminder detection window (cron runs every 2 min). */ -const REMINDER_WINDOW_MINUTES = 2; +/** + * payment_deadline = MIN(booking_time + 2h, departure_time - 30min) + */ +function computePaymentDeadline(createdAt: Date, departureAt: Date): Date { + const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000); + const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000); + return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline; +} function fmtTime(d: Date): string { return d.toLocaleTimeString('en-GB', { @@ -28,66 +35,72 @@ export class TasksService { ) {} // ───────────────────────────────────────────────────────────────────────── - // Every 2 min: advance TrainSchedule statuses (departure / arrival). + // Every 1 min: advance TrainSchedule statuses. + // + // SCHEDULED β†’ BOARDING when departure ≀ 30 min away (closed to new bookings) + // BOARDING β†’ EN_ROUTE at actual departure + // EN_ROUTE β†’ ARRIVED at arrival time // ───────────────────────────────────────────────────────────────────────── - @Cron('*/2 * * * *') + @Cron('*/1 * * * *') async syncScheduleStatuses() { const now = new Date(); + const thirtyMinFromNow = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000); - const [departed, arrived] = await Promise.all([ + const [boarding, departed, arrived] = await Promise.all([ this.prisma.trainSchedule.updateMany({ - where: { status: 'SCHEDULED', departureAt: { lte: now } }, + where: { status: 'SCHEDULED', departureAt: { lte: thirtyMinFromNow } }, + data: { status: 'BOARDING' }, + }), + this.prisma.trainSchedule.updateMany({ + where: { status: 'BOARDING', departureAt: { lte: now } }, data: { status: 'EN_ROUTE' }, }), this.prisma.trainSchedule.updateMany({ - where: { status: { in: ['EN_ROUTE', 'BOARDING'] }, arrivalAt: { lte: now } }, + where: { status: 'EN_ROUTE', arrivalAt: { lte: now } }, data: { status: 'ARRIVED' }, }), ]); - if (departed.count > 0 || arrived.count > 0) { + if (boarding.count > 0 || departed.count > 0 || arrived.count > 0) { this.logger.log( - `Schedule sync: ${departed.count} β†’ EN_ROUTE, ${arrived.count} β†’ ARRIVED`, + `Schedule sync: ${boarding.count} β†’ BOARDING, ${departed.count} β†’ EN_ROUTE, ${arrived.count} β†’ ARRIVED`, ); } } // ───────────────────────────────────────────────────────────────────────── - // Every 2 min: payment deadline enforcement. + // Every 1 min: payment deadline enforcement. // - // β€’ 3 h before departure β†’ send one SMS reminder to complete payment. - // β€’ 2 h before departure β†’ cancel booking if payment is still pending - // and notify the passenger by SMS. + // Reminder β€” sent once at the midpoint of the booking's payment window: + // reminder_at = booking_time + total_window / 2 // - // Example: train departs 08:00 - // 05:00 β†’ reminder SMS sent ("pay before 06:00 or booking is cancelled") - // 06:00 β†’ booking auto-cancelled, cancellation SMS sent + // Cancel β€” when now β‰₯ payment_deadline + // payment_deadline = MIN(booking_time + 2h, departure_time - 30min) + // + // Examples (departure 10:00, cutoff 9:30): + // Booked 8:00 β†’ deadline 9:30, window 1.5h, reminder at 8:45 + // Booked 9:00 β†’ deadline 9:30, window 30min, reminder at 9:15 // ───────────────────────────────────────────────────────────────────────── - @Cron('*/2 * * * *') + @Cron('*/1 * * * *') async enforcePaymentDeadlines() { const now = new Date(); - await Promise.all([ this.sendPaymentReminders(now), this.cancelExpiredPendingBookings(now), ]); } - // ── 3-hour reminder ─────────────────────────────────────────────────────── + // ── Send reminder at the midpoint of each booking's payment window ──────── private async sendPaymentReminders(now: Date) { - // Narrow 4-minute window (Β±2 min around the 3-hour mark) so each booking - // is caught by exactly one cron tick and paymentReminderSentAt guards re-sends. - const windowMs = REMINDER_WINDOW_MINUTES * 60 * 1000; - const reminderMs = REMINDER_MINUTES * 60 * 1000; - - const windowStart = new Date(now.getTime() + reminderMs - windowMs); - const windowEnd = new Date(now.getTime() + reminderMs + windowMs); + // Only look at bookings created within the last 3 h with a future departure. + const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000); const bookings = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', paymentReminderSentAt: null, - schedule: { departureAt: { gte: windowStart, lte: windowEnd } }, + createdAt: { gte: threeHoursAgo }, + schedule: { departureAt: { gte: now } }, } as any, include: { schedule: { @@ -101,15 +114,28 @@ export class TasksService { for (const booking of bookings) { try { - const dep = booking.schedule.departureAt as Date; - const deadline = new Date(dep.getTime() - DEADLINE_MINUTES * 60 * 1000); - const origin = booking.schedule.originStation?.name ?? ''; - const dest = booking.schedule.destinationStation?.name ?? ''; + const createdAt = booking.createdAt as Date; + const dep = booking.schedule.departureAt as Date; + const paymentDeadline = computePaymentDeadline(createdAt, dep); + const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime(); + + // Skip degenerate windows (< 2 min) β€” the cancel job will handle these immediately + if (totalWindowMs < 2 * 60 * 1000) continue; + + // Remind once, at the midpoint of the total payment window + const reminderAt = new Date(createdAt.getTime() + totalWindowMs / 2); + if (now < reminderAt) continue; + + const origin = booking.schedule.originStation?.name ?? ''; + const dest = booking.schedule.destinationStation?.name ?? ''; + const remainingMs = Math.max(0, paymentDeadline.getTime() - now.getTime()); + const remainingMin = Math.round(remainingMs / 60_000); const message = `EDR: Your booking ${booking.bookingRef} ` + `(${origin} β†’ ${dest}) departs at ${fmtTime(dep)}. ` + - `Complete payment by ${fmtTime(deadline)} or your booking will be cancelled.`; + `Complete payment within ${remainingMin} minute(s) (by ${fmtTime(paymentDeadline)}) ` + + `or your booking will be cancelled.`; if (booking.contactPhone) { await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null); @@ -121,7 +147,8 @@ export class TasksService { }); this.logger.log( - `Payment reminder sent: ${booking.bookingRef} (departs ${fmtTime(dep)}, deadline ${fmtTime(deadline)})`, + `Payment reminder sent: ${booking.bookingRef} ` + + `(deadline ${fmtTime(paymentDeadline)}, ${remainingMin} min remaining)`, ); } catch (err) { this.logger.error( @@ -131,14 +158,22 @@ export class TasksService { } } - // ── 2-hour auto-cancel ──────────────────────────────────────────────────── + // ── Cancel bookings whose payment deadline has passed ───────────────────── private async cancelExpiredPendingBookings(now: Date) { - const cutoff = new Date(now.getTime() + DEADLINE_MINUTES * 60 * 1000); // now + 2 h + const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000); + const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000); + // payment_deadline = MIN(createdAt + 2h, departureAt - 30min) + // Deadline is reached when either branch of the MIN is in the past: + // (a) createdAt ≀ now - 2h β†’ 2-hour max window elapsed + // (b) departureAt ≀ now + 30min β†’ departure within 30 min const expiredBookings = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', - schedule: { departureAt: { lte: cutoff } }, + OR: [ + { createdAt: { lte: twoHoursAgo } }, + { schedule: { departureAt: { lte: departureCutoff } } }, + ], }, include: { schedule: { @@ -151,8 +186,16 @@ export class TasksService { }, }); + let cancelledCount = 0; + for (const booking of expiredBookings) { try { + // Re-verify exact deadline to avoid racing with a concurrent payment confirmation + const createdAt = booking.createdAt as Date; + const dep = booking.schedule.departureAt as Date; + const paymentDeadline = computePaymentDeadline(createdAt, dep); + if (now < paymentDeadline) continue; + // 1. Release held seats (Journey rows are the occupancy source of truth) await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any }); @@ -161,12 +204,12 @@ export class TasksService { data: { bookingId: booking.id, cancelledBy: 'SYSTEM', - reason: 'Payment not completed before departure deadline', + reason: 'Payment not completed before deadline', refundAmount: 0, refundMethod: booking.paymentIntent?.method ?? 'NONE', refundStatus: 'NOT_APPLICABLE', }, - }).catch(() => null); // booking may already have a cancellation record + }).catch(() => null); // 3. Mark cancelled await this.prisma.booking.update({ @@ -175,22 +218,20 @@ export class TasksService { }); // 4. Notify passenger - const dep = booking.schedule.departureAt as Date; const origin = booking.schedule.originStation?.name ?? ''; const dest = booking.schedule.destinationStation?.name ?? ''; const message = `EDR: Your booking ${booking.bookingRef} ` + `(${origin} β†’ ${dest}, departs ${fmtTime(dep)}) has been cancelled ` + - `because payment was not completed before the deadline.`; + `because payment was not completed before the deadline (${fmtTime(paymentDeadline)}).`; if (booking.contactPhone) { await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null); } - this.logger.log( - `Auto-cancelled: ${booking.bookingRef} (payment deadline expired, departs ${fmtTime(dep)})`, - ); + this.logger.log(`Auto-cancelled: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})`); + cancelledCount++; } catch (err) { this.logger.error( `Auto-cancel failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`, @@ -198,8 +239,8 @@ export class TasksService { } } - if (expiredBookings.length > 0) { - this.logger.log(`Auto-cancelled ${expiredBookings.length} expired pending booking(s)`); + if (cancelledCount > 0) { + this.logger.log(`Auto-cancelled ${cancelledCount} expired pending booking(s)`); } } } diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 29af2b29b..8c3479f83 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -8,7 +8,6 @@ import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { useEffect, useState, useRef } from 'react'; import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train, FileText } from 'lucide-react'; -import { QRCodeSVG } from 'qrcode.react'; import { format } from 'date-fns'; type BookingWithTicket = { @@ -77,54 +76,71 @@ export default function ConfirmationPage() { }; const handleDownloadVoucher = async () => { - if (!_booking || !pnr) { + if (!pnr) { alert('Booking data not available. Please try again.'); return; } + if (!passengers.length) { + alert('No passenger data found.'); + return; + } setIsGeneratingVoucher(true); try { - console.log('πŸ“„ Generating voucher with data:', { _booking, pnr, selectedSchedule, passengers }); - - const { generateVoucherPDF } = await import('@/lib/generate-voucher'); - - const voucherData = { - bookingRef: pnr, - status: _booking.status || 'CONFIRMED', - passengers: passengers.map(p => ({ - fullName: p.name, - category: 'ADULT', - seat: p.seatNumber ? { - number: p.seatNumber, - coach: 'N/A', - seatClass: selectedSchedule?.selectedSeatClassName || 'Standard', - } : undefined, - })), - schedule: { - trainNumber: selectedSchedule?.trainNumber || 'N/A', - trainName: 'EDR Express', - origin: { - name: selectedSchedule?.origin || 'Origin', - code: 'ORG', - city: selectedSchedule?.origin || 'Origin', - }, - destination: { - name: selectedSchedule?.destination || 'Destination', - code: 'DST', - city: selectedSchedule?.destination || 'Destination', - }, - departureAt: selectedSchedule?.departureTime || new Date().toISOString(), - arrivalAt: selectedSchedule?.arrivalTime || new Date().toISOString(), - }, - totalMinor: _booking.totalMinor || passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0), - currency: 'ETB', - bookingType: 'ONE_WAY', - createdAt: new Date().toISOString(), + const { generatePassengerVoucherPDF } = await import('@/lib/generate-voucher'); + + const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule; + const totalFare = _booking?.totalMinor + || passengers.reduce((s) => s + (activeSchedule?.baseFareAdult || 0), 0); + const farePerPassenger = Math.round(totalFare / passengers.length); + const createdAt = _booking?.createdAt || new Date().toISOString(); + const status = _booking?.status || 'CONFIRMED'; + + const outbound = { + trainNumber: activeSchedule?.trainNumber || 'N/A', + trainName: 'EDR Express', + origin: { name: activeSchedule?.origin || 'Origin', code: 'ORG', city: activeSchedule?.origin || 'Origin' }, + destination: { name: activeSchedule?.destination || 'Destination', code: 'DST', city: activeSchedule?.destination || 'Destination' }, + departureAt: activeSchedule?.departureTime || new Date().toISOString(), + arrivalAt: activeSchedule?.arrivalTime || new Date().toISOString(), + seatClass: activeSchedule?.selectedSeatClassName, }; - console.log('πŸ“„ Voucher data prepared:', voucherData); - await generateVoucherPDF(voucherData); - console.log('βœ… Voucher generated successfully'); + const inbound = inboundSchedule ? { + trainNumber: inboundSchedule.trainNumber || 'N/A', + trainName: 'EDR Express', + origin: { name: inboundSchedule.origin, code: 'ORG', city: inboundSchedule.origin }, + destination: { name: inboundSchedule.destination, code: 'DST', city: inboundSchedule.destination }, + departureAt: inboundSchedule.departureTime || new Date().toISOString(), + arrivalAt: inboundSchedule.arrivalTime || new Date().toISOString(), + seatClass: inboundSchedule.selectedSeatClassName, + } : undefined; + + for (let i = 0; i < passengers.length; i++) { + const p = passengers[i]; + const ticketNumber = `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(i + 1).toString().padStart(2, '0')}`; + + await generatePassengerVoucherPDF({ + bookingRef: pnr, + ticketNumber, + passengerName: p.name || `Passenger ${i + 1}`, + dateOfBirth: p.dateOfBirth, + nationality: p.nationality, + seatNumber: p.seatNumber, + outboundSeatNumber: (p as any).outboundSeatNumber, + inboundSeatNumber: (p as any).inboundSeatNumber, + status, + outboundSchedule: outbound, + inboundSchedule: inbound, + isRoundTrip, + fareMinor: farePerPassenger, + currency: 'ETB', + createdAt, + }); + + // brief pause between downloads so browsers don't block them + if (i < passengers.length - 1) await new Promise(r => setTimeout(r, 400)); + } } catch (error) { console.error('❌ Failed to generate voucher:', error); alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`); @@ -191,17 +207,9 @@ export default function ConfirmationPage() { - {/* Trip Summary with QR Code */} + {/* Trip Details */}
-
- {/* QR Code Section */} -
- -

Scan at gate

-
- - {/* Trip Details */} -
+
@@ -302,7 +310,6 @@ export default function ConfirmationPage() {
)} -
diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index 92b82d2cb..b38234732 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -332,12 +332,135 @@ function DobPickerModal({ ); } +// ─── phone validation ───────────────────────────────────────────────────────── + +type PhoneNat = 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER'; + +const PHONE_PRESETS: Record = { + ETHIOPIAN: { flag: 'πŸ‡ͺπŸ‡Ή', code: '+251', example: '912345678', hint: '+251912345678 or 0912345678' }, + DJIBOUTIAN: { flag: 'πŸ‡©πŸ‡―', code: '+253', example: '77123456', hint: '+25377123456' }, + OTHER: { flag: '🌐', code: '+', example: '14155552671', hint: 'International: +[country code][number]' }, +}; + +function getPhoneNat(nationality: string): PhoneNat { + const n = (nationality || '').toUpperCase(); + if (n === 'ETHIOPIAN') return 'ETHIOPIAN'; + if (n === 'DJIBOUTIAN') return 'DJIBOUTIAN'; + return 'OTHER'; +} + +function validatePhone(phone: string, nationality: string): string | null { + const normalized = (phone || '').replace(/[\s\-().]/g, ''); + if (!normalized) return 'Phone number is required'; + const nat = getPhoneNat(nationality); + if (nat === 'ETHIOPIAN') { + if (/^(\+251\d{9}|09\d{8})$/.test(normalized)) return null; + return 'Invalid Ethiopian phone number (e.g., +251912345678 or 0912345678)'; + } + if (nat === 'DJIBOUTIAN') { + if (/^\+253\d{8}$/.test(normalized)) return null; + return 'Invalid Djiboutian phone number (e.g., +25377123456)'; + } + if (/^\+[1-9]\d{7,14}$/.test(normalized)) return null; + return 'Invalid international phone number (e.g., +14155552671)'; +} + +function stripPhonePrefix(stored: string, nat: PhoneNat): string { + const code = PHONE_PRESETS[nat].code; + if (nat !== 'OTHER' && stored.startsWith(code)) return stored.slice(code.length); + if (nat === 'OTHER' && stored.startsWith('+')) return stored.slice(1); + return stored; +} + +function buildFullNumber(localInput: string, nat: PhoneNat): string { + const stripped = localInput.replace(/[\s\-().]/g, ''); + if (!stripped) return stripped; + if (nat === 'ETHIOPIAN') { + if (stripped.startsWith('+') || stripped.startsWith('0')) return stripped; + return '+251' + stripped; + } + if (nat === 'DJIBOUTIAN') { + if (stripped.startsWith('+')) return stripped; + return '+253' + stripped; + } + return stripped.startsWith('+') ? stripped : '+' + stripped; +} + +function PhoneInput({ + nationality, + storedValue, + onInterimChange, + onNormalized, + error, +}: { + nationality: string; + storedValue: string; + onInterimChange: (full: string) => void; + onNormalized: (full: string) => void; + error?: string; +}) { + const nat = getPhoneNat(nationality); + const preset = PHONE_PRESETS[nat]; + const [localInput, setLocalInput] = useState(() => stripPhonePrefix(storedValue || '', nat)); + const prevStoredRef = useRef(storedValue); + + useEffect(() => { + if (storedValue !== prevStoredRef.current) { + prevStoredRef.current = storedValue; + setLocalInput(stripPhonePrefix(storedValue || '', nat)); + } + }, [storedValue, nat]); + + const handleChange = (e: React.ChangeEvent) => { + const raw = e.target.value; + setLocalInput(raw); + onInterimChange(buildFullNumber(raw, nat)); + }; + + const handleBlur = () => { + const full = buildFullNumber(localInput, nat); + setLocalInput(stripPhonePrefix(full, nat)); + onNormalized(full); + }; + + return ( +
+
+
+ {preset.flag} + {preset.code} +
+ +
+ {error ? ( +

{error}

+ ) : ( +

Format: {preset.hint}

+ )} +
+ ); +} + +// ─── passenger zod schema ────────────────────────────────────────────────────── + const passengerSchema = z.object({ name: z.string().min(2, 'Full name is required (min 2 characters)'), dateOfBirth: z.string().min(1, 'Date of birth is required'), gender: z.string().min(1, 'Gender is required'), nationality: z.string().min(1, 'Nationality is required'), - phone: z.string().min(1, 'Phone number is required'), + phone: z.string(), email: z.string().optional(), nationalId: z.string().optional(), passportNumber: z.string().optional(), @@ -358,6 +481,10 @@ const passengerSchema = z.object({ ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['email'] }); } } + const phoneError = validatePhone(data.phone, data.nationality); + if (phoneError) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: phoneError, path: ['phone'] }); + } const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian'; if (isNonEthiopian) { if (!data.passportNumber || data.passportNumber.trim().length === 0) { @@ -769,14 +896,13 @@ export default function PassengersPage() { {/* Phone */}
- setValue(`passengers.${index}.phone`, v)} + onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })} + error={errors.passengers?.[index]?.phone?.message} /> - {errors.passengers?.[index]?.phone && ( -

{errors.passengers[index]?.phone?.message}

- )}
{/* Email */} @@ -850,14 +976,13 @@ export default function PassengersPage() { {/* Phone */}
- setValue(`passengers.${index}.phone`, v)} + onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })} + error={errors.passengers?.[index]?.phone?.message} /> - {errors.passengers?.[index]?.phone && ( -

{errors.passengers[index]?.phone?.message}

- )}
{/* Email */} diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 6527a88cd..989516839 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -23,23 +23,19 @@ const getIconForMethod = (methodId: string) => { return Smartphone; }; -const NATIONALITY_TO_CURRENCY: Record = { - ETHIOPIAN: 'ETB', - DJIBOUTIAN: 'DJF', -}; export default function PaymentPage() { const router = useRouter(); const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria } = useBookingStore(); const { setPaymentIntent, updateStatus, setCurrency } = usePaymentStore(); const [selectedMethod, setSelectedMethod] = useState(null); + const [selectedMethodCurrency, setSelectedMethodCurrency] = useState(null); const [isProcessing, setIsProcessing] = useState(false); const [paymentError, setPaymentError] = useState(null); const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; - const displayCurrency: 'ETB' | 'DJF' | 'USD' = - NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ?? 'USD'; + const displayCurrency = 'ETB' as const; // Keep payment store in sync so the mutation picks up the right currency. useEffect(() => { @@ -54,7 +50,22 @@ export default function PaymentPage() { }, }); - // Calculate total amount + // Fetch actual booking amount from API when a payment method is selected + const amountCurrency = selectedMethodCurrency || displayCurrency; + + const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({ + queryKey: ['bookingAmount', bookingId, amountCurrency, selectedMethod], + queryFn: async () => { + const url = `/payments/booking-amount?bookingId=${bookingId}¤cy=${amountCurrency}`; + console.log('[BookingAmount] Request:', { url, bookingId, currency: amountCurrency, selectedMethod }); + const response: any = await apiClient.get(url); + console.log('[BookingAmount] Response:', response); + return response; + }, + enabled: !!selectedMethod && !!bookingId, + }); + + // Fallback: estimate from local store while API hasn't responded yet const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce( (sum) => sum + (outboundSchedule.baseFareAdult || 0), 0, @@ -69,8 +80,12 @@ export default function PaymentPage() { (sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0, ); - - const totalAmount = baseFare; + + // API returns amount in major units (e.g. 11602.5 DJF); convert to minor for display consistency + const totalAmount = bookingAmountData != null + ? Math.round(bookingAmountData.amount * 100) + : baseFare; + const confirmedCurrency = bookingAmountData?.currency || amountCurrency; const paymentMutation = useMutation({ mutationFn: async (data: any) => { @@ -248,7 +263,12 @@ export default function PaymentPage() {
Total - {displayCurrency} {(totalAmount / 100).toFixed(2)} + + {loadingAmount && ( + + )} + {confirmedCurrency} {(totalAmount / 100).toFixed(2)} +
@@ -259,15 +279,19 @@ export default function PaymentPage() { )} diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 10eab03e0..2f053d98c 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -144,8 +144,8 @@ export default function ResultsPage() { ? (outboundSchedules.length > 0 && inboundSchedules.length > 0) : outboundSchedules.length > 0; - const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string) => { - setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName } })); + const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => { + setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } })); }; const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => { @@ -161,10 +161,9 @@ export default function ResultsPage() { const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.code); // Use displayAmountMinor (passenger's currency) so stored fare matches what the card showed. const minFare = coachType?.classes.length - ? Math.min(...coachType.classes.map(c => c.displayAmountMinor ?? c.baseFareMinor)) + ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) : 0; - const fareCurrency: string = - coachType?.classes[0]?.displayCurrency ?? schedule.displayCurrency ?? 'ETB'; + const fareCurrency = 'ETB'; const hours = Math.floor((schedule.durationMinutes || 0) / 60); const minutes = (schedule.durationMinutes || 0) % 60; @@ -186,6 +185,7 @@ export default function ResultsPage() { selectedCoachTypeId: selectedCoachType.id, selectedCoachTypeCode: selectedCoachType.code, selectedCoachTypeName: selectedCoachType.name, + seatClassName: (selectedCoachType as any).seatClassName || selectedCoachType.name, }; // For round trip, store outbound and wait for inbound selection @@ -222,17 +222,13 @@ export default function ResultsPage() { // Calculate lowest fare and display currency from coach types / faresByClass. // Prefer displayAmountMinor (passenger's own currency) over baseFareMinor (ETB internal). let lowestFare = null; - let displayCurrency = schedule.displayCurrency || 'ETB'; + const displayCurrency = 'ETB'; if (schedule.coachTypes?.length) { const allClasses = schedule.coachTypes.flatMap(ct => ct.classes); - const allFares = allClasses.map(c => c.displayAmountMinor ?? c.baseFareMinor).filter(f => f > 0); + const allFares = allClasses.map(c => c.baseFareMinor).filter(f => f > 0); lowestFare = allFares.length ? Math.min(...allFares) : null; - const firstWithCurrency = allClasses.find(c => c.displayCurrency); - if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency; } else if (schedule.faresByClass?.length) { - lowestFare = Math.min(...schedule.faresByClass.map(f => f.displayAmountMinor ?? f.baseFareMinor).filter(f => f > 0)); - const firstWithCurrency = schedule.faresByClass.find(f => f.displayCurrency); - if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency; + lowestFare = Math.min(...schedule.faresByClass.map(f => f.baseFareMinor).filter(f => f > 0)); } else if (schedule.combinedMinFareDisplay) { lowestFare = schedule.combinedMinFareDisplay; } @@ -551,14 +547,14 @@ export default function ResultsPage() {
{coachTypes.map((coachType: any, index: number) => { const isSelected = selectedCoachType?.id === coachType.coachTypeId; - const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.displayAmountMinor ?? c.baseFareMinor)) : 0; - const coachCurrency: string = (coachType.classes[0] as any)?.displayCurrency ?? (classModal as any).displayCurrency ?? 'ETB'; + const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0; + const coachCurrency = 'ETB'; const CoachIcon = getCoachIcon(coachType.coachTypeName); return (
- {((cls.displayAmountMinor ?? cls.baseFareMinor) / 100).toFixed(2)} + {(cls.baseFareMinor / 100).toFixed(2)} - {cls.displayCurrency ?? coachCurrency} + {coachCurrency}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 655f0a7c6..67422ad69 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -62,11 +62,7 @@ export default function ReviewPage() { // Prefer the currency already stored on the selected schedule (set from search results). // Fall back to deriving from nationality so the review page is never left with a stale value. - const NATIONALITY_TO_CURRENCY: Record = { ETHIOPIAN: 'ETB', DJIBOUTIAN: 'DJF' }; - const displayCurrency: string = - (isRoundTrip ? outboundSchedule?.displayCurrency : selectedSchedule?.displayCurrency) ?? - NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ?? - 'USD'; + const displayCurrency = 'ETB'; useEffect(() => { if (!seatHold?.expiresAt) return; @@ -201,19 +197,34 @@ export default function ReviewPage() { return; } - // Get seat class ID - let seatClassId = 'default-seat-class-id'; - let returnSeatClassId = 'default-seat-class-id'; + // Get seat class ID by name-matching against the /seat-classes list + let seatClassId = ''; + let returnSeatClassId = ''; try { - const seatClasses: any = await apiClient.get('/seat-classes'); - console.log('Seat classes:', seatClasses); + const seatClasses: any[] = await apiClient.get('/seat-classes'); if (seatClasses && seatClasses.length > 0) { - seatClassId = seatClasses[0].id; - returnSeatClassId = seatClasses[0].id; + const outboundClassName = isRoundTrip + ? (outboundSchedule as any)?.seatClassName + : (selectedSchedule as any)?.seatClassName; + const returnClassName = isRoundTrip + ? (inboundSchedule as any)?.seatClassName + : outboundClassName; + + const findByName = (name: string) => + seatClasses.find((sc: any) => sc.name === name)?.id || seatClasses[0].id; + + seatClassId = outboundClassName ? findByName(outboundClassName) : seatClasses[0].id; + returnSeatClassId = returnClassName ? findByName(returnClassName) : seatClasses[0].id; + console.log('Seat class lookup:', { outboundClassName, returnClassName, seatClassId, returnSeatClassId }); } } catch (err) { console.error('Failed to fetch seat classes:', err); } + + if (!seatClassId) { + alert('Unable to determine seat class. Please go back and re-select your seats.'); + return; + } let bookingData: any; if (isAuthenticated) { diff --git a/apps/edr-passenger-web/portal/src/lib/booking-store.ts b/apps/edr-passenger-web/portal/src/lib/booking-store.ts index 4aaef69a1..93262f5b8 100644 --- a/apps/edr-passenger-web/portal/src/lib/booking-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/booking-store.ts @@ -54,6 +54,10 @@ export interface SelectedSchedule { displayCurrency: string; selectedSeatClass?: string; selectedSeatClassName?: string; + seatClassName?: string; + selectedCoachTypeId?: string; + selectedCoachTypeCode?: string; + selectedCoachTypeName?: string; } export interface SeatHold { diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts index f05db069b..37dfa5ce9 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -1,392 +1,301 @@ import jsPDF from 'jspdf'; import autoTable from 'jspdf-autotable'; -interface VoucherData { +interface ScheduleInfo { + trainNumber: string; + trainName?: string; + origin: { name: string; code: string; city: string }; + destination: { name: string; code: string; city: string }; + departureAt: string; + arrivalAt: string; + seatClass?: string; +} + +interface PassengerVoucherData { bookingRef: string; + ticketNumber: string; + passengerName: string; + dateOfBirth?: string; + nationality?: string; + seatNumber?: string; + outboundSeatNumber?: string; + inboundSeatNumber?: string; status: string; - passengers: Array<{ - fullName: string; - category: string; - seat?: { - number: string; - coach: string; - seatClass: string; - }; - }>; - schedule: { - trainNumber: string; - trainName?: string; - origin: { - name: string; - code: string; - city: string; - }; - destination: { - name: string; - code: string; - city: string; - }; - departureAt: string; - arrivalAt: string; - }; - totalMinor: number; + outboundSchedule: ScheduleInfo; + inboundSchedule?: ScheduleInfo; + isRoundTrip: boolean; + fareMinor: number; currency: string; - bookingType: string; createdAt: string; } -export const generateVoucherPDF = async (booking: VoucherData) => { - const doc = new jsPDF({ - orientation: 'portrait', - unit: 'mm', - format: 'a4', - }); +// ─── shared drawing helpers ─────────────────────────────────────────────────── +const PRIMARY = [20, 113, 76] as const; +const DARK = [51, 51, 51] as const; +const MED = [102, 102, 102] as const; +const LIGHT = [200, 200, 200] as const; + +async function drawHeader(doc: jsPDF, margin: number): Promise { const pageWidth = doc.internal.pageSize.getWidth(); - const pageHeight = doc.internal.pageSize.getHeight(); - const margin = 15; - let yPos = margin; - // Colors - const primaryColor = [20, 113, 76]; // EDR Green - const darkGray = [51, 51, 51]; - const mediumGray = [102, 102, 102]; - const lightGray = [200, 200, 200]; - - // ============ HEADER ============ - // Company branding strip - doc.setFillColor(primaryColor[0], primaryColor[1], primaryColor[2]); + doc.setFillColor(...PRIMARY); doc.rect(0, 0, pageWidth, 30, 'F'); - // Load and add logo try { - const logoImg = await fetch('/edr-logo.png'); + const logoImg = await fetch('/edr-logo.png'); const logoBlob = await logoImg.blob(); const logoDataUrl = await new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.readAsDataURL(logoBlob); }); - - // Create image to get dimensions const img = new Image(); - await new Promise((resolve) => { - img.onload = resolve; - img.src = logoDataUrl; - }); - - // Calculate aspect ratio and dimensions - const logoHeight = 18; - const logoWidth = (img.width / img.height) * logoHeight; - - // Add logo on left side with proper aspect ratio - doc.addImage(logoDataUrl, 'PNG', margin, 6, logoWidth, logoHeight); - - // Company name next to logo + await new Promise((resolve) => { img.onload = resolve; img.src = logoDataUrl; }); + const logoH = 18; + const logoW = (img.width / img.height) * logoH; + doc.addImage(logoDataUrl, 'PNG', margin, 6, logoW, logoH); doc.setTextColor(255, 255, 255); - doc.setFontSize(20); - doc.setFont('helvetica', 'bold'); - doc.text('ETHIO-DJIBOUTI RAILWAY', margin + logoWidth + 5, 14); - - doc.setFontSize(9); - doc.setFont('helvetica', 'normal'); - doc.text('Premium Travel Experience', margin + logoWidth + 5, 20); - } catch (error) { - console.error('Failed to load logo:', error); - // Fallback: just show text centered + doc.setFontSize(18); doc.setFont('helvetica', 'bold'); + doc.text('ETHIO-DJIBOUTI RAILWAY', margin + logoW + 5, 14); + doc.setFontSize(9); doc.setFont('helvetica', 'normal'); + doc.text('Premium Travel Experience', margin + logoW + 5, 20); + } catch { doc.setTextColor(255, 255, 255); - doc.setFontSize(24); - doc.setFont('helvetica', 'bold'); - doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, 12, { align: 'center' }); - - doc.setFontSize(10); - doc.setFont('helvetica', 'normal'); - doc.text('Premium Travel Experience', pageWidth / 2, 18, { align: 'center' }); + doc.setFontSize(22); doc.setFont('helvetica', 'bold'); + doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, 13, { align: 'center' }); + doc.setFontSize(9); doc.setFont('helvetica', 'normal'); + doc.text('Premium Travel Experience', pageWidth / 2, 20, { align: 'center' }); } + return 40; +} - yPos = 40; - - // ============ TITLE & STATUS ============ - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFontSize(20); - doc.setFont('helvetica', 'bold'); - doc.text('BOOKING VOUCHER', pageWidth / 2, yPos, { align: 'center' }); - - yPos += 10; - - // Status badge (simplified) - const statusText = booking.status === 'TICKETED' || booking.status === 'CONFIRMED' ? 'CONFIRMED' : booking.status; - const statusColor = booking.status === 'TICKETED' || booking.status === 'CONFIRMED' ? [34, 197, 94] : [234, 179, 8]; - - doc.setFillColor(statusColor[0], statusColor[1], statusColor[2]); - doc.rect(pageWidth / 2 - 20, yPos - 4, 40, 8, 'F'); +function drawStatusBadge(doc: jsPDF, status: string, y: number, pageWidth: number): number { + const label = (status === 'TICKETED' || status === 'CONFIRMED') ? 'CONFIRMED' : status; + const color = (status === 'TICKETED' || status === 'CONFIRMED') ? [34, 197, 94] : [234, 179, 8]; + doc.setFillColor(color[0], color[1], color[2]); + doc.rect(pageWidth / 2 - 22, y - 4, 44, 8, 'F'); doc.setTextColor(255, 255, 255); - doc.setFontSize(9); - doc.setFont('helvetica', 'bold'); - doc.text(statusText, pageWidth / 2, yPos + 1, { align: 'center' }); + doc.setFontSize(9); doc.setFont('helvetica', 'bold'); + doc.text(label, pageWidth / 2, y + 1, { align: 'center' }); + return y + 12; +} - yPos += 12; - - // ============ QR CODE ============ - // Generate QR code data URL - const canvas = document.createElement('canvas'); - const QRCode = (await import('qrcode')).default; - - const qrSize = 35; // 35mm = 3.5cm - await QRCode.toCanvas(canvas, booking.bookingRef, { - width: 300, - margin: 2, - color: { - dark: '#000000', - light: '#FFFFFF', - }, - }); - - const qrDataUrl = canvas.toDataURL('image/png'); - - // Place QR code at top-right - const qrX = pageWidth - margin - qrSize; - const qrY = yPos; - - doc.addImage(qrDataUrl, 'PNG', qrX, qrY, qrSize, qrSize); - - doc.setFontSize(8); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); - doc.text('SCAN AT TERMINAL', qrX + qrSize / 2, qrY + qrSize + 4, { align: 'center' }); - - // ============ BOOKING REFERENCE ============ +function drawBookingRefBox(doc: jsPDF, bookingRef: string, ticketNumber: string, y: number, margin: number, pageWidth: number): number { doc.setFillColor(245, 245, 245); - doc.rect(margin, yPos, pageWidth - margin * 2 - qrSize - 5, 18, 'F'); - - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFontSize(9); - doc.setFont('helvetica', 'normal'); - doc.text('BOOKING REFERENCE', margin + 5, yPos + 6); - - doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); - doc.setFontSize(18); - doc.setFont('helvetica', 'bold'); - doc.text(booking.bookingRef, margin + 5, yPos + 14); + doc.rect(margin, y, pageWidth - margin * 2, 22, 'F'); - yPos += 25; + doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal'); + doc.text('BOOKING REFERENCE', margin + 5, y + 6); + doc.setTextColor(...PRIMARY); doc.setFontSize(16); doc.setFont('helvetica', 'bold'); + doc.text(bookingRef, margin + 5, y + 14); - // ============ JOURNEY DETAILS ============ - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFontSize(12); - doc.setFont('helvetica', 'bold'); - doc.text('JOURNEY DETAILS', margin, yPos); - - yPos += 8; + const rightX = pageWidth - margin - 5; + doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal'); + doc.text('TICKET NUMBER', rightX, y + 6, { align: 'right' }); + doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold'); + doc.text(ticketNumber, rightX, y + 14, { align: 'right' }); - // Route box - doc.setDrawColor(lightGray[0], lightGray[1], lightGray[2]); - doc.setLineWidth(0.5); - doc.rect(margin, yPos, pageWidth - margin * 2, 40); + return y + 28; +} + +function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, label: string | null, y: number, margin: number, pageWidth: number): number { + doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold'); + doc.text(label ? `JOURNEY DETAILS β€” ${label.toUpperCase()}` : 'JOURNEY DETAILS', margin, y); + y += 7; + + doc.setDrawColor(...LIGHT); doc.setLineWidth(0.5); + doc.rect(margin, y, pageWidth - margin * 2, 40); // Origin - doc.setFontSize(9); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); - doc.text('FROM', margin + 5, yPos + 6); - - doc.setFontSize(16); - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFont('helvetica', 'bold'); - doc.text(booking.schedule.origin.code, margin + 5, yPos + 14); - - doc.setFontSize(10); - doc.setFont('helvetica', 'normal'); - doc.text(booking.schedule.origin.name, margin + 5, yPos + 20); - - doc.setFontSize(8); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.text(booking.schedule.origin.city, margin + 5, yPos + 25); + doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); + doc.text('FROM', margin + 5, y + 6); + doc.setFontSize(15); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold'); + doc.text(schedule.origin.code, margin + 5, y + 14); + doc.setFontSize(9); doc.setFont('helvetica', 'normal'); + doc.text(schedule.origin.name, margin + 5, y + 20); + doc.setFontSize(8); doc.setTextColor(...MED); + doc.text(schedule.origin.city, margin + 5, y + 25); - // Departure time - const departureDate = new Date(booking.schedule.departureAt); - doc.setFontSize(14); - doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); - doc.setFont('helvetica', 'bold'); - doc.text(departureDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), margin + 5, yPos + 33); - - doc.setFontSize(8); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); - doc.text(departureDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, yPos + 38); + const dep = new Date(schedule.departureAt); + doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold'); + doc.text(dep.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), margin + 5, y + 33); + doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); + doc.text(dep.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, y + 38); // Arrow - doc.setDrawColor(primaryColor[0], primaryColor[1], primaryColor[2]); - doc.setLineWidth(1); - const arrowStartX = pageWidth / 2 - 10; - const arrowEndX = pageWidth / 2 + 10; - const arrowY = yPos + 20; - - // Draw arrow line - doc.line(arrowStartX, arrowY, arrowEndX, arrowY); - - // Draw arrow head manually with lines - doc.line(arrowEndX, arrowY, arrowEndX - 3, arrowY - 2); - doc.line(arrowEndX, arrowY, arrowEndX - 3, arrowY + 2); + doc.setDrawColor(...PRIMARY); doc.setLineWidth(0.8); + const ax = pageWidth / 2, ay = y + 20; + doc.line(ax - 10, ay, ax + 10, ay); + doc.line(ax + 10, ay, ax + 7, ay - 2); + doc.line(ax + 10, ay, ax + 7, ay + 2); // Destination - const destX = pageWidth - margin - 50; - doc.setFontSize(9); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); - doc.text('TO', destX, yPos + 6); - - doc.setFontSize(16); - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFont('helvetica', 'bold'); - doc.text(booking.schedule.destination.code, destX, yPos + 14); - - doc.setFontSize(10); - doc.setFont('helvetica', 'normal'); - doc.text(booking.schedule.destination.name, destX, yPos + 20); - - doc.setFontSize(8); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.text(booking.schedule.destination.city, destX, yPos + 25); + const dx = pageWidth - margin - 50; + doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); + doc.text('TO', dx, y + 6); + doc.setFontSize(15); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold'); + doc.text(schedule.destination.code, dx, y + 14); + doc.setFontSize(9); doc.setFont('helvetica', 'normal'); + doc.text(schedule.destination.name, dx, y + 20); + doc.setFontSize(8); doc.setTextColor(...MED); + doc.text(schedule.destination.city, dx, y + 25); - // Arrival time - const arrivalDate = new Date(booking.schedule.arrivalAt); - doc.setFontSize(14); - doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); - doc.setFont('helvetica', 'bold'); - doc.text(arrivalDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), destX, yPos + 33); - - doc.setFontSize(8); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); - doc.text(arrivalDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), destX, yPos + 38); + const arr = new Date(schedule.arrivalAt); + doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold'); + doc.text(arr.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), dx, y + 33); + doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); + doc.text(arr.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), dx, y + 38); - yPos += 48; + y += 47; - // Train info - doc.setFillColor(250, 250, 250); - doc.rect(margin, yPos, pageWidth - margin * 2, 12, 'F'); - - doc.setFontSize(9); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); - doc.text('TRAIN', margin + 5, yPos + 5); - - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFont('helvetica', 'bold'); - doc.text(booking.schedule.trainNumber, margin + 5, yPos + 9); - - if (booking.schedule.trainName) { - doc.setFont('helvetica', 'normal'); - doc.text(` - ${booking.schedule.trainName}`, margin + 25, yPos + 9); + // Train info bar + doc.setFillColor(248, 248, 248); + doc.rect(margin, y, pageWidth - margin * 2, 12, 'F'); + doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); + doc.text('TRAIN', margin + 5, y + 5); + doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold'); + doc.text(schedule.trainNumber + (schedule.trainName ? ` β€” ${schedule.trainName}` : ''), margin + 20, y + 9); + if (schedule.seatClass) { + doc.setFont('helvetica', 'normal'); doc.setTextColor(...MED); + doc.text(schedule.seatClass, pageWidth - margin - 5, y + 9, { align: 'right' }); } - yPos += 18; + return y + 18; +} - // ============ PASSENGERS ============ - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFontSize(12); - doc.setFont('helvetica', 'bold'); - doc.text('PASSENGERS', margin, yPos); - - yPos += 8; +function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number, margin: number): number { + doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold'); + doc.text('PASSENGER DETAILS', margin, y); + y += 7; - // Passenger table - const passengerData = booking.passengers.map((p, idx) => [ - (idx + 1).toString(), - p.fullName, - p.category, - p.seat?.number || '-', - p.seat?.coach || '-', - p.seat?.seatClass || '-', - ]); + const rows: [string, string][] = [ + ['Full Name', data.passengerName || 'β€”'], + ['Date of Birth', data.dateOfBirth ? new Date(data.dateOfBirth).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : 'β€”'], + ['Nationality', data.nationality || 'β€”'], + ]; + + if (data.isRoundTrip) { + rows.push(['Outbound Seat', data.outboundSeatNumber || 'β€”']); + rows.push(['Return Seat', data.inboundSeatNumber || 'β€”']); + } else { + rows.push(['Seat', data.seatNumber || 'β€”']); + } autoTable(doc, { - startY: yPos, - head: [['#', 'Passenger Name', 'Type', 'Seat', 'Coach', 'Class']], - body: passengerData, - theme: 'striped', - headStyles: { - fillColor: [primaryColor[0], primaryColor[1], primaryColor[2]], - textColor: [255, 255, 255], - fontSize: 9, - fontStyle: 'bold', - }, - bodyStyles: { - fontSize: 9, - textColor: [darkGray[0], darkGray[1], darkGray[2]], - }, - alternateRowStyles: { - fillColor: [250, 250, 250], + startY: y, + body: rows, + theme: 'plain', + styles: { fontSize: 9, cellPadding: 3 }, + columnStyles: { + 0: { fontStyle: 'bold', textColor: [MED[0], MED[1], MED[2]], cellWidth: 45 }, + 1: { textColor: [DARK[0], DARK[1], DARK[2]] }, }, + alternateRowStyles: { fillColor: [248, 248, 248] }, margin: { left: margin, right: margin }, }); - yPos = (doc as any).lastAutoTable.finalY + 10; + return (doc as any).lastAutoTable.finalY + 8; +} - // ============ PAYMENT SUMMARY ============ - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFontSize(12); - doc.setFont('helvetica', 'bold'); - doc.text('PAYMENT SUMMARY', margin, yPos); - - yPos += 8; +function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: number, margin: number, pageWidth: number): number { + doc.setFillColor(248, 248, 248); + doc.rect(margin, y, pageWidth - margin * 2, 20, 'F'); + doc.setFontSize(9); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); + doc.text('Fare', margin + 5, y + 7); + doc.setFontSize(15); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold'); + doc.text(`${currency} ${(fareMinor / 100).toFixed(2)}`, pageWidth - margin - 5, y + 7, { align: 'right' }); + doc.setFontSize(9); doc.setTextColor(34, 197, 94); doc.setFont('helvetica', 'bold'); + doc.text('βœ“ PAID', margin + 5, y + 15); + return y + 26; +} - doc.setFillColor(250, 250, 250); - doc.rect(margin, yPos, pageWidth - margin * 2, 20, 'F'); - - doc.setFontSize(10); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); - doc.text('Total Amount', margin + 5, yPos + 7); - - doc.setFontSize(16); - doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); - doc.setFont('helvetica', 'bold'); - doc.text(`${booking.currency} ${(booking.totalMinor / 100).toFixed(2)}`, pageWidth - margin - 5, yPos + 7, { align: 'right' }); - - doc.setFontSize(9); - doc.setTextColor(34, 197, 94); - doc.setFont('helvetica', 'bold'); - doc.text('βœ“ PAID', margin + 5, yPos + 15); - - yPos += 28; - - // ============ INSTRUCTIONS ============ +function drawInstructions(doc: jsPDF, y: number, margin: number, pageWidth: number): number { doc.setFillColor(252, 211, 77); - doc.rect(margin, yPos, pageWidth - margin * 2, 18, 'F'); - - doc.setFontSize(9); - doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); - doc.setFont('helvetica', 'bold'); - doc.text('⚠ IMPORTANT INSTRUCTIONS', margin + 5, yPos + 6); - - doc.setFont('helvetica', 'normal'); - doc.setFontSize(8); - doc.text('β€’ Present this voucher at the terminal for boarding', margin + 5, yPos + 11); - doc.text('β€’ Arrive at least 30 minutes before departure', margin + 5, yPos + 15); + doc.rect(margin, y, pageWidth - margin * 2, 18, 'F'); + doc.setFontSize(9); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold'); + doc.text('⚠ IMPORTANT INSTRUCTIONS', margin + 5, y + 6); + doc.setFont('helvetica', 'normal'); doc.setFontSize(8); + doc.text('β€’ Present this voucher at the terminal for boarding', margin + 5, y + 11); + doc.text('β€’ Arrive at least 30 minutes before departure', margin + 5, y + 15); + return y + 24; +} - // ============ FOOTER ============ - const footerY = pageHeight - 25; - - doc.setDrawColor(lightGray[0], lightGray[1], lightGray[2]); - doc.line(margin, footerY, pageWidth - margin, footerY); - - doc.setFontSize(8); - doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); - doc.setFont('helvetica', 'normal'); +function drawFooter(doc: jsPDF, createdAt: string): void { + const pageWidth = doc.internal.pageSize.getWidth(); + const pageHeight = doc.internal.pageSize.getHeight(); + const footerY = pageHeight - 22; + + doc.setDrawColor(...LIGHT); + doc.line(15, footerY, pageWidth - 15, footerY); + doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal'); doc.text('Support: support@edr.com | +251-11-XXX-XXXX', pageWidth / 2, footerY + 5, { align: 'center' }); doc.text('Terms & Conditions apply. Visit www.edr.com for details.', pageWidth / 2, footerY + 9, { align: 'center' }); - doc.setFontSize(7); - doc.text(`Generated: ${new Date().toLocaleString('en-US')}`, pageWidth / 2, footerY + 13, { align: 'center' }); + doc.text(`Generated: ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 13, { align: 'center' }); +} - // Watermark (removed rotation as it may cause issues) - doc.setTextColor(240, 240, 240); - doc.setFontSize(50); - doc.setFont('helvetica', 'bold'); - doc.text('EDR', pageWidth / 2, pageHeight / 2, { align: 'center' }); +// ─── public API ────────────────────────────────────────────────────────────── - // Save PDF - doc.save(`EDR-Voucher-${booking.bookingRef}.pdf`); +/** Generates and downloads one PDF voucher for a single passenger. */ +export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise => { + const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); + const pageW = doc.internal.pageSize.getWidth(); + const margin = 15; + + let y = await drawHeader(doc, margin); + + // Title + doc.setTextColor(...DARK); doc.setFontSize(18); doc.setFont('helvetica', 'bold'); + doc.text('PASSENGER VOUCHER', pageW / 2, y, { align: 'center' }); + y += 10; + + y = drawStatusBadge(doc, data.status, y, pageW); + y = drawBookingRefBox(doc, data.bookingRef, data.ticketNumber, y, margin, pageW); + y = drawJourneyLeg(doc, data.outboundSchedule, data.isRoundTrip ? 'Outbound' : null, y, margin, pageW); + + if (data.isRoundTrip && data.inboundSchedule) { + y = drawJourneyLeg(doc, data.inboundSchedule, 'Return', y, margin, pageW); + } + + y = drawPassengerDetails(doc, data, y, margin); + y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW); + drawInstructions(doc, y, margin, pageW); + drawFooter(doc, data.createdAt); + + const safeName = (data.passengerName || 'Passenger').replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_-]/g, ''); + doc.save(`Voucher_${safeName}.pdf`); +}; + +// ─── legacy combined voucher (kept for backward compat) ────────────────────── + +interface VoucherData { + bookingRef: string; + status: string; + passengers: Array<{ fullName: string; category: string; seat?: { number: string; coach: string; seatClass: string } }>; + schedule: { trainNumber: string; trainName?: string; origin: { name: string; code: string; city: string }; destination: { name: string; code: string; city: string }; departureAt: string; arrivalAt: string }; + totalMinor: number; + currency: string; + bookingType: string; + createdAt: string; +} + +export const generateVoucherPDF = async (booking: VoucherData): Promise => { + for (let i = 0; i < booking.passengers.length; i++) { + const p = booking.passengers[i]; + await generatePassengerVoucherPDF({ + bookingRef: booking.bookingRef, + ticketNumber: `TKT-${booking.bookingRef}-${(i + 1).toString().padStart(2, '0')}`, + passengerName: p.fullName, + seatNumber: p.seat?.number, + status: booking.status, + outboundSchedule: { ...booking.schedule, seatClass: p.seat?.seatClass }, + isRoundTrip: false, + fareMinor: Math.round(booking.totalMinor / booking.passengers.length), + currency: booking.currency, + createdAt: booking.createdAt, + }); + // small delay so browsers don't block multiple sequential downloads + if (i < booking.passengers.length - 1) await new Promise(r => setTimeout(r, 400)); + } }; From 6fa315db0f4bad01da7eca90b87ad3f02ae26cf0 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 14:45:22 +0000 Subject: [PATCH 03/42] fix --- ...0002-CreateLastMileContainerAllocations.ts | 78 +++++++++ ...00000-CreateBookingContainerAllocations.ts | 80 +++++++++ ...000-CreateFirstMileContainerAllocations.ts | 74 ++++++++ .../bookings/booking-allocation.controller.ts | 20 +++ .../src/modules/bookings/bookings.module.ts | 5 +- .../src/modules/bookings/bookings.service.ts | 32 ++++ .../bookings/dto/allocate-containers.dto.ts | 8 + .../booking-container-allocation.entity.ts | 32 ++++ .../bookings/entities/booking.entity.ts | 4 + .../first-mile/dto/allocate-containers.dto.ts | 8 + .../first-mile-container-allocation.entity.ts | 36 ++++ .../first-mile/entities/first-mile.entity.ts | 10 +- .../first-mile/first-mile.controller.ts | 11 ++ .../modules/first-mile/first-mile.module.ts | 3 +- .../modules/first-mile/first-mile.service.ts | 35 ++++ .../last-mile/dto/allocate-containers.dto.ts | 8 + .../last-mile-container-allocation.entity.ts | 32 ++++ .../last-mile/entities/last-mile.entity.ts | 6 +- .../modules/last-mile/last-mile.controller.ts | 11 ++ .../src/modules/last-mile/last-mile.module.ts | 3 +- .../modules/last-mile/last-mile.service.ts | 35 +++- .../components/ContainerAllocationTable.tsx | 164 ++++++++++++++++++ .../FirstMileContainerAllocationTable.tsx | 164 ++++++++++++++++++ .../LastMileContainerAllocationTable.tsx | 164 ++++++++++++++++++ .../backoffice/src/constants/apiConfig.ts | 4 +- .../src/pages/bookings/BookingDetailPage.tsx | 29 ++++ .../src/pages/operations/FirstMilePage.tsx | 79 +++++++++ .../src/pages/operations/LastMilePage.tsx | 100 +++++++++++ 28 files changed, 1227 insertions(+), 8 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1810000000002-CreateLastMileContainerAllocations.ts create mode 100644 apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts create mode 100644 apps/edr-freight-api/src/migrations/1830000000000-CreateFirstMileContainerAllocations.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/allocate-containers.dto.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/entities/booking-container-allocation.entity.ts create mode 100644 apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts create mode 100644 apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx diff --git a/apps/edr-freight-api/src/migrations/1810000000002-CreateLastMileContainerAllocations.ts b/apps/edr-freight-api/src/migrations/1810000000002-CreateLastMileContainerAllocations.ts new file mode 100644 index 000000000..b2026b753 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1810000000002-CreateLastMileContainerAllocations.ts @@ -0,0 +1,78 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Create the freight.last_mile_container_allocations table β€” container allocation + * records linking last-mile deliveries with containers and vehicles. + */ +export class CreateLastMileContainerAllocations1810000000002 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.last_mile_container_allocations'); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: 'freight.last_mile_container_allocations', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'last_mile_id', type: 'uuid', isNullable: false }, + { name: 'container_id', type: 'uuid', isNullable: false }, + { name: 'vehicle_id', type: 'uuid', isNullable: true }, + { + name: 'container_type', + type: 'text', + isNullable: false, + }, + { + name: 'quantity', + type: 'integer', + default: 1, + isNullable: false, + }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'freight.last_mile_container_allocations', + new TableForeignKey({ + columnNames: ['last_mile_id'], + referencedTableName: 'freight.last_mile', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'freight.last_mile_container_allocations', + new TableForeignKey({ + columnNames: ['vehicle_id'], + referencedTableName: 'freight.vehicles', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.query( + `CREATE INDEX "IDX_last_mile_container_allocations_last_mile_id" ON "freight"."last_mile_container_allocations" ("last_mile_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_last_mile_container_allocations_vehicle_id" ON "freight"."last_mile_container_allocations" ("vehicle_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.last_mile_container_allocations'); + if (exists) { + await queryRunner.dropTable('freight.last_mile_container_allocations'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts b/apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts new file mode 100644 index 000000000..70b0832f5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts @@ -0,0 +1,80 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Create the freight.booking_container_allocations table β€” container-to-vehicle + * allocation mapping for flexible routing of containers across available vehicles. + */ +export class CreateBookingContainerAllocations1825000000000 implements MigrationInterface { + name = 'CreateBookingContainerAllocations1825000000000'; + + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.booking_container_allocations'); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: 'freight.booking_container_allocations', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'booking_id', type: 'uuid', isNullable: false }, + { name: 'container_id', type: 'uuid', isNullable: false }, + { name: 'vehicle_id', type: 'uuid', isNullable: true }, + { + name: 'container_type', + type: 'text', + isNullable: false, + }, + { + name: 'quantity', + type: 'integer', + default: 1, + isNullable: false, + }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'freight.booking_container_allocations', + new TableForeignKey({ + columnNames: ['booking_id'], + referencedTableName: 'freight.bookings', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'freight.booking_container_allocations', + new TableForeignKey({ + columnNames: ['vehicle_id'], + referencedTableName: 'freight.vehicles', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.query( + `CREATE INDEX "IDX_booking_container_allocations_booking_id" ON "freight"."booking_container_allocations" ("booking_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_booking_container_allocations_vehicle_id" ON "freight"."booking_container_allocations" ("vehicle_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.booking_container_allocations'); + if (exists) { + await queryRunner.dropTable('freight.booking_container_allocations'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1830000000000-CreateFirstMileContainerAllocations.ts b/apps/edr-freight-api/src/migrations/1830000000000-CreateFirstMileContainerAllocations.ts new file mode 100644 index 000000000..b91e88633 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000000-CreateFirstMileContainerAllocations.ts @@ -0,0 +1,74 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Create freight.first_mile_container_allocations table β€” tracks + * container allocations per first-mile shipment with optional vehicle assignment. + */ +export class CreateFirstMileContainerAllocations1830000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.first_mile_container_allocations'); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: 'freight.first_mile_container_allocations', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'first_mile_id', type: 'uuid', isNullable: false }, + { name: 'container_id', type: 'uuid', isNullable: false }, + { name: 'vehicle_id', type: 'uuid', isNullable: true }, + { name: 'container_type', type: 'text', isNullable: false }, + { + name: 'quantity', + type: 'int', + default: 1, + isNullable: false, + }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'freight.first_mile_container_allocations', + new TableForeignKey({ + columnNames: ['first_mile_id'], + referencedTableName: 'freight.first_mile', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'freight.first_mile_container_allocations', + new TableForeignKey({ + columnNames: ['vehicle_id'], + referencedTableName: 'freight.vehicles', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.query( + `CREATE INDEX "IDX_first_mile_container_allocations_first_mile_id" ON "freight"."first_mile_container_allocations" ("first_mile_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_first_mile_container_allocations_vehicle_id" ON "freight"."first_mile_container_allocations" ("vehicle_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.first_mile_container_allocations'); + if (exists) { + await queryRunner.dropTable('freight.first_mile_container_allocations'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts b/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts new file mode 100644 index 000000000..cfb9887c3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts @@ -0,0 +1,20 @@ +import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingsService } from './bookings.service'; +import { AllocateContainersDto } from './dto/allocate-containers.dto'; + +@ApiTags('bookings') +@Controller('bookings') +@ApiBearerAuth() +export class BookingAllocationController { + constructor(private readonly bookingsService: BookingsService) {} + + @Post(':bookingId/allocate-containers') + @ApiOperation({ summary: 'Allocate containers to vehicles' }) + async allocateContainers( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: AllocateContainersDto, + ) { + return this.bookingsService.allocateContainers(bookingId, dto.allocations); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index ded7d0239..b959677bd 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -16,6 +16,7 @@ import { BookingPricingService } from './booking-pricing.service'; import { BookingReferenceDataService } from './booking-reference-data.service'; import { BookingTransitionService } from './booking-transition.service'; import { BookingsController } from './bookings.controller'; +import { BookingAllocationController } from './booking-allocation.controller'; import { PayController } from './pay.controller'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; @@ -28,6 +29,7 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingContractSignature } from './entities/booking-contract-signature.entity'; import { BookingReviewNote } from './entities/booking-review-note.entity'; import { Booking } from './entities/booking.entity'; +import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder'; import { ContractRendererService } from '../../contracts/contract-renderer.service'; @@ -47,6 +49,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu BookingRateSnapshot, BookingReviewNote, BookingContractSignature, + BookingContainerAllocation, ]), PaymentModule, forwardRef(() => TrainSchedulingModule), @@ -63,7 +66,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu config.get('app.cbeExchange') ?? {}, }), ], - controllers: [BookingsController, PayController], + controllers: [BookingsController, BookingAllocationController, PayController], providers: [ BookingsService, BookingsRepository, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 3d3bab20b..ea7eea31f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -43,6 +43,7 @@ import { FreightType, } from './entities/booking.entity'; import { Booking } from './entities/booking.entity'; +import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; import { FileRecord } from '../files/entities/file.entity'; /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ @@ -1305,4 +1306,35 @@ export class BookingsService { createdAt: b.createdAt, })); } + + async allocateContainers( + bookingId: string, + allocations: Array<{ containerId: string; vehicleId: string }>, + ) { + const booking = await this.findById(bookingId); + if (!booking) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + + await this.dataSource.transaction(async (manager) => { + for (const allocation of allocations) { + await manager.delete(BookingContainerAllocation, { + bookingId, + containerId: allocation.containerId, + }); + await manager.insert(BookingContainerAllocation, { + bookingId, + containerId: allocation.containerId, + vehicleId: allocation.vehicleId, + containerType: 'CONTAINER', + quantity: 1, + }); + } + }); + + return { + success: true, + allocated: allocations.length, + }; + } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/allocate-containers.dto.ts new file mode 100644 index 000000000..8b9b7da39 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/allocate-containers.dto.ts @@ -0,0 +1,8 @@ +export class ContainerAllocationDto { + containerId!: string; + vehicleId!: string; +} + +export class AllocateContainersDto { + allocations!: ContainerAllocationDto[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-allocation.entity.ts new file mode 100644 index 000000000..8cb186e09 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-allocation.entity.ts @@ -0,0 +1,32 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Booking } from './booking.entity'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ schema: 'freight', name: 'booking_container_allocations' }) +@Index(['bookingId']) +@Index(['vehicleId']) +export class BookingContainerAllocation extends BaseEntity { + @ManyToOne(() => Booking, (b) => b.containerAllocations) + @JoinColumn({ name: 'booking_id' }) + booking!: Booking; + + @Column('uuid', { name: 'booking_id' }) + bookingId!: string; + + @Column('uuid', { name: 'container_id' }) + containerId!: string; + + @ManyToOne(() => Vehicle) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column('uuid', { name: 'vehicle_id', nullable: true }) + vehicleId?: string; + + @Column('text') + containerType!: string; // CONTAINER, BULK_DRY, etc + + @Column('integer', { default: 1 }) + quantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index ac7fe636a..3c93b26f2 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -13,6 +13,7 @@ import { FileRecord } from '../../files/entities/file.entity'; import { BookingApprovalStep } from './booking-approval-step.entity'; import { BookingCargoModifier } from './booking-cargo-modifier.entity'; import { BookingContainer } from './booking-container.entity'; +import { BookingContainerAllocation } from './booking-container-allocation.entity'; import { BookingRateSnapshot } from './booking-rate-snapshot.entity'; import { BookingReviewNote } from './booking-review-note.entity'; @@ -441,6 +442,9 @@ export class Booking extends BaseEntity { @OneToMany(() => BookingContainer, (bc) => bc.booking) bookingContainers?: BookingContainer[]; + @OneToMany(() => BookingContainerAllocation, (ca) => ca.booking) + containerAllocations?: BookingContainerAllocation[]; + @OneToMany(() => BookingCargoModifier, (m) => m.booking) cargoModifiers?: BookingCargoModifier[]; diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts new file mode 100644 index 000000000..b750f1147 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts @@ -0,0 +1,8 @@ +export class FirstMileContainerAllocationDto { + containerId!: string; + vehicleId!: string; +} + +export class AllocateFirstMileContainersDto { + allocations!: FirstMileContainerAllocationDto[]; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts new file mode 100644 index 000000000..e9407a57f --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts @@ -0,0 +1,36 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +import { FirstMile } from './first-mile.entity'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ name: 'first_mile_container_allocations', schema: 'freight' }) +@Index(['firstMileId']) +@Index(['vehicleId']) +export class FirstMileContainerAllocation extends BaseEntity { + @Column({ name: 'first_mile_id', type: 'uuid' }) + firstMileId!: string; + + @ManyToOne(() => FirstMile, (firstMile) => firstMile.containerAllocations, { + nullable: false, + eager: false, + }) + @JoinColumn({ name: 'first_mile_id' }) + firstMile?: FirstMile; + + @Column({ name: 'container_id', type: 'uuid' }) + containerId!: string; + + @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) + vehicleId?: string | null; + + @ManyToOne(() => Vehicle, { nullable: true, eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle | null; + + @Column({ name: 'container_type', type: 'text' }) + containerType!: string; + + @Column({ name: 'quantity', type: 'int', default: 1 }) + quantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts index e810d23cc..b2eb3801f 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts @@ -1,8 +1,9 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Booking } from '../../bookings/entities/booking.entity'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { FirstMileContainerAllocation } from './first-mile-container-allocation.entity'; export const FIRST_MILE_STATUSES = [ 'PAYMENT_PENDING', @@ -49,4 +50,11 @@ export class FirstMile extends BaseEntity { @ManyToOne(() => Vehicle, { nullable: true, eager: false }) @JoinColumn({ name: 'vehicle_id' }) vehicle?: Vehicle | null; + + @OneToMany( + () => FirstMileContainerAllocation, + (containerAllocation) => containerAllocation.firstMile, + { eager: false }, + ) + containerAllocations!: FirstMileContainerAllocation[]; } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 78c3d43ff..3ecc2db20 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -17,6 +17,7 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; +import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto'; import { FirstMileStatus } from './entities/first-mile.entity'; import { FirstMileService } from './first-mile.service'; @@ -83,4 +84,14 @@ export class FirstMileController { remove(@Param('id', ParseUUIDPipe) id: string) { return this.firstMileService.remove(id); } + + @Post(':firstMileId/allocate-containers') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Allocate containers to vehicles for a first-mile leg' }) + allocateContainers( + @Param('firstMileId', ParseUUIDPipe) firstMileId: string, + @Body() dto: AllocateFirstMileContainersDto, + ) { + return this.firstMileService.allocateContainers(firstMileId, dto.allocations); + } } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts index bf6815af7..0cdf3c92c 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts @@ -6,13 +6,14 @@ import { DriversModule } from '../drivers/drivers.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { VehiclesModule } from '../vehicles/vehicles.module'; import { FirstMile } from './entities/first-mile.entity'; +import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; import { FirstMileController } from './first-mile.controller'; import { FirstMileRepository } from './first-mile.repository'; import { FirstMileService } from './first-mile.service'; @Module({ imports: [ - TypeOrmModule.forFeature([FirstMile]), + TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]), forwardRef(() => BookingsModule), VehiclesModule, DriversModule, diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 45c2658db..08cd9ab10 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,5 +1,7 @@ import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; @@ -8,6 +10,7 @@ import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; import { FirstMile, FirstMileStatus } from './entities/first-mile.entity'; +import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; import { FirstMileRepository } from './first-mile.repository'; type FirstMileListFilter = { @@ -32,6 +35,7 @@ export class FirstMileService { private readonly logger = new Logger(FirstMileService.name); constructor( + @InjectDataSource() private readonly dataSource: DataSource, private readonly firstMileRepository: FirstMileRepository, private readonly bookingsRepository: BookingsRepository, private readonly vehiclesService: VehiclesService, @@ -273,4 +277,35 @@ export class FirstMileService { await this.findById(id); await this.firstMileRepository.softDelete(id); } + + async allocateContainers( + firstMileId: string, + allocations: Array<{ containerId: string; vehicleId: string }>, + ) { + const firstMile = await this.findById(firstMileId); + if (!firstMile) { + throw new NotFoundException(`First-mile record ${firstMileId} not found`); + } + + await this.dataSource.transaction(async (manager) => { + for (const allocation of allocations) { + await manager.delete(FirstMileContainerAllocation, { + firstMileId, + containerId: allocation.containerId, + }); + await manager.insert(FirstMileContainerAllocation, { + firstMileId, + containerId: allocation.containerId, + vehicleId: allocation.vehicleId, + containerType: 'CONTAINER', + quantity: 1, + }); + } + }); + + return { + success: true, + allocated: allocations.length, + }; + } } diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts new file mode 100644 index 000000000..de86ac883 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts @@ -0,0 +1,8 @@ +export class LastMileContainerAllocationDto { + containerId!: string; + vehicleId!: string; +} + +export class AllocateLastMileContainersDto { + allocations!: LastMileContainerAllocationDto[]; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts new file mode 100644 index 000000000..8a61c73bf --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts @@ -0,0 +1,32 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { LastMile } from './last-mile.entity'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ schema: 'freight', name: 'last_mile_container_allocations' }) +@Index(['lastMileId']) +@Index(['vehicleId']) +export class LastMileContainerAllocation extends BaseEntity { + @ManyToOne(() => LastMile, (lm) => lm.containerAllocations) + @JoinColumn({ name: 'last_mile_id' }) + lastMile!: LastMile; + + @Column('uuid', { name: 'last_mile_id' }) + lastMileId!: string; + + @Column('uuid', { name: 'container_id' }) + containerId!: string; + + @ManyToOne(() => Vehicle) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle | null; + + @Column('uuid', { name: 'vehicle_id', nullable: true }) + vehicleId?: string | null; + + @Column('text') + containerType!: string; + + @Column('integer', { default: 1 }) + quantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index ad4b789f4..61aad0d72 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -1,8 +1,9 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Booking } from '../../bookings/entities/booking.entity'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { LastMileContainerAllocation } from './last-mile-container-allocation.entity'; export const LAST_MILE_STATUSES = [ 'PAYMENT_PENDING', @@ -49,4 +50,7 @@ export class LastMile extends BaseEntity { @ManyToOne(() => Vehicle, { nullable: true, eager: false }) @JoinColumn({ name: 'vehicle_id' }) vehicle?: Vehicle | null; + + @OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile) + containerAllocations?: LastMileContainerAllocation[]; } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index e8abf52c6..935aa5ac7 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -17,6 +17,7 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; +import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto'; import { LastMileStatus } from './entities/last-mile.entity'; import { LastMileService } from './last-mile.service'; @@ -83,4 +84,14 @@ export class LastMileController { remove(@Param('id', ParseUUIDPipe) id: string) { return this.lastMileService.remove(id); } + + @Post(':id/allocate-containers') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Allocate containers to vehicles' }) + async allocateContainers( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AllocateLastMileContainersDto, + ) { + return this.lastMileService.allocateContainers(id, dto.allocations); + } } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts index e4b99a18c..e6ed6634d 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts @@ -6,13 +6,14 @@ import { DriversModule } from '../drivers/drivers.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { VehiclesModule } from '../vehicles/vehicles.module'; import { LastMile } from './entities/last-mile.entity'; +import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileController } from './last-mile.controller'; import { LastMileRepository } from './last-mile.repository'; import { LastMileService } from './last-mile.service'; @Module({ imports: [ - TypeOrmModule.forFeature([LastMile]), + TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation]), forwardRef(() => BookingsModule), VehiclesModule, DriversModule, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 77a8a2fea..5faad49b9 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere } from 'typeorm'; +import { DataSource, FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; @@ -8,6 +8,7 @@ import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; +import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileRepository } from './last-mile.repository'; type LastMileListFilter = { @@ -37,6 +38,7 @@ export class LastMileService { private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly smsClient: SmsClientService, + private readonly dataSource: DataSource, ) {} async acceptBooking(bookingReference: string): Promise { @@ -206,4 +208,35 @@ export class LastMileService { await this.findById(id); await this.lastMileRepository.softDelete(id); } + + async allocateContainers( + lastMileId: string, + allocations: Array<{ containerId: string; vehicleId: string }>, + ) { + const lastMile = await this.findById(lastMileId); + if (!lastMile) { + throw new NotFoundException(`Last-mile record ${lastMileId} not found`); + } + + await this.dataSource.transaction(async (manager) => { + for (const allocation of allocations) { + await manager.delete(LastMileContainerAllocation, { + lastMileId, + containerId: allocation.containerId, + }); + await manager.insert(LastMileContainerAllocation, { + lastMileId, + containerId: allocation.containerId, + vehicleId: allocation.vehicleId, + containerType: 'CONTAINER', + quantity: 1, + }); + } + }); + + return { + success: true, + allocated: allocations.length, + }; + } } diff --git a/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx new file mode 100644 index 000000000..950cfd476 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx @@ -0,0 +1,164 @@ +import { useState, useMemo } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + Box, + Button, + Group, + Loader, + Select, + Stack, + Table, + Text, + Alert, +} from "@mantine/core"; +import { AlertCircle } from "lucide-react"; +import toast from "react-hot-toast"; + +import { vehiclesService } from "@/services/vehicles.service"; + +export interface ContainerAllocationRow { + id: string; + type: string; + qty: number; +} + +export interface ContainerAllocationTableProps { + bookingId: string; + containers: ContainerAllocationRow[]; + onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise; +} + +/** + * Manual container-to-vehicle allocation table for freight bookings. + * Displays containers with type/qty, vehicle dropdown per row, and save action. + */ +export function ContainerAllocationTable({ + bookingId, + containers, + onSave, +}: ContainerAllocationTableProps) { + const [allocations, setAllocations] = useState>( + () => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + + const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ + queryKey: ["vehicles", "active"], + queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), + }); + + const vehicleOptions = useMemo( + () => + vehicles.map((v) => ({ + value: v.id, + label: `${v.plateNumber} (${v.vehicleType})`, + description: `${v.model} Β· ${v.manufacturer}`, + })), + [vehicles], + ); + + const saveAllocation = useMutation({ + mutationFn: async () => { + const mappings = containers + .filter((c) => allocations[c.id]) + .map((c) => ({ + containerId: c.id, + vehicleId: allocations[c.id]!, + })); + + if (mappings.length === 0) { + throw new Error("No containers allocated to vehicles"); + } + + await onSave(mappings); + }, + onSuccess: () => { + toast.success("Container allocations saved"); + setAllocations( + containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to save allocations", + ); + }, + }); + + const allocatedCount = Object.values(allocations).filter(Boolean).length; + const allAllocated = allocatedCount === containers.length; + + if (vehiclesLoading) { + return ( + + + + ); + } + + return ( + + {vehicles.length === 0 && ( + } color="yellow"> + No active vehicles available. Add vehicles before allocating containers. + + )} + + + + + + Container ID + Type + Qty + Assigned Vehicle + + + + {containers.map((container) => ( + + + + {container.id} + + + {container.type} + {container.qty} + +
+
+ + + + {allocatedCount} of {containers.length} containers allocated + + + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx new file mode 100644 index 000000000..85bba1dc4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx @@ -0,0 +1,164 @@ +import { useState, useMemo } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + Box, + Button, + Group, + Loader, + Select, + Stack, + Table, + Text, + Alert, +} from "@mantine/core"; +import { AlertCircle } from "lucide-react"; +import toast from "react-hot-toast"; + +import { vehiclesService } from "@/services/vehicles.service"; + +export interface ContainerAllocationRow { + id: string; + type: string; + qty: number; +} + +export interface FirstMileContainerAllocationTableProps { + firstMileId: string; + containers: ContainerAllocationRow[]; + onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise; +} + +/** + * Manual container-to-vehicle allocation table for first-mile pickups. + * Displays containers with type/qty, vehicle dropdown per row, and save action. + */ +export function FirstMileContainerAllocationTable({ + firstMileId, + containers, + onSave, +}: FirstMileContainerAllocationTableProps) { + const [allocations, setAllocations] = useState>( + () => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + + const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ + queryKey: ["vehicles", "active"], + queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), + }); + + const vehicleOptions = useMemo( + () => + vehicles.map((v) => ({ + value: v.id, + label: `${v.plateNumber} (${v.vehicleType})`, + description: `${v.model} Β· ${v.manufacturer}`, + })), + [vehicles], + ); + + const saveAllocation = useMutation({ + mutationFn: async () => { + const mappings = containers + .filter((c) => allocations[c.id]) + .map((c) => ({ + containerId: c.id, + vehicleId: allocations[c.id]!, + })); + + if (mappings.length === 0) { + throw new Error("No containers allocated to vehicles"); + } + + await onSave(mappings); + }, + onSuccess: () => { + toast.success("Container allocations saved"); + setAllocations( + containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to save allocations", + ); + }, + }); + + const allocatedCount = Object.values(allocations).filter(Boolean).length; + const allAllocated = allocatedCount === containers.length; + + if (vehiclesLoading) { + return ( + + + + ); + } + + return ( + + {vehicles.length === 0 && ( + } color="yellow"> + No active vehicles available. Add vehicles before allocating containers. + + )} + + + + + + Container ID + Type + Qty + Assigned Vehicle + + + + {containers.map((container) => ( + + + + {container.id} + + + {container.type} + {container.qty} + +
+
+ + + + {allocatedCount} of {containers.length} containers allocated + + + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx new file mode 100644 index 000000000..d11d99a4a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx @@ -0,0 +1,164 @@ +import { useState, useMemo } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + Box, + Button, + Group, + Loader, + Select, + Stack, + Table, + Text, + Alert, +} from "@mantine/core"; +import { AlertCircle } from "lucide-react"; +import toast from "react-hot-toast"; + +import { vehiclesService } from "@/services/vehicles.service"; + +export interface LastMileContainerRow { + id: string; + type: string; + qty: number; +} + +export interface LastMileContainerAllocationTableProps { + lastMileId: string; + containers: LastMileContainerRow[]; + onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise; +} + +/** + * Manual container-to-vehicle allocation table for last-mile deliveries. + * Displays containers with type/qty, vehicle dropdown per row, and save action. + */ +export function LastMileContainerAllocationTable({ + lastMileId, + containers, + onSave, +}: LastMileContainerAllocationTableProps) { + const [allocations, setAllocations] = useState>( + () => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + + const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ + queryKey: ["vehicles", "active"], + queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), + }); + + const vehicleOptions = useMemo( + () => + vehicles.map((v) => ({ + value: v.id, + label: `${v.plateNumber} (${v.vehicleType})`, + description: `${v.model} Β· ${v.manufacturer}`, + })), + [vehicles], + ); + + const saveAllocation = useMutation({ + mutationFn: async () => { + const mappings = containers + .filter((c) => allocations[c.id]) + .map((c) => ({ + containerId: c.id, + vehicleId: allocations[c.id]!, + })); + + if (mappings.length === 0) { + throw new Error("No containers allocated to vehicles"); + } + + await onSave(mappings); + }, + onSuccess: () => { + toast.success("Container allocations saved"); + setAllocations( + containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to save allocations", + ); + }, + }); + + const allocatedCount = Object.values(allocations).filter(Boolean).length; + const allAllocated = allocatedCount === containers.length; + + if (vehiclesLoading) { + return ( + + + + ); + } + + return ( + + {vehicles.length === 0 && ( + } color="yellow"> + No active vehicles available. Add vehicles before allocating containers. + + )} + + + + + + Container ID + Type + Qty + Assigned Vehicle + + + + {containers.map((container) => ( + + + + {container.id} + + + {container.type} + {container.qty} + +
+
+ + + + {allocatedCount} of {containers.length} containers allocated + + + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index 1217b8762..7a7604cc7 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,6 +1,6 @@ -export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -// export const API_BASE_URL = 'http://localhost:3001'; +export const API_BASE_URL = 'http://localhost:3001'; /** * URL that streams an uploaded file through the API by its UUID. Routes the diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx index 4ecfeebd5..bf01c4818 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx @@ -1,5 +1,7 @@ import { Container, Grid, Stack } from "@mantine/core"; import { useNavigate, useParams } from "react-router-dom"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import toast from "react-hot-toast"; import { BookingApprovalCard, @@ -16,10 +18,26 @@ import { type BookingDetailView, } from "@/components/bookings/detail"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import ContainerAllocationTable from "@/components/ContainerAllocationTable"; +import { api } from "@/services/api"; +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; const BookingDetailPage = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const qc = useQueryClient(); + + const allocateMutation = useMutation({ + mutationFn: (data: any) => + api.post(`/bookings/${id}/allocate-containers`, data), + onSuccess: () => { + toast.success("Containers allocated"); + qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.byId(id ?? "") }); + }, + onError: () => { + toast.error("Failed to allocate containers"); + }, + }); // Mock data - replace with actual API call const booking: BookingDetailView = { @@ -134,6 +152,17 @@ const BookingDetailPage = () => { + ({ + id: c.id, + type: c.containerType?.label ?? "Unknown", + qty: c.quantity, + }))} + onSave={(allocations) => + allocateMutation.mutateAsync({ allocations }) + } + /> @@ -336,6 +339,9 @@ const FirstMilePage = () => { const [invoiceOpen, setInvoiceOpen] = useState(false); const [invoiceRecord, setInvoiceRecord] = useState(null); + const [containerAllocationOpen, setContainerAllocationOpen] = useState(false); + const [containerAllocationFirstMileId, setContainerAllocationFirstMileId] = useState(null); + const { data: listData, isLoading } = useQuery({ queryKey: QUERY_KEYS.FIRST_MILE.list(), queryFn: async () => { @@ -434,6 +440,19 @@ const FirstMilePage = () => { }, }); + const allocateMutation = useMutation({ + mutationFn: (data) => apiClient.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data), + onSuccess: () => { + toast({ title: "Containers allocated" }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.detail(containerAllocationFirstMileId ?? "") }); + setContainerAllocationOpen(false); + setContainerAllocationFirstMileId(null); + }, + onError: () => { + toast({ title: "Allocation failed", variant: "destructive" }); + }, + }); + const activeRecord = useMemo( () => records.find((r) => r.id === activeId) ?? null, [records, activeId], @@ -508,6 +527,16 @@ const FirstMilePage = () => { setInvoiceRecord(null); }; + const openContainerAllocation = (firstMileId: string) => { + setContainerAllocationFirstMileId(firstMileId); + setContainerAllocationOpen(true); + }; + + const closeContainerAllocation = () => { + setContainerAllocationOpen(false); + setContainerAllocationFirstMileId(null); + }; + const handleSaveDistance = () => { const distance = parseFloat(distanceValue); if (!activeId || isNaN(distance) || distance < 0) { @@ -1272,6 +1301,56 @@ const FirstMilePage = () => { + + {/* Container Allocation modal */} + Allocate Containers to Vehicles} + size="xl" + radius="lg" + centered + > + + {activeRecord && ( + <> + {/* Capacity guidance */} + {activeRecord.booking?.cargoType?.label === "BULK" ? ( + + + Select multiple containers per vehicle based on capacity. Each vehicle can carry multiple containers if capacity allows. + + + Capacity: TBD β€” TODO: add vehicle capacity_tons to vehicle API if missing + + + ) : ( + + + One vehicle per container. Each container will be assigned to a single vehicle. + + + )} + + + {/* Container table */} + { + await allocateMutation.mutateAsync(allocations); + }} + /> + + )} + + + + + ); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 9798a90bf..99a323a6d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -45,6 +45,8 @@ import { } from "@/services/last-mile.service"; import { vehiclesService } from "@/services/vehicles.service"; import { ratesService } from "@/services/rates.service"; +import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable"; +import { api } from "@/auth/http"; const formatPrice = (amount: number) => `ETB ${amount.toLocaleString("en-US", { @@ -321,6 +323,9 @@ const LastMilePage = () => { const [invoiceOpen, setInvoiceOpen] = useState(false); const [invoiceRecord, setInvoiceRecord] = useState(null); + const [allocationOpen, setAllocationOpen] = useState(false); + const [allocationContainers, setAllocationContainers] = useState([]); + const { data: listData, isLoading } = useQuery({ queryKey: QUERY_KEYS.LAST_MILE.list(), queryFn: async () => { @@ -385,6 +390,19 @@ const LastMilePage = () => { }, }); + const allocateMutation = useMutation({ + mutationFn: (data: Array<{ containerId: string; vehicleId: string }>) => + api.post(`/last-mile/${activeId}/allocate-containers`, data), + onSuccess: () => { + toast({ title: "Containers allocated", variant: "default" }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.detail(activeId ?? "") }); + closeAllocation(); + }, + onError: () => { + toast({ title: "Allocation failed", variant: "destructive" }); + }, + }); + const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({ queryKey: ["warehouse-inventory", "arrival-queue"], queryFn: () => warehouseService.arrivalQueue().then((r) => r.data), @@ -477,6 +495,18 @@ const LastMilePage = () => { setInvoiceRecord(null); }; + const openAllocation = (id: string, containers?: LastMileContainerRow[]) => { + setActiveId(id); + setAllocationContainers(containers ?? []); + setAllocationOpen(true); + }; + + const closeAllocation = () => { + setAllocationOpen(false); + setActiveId(null); + setAllocationContainers([]); + }; + const handleSaveDistance = () => { const distance = parseFloat(distanceValue); if (!activeId || isNaN(distance) || distance < 0) { @@ -1243,6 +1273,76 @@ const LastMilePage = () => { + + {/* Container Allocation modal */} + Allocate Containers to Vehicles} + size="xl" + radius="lg" + centered + > + + {activeRecord && ( + <> + + + + + {bookingRef(activeRecord)} + {customerName(activeRecord)} + + + Cargo Type + {activeRecord.booking?.cargoType?.label ?? activeRecord.booking?.cargoType?.name ?? "β€”"} + + + + + + {/* Capacity logic based on cargo type */} + {activeRecord.booking?.cargoType?.name === "BULK" ? ( + + + + Smart Capacity Allocation + + + Capacity: TBD + + TODO: add vehicle capacity_tons to vehicle API if missing + + + TODO: add container weight to booking if missing + + + + Select multiple containers per vehicle based on capacity + + + + ) : ( + + One vehicle per container + + )} + + )} + + { + await allocateMutation.mutateAsync(mappings); + }} + /> + + + + + + ); }; From c728b249c283259eed15d2871dfe264adc641e2e Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 14:48:47 +0000 Subject: [PATCH 04/42] fix --- .../entities/first-mile-container-allocation.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts index e9407a57f..b0fa54c32 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts @@ -1,5 +1,5 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; import { FirstMile } from './first-mile.entity'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; From 9b11eeb623925019cf1c19f7bf0f580800e80c31 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 14:54:34 +0000 Subject: [PATCH 05/42] fix --- .../backoffice/src/pages/operations/FirstMilePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 59cd50a21..06487d3ca 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -46,7 +46,7 @@ import { import { bookingsService } from "@/services/bookings.service"; import { vehiclesService } from "@/services/vehicles.service"; import { ratesService } from "@/services/rates.service"; -import { apiClient } from "@/services/api-client"; +import { api } from "@/auth/http"; import type { BookingDetail } from "@/types/booking"; const formatPrice = (amount: number) => From 04bc9eded685b48715bef83ca718face42b64b2f Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 15:28:04 +0000 Subject: [PATCH 06/42] fix --- .../src/modules/bookings/bookings.module.ts | 1 - .../first-mile/first-mile-invoice.service.ts | 106 ++++++++++++++++++ .../first-mile/first-mile.controller.ts | 15 ++- .../modules/first-mile/first-mile.module.ts | 7 +- .../last-mile/last-mile-invoice.service.ts | 97 ++++++++++++++++ .../modules/last-mile/last-mile.controller.ts | 15 ++- .../src/modules/last-mile/last-mile.module.ts | 7 +- 7 files changed, 237 insertions(+), 11 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 0d5c52ee0..5d7e3b2c9 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -20,7 +20,6 @@ import { BookingPricingService } from './booking-pricing.service'; import { BookingReferenceDataService } from './booking-reference-data.service'; import { BookingTransitionService } from './booking-transition.service'; import { BookingsController } from './bookings.controller'; -import { BookingAllocationController } from './booking-allocation.controller'; import { PayController } from './pay.controller'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts new file mode 100644 index 000000000..c63a4c9e1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts @@ -0,0 +1,106 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Freight } from '@edr/types'; + +import { + BillingService, + InvoiceEventPayload, +} from '../billing/billing.service'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { FirstMileRepository } from './first-mile.repository'; +import { FirstMile } from './entities/first-mile.entity'; + +/** + * Owns the first-mile ⇄ invoice mapping β€” the one place that knows how a + * first-mile record turns into invoices, which type to use, and how it + * advances when paid. First-mile records are billable entities, so they + * generate their own invoices directly via {@link BillingService}. + */ +@Injectable() +export class FirstMileInvoiceService { + private readonly logger = new Logger(FirstMileInvoiceService.name); + + constructor( + private readonly billing: BillingService, + private readonly firstMileRepo: FirstMileRepository, + ) {} + + /** + * Ensure the first-mile record has its invoice, generating one from the + * remaining payment if absent. Called when a first-mile record reaches a + * billable state. Idempotent β€” returns the existing open invoice instead + * of a duplicate. Returns `null` (and logs) when the record is not billable: + * no company to bill. + */ + async ensureInvoiceFor(record: FirstMile): Promise { + const existing = await this.billing.findPayable( + 'first_mile' as Freight.InvoiceSource, + record.id, + 'DELIVERY_FEE', + ); + if (existing) return existing; + + if (!record.bookingId) { + this.logger.warn( + `Skipping invoice for first-mile record ${record.id}: no booking to reference.`, + ); + return null; + } + + // Fetch the booking to get the companyId and companyProfileId + const fm = record.booking ? record : (await this.firstMileRepo.findById(record.bookingId, { relations: { booking: true } })); + if (!fm) return null; + if (!fm.booking?.companyId) { + this.logger.warn( + `Skipping invoice for first-mile record ${record.id}: no company to bill.`, + ); + return null; + } + + const totalAmount = record.remainingPayment || 0; + if (!Number.isFinite(totalAmount) || totalAmount <= 0) { + this.logger.warn( + `Skipping invoice for first-mile record ${record.id}: no remaining payment.`, + ); + return null; + } + + return this.billing.generateInvoice({ + source: 'first_mile' as Freight.InvoiceSource, + sourceId: record.id, + type: 'DELIVERY_FEE', + companyId: fm.booking!.companyId, + companyProfileId: fm.booking!.companyProfileId || '', + currency: 'ETB', + lines: [ + { + chargeType: 'DELIVERY', + description: 'First-mile delivery', + quantity: 1, + unitRate: totalAmount, + amount: totalAmount, + }, + ], + totalAmount, + }); + } + + /** + * React to a first-mile invoice being paid β€” the settlement branch point. + * Mark the first-mile record as having completed post-payment processing. + */ + @OnEvent('first_mile.invoice.paid') + async onPaid(payload: InvoiceEventPayload): Promise { + if (payload.type === 'DELIVERY_FEE') { + const record = await this.firstMileRepo.findById(payload.sourceId); + if (!record) { + this.logger.warn( + `Cannot mark unknown first-mile record ${payload.sourceId} as paid.`, + ); + return; + } + + this.logger.log(`First-mile invoice paid for record ${payload.sourceId}.`); + } + } +} diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 3ecc2db20..6bb307a4b 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -20,13 +20,17 @@ import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto'; import { FirstMileStatus } from './entities/first-mile.entity'; import { FirstMileService } from './first-mile.service'; +import { FirstMileInvoiceService } from './first-mile-invoice.service'; @ApiTags('first-mile') @ApiBearerAuth() @Controller('first-mile') @TrainSchedulingView() export class FirstMileController { - constructor(private readonly firstMileService: FirstMileService) {} + constructor( + private readonly firstMileService: FirstMileService, + private readonly firstMileInvoiceService: FirstMileInvoiceService, + ) {} @Get() @ApiOperation({ summary: 'List first-mile legs' }) @@ -73,8 +77,13 @@ export class FirstMileController { @Patch(':id') @TrainSchedulingManage() @ApiOperation({ summary: 'Update a first-mile leg' }) - update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) { - return this.firstMileService.update(id, dto); + async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) { + const record = await this.firstMileService.update(id, dto); + // Auto-generate invoice if distance or payment was updated + if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) { + await this.firstMileInvoiceService.ensureInvoiceFor(record); + } + return record; } @Delete(':id') diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts index 0cdf3c92c..a69c920f1 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts @@ -1,6 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BillingModule } from '../billing/billing.module'; import { BookingsModule } from '../bookings/bookings.module'; import { DriversModule } from '../drivers/drivers.module'; import { NotificationsModule } from '../notifications/notifications.module'; @@ -8,19 +9,21 @@ import { VehiclesModule } from '../vehicles/vehicles.module'; import { FirstMile } from './entities/first-mile.entity'; import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; import { FirstMileController } from './first-mile.controller'; +import { FirstMileInvoiceService } from './first-mile-invoice.service'; import { FirstMileRepository } from './first-mile.repository'; import { FirstMileService } from './first-mile.service'; @Module({ imports: [ TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]), + BillingModule, forwardRef(() => BookingsModule), VehiclesModule, DriversModule, NotificationsModule, ], controllers: [FirstMileController], - providers: [FirstMileRepository, FirstMileService], - exports: [FirstMileRepository, FirstMileService], + providers: [FirstMileRepository, FirstMileService, FirstMileInvoiceService], + exports: [FirstMileRepository, FirstMileService, FirstMileInvoiceService], }) export class FirstMileModule {} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts new file mode 100644 index 000000000..c304a89e8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts @@ -0,0 +1,97 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Freight } from '@edr/types'; + +import { + BillingService, + GenerateInvoiceInput, + InvoiceEventPayload, +} from '../billing/billing.service'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { LastMileRepository } from './last-mile.repository'; +import { LastMile } from './entities/last-mile.entity'; + +/** + * Owns the last-mile ⇄ invoice mapping β€” the one place that knows how a last-mile + * record turns into invoices, which type to use, and how it advances when paid. + * Last-mile records are billable business entities for delivery fees, so they + * generate their own invoices directly via {@link BillingService}. All last-mile-specific + * type branching lives here, at the two points it belongs: invoice creation and + * settlement (the paid handler). + */ +@Injectable() +export class LastMileInvoiceService { + private readonly logger = new Logger(LastMileInvoiceService.name); + + constructor( + private readonly billing: BillingService, + private readonly lastMileRepo: LastMileRepository, + ) {} + + /** + * Ensure the last-mile record has its invoice, generating one from the + * remainingPayment if absent. Called when a last-mile record reaches a + * billable state. Idempotent β€” returns the existing open invoice instead + * of a duplicate. Returns `null` (and logs) when the record is not billable: + * no company to bill (invoices FK requires a companyId). + */ + async ensureInvoiceFor(record: LastMile): Promise { + // Check if invoice already exists + const existing = await this.billing.findPayable( + 'last_mile' as Freight.InvoiceSource, + record.id, + 'DELIVERY_FEE', + ); + if (existing) return existing; + + // Can't bill without company + const lm = record.booking ? record : (await this.lastMileRepo.findById(record.id, { relations: { booking: true } })); + if (!lm) return null; + if (!lm.booking?.companyId) { + this.logger.warn( + `Skipping invoice for last-mile record ${record.id}: no company to bill.`, + ); + return null; + } + + // Generate invoice with remainingPayment as totalAmount + const input: GenerateInvoiceInput = { + source: 'last_mile' as Freight.InvoiceSource, + sourceId: record.id, + type: 'DELIVERY_FEE', + companyId: lm.booking!.companyId, + companyProfileId: lm.booking!.companyProfileId || '', + currency: 'ETB', + lines: [ + { + chargeType: 'DELIVERY', + description: 'Last-mile delivery', + quantity: 1, + unitRate: record.remainingPayment || 0, + amount: record.remainingPayment || 0, + }, + ], + totalAmount: record.remainingPayment || 0, + }; + + return this.billing.generateInvoice(input); + } + + /** + * React to a last-mile invoice being paid β€” the settlement branch point. + * Advances the last-mile record to mark post-payment as completed. + */ + @OnEvent('last_mile.invoice.paid') + async onPaid(payload: InvoiceEventPayload): Promise { + if (payload.type === 'DELIVERY_FEE') { + const record = await this.lastMileRepo.findById(payload.sourceId); + if (record) { + this.logger.log(`Last-mile invoice paid for record ${payload.sourceId}.`); + } else { + this.logger.warn( + `Cannot mark last-mile record ${payload.sourceId} as paid: not found.`, + ); + } + } + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 935aa5ac7..929d97a3e 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -20,13 +20,17 @@ import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto'; import { LastMileStatus } from './entities/last-mile.entity'; import { LastMileService } from './last-mile.service'; +import { LastMileInvoiceService } from './last-mile-invoice.service'; @ApiTags('last-mile') @ApiBearerAuth() @Controller('last-mile') @TrainSchedulingView() export class LastMileController { - constructor(private readonly lastMileService: LastMileService) {} + constructor( + private readonly lastMileService: LastMileService, + private readonly lastMileInvoiceService: LastMileInvoiceService, + ) {} @Get() @ApiOperation({ summary: 'List last-mile legs' }) @@ -73,8 +77,13 @@ export class LastMileController { @Patch(':id') @TrainSchedulingManage() @ApiOperation({ summary: 'Update a last-mile leg' }) - update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) { - return this.lastMileService.update(id, dto); + async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) { + const record = await this.lastMileService.update(id, dto); + // Auto-generate invoice if distance or payment was updated + if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) { + await this.lastMileInvoiceService.ensureInvoiceFor(record); + } + return record; } @Delete(':id') diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts index e6ed6634d..32b688069 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts @@ -1,6 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BillingModule } from '../billing/billing.module'; import { BookingsModule } from '../bookings/bookings.module'; import { DriversModule } from '../drivers/drivers.module'; import { NotificationsModule } from '../notifications/notifications.module'; @@ -8,19 +9,21 @@ import { VehiclesModule } from '../vehicles/vehicles.module'; import { LastMile } from './entities/last-mile.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileController } from './last-mile.controller'; +import { LastMileInvoiceService } from './last-mile-invoice.service'; import { LastMileRepository } from './last-mile.repository'; import { LastMileService } from './last-mile.service'; @Module({ imports: [ TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation]), + BillingModule, forwardRef(() => BookingsModule), VehiclesModule, DriversModule, NotificationsModule, ], controllers: [LastMileController], - providers: [LastMileRepository, LastMileService], - exports: [LastMileRepository, LastMileService], + providers: [LastMileRepository, LastMileService, LastMileInvoiceService], + exports: [LastMileRepository, LastMileService, LastMileInvoiceService], }) export class LastMileModule {} From 1193e2bfa957debc38c6f409c445d7c026b0d3e8 Mon Sep 17 00:00:00 2001 From: yaschalew Date: Mon, 29 Jun 2026 18:35:51 +0300 Subject: [PATCH 07/42] fix commit --- apps/edr-freight-web/backoffice/src/constants/apiConfig.ts | 4 ++-- apps/edr-freight-web/portal/src/constants/apiConfig.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index 7a7604cc7..1217b8762 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,6 +1,6 @@ -// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -export const API_BASE_URL = 'http://localhost:3001'; +// export const API_BASE_URL = 'http://localhost:3001'; /** * URL that streams an uploaded file through the API by its UUID. Routes the diff --git a/apps/edr-freight-web/portal/src/constants/apiConfig.ts b/apps/edr-freight-web/portal/src/constants/apiConfig.ts index a24cb4a6d..1b070d87d 100644 --- a/apps/edr-freight-web/portal/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/portal/src/constants/apiConfig.ts @@ -1,5 +1,5 @@ -// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -export const API_BASE_URL = 'http://localhost:3001'; +export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +// export const API_BASE_URL = 'http://localhost:3001'; /** * URL that streams an uploaded file through the API by its UUID. Routes the From ce3584cd4a59f411803032f990fa037d9179d73f Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 29 Jun 2026 15:41:00 +0000 Subject: [PATCH 08/42] cfix payment --- .../src/modules/payment/payment.service.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 71371989b..347fedc1e 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -462,16 +462,29 @@ export class PaymentService { failureCode?: string; failureMessage?: string; }): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> { + console.log(`Received payment event: ${JSON.stringify(event)}`); if (event.eventType === "payment.succeeded") { const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); if (!intent) { return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; } + console.log(`Processing payment succeeded event for intent: }`,intent); const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { providerTxnId: event.providerTxnId, paidAt: event.paidAt ? new Date(event.paidAt) : undefined, notify: true, }); + console.log(`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); + + // When the intent references a booking, flip the booking itself paid. + // refId holds the booking id (the domain reference the intent opened with). + if (intent.referenceType === PaymentReferenceType.BOOKING) { + await this.datasource.manager.update( + Booking, + { id: intent.refId }, + { status: "PAID", paymentStatus: "PAID" }, + ); + } // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); return { processed: true, alreadyFinalized }; } From d91389daac5b9232cce0f01decc9bca60dac1d11 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 29 Jun 2026 15:46:17 +0000 Subject: [PATCH 09/42] cfix payment --- ...lumn.ts => 1810000000004-AddPostPaymentCompletedColumn.ts} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename apps/edr-freight-api/src/migrations/{1719667261000-AddPostPaymentCompletedColumn.ts => 1810000000004-AddPostPaymentCompletedColumn.ts} (93%) diff --git a/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts b/apps/edr-freight-api/src/migrations/1810000000004-AddPostPaymentCompletedColumn.ts similarity index 93% rename from apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts rename to apps/edr-freight-api/src/migrations/1810000000004-AddPostPaymentCompletedColumn.ts index c755d6356..aeedae2b1 100644 --- a/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts +++ b/apps/edr-freight-api/src/migrations/1810000000004-AddPostPaymentCompletedColumn.ts @@ -1,7 +1,7 @@ import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; -export class AddPostPaymentCompletedColumn1719667261000 implements MigrationInterface { - name = 'AddPostPaymentCompletedColumn1719667261000'; +export class AddPostPaymentCompletedColumn1810000000004 implements MigrationInterface { + name = 'AddPostPaymentCompletedColumn1810000000004'; public async up(queryRunner: QueryRunner): Promise { const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries'); From 32ca68f68cd502fe21e930fba9d5765195b924be Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 29 Jun 2026 21:12:20 +0300 Subject: [PATCH 10/42] TypeError issue resolution --- .../modules/schedules/schedules.controller.ts | 3 +-- .../src/modules/schedules/schedules.dto.ts | 22 ++++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index ac55bc14b..6d1091719 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -2,9 +2,8 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SchedulesService } from './schedules.service'; -import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto } from './schedules.dto'; +import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus } from './schedules.dto'; import { JwtGuard } from '../../common/jwt.guard'; -import { TripStatus } from '@prisma/client'; @ApiTags('Schedule') @Controller('schedules') diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 4e422f2ad..b6363e085 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -1,7 +1,27 @@ import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, Min } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { TripStatus, StopStatus, PassengerCategory } from '@prisma/client'; + +export enum TripStatus { + SCHEDULED = 'SCHEDULED', + BOARDING = 'BOARDING', + EN_ROUTE = 'EN_ROUTE', + ARRIVED = 'ARRIVED', + CANCELLED = 'CANCELLED', + DELAYED = 'DELAYED', +} + +export enum StopStatus { + COMPLETED = 'COMPLETED', + APPROACHING = 'APPROACHING', + CURRENT = 'CURRENT', + UPCOMING = 'UPCOMING', +} + +export enum PassengerCategory { + ADULT = 'ADULT', + CHILD = 'CHILD', +} export class PlannedStopTimeDto { @ApiProperty({ example: 1, description: 'Route stop sequence number this timing applies to' }) @IsInt() @Min(1) sequence: number; From 10cde2b2e3445d7ea6f497e574102f183f05addb Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 29 Jun 2026 19:44:40 +0000 Subject: [PATCH 11/42] fix --- apps/edr-freight-api/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index a9965c74a..b0850737b 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -34,4 +34,4 @@ RUN addgroup --system --gid 1001 nodejs \ COPY --from=deployer --chown=nestjs:nodejs /deploy . USER nestjs EXPOSE 3001 -CMD ["sh", "-c", "pnpm run migrate && node dist/main.js"] +CMD ["node", "dist/main.js"] From 8c2a2e34bf0b44ffc54c384a27a990209263dacb Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 29 Jun 2026 23:08:27 +0300 Subject: [PATCH 12/42] Migration issue resolution - individual ticket no timezone --- .../migration.sql | 73 +++++++++++++------ 1 file changed, 52 insertions(+), 21 deletions(-) diff --git a/apps/edr-passenger-api/prisma/migrations/20240101000000_individual_tickets_no_timezone/migration.sql b/apps/edr-passenger-api/prisma/migrations/20240101000000_individual_tickets_no_timezone/migration.sql index a3a9b7445..44d778635 100644 --- a/apps/edr-passenger-api/prisma/migrations/20240101000000_individual_tickets_no_timezone/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20240101000000_individual_tickets_no_timezone/migration.sql @@ -1,8 +1,15 @@ --- DropForeignKey -ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey"; - --- DropForeignKey -ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_ticketId_fkey"; +-- DropForeignKey (only if table exists) +DO $$ +BEGIN + IF EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'passenger' + AND table_name = 'TicketSeat' + ) THEN + ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey"; + ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_ticketId_fkey"; + END IF; +END $$; -- DropIndex DROP INDEX IF EXISTS "passenger"."Ticket_bookingId_key"; @@ -20,18 +27,28 @@ ALTER TABLE "passenger"."Ticket" -- DropTable DROP TABLE IF EXISTS "passenger"."TicketSeat"; --- Remove GateValidationLog rows referencing orphan tickets first -DELETE FROM "passenger"."GateValidationLog" -WHERE "ticketId" IN ( - SELECT "id" FROM "passenger"."Ticket" - WHERE "seatId" = '' - OR "seatId" NOT IN (SELECT "id" FROM "passenger"."Seat") -); +-- Remove GateValidationLog rows referencing orphan tickets first (only if tickets have seatId column) +DO $$ +BEGIN + IF EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'passenger' + AND table_name = 'Ticket' + AND column_name = 'seatId' + ) THEN + DELETE FROM "passenger"."GateValidationLog" + WHERE "ticketId" IN ( + SELECT "id" FROM "passenger"."Ticket" + WHERE "seatId" = '' + OR "seatId" NOT IN (SELECT "id" FROM "passenger"."Seat") + ); --- Remove orphan ticket rows -DELETE FROM "passenger"."Ticket" -WHERE "seatId" = '' - OR "seatId" NOT IN (SELECT "id" FROM "passenger"."Seat"); + -- Remove orphan ticket rows + DELETE FROM "passenger"."Ticket" + WHERE "seatId" = '' + OR "seatId" NOT IN (SELECT "id" FROM "passenger"."Seat"); + END IF; +END $$; -- CreateIndex CREATE INDEX IF NOT EXISTS "Ticket_bookingId_idx" ON "passenger"."Ticket"("bookingId"); @@ -39,8 +56,22 @@ CREATE INDEX IF NOT EXISTS "Ticket_bookingId_idx" ON "passenger"."Ticket"("booki -- CreateIndex CREATE INDEX IF NOT EXISTS "Ticket_seatId_idx" ON "passenger"."Ticket"("seatId"); --- AddForeignKey -ALTER TABLE "passenger"."Ticket" - ADD CONSTRAINT "Ticket_seatId_fkey" - FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") - ON DELETE RESTRICT ON UPDATE CASCADE; +-- AddForeignKey (only if not already exists) +DO $$ +BEGIN + IF EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'passenger' + AND table_name = 'Ticket' + AND column_name = 'seatId' + ) AND NOT EXISTS ( + SELECT FROM information_schema.table_constraints + WHERE constraint_schema = 'passenger' + AND constraint_name = 'Ticket_seatId_fkey' + ) THEN + ALTER TABLE "passenger"."Ticket" + ADD CONSTRAINT "Ticket_seatId_fkey" + FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; From 7d8e19b37d7861023aee00d12003556e7ec7893e Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 29 Jun 2026 23:19:10 +0300 Subject: [PATCH 13/42] Fix failed migration state --- .../migration.sql | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260629100000_fix_failed_migration_state/migration.sql diff --git a/apps/edr-passenger-api/prisma/migrations/20260629100000_fix_failed_migration_state/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260629100000_fix_failed_migration_state/migration.sql new file mode 100644 index 000000000..a72a795c1 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260629100000_fix_failed_migration_state/migration.sql @@ -0,0 +1,10 @@ +-- This migration fixes the failed state of 20240101000000_individual_tickets_no_timezone +-- It marks the failed migration as rolled back so it can be retried + +-- Mark the failed migration as rolled back +UPDATE passenger._prisma_migrations +SET rolled_back_at = CURRENT_TIMESTAMP, + logs = 'Migration failed due to missing TicketSeat table. Automatically rolled back by fix migration to allow retry with idempotent SQL.' +WHERE migration_name = '20240101000000_individual_tickets_no_timezone' + AND rolled_back_at IS NULL + AND finished_at IS NULL; From c52b02ef722fdf161f7c88f163c7d555f40f3222 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 29 Jun 2026 23:27:36 +0300 Subject: [PATCH 14/42] Move the fix before the failing migration --- .../migration.sql | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/edr-passenger-api/prisma/migrations/{20260629100000_fix_failed_migration_state => 20240100000000_fix_failed_migration_state}/migration.sql (100%) diff --git a/apps/edr-passenger-api/prisma/migrations/20260629100000_fix_failed_migration_state/migration.sql b/apps/edr-passenger-api/prisma/migrations/20240100000000_fix_failed_migration_state/migration.sql similarity index 100% rename from apps/edr-passenger-api/prisma/migrations/20260629100000_fix_failed_migration_state/migration.sql rename to apps/edr-passenger-api/prisma/migrations/20240100000000_fix_failed_migration_state/migration.sql From f069ee985d186f2e71c56a4a5920e70109499e35 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 29 Jun 2026 23:45:59 +0300 Subject: [PATCH 15/42] Resolve migrations --- apps/edr-passenger-api/Dockerfile | 5 ++++- apps/edr-passenger-api/scripts/resolve-migrations.sh | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 apps/edr-passenger-api/scripts/resolve-migrations.sh diff --git a/apps/edr-passenger-api/Dockerfile b/apps/edr-passenger-api/Dockerfile index 2b0ee8041..a73b5da13 100644 --- a/apps/edr-passenger-api/Dockerfile +++ b/apps/edr-passenger-api/Dockerfile @@ -37,7 +37,10 @@ WORKDIR /deploy RUN corepack enable && corepack prepare pnpm@11.1.1 --activate ENV CI=true ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 -CMD ["sh", "-c", "npm run prisma:generate && npm run prisma:migrate && npm run prisma:seed"] +# Copy the resolution script +COPY apps/edr-passenger-api/scripts/resolve-migrations.sh /deploy/scripts/ +RUN chmod +x /deploy/scripts/resolve-migrations.sh +CMD ["sh", "-c", "/deploy/scripts/resolve-migrations.sh && npm run prisma:generate && npm run prisma:migrate && npm run prisma:seed"] FROM node:24.15.0-alpine AS runner RUN apk add --no-cache libc6-compat diff --git a/apps/edr-passenger-api/scripts/resolve-migrations.sh b/apps/edr-passenger-api/scripts/resolve-migrations.sh new file mode 100644 index 000000000..95b0bbb53 --- /dev/null +++ b/apps/edr-passenger-api/scripts/resolve-migrations.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +echo "πŸ” Checking for failed migrations..." + +# Mark the specific failed migration as applied +npx prisma migrate resolve --applied "20240101000000_individual_tickets_no_timezone" || true + +echo "βœ… Migration resolution complete" From 140a989d34106a3080d345a4371256d780fe1004 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 29 Jun 2026 23:55:24 +0300 Subject: [PATCH 16/42] Skip different schema version migrations --- apps/edr-passenger-api/scripts/resolve-migrations.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/edr-passenger-api/scripts/resolve-migrations.sh b/apps/edr-passenger-api/scripts/resolve-migrations.sh index 95b0bbb53..277b44a76 100644 --- a/apps/edr-passenger-api/scripts/resolve-migrations.sh +++ b/apps/edr-passenger-api/scripts/resolve-migrations.sh @@ -3,7 +3,11 @@ set -e echo "πŸ” Checking for failed migrations..." -# Mark the specific failed migration as applied +# Mark legacy migrations as applied (these are from an old schema that doesn't match current DB) +# These migrations were designed for a different schema version and should be skipped +npx prisma migrate resolve --applied "20240100000000_fix_failed_migration_state" || true npx prisma migrate resolve --applied "20240101000000_individual_tickets_no_timezone" || true +npx prisma migrate resolve --applied "20240102000000_drop_ticket_column_defaults" || true +npx prisma migrate resolve --applied "20241201000000_remove_station_timezone" || true echo "βœ… Migration resolution complete" From 44930b5eab167ecace24fc64f5aad1418ebf6648 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Tue, 30 Jun 2026 00:03:38 +0300 Subject: [PATCH 17/42] fix(passenger-api): resolve all pre-init legacy migrations --- apps/edr-passenger-api/scripts/resolve-migrations.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/edr-passenger-api/scripts/resolve-migrations.sh b/apps/edr-passenger-api/scripts/resolve-migrations.sh index 277b44a76..96cba674a 100644 --- a/apps/edr-passenger-api/scripts/resolve-migrations.sh +++ b/apps/edr-passenger-api/scripts/resolve-migrations.sh @@ -5,9 +5,12 @@ echo "πŸ” Checking for failed migrations..." # Mark legacy migrations as applied (these are from an old schema that doesn't match current DB) # These migrations were designed for a different schema version and should be skipped +# All migrations before 20260605195213_init should be resolved as they modify tables that don't exist yet npx prisma migrate resolve --applied "20240100000000_fix_failed_migration_state" || true npx prisma migrate resolve --applied "20240101000000_individual_tickets_no_timezone" || true npx prisma migrate resolve --applied "20240102000000_drop_ticket_column_defaults" || true npx prisma migrate resolve --applied "20241201000000_remove_station_timezone" || true +npx prisma migrate resolve --applied "20250106070000_add_gender_to_traveler_profile" || true +npx prisma migrate resolve --applied "20260101000000_add_configurable_fare_system" || true echo "βœ… Migration resolution complete" From 0cefd9ffb28ad0665ca1ada14754d9bf8a495f26 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Tue, 30 Jun 2026 00:16:31 +0300 Subject: [PATCH 18/42] fix(passenger-api): ensure Prisma client is properly copied to runtime container --- apps/edr-passenger-api/Dockerfile | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/edr-passenger-api/Dockerfile b/apps/edr-passenger-api/Dockerfile index a73b5da13..d4e5392b3 100644 --- a/apps/edr-passenger-api/Dockerfile +++ b/apps/edr-passenger-api/Dockerfile @@ -23,10 +23,10 @@ RUN pnpm turbo build --filter="@edr/passenger-api..." FROM base AS deployer COPY --from=builder /app/ . RUN pnpm deploy --filter="@edr/passenger-api" --legacy /deploy -RUN if [ -d node_modules/.prisma ]; then \ - mkdir -p /deploy/node_modules && \ - cp -r node_modules/.prisma /deploy/node_modules/.prisma; \ - fi +# Copy Prisma schema and generated client to deployment directory +RUN mkdir -p /deploy/node_modules/.prisma /deploy/node_modules/@prisma && \ + cp -r node_modules/.prisma/client /deploy/node_modules/.prisma/ 2>/dev/null || true && \ + cp -r node_modules/@prisma/client /deploy/node_modules/@prisma/ 2>/dev/null || true # --- Migration image: built in CI, run as a one-shot `docker run --rm --env-file ...` # against the real DB, as its own gated step *before* the app image is built/deployed. @@ -48,7 +48,7 @@ ENV NODE_ENV=production WORKDIR /app RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 --ingroup nodejs nestjs -COPY --from=deployer --chown=nestjs:nodejs /deploy . +COPY --from=deployer --chown=nestjs:nodejs /deploy .\ USER nestjs EXPOSE 4000 CMD ["node", "dist/main.js"] From bd372cce32172c89ea361bd336296d10d8a6fdee Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Tue, 30 Jun 2026 00:30:42 +0300 Subject: [PATCH 19/42] fix(passenger-api): remove trailing backslash in Dockerfile --- apps/edr-passenger-api/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-passenger-api/Dockerfile b/apps/edr-passenger-api/Dockerfile index d4e5392b3..1b5c8e9d3 100644 --- a/apps/edr-passenger-api/Dockerfile +++ b/apps/edr-passenger-api/Dockerfile @@ -48,7 +48,7 @@ ENV NODE_ENV=production WORKDIR /app RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 --ingroup nodejs nestjs -COPY --from=deployer --chown=nestjs:nodejs /deploy .\ +COPY --from=deployer --chown=nestjs:nodejs /deploy . USER nestjs EXPOSE 4000 CMD ["node", "dist/main.js"] From 9bd7edbd03e547d9d301885a510eba68502a9a9f Mon Sep 17 00:00:00 2001 From: "Stephanos A." Date: Tue, 30 Jun 2026 00:42:39 +0300 Subject: [PATCH 20/42] Update Dockerfile --- apps/edr-passenger-api/Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/edr-passenger-api/Dockerfile b/apps/edr-passenger-api/Dockerfile index 1b5c8e9d3..4941ceb46 100644 --- a/apps/edr-passenger-api/Dockerfile +++ b/apps/edr-passenger-api/Dockerfile @@ -23,10 +23,10 @@ RUN pnpm turbo build --filter="@edr/passenger-api..." FROM base AS deployer COPY --from=builder /app/ . RUN pnpm deploy --filter="@edr/passenger-api" --legacy /deploy -# Copy Prisma schema and generated client to deployment directory -RUN mkdir -p /deploy/node_modules/.prisma /deploy/node_modules/@prisma && \ - cp -r node_modules/.prisma/client /deploy/node_modules/.prisma/ 2>/dev/null || true && \ - cp -r node_modules/@prisma/client /deploy/node_modules/@prisma/ 2>/dev/null || true +# Copy prisma directory and generate client in deploy location +RUN cp -r apps/edr-passenger-api/prisma /deploy/ && \ + cd /deploy && \ + npx prisma generate --schema=prisma/schema.prisma # --- Migration image: built in CI, run as a one-shot `docker run --rm --env-file ...` # against the real DB, as its own gated step *before* the app image is built/deployed. From 3ceacbe9a9087498cf007111b7b09ad3d35bce35 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 02:03:20 +0000 Subject: [PATCH 21/42] fix(migrations): use CREATE TYPE IF NOT EXISTS for invoices enum Allows migration to run when enum already exists in prod DB. Prevents 'type already exists' error on redeployment. Co-Authored-By: Claude Haiku 4.5 --- .../src/migrations/1821000000002-CreateInvoices.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts index 5c42cad65..09cb68262 100644 --- a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts +++ b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts @@ -16,7 +16,7 @@ export class CreateInvoices1821000000002 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` - CREATE TYPE freight.invoices_status_enum AS ENUM ( + CREATE TYPE IF NOT EXISTS freight.invoices_status_enum AS ENUM ( 'DRAFT', 'PENDING', 'PAID', From ec82f8eb9fcd8186222a1f1c0f8e0e6860ddb320 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 02:03:20 +0000 Subject: [PATCH 22/42] fix(migrations): use CREATE TYPE IF NOT EXISTS for invoices enum Allows migration to run when enum already exists in prod DB. Prevents 'type already exists' error on redeployment. Co-Authored-By: Claude Haiku 4.5 --- .../1821000000002-CreateInvoices.ts | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts index 5c42cad65..610fe6b30 100644 --- a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts +++ b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts @@ -15,16 +15,22 @@ export class CreateInvoices1821000000002 implements MigrationInterface { name = "CreateInvoices1821000000002"; public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(` - CREATE TYPE freight.invoices_status_enum AS ENUM ( - 'DRAFT', - 'PENDING', - 'PAID', - 'OVERDUE', - 'CANCELLED', - 'REFUNDED' - ); - `); + const typeExists = await queryRunner.query( + `SELECT 1 FROM pg_type WHERE typname = 'invoices_status_enum' AND typnamespace = 'freight'::regnamespace;`, + ); + + if (!typeExists.length) { + await queryRunner.query(` + CREATE TYPE freight.invoices_status_enum AS ENUM ( + 'DRAFT', + 'PENDING', + 'PAID', + 'OVERDUE', + 'CANCELLED', + 'REFUNDED' + ); + `); + } await queryRunner.query(` CREATE TABLE freight.invoices ( From 76b25bd2145dbb561caa2da1d4add7c1beeb8c4c Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 02:33:29 +0000 Subject: [PATCH 23/42] fix --- .../1821000000002-CreateInvoices.ts | 19 ------------------- .../first-mile/entities/first-mile.entity.ts | 4 ++-- .../last-mile/entities/last-mile.entity.ts | 4 ++-- 3 files changed, 4 insertions(+), 23 deletions(-) diff --git a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts index 449863750..09cb68262 100644 --- a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts +++ b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts @@ -15,24 +15,6 @@ export class CreateInvoices1821000000002 implements MigrationInterface { name = "CreateInvoices1821000000002"; public async up(queryRunner: QueryRunner): Promise { -<<<<<<< HEAD - const typeExists = await queryRunner.query( - `SELECT 1 FROM pg_type WHERE typname = 'invoices_status_enum' AND typnamespace = 'freight'::regnamespace;`, - ); - - if (!typeExists.length) { - await queryRunner.query(` - CREATE TYPE freight.invoices_status_enum AS ENUM ( - 'DRAFT', - 'PENDING', - 'PAID', - 'OVERDUE', - 'CANCELLED', - 'REFUNDED' - ); - `); - } -======= await queryRunner.query(` CREATE TYPE IF NOT EXISTS freight.invoices_status_enum AS ENUM ( 'DRAFT', @@ -43,7 +25,6 @@ export class CreateInvoices1821000000002 implements MigrationInterface { 'REFUNDED' ); `); ->>>>>>> 3ceacbe9a9087498cf007111b7b09ad3d35bce35 await queryRunner.query(` CREATE TABLE freight.invoices ( diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts index b2eb3801f..319350b43 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts @@ -35,8 +35,8 @@ export class FirstMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; - // @Column({ type: 'boolean', default: false }) - // isPostPaymentCompleted!: boolean; + @Column({ type: 'boolean', default: false }) + isPostPaymentCompleted!: boolean; @Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) estimatedKm?: number | null; diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index 61aad0d72..c1787cda8 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -35,8 +35,8 @@ export class LastMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; - // @Column({ type: 'boolean', default: false }) - // isPostPaymentCompleted!: boolean; + @Column({ type: 'boolean', default: false }) + isPostPaymentCompleted!: boolean; @Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) estimatedKm?: number | null; From f9ef473cffe9b23d19b15a5ad115433ba12f8d06 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 02:45:37 +0000 Subject: [PATCH 24/42] fix(migrations): check pg_type before CREATE TYPE enum PostgreSQL doesn't support IF NOT EXISTS on CREATE TYPE AS ENUM. Query pg_type table to check if enum exists before creating. Compatible with all PostgreSQL versions. Co-Authored-By: Claude Haiku 4.5 --- .../1821000000002-CreateInvoices.ts | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts index 09cb68262..93196578d 100644 --- a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts +++ b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts @@ -15,16 +15,22 @@ export class CreateInvoices1821000000002 implements MigrationInterface { name = "CreateInvoices1821000000002"; public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(` - CREATE TYPE IF NOT EXISTS freight.invoices_status_enum AS ENUM ( - 'DRAFT', - 'PENDING', - 'PAID', - 'OVERDUE', - 'CANCELLED', - 'REFUNDED' - ); - `); + const typeExists = await queryRunner.query( + `SELECT 1 FROM pg_type WHERE typname = 'invoices_status_enum' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'freight');`, + ); + + if (!typeExists.length) { + await queryRunner.query(` + CREATE TYPE freight.invoices_status_enum AS ENUM ( + 'DRAFT', + 'PENDING', + 'PAID', + 'OVERDUE', + 'CANCELLED', + 'REFUNDED' + ); + `); + } await queryRunner.query(` CREATE TABLE freight.invoices ( From b1d9045d0b790c93e9b9a2a520241afd6ac58212 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 03:15:58 +0000 Subject: [PATCH 25/42] fix: remove isPostPaymentCompleted filter check Property removed from entities until migration creates column. Temporarily skip this filter until feature is fully implemented. Co-Authored-By: Claude Haiku 4.5 --- .../backoffice/src/pages/operations/FirstMilePage.tsx | 1 - .../backoffice/src/pages/operations/LastMilePage.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index e17ea46c7..2b2f00904 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -530,7 +530,6 @@ const FirstMilePage = () => { }; const matchesFilter = (r: FirstMileRecord) => { - if (filterPostPaymentPending && r.isPostPaymentCompleted) return false; switch (statusFilter) { case "ALL": return true; case "ASSIGNED": return isAssigned(r); diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 9798a90bf..e81f516e2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -509,7 +509,6 @@ const LastMilePage = () => { ); const matchesFilter = (r: LastMileRecord) => { - if (filterPostPaymentPending && r.isPostPaymentCompleted) return false; switch (statusFilter) { case "ALL": return true; case "ASSIGNED": return isAssigned(r); From 25e91c6d933cd7656904e960b69f40787a94576e Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 03:33:54 +0000 Subject: [PATCH 26/42] fix: comment out isPostPaymentCompleted column decorator Column doesn't exist in DB yet. Commenting out @Column decorator prevents TypeORM from trying to select non-existent column. Fixes 500 QueryFailedError on first-mile/last-mile list endpoints. Co-Authored-By: Claude Haiku 4.5 --- .../src/modules/first-mile/entities/first-mile.entity.ts | 5 +++-- .../src/modules/last-mile/entities/last-mile.entity.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts index 513aaf98e..ef2ffb845 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts @@ -34,8 +34,9 @@ export class FirstMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; - @Column({ type: 'boolean', default: false }) - isPostPaymentCompleted!: boolean; + // TODO: uncomment after migration creates column + // @Column({ type: 'boolean', default: false }) + // isPostPaymentCompleted!: boolean; @Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) estimatedKm?: number | null; diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index 6c8c9d1ca..0d342956f 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -34,8 +34,9 @@ export class LastMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; - @Column({ type: 'boolean', default: false }) - isPostPaymentCompleted!: boolean; + // TODO: uncomment after migration creates column + // @Column({ type: 'boolean', default: false }) + // isPostPaymentCompleted!: boolean; @Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) estimatedKm?: number | null; From 9f5c22c1ee58f7eb915c3bc90bc3e067b82bccc1 Mon Sep 17 00:00:00 2001 From: hagiye Date: Tue, 30 Jun 2026 07:19:12 +0300 Subject: [PATCH 27/42] Goods recieved notes and Booking delivery --- apps/edr-freight-api/package.json | 1 + ...000000-AddGrnNumberToWarehouseInventory.ts | 34 ++ .../entities/warehouse-inventory.entity.ts | 3 + .../warehouse-inventory.controller.ts | 10 + .../warehouses/warehouse-inventory.service.ts | 439 ++++++++++++++++-- .../seed-warehouse-export-receive-ready.ts | 142 ++++++ .../warehouses/InventoryDetailModal.tsx | 12 + .../warehouses/ReceiveInventoryModal.tsx | 174 +++++-- .../warehouses/ReleaseOrderModal.tsx | 201 ++++++-- .../warehouses/WarehouseInventoryTable.tsx | 68 ++- .../backoffice/src/constants/URLS.ts | 1 + .../backoffice/src/constants/apiConfig.ts | 4 +- .../warehouses/ExportWarehouseFlowPage.tsx | 38 +- .../backoffice/src/services/api.ts | 6 +- .../src/services/warehouse.service.ts | 4 + .../backoffice/src/types/warehouse.ts | 7 + .../portal/src/constants/apiConfig.ts | 4 +- .../MyPortalPage/components/BookingRow.tsx | 8 + .../BookingDetailPage/ReadonlyBookingView.tsx | 26 +- .../delivery/ApproveDeliveryButton.tsx | 73 +++ .../portal/src/services/api.ts | 7 + 21 files changed, 1126 insertions(+), 136 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1828000000000-AddGrnNumberToWarehouseInventory.ts create mode 100644 apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 27737c84c..134bfd885 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -18,6 +18,7 @@ "seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts", "seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts", "seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts", + "seed:warehouse-export-receive-ready": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-export-receive-ready.ts", "seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts", "seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts", "seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts", diff --git a/apps/edr-freight-api/src/migrations/1828000000000-AddGrnNumberToWarehouseInventory.ts b/apps/edr-freight-api/src/migrations/1828000000000-AddGrnNumberToWarehouseInventory.ts new file mode 100644 index 000000000..c57a43aaa --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1828000000000-AddGrnNumberToWarehouseInventory.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddGrnNumberToWarehouseInventory1828000000000 implements MigrationInterface { + name = 'AddGrnNumberToWarehouseInventory1828000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory + ADD COLUMN IF NOT EXISTS grn_number VARCHAR(100) NULL + `); + + await queryRunner.query(` + UPDATE freight.warehouse_inventory + SET grn_number = substring(notes FROM 'GRN Number: ([^\\n\\r]+)') + WHERE grn_number IS NULL + AND notes IS NOT NULL + AND notes ~ 'GRN Number: ' + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_grn_number + ON freight.warehouse_inventory(grn_number) + WHERE grn_number IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_grn_number`); + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory + DROP COLUMN IF EXISTS grn_number + `); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index 815841e54..290b6f0c2 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -110,6 +110,9 @@ export class WarehouseInventory extends BaseEntity { @Column({ name: 'volume', type: 'numeric', precision: 12, scale: 3, nullable: true }) volume?: number | null; + @Column({ name: 'grn_number', type: 'varchar', length: 100, nullable: true }) + grnNumber?: string | null; + @Column({ name: 'status', type: 'varchar', length: 32, default: 'RECEIVED' }) status!: WarehouseInventoryStatus; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 68f536b33..6b2bd8c28 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -273,6 +273,16 @@ export class WarehouseInventoryController { return res.send(buffer); } + @Get(':id/grn-document') + @ApiOperation({ summary: 'View goods received note PDF' }) + async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.inventoryService.grnDocument(id); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + @Get(':id/handover-document') @ApiOperation({ summary: 'View import goods handover document PDF' }) async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 618553df3..001897b3f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -52,6 +52,7 @@ const isLoadableWagonStatus = (status: string | null | undefined) => LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status)); const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:'; +const HANDOVER_DOCUMENT_MARKER = '[Handover Document]'; export interface InventoryInquiryResult { id: string; @@ -249,6 +250,7 @@ export interface ReadyToLoadRow { containerNumber: string | null; cargoType: string | null; weight: number | null; + grnNumber: string | null; origin: string | null; destination: string | null; inspectionStatus: string | null; @@ -295,6 +297,7 @@ export interface ImportUnloadedRow { containerNumber: string | null; cargoType: string | null; weight: number | null; + grnNumber: string | null; trainSchedule: string | null; inspectionStatus: string | null; pickupOption: string; @@ -302,6 +305,8 @@ export interface ImportUnloadedRow { currentStatus: string; releaseDate: string | null; releaseOrderReference: string | null; + handoverDocumentReference: string | null; + handoverDocumentDate: string | null; deliveredAt: string | null; } @@ -415,7 +420,10 @@ export class WarehouseInventoryService { const search = filter.search?.trim(); const where: FindManyOptions['where'] = search - ? { ...base, notes: ILike(`%${search}%`) } + ? [ + { ...base, notes: ILike(`%${search}%`) }, + { ...base, grnNumber: ILike(`%${search}%`) }, + ] : base; const items = await this.inventoryRepository.findAll({ @@ -766,6 +774,7 @@ export class WarehouseInventoryService { const [booking] = await manager.query( `SELECT b.reference AS "reference", b.payment_status AS "paymentStatus", + b.freight_type AS "freightType", b.cargo_total_weight_vgm AS "weight", company.name AS "customer", company.tin AS "customerTin", @@ -847,6 +856,12 @@ export class WarehouseInventoryService { const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } }); if (existing) { skip('Already received'); continue; } + const containerQuantity = Number(booking.containerQuantity ?? 0); + if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) { + skip('Container booking has no container quantity'); + continue; + } + const now = new Date(); const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now); const truckEntrance = dto.truckEntrance @@ -867,8 +882,9 @@ export class WarehouseInventoryService { yardId: dto.yardId, zoneId: dto.zoneId, bookingId, - quantity: Number(booking.containerQuantity) || 1, + quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1, weight: Number(booking.weight) || 0, + grnNumber, status: 'RECEIVED', arrivedAt: now, notes: receiveNote, @@ -962,6 +978,7 @@ export class WarehouseInventoryService { ct.container_number AS "containerNumber", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", inv.weight AS "weight", + COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber", oy.code AS "origin", dy.code AS "destination", oy.country AS "originCountry", @@ -1021,6 +1038,7 @@ export class WarehouseInventoryService { ORDER BY c.container_number LIMIT 1) AS "containerNumber", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", inv.weight AS "weight", + COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber", ts.train_number AS "trainSchedule", inv.inspection_status AS "inspectionStatus", CASE WHEN b.last_mile_delivery_address IS NOT NULL @@ -1029,6 +1047,8 @@ export class WarehouseInventoryService { inv.status AS "currentStatus", inv.release_date AS "releaseDate", inv.release_order_reference AS "releaseOrderReference", + substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference", + substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate", inv.delivered_at AS "deliveredAt", oy.country AS "originCountry", dy.country AS "destinationCountry" @@ -1669,6 +1689,7 @@ export class WarehouseInventoryService { quantity, weight, volume: dto.volume ?? null, + grnNumber, status: 'RECEIVED', arrivedAt: now, notes: receiveNote, @@ -1933,24 +1954,31 @@ export class WarehouseInventoryService { ); } - const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date(); - const reference = dto.reference?.trim() || null; + const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime); + const releaseDate = isTruckLeaving + ? dto.releaseDate ? new Date(dto.releaseDate) : new Date() + : item.releaseDate ?? null; + const reference = dto.reference?.trim() || (await this.generateReleaseReference(item)); const exitInspectionNote = this.buildExitInspectionNote(dto); await this.dataSource.transaction(async (manager) => { await manager.getRepository(WarehouseInventory).update(id, { releaseDate, releaseOrderReference: reference, - notes: [item.notes?.trim(), exitInspectionNote].filter(Boolean).join('\n\n'), + notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote), }); await this.activityLog.record( { activityType: 'INVENTORY_RELEASED', inventoryId: id, warehouseId: item.warehouseId, - description: reference - ? `Release order ${reference} sent to customer` - : 'Release order sent to customer', + description: isTruckLeaving + ? reference + ? `Exit paper ${reference} generated` + : 'Exit paper generated' + : reference + ? `Truck arrival ${reference} registered` + : 'Truck arrival registered', performedBy: dto.performedBy, }, manager, @@ -2039,6 +2067,106 @@ export class WarehouseInventoryService { } /** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP β†’ DELIVERED). */ + async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { + const [row] = await this.dataSource.query( + `SELECT inv.id, + COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber", + COALESCE(inv.arrived_at, inv.created_at) AS "receivedAt", + inv.quantity, + inv.weight, + inv.volume, + inv.status, + inv.notes, + b.id AS "bookingId", + b.reference AS "bookingReference", + b.status AS "bookingStatus", + b.freight_type AS "freightType", + b.trade_direction AS "tradeDirection", + b.cargo_total_weight_vgm AS "bookingDeclaredWeight", + company.name AS "customerName", + company.tin AS "customerTin", + service_type.service_name AS "serviceType", + origin_yard.label AS "originYardLabel", + origin_yard.code AS "originYardCode", + destination_yard.label AS "destinationYardLabel", + destination_yard.code AS "destinationYardCode", + COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", + booking_container."containerSummary" AS "bookingContainerSummary", + COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription", + wh.name AS "warehouseName", + wh.code AS "warehouseCode", + yard.name AS "yardName", + yard.code AS "yardCode", + zone.name AS "zoneName", + zone.code AS "zoneCode" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.service_types service_type ON service_type.id = b.service_type_id + LEFT JOIN freight.yards origin_yard ON origin_yard.id = b.origin_yard_id + LEFT JOIN freight.yards destination_yard ON destination_yard.id = b.destination_yard_id + LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id + LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id + LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id + LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL + LEFT JOIN LATERAL ( + SELECT MIN(bc.container_number) AS container_number, + STRING_AGG( + CONCAT_WS(' ', bc.quantity::text, COALESCE(ct.label, ct.code, 'container')), + ', ' + ORDER BY COALESCE(ct.label, ct.code, bc.container_type_id::text) + ) AS "containerSummary" + FROM freight.booking_container bc + LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id + WHERE bc.booking_id = b.id + AND bc.deleted_at IS NULL + ) booking_container ON true + LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL + LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) + WHERE inv.id = $1 AND inv.deleted_at IS NULL + LIMIT 1`, + [id], + ); + if (!row) { + throw new NotFoundException(`Inventory item ${id} not found`); + } + if (!row.grnNumber) { + throw new BadRequestException('GRN number is missing for this inventory item'); + } + + const html = this.buildGrnDocumentHtml({ + grnNumber: row.grnNumber, + receivedAt: row.receivedAt ? new Date(row.receivedAt) : new Date(), + bookingReference: row.bookingReference ?? row.bookingId ?? 'N/A', + bookingStatus: row.bookingStatus ?? null, + customerName: row.customerName ?? null, + customerTin: row.customerTin ?? null, + serviceType: row.serviceType ?? null, + freightType: row.freightType ?? null, + tradeDirection: row.tradeDirection ?? null, + route: [row.originYardLabel ?? row.originYardCode, row.destinationYardLabel ?? row.destinationYardCode] + .filter(Boolean) + .join(' to ') || null, + containerNumber: row.containerNumber ?? null, + bookingContainerSummary: row.bookingContainerSummary ?? null, + cargoDescription: row.cargoDescription ?? null, + quantity: Number(row.quantity ?? 0), + weight: Number(row.weight ?? 0), + volume: row.volume == null ? null : Number(row.volume), + bookingDeclaredWeight: Number(row.bookingDeclaredWeight ?? 0), + warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null, + yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null, + zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null, + inventoryStatus: row.status ?? null, + receiveSummary: this.extractReceiveSummary(row.notes), + }); + + return { + filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, + buffer: await this.releaseDocuments.htmlToPdfBuffer(html), + }; + } + async approveDeliveryForBooking( bookingId: string, userId?: string, @@ -2121,8 +2249,17 @@ export class WarehouseInventoryService { b.status AS "bookingStatus", b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", + b.scheduled_date AS "scheduledDate", + b.cargo_total_weight_vgm AS "bookingDeclaredWeight", + b.last_mile_delivery_address AS "lastMileDeliveryAddress", company.name AS "customerName", + service_type.service_name AS "serviceType", + origin_yard.label AS "originYardLabel", + origin_yard.code AS "originYardCode", + destination_yard.label AS "destinationYardLabel", + destination_yard.code AS "destinationYardCode", COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", + booking_container."containerSummary" AS "bookingContainerSummary", COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription", wh.name AS "warehouseName", wh.code AS "warehouseCode", @@ -2134,14 +2271,25 @@ export class WarehouseInventoryService { FROM freight.warehouse_inventory inv LEFT JOIN freight.bookings b ON b.id = inv.booking_id LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.service_types service_type ON service_type.id = b.service_type_id + LEFT JOIN freight.yards origin_yard ON origin_yard.id = b.origin_yard_id + LEFT JOIN freight.yards destination_yard ON destination_yard.id = b.destination_yard_id LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL - LEFT JOIN freight.booking_container booking_container ON ( - booking_container.booking_id = b.id - AND booking_container.deleted_at IS NULL - ) + LEFT JOIN LATERAL ( + SELECT MIN(bc.container_number) AS container_number, + STRING_AGG( + CONCAT_WS(' ', bc.quantity::text, COALESCE(ct.label, ct.code, 'container')), + ', ' + ORDER BY COALESCE(ct.label, ct.code, bc.container_type_id::text) + ) AS "containerSummary" + FROM freight.booking_container bc + LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id + WHERE bc.booking_id = b.id + AND bc.deleted_at IS NULL + ) booking_container ON true LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL @@ -2158,18 +2306,37 @@ export class WarehouseInventoryService { } const bookingReference = row.bookingReference || row.bookingId || 'N/A'; + const reference = + this.extractHandoverDocumentLine(row.notes, 'Handover Reference') || + `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`; + const generatedAtValue = this.extractHandoverDocumentLine(row.notes, 'Generated At'); + const generatedAt = generatedAtValue ? new Date(generatedAtValue) : new Date(); + const handedOverAt = Number.isNaN(generatedAt.getTime()) ? new Date() : generatedAt; + if (!generatedAtValue) { + await this.inventoryRepository.update(id, { + notes: this.replaceHandoverDocumentNote(row.notes, this.buildHandoverDocumentNote(reference, handedOverAt)), + }); + } + const html = this.buildHandoverDocumentHtml({ - reference: `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`, - handedOverAt: new Date(row.handoverDate ?? Date.now()), + reference, + handedOverAt, bookingReference, bookingStatus: row.bookingStatus ?? null, customerName: row.customerName ?? null, + serviceType: row.serviceType ?? null, freightType: row.freightType ?? null, tradeDirection: row.tradeDirection ?? null, + route: [row.originYardLabel ?? row.originYardCode, row.destinationYardLabel ?? row.destinationYardCode] + .filter(Boolean) + .join(' to ') || null, + scheduledDate: row.scheduledDate ? new Date(row.scheduledDate) : null, containerNumber: row.containerNumber ?? null, + bookingContainerSummary: row.bookingContainerSummary ?? null, cargoDescription: row.cargoDescription ?? null, quantity: Number(row.quantity ?? 0), weight: Number(row.weight ?? 0), + bookingDeclaredWeight: Number(row.bookingDeclaredWeight ?? 0), warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null, yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null, zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null, @@ -2178,11 +2345,12 @@ export class WarehouseInventoryService { releaseOrderReference: row.releaseOrderReference ?? null, releaseDate: row.releaseDate ? new Date(row.releaseDate) : null, trainSchedule: row.trainSchedule ?? null, + lastMileDeliveryAddress: row.lastMileDeliveryAddress ?? null, customerApproval: this.extractCustomerDeliveryApproval(row.notes), }); return { - filename: `handover-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, + filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, buffer: await this.releaseDocuments.htmlToPdfBuffer(html), }; } @@ -2666,6 +2834,128 @@ export class WarehouseInventoryService { return this.findById(id); } + private buildGrnDocumentHtml(data: { + grnNumber: string; + receivedAt: Date; + bookingReference: string; + bookingStatus: string | null; + customerName: string | null; + customerTin: string | null; + serviceType: string | null; + freightType: string | null; + tradeDirection: string | null; + route: string | null; + containerNumber: string | null; + bookingContainerSummary: string | null; + cargoDescription: string | null; + quantity: number; + weight: number; + volume: number | null; + bookingDeclaredWeight: number; + warehouse: string | null; + yard: string | null; + zone: string | null; + inventoryStatus: string | null; + receiveSummary: string | null; + }): string { + const esc = (value: unknown) => + String(value ?? '-') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const receivedAt = data.receivedAt.toLocaleString('en-GB', { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + const rows: Array<[string, unknown]> = [ + ['Booking Reference', data.bookingReference], + ['Customer / Consignee', data.customerName], + ['Customer TIN', data.customerTin], + ['Booking Status', data.bookingStatus], + ['Service Type', data.serviceType], + ['Freight Type', data.freightType], + ['Trade Direction', data.tradeDirection], + ['Route', data.route], + ['Container Number', data.containerNumber], + ['Booking Containers', data.bookingContainerSummary], + ['Cargo / Goods Description', data.cargoDescription], + ['Quantity', data.quantity], + ['Received Weight', `${data.weight.toLocaleString()} kg`], + ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null], + ['Volume', data.volume == null ? null : data.volume.toLocaleString()], + ['Warehouse', data.warehouse], + ['Yard', data.yard], + ['Zone', data.zone], + ['Inventory Status', data.inventoryStatus], + ...(data.receiveSummary ? [['Receive Details', data.receiveSummary] as [string, string]] : []), + ]; + + return ` + + + + Goods Received Note + + + +
+
+
Ethio-Djibouti Railway S.C.
+

Goods Received Note

+
Warehouse receiving confirmation
+
+
+ GRN Number + ${esc(data.grnNumber)} + Received: ${esc(receivedAt)} +
+
+
+
+ This Goods Received Note confirms that the listed goods were received into EDR warehouse custody at the stated location. +
+
Receiving Particulars
+ + + ${rows.map(([label, value]) => ``).join('')} + +
${esc(label)}${esc(value)}
+
Receipt Clause
+
+ This document records warehouse receipt only. Loading, dispatch, release, delivery, customs, and fee clearance remain subject to their respective operational approvals. +
+
+
Warehouse receiver name / signature / date
+
Driver or customer representative name / signature / date
+
+ +`; + } + private buildReleaseDocumentHtml(data: { reference: string; issuedAt: Date; @@ -2721,7 +3011,7 @@ export class WarehouseInventoryService { - Warehouse Gate Clearance / Release Order + Warehouse Release / Exit Paper