From afcae037fc6eb7ce3e5c5af4673cd743ab03ce51 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 27 Jul 2026 11:07:33 +0300 Subject: [PATCH 01/40] refactor: ( bookings ) unify authenticated booking flow with guest service --- .../modules/auth/passenger-auth.service.ts | 21 ++++- .../modules/bookings/bookings.controller.ts | 7 +- .../modules/bookings/guest-booking.service.ts | 84 ++++++++++++++--- .../passengers/passengers.controller.ts | 9 +- .../modules/passengers/passengers.service.ts | 61 ++++++++++++ .../modules/verifayda/verifayda.service.ts | 19 +++- .../src/app/booking/auth-check/page.tsx | 5 +- .../src/app/booking/passengers/page.tsx | 93 ++++++++++++++----- .../portal/src/components/AppSidebar.tsx | 30 +++--- .../portal/src/components/BottomTabBar.tsx | 20 ++-- .../src/modules/intents/intents.service.ts | 18 +++- 11 files changed, 283 insertions(+), 84 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts index 9c962477b..51c57fec8 100644 --- a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts +++ b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts @@ -261,10 +261,26 @@ export class PassengerAuthService { if (!passenger) throw new Error('Passenger not found'); const iam = iamRows[0]; + const meta = iam?.metadata ?? {}; const faydaVerified = iam?.verified_by === 'fayda'; - const nationality = iam?.metadata?.nationality ?? null; + // A Fayda-verified holder is an Ethiopian national ID holder, so default nationality to + // Ethiopian when the metadata doesn't carry it explicitly. + const nationality = meta.nationality ?? (faydaVerified ? 'ETHIOPIAN' : null); + // Fayda stores gender as { am, en }; tolerate a legacy plain string too. + const gender = + meta.gender && typeof meta.gender === 'object' + ? (meta.gender.en ?? meta.gender.am ?? null) + : (meta.gender ?? null); + // birthdate is persisted as ISO by the Fayda upsert; tolerate a "/"-separated legacy value. + const rawDob = meta.dateOfBirth ?? meta.birthdate ?? null; + const dateOfBirth = rawDob ? String(rawDob).replace(/\//g, '-') : null; return { + // The web User object keys on `id` (the IAM user id) — the login response returns it, so + // this profile refresh MUST too, otherwise fetchProfile() overwrites the logged-in user + // with an id-less object and everything guarded on `user.id` (passenger-form prefill, + // save-details userId) silently breaks. + id: iamUserId, iamUserId, // Top-level passengerId keeps the profile shape consistent with the login // response so the web User object always carries it (the JWT does not). @@ -272,8 +288,11 @@ export class PassengerAuthService { email: iam?.email ?? null, phone: iam?.phone_number ?? null, fullName: iam?.name?.en ?? iam?.name?.am ?? null, + gender, + dateOfBirth, nationality, faydaVerified, + faydaSub: meta.sub ?? null, preferredCurrency: resolvePreferredCurrency(nationality, faydaVerified), createdAt: passenger.createdAt, passenger: { diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 88bbe6ec4..46eb88422 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -526,10 +526,13 @@ export class BookingsController { }) @ApiResponse({ status: 404, description: "Schedule or seat hold not found" }) create(@Req() req: any, @Body() dto: CreateBookingDto) { - // Always resolve passengerId from the authenticated JWT — never trust the request body + // Always resolve identity from the authenticated JWT — never trust the request body. + // Routed through the unified GuestBookingService: because req.user.id is present, it + // resolves the existing passenger from the token and layers on the authenticated-only + // behaviours (iam.users contact, loyalty, audit, package inventory, seat-vs-hold guard). const iamUserId = req.user?.id; if (!iamUserId) throw new UnauthorizedException(); - return this.service.create({ ...dto, passengerId: iamUserId }); + return this.guestService.createGuestBooking(dto as unknown as CreateGuestBookingDto, req); } @Get(":id/usage") 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 3e2a225e6..2ed04c176 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 @@ -1,10 +1,13 @@ import { Injectable, BadRequestException, NotFoundException, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; import { SeatsService } from '../seats/seats.service'; import { VerifaydaService } from '../verifayda/verifayda.service'; import { CurrencyService } from '../currency/currency.service'; import { PassengerAuthService } from '../auth/passenger-auth.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; +import { AuditService } from '../../common/audit.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto'; import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; @@ -59,11 +62,13 @@ export class GuestBookingService { constructor( private prisma: PrismaService, + @InjectDataSource() private readonly dataSource: DataSource, private seatsService: SeatsService, private verifaydaService: VerifaydaService, private currencyService: CurrencyService, private passengerAuthService: PassengerAuthService, private fareEngine: FareEngineService, + private auditService: AuditService, private eventEmitter: EventEmitter2, ) { } @@ -107,6 +112,7 @@ export class GuestBookingService { } private async createGuestOneWayBooking(dto: CreateGuestBookingDto, req?: any) { + const authUserId: string | null = req?.user?.id ?? null; // Validate hold const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); if (!hold || hold.expiresAt < new Date()) { @@ -288,6 +294,7 @@ export class GuestBookingService { // Resolve or create the guest Passenger record const firstPassenger = passengersData[0]; const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger, req); + const contact = await this.resolveActorContact(req, firstPassenger); // Save passenger details for future use (if requested) if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) { @@ -329,8 +336,8 @@ export class GuestBookingService { bookingType: 'ONE_WAY', ...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}), userAgent: dto.deviceId, - contactEmail: firstPassenger.email || null, - contactPhone: firstPassenger.phone || null, + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone, seats: { create: passengersWithFares.map((p) => ({ seat: { connect: { id: p.seatId } }, @@ -354,11 +361,18 @@ export class GuestBookingService { }, }); - // Save passenger details as traveler profiles - await this.createTravelerProfiles(guestPassengerId, passengersData); + // Save passenger details as traveler profiles — guest bookings only. + // Authenticated passengers already have a profile, matching the old BookingsService. + if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData); // Confirm seats await this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id)); + + // Authenticated-only side effect: audit the booking creation. + if (authUserId) { + await this.auditService.log({ userId: guestPassengerId, action: 'CREATE', entityType: 'Booking', entityId: booking.id, newData: { bookingRef: booking.bookingRef, bookingType: 'ONE_WAY', totalMinor: resolvedTotalMinor } }); + } + this.eventEmitter.emit('booking.created', { booking }); return { @@ -385,6 +399,7 @@ export class GuestBookingService { } private async createGuestRoundTripBooking(dto: CreateGuestBookingDto, req?: any) { + const authUserId: string | null = req?.user?.id ?? null; if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) { throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP'); } @@ -585,6 +600,7 @@ export class GuestBookingService { // Create or resolve guest passenger (same as one-way) const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req); + const contact = await this.resolveActorContact(req, passengersData[0]); // Create booking with outbound seats; return seats confirmed separately const outboundSeatIds = dto.passengers.map(p => p.seatId).filter((id): id is string => !!id); @@ -613,8 +629,8 @@ export class GuestBookingService { returnLegStatus: 'NEITHER_USED', ...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}), userAgent: dto.deviceId, - contactEmail: passengersData[0]?.email || null, - contactPhone: passengersData[0]?.phone || null, + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone, seats: { create: [ ...passengersWithFares.map((p) => ({ @@ -656,12 +672,19 @@ export class GuestBookingService { }, }); - await this.createTravelerProfiles(guestPassengerId, passengersData); + // Traveler profiles: guest bookings only (authenticated passengers already have one). + if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData); await Promise.all([ this.seatsService.confirmSeats(outboundSeatIds), this.seatsService.confirmSeats(returnSeatIds), ]); + + // Authenticated-only side effect: audit the booking creation. + if (authUserId) { + await this.auditService.log({ userId: guestPassengerId, action: 'CREATE', entityType: 'Booking', entityId: booking.id, newData: { bookingRef: booking.bookingRef, bookingType: 'ROUND_TRIP', totalMinor } }); + } + this.eventEmitter.emit('booking.created', { booking }); return { @@ -687,6 +710,7 @@ export class GuestBookingService { } private async createGuestTransitBooking(dto: CreateGuestBookingDto, req?: any) { + const authUserId: string | null = req?.user?.id ?? null; if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) { throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings'); } @@ -792,6 +816,7 @@ export class GuestBookingService { : totalMinor; const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req); + const contact = await this.resolveActorContact(req, passengersData[0]); // Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2 const booking = await this.prisma.booking.create({ @@ -814,8 +839,8 @@ export class GuestBookingService { leg2DestinationStationId: dto.leg2DestinationStationId, leg2SeatClassId: leg2SeatClassId, userAgent: dto.deviceId, - contactEmail: passengersData[0]?.email || null, - contactPhone: passengersData[0]?.phone || null, + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone, seats: { create: [ ...passengersData.map(p => ({ @@ -857,7 +882,8 @@ export class GuestBookingService { }, }); - await this.createTravelerProfiles(guestPassengerId, passengersData); + // Traveler profiles: guest bookings only (authenticated passengers already have one). + if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData); await Promise.all([ this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id)), @@ -883,6 +909,7 @@ export class GuestBookingService { } private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto, req?: any) { + const authUserId: string | null = req?.user?.id ?? null; if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId || !dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId || !dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) { @@ -994,6 +1021,7 @@ export class GuestBookingService { : totalMinor; const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req); + const contact = await this.resolveActorContact(req, passengersData[0]); const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: number) => ({ seat: { connect: { id: seatId } }, @@ -1035,8 +1063,8 @@ export class GuestBookingService { returnLeg2SeatClassId: retL2ClassId, returnLegStatus: 'NEITHER_USED', userAgent: dto.deviceId, - contactEmail: passengersData[0]?.email || null, - contactPhone: passengersData[0]?.phone || null, + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone, seats: { create: [ ...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)), @@ -1052,7 +1080,8 @@ export class GuestBookingService { }, }); - await this.createTravelerProfiles(guestPassengerId, passengersData); + // Traveler profiles: guest bookings only (authenticated passengers already have one). + if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData); await Promise.all([ this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId).filter((id): id is string => !!id)), @@ -1081,11 +1110,40 @@ export class GuestBookingService { }; } + /** + * Resolves the booking contact. Authenticated callers get their contact from iam.users + * (matching the old BookingsService.resolveIamContact); guests fall back to the first + * passenger's inline phone/email exactly as before. + */ + private async resolveActorContact( + req: any, + firstPassenger: any, + ): Promise<{ contactEmail: string | null; contactPhone: string | null }> { + const iamUserId = req?.user?.id; + if (iamUserId) { + const rows = await this.dataSource.query<{ email: string; phone_number: string | null }[]>( + `SELECT email, phone_number FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ); + return { contactEmail: rows[0]?.email ?? null, contactPhone: rows[0]?.phone_number ?? null }; + } + return { contactEmail: firstPassenger?.email || null, contactPhone: firstPassenger?.phone || null }; + } + private async resolveGuestPassenger( dto: Pick, firstPassenger: any, req?: any, ): Promise<{ guestPassengerId: string; iamUserId: string | null; createdAccount: boolean }> { + // Authenticated caller: resolve the existing passenger from the JWT subject. + // Never trust a client-supplied passengerId — identity comes from the token only. + const authUserId = req?.user?.id; + if (authUserId) { + const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId: authUserId }, select: { id: true } }); + if (!passenger) throw new NotFoundException('Passenger profile not found for this account'); + return { guestPassengerId: passenger.id, iamUserId: authUserId, createdAccount: false }; + } + if (dto.createAccount && firstPassenger.email && dto.password) { const guestName = firstPassenger.passengerName ?? 'Guest'; const result = await this.passengerAuthService.registerWithPassword( diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts index 20de8ec23..e6c4d2fbc 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -254,11 +254,10 @@ export class PassengersController { } try { - const passenger = await this.prisma.passenger.findUnique({ - where: { iamUserId: req.user.id }, - }); - if (!passenger) return null; - return this.service.getProfile(passenger.id); + // Identity (name, DOB, gender, nationality, Fayda status) lives on the IAM user record, + // not the Passenger row — return the full booking-form payload built from it so an + // already-verified passenger's form can prefill and lock. See getMyProfile. + return await this.service.getMyProfile(req.user.id); } catch (error) { return null; } diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index 742b7ae89..6792a8d76 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -22,6 +22,7 @@ type IamUserRow = { name: { en: string; am: string } | null; phone_number: string | null; metadata: Record | null; + verified_by?: string | null; }; @Injectable() @@ -237,6 +238,66 @@ export class PassengersService { }; } + /** + * Full passenger-form payload for the logged-in user, sourced from the IAM user record + * (iam.users) — where the Fayda-verified identity actually lives — rather than the sparse + * Passenger row. The booking passenger form (/booking/passengers) calls this via + * GET /passengers/me to prefill (and lock) an already-verified passenger's details. + * + * Identity fields don't depend on a Passenger row existing; only `id` (used later to tag the + * primary passenger on the booking) does, and it's null if no Passenger row is linked yet. + */ + async getMyProfile(iamUserId: string) { + const [passenger, iamRows] = await Promise.all([ + this.prisma.passenger.findUnique({ where: { iamUserId } }), + this.dataSource.query( + `SELECT id, email, name, phone_number, metadata, verified_by FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ), + ]); + + const iam = iamRows[0] ?? null; + if (!iam && !passenger) return null; + + const meta = iam?.metadata ?? {}; + const faydaVerified = + iam?.verified_by === 'fayda' || + meta.faydaVerified === true || + meta.faydaVerified === 'true'; + + // Fayda writes gender as { am, en }; older/manual records may store a plain string. + const gender = + meta.gender && typeof meta.gender === 'object' + ? (meta.gender.en ?? meta.gender.am ?? null) + : (meta.gender ?? null); + + const fullName = iam?.name?.en ?? iam?.name?.am ?? null; + + // Stored as ISO by the Fayda upsert; tolerate a "/"-separated legacy value. + const rawDob = meta.dateOfBirth ?? meta.birthdate ?? null; + const dateOfBirth = rawDob ? String(rawDob).replace(/\//g, '-') : null; + + // Nationality isn't always in metadata; a Fayda-verified holder is Ethiopian by definition. + const nationality = meta.nationality ?? (faydaVerified ? 'ETHIOPIAN' : null); + + return { + id: passenger?.id ?? null, + fullName, + email: iam?.email ?? meta.email ?? null, + phone: iam?.phone_number ?? meta.phoneNumber ?? null, + gender, + dateOfBirth, + nationality, + faydaVerified, + faydaSub: meta.sub ?? null, + passportNumber: meta.passportNumber ?? null, + passportCountry: meta.passportCountry ?? null, + passportIssueDate: meta.passportIssueDate ?? null, + passportExpiryDate: meta.passportExpiryDate ?? null, + passportIssuingAuthority: meta.passportIssuingAuthority ?? null, + }; + } + async getStats(passengerId: string) { const [totalTrips, totalSpendResult, loyalty] = await Promise.all([ this.prisma.booking.count({ where: { passengerId, status: 'BOARDED' as any } }), diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts index 89edf25a6..df6cfc21d 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts @@ -512,17 +512,30 @@ export class VerifaydaService { gender: { am: normalized.genderAm ?? '', en: normalized.genderEn ?? '' }, name: { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' }, phoneNumber: normalized.rawPhoneNumber ?? '', + // Persist the identity fields the passenger booking form needs. Fayda returns + // these on every verification but they were previously dropped, leaving the + // logged-in/verified form with nothing to prefill. birthdate arrives as + // YYYY/MM/DD — store it as the ISO YYYY-MM-DD the form expects. A Fayda-verified + // holder is an Ethiopian national ID holder, so nationality is always Ethiopian. + dateOfBirth: normalized.birthdate ? normalized.birthdate.replace(/\//g, '-') : '', + nationality: 'ETHIOPIAN', }; - // Step 1 — already linked to this Fayda sub; ensure verified_by is set + // Step 1 — already linked to this Fayda sub; refresh metadata (backfills the newly + // persisted dateOfBirth/nationality for users linked before this change) and ensure + // verified_by is set. const bySub = await this.dataSource.query<{ id: string }[]>( `SELECT id FROM iam.users WHERE metadata->>'sub' = $1 LIMIT 1`, [normalized.sub], ); if (bySub.length > 0) { await this.dataSource.query( - `UPDATE iam.users SET verified_by = 'fayda', updated_at = NOW() WHERE id = $1`, - [bySub[0].id], + `UPDATE iam.users + SET metadata = COALESCE(metadata, '{}'::jsonb) || $1::jsonb, + verified_by = 'fayda', + updated_at = NOW() + WHERE id = $2`, + [JSON.stringify(iamMetadata), bySub[0].id], ); return { iamUserId: bySub[0].id, userDataSaved: true }; } diff --git a/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx index e38c07d90..e8f2d850e 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx @@ -4,8 +4,7 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/lib/auth-store'; import { useBookingStore } from '@/lib/booking-store'; -import { UserPlus, ChevronLeft } from 'lucide-react'; -// import { LogIn } from 'lucide-react'; // TODO: re-enable auth — used by commented-out SignIn/Register button +import { UserPlus, ChevronLeft, LogIn } from 'lucide-react'; function Tooltip({ children, content }: { children: React.ReactNode; content: string[] }) { const [visible, setVisible] = useState(false); @@ -96,7 +95,6 @@ export default function AuthCheckPage() { - {/* TODO: re-enable auth — SignIn or Register button commented out until auth integration - */}
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 b29a4b102..60cd350a2 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 @@ -933,27 +933,60 @@ function PassengersForm() { } try { - // Fetch passenger profile from backend - const passengerData: any = await apiClient.get(`/passengers/me`); + // Fetch passenger profile from backend. This may be null (e.g. no Passenger row linked + // yet) — in that case fall back to the `user` object, which /auth/profile hydrates from + // the same IAM record (name, gender, DOB, nationality, Fayda status). Merging the two + // means an already-verified passenger's form still prefills from whichever source has + // the data, rather than being left blank. + const passengerData: any = (await apiClient.get(`/passengers/me`)) || {}; - if (!passengerData) { + const pick = (a: any, b: any) => (a !== undefined && a !== null && a !== '' ? a : b); + + // Nationality for THIS booking is the one chosen at search — the passengers-page + // nationality field is read-only. It, not the account's stored nationality, decides + // whether the Fayda gate applies, so a logged-in user who picked "Other"/"Djiboutian" + // isn't wrongly forced into Fayda (Fayda is only for Ethiopian nationals). + const nationality = searchCriteria?.nationality || pick(passengerData.nationality, user.nationality) || 'ETHIOPIAN'; + const isEthiopian = String(nationality).toUpperCase() === 'ETHIOPIAN'; + const isVerified = Boolean(pick(passengerData.faydaVerified, user.faydaVerified)); + + // A logged-in but NOT Fayda-verified Ethiopian must pass the Fayda gate exactly like a + // guest. Prefilling their identity and expanding the form would let them submit the + // booking without ever verifying — only a verified passenger may pass. When Fayda is + // globally disabled there is no gate, so the restriction doesn't apply. + const mustVerifyFayda = isEthiopian && !isVerified && faydaEnabled; + + // Nationality + contact aren't identity-verifying, so they're safe to prefill either way. + setValue('passengers.0.nationality', nationality); + const phoneVal = pick(passengerData.phone, user.phone); + if (phoneVal) setValue('passengers.0.phone', phoneVal); + const emailVal = pick(passengerData.email, user.email); + if (emailVal) setValue('passengers.0.email', emailVal); + + if (mustVerifyFayda) { + // Force the Fayda gate: leave name/DOB/gender empty and keep the form collapsed so the + // "Verify with Fayda" screen is shown instead of an editable, pre-filled form. + setValue('passengers.0.faydaVerified', false); + setValue('passengers.0.formExpanded', false); setFormInitialized(true); return; } - // Only populate first passenger - setValue('passengers.0.name', passengerData?.fullName || user.fullName || ''); - setValue('passengers.0.dateOfBirth', passengerData?.dateOfBirth || user.dateOfBirth || ''); - if (passengerData?.gender || user.gender) setValue('passengers.0.gender', (passengerData?.gender || user.gender) as any); - setValue('passengers.0.nationality', passengerData?.nationality || user.nationality || 'ETHIOPIAN'); - if (passengerData?.phone || user.phone) setValue('passengers.0.phone', passengerData?.phone || user.phone || ''); - if (passengerData?.email || user.email) setValue('passengers.0.email', passengerData?.email || user.email || ''); - if (passengerData?.passportNumber) setValue('passengers.0.passportNumber', passengerData.passportNumber); - setValue('passengers.0.passportCountry', passengerData?.passportCountry || (searchCriteria?.nationality === 'DJIBOUTIAN' ? 'Djibouti' : '')); - if (passengerData?.passportIssueDate) setValue('passengers.0.passportIssueDate', passengerData.passportIssueDate); - if (passengerData?.passportExpiryDate) setValue('passengers.0.passportExpiryDate', passengerData.passportExpiryDate); - if (passengerData?.passportIssuingAuthority) setValue('passengers.0.passportIssuingAuthority', passengerData.passportIssuingAuthority); - setValue('passengers.0.faydaVerified', passengerData?.faydaVerified || user.faydaVerified || false); + // Verified Ethiopian, or a non-Ethiopian (passport flow): prefill everything and expand. + setValue('passengers.0.name', pick(passengerData.fullName, user.fullName) || ''); + setValue('passengers.0.dateOfBirth', pick(passengerData.dateOfBirth, user.dateOfBirth) || ''); + const genderVal = pick(passengerData.gender, user.gender); + if (genderVal) setValue('passengers.0.gender', genderVal as any); + const passportNumberVal = pick(passengerData.passportNumber, user.passportNumber); + if (passportNumberVal) setValue('passengers.0.passportNumber', passportNumberVal); + setValue('passengers.0.passportCountry', pick(passengerData.passportCountry, user.passportCountry) || (searchCriteria?.nationality === 'DJIBOUTIAN' ? 'Djibouti' : '')); + const passportIssueVal = pick(passengerData.passportIssueDate, user.passportIssueDate); + if (passportIssueVal) setValue('passengers.0.passportIssueDate', passportIssueVal); + const passportExpiryVal = pick(passengerData.passportExpiryDate, user.passportExpiryDate); + if (passportExpiryVal) setValue('passengers.0.passportExpiryDate', passportExpiryVal); + const passportAuthVal = pick(passengerData.passportIssuingAuthority, user.passportIssuingAuthority); + if (passportAuthVal) setValue('passengers.0.passportIssuingAuthority', passportAuthVal); + setValue('passengers.0.faydaVerified', isVerified); setValue('passengers.0.formExpanded', true); setFormInitialized(true); @@ -1126,10 +1159,21 @@ function PassengersForm() { // Identity fields sourced from a completed Fayda verification are locked — the // passenger can't edit the verified name / date of birth / gender. const isFaydaLocked = !!passengers[index]?.faydaVerified; + // ...but lock each field only when it actually carries a value. A verified profile + // can be missing a field (e.g. a Fayda *login* record whose metadata has no date of + // birth) — locking an empty, required input would strand the user with no way to + // fill it or submit. A missing field stays editable so they can complete it. + const isNameLocked = isFaydaLocked && !!passengers[index]?.name; + const isDobLocked = isFaydaLocked && !!passengers[index]?.dateOfBirth; + const isGenderLocked = isFaydaLocked && !!passengers[index]?.gender; // Contact fields lock only when Fayda actually supplied them; a value Fayda left // blank stays editable so the passenger can add their own phone/email. - const isPhoneLocked = !!passengers[index]?.faydaPhoneLocked; - const isEmailLocked = !!passengers[index]?.faydaEmailLocked; + // A logged-in, already-verified primary passenger's contact details also come from + // their verified profile — lock those too, alongside name/DOB/gender. Only lock a + // field that actually has a value, so an incomplete profile can't strand the user + // on an unfillable required field. + const isPhoneLocked = !!passengers[index]?.faydaPhoneLocked || (isLoggedInAndVerified && !!passengers[index]?.phone); + const isEmailLocked = !!passengers[index]?.faydaEmailLocked || (isLoggedInAndVerified && !!passengers[index]?.email); return (
@@ -1227,8 +1271,8 @@ function PassengersForm() { {errors.passengers?.[index]?.name && ( @@ -1244,14 +1288,14 @@ function PassengersForm() { onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })} error={errors.passengers?.[index]?.dateOfBirth?.message} passengerType={isChildPassenger ? 'CHILD' : 'ADULT'} - disabled={isFaydaLocked} + disabled={isDobLocked} />
{/* Gender */}
- {isFaydaLocked ? ( + {isGenderLocked ? ( setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })} error={errors.passengers?.[index]?.dateOfBirth?.message} passengerType={isChildPassenger ? 'CHILD' : 'ADULT'} - disabled={isFaydaLocked} + disabled={isDobLocked} />
{/* Gender */}
- {isFaydaLocked ? ( + {isGenderLocked ? ( setValue(`passengers.${index}.phone`, v)} onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })} error={errors.passengers?.[index]?.phone?.message} + disabled={isPhoneLocked} />
diff --git a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx index 08874193a..0acb5d9ea 100644 --- a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx +++ b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx @@ -192,22 +192,20 @@ export default function AppSidebar() { )}
) : ( - // TODO: re-enable auth — Sign in / Register links commented out until auth integration - //
- // - // Sign in - // - // - // Register - // - //
- null +
+ + Sign in + + + Register + +
)} diff --git a/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx b/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx index 8017f3087..ee4cdccad 100644 --- a/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx +++ b/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx @@ -1,10 +1,9 @@ 'use client'; -import { Home, Phone, Ticket } from 'lucide-react'; -// import { User } from 'lucide-react'; // TODO: re-enable auth — used by commented-out Sign in tab +import { Home, Phone, Ticket, User } from 'lucide-react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -// import { useAuthStore } from '@/lib/auth-store'; // TODO: re-enable auth +import { useAuthStore } from '@/lib/auth-store'; // The linear, one-screen-at-a-time booking flow — each of these pages already // has its own sticky mobile CTA bar (and the mobile step strip at the top), @@ -22,7 +21,7 @@ const LINEAR_FLOW_PREFIXES = [ export default function BottomTabBar() { const pathname = usePathname() ?? ''; - // const isAuthenticated = useAuthStore((s) => s.isAuthenticated); // TODO: re-enable auth + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isInLinearFlow = LINEAR_FLOW_PREFIXES.some((p) => pathname.startsWith(p)); if (isInLinearFlow) return null; @@ -31,13 +30,12 @@ export default function BottomTabBar() { { href: '/', label: 'Home', icon: Home, match: (p: string) => p === '/' }, { href: '/booking/lookup', label: 'Bookings', icon: Ticket, match: (p: string) => p.startsWith('/booking/lookup') || p.startsWith('/booking/detail') }, { href: '/contact', label: 'Contact', icon: Phone, match: (p: string) => p.startsWith('/contact') }, - // TODO: re-enable auth — auth login/register tab commented out until auth integration - // { - // href: isAuthenticated ? '/profile' : '/login', - // label: isAuthenticated ? 'Account' : 'Sign in', - // icon: User, - // match: (p: string) => p.startsWith('/profile') || p.startsWith('/login') || p.startsWith('/register'), - // }, + { + href: isAuthenticated ? '/profile' : '/login', + label: isAuthenticated ? 'Account' : 'Sign in', + icon: User, + match: (p: string) => p.startsWith('/profile') || p.startsWith('/login') || p.startsWith('/register'), + }, ]; return ( diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index c75edef1f..ec334195b 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -296,9 +296,10 @@ export class IntentsService { * expiresAt, so opening a fresh session would leave two concurrently-payable * sessions and invite a double charge (observed in prod: a superseded Telebirr * session was paid after cancellation, orphaning the capture). - * - Expired, or the requested amount/currency changed: retired (CANCELLED, no - * notification — nothing was paid; a payment.failed here would wrongly fail the - * domain order mid-retry) and null is returned so the caller opens a fresh session. + * - Expired, or the requested amount/currency or platform (web↔mobile) changed: + * retired (CANCELLED, no notification — nothing was paid; a payment.failed here + * would wrongly fail the domain order mid-retry) and null is returned so the caller + * opens a fresh session with the correct amount/clientAction for the new platform. * * When the status query itself errors, the existing intent is reused unchanged: * superseding blind could leave two live sessions and a double charge. @@ -332,8 +333,15 @@ export class IntentsService { const chargeChanged = intent.amountMinor !== request.amountMinor || intent.currency !== request.currency; + // A web↔mobile switch needs a different clientAction shape (e.g. Telebirr: + // REDIRECT for web vs LAUNCH_APP for the native app), so reusing the stored + // session would hand the payer the wrong launch method and break the return. + // Detect the stored session's platform from its clientAction and retire on a switch. + const storedIsMobileLaunch = intent.clientAction?.type === "LAUNCH_APP"; + const requestedMobile = (request.platform ?? "web") === "mobile"; + const platformChanged = storedIsMobileLaunch !== requestedMobile; - if (!expired && !chargeChanged) { + if (!expired && !chargeChanged && !platformChanged) { this.logger.log( `intent ${intent.id} reused (live ${intent.provider} session, unpaid, not expired) for ` + `${request.service}/${request.referenceType}/${request.referenceId}`, @@ -346,7 +354,7 @@ export class IntentsService { failureCode: expired ? "EXPIRED" : "SUPERSEDED", failureMessage: expired ? "Provider session expired before the payer acted" - : "Payer re-initiated with a changed amount; previous session superseded", + : "Payer re-initiated with a changed amount or platform; previous session superseded", }); this.logger.log( `intent ${intent.id} retired (${expired ? "EXPIRED" : "SUPERSEDED"}) — fresh session will be opened`, From 500668a415101658ec36df28b4ffb65ea94a843c Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 27 Jul 2026 08:05:12 +0000 Subject: [PATCH 02/40] =?UTF-8?q?Truck=20detention=20was=20timed=20once=20?= =?UTF-8?q?per=20delivery,=20so=20every=20truck=20on=20a=20multi-truck=20l?= =?UTF-8?q?ast=20mile=20was=20billed=20the=20same=20number=20of=20days=20r?= =?UTF-8?q?egardless=20of=20when=20it=20actually=20arrived=20or=20was=20re?= =?UTF-8?q?leased.=20Each=20truck=20now=20carries=20its=20own=20detention?= =?UTF-8?q?=20clock=20(destination=20arrival=20=E2=86=92=20release)=20with?= =?UTF-8?q?=20its=20own=20rule=20match,=20chargeable=20days=20and=20amount?= =?UTF-8?q?;=20the=20modal=20records=20and=20displays=20the=20window=20per?= =?UTF-8?q?=20truck,=20and=20the=20summary=20shows=20the=20longest=20deten?= =?UTF-8?q?tion=20plus=20the=20combined=20total.=20Also=20corrected=20a=20?= =?UTF-8?q?false=20no=20detention=20rule=20matches=20warning=20that=20appe?= =?UTF-8?q?ared=20whenever=20more=20than=20one=20truck=20was=20assigned.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...860000000000-AddPerTruckDetentionWindow.ts | 35 +++ .../last-mile/dto/set-detention-times.dto.ts | 29 ++ .../last-mile-vehicle-assignment.entity.ts | 15 ++ .../modules/last-mile/last-mile.controller.ts | 13 + .../modules/last-mile/last-mile.service.ts | 40 +++ .../warehouses/per-truck-detention.spec.ts | 82 ++++++ .../warehouse-fee.bulk-quantity.spec.ts | 4 + .../warehouses/warehouse-fee.service.ts | 111 +++++--- .../operations/TruckDetentionModal.tsx | 249 ++++++++++++++---- .../src/services/last-mile.service.ts | 12 + .../backoffice/src/types/warehouse.ts | 8 +- 11 files changed, 512 insertions(+), 86 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2860000000000-AddPerTruckDetentionWindow.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile/dto/set-detention-times.dto.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/per-truck-detention.spec.ts diff --git a/apps/edr-freight-api/src/migrations/2860000000000-AddPerTruckDetentionWindow.ts b/apps/edr-freight-api/src/migrations/2860000000000-AddPerTruckDetentionWindow.ts new file mode 100644 index 000000000..710ad12ad --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2860000000000-AddPerTruckDetentionWindow.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-truck detention clocks. Detention was timed once per last-mile leg + * (last_mile.arrived_at / delivered_at), so every truck on a multi-truck + * delivery shared one window and was billed identical days — wrong the moment + * two trucks arrive or return at different times. + * + * Deliberately NEW columns rather than reusing the existing per-truck + * arrived_at / departed_at on this table: those are WAREHOUSE gate-in/gate-out + * events stamped by release(), whereas detention runs from arrival at the + * DESTINATION until the truck is released/returned. + * + * Both nullable — a truck without its own window falls back to the leg-level + * timestamps, so legacy legs keep billing exactly as before. + */ +export class AddPerTruckDetentionWindow2860000000000 implements MigrationInterface { + name = 'AddPerTruckDetentionWindow2860000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + ADD COLUMN IF NOT EXISTS destination_arrived_at timestamptz, + ADD COLUMN IF NOT EXISTS returned_at timestamptz; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + DROP COLUMN IF EXISTS returned_at, + DROP COLUMN IF EXISTS destination_arrived_at; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/set-detention-times.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/set-detention-times.dto.ts new file mode 100644 index 000000000..9f103e15b --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/set-detention-times.dto.ts @@ -0,0 +1,29 @@ +import { Type } from 'class-transformer'; +import { IsArray, IsDateString, IsOptional, IsUUID, ValidateNested } from 'class-validator'; + +/** + * One truck's detention window. Each truck reaches the destination and is + * released at its own time, so detention days differ between trucks on the + * same delivery. Null clears the value (falls back to the leg-level pair). + */ +export class TruckDetentionTimeInput { + @IsUUID() + vehicleId!: string; + + /** Detention clock start — this truck reached the destination. */ + @IsOptional() + @IsDateString() + destinationArrivedAt?: string | null; + + /** Detention clock end — this truck was released/returned. Omit = still out. */ + @IsOptional() + @IsDateString() + returnedAt?: string | null; +} + +export class SetDetentionTimesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TruckDetentionTimeInput) + trucks!: TruckDetentionTimeInput[]; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts index e57c16b8a..f2b4e2467 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts @@ -51,6 +51,21 @@ export class LastMileVehicleAssignment extends BaseEntity { @Column({ name: 'departed_at', type: 'timestamptz', nullable: true }) departedAt?: Date | null; + /** + * Detention clock START for THIS truck: reached the delivery destination. + * Distinct from `arrivedAt` (warehouse gate-in). Null falls back to the + * leg-level `last_mile.arrived_at`. + */ + @Column({ name: 'destination_arrived_at', type: 'timestamptz', nullable: true }) + destinationArrivedAt?: Date | null; + + /** + * Detention clock END for THIS truck: released / returned by the customer. + * Null (with no leg-level `delivered_at`) means still out — detention accrues. + */ + @Column({ name: 'returned_at', type: 'timestamptz', nullable: true }) + returnedAt?: Date | null; + /** Weighed gross on exit, in TONNES (not kg — see the migration note). */ @Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) grossWeightTons?: number | null; 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 29e857e2f..e8fa57cdc 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 @@ -23,6 +23,7 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { SetVehiclesDto } from './dto/set-vehicles.dto'; +import { SetDetentionTimesDto } from './dto/set-detention-times.dto'; import { SetDistancesDto } from './dto/set-distances.dto'; import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto'; import { LastMileStatus } from './entities/last-mile.entity'; @@ -131,6 +132,18 @@ export class LastMileController { return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment); } + @Post(':id/detention-times') + @BookingStaff(FREIGHT_PERMS.lastMile.update) + @ApiOperation({ + summary: 'Set each truck\'s own detention window (arrived at destination / returned)', + }) + async setDetentionTimes( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetDetentionTimesDto, + ) { + return this.lastMileService.setDetentionTimes(id, dto.trucks); + } + @Post(':id/proof-of-delivery') @BookingStaff(FREIGHT_PERMS.lastMile.update) @UseInterceptors(AnyFilesInterceptor()) 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 601e03aec..d7c45bb3c 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 @@ -869,6 +869,46 @@ export class LastMileService { * sum and drives billing; `remainingPayment` (total km × rate) is recomputed * client-side. Does NOT generate an invoice — that's a separate explicit step. */ + /** + * Per-truck detention windows. Each truck reaches the destination and is + * released at its own time, so every truck gets its own clock (and therefore + * its own chargeable days). Locked once the detention invoice exists. + */ + async setDetentionTimes( + id: string, + trucks: Array<{ + vehicleId: string; + destinationArrivedAt?: string | null; + returnedAt?: string | null; + }>, + ): Promise { + await this.findById(id); + + const invoices = await this.billing.findBySourceIds('last_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Detention times cannot be changed after the invoice is generated', + ); + } + + for (const t of trucks) { + const start = t.destinationArrivedAt ? new Date(t.destinationArrivedAt) : null; + const end = t.returnedAt ? new Date(t.returnedAt) : null; + if (start && end && end.getTime() < start.getTime()) { + throw new BadRequestException( + 'A truck cannot be returned before it arrived — check the detention times', + ); + } + await this.dataSource.manager.update( + LastMileVehicleAssignment, + { lastMileId: id, vehicleId: t.vehicleId }, + { destinationArrivedAt: start, returnedAt: end }, + ); + } + + return this.findById(id); + } + async setDistances( id: string, distances: Array<{ vehicleId: string; distanceKm: number }>, diff --git a/apps/edr-freight-api/src/modules/warehouses/per-truck-detention.spec.ts b/apps/edr-freight-api/src/modules/warehouses/per-truck-detention.spec.ts new file mode 100644 index 000000000..471b82965 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/per-truck-detention.spec.ts @@ -0,0 +1,82 @@ +import { WarehouseFeeService } from './warehouse-fee.service'; + +/** + * Detention is per truck: two trucks on the same delivery with different + * windows must produce different chargeable days and amounts (the old + * leg-level clock billed them identically). + */ +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; + +const svc = Object.create(WarehouseFeeService.prototype) as { + computeTruckDetention: ( + rule: Record | null, + row: { arrivedAt: Date | string | null; deliveredAt: Date | string | null; truckCount: number }, + now: Date, + billingCurrency: string, + ) => Promise<{ chargeableDays: number; billableUnits: number; amount: number; endIsOpen: boolean }>; + normalizeCurrency: (c?: string | null) => string; + convertAmount: (a: number, from: string, to: string) => Promise; + calculateTieredAmount: unknown; +}; +svc.normalizeCurrency = (c) => (c ? String(c).toUpperCase() : 'USD'); +svc.convertAmount = async (a) => a; + +// 3h grace, 50/truck/day, no tiers. +const rule = { freeHours: 3, ratePerDay: 50, currency: 'USD', id: 'r1', name: 'Detention', tiers: [] }; +const now = new Date('2026-07-25T12:00:00Z'); + +describe('per-truck detention', () => { + it('bills each truck on its own window', async () => { + // Truck A: out ~1 day past grace. Truck B: out ~3 days past grace. + const a = await svc.computeTruckDetention( + rule, + { + arrivedAt: new Date(now.getTime() - DAY - 4 * HOUR), + deliveredAt: now, + truckCount: 1, + }, + now, + 'USD', + ); + const b = await svc.computeTruckDetention( + rule, + { + arrivedAt: new Date(now.getTime() - 3 * DAY - 4 * HOUR), + deliveredAt: now, + truckCount: 1, + }, + now, + 'USD', + ); + + expect(a.chargeableDays).toBe(2); + expect(b.chargeableDays).toBe(4); + expect(a.amount).toBe(100); + expect(b.amount).toBe(200); + // The whole point: same delivery, different bills. + expect(a.amount).not.toBe(b.amount); + }); + + it('charges nothing inside the grace window', async () => { + const out = await svc.computeTruckDetention( + rule, + { arrivedAt: new Date(now.getTime() - 2 * HOUR), deliveredAt: now, truckCount: 1 }, + now, + 'USD', + ); + expect(out.chargeableDays).toBe(0); + expect(out.amount).toBe(0); + }); + + it('keeps accruing against now when a truck has not returned', async () => { + const out = await svc.computeTruckDetention( + rule, + { arrivedAt: new Date(now.getTime() - 2 * DAY), deliveredAt: null, truckCount: 1 }, + now, + 'USD', + ); + expect(out.endIsOpen).toBe(true); + expect(out.chargeableDays).toBe(2); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts index aa4d5e7b1..e23c6d1f8 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts @@ -37,6 +37,10 @@ describe('WarehouseFeeService bulk quantity billing', () => { inventoryWeight: 25, bookingContainerCount: 0, cargoUnitOfMeasure: null, + // Double handling now bills only when staff answered Yes after unloading; + // these quantity-basis cases assume that answer (the gate itself is covered + // in double-handling-gate.spec.ts). + doubleHandling: true, facilityId: null, warehouseId: null, yardId: null, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index 0d8c6549f..aa30b31e7 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -92,10 +92,20 @@ export interface FeePreview { ratePerDay: number; amount: number; }>; - /** Truck detention: per-vehicle-type breakdown — each truck-type group billed by its own matching rule. */ + /** + * Truck detention: one row PER TRUCK — each truck has its own detention + * window (it arrives and is released at its own time) and its own matching + * rule by truck type, so days and amount differ between trucks. + */ groups?: Array<{ + assignmentId: string | null; + vehicleId: string | null; + plateNumber: string | null; vehicleType: string | null; truckCount: number; + startDate: string | null; + endDate: string | null; + endIsOpen: boolean; chargeableDays: number; ratePerDay: number; amount: number; @@ -827,25 +837,48 @@ export class WarehouseFeeService { }; } - // Group the leg's vehicles by CANONICAL truck type so each type is billed - // by its own matching rule (rates differ by truck type). The FK to - // truck_types is the source of truth — renaming a type's label no longer - // silently unmatches its rule; the normalized legacy vehicle_type code is - // only a fallback for vehicles without the FK (LEFT JOIN keeps them billed - // instead of dropping them). Falls back to one untyped group. - const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> = - await this.dataSource.query( - `SELECT COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType", - count(*)::int AS "truckCount" - FROM freight.last_mile_vehicle_assignments va - JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL - LEFT JOIN freight.truck_types t - ON t.id = v.truck_type_id AND t.deleted_at IS NULL - WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL - GROUP BY 1`, - [lastMileId], - ); - const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }]; + // One row PER TRUCK: each truck has its own detention window (it reaches the + // destination and is released at its own time) and resolves its own rule by + // CANONICAL truck type — the truck_types FK is the source of truth, with the + // normalized legacy vehicle_type code as fallback so FK-less vehicles keep + // billing. Per-truck timestamps fall back to the leg-level pair for legacy + // legs recorded before per-truck tracking. + const truckRows: Array<{ + assignmentId: string; + vehicleId: string; + plateNumber: string | null; + vehicleType: string | null; + startAt: Date | string | null; + endAt: Date | string | null; + }> = await this.dataSource.query( + `SELECT va.id AS "assignmentId", + va.vehicle_id AS "vehicleId", + COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber", + COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType", + COALESCE(va.destination_arrived_at, $2::timestamptz) AS "startAt", + COALESCE(va.returned_at, $3::timestamptz) AS "endAt" + FROM freight.last_mile_vehicle_assignments va + JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL + LEFT JOIN freight.truck_types t + ON t.id = v.truck_type_id AND t.deleted_at IS NULL + WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL + ORDER BY va.created_at ASC`, + [lastMileId, leg.arrivedAt ?? null, leg.deliveredAt ?? null], + ); + // No trucks assigned yet: keep the leg-level single-truck estimate so the + // preview still tells the operator what detention would cost. + const trucks = truckRows.length + ? truckRows + : [ + { + assignmentId: null as string | null, + vehicleId: null as string | null, + plateNumber: null as string | null, + vehicleType: null as string | null, + startAt: leg.arrivedAt ?? null, + endAt: leg.deliveredAt ?? null, + }, + ]; const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE'); @@ -853,7 +886,7 @@ export class WarehouseFeeService { const targetCurrency = this.normalizeCurrency(billingCurrency); const computed = await Promise.all( - groups.map(async (g) => { + trucks.map(async (t) => { const item: ItemAttributes = { arrivedAt: null, gateClearedAt: null, @@ -862,7 +895,7 @@ export class WarehouseFeeService { tradeDirection: leg.tradeDirection ?? null, cargoTypeCode: null, containerTypeCode: null, - vehicleType: g.vehicleType ?? null, + vehicleType: t.vehicleType ?? null, inventoryQuantity: 1, inventoryWeight: 0, bookingContainerCount: 1, @@ -875,37 +908,47 @@ export class WarehouseFeeService { zoneId: null, }; const rule = this.bestRule(detentionRules, item); + // truckCount 1 — this row IS one truck. const c = await this.computeTruckDetention( rule, - { arrivedAt: leg.arrivedAt, deliveredAt: leg.deliveredAt, truckCount: g.truckCount }, + { arrivedAt: t.startAt, deliveredAt: t.endAt, truckCount: 1 }, now, billingCurrency, ); - return { vehicleType: g.vehicleType ?? null, truckCount: Math.max(1, Math.round(Number(g.truckCount) || 1)), c }; + return { ...t, c }; }), ); const totalAmount = Math.round(computed.reduce((s, x) => s + x.c.amount, 0) * 100) / 100; - const totalTrucks = computed.reduce((s, x) => s + x.truckCount, 0); + const totalTrucks = computed.length; const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0); - const chargeableDays = computed[0]?.c.chargeableDays ?? 0; + // Header days: the worst truck — a single number can't represent per-truck + // windows, and the longest detention is the one operations must act on. + const chargeableDays = computed.reduce((m, x) => Math.max(m, x.c.chargeableDays), 0); const single = computed.length === 1 ? computed[0].c : null; const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? null; + const earliestStart = computed + .map((x) => (x.startAt ? new Date(x.startAt).getTime() : null)) + .filter((n): n is number => n != null) + .sort((a, b) => a - b)[0]; + const anyOpen = computed.some((x) => x.c.endIsOpen); return { ruleType: 'TRUCK_DETENTION_FEE', basis: null, unitLabel: 'truck', ruleId: single?.ruleId ?? null, - ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck-type rules' : anyRuleName, + ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck rules' : anyRuleName, freeDays: 0, ratePerDay: single?.ratePerDay ?? 0, currency: targetCurrency, ruleCurrency: single?.ruleCurrency ?? null, billingCurrency: targetCurrency, - startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null, - endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : now).toISOString(), - endIsOpen: !leg.deliveredAt, + startDate: earliestStart != null ? new Date(earliestStart).toISOString() : null, + endDate: (anyOpen ? now : new Date(Math.max( + ...computed.map((x) => (x.endAt ? new Date(x.endAt).getTime() : now.getTime())), + ))).toISOString(), + endIsOpen: anyOpen, elapsedDays: chargeableDays, chargeableDays, containerCount: totalTrucks, @@ -913,8 +956,14 @@ export class WarehouseFeeService { amount: totalAmount, tiers: single ? single.tiers : [], groups: computed.map((x) => ({ + assignmentId: x.assignmentId, + vehicleId: x.vehicleId, + plateNumber: x.plateNumber, vehicleType: x.vehicleType, - truckCount: x.truckCount, + truckCount: 1, + startDate: x.startAt ? new Date(x.startAt).toISOString() : null, + endDate: x.c.endDate, + endIsOpen: x.c.endIsOpen, chargeableDays: x.c.chargeableDays, ratePerDay: x.c.ratePerDay, amount: x.c.amount, diff --git a/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx b/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx index c9748d80a..934832c34 100644 --- a/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx @@ -43,21 +43,56 @@ function Stat({ label, value, strong }: { label: string; value: React.ReactNode; ); } +type TruckRow = { + vehicleId: string; + label: string; + arrived: Date | null; + returned: Date | null; +}; + +const plateOf = (a: NonNullable[number]) => + [a.vehicle?.code, a.vehicle?.plateNumber].filter(Boolean).join(' · ') || a.vehicleId; + /** - * View/override the detention clock (arrival + delivery/return) for a last-mile - * leg, preview the per-truck-per-day charge, and generate the detention invoice. + * Detention is PER TRUCK: every truck reaches the destination and is released at + * its own time, so each row carries its own clock, days and amount. Legs with no + * trucks assigned fall back to the single leg-level window. */ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionModalProps) { const { toast } = useToast(); const qc = useQueryClient(); const id = record?.id ?? null; + const assignments = record?.vehicleAssignments ?? []; + const perTruck = assignments.length > 0; + + const [rows, setRows] = useState([]); + // Leg-level fallback (no trucks assigned yet). const [arrived, setArrived] = useState(null); const [delivered, setDelivered] = useState(null); useEffect(() => { + setRows( + assignments.map((a) => ({ + vehicleId: a.vehicleId, + label: plateOf(a), + // Fall back to the leg-level pair so a truck without its own window + // shows what it is actually being billed on today. + arrived: a.destinationArrivedAt + ? new Date(a.destinationArrivedAt) + : record?.arrivedAt + ? new Date(record.arrivedAt) + : null, + returned: a.returnedAt + ? new Date(a.returnedAt) + : record?.deliveredAt + ? new Date(record.deliveredAt) + : null, + })), + ); setArrived(record?.arrivedAt ? new Date(record.arrivedAt) : null); setDelivered(record?.deliveredAt ? new Date(record.deliveredAt) : null); - }, [record?.id, record?.arrivedAt, record?.deliveredAt, opened]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [record?.id, record?.arrivedAt, record?.deliveredAt, assignments.length, opened]); const previewQuery = useQuery({ queryKey: ['truck-detention-preview', id], @@ -65,19 +100,35 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM enabled: opened && Boolean(id), }); const preview = previewQuery.data; + // With several trucks the header rule is null by design (each truck resolves + // its own) — only warn when NO truck matched a rule. + const hasAnyRule = Boolean(preview?.ruleId) || (preview?.groups ?? []).some((g) => g.ruleId); + const byVehicle = new Map((preview?.groups ?? []).map((g) => [g.vehicleId ?? '', g])); const saveTimes = useMutation({ mutationFn: () => - lastMileService.update(id as string, { - arrivedAt: arrived ? arrived.toISOString() : null, - deliveredAt: delivered ? delivered.toISOString() : null, - }), + perTruck + ? lastMileService.setDetentionTimes( + id as string, + rows.map((r) => ({ + vehicleId: r.vehicleId, + destinationArrivedAt: r.arrived ? r.arrived.toISOString() : null, + returnedAt: r.returned ? r.returned.toISOString() : null, + })), + ) + : lastMileService.update(id as string, { + arrivedAt: arrived ? arrived.toISOString() : null, + deliveredAt: delivered ? delivered.toISOString() : null, + }), onSuccess: () => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); void previewQuery.refetch(); toast({ title: 'Detention times saved' }); }, - onError: () => toast({ title: 'Save failed', variant: 'destructive' }), + onError: (e: unknown) => { + const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast({ title: 'Save failed', description, variant: 'destructive' }); + }, }); const generate = useMutation({ @@ -93,12 +144,40 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM }, }); + const handleSave = () => { + const values = perTruck + ? rows.flatMap((r) => [r.arrived, r.returned]) + : [arrived, delivered]; + // No backdating: detention times are recorded as they happen. + if (values.some((v) => isBackdated(v))) { + toast({ variant: 'destructive', title: 'Detention times cannot be in the past' }); + return; + } + const reversed = perTruck + ? rows.find((r) => r.arrived && r.returned && r.returned < r.arrived) + : arrived && delivered && delivered < arrived + ? { label: 'this delivery' } + : undefined; + if (reversed) { + toast({ + variant: 'destructive', + title: 'Return time is before arrival', + description: `Check the times for ${reversed.label}.`, + }); + return; + } + saveTimes.mutate(); + }; + + const patchRow = (vehicleId: string, patch: Partial) => + setRows((prev) => prev.map((r) => (r.vehicleId === vehicleId ? { ...r, ...patch } : r))); + return ( Truck detention{record?.booking?.reference ? ` · ${record.booking.reference}` : ''} @@ -106,40 +185,94 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM } > - - setArrived(v ? new Date(v) : null)} - minDate={new Date()} - clearable - /> - setDelivered(v ? new Date(v) : null)} - minDate={new Date()} - clearable - /> - + {perTruck ? ( + + + Each truck has its own detention clock — record when it reached the destination and + when it was released. Days and charges are calculated per truck. + + {rows.map((r) => { + const g = byVehicle.get(r.vehicleId); + return ( + + + + + {r.label} + + {g?.vehicleType && ( + + {g.vehicleType} + + )} + + {g && ( + + + {g.chargeableDays} day{g.chargeableDays === 1 ? '' : 's'} + {g.endIsOpen ? ' · still out' : ''} + + + {money(g.amount, preview?.currency ?? 'USD')} + + + )} + + + patchRow(r.vehicleId, { arrived: v ? new Date(v) : null })} + minDate={new Date()} + clearable + /> + patchRow(r.vehicleId, { returned: v ? new Date(v) : null })} + minDate={new Date()} + clearable + /> + + {g && !g.ruleId && ( + + No detention rule matches this truck type — it will not be billed. + + )} + + ); + })} + + ) : ( + <> + + No trucks assigned yet — this records the delivery-level detention window. Assign + trucks to track each one separately. + + + setArrived(v ? new Date(v) : null)} + minDate={new Date()} + clearable + /> + setDelivered(v ? new Date(v) : null)} + minDate={new Date()} + clearable + /> + + + )} - @@ -154,7 +287,7 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM No preview available. - ) : !preview.ruleId ? ( + ) : !hasAnyRule ? ( No active Truck Detention rule matches this booking. Create one under Warehouse → Fee rules (rule type "Truck Detention Cost"). @@ -162,39 +295,47 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM ) : ( - + - + {preview.endIsOpen && ( - Still accruing — no delivery/return time yet. The amount grows until the vehicle is returned. + Still accruing — at least one truck has no release time yet. The amount grows until + every truck is returned. )} - {preview.groups && preview.groups.length > 1 ? ( + {preview.groups && preview.groups.length > 0 ? ( - Truck type - Trucks + Truck + Type Days - Rate / truck / day + Rate / day Amount {preview.groups.map((g, i) => ( - + - {g.vehicleType ?? 'Unknown'} + {g.plateNumber ?? 'Unassigned'} {!g.ruleId && ( {' '}· no rule )} - {g.truckCount} - {g.chargeableDays} + {g.vehicleType ?? 'Unknown'} + + {g.chargeableDays} + {g.endIsOpen && ( + + {' '}· open + + )} + {money(g.ratePerDay, preview.currency)} {money(g.amount, preview.currency)} diff --git a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts index 16e02abf5..91e95ef2f 100644 --- a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts @@ -75,6 +75,9 @@ export interface LastMileRecord { /** Per-truck arrival / exit, stamped by the warehouse weighing steps. */ arrivedAt?: string | null; departedAt?: string | null; + /** This truck's own detention window (destination arrival → released). */ + destinationArrivedAt?: string | null; + returnedAt?: string | null; grossWeightTons?: number | null; netWeightTons?: number | null; vehicle?: LastMileVehicle | null; @@ -143,4 +146,13 @@ export const lastMileService = { /** Preview the truck-detention charge for a last-mile leg. */ truckDetentionPreview: (id: string) => api.get(`${LM.BASE}/${id}/truck-detention-preview`), + /** Per-truck detention windows — each truck has its own clock. */ + setDetentionTimes: ( + id: string, + trucks: Array<{ + vehicleId: string; + destinationArrivedAt?: string | null; + returnedAt?: string | null; + }>, + ) => api.post(`${LM.BASE}/${id}/detention-times`, { trucks }), }; diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index c38d9b40d..24713f738 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -856,10 +856,16 @@ export interface FeePreview { billableUnits: number; amount: number; tiers?: FeePreviewTier[]; - /** Truck detention: per-vehicle-type breakdown. */ + /** Truck detention: one row per truck — each has its own window and rule. */ groups?: Array<{ + assignmentId?: string | null; + vehicleId?: string | null; + plateNumber?: string | null; vehicleType: string | null; truckCount: number; + startDate?: string | null; + endDate?: string | null; + endIsOpen?: boolean; chargeableDays: number; ratePerDay: number; amount: number; From 93615158abed9e0efab68b77e4f48e8d5dbafdca Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 27 Jul 2026 11:27:21 +0300 Subject: [PATCH 03/40] fix: ( booking ) remove duplicated auditservice --- .../src/modules/bookings/guest-booking.service.ts | 2 -- 1 file changed, 2 deletions(-) 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 9be3e8c4b..238d58d82 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 @@ -10,7 +10,6 @@ import { FareEngineService } from '../fare-engine/fare-engine.service'; import { AuditService } from '../../common/audit.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { PaymentsService } from '../payments/payments.service'; -import { AuditService } from '../../common/audit.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { CreateGuestBookingDto, SavedPassengerProfileDto, IssueReservationBookingDto, ReservationBookingKind } from './guest-booking.dto'; import { Currency, PassengerCategory, IdDocumentType, PaymentMethodType, PaymentIntentStatus } from '@prisma/client'; @@ -99,7 +98,6 @@ export class GuestBookingService { private auditService: AuditService, private eventEmitter: EventEmitter2, private paymentsService: PaymentsService, - private auditService: AuditService, private smsClient: SmsClientService, ) { } From 101bf69271f3814631c19a82a49f6cae274485c7 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 27 Jul 2026 09:55:15 +0000 Subject: [PATCH 04/40] feat(warehouses): map GRN numbers to the goods owner GRN--- carried no owner, so a note couldn't be identified by who owns the cargo. Add an owner segment sourced from the booking's company at every generation point (import, export, facility, manual receive), keep REF8 for uniqueness, and label the GRN document row Owner's Name. --- .../src/common/grn.util.spec.ts | 40 +++++ apps/edr-freight-api/src/common/grn.util.ts | 34 +++- ...990000000000-IndodeYardsAndCargoRouting.ts | 131 +++++++++++++++ .../facility-handling.service.ts | 2 + .../dto/create-warehouse-yard.dto.ts | 27 +++- .../entities/warehouse-yard.entity.ts | 31 +++- .../warehouses/scheduling-read.facade.ts | 7 + .../warehouses/warehouse-inventory.service.ts | 61 +++++-- .../warehouses/warehouse-yards.repository.ts | 19 ++- .../warehouses/warehouse-yards.service.ts | 14 +- .../warehouses/ReceiveInventoryModal.tsx | 81 ++++++++-- .../src/components/warehouses/options.test.ts | 153 ++++++++++++++++++ .../src/components/warehouses/options.ts | 54 +++++++ .../src/pages/warehouses/ArrivalQueuePage.tsx | 89 +++++++--- .../backoffice/src/types/warehouse.ts | 11 ++ 15 files changed, 690 insertions(+), 64 deletions(-) create mode 100644 apps/edr-freight-api/src/common/grn.util.spec.ts create mode 100644 apps/edr-freight-api/src/migrations/2990000000000-IndodeYardsAndCargoRouting.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/options.test.ts diff --git a/apps/edr-freight-api/src/common/grn.util.spec.ts b/apps/edr-freight-api/src/common/grn.util.spec.ts new file mode 100644 index 000000000..d95c934ca --- /dev/null +++ b/apps/edr-freight-api/src/common/grn.util.spec.ts @@ -0,0 +1,40 @@ +import { generateGrnNumber, grnOwnerSlug } from './grn.util'; + +/** + * The GRN is mapped to the goods owner for BOTH directions, so a note is + * identifiable by who owns the cargo. The reference slice stays the uniqueness + * anchor — one owner can have several bookings received the same day. + */ +const date = new Date('2026-07-27T09:15:00Z'); +const bookingId = '1a2b3c4d-1111-2222-3333-444455556666'; + +describe('GRN number', () => { + it('maps an import GRN to the owner', () => { + expect(generateGrnNumber('IMPORT', bookingId, date, 'Shafici Pharmaceutical')).toBe( + 'GRN-IMPORT-20260727-SHAFICIPHARM-1A2B3C4D', + ); + }); + + it('maps an export GRN to the owner the same way', () => { + expect(generateGrnNumber('EXPORT', bookingId, date, 'Tria Trading PLC')).toBe( + 'GRN-EXPORT-20260727-TRIATRADINGP-1A2B3C4D', + ); + }); + + it('keeps the owner-less format when there is no owner (manual walk-in)', () => { + expect(generateGrnNumber('WH', bookingId, date)).toBe('GRN-WH-20260727-1A2B3C4D'); + expect(generateGrnNumber('WH', bookingId, date, ' ')).toBe('GRN-WH-20260727-1A2B3C4D'); + }); + + it('stays unique per booking for one owner on one day', () => { + const a = generateGrnNumber('IMPORT', bookingId, date, 'Acme'); + const b = generateGrnNumber('IMPORT', 'ffffffff-9999-0000-0000-000000000000', date, 'Acme'); + expect(a).not.toBe(b); + }); + + it('strips punctuation and caps the owner segment', () => { + expect(grnOwnerSlug('Ethio-Djibouti Railway S.C.')).toBe('ETHIODJIBOUT'); + expect(grnOwnerSlug('a/b c')).toBe('ABC'); + expect(grnOwnerSlug(null)).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/common/grn.util.ts b/apps/edr-freight-api/src/common/grn.util.ts index 5cae30302..128e0496a 100644 --- a/apps/edr-freight-api/src/common/grn.util.ts +++ b/apps/edr-freight-api/src/common/grn.util.ts @@ -1,13 +1,41 @@ /** - * Goods Received Note number: `GRN---`. + * Goods Received Note number: `GRN----`. + * + * The GRN is mapped to the goods OWNER (the booking's customer / consignee) for + * both import and export, so a note is identifiable by who owns the cargo + * without opening it. The trailing reference slice stays as the uniqueness + * anchor — one owner can have several bookings received on the same day. + * Owner-less receipts (manual walk-ins with no booking) fall back to the + * original `GRN---` form. * * Shared so a GRN raised at a load/unload facility is indistinguishable from one * raised in a warehouse — the two live in different tables * (facility_handling_events vs warehouse_inventory), and a second generator would * eventually let their formats drift apart. */ -export function generateGrnNumber(direction: string, referenceId: string, date: Date): string { +export function generateGrnNumber( + direction: string, + referenceId: string, + date: Date, + ownerName?: string | null, +): string { const stamp = date.toISOString().slice(0, 10).replace(/-/g, ''); const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase(); - return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`; + const owner = grnOwnerSlug(ownerName); + const base = `GRN-${direction.toUpperCase()}-${stamp}`; + return owner ? `${base}-${owner}-${suffix}` : `${base}-${suffix}`; +} + +/** + * Owner name → GRN-safe token: letters/digits only, upper-cased, capped so a + * long company name can't run away with the number. Null when there is nothing + * usable, which drops the segment rather than emitting an empty `--`. + */ +export function grnOwnerSlug(ownerName?: string | null): string | null { + const slug = (ownerName ?? '') + .normalize('NFKD') + .replace(/[^a-zA-Z0-9]+/g, '') + .toUpperCase() + .slice(0, 12); + return slug || null; } diff --git a/apps/edr-freight-api/src/migrations/2990000000000-IndodeYardsAndCargoRouting.ts b/apps/edr-freight-api/src/migrations/2990000000000-IndodeYardsAndCargoRouting.ts new file mode 100644 index 000000000..d89e0c04b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2990000000000-IndodeYardsAndCargoRouting.ts @@ -0,0 +1,131 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Indode's real 11-yard layout, plus the plumbing to auto-route a booking to + * the right yard by cargo type (and, for container yards, trade direction): + * + * - `warehouse_yards.direction` — IMPORT | EXPORT | BOTH | null. Only + * meaningful for CONTAINER_YARD, where import and export stacks are + * physically separate (Yard 5 vs Yard 6). Everything else takes cargo + * either way. A CONTAINER_YARD left at null/BOTH is a signal too: it means + * "not a customer cargo yard" — Yards 10/11 (service/equipment) are + * CONTAINER_YARD structurally but must never be offered for ordinary + * import/export cargo, so the frontend match requires an EXACT IMPORT/ + * EXPORT direction hit for container freight rather than treating BOTH as + * a wildcard. + * - `warehouse_yard_cargo_types` — which cargo types a yard accepts (mirrors + * the existing `cargo_type_wagon_types` join table). Empty = open to any + * cargo type of the yard's structural type (additive, never restrictive + * by default), so this cannot break a yard nobody has configured yet. + * + * Three cargo types didn't exist yet (Fertilizer, Coffee, Tea) — added here + * so Yards 1 and 9 have a real mapping ready for when they reopen. + */ +export class IndodeYardsAndCargoRouting2990000000000 implements MigrationInterface { + name = "IndodeYardsAndCargoRouting2990000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_yards + ADD COLUMN IF NOT EXISTS direction varchar(10) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_yard_cargo_types ( + yard_id uuid NOT NULL REFERENCES freight.warehouse_yards (id) ON DELETE CASCADE, + cargo_type_id uuid NOT NULL REFERENCES freight.cargo_types (id) ON DELETE CASCADE, + PRIMARY KEY (yard_id, cargo_type_id) + ) + `); + + // New cargo types Indode's yard list names but the catalog didn't have yet. + await queryRunner.query(` + INSERT INTO freight.cargo_types (code, cargo_type_name, unit_of_measure, is_active) + VALUES + ('FERTILIZER', 'Fertilizer', 'PER_TON', true), + ('COFFEE', 'Coffee', 'PER_TON', true), + ('TEA', 'Tea', 'PER_TON', true) + ON CONFLICT (code) DO NOTHING + `); + + // The 11 real yards at Indode Open Warehouse (code 'IOW'). + await queryRunner.query(` + INSERT INTO freight.warehouse_yards + (warehouse_id, name, code, type, direction, status, is_active) + SELECT w.id, y.name, y.code, y.type, y.direction, y.status, y.status = 'ACTIVE' + FROM freight.warehouses w + CROSS JOIN (VALUES + ('Y1', 'Bagged Cargo Discharge - Fertilizer', 'BULK_YARD', NULL, 'INACTIVE'), + ('Y2', 'Break Bulk', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'), + ('Y3', 'Ro-Ro / Pac', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'), + ('Y4', 'Dry Bulk', 'BULK_YARD', NULL, 'INACTIVE'), + ('Y5', 'Container Terminal - Import (Stack Area)', 'CONTAINER_YARD', 'IMPORT', 'ACTIVE'), + ('Y6', 'Container Terminal - Export', 'CONTAINER_YARD', 'EXPORT', 'ACTIVE'), + ('Y7', 'Cold Chain', 'COLD_STORAGE_YARD', NULL, 'INACTIVE'), + ('Y8', 'Chemical', 'HAZARDOUS_YARD', NULL, 'INACTIVE'), + ('Y9', 'Coffee and Tea', 'GENERAL_CARGO_YARD', NULL, 'INACTIVE'), + ('Y10', 'Container Service Yard - Maintenance', 'CONTAINER_YARD', 'BOTH', 'ACTIVE'), + ('Y11', 'Equipment (Empty Container)', 'CONTAINER_YARD', 'BOTH', 'ACTIVE') + ) AS y(code, name, type, direction, status) + WHERE w.code = 'IOW' + ON CONFLICT (warehouse_id, code) DO NOTHING + `); + + // One default zone per new yard, matching its yard's type — every existing + // yard (CY-1, CY-A) already follows this one-zone-per-yard shape. + await queryRunner.query(` + INSERT INTO freight.warehouse_zones (yard_id, name, code, type, status, is_active) + SELECT y.id, y.name || ' Zone 1', 'Z1', + CASE y.type + WHEN 'CONTAINER_YARD' THEN 'CONTAINER_ZONE' + WHEN 'COLD_STORAGE_YARD' THEN 'COLD_STORAGE_ZONE' + WHEN 'HAZARDOUS_YARD' THEN 'HAZARDOUS_ZONE' + WHEN 'BULK_YARD' THEN 'BULK_ZONE' + ELSE 'GENERAL_CARGO_ZONE' + END, + y.status, y.status = 'ACTIVE' + FROM freight.warehouse_yards y + JOIN freight.warehouses w ON w.id = y.warehouse_id + WHERE w.code = 'IOW' AND y.code LIKE 'Y%' + ON CONFLICT (yard_id, code) DO NOTHING + `); + + // Cargo-type routing. Yards 5/6/10/11 (CONTAINER_YARD) are intentionally + // left with no rows — direction alone decides those, per the entity comment. + await queryRunner.query(` + INSERT INTO freight.warehouse_yard_cargo_types (yard_id, cargo_type_id) + SELECT y.id, ct.id + FROM freight.warehouses w + JOIN freight.warehouse_yards y ON y.warehouse_id = w.id + JOIN (VALUES + ('Y1', 'FERTILIZER'), + ('Y2', 'STEEL_BILLET'), ('Y2', 'PLASTIC_BARREL'), ('Y2', 'MACHINERY'), ('Y2', 'LIVESTOCK'), + ('Y3', 'AUTOMOBILE'), ('Y3', 'TRUCK'), + ('Y4', 'BARLY'), ('Y4', 'BEANS'), ('Y4', 'BULK'), ('Y4', 'CEREAL'), + ('Y4', 'EDIBLE_OIL'), ('Y4', 'RICE'), ('Y4', 'SUGAR'), ('Y4', 'WHEAT'), + ('Y7', 'PERISHABLE'), + ('Y9', 'COFFEE'), ('Y9', 'TEA') + ) AS m(yard_code, cargo_code) ON m.yard_code = y.code + JOIN freight.cargo_types ct ON ct.code = m.cargo_code + WHERE w.code = 'IOW' + ON CONFLICT (yard_id, cargo_type_id) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM freight.warehouse_zones z + USING freight.warehouse_yards y, freight.warehouses w + WHERE z.yard_id = y.id AND y.warehouse_id = w.id + AND w.code = 'IOW' AND y.code LIKE 'Y%' + `); + await queryRunner.query(` + DELETE FROM freight.warehouse_yards y + USING freight.warehouses w + WHERE y.warehouse_id = w.id AND w.code = 'IOW' AND y.code LIKE 'Y%' + `); + // Cargo types and the join table are left in place — other data may have + // started referencing them since; dropping columns/tables is not reversible + // once real rows exist, and leaving them is harmless. + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts index 20ac4859a..4fe20dbf5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts @@ -48,10 +48,12 @@ export class FacilityHandlingService { if (!facility?.hasFacility) return null; const occurredAt = input.occurredAt ?? new Date(); + // Mapped to the goods owner, same as every warehouse-raised GRN. const grnNumber = generateGrnNumber( booking.tradeDirection ?? 'DOMESTIC', booking.id, occurredAt, + booking.company?.name ?? null, ); // Link the storage record when this facility keeps cargo — that link is diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts index 56b9d0810..39a90ef8e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts @@ -1,7 +1,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsArray, IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; -import { WAREHOUSE_YARD_TYPES, WarehouseYardType } from '../entities/warehouse-yard.entity'; +import { + WAREHOUSE_YARD_DIRECTIONS, + WAREHOUSE_YARD_TYPES, + WarehouseYardDirection, + WarehouseYardType, +} from '../entities/warehouse-yard.entity'; export class CreateWarehouseYardDto { @ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' }) @@ -46,4 +51,22 @@ export class CreateWarehouseYardDto { @IsNumber() @Min(0) maxVolume?: number; + + @ApiPropertyOptional({ + enum: WAREHOUSE_YARD_DIRECTIONS, + description: 'Trade direction this yard serves. Only meaningful for CONTAINER_YARD — omit/BOTH for everything else.', + }) + @IsOptional() + @IsEnum(WAREHOUSE_YARD_DIRECTIONS) + direction?: WarehouseYardDirection; + + @ApiPropertyOptional({ + type: [String], + format: 'uuid', + description: 'Cargo types this yard accepts. Empty/omitted = open to any cargo type of this yard\'s structural type.', + }) + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + cargoTypeIds?: string[]; } diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts index 6e3c93292..5169f486a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts @@ -1,6 +1,7 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm'; +import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; import { Warehouse } from './warehouse.entity'; import { WarehouseZone } from './warehouse-zone.entity'; @@ -16,6 +17,15 @@ export type WarehouseYardType = (typeof WAREHOUSE_YARD_TYPES)[number]; export const WAREHOUSE_YARD_STATUSES = ['ACTIVE', 'INACTIVE'] as const; export type WarehouseYardStatus = (typeof WAREHOUSE_YARD_STATUSES)[number]; +/** + * Which trade direction this yard serves. Only meaningful for CONTAINER_YARD, + * where import and export stacks are physically separate areas (e.g. Indode's + * Yard 5 for import vs Yard 6 for export) — every other yard type takes cargo + * either way, so BOTH/null is the right default there. + */ +export const WAREHOUSE_YARD_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const; +export type WarehouseYardDirection = (typeof WAREHOUSE_YARD_DIRECTIONS)[number]; + @Entity({ schema: 'freight', name: 'warehouse_yards' }) @Index(['warehouseId']) @Index(['type']) @@ -64,6 +74,25 @@ export class WarehouseYard extends BaseEntity { @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; + /** Null = BOTH (no direction restriction). Only relevant for CONTAINER_YARD. */ + @Column({ name: 'direction', type: 'varchar', length: 10, nullable: true }) + direction?: WarehouseYardDirection | null; + + /** + * Cargo types this yard accepts — e.g. Yard 3 (Ro-Ro) takes Automobile/Truck, + * Yard 9 (Coffee and Tea) takes only those two. Empty/no rows = open to any + * cargo type of the yard's structural `type` (the pre-existing behavior), + * so this is additive and never blocks a yard that hasn't been configured. + */ + @ManyToMany(() => CargoType) + @JoinTable({ + name: 'warehouse_yard_cargo_types', + schema: 'freight', + joinColumn: { name: 'yard_id', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'cargo_type_id', referencedColumnName: 'id' }, + }) + cargoTypes?: CargoType[]; + @OneToMany(() => WarehouseZone, (zone) => zone.yard) zones?: WarehouseZone[]; } diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index fd967cc8c..59f4b5478 100644 --- a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -17,6 +17,8 @@ export interface ImportTrainRow { route: string | null; origin: string | null; destination: string | null; + /** freight.yards.id the train is heading to — lets the frontend restrict the unload warehouse picker to the warehouse actually at this station, instead of listing every warehouse. */ + destinationStationId: string | null; arrivalTime: string | null; totalBookings: number; totalContainers: number; @@ -38,6 +40,8 @@ export interface ImportTrainItemRow { freightType: string | null; containerNumber: string | null; cargoType: string | null; + /** Cargo type CODE (e.g. "WHEAT"), for matching against a yard's configured cargo types — `cargoType` above is the display name. */ + cargoTypeCode: string | null; weight: number | null; arrivalTime: string | null; currentStatus: string | null; @@ -205,6 +209,7 @@ export class SchedulingReadFacade { ts.train_number AS "trainNumber", oy.code AS "origin", dy.code AS "destination", + dy.id AS "destinationStationId", oy.country AS "originCountry", dy.country AS "destinationCountry", COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", @@ -280,6 +285,7 @@ export class SchedulingReadFacade { WHERE c.booking_id = b.id AND c.deleted_at IS NULL ORDER BY c.container_number LIMIT 1) AS "containerNumber", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", + cgt.code AS "cargoTypeCode", b.cargo_total_weight_vgm AS "weight", COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", COALESCE(inv.status, b.status) AS "currentStatus", @@ -376,6 +382,7 @@ export class SchedulingReadFacade { ts.train_number AS "trainNumber", oy.code AS "origin", dy.code AS "destination", + dy.id AS "destinationStationId", dy.label AS "destinationName", oy.country AS "originCountry", dy.country AS "destinationCountry", 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 275cf08dc..c0b3cc060 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 @@ -1141,6 +1141,8 @@ export class WarehouseInventoryService { async autoUnloadArrived(): Promise { const arrived: { id: string; + /** Goods owner (company) — the GRN number is mapped to it. */ + customer: string | null; weight: string | null; freightType: string | null; tradeDirection: string | null; @@ -1158,10 +1160,12 @@ export class WarehouseInventoryService { WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL) ) AS weight, b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", - cgt.code AS "cargoTypeCode" + cgt.code AS "cargoTypeCode", + company.name AS customer FROM freight.bookings b LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.companies company ON company.id = b.company_id WHERE b.status = ANY($1) AND b.deleted_at IS NULL AND inv.id IS NULL`, [this.ARRIVED_BOOKING_STATUSES], ); @@ -1198,7 +1202,7 @@ export class WarehouseInventoryService { status: 'RECEIVED', arrivedAt: new Date(), ...(booking.tradeDirection === 'EXPORT' - ? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) } + ? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date(), booking.customer) } : {}), notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue', }); @@ -1223,12 +1227,19 @@ export class WarehouseInventoryService { // A GRN is the receipt for cargo entering the warehouse, so every booking // gets one on unload — import as well as export. The direction only decides // the GRN prefix, not whether one is issued. - const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query( - `SELECT trade_direction AS "tradeDirection" - FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, - [bookingId], - ); + // The GRN is mapped to the goods owner (the booking's company), so pull it + // alongside the direction rather than issuing an owner-less number. + const [bookingRow]: Array<{ tradeDirection: string | null; ownerName: string | null }> = + await this.dataSource.query( + `SELECT b.trade_direction AS "tradeDirection", + company.name AS "ownerName" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + WHERE b.id = $1 AND b.deleted_at IS NULL`, + [bookingId], + ); const grnDirection = bookingRow?.tradeDirection ?? 'WH'; + const ownerName = bookingRow?.ownerName ?? null; let location: DefaultLocation | null = dto.warehouseId && dto.yardId && dto.zoneId @@ -1252,7 +1263,7 @@ export class WarehouseInventoryService { // Keep an already-issued GRN rather than reissuing; mint one otherwise. ...(existing[0].grnNumber ? {} - : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }), + : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName) }), notes: dto.notes ?? existing[0].notes ?? 'Unloaded', }); return this.findById(existing[0].id); @@ -1267,7 +1278,7 @@ export class WarehouseInventoryService { weight: 0, status: 'RECEIVED', arrivedAt, - grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt), + grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName), notes: dto.notes ?? 'Unloaded', }); return this.findById(saved.id); @@ -1577,7 +1588,7 @@ export class WarehouseInventoryService { } const now = new Date(); - const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now); + const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer); const truckEntrance = dto.truckEntrance ? this.mergeSystemTruckEntrance(dto.truckEntrance, booking) : undefined; @@ -2143,6 +2154,8 @@ export class WarehouseInventoryService { const bookings: { id: string; status: string; + /** Goods owner (company) — the GRN number is mapped to it. */ + customer: string | null; weight: string | null; freightType: string | null; tradeDirection: string | null; @@ -2165,10 +2178,12 @@ export class WarehouseInventoryService { WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL) ) AS weight, b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", - cgt.code AS "cargoTypeCode" + cgt.code AS "cargoTypeCode", + company.name AS customer FROM freight.train_schedule_bookings tsb JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.companies company ON company.id = b.company_id WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL AND b.destination_yard_id = $2`, [scheduleId, schedule.destinationStationId], @@ -2235,7 +2250,7 @@ export class WarehouseInventoryService { unloadedAt: now, arrivedAt: existing.arrivedAt ?? now, // Import GRN is issued automatically at train unload. - ...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now) }), + ...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer) }), }); await this.activityLog.record({ activityType: 'INVENTORY_UNLOADED', @@ -2285,7 +2300,7 @@ export class WarehouseInventoryService { quantity: 1, weight: Number(booking.weight) || 0, status: 'UNLOADED', - grnNumber: this.generateGrnNumber('IMPORT', booking.id, now), + grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer), arrivedAt: now, unloadedAt: now, notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train', @@ -2719,7 +2734,12 @@ export class WarehouseInventoryService { this.assertCapacity('Zone', zone, weight, volume, containerCount); const now = new Date(); - const grnNumber = this.generateGrnNumber(bookingDirection ?? 'WH', dto.bookingId ?? 'MANUAL', now); + const grnNumber = this.generateGrnNumber( + bookingDirection ?? 'WH', + dto.bookingId ?? 'MANUAL', + now, + truckEntrance?.ownerName ?? bookingSource?.customer, + ); const receiveNote = this.buildReceiveNote({ grnNumber, notes: dto.notes?.trim() || 'Single booking received', @@ -5350,7 +5370,9 @@ export class WarehouseInventoryService { }); const rows: Array<[string, unknown]> = [ ['Booking Reference', data.bookingReference], - ['Customer / Consignee', data.customerName], + // The GRN is mapped to the owner (import: consignee, export: shipper) — + // named explicitly so the note reads the same for both directions. + ["Owner's Name", data.customerName], ['Customer TIN', data.customerTin], ['Booking Status', data.bookingStatus], ['Service Type', data.serviceType], @@ -5981,8 +6003,13 @@ export class WarehouseInventoryService { } /** Shared with the facility handling flow — see common/grn.util.ts. */ - private generateGrnNumber(direction: string, referenceId: string, date: Date): string { - return generateGrnNumber(direction, referenceId, date); + private generateGrnNumber( + direction: string, + referenceId: string, + date: Date, + ownerName?: string | null, + ): string { + return generateGrnNumber(direction, referenceId, date, ownerName); } private async generateReleaseReference(item: WarehouseInventory): Promise { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts index 99bbdd21f..41f6aacaf 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts @@ -1,8 +1,9 @@ import { BaseRepository } from '@edr/api-common'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { DeepPartial, Repository } from 'typeorm'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { WarehouseYard } from './entities/warehouse-yard.entity'; @Injectable() @@ -10,4 +11,20 @@ export class WarehouseYardsRepository extends BaseRepository { constructor(@InjectRepository(WarehouseYard) repository: Repository) { super(repository); } + + /** The cargoTypes relation can't ride a column UPDATE — sync it via entity save, like the plain columns. */ + async update(id: string, data: DeepPartial): Promise { + const { cargoTypes, ...columns } = data; + if (Object.keys(columns).length) { + await this.repository.update(id, columns as never); + } + if (cargoTypes) { + const entity = await this.repository.findOne({ where: { id } as never }); + if (entity) { + entity.cargoTypes = cargoTypes as CargoType[]; + await this.repository.save(entity); + } + } + return this.findById(id); + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts index 5b5e2b227..874de75db 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; import { WarehouseYard } from './entities/warehouse-yard.entity'; @@ -15,7 +16,7 @@ export class WarehouseYardsService { findAll(): Promise { return this.yardsRepository.findAll({ - relations: { warehouse: true, zones: true }, + relations: { warehouse: true, zones: true, cargoTypes: true }, order: { code: 'ASC' }, }); } @@ -23,14 +24,14 @@ export class WarehouseYardsService { findByWarehouse(warehouseId: string): Promise { return this.yardsRepository.findAll({ where: { warehouseId }, - relations: { zones: true }, + relations: { zones: true, cargoTypes: true }, order: { code: 'ASC' }, }); } async findById(id: string): Promise { const yard = await this.yardsRepository.findById(id, { - relations: { warehouse: true, zones: true }, + relations: { warehouse: true, zones: true, cargoTypes: true }, }); if (!yard) { @@ -51,6 +52,7 @@ export class WarehouseYardsService { name: dto.name.trim(), code: dto.code.trim(), type: dto.type, + direction: dto.direction ?? null, capacityWeight: dto.capacityWeight ?? null, capacityContainers: dto.capacityContainers ?? null, maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null, @@ -60,6 +62,8 @@ export class WarehouseYardsService { currentVolume: 0, status: 'ACTIVE', isActive: true, + // Join rows are written by the save (RESTRICT FK rejects unknown ids). + cargoTypes: (dto.cargoTypeIds ?? []).map((id) => ({ id }) as CargoType), }); } @@ -84,12 +88,16 @@ export class WarehouseYardsService { name: dto.name?.trim() ?? existing.name, code: dto.code?.trim() ?? existing.code, type: dto.type ?? existing.type, + direction: dto.direction ?? existing.direction, capacityWeight: newCapacityWeight, capacityContainers: newCapacityContainers, maxWeight: dto.maxWeight ?? existing.maxWeight, maxVolume: dto.maxVolume ?? existing.maxVolume, status, isActive: status === 'ACTIVE', + ...(dto.cargoTypeIds + ? { cargoTypes: dto.cargoTypeIds.map((cargoTypeId) => ({ id: cargoTypeId }) as CargoType) } + : {}), }); if (!updated) { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 54e60d8b2..cfbdd7b82 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -80,7 +80,7 @@ import { MoveInventoryModal } from './MoveInventoryModal'; import { ReleaseOrderModal } from './ReleaseOrderModal'; import { StoreInventoryModal } from './StoreInventoryModal'; import { WarehouseInquiryTable } from './WarehouseInquiryTable'; -import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options'; +import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options'; import { openPdfBlob } from './pdf'; import '@/components/overview/overview.css'; @@ -1978,14 +1978,6 @@ function LoadedExportTab({ ); } -const importLocationTypesForFreight = (freightType: string | null | undefined) => { - const normalized = (freightType ?? '').toUpperCase(); - if (normalized === 'CONTAINER') { - return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] }; - } - return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] }; -}; - const isImportContainerFreight = (freightType: string | null | undefined) => (freightType ?? '').toUpperCase() === 'CONTAINER'; @@ -2039,10 +2031,60 @@ function ImportTrainDetailTable({ enabled: Boolean(train.scheduleId), }), ); - const warehouseOptions = useMemo( - () => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })), - [warehouses], + // A train only ever unloads at the warehouse actually sitting at its + // destination station — Indode's train never offers Sebeta's warehouse. + const scopedWarehouses = useMemo( + () => warehousesAtStation(warehouses, train.destinationStationId), + [warehouses, train.destinationStationId], ); + const warehouseOptions = useMemo( + () => scopedWarehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })), + [scopedWarehouses], + ); + // With exactly one warehouse at the station there is nothing to choose — + // pre-fill it so staff only has to pick yard/zone, not re-discover Indode. + useEffect(() => { + if (scopedWarehouses.length !== 1) return; + const onlyWarehouseId = scopedWarehouses[0].id; + items.filter(isImportUnloadPending).forEach((item) => { + if (!assignments[item.bookingId]?.warehouseId) { + onAssignmentChange(item.bookingId, { ...assignments[item.bookingId], warehouseId: onlyWarehouseId }); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scopedWarehouses, items]); + + // Once a booking's warehouse is known, its yard (and then zone) follow from + // what the cargo actually is — a Wheat booking only ever has one candidate + // yard (Dry Bulk) once Indode's real yard layout is configured, so staff + // never see a picker for something that isn't actually a choice. + useEffect(() => { + items.filter(isImportUnloadPending).forEach((item) => { + const draft = assignments[item.bookingId]; + if (!draft?.warehouseId) return; + + if (!draft.yardId) { + const candidateYards = yardsForBooking(yards, { + warehouseId: draft.warehouseId, + freightType: item.freightType, + tradeDirection: 'IMPORT', + cargoTypeCode: item.cargoTypeCode, + }); + if (candidateYards.length === 1) { + onAssignmentChange(item.bookingId, { ...draft, yardId: candidateYards[0].id }); + } + return; + } + + if (!draft.zoneId) { + const candidateZones = zones.filter((zone) => zone.yardId === draft.yardId); + if (candidateZones.length === 1) { + onAssignmentChange(item.bookingId, { ...draft, zoneId: candidateZones[0].id }); + } + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [assignments, items, yards, zones]); useEffect(() => { const pending = items.filter(isImportUnloadPending); @@ -2093,12 +2135,17 @@ function ImportTrainDetailTable({ {items.map((it: ImportTrainItem) => { const draft = assignments[it.bookingId] ?? {}; - const { yardTypes, zoneTypes } = importLocationTypesForFreight(it.freightType); - const yardOptions = yards - .filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type)) - .map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` })); + const yardOptions = yardsForBooking(yards, { + warehouseId: draft.warehouseId, + freightType: it.freightType, + tradeDirection: 'IMPORT', + cargoTypeCode: it.cargoTypeCode, + }).map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` })); + // The yard is already scoped to what this cargo can go into — a + // zone's own type always matches its parent yard's purpose (see the + // Indode seed migration), so no separate zone-type filter is needed. const zoneOptions = zones - .filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type)) + .filter((zone) => zone.yardId === draft.yardId) .map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` })); const pending = isImportUnloadPending(it); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/options.test.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/options.test.ts new file mode 100644 index 000000000..9c0181b40 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/options.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; + +import { warehousesAtStation, yardsForBooking } from "./options"; +import type { Warehouse, WarehouseYard } from "@/types/warehouse"; + +// Mirrors Indode's real 11-yard layout at a reduced scale, so these cases read +// against the actual booking-routing decisions staff rely on. +const yard = (overrides: Partial): WarehouseYard => + ({ + id: overrides.code, + warehouseId: "indode", + name: overrides.code, + code: overrides.code, + type: "GENERAL_CARGO_YARD", + capacityWeight: null, + capacityContainers: null, + maxWeight: null, + maxVolume: null, + currentWeight: 0, + currentContainers: 0, + currentVolume: 0, + status: "ACTIVE", + isActive: true, + ...overrides, + }) as WarehouseYard; + +const YARDS: WarehouseYard[] = [ + yard({ code: "Y2", type: "GENERAL_CARGO_YARD", cargoTypes: [{ id: "1", code: "STEEL_BILLET" }] }), + yard({ code: "Y3", type: "GENERAL_CARGO_YARD", cargoTypes: [{ id: "2", code: "AUTOMOBILE" }, { id: "3", code: "TRUCK" }] }), + yard({ code: "Y4", type: "BULK_YARD", status: "INACTIVE", isActive: false, cargoTypes: [{ id: "4", code: "WHEAT" }] }), + yard({ code: "Y5", type: "CONTAINER_YARD", direction: "IMPORT" }), + yard({ code: "Y6", type: "CONTAINER_YARD", direction: "EXPORT" }), + yard({ code: "Y10", type: "CONTAINER_YARD", direction: "BOTH" }), // service yard + yard({ code: "Y11", type: "CONTAINER_YARD", direction: "BOTH" }), // equipment yard +]; + +describe("yardsForBooking", () => { + it("container import narrows to exactly the import stack", () => { + const result = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "CONTAINER", + tradeDirection: "IMPORT", + cargoTypeCode: null, + }); + expect(result.map((y) => y.code)).toEqual(["Y5"]); + }); + + it("container export narrows to exactly the export stack", () => { + const result = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "CONTAINER", + tradeDirection: "EXPORT", + cargoTypeCode: null, + }); + expect(result.map((y) => y.code)).toEqual(["Y6"]); + }); + + it("never offers a BOTH-direction container yard (service/equipment) for ordinary cargo", () => { + const result = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "CONTAINER", + tradeDirection: "IMPORT", + cargoTypeCode: null, + }); + expect(result.map((y) => y.code)).not.toContain("Y10"); + expect(result.map((y) => y.code)).not.toContain("Y11"); + }); + + it("bulk cargo narrows to the yard configured for that exact cargo type", () => { + const automobile = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: "AUTOMOBILE", + }); + expect(automobile.map((y) => y.code)).toEqual(["Y3"]); + + const steel = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: "STEEL_BILLET", + }); + expect(steel.map((y) => y.code)).toEqual(["Y2"]); + }); + + it("falls back to every non-container yard when the one configured for this cargo type is closed", () => { + // Y4 (Dry Bulk, WHEAT) is inactive — never strand staff with an empty + // picker just because the ideal yard is closed; same safety net as + // warehousesAtStation falling back when a station has no mapped warehouse. + const result = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: "WHEAT", + }); + expect(result.map((y) => y.code).sort()).toEqual(["Y2", "Y3"]); + }); + + it("falls back to every non-container yard when no yard is configured for that cargo type yet", () => { + const result = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: "SOMETHING_UNMAPPED", + }); + expect(result.map((y) => y.code).sort()).toEqual(["Y2", "Y3"]); + }); + + it("a yard with no configured cargo types is open to anything (unconfigured, not restrictive)", () => { + const openYard = yard({ code: "GENERIC", type: "BULK_YARD" }); + const result = yardsForBooking([...YARDS, openYard], { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: "STEEL_BILLET", + }); + expect(result.map((y) => y.code).sort()).toEqual(["GENERIC", "Y2"]); + }); + + it("only offers yards at the requested warehouse", () => { + const otherWarehouseYard = yard({ code: "SEBETA-Y1", warehouseId: "sebeta", type: "GENERAL_CARGO_YARD" }); + const result = yardsForBooking([...YARDS, otherWarehouseYard], { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: null, + }); + expect(result.map((y) => y.code)).not.toContain("SEBETA-Y1"); + }); +}); + +describe("warehousesAtStation", () => { + const warehouse = (id: string, stationId: string | null): Warehouse => + ({ id, stationId, name: id, code: id } as Warehouse); + + it("restricts to the warehouse at the given station", () => { + const warehouses = [warehouse("indode", "station-a"), warehouse("sebeta", "station-b")]; + const result = warehousesAtStation(warehouses, "station-a"); + expect(result.map((w) => w.id)).toEqual(["indode"]); + }); + + it("falls back to every warehouse when the station has no match", () => { + const warehouses = [warehouse("indode", "station-a"), warehouse("sebeta", "station-b")]; + const result = warehousesAtStation(warehouses, "station-unknown"); + expect(result).toEqual(warehouses); + }); + + it("falls back to every warehouse when the station is null", () => { + const warehouses = [warehouse("indode", "station-a")]; + expect(warehousesAtStation(warehouses, null)).toEqual(warehouses); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts index d124a3325..958e2d7f7 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts @@ -4,6 +4,8 @@ import { WAREHOUSE_ZONE_TYPES, WAREHOUSE_STATUSES, INVENTORY_STATUSES, + type Warehouse, + type WarehouseYard, } from '@/types/warehouse'; export const humanizeEnum = (value: string) => @@ -16,6 +18,58 @@ export const humanizeEnum = (value: string) => const toOptions = (values: readonly string[]) => values.map((value) => ({ value, label: humanizeEnum(value) })); +/** + * Warehouses actually located at a train's station — e.g. a train destined for + * Indode should only offer Indode's own warehouse, not Sebeta's or Modjo's. + * Falls back to every warehouse when the station is unmapped (no `stationId` + * match anywhere), so unusual/legacy data never blocks the unload flow entirely. + */ +export const warehousesAtStation = (warehouses: Warehouse[], stationId: string | null | undefined) => { + if (!stationId) return warehouses; + const atStation = warehouses.filter((w) => w.stationId === stationId); + return atStation.length ? atStation : warehouses; +}; + +/** + * Yards at ONE warehouse eligible to receive a booking, given what it actually + * is — e.g. at Indode: container import always narrows to Yard 5, export to + * Yard 6; a Wheat booking narrows to Yard 4 (Dry Bulk), not Break Bulk or + * Coffee/Tea. Mirrors `warehousesAtStation`'s fallback philosophy: an + * unconfigured yard (no cargo types set) stays open rather than disappearing, + * but a yard that IS configured for other cargo never shows for a mismatch. + * + * Container yards are the one case with no such fallback: a CONTAINER_YARD + * left at direction BOTH/null (Indode's Yard 10 service yard, Yard 11 + * equipment yard) is a service/equipment yard, not a customer cargo yard, and + * must never be offered just because the exact-direction stack is missing. + */ +export const yardsForBooking = ( + yards: WarehouseYard[], + params: { + warehouseId: string | null | undefined; + freightType: string | null | undefined; + tradeDirection: string | null | undefined; + cargoTypeCode: string | null | undefined; + }, +): WarehouseYard[] => { + const atWarehouse = yards.filter((y) => y.warehouseId === params.warehouseId && y.isActive); + const isContainer = (params.freightType ?? '').toUpperCase() === 'CONTAINER'; + + if (isContainer) { + const direction = (params.tradeDirection ?? '').toUpperCase(); + return atWarehouse.filter((y) => y.type === 'CONTAINER_YARD' && y.direction === direction); + } + + const nonContainer = atWarehouse.filter((y) => y.type !== 'CONTAINER_YARD'); + if (!params.cargoTypeCode) return nonContainer; + + const cargoMatched = nonContainer.filter((y) => { + const codes = (y.cargoTypes ?? []).map((c) => c.code); + return codes.length === 0 || codes.includes(params.cargoTypeCode as string); + }); + return cargoMatched.length ? cargoMatched : nonContainer; +}; + export const warehouseTypeOptions = toOptions(WAREHOUSE_TYPES); export const yardTypeOptions = toOptions(WAREHOUSE_YARD_TYPES); export const zoneTypeOptions = toOptions(WAREHOUSE_ZONE_TYPES); diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx index 61afa426c..8bb1080d9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx @@ -18,6 +18,8 @@ import { WarehouseOpsKpiStrip, formatDate, formatNumber, + warehousesAtStation, + yardsForBooking, } from '@/components/warehouses'; import { useAutoUnloadArrivedBookings, @@ -49,14 +51,6 @@ const getPendingUnloadBookings = (train: ImportTrain) => const isFullyUnloaded = (train: ImportTrain) => Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0); -const locationTypesForFreight = (freightType: string | null | undefined) => { - const normalized = (freightType ?? '').toUpperCase(); - if (normalized === 'CONTAINER') { - return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] }; - } - return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] }; -}; - const isContainerFreight = (freightType: string | null | undefined) => (freightType ?? '').toUpperCase() === 'CONTAINER'; @@ -65,7 +59,7 @@ function isUnloadPending(item: ImportTrainItem) { } function ImportTrainDetailRows({ - scheduleId, + train, warehouses, yards, zones, @@ -73,7 +67,7 @@ function ImportTrainDetailRows({ onAssignmentChange, onReadyChange, }: { - scheduleId: string; + train: ImportTrain; warehouses: Warehouse[]; yards: WarehouseYard[]; zones: WarehouseZone[]; @@ -81,11 +75,61 @@ function ImportTrainDetailRows({ onAssignmentChange: (bookingId: string, draft: AssignmentDraft) => void; onReadyChange: (ready: boolean) => void; }) { - const { data: items = [], isLoading } = useImportTrainItems(scheduleId); - const warehouseOptions = useMemo( - () => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })), - [warehouses], + const { data: items = [], isLoading } = useImportTrainItems(train.scheduleId); + // A train only ever unloads at the warehouse actually sitting at its + // destination station — Indode's train never offers Sebeta's warehouse. + const scopedWarehouses = useMemo( + () => warehousesAtStation(warehouses, train.destinationStationId), + [warehouses, train.destinationStationId], ); + const warehouseOptions = useMemo( + () => scopedWarehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })), + [scopedWarehouses], + ); + // With exactly one warehouse at the station there is nothing to choose — + // pre-fill it so staff only has to pick yard/zone, not re-discover Indode. + useEffect(() => { + if (scopedWarehouses.length !== 1) return; + const onlyWarehouseId = scopedWarehouses[0].id; + items.filter(isUnloadPending).forEach((item) => { + if (!assignments[item.bookingId]?.warehouseId) { + onAssignmentChange(item.bookingId, { ...assignments[item.bookingId], warehouseId: onlyWarehouseId }); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scopedWarehouses, items]); + + // Once a booking's warehouse is known, its yard (and then zone) follow from + // what the cargo actually is — a Wheat booking only ever has one candidate + // yard (Dry Bulk) once Indode's real yard layout is configured, so staff + // never see a picker for something that isn't actually a choice. + useEffect(() => { + items.filter(isUnloadPending).forEach((item) => { + const draft = assignments[item.bookingId]; + if (!draft?.warehouseId) return; + + if (!draft.yardId) { + const candidateYards = yardsForBooking(yards, { + warehouseId: draft.warehouseId, + freightType: item.freightType, + tradeDirection: 'IMPORT', + cargoTypeCode: item.cargoTypeCode, + }); + if (candidateYards.length === 1) { + onAssignmentChange(item.bookingId, { ...draft, yardId: candidateYards[0].id }); + } + return; + } + + if (!draft.zoneId) { + const candidateZones = zones.filter((zone) => zone.yardId === draft.yardId); + if (candidateZones.length === 1) { + onAssignmentChange(item.bookingId, { ...draft, zoneId: candidateZones[0].id }); + } + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [assignments, items, yards, zones]); useEffect(() => { const pending = items.filter(isUnloadPending); @@ -135,12 +179,17 @@ function ImportTrainDetailRows({ {items.map((item: ImportTrainItem) => { const draft = assignments[item.bookingId] ?? {}; - const { yardTypes, zoneTypes } = locationTypesForFreight(item.freightType); - const yardOptions = yards - .filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type)) - .map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` })); + const yardOptions = yardsForBooking(yards, { + warehouseId: draft.warehouseId, + freightType: item.freightType, + tradeDirection: 'IMPORT', + cargoTypeCode: item.cargoTypeCode, + }).map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` })); + // The yard is already scoped to what this cargo can go into — a + // zone's own type always matches its parent yard's purpose (see the + // Indode seed migration), so no separate zone-type filter is needed. const zoneOptions = zones - .filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type)) + .filter((zone) => zone.yardId === draft.yardId) .map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` })); const pending = isUnloadPending(item); @@ -395,7 +444,7 @@ export default function ArrivalQueuePage() { ; capacityWeight: number | null; capacityContainers: number | null; maxWeight: number | null; @@ -518,6 +525,8 @@ export interface ImportTrain { route: string | null; origin: string | null; destination: string | null; + /** freight.yards.id the train is heading to — matches Warehouse.stationId, so the unload picker can be scoped to the warehouse actually at this station. */ + destinationStationId: string | null; departureTime?: string | null; arrivalTime: string | null; totalBookings: number; @@ -632,6 +641,8 @@ export interface ImportTrainItem { freightType: string | null; containerNumber: string | null; cargoType: string | null; + /** Cargo type CODE (e.g. "WHEAT"), for matching against a yard's configured cargo types — `cargoType` above is the display name. */ + cargoTypeCode: string | null; weight: number | null; arrivalTime: string | null; currentStatus: string | null; From 4e7b7d9f4ddf7075d67e4543a23b1f2b9704a5f5 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Mon, 27 Jul 2026 13:18:28 +0300 Subject: [PATCH 05/40] Change restart policy and remove npmrc secrets Updated restart policy for multiple services to 'always' and removed npmrc secrets. --- docker-compose.yaml | 41 ++++++++++++++--------------------------- 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index a621749dc..3659e963f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -9,21 +9,18 @@ services: build: context: . dockerfile: apps/edr-freight-api/Dockerfile - secrets: - - npmrc ports: - "${FREIGHT_API_PORT:-3001}:${FREIGHT_API_PORT:-3001}" env_file: - apps/edr-freight-api/.env extra_hosts: - "paymentcallback.triaplc.com:10.18.7.179" + restart: always gps-tracker: build: context: . dockerfile: apps/edr-gps-tracker/Dockerfile - secrets: - - npmrc ports: - "${GT06_TCP_PORT:-5023}:5023" environment: @@ -31,8 +28,8 @@ services: GT06_TCP_HOST: "0.0.0.0" env_file: - apps/edr-gps-tracker/.env - restart: unless-stopped - + restart: always + passenger-api: build: context: . @@ -43,8 +40,8 @@ services: - apps/edr-passenger-api/.env extra_hosts: - "paymentcallback.triaplc.com:10.18.7.179" - restart: unless-stopped - + restart: always + freight-portal: build: context: . @@ -58,11 +55,10 @@ services: VITE_GOOGLE_MAPS_API_KEY: ${VITE_GOOGLE_MAPS_API_KEY:-} VITE_POSTHOG_KEY: ${VITE_POSTHOG_KEY:-} VITE_POSTHOG_HOST: ${VITE_POSTHOG_HOST:-} - secrets: - - npmrc ports: - "${FREIGHT_PORTAL_PORT:-5173}:80" - + restart: always + freight-backoffice: build: context: . @@ -76,11 +72,10 @@ services: VITE_GOOGLE_MAPS_API_KEY: ${VITE_GOOGLE_MAPS_API_KEY:-} VITE_POSTHOG_KEY: ${VITE_POSTHOG_KEY:-} VITE_POSTHOG_HOST: ${VITE_POSTHOG_HOST:-} - secrets: - - npmrc ports: - "${FREIGHT_BACKOFFICE_PORT:-5183}:80" - + restart: always + passenger-portal: build: context: . @@ -89,14 +84,12 @@ services: APP_PACKAGE: "@edr/passenger-portal" APP_PATH: apps/edr-passenger-web/portal NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-} - secrets: - - npmrc ports: - "${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174}" env_file: - apps/edr-passenger-web/portal/.env - restart: unless-stopped - + restart: always + passenger-backoffice: build: context: . @@ -105,14 +98,12 @@ services: APP_PACKAGE: "@edr/passenger-backoffice" APP_PATH: apps/edr-passenger-web/backoffice NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-} - secrets: - - npmrc ports: - "${PASSENGER_BACKOFFICE_PORT:-5184}:${PASSENGER_BACKOFFICE_PORT:-5184}" env_file: - apps/edr-passenger-web/backoffice/.env - restart: unless-stopped - + restart: always + payment-api: build: context: . @@ -120,12 +111,8 @@ services: args: APP_PACKAGE: "@edr/payment-api" APP_PATH: apps/edr-payment-api - secrets: - - npmrc ports: - "${PAYMENT_API_PORT:-3008}:${PAYMENT_API_PORT:-3008}" env_file: - apps/edr-payment-api/.env -secrets: - npmrc: - file: .npmrc + restart: always From d71f6dc1b3abc7113e909499f9447f65a7886091 Mon Sep 17 00:00:00 2001 From: Muluhabt Date: Mon, 27 Jul 2026 14:42:57 +0300 Subject: [PATCH 06/40] Adding train delay minutes and also disabling no schedule days in the booking selection --- .../modules/bookings/bookings.controller.ts | 18 ++ .../src/modules/bookings/bookings.service.ts | 33 ++++ .../src/modules/live/live.module.ts | 2 +- .../notifications/notifications.service.ts | 12 ++ .../modules/schedules/schedules.controller.ts | 18 +- .../src/modules/schedules/schedules.dto.ts | 8 + .../src/modules/schedules/schedules.module.ts | 3 +- .../modules/schedules/schedules.service.ts | 70 +++++++- .../src/modules/search/search.controller.ts | 17 +- .../src/modules/search/search.dto.ts | 14 ++ .../src/modules/search/search.service.ts | 125 ++++++++++++-- .../src/modules/seats/seats.service.ts | 67 +++++++- .../reserve-seat-issue-booking.e2e-spec.ts | 155 +++++++++++++++++- .../backoffice/src/app/schedules/page.tsx | 94 ++++++++++- .../backoffice/src/app/seats/page.tsx | 84 ++++++++-- .../backoffice/src/lib/api/index.ts | 6 + .../portal/src/app/booking/search/page.tsx | 75 ++++++++- .../src/components/ModernDatePicker.tsx | 14 +- .../portal/src/components/SearchWidget.tsx | 84 +++++++++- 19 files changed, 845 insertions(+), 54 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 5953d17a2..a32cffc84 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -369,6 +369,24 @@ export class BookingsController { return this.guestService.issueBookingFromReservation(seatId, dto, actingUserId); } + @Delete("reservations/:seatId") + @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin]) + @ApiBearerAuth("IAM-auth") + @ApiOperation({ + summary: "Cancel a seat's pending-payment reservation and release the seat", + description: + "For a seat with an active PASSENGER-kind reservation (payment link sent, not yet paid): cancels that booking and releases the seat's hold, so it's genuinely free for someone else. The old payment link stops working immediately (the booking is no longer PENDING_PAYMENT).", + }) + @ApiQuery({ name: "scheduleId", required: true, description: "TrainSchedule UUID the reservation was issued on" }) + cancelReservationForSeat( + @Param("seatId") seatId: string, + @Query("scheduleId") scheduleId: string, + @Req() req: any, + ) { + const actingUserId = req.user?.id ?? req.user?.sub ?? null; + return this.service.cancelReservationForSeat(seatId, scheduleId, actingUserId); + } + @Get("pay/:token") @SetMetadata("isPublic", true) @ApiOperation({ 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 b5825ddc5..26ab854d3 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -2138,6 +2138,39 @@ export class BookingsService { return { cancelled: true, refundAmount: refundAmount / 100, currency: booking.displayCurrency}; } + /** + * Staff releasing a seat that already has an in-flight backoffice reservation must not + * leave that booking dangling as PENDING_PAYMENT with a still-payable link — the traveler + * could pay for a seat that's since been given away. Finds the active reservation covering + * this exact seat+schedule and cancels it via the normal cancel() path (refund=0, since it's + * still unpaid), then separately releases the SeatHold issueBookingFromReservation created — + * cancel()'s releaseSeats() only deletes Journey/JourneySegment rows, which don't exist yet + * for an unpaid reservation, so without this the seat would stay held until the hold's own + * expiry. Once status flips to CANCELLED, getByPayToken's existing status check already + * rejects the old payToken with "This booking is no longer awaiting payment" — no separate + * payToken invalidation needed. + */ + async cancelReservationForSeat(seatId: string, scheduleId: string, actingUserId: string | null) { + const bookingSeat = await this.prisma.bookingSeat.findFirst({ + where: { + seatId, + scheduleId, + booking: { source: 'BACKOFFICE_RESERVATION', status: 'PENDING_PAYMENT' }, + }, + include: { booking: true }, + }); + if (!bookingSeat) throw new NotFoundException('No pending reservation found for this seat'); + + const { bookingRef } = bookingSeat.booking; + const result = await this.cancel(bookingRef, 'Seat released by staff before payment', actingUserId ?? undefined); + + await this.prisma.seatHold.deleteMany({ + where: { scheduleId, seatIds: { hasSome: [seatId] } }, + }); + + return { ...result, bookingRef }; + } + async update(id: string, dto: any) { const booking = await this.prisma.booking.findUnique({ where: { id } }); if (!booking) throw new NotFoundException('Booking not found'); diff --git a/apps/edr-passenger-api/src/modules/live/live.module.ts b/apps/edr-passenger-api/src/modules/live/live.module.ts index 268ba2385..aa46c7d57 100644 --- a/apps/edr-passenger-api/src/modules/live/live.module.ts +++ b/apps/edr-passenger-api/src/modules/live/live.module.ts @@ -2,5 +2,5 @@ import { Module } from '@nestjs/common'; import { LiveController } from './live.controller'; import { LiveService } from './live.service'; -@Module({ controllers: [LiveController], providers: [LiveService] }) +@Module({ controllers: [LiveController], providers: [LiveService], exports: [LiveService] }) export class LiveModule {} diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index 12f549e9f..9ef922bb3 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -312,6 +312,18 @@ export class NotificationsService { const ref = booking?.bookingRef ?? payload.booking.bookingRef; const passengerId = booking?.passengerId ?? payload.booking.passengerId; + // A backoffice-issued reservation already sends its own purpose-built message — + // GuestBookingService.issueBookingFromReservation texts /reserve/pay/ for a + // PASSENGER-kind booking (the traveler has no portal session, so this generic template's + // /booking/detail?ref= link doesn't work), and for STAFF kind the booking is finalized + // immediately after this event fires, so onPaymentSucceeded's "ticket ready" message is + // the correct one to send, not a redundant/contradictory "awaiting payment" notice. + const source = (booking as any)?.source ?? payload.booking?.source; + if (source === 'BACKOFFICE_RESERVATION') { + this.logger.log(`Skipping generic booking.created notification for ${ref} — reservation flow sends its own`); + return; + } + const template = await this.prisma.notificationTemplate.findUnique({ where: { code: 'booking.created' }, }); 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 b11ac3098..c6921210a 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseInt 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, TripStatus } from './schedules.dto'; +import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus, ApplyDelayDto } from './schedules.dto'; import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @@ -171,6 +171,22 @@ export class SchedulesController { @Body() dto: UpdateStopTimeDto, ) { return this.service.updateStop(id, sequence, dto); } + @Post(':id/delay') + @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @ApiOperation({ + summary: 'Report a delay — pushes every downstream stop\'s planned times (and check-in cutoffs) back by the same amount', + description: `Shifts plannedArrivalAt/plannedDepartureAt on every stop not yet BOARDED/COMPLETED (or from fromSequence +onward, if given) by delayMinutes. Since check-in cutoffs are derived directly from these planned +times, this is the only action needed for booking closure to reflect the delay — no separate cutoff +update. Also shifts the schedule's own departureAt/arrivalAt when the origin stop is included, and +records the accumulated delay on the schedule's live status. Does not change schedule/stop status.`, + }) + @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) + @ApiResponse({ status: 200, description: 'Schedule with shifted stop times' }) + applyDelay(@Param('id') id: string, @Body() dto: ApplyDelayDto) { + return this.service.applyDelay(id, dto); + } + @Put(':scheduleId/fares/:seatClassId') @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @ApiOperation({ 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 49dec1d96..333eb7d26 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -113,6 +113,14 @@ export class UpdateScheduleStatusDto { @ApiProperty({ enum: TripStatus, example: TripStatus.EN_ROUTE }) @IsEnum(TripStatus) status: TripStatus; } +export class ApplyDelayDto { + @ApiProperty({ example: 60, description: 'Minutes to shift downstream stop times by. Negative to correct an over-reported delay.' }) + @IsInt() delayMinutes: number; + + @ApiPropertyOptional({ example: 3, description: 'Only shift stops from this sequence onward. Omit to default to every stop not yet BOARDED/COMPLETED.' }) + @IsOptional() @IsInt() @Min(1) fromSequence?: number; +} + export class BulkCreateSchedulesDto { @ApiProperty({ example: 'train-uuid', description: 'Train UUID' }) @IsString() trainId: string; @ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string; diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts index 18d88d631..e01dc616b 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts @@ -5,9 +5,10 @@ import { RoutesController } from './routes.controller'; import { RoutesService } from './routes.service'; import { FareEngineModule } from '../fare-engine/fare-engine.module'; import { AuditModule } from '../../common/audit.module'; +import { LiveModule } from '../live/live.module'; @Module({ - imports: [FareEngineModule, AuditModule], + imports: [FareEngineModule, AuditModule, LiveModule], controllers: [RoutesController, SchedulesController], providers: [RoutesService, SchedulesService], exports: [RoutesService, SchedulesService], diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index be1c936b4..402f5eb66 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -2,10 +2,11 @@ import { Injectable, Logger, NotFoundException, BadRequestException } from '@nes import { PrismaService } from '../../common/prisma.service'; import { RoutesService } from './routes.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; -import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto } from './schedules.dto'; +import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, ApplyDelayDto } from './schedules.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils'; import { AuditService } from '../../common/audit.service'; +import { LiveService } from '../live/live.service'; @Injectable() export class SchedulesService { @@ -16,6 +17,7 @@ export class SchedulesService { private routesService: RoutesService, private fareEngine: FareEngineService, private auditService: AuditService, + private liveService: LiveService, ) { } /** @@ -126,6 +128,7 @@ export class SchedulesService { include: { coach: true }, orderBy: { positionNumber: 'asc' }, }, + liveStatus: { select: { delayMinutes: true } }, _count: { select: { coachAssignments: true, bookings: true } }, }, orderBy: { departureAt: 'asc' }, @@ -246,6 +249,7 @@ export class SchedulesService { orderBy: { positionNumber: 'asc' }, }, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + liveStatus: { select: { delayMinutes: true } }, }, }); if (!schedule) throw new NotFoundException('Schedule not found'); @@ -462,6 +466,70 @@ export class SchedulesService { }); } + /** + * Shifts stored planned times additively rather than reusing updateSchedulePartial's + * recompute-from-route-interpolation path — that path also guards `departureAt must be in the + * future`, which a delay report for an already-departed/EN_ROUTE train would legitimately + * fail. Check-in cutoffs (resolveCheckinCutoff, SeatsService.holdSeats) are both derived + * directly from TripStopTime.plannedArrivalAt/plannedDepartureAt at read time, so shifting the + * stored values here is the entire fix — neither of those needs to change. + */ + async applyDelay(scheduleId: string, dto: ApplyDelayDto) { + const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } }); + if (!schedule) throw new NotFoundException('Schedule not found'); + + const stopWhere: any = { scheduleId }; + if (dto.fromSequence != null) { + stopWhere.sequence = { gte: dto.fromSequence }; + } else { + // Default: only stops the train hasn't reached yet — a delay report must not retroactively + // move a stop that's already BOARDED/COMPLETED. + stopWhere.status = { notIn: ['BOARDED', 'COMPLETED'] }; + } + + const stopsToShift = await this.prisma.tripStopTime.findMany({ where: stopWhere }); + const shiftMs = dto.delayMinutes * 60_000; + const includesOrigin = stopsToShift.some((s) => s.sequence === 1); + + await this.prisma.$transaction(async (tx) => { + for (const stop of stopsToShift) { + await tx.tripStopTime.update({ + where: { id: stop.id }, + data: { + plannedArrivalAt: stop.plannedArrivalAt ? new Date(stop.plannedArrivalAt.getTime() + shiftMs) : undefined, + plannedDepartureAt: stop.plannedDepartureAt ? new Date(stop.plannedDepartureAt.getTime() + shiftMs) : undefined, + }, + }); + } + + // Origin stop shifted → the schedule's own departureAt/arrivalAt drive search's day-window + // queries and the displayed departure time, so they must move too (both together, so + // durationMinutes stays correct). + if (includesOrigin) { + await tx.trainSchedule.update({ + where: { id: scheduleId }, + data: { + departureAt: new Date(schedule.departureAt.getTime() + shiftMs), + arrivalAt: new Date(schedule.arrivalAt.getTime() + shiftMs), + }, + }); + } + }); + + const currentLive = await this.prisma.tripLiveStatus.findUnique({ where: { scheduleId } }); + const accumulatedDelayMinutes = Math.max(0, (currentLive?.delayMinutes ?? 0) + dto.delayMinutes); + await this.liveService.updateLiveStatus(scheduleId, { delayMinutes: accumulatedDelayMinutes }); + + await this.auditService.log({ + action: 'UPDATE', + entityType: 'Schedule', + entityId: scheduleId, + newData: { delayMinutes: dto.delayMinutes, fromSequence: dto.fromSequence, accumulatedDelayMinutes }, + }); + + return this.getSchedule(scheduleId); + } + async upsertScheduleFare( scheduleId: string, seatClassId: string, diff --git a/apps/edr-passenger-api/src/modules/search/search.controller.ts b/apps/edr-passenger-api/src/modules/search/search.controller.ts index 384592dc9..92aff000a 100644 --- a/apps/edr-passenger-api/src/modules/search/search.controller.ts +++ b/apps/edr-passenger-api/src/modules/search/search.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Post, Get, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SearchService } from './search.service'; -import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto } from './search.dto'; +import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto, AvailableDatesQueryDto } from './search.dto'; @ApiTags('Search') @Controller('search') @@ -67,6 +67,21 @@ Nationality-Based: return this.service.getFareQuote(dto); } + @Get('available-dates') + @ApiOperation({ + summary: 'Which dates in a range have a bookable schedule for an origin/destination pair', + description: `Used to disable schedule-less dates on the search date picker before the user submits a search. + +For each date in the (server-clamped, max 90-day) range, a date is "available" if at least one +schedule exists for the origin→destination pair whose status/package/coach state is bookable and +whose check-in cutoff has not yet passed. This does not check seat-level availability — a date +can be marked available and still turn out fully booked when actually searched.`, + }) + @ApiResponse({ status: 200, description: 'routeExists flag plus a per-date availability list' }) + getAvailableDates(@Query() dto: AvailableDatesQueryDto) { + return this.service.getAvailableDates(dto); + } + @Get('fare-breakdown') @ApiOperation({ summary: 'Per-passenger fare breakdown for booking review page', diff --git a/apps/edr-passenger-api/src/modules/search/search.dto.ts b/apps/edr-passenger-api/src/modules/search/search.dto.ts index cfb1075ca..1c99ce418 100644 --- a/apps/edr-passenger-api/src/modules/search/search.dto.ts +++ b/apps/edr-passenger-api/src/modules/search/search.dto.ts @@ -29,6 +29,20 @@ export class SearchTripsDto { @IsOptional() @IsDateString() returnDate?: string; } +export class AvailableDatesQueryDto { + @ApiProperty({ example: 'station-uuid', description: 'Origin station UUID' }) + @IsString() originStationId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' }) + @IsString() destinationStationId: string; + + @ApiProperty({ example: '2026-06-15', description: 'Start of the date range (YYYY-MM-DD)' }) + @IsDateString() from: string; + + @ApiProperty({ example: '2026-09-13', description: 'End of the date range (YYYY-MM-DD), inclusive — server clamps to a max 90-day span' }) + @IsDateString() to: string; +} + export class FareQuoteDto { @ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID from search results' }) @IsString() scheduleId: string; 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 9c368a976..de07f5d6a 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -5,6 +5,7 @@ import { FareQuoteDto, FareBreakdownRequestDto, FareBreakdownPassengerDto, + AvailableDatesQueryDto, } from "./search.dto"; import { CurrencyService } from "../currency/currency.service"; import { FareEngineService } from "../fare-engine/fare-engine.service"; @@ -383,16 +384,9 @@ export class SearchService { // 1. Does any active route connect these two stations, in this direction, at all — // ignoring date entirely? - const candidateRoutes = await this.prisma.route.findMany({ - where: { active: true, stops: { some: { stationId: originStationId } } }, - select: { stops: { select: { stationId: true, sequence: true } } }, - }); - const routeExists = candidateRoutes.some((r) => { - const o = r.stops.find((s) => s.stationId === originStationId); - const d = r.stops.find((s) => s.stationId === destinationStationId); - return !!o && !!d && o.sequence < d.sequence; - }); - if (!routeExists) return withCode(Passenger.SearchEmptyReasonCode.NoRoute); + if (!(await this.routeExistsForPair(originStationId, destinationStationId))) { + return withCode(Passenger.SearchEmptyReasonCode.NoRoute); + } // 2. A route exists — is there any schedule at all on the requested date for this pair // (regardless of status/package/coach/cutoff — those are checked next)? @@ -425,12 +419,7 @@ export class SearchService { // 3. Schedules exist that date — narrow to ones that would otherwise be bookable // (right status, not package-only, has at least one coach assigned). - const bookable = sameDayForPair.filter( - (s) => - (["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) && - !s.isPackageOnly && - s.coachAssignments.length > 0, - ); + const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s)); if (bookable.length === 0) { if (sameDayForPair.every((s) => s.status === "CANCELLED")) return withCode(Passenger.SearchEmptyReasonCode.Cancelled); @@ -452,6 +441,110 @@ export class SearchService { return withCode(Passenger.SearchEmptyReasonCode.FullyBooked); } + /** + * Whether any active route connects originStationId → destinationStationId in this + * direction, ignoring date/schedule state entirely. Shared by classifyEmptySearch and + * getAvailableDates. + */ + private async routeExistsForPair(originStationId: string, destinationStationId: string): Promise { + const candidateRoutes = await this.prisma.route.findMany({ + where: { active: true, stops: { some: { stationId: originStationId } } }, + select: { stops: { select: { stationId: true, sequence: true } } }, + }); + return candidateRoutes.some((r) => { + const o = r.stops.find((s) => s.stationId === originStationId); + const d = r.stops.find((s) => s.stationId === destinationStationId); + return !!o && !!d && o.sequence < d.sequence; + }); + } + + /** Status/package/coach bookability only — ignores date, cutoff, and seat-level availability. */ + private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean { + return ( + (["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) && + !s.isPackageOnly && + s.coachAssignments.length > 0 + ); + } + + private readonly MAX_AVAILABLE_DATES_SPAN_DAYS = 90; + private readonly ADDIS_OFFSET_MS = 3 * 60 * 60 * 1000; + private readonly ONE_DAY_MS = 24 * 60 * 60 * 1000; + + /** Converts an absolute instant to its calendar date string in Africa/Addis_Ababa (fixed UTC+3, no DST). */ + private toAddisDateStr(d: Date): string { + return new Date(d.getTime() + this.ADDIS_OFFSET_MS).toISOString().slice(0, 10); + } + + /** + * For each date in the (server-clamped) range, whether at least one bookable schedule exists + * for originStationId → destinationStationId — used to disable schedule-less dates on the + * search date picker before the user submits a search. Reuses the same route-existence and + * bookability checks as classifyEmptySearch, plus the same check-in cutoff resolution used + * throughout this service, but does not compute seat-level availability (see buildScheduleResult) + * — a date can be marked available and still turn out fully booked when actually searched. + */ + async getAvailableDates(dto: AvailableDatesQueryDto) { + const { originStationId, destinationStationId } = dto; + + const todayStr = this.toAddisDateStr(new Date()); + const from = dto.from > todayStr ? dto.from : todayStr; + const fromDate = new Date(`${from}T00:00:00+03:00`); + + const maxToDate = new Date(fromDate.getTime() + this.MAX_AVAILABLE_DATES_SPAN_DAYS * this.ONE_DAY_MS); + const requestedToDate = new Date(`${dto.to}T00:00:00+03:00`); + const toDate = requestedToDate < maxToDate ? requestedToDate : maxToDate; + const to = this.toAddisDateStr(toDate); + + if (!(await this.routeExistsForPair(originStationId, destinationStationId))) { + return { + originStationId, + destinationStationId, + from, + to, + routeExists: false, + dates: [] as { date: string; available: boolean }[], + }; + } + + const rangeEnd = new Date(toDate.getTime() + this.ONE_DAY_MS); + const schedules = await this.prisma.trainSchedule.findMany({ + where: { + departureAt: { gte: fromDate, lt: rangeEnd }, + stopTimes: { some: { stationId: originStationId } }, + }, + select: { + departureAt: true, + status: true, + isPackageOnly: true, + route: { + select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } }, + }, + stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } }, + coachAssignments: { select: { id: true } }, + }, + }); + + const now = Date.now(); + const availableDays = new Set(); + for (const s of schedules) { + const originStop = s.stopTimes.find((st) => st.stationId === originStationId); + const destinationStop = s.stopTimes.find((st) => st.stationId === destinationStationId); + if (!originStop || !destinationStop || originStop.sequence >= destinationStop.sequence) continue; + if (!this.isBookableSchedule(s)) continue; + if (now >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime()) continue; + availableDays.add(this.toAddisDateStr(s.departureAt)); + } + + const dates: { date: string; available: boolean }[] = []; + for (let cursor = fromDate; cursor <= toDate; cursor = new Date(cursor.getTime() + this.ONE_DAY_MS)) { + const dateStr = this.toAddisDateStr(cursor); + dates.push({ date: dateStr, available: availableDays.has(dateStr) }); + } + + return { originStationId, destinationStationId, from, to, routeExists: true, dates }; + } + // ── Transit search ───────────────────────────────────────────────────────── private readonly MIN_CONNECTION_MINUTES = 30; private readonly MAX_CONNECTION_MINUTES = 360; diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index bd0a3cadf..77e13740a 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -45,13 +45,16 @@ export class SeatsService { }); const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id)); - const effectiveStatuses = await this.resolveEffectiveStatuses( - scheduleId, - allSeatIds, - originStationId ?? schedule.originStationId, - destinationStationId ?? schedule.destinationStationId, - journeyDirection - ); + const [effectiveStatuses, reservations] = await Promise.all([ + this.resolveEffectiveStatuses( + scheduleId, + allSeatIds, + originStationId ?? schedule.originStationId, + destinationStationId ?? schedule.destinationStationId, + journeyDirection + ), + this.resolveActiveReservations(allSeatIds, scheduleId), + ]); return { coaches: assignments.map((a) => { @@ -70,6 +73,7 @@ export class SeatsService { ? this.resolveBedPosition(s.col, s.bedPosition) : s.bedPosition; const effectiveStatus = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE'); + const reservation = reservations.get(s.id); return { id: s.id, seatNumber: s.seatNumber, @@ -88,6 +92,15 @@ export class SeatsService { position: this.colToPosition(s.col, a.coach.arrangement), bed_type: this.bedPositionToType(resolvedBedPosition), } : {}), + // Backoffice-issued reservation covering this seat, if any — lets staff see who's + // paying/ticketed for a HELD (awaiting payment) or BLOCKED (ticketed) seat without + // leaving the seat map. See resolveActiveReservations. + ...(reservation ? { + bookingRef: reservation.bookingRef, + reservationStatus: reservation.status, + reservationPassengerName: reservation.passengerName, + reservationContactPhone: reservation.contactPhone, + } : {}), }; }); @@ -259,6 +272,46 @@ export class SeatsService { return statusMap; } + /** + * Batch-resolves the backoffice-issued reservation (if any) covering each of these seats on + * this schedule — a booking created via GuestBookingService.issueBookingFromReservation + * (`source: 'BACKOFFICE_RESERVATION'`), still PENDING_PAYMENT (payment link sent, not yet + * paid) or already CONFIRMED (ticketed). Used to surface the booking reference on the + * backoffice seat map so staff can see who's paying/ticketed for a given seat without + * looking it up separately. + */ + private async resolveActiveReservations( + seatIds: string[], + scheduleId: string, + ): Promise> { + const map = new Map(); + if (seatIds.length === 0) return map; + + const bookingSeats = await this.prisma.bookingSeat.findMany({ + where: { + seatId: { in: seatIds }, + scheduleId, + booking: { source: 'BACKOFFICE_RESERVATION', status: { in: ['PENDING_PAYMENT', 'CONFIRMED'] } }, + }, + select: { + seatId: true, + passengerName: true, + booking: { select: { bookingRef: true, status: true, contactPhone: true } }, + }, + }); + + for (const bs of bookingSeats) { + if (!bs.seatId) continue; + map.set(bs.seatId, { + bookingRef: bs.booking.bookingRef, + status: bs.booking.status, + passengerName: bs.passengerName, + contactPhone: bs.booking.contactPhone, + }); + } + return map; + } + async holdSeats(dto: HoldSeatsDto) { const passengerIds = dto.passengers.map(p => p.passengerId); const seatIds = dto.passengers.map(p => p.seatId); diff --git a/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts b/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts index 75f822289..5d63d7a06 100644 --- a/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts +++ b/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts @@ -26,6 +26,7 @@ import { validateSync } from "class-validator"; import { plainToInstance } from "class-transformer"; import { SchedulesService } from "../src/modules/schedules/schedules.service"; import { SeatsService } from "../src/modules/seats/seats.service"; +import { SegmentsService } from "../src/modules/segments/segments.service"; import { TicketsService } from "../src/modules/tickets/tickets.service"; import { PaymentsService } from "../src/modules/payments/payments.service"; import { CurrencyService } from "../src/modules/currency/currency.service"; @@ -34,6 +35,7 @@ import { SystemConfigService } from "../src/modules/system-config/system-config. import { BookingsService } from "../src/modules/bookings/bookings.service"; import { GuestBookingService } from "../src/modules/bookings/guest-booking.service"; import { ReservationBookingKind, IssueReservationBookingDto } from "../src/modules/bookings/guest-booking.dto"; +import { NotificationsService } from "../src/modules/notifications/notifications.service"; import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; import { IDS, resetAndSeedCore } from "./fixtures/seed-core"; @@ -48,7 +50,9 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { let seatsService: SeatsService; let guestBookingService: GuestBookingService; let bookingsService: BookingsService; + let notificationsService: NotificationsService; let smsClient: { sendSms: jest.Mock }; + let emailClient: { sendEmail: jest.Mock }; beforeAll(async () => { harness = await createServiceHarness(); @@ -57,7 +61,11 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { const fareEngine = harness.moduleRef.get(FareEngineService); const systemConfig = new SystemConfigService(harness.prisma as any); - seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); + // Real SegmentsService (not asyncStub) — the getSeatMap test below exercises + // resolveEffectiveStatuses, which calls segmentsService.getSeatAvailabilityMap and needs + // an actual Map back, not asyncStub's `async () => undefined`. + const segmentsService = new SegmentsService(harness.prisma as any); + seatsService = new SeatsService(harness.prisma as any, segmentsService, systemConfig, asyncStub(), asyncStub()); const ticketsService = new TicketsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); const paymentsService = new PaymentsService( harness.prisma as any, @@ -85,12 +93,26 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { harness.prisma as any, asyncStub(), // dataSource seatsService, - { emit: () => true } as any, + ticketsService, + { emit: () => true } as any, // eventEmitter asyncStub(), // verifaydaService currencyService, fareEngine, asyncStub(), // auditService ); + + emailClient = { sendEmail: jest.fn().mockResolvedValue({ queued: true }) }; + // Same smsClient instance guestBookingService uses — lets the notification-suppression + // test assert on ONE shared call count across both services, proving the reservation + // flow's own SMS is the only message sent for a BACKOFFICE_RESERVATION booking. + notificationsService = new NotificationsService( + harness.prisma as any, + asyncStub(), // dataSource (TypeORM) — only reached for non-UUID recipients / IAM lookups, + // never hit by these guest-passenger-id-keyed test bookings + emailClient as any, + smsClient as any, + asyncStub(), // pushAdapter + ); }); afterAll(async () => { @@ -99,6 +121,7 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { beforeEach(() => { smsClient.sendSms.mockClear(); + emailClient.sendEmail.mockClear(); }); /** Creates a fresh Train + TrainSchedule on the seed-core route, coach assigned at creation. */ @@ -294,6 +317,53 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { expect(byToken.schedule.origin.id).toBe(IDS.stationA); }); + it("NotificationsService.onBookingCreated skips its own message for a BACKOFFICE_RESERVATION booking (issueBookingFromReservation already sent one), but still fires for a normal booking", async () => { + // Regression for: the customer got TWO conflicting messages for one reservation — + // the reservation-specific /reserve/pay/ SMS from issueBookingFromReservation, + // AND a second, generic booking.created notification pointing at /booking/detail?ref=, + // a page that doesn't work for a traveler with no portal session. + await resetAndSeedCore(harness.prisma); + await harness.prisma.notificationTemplate.upsert({ + where: { code: "booking.created" }, + update: { active: true }, + create: { code: "booking.created", channel: "SMS,EMAIL", bodyTemplate: "Booking {{bookingRef}} created. Pay: {{payLink}}", active: true }, + }); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-NOTIFY-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id); + const result: any = await guestBookingService.issueBookingFromReservation( + seats[0].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any, + "staff-user-5", + ); + expect(smsClient.sendSms).toHaveBeenCalledTimes(1); // the reservation flow's own SMS + + // Directly invoke the event handler (the test harness's eventEmitter is a stub, so the + // real 'booking.created' emit from issueBookingFromReservation never reaches it) — this + // is what NotificationsService would have done had it received that event. + await notificationsService.onBookingCreated({ booking: { id: result.booking.id, bookingRef: result.booking.bookingRef } }); + expect(smsClient.sendSms).toHaveBeenCalledTimes(1); // still 1 — onBookingCreated no-oped + expect(emailClient.sendEmail).not.toHaveBeenCalled(); + + // Control: a normal (non-reservation) booking must still get the generic notification. + const passenger = await harness.prisma.passenger.create({ data: {} }); + const normalBooking = await harness.prisma.booking.create({ + data: { + bookingRef: `WEB-CTRL-${Date.now()}`, + passengerId: passenger.id, + scheduleId: schedule.id, + status: "PENDING_PAYMENT", + totalMinor: 10_000, + contactPhone: "+251911234567", + source: "WEB", + }, + }); + await notificationsService.onBookingCreated({ booking: { id: normalBooking.id, bookingRef: normalBooking.bookingRef } }); + expect(smsClient.sendSms).toHaveBeenCalledTimes(2); // suppression didn't leak to non-reservation bookings + }); + it("PASSENGER path: the seat stays reserved (not publicly available) after the payment link is sent", async () => { // Regression for: unblockSeat() released the reservation's SeatBlock and confirmSeats() // was a no-op with no SeatHold to extend, so the seat had no SeatBlock, no SeatHold, and @@ -325,6 +395,87 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { ).rejects.toThrow(/already (held|booked)/i); }); + it("cancelReservationForSeat: cancels the pending booking, frees the seat, and kills the old pay link", async () => { + await resetAndSeedCore(harness.prisma); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-CANCEL-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id); + const result: any = await guestBookingService.issueBookingFromReservation( + seats[0].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any, + "staff-user-7", + ); + const payToken = result.booking.payToken; + + const cancelResult: any = await bookingsService.cancelReservationForSeat(seats[0].id, schedule.id, "staff-user-7"); + expect(cancelResult.cancelled).toBe(true); + expect(cancelResult.bookingRef).toBe(result.booking.bookingRef); + + const cancelledBooking = await harness.prisma.booking.findUnique({ where: { id: result.booking.id } }); + expect(cancelledBooking?.status).toBe("CANCELLED"); + + // The seat is genuinely free — a member of the public can now hold it. + await expect( + seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + passengers: [{ passengerId: "someone-else", seatId: seats[0].id }], + } as any), + ).resolves.toBeTruthy(); + + // The old payment link no longer works. + await expect(bookingsService.getByPayToken(payToken)).rejects.toThrow(/no longer awaiting payment/i); + }); + + it("cancelReservationForSeat 404s when there's no pending reservation for this seat", async () => { + await resetAndSeedCore(harness.prisma); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-CANCEL-404-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + await expect( + bookingsService.cancelReservationForSeat(seats[0].id, schedule.id, "staff-user-8"), + ).rejects.toThrow(/no pending reservation/i); + }); + + it("getSeatMap surfaces the bookingRef (PNR) for a seat with an active reservation — pending payment AND ticketed", async () => { + await resetAndSeedCore(harness.prisma); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-SEATMAP-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + // Seat 0: PASSENGER reservation — still PENDING_PAYMENT. + await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id); + const pending: any = await guestBookingService.issueBookingFromReservation( + seats[0].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any, + "staff-user-6", + ); + + // Seat 1: STAFF reservation — fee-waived, ticketed, CONFIRMED immediately. + await seatsService.blockSeat(seats[1].id, "Reserved for staff issue", schedule.id); + const staffResult: any = await guestBookingService.issueBookingFromReservation( + seats[1].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.STAFF }) as any, + "staff-user-6", + ); + + const seatMap: any = await seatsService.getSeatMap(schedule.id); + const flatSeats = seatMap.coaches.flatMap((c: any) => c.seats ?? []); + const pendingSeat = flatSeats.find((s: any) => s.id === seats[0].id); + const ticketedSeat = flatSeats.find((s: any) => s.id === seats[1].id); + + expect(pendingSeat.bookingRef).toBe(pending.booking.bookingRef); + expect(pendingSeat.reservationStatus).toBe("PENDING_PAYMENT"); + expect(pendingSeat.status).toBe("HELD"); // covered by the SeatHold, not a SeatBlock + + expect(ticketedSeat.bookingRef).toBe(staffResult.booking.bookingRef); + expect(ticketedSeat.reservationStatus).toBe("CONFIRMED"); + }); + it("requires a phone number for a PASSENGER booking", async () => { await resetAndSeedCore(harness.prisma); const dep = new Date(Date.now() + 3 * 60 * 60_000); diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index dcaf6261c..c74b69b0b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical } from 'lucide-react'; +import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical, Clock } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; @@ -28,6 +28,7 @@ interface Schedule { destinationStation?: { id: string; name: string }; coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>; isPackageOnly?: boolean; + liveStatus?: { delayMinutes: number } | null; } interface Train { @@ -454,6 +455,16 @@ export default function SchedulesPage() { {formatDateTime(schedule.arrivalAt)} ), }, + { + key: 'liveStatus.delayMinutes', + label: 'Delay', + sortable: true, + render: (schedule: Schedule) => { + const delay = schedule.liveStatus?.delayMinutes ?? 0; + if (delay <= 0) return On time; + return +{delay} min; + }, + }, { key: 'coachAssignments', label: 'Coaches', @@ -486,6 +497,15 @@ export default function SchedulesPage() { const [cancelConfirm, setCancelConfirm] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null }); + const applyDelayMutation = useMutation({ + mutationFn: ({ id, minutes }: { id: string; minutes: number }) => + apiClient.post(`/schedules/${id}/delay`, { delayMinutes: minutes }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules'] }), + }); + const [delayPrompt, setDelayPrompt] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null }); + const [delayMinutesInput, setDelayMinutesInput] = useState(''); + const [delayError, setDelayError] = useState(null); + const scheduleActions = [ { label: 'Edit', @@ -493,6 +513,17 @@ export default function SchedulesPage() { variant: 'secondary' as const, icon: Edit, }, + { + label: 'Report Delay', + onClick: (schedule: Schedule) => { + setDelayMinutesInput(''); + setDelayError(null); + setDelayPrompt({ isOpen: true, item: schedule }); + }, + variant: 'secondary' as const, + icon: Clock, + hidden: (schedule: Schedule) => schedule.status === 'CANCELLED', + }, { label: 'Cancel', onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }), @@ -653,6 +684,67 @@ export default function SchedulesPage() { isLoading={cancelScheduleMutation.isPending} /> + setDelayPrompt({ isOpen: false, item: null })} + title={`Report Delay${delayPrompt.item ? `: ${delayPrompt.item.originStation?.name ?? ''} → ${delayPrompt.item.destinationStation?.name ?? ''}` : ''}`} + size="sm" + > + {delayPrompt.item && ( +
{ + e.preventDefault(); + const minutes = parseInt(delayMinutesInput, 10); + if (Number.isNaN(minutes)) { setDelayError('Enter a whole number of minutes.'); return; } + try { + await applyDelayMutation.mutateAsync({ id: delayPrompt.item!.id, minutes }); + setDelayPrompt({ isOpen: false, item: null }); + } catch (err: any) { + setDelayError(err?.response?.data?.message || 'Failed to apply delay.'); + } + }} + className="space-y-4" + > + {delayError && ( +
{delayError}
+ )} +
+ Current reported delay + {(delayPrompt.item.liveStatus?.delayMinutes ?? 0) > 0 ? ( + +{delayPrompt.item.liveStatus?.delayMinutes} min + ) : ( + On time + )} +
+
+ + setDelayMinutesInput(e.target.value)} + placeholder="e.g. 60" + className="input" + required + autoFocus + /> +

+ Adds to the current reported delay above and pushes every downstream station's + check-in cutoff back by this many minutes. Use a negative number to correct an + over-reported delay. +

+
+
+ setDelayPrompt({ isOpen: false, item: null })}> + Cancel + + + Apply Delay + +
+ + )} +
+ setDeleteConfirm({ isOpen: false, item: null })} diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index 3ce651ac8..092e26309 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -39,7 +39,7 @@ export default function SeatsPage() { phone: '', email: '', }); - const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string } | null>(null); + const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string; bookingRef?: string } | null>(null); const queryClient = useQueryClient(); const { data: schedulesData } = useQuery({ @@ -126,13 +126,19 @@ export default function SeatsPage() { bookingsApi.issueFromReservation(seatId, data), onSuccess: (result: any) => { invalidateSeatData(); - setIssueBookingResult({ payUrl: result?.payUrl }); - if (!result?.payUrl) { - // STAFF booking — nothing further to show the admin, close immediately. - setShowIssueBookingModal(false); - setSelectedSeat(null); - setIssueBookingCoach(null); - } + // Always show the reference — the PASSENGER path also needs the PNR alongside the + // pay link (staff need to know which booking a seat belongs to, whether it's + // awaiting payment or already ticketed), so no longer auto-closing for STAFF. + setIssueBookingResult({ payUrl: result?.payUrl, bookingRef: result?.booking?.bookingRef }); + }, + }); + + // Cancels a seat's still-unpaid reservation (payment link sent) and releases the seat — + // distinct from unblockMutation, which only handles a plain SeatBlock (no booking involved). + const cancelReservationMutation = useMutation({ + mutationFn: (seatId: string) => bookingsApi.cancelReservation(seatId, selectedSchedule), + onSuccess: () => { + invalidateSeatData(); }, }); @@ -222,6 +228,16 @@ export default function SeatsPage() { } }; + // Distinct from handleUnblock — this seat has no SeatBlock (issuing the reservation already + // released it), it's HELD by the SeatHold behind an unpaid booking. Cancelling that booking + // invalidates its payment link immediately, so warn staff explicitly about that. + const handleCancelReservation = async (seat: any) => { + if (!selectedSchedule) return; + if (confirm(`Cancel the reservation for seat ${seat.seatNumber} (PNR ${seat.bookingRef})? The payment link already sent to the traveler will stop working.`)) { + await cancelReservationMutation.mutateAsync(seat.id); + } + }; + const handleIssueBooking = (seat: any, coach: any) => { if (activeTab !== 'schedule' || !selectedSchedule) { alert('Select a specific schedule (Schedule tab) to issue a booking for a reserved seat.'); @@ -433,6 +449,7 @@ export default function SeatsPage() { handleBlock={handleBlock} handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} + handleCancelReservation={handleCancelReservation} handleUndoRemove={handleUndoRemove} handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} @@ -529,6 +546,7 @@ export default function SeatsPage() { handleBlock={handleBlock} handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} + handleCancelReservation={handleCancelReservation} handleUndoRemove={handleUndoRemove} handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} @@ -552,6 +570,7 @@ export default function SeatsPage() { handleBlock={handleBlock} handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} + handleCancelReservation={handleCancelReservation} handleUndoRemove={handleUndoRemove} handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} @@ -888,12 +907,25 @@ export default function SeatsPage() { title="Issue Booking" size="md" > - {issueBookingResult?.payUrl ? ( + {issueBookingResult ? (

- Booking created. A payment link has been sent via SMS to the traveler. + {issueBookingResult.payUrl + ? 'Booking created. A payment link has been sent via SMS to the traveler.' + : 'Booking confirmed and ticketed.'}

-
{issueBookingResult.payUrl}
+ {issueBookingResult.bookingRef && ( +
+ +
{issueBookingResult.bookingRef}
+
+ )} + {issueBookingResult.payUrl && ( +
+ +
{issueBookingResult.payUrl}
+
+ )}
{ @@ -1231,6 +1263,7 @@ interface SeatIconProps { handleBlock: (seat: any) => void; handleRemoveSeat: (seat: any) => void; handleUnblock: (seat: any) => void; + handleCancelReservation: (seat: any) => void; handleUndoRemove: (seat: any) => void; handleSetMaintenance: (seat: any) => void; handleClearMaintenance: (seat: any) => void; @@ -1248,6 +1281,7 @@ function SeatIcon({ handleBlock, handleRemoveSeat, handleUnblock, + handleCancelReservation, handleUndoRemove, handleSetMaintenance, handleClearMaintenance, @@ -1285,6 +1319,10 @@ function SeatIcon({ const color = getSeatColor(status); const canBlock = status === 'AVAILABLE'; const canUnblock = status === 'BLOCKED'; + // A HELD seat with a bookingRef + PENDING_PAYMENT is a backoffice reservation awaiting + // payment (see resolveActiveReservations) — issuing it already released the SeatBlock, so + // it's not reachable via canUnblock anymore; this is the seat's own release path. + const canCancelReservation = status === 'HELD' && !!seat.bookingRef && seat.reservationStatus === 'PENDING_PAYMENT'; const canMaintenance = false; const canClearMaintenance = status === 'UNDER_MAINTENANCE'; @@ -1299,7 +1337,7 @@ function SeatIcon({ {isBedCoach ? (
@@ -1307,14 +1345,23 @@ function SeatIcon({ ) : (
)} - {(canBlock || canUnblock || canMaintenance || canClearMaintenance) && ( + {seat.bookingRef && ( + + {seat.bookingRef} + + )} + + {(canBlock || canUnblock || canCancelReservation || canMaintenance || canClearMaintenance) && (
{canBlock && ( <> @@ -1334,6 +1381,15 @@ function SeatIcon({ )} + {canCancelReservation && ( + + )} {canUnblock && ( <> + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx index 3d8d8fe8c..58eb5a3cd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx @@ -4,10 +4,9 @@ import { Button, Card, Group, - Loader, + Modal, Select, Stack, - Switch, Tabs, Text, TextInput, @@ -46,6 +45,7 @@ import { } from "@edr/ui-common"; import TransferFulfillModal from "./TransferFulfillModal"; +import TransferHistoryPanel from "./TransferHistoryPanel"; import { TransferCloseShortModal, TransferRequestFormModal, @@ -103,6 +103,9 @@ export default function WagonTransfersPage() { const [formOpen, setFormOpen] = useState(false); const [carryOver, setCarryOver] = useState(null); const [fulfilling, setFulfilling] = useState(null); + const [withdrawing, setWithdrawing] = useState( + null, + ); const [closingShort, setClosingShort] = useState( null, ); @@ -269,15 +272,7 @@ export default function WagonTransfersPage() { radius="md" variant="subtle" color="red" - loading={cancel.isPending} - onClick={async () => { - try { - await cancel.mutateAsync({ id: r.id }); - toast.success("Request withdrawn"); - } catch { - // interceptor surfaces the reason - } - }} + onClick={() => setWithdrawing(r)} > Withdraw @@ -494,132 +489,53 @@ export default function WagonTransfersPage() { } }} /> + setWithdrawing(null)} + radius="md" + title="Withdraw this request?" + > + {!withdrawing ? null : ( + + + {yardLabel(withdrawing.fromYard)} →{" "} + {yardLabel(withdrawing.toYard)} ·{" "} + {wagonTypeLabel(withdrawing.wagonType)} ·{" "} + {withdrawing.quantity} wagon(s) + + + The source yard stops seeing it. Withdrawing can't be undone — + raise a new request if you still need the wagons. + + + + + + + )} + ); } - -/** - * Who moved what. A staffer sees their own activity; holders of - * `transfer_history_all` can widen it to every staffer (the backend enforces - * the scope regardless of the toggle). - */ -function TransferHistoryPanel() { - const { user } = useAuth(); - const canSeeAll = hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll); - const [allStaff, setAllStaff] = useState(false); - const [page, setPage] = useState(1); - const scopeAll = canSeeAll && allStaff; - - const mine = useQuery({ - ...api.wagonTransferRequests.history.queryOptions({ - input: { page, pageSize: 20 }, - }), - enabled: !scopeAll, - }); - const all = useQuery({ - ...api.wagonTransferRequests.historyAll.queryOptions({ - input: { page, pageSize: 20 }, - }), - enabled: scopeAll, - }); - const source = scopeAll ? all : mine; - const requests = source.data?.requests ?? []; - const movements = source.data?.movements ?? []; - const meta = source.data?.meta; - - return ( - - - - Transfer history - {canSeeAll ? ( - { - setAllStaff(e.currentTarget.checked); - setPage(1); - }} - /> - ) : null} - - - {source.isLoading ? ( - - - - ) : ( - - - - Requests ({meta?.requestsTotal ?? 0}) - - {requests.length === 0 ? ( - - Nothing yet. - - ) : ( - requests.map((r) => ( - - - {yardLabel(r.fromYard)} → {yardLabel(r.toYard)} ·{" "} - {r.fulfilledQuantity}/{r.quantity} - - - - )) - )} - - - - - Wagons moved ({meta?.movementsTotal ?? 0}) - - {movements.length === 0 ? ( - - Nothing yet. - - ) : ( - movements.map((m) => ( - - - {m.wagon?.wagonNumber ?? "Wagon"} · {yardLabel(m.fromYard)} →{" "} - {yardLabel(m.toYard)} - - - {fmtDateTime(m.occurredAt)} - - - )) - )} - - - )} - - - - - Page {meta?.page ?? page} of {meta?.totalPages ?? 1} - - - - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 52ba5c696..a28e15cd2 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -170,6 +170,7 @@ import { } from "./payments.service"; import { routesService, + type RouteListFilters, type RouteRecord, type SaveRoutePayload, type YardRef, @@ -1507,6 +1508,13 @@ export const api = { (input) => ["routes", input?.status ?? "all"], ), + listPaged: endpoint>( + "routes", + "listPaged", + (filters) => routesService.getPaged(filters).then((r) => r.data), + (filters) => ["routes", "paged", filters], + ), + yards: endpoint( "routes", "yards", @@ -2082,6 +2090,16 @@ export const api = { ({ slug, filters }) => [...QUERY_KEYS.FLEET.list(slug), filters ?? {}], ), + listPaged: endpoint< + { slug: FleetResourceSlug; filters?: FleetListFilters }, + PaginatedResponse + >( + "fleet", + "listPaged", + ({ slug, filters }) => fleetService.listPaged(slug, filters), + ({ slug, filters }) => [...QUERY_KEYS.FLEET.list(slug), "paged", filters ?? {}], + ), + create: endpoint< { slug: FleetResourceSlug; data: Record }, unknown diff --git a/apps/edr-freight-web/backoffice/src/services/fleet/fleet.service.ts b/apps/edr-freight-web/backoffice/src/services/fleet/fleet.service.ts index 85d3ce4a3..8d4e60dd3 100644 --- a/apps/edr-freight-web/backoffice/src/services/fleet/fleet.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/fleet/fleet.service.ts @@ -1,3 +1,5 @@ +import type { PaginatedResponse } from "@edr/types"; + import { cargoService, type Cargo } from "@/services/cargoService"; import { containerService, type Container } from "@/services/containerService"; import { @@ -28,6 +30,20 @@ const listHandlers: Record< drivers: (filters) => driversService.getAll(filters ?? {}).then((r) => r.data), }; +/** + * Server-paginated slugs. Everything else still lists in full and pages in the + * browser — add an entry here once its API grows a `/paged` endpoint. + */ +const pagedHandlers: Partial< + Record Promise>> +> = { + locomotives: (filters) => locomotivesService.getPaged(filters).then((r) => r.data), + wagons: (filters) => wagonService.getPaged(filters).then((r) => r.data), +}; + +export const isFleetServerPaginated = (slug: FleetResourceSlug): boolean => + slug in pagedHandlers; + const createHandlers: Record) => Promise> = { locomotives: (data) => locomotivesService.create(data), trains: (data) => trainService.create(data), @@ -63,6 +79,12 @@ const removeHandlers: Record Promise export const fleetService = { list: (slug: FleetResourceSlug, filters?: FleetListFilters) => listHandlers[slug](filters), + /** Only for slugs in `pagedHandlers` — guard with `isFleetServerPaginated`. */ + listPaged: (slug: FleetResourceSlug, filters: FleetListFilters = {}) => { + const handler = pagedHandlers[slug]; + if (!handler) throw new Error(`Fleet resource "${slug}" has no paginated list endpoint`); + return handler(filters); + }, create: (slug: FleetResourceSlug, data: Record) => createHandlers[slug](data), update: (slug: FleetResourceSlug, id: string, data: Record) => updateHandlers[slug](id, data), diff --git a/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts b/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts index 5dec1c05e..fa80ec86c 100644 --- a/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts @@ -1,3 +1,5 @@ +import type { PaginatedResponse } from '@edr/types'; + import { api as apiClient } from '../auth/http'; import { URL_CONSTANTS } from '@/constants/URLS'; @@ -19,8 +21,31 @@ export interface LocomotiveListFilters { excludeCoupled?: boolean; /** With excludeCoupled: keep THIS train's own coupled locos in the list. */ excludeTrainId?: string; + /** Free-text over code + name — only honoured by `getPaged`. */ + search?: string; + /** Registration day range (YYYY-MM-DD), both ends inclusive. */ + createdFrom?: string; + createdTo?: string; + /** Only read by `getPaged`. */ + page?: number; + pageSize?: number; } +const locomotiveListQuery = (filters: LocomotiveListFilters): string => { + const params = new URLSearchParams(); + if (filters.status) params.set('status', filters.status); + if (filters.currentYardId) params.set('currentYardId', filters.currentYardId); + if (filters.excludeCoupled) params.set('excludeCoupled', 'true'); + if (filters.excludeTrainId) params.set('excludeTrainId', filters.excludeTrainId); + if (filters.search?.trim()) params.set('search', filters.search.trim()); + if (filters.createdFrom) params.set('createdFrom', filters.createdFrom); + if (filters.createdTo) params.set('createdTo', filters.createdTo); + if (filters.page) params.set('page', String(filters.page)); + if (filters.pageSize) params.set('pageSize', String(filters.pageSize)); + const qs = params.toString(); + return qs ? `?${qs}` : ''; +}; + export interface Locomotive { id: string; code: string; @@ -45,17 +70,15 @@ export type SaveLocomotivePayload = Omit< >; export const locomotivesService = { - getAll: (filters: LocomotiveListFilters = {}) => { - const params = new URLSearchParams(); - if (filters.status) params.set('status', filters.status); - if (filters.currentYardId) params.set('currentYardId', filters.currentYardId); - if (filters.excludeCoupled) params.set('excludeCoupled', 'true'); - if (filters.excludeTrainId) params.set('excludeTrainId', filters.excludeTrainId); - const qs = params.toString(); - return apiClient.get( - `${URL_CONSTANTS.LOCOMOTIVES.BASE}${qs ? `?${qs}` : ''}`, - ); - }, + getAll: (filters: LocomotiveListFilters = {}) => + apiClient.get( + `${URL_CONSTANTS.LOCOMOTIVES.BASE}${locomotiveListQuery(filters)}`, + ), + /** Same filters as `getAll` plus search, server-paginated ({items, meta}). */ + getPaged: (filters: LocomotiveListFilters = {}) => + apiClient.get>( + `${URL_CONSTANTS.LOCOMOTIVES.BASE}/paged${locomotiveListQuery(filters)}`, + ), getById: (id: string) => apiClient.get(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id)), create: (data: Partial) => apiClient.post(URL_CONSTANTS.LOCOMOTIVES.BASE, data), diff --git a/apps/edr-freight-web/backoffice/src/services/routes.service.ts b/apps/edr-freight-web/backoffice/src/services/routes.service.ts index f279f3f1e..d3795d83f 100644 --- a/apps/edr-freight-web/backoffice/src/services/routes.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/routes.service.ts @@ -1,3 +1,5 @@ +import type { PaginatedResponse } from '@edr/types'; + import { api as apiClient } from '../auth/http'; import { URL_CONSTANTS } from '@/constants/URLS'; @@ -78,9 +80,22 @@ export const ROUTE_STATUS_OPTIONS: Array<{ value: RouteStatus; label: string }> { value: 'STOP_WORKING', label: 'Stop working' }, ]; +export interface RouteListFilters { + status?: RouteStatus; + search?: string; + page?: number; + pageSize?: number; +} + export const routesService = { getAll: (params?: { status?: RouteStatus; search?: string }) => apiClient.get(URL_CONSTANTS.ROUTES.BASE, { params }), + /** Same filters as `getAll`, server-paginated ({items, meta}). */ + getPaged: (params: RouteListFilters = {}) => + apiClient.get>( + `${URL_CONSTANTS.ROUTES.BASE}/paged`, + { params }, + ), getById: (id: string) => apiClient.get(URL_CONSTANTS.ROUTES.BY_ID(id)), create: (data: SaveRoutePayload) => apiClient.post(URL_CONSTANTS.ROUTES.BASE, data), update: (id: string, data: Partial) => diff --git a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts index d562625a9..96791e13d 100644 --- a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts @@ -41,10 +41,35 @@ export interface WagonListFilters { currentYardId?: string; wagonTypeId?: string; trainId?: string; + /** Drop wagons already coupled to a built train — only loose ones can be taken. */ + unassigned?: boolean; /** Run number — matches a wagon whose export OR import run equals it. */ trainNumber?: string; + /** Registration day range (YYYY-MM-DD), both ends inclusive. */ + createdFrom?: string; + createdTo?: string; + /** Only read by `getPaged`. */ + page?: number; + pageSize?: number; } +const wagonListQuery = (filters: WagonListFilters): string => { + const params = new URLSearchParams(); + if (filters.search?.trim()) params.set('search', filters.search.trim()); + if (filters.status) params.set('status', filters.status); + if (filters.currentYardId) params.set('currentYardId', filters.currentYardId); + if (filters.wagonTypeId) params.set('wagonTypeId', filters.wagonTypeId); + if (filters.trainId) params.set('trainId', filters.trainId); + if (filters.unassigned) params.set('unassigned', 'true'); + if (filters.trainNumber) params.set('trainNumber', filters.trainNumber); + if (filters.createdFrom) params.set('createdFrom', filters.createdFrom); + if (filters.createdTo) params.set('createdTo', filters.createdTo); + if (filters.page) params.set('page', String(filters.page)); + if (filters.pageSize) params.set('pageSize', String(filters.pageSize)); + const qs = params.toString(); + return qs ? `?${qs}` : ''; +}; + /** * One row of the wagon_movements ledger: every physical relocation between * yards — a booking's loaded leg, an empty reposition ride, or a manual staff @@ -70,17 +95,11 @@ export interface WagonMovementRecord { } export const wagonService = { - getAll: (filters: WagonListFilters = {}) => { - const params = new URLSearchParams(); - if (filters.search?.trim()) params.set('search', filters.search.trim()); - if (filters.status) params.set('status', filters.status); - if (filters.currentYardId) params.set('currentYardId', filters.currentYardId); - if (filters.wagonTypeId) params.set('wagonTypeId', filters.wagonTypeId); - if (filters.trainId) params.set('trainId', filters.trainId); - if (filters.trainNumber) params.set('trainNumber', filters.trainNumber); - const qs = params.toString(); - return apiClient.get(`/wagons${qs ? `?${qs}` : ''}`); - }, + getAll: (filters: WagonListFilters = {}) => + apiClient.get(`/wagons${wagonListQuery(filters)}`), + /** Same filters as `getAll`, server-paginated ({items, meta}). */ + getPaged: (filters: WagonListFilters = {}) => + apiClient.get>(`/wagons/paged${wagonListQuery(filters)}`), getById: (id: string) => apiClient.get(`/wagons/${id}`), getMovements: (id: string) => apiClient.get(`/wagons/${id}/movements`), From 481878e55305df71e556ecc3afd8a03ddaa4cf2c Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 27 Jul 2026 21:27:36 +0000 Subject: [PATCH 11/40] paginate wagons, locomotives and routes lists server-side --- .../wagons/dto/list-wagons-query.dto.ts | 11 +-------- .../src/modules/wagons/wagons.controller.ts | 12 +++------- .../src/modules/wagons/wagons.service.ts | 21 ++++++----------- .../src/pages/fleet/FleetResourcePage.tsx | 10 ++++++-- .../backoffice/src/services/api.ts | 12 ++++++++-- .../src/services/fleet/fleet.service.ts | 4 ++-- .../backoffice/src/services/wagon.service.ts | 23 +++++++++++++++---- 7 files changed, 49 insertions(+), 44 deletions(-) diff --git a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts index ae48a2713..328a6eaa8 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts @@ -72,16 +72,7 @@ export class ListWagonsQueryDto { @Min(1) page?: number; - @ApiPropertyOptional({ minimum: 1, maximum: 500 }) - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - @Max(500) - limit?: number; - - /** Page size for `GET /wagons/paged`; the legacy `limit` still drives `GET /wagons`. */ - @ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 }) + @ApiPropertyOptional({ default: 10, minimum: 1, maximum: 100 }) @IsOptional() @Type(() => Number) @IsInt() diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts index 3b8a7077b..c792057d8 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -39,19 +39,13 @@ export class WagonsController { @Get() @StaffReference() - @ApiOperation({ summary: 'List all wagons' }) + @ApiOperation({ + summary: 'List wagons, paginated ({items, meta}) — 10 per page by default', + }) findAll(@Query() query: ListWagonsQueryDto) { return this.wagonsService.findAll(query); } - // Must be declared before @Get(':id') so the path isn't captured as an id. - @Get('paged') - @StaffReference() - @ApiOperation({ summary: 'List wagons, paginated ({items, meta})' }) - findAllPaged(@Query() query: ListWagonsQueryDto) { - return this.wagonsService.findAllPaged(query); - } - @Get(':id') @StaffReference() @ApiOperation({ summary: 'Get a wagon by ID' }) diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 98f5c32b0..f51846e2d 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -115,20 +115,13 @@ export class WagonsService { return qb; } - async findAll(query: ListWagonsQueryDto = {}): Promise { - const qb = this.buildListQuery(query); - - if (query.page && query.limit) { - qb.skip((Number(query.page) - 1) * Number(query.limit)); - } - if (query.limit) qb.take(Number(query.limit)); - - return qb.getMany(); - } - - /** Same filters as `findAll`, on the shared `{items, meta}` list envelope. */ - findAllPaged(query: ListWagonsQueryDto = {}): Promise> { - return paginateQuery(this.buildListQuery(query), query); + /** + * The wagon list is always a page. Callers that genuinely need every row + * (yard workspace, coupling pickers) walk the pages client-side — see + * `wagonService.listAll` in the backoffice. + */ + findAll(query: ListWagonsQueryDto = {}): Promise> { + return paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 }); } async findById(id: string): Promise { diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 83a931b18..213501d74 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -155,9 +155,15 @@ const FleetResourcePage = () => { const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useQuery( api.cargoTypes.list.queryOptions({ staleTime: Infinity }), ); - const { data: wagons = [], isLoading: wagonsLoading } = useQuery( - api.wagons.list.queryOptions({ input: {} }), + // Whole-fleet list for the "Wagon" form select — page-walked, so only fetch it + // where a form actually offers that select (containers), not on every slug. + const needsWagonOptions = Boolean( + config?.formFields.some((field) => field.dynamicOptions === "wagons"), ); + const { data: wagons = [], isLoading: wagonsLoading } = useQuery({ + ...api.wagons.list.queryOptions({ input: {} }), + enabled: needsWagonOptions, + }); const { data: containers = [], isLoading: containersLoading } = useQuery( api.containers.list.queryOptions(), ); diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index a28e15cd2..ef84826aa 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -1632,17 +1632,25 @@ export const api = { }, wagons: { + /** Every match, page-walked — for pickers and yard views. Lists use `listPaged`. */ list: endpoint<{ filters?: WagonListFilters }, Wagon[]>( "wagons", "list", - ({ filters }) => wagonService.getAll(filters ?? {}).then((r) => r.data), + ({ filters }) => wagonService.listAll(filters ?? {}), ({ filters }) => ["wagons", "list", filters ?? {}], ), + listPaged: endpoint<{ filters?: WagonListFilters }, PaginatedResponse>( + "wagons", + "listPaged", + ({ filters }) => wagonService.getAll(filters ?? {}).then((r) => r.data), + ({ filters }) => ["wagons", "listPaged", filters ?? {}], + ), + listByTrain: endpoint<{ trainId: string }, Wagon[]>( "wagons", "listByTrain", - ({ trainId }) => wagonService.getByTrain(trainId).then((r) => r.data), + ({ trainId }) => wagonService.getByTrain(trainId), ({ trainId }) => ["wagons", "train", trainId], ), diff --git a/apps/edr-freight-web/backoffice/src/services/fleet/fleet.service.ts b/apps/edr-freight-web/backoffice/src/services/fleet/fleet.service.ts index 8d4e60dd3..e47160176 100644 --- a/apps/edr-freight-web/backoffice/src/services/fleet/fleet.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/fleet/fleet.service.ts @@ -23,7 +23,7 @@ const listHandlers: Record< > = { locomotives: (filters) => locomotivesService.getAll(filters ?? {}).then((r) => r.data), trains: () => trainService.getAll().then((r) => r.data), - wagons: (filters) => wagonService.getAll(filters ?? {}).then((r) => r.data), + wagons: (filters) => wagonService.listAll(filters ?? {}), containers: () => containerService.getAll().then((r) => r.data), cargoes: () => cargoService.getAll().then((r) => r.data), vehicles: (filters) => vehiclesService.getAll(filters ?? {}).then((r) => r.data), @@ -38,7 +38,7 @@ const pagedHandlers: Partial< Record Promise>> > = { locomotives: (filters) => locomotivesService.getPaged(filters).then((r) => r.data), - wagons: (filters) => wagonService.getPaged(filters).then((r) => r.data), + wagons: (filters) => wagonService.getAll(filters).then((r) => r.data), }; export const isFleetServerPaginated = (slug: FleetResourceSlug): boolean => diff --git a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts index 96791e13d..9a5127f2b 100644 --- a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts @@ -95,15 +95,28 @@ export interface WagonMovementRecord { } export const wagonService = { + /** One page ({items, meta}); 10 rows unless `pageSize` says otherwise. */ getAll: (filters: WagonListFilters = {}) => - apiClient.get(`/wagons${wagonListQuery(filters)}`), - /** Same filters as `getAll`, server-paginated ({items, meta}). */ - getPaged: (filters: WagonListFilters = {}) => - apiClient.get>(`/wagons/paged${wagonListQuery(filters)}`), + apiClient.get>(`/wagons${wagonListQuery(filters)}`), + /** + * Every matching wagon, page-walked at the API's 100-row cap. For the pickers + * and yard views that filter the whole fleet in the browser — a list page + * should use `getAll` and show the real page controls instead. + */ + listAll: async (filters: WagonListFilters = {}): Promise => { + const pageSize = 100; + const first = await wagonService.getAll({ ...filters, page: 1, pageSize }); + const items = [...first.data.items]; + for (let page = 2; page <= (first.data.meta.totalPages ?? 1); page += 1) { + const next = await wagonService.getAll({ ...filters, page, pageSize }); + items.push(...next.data.items); + } + return items; + }, getById: (id: string) => apiClient.get(`/wagons/${id}`), getMovements: (id: string) => apiClient.get(`/wagons/${id}/movements`), - getByTrain: (trainId: string) => apiClient.get(`/wagons?trainId=${trainId}`), + getByTrain: (trainId: string) => wagonService.listAll({ trainId }), assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) => apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }), unassign: (wagonId: string) => apiClient.post(`/wagons/${wagonId}/unassign-train`), From 2429f6b6292aa2fce32459f584847272398cd84e Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 28 Jul 2026 05:02:58 +0000 Subject: [PATCH 12/40] implement contract cancellation feature and update contract statuses - Added functionality to cancel contracts, allowing users to provide a reason for cancellation. - Updated contract statuses to include SUSPENDED and changed CLOSED to COMPLETED. - Enhanced the UI to reflect the new cancellation option and updated messaging for contract statuses. - Refactored contract booking actions to accommodate changes in booking logic for ONE_TIME and GENERAL contracts. - Removed clearance document management from the contract detail page, as it is now handled per booking. - Introduced a SQL script to reset bookings and train schedules for development purposes. --- .../3000000000000-AddContractSuspension.ts | 25 + .../booking-transition.accept.spec.ts | 3 +- .../booking-transition.clearance.spec.ts | 9 +- .../booking-transition.operation.spec.ts | 6 +- .../bookings/booking-transition.service.ts | 21 +- .../modules/bookings/bookings.repository.ts | 85 +- .../modules/bookings/clearance.util.spec.ts | 6 +- .../src/modules/bookings/clearance.util.ts | 15 +- .../contracts/booking-clearance.service.ts | 18 +- .../contracts/booking-request.service.ts | 5 + .../contract-booking.completion.spec.ts | 77 +- .../contract-booking.consolidation.spec.ts | 1 - .../contracts/contract-booking.service.ts | 295 +++--- .../contracts/contract-clearance.service.ts | 118 +-- .../contract-duplicate-guard.spec.ts | 79 ++ .../contracts/contract-notifier.service.ts | 27 + .../contracts/contract-suspension.spec.ts | 132 +++ .../contracts/contract-transition.service.ts | 182 +++- .../modules/contracts/contracts.controller.ts | 95 +- .../modules/contracts/contracts.repository.ts | 36 +- .../modules/contracts/contracts.service.ts | 71 +- .../modules/contracts/dto/approve-step.dto.ts | 14 + .../entities/contract-review-note.entity.ts | 6 + .../contracts/entities/contract.entity.ts | 24 + .../booking-batch.service.spec.ts | 118 +++ .../train-scheduling/booking-batch.service.ts | 212 ++++- .../booking-journey.service.ts | 14 + .../create-container-train-schedule.dto.ts | 4 +- .../train-capacity.util.spec.ts | 63 +- .../train-scheduling/train-capacity.util.ts | 47 +- .../train-scheduling.service.ts | 115 ++- .../wagon-stock-ledger.util.spec.ts | 70 ++ .../wagon-stock-ledger.util.ts | 86 ++ .../entities/train-set-locomotive.entity.ts | 2 +- .../train-sets/entities/train-set.entity.ts | 2 +- .../src/modules/trains/dto/build-train.dto.ts | 4 +- .../dto/update-train-locomotives.dto.ts | 4 +- .../entities/train-locomotive.entity.ts | 2 +- .../modules/trains/entities/train.entity.ts | 2 +- .../trains/train-builder.controller.ts | 2 +- .../modules/trains/train-builder.service.ts | 56 +- .../src/seed/freight-permissions.registry.ts | 5 + apps/edr-freight-web/backoffice/src/App.tsx | 4 +- .../contracts/ContractActionsToolbar.tsx | 166 +++- .../contracts/ContractApprovalStepsCard.tsx | 23 + .../contracts/ContractDocumentEditorModal.tsx | 18 +- .../contracts/GlCreateBookingForm.tsx | 19 +- .../fleet/WagonMovementHistoryModal.tsx | 19 +- .../trainBuilder/BuildTrainModal.tsx | 14 +- .../trainBuilder/ChangeLocomotivesModal.tsx | 10 +- .../trainBuilder/ConsistWagonList.tsx | 60 +- .../components/trainBuilder/trainStatus.ts | 52 + .../backoffice/src/constants/URLS.ts | 8 +- .../contracts/contract-status.config.ts | 19 +- .../src/hooks/contracts/useContracts.ts | 42 +- .../backoffice/src/lib/permissions.ts | 1 + .../bookings/DocumentClearanceDetailPage.tsx | 6 +- .../contracts/ClearanceDocumentsPage.tsx | 322 +------ .../contracts/ContractClearanceListPage.tsx | 894 ++++-------------- .../contracts/GlDjiboutiClearanceListPage.tsx | 413 ++------ .../trainBuilder/TrainBuilderDetailPage.tsx | 59 +- .../src/services/contracts.service.ts | 60 +- apps/edr-freight-web/portal/src/App.tsx | 5 - .../ContractClearanceAction.tsx | 90 -- .../ContractCustomerAction.tsx | 20 +- .../deriveContractCustomerAction.ts | 94 -- .../portal/src/constants/URLS.ts | 1 + .../portal/src/pages/MyPortalPage/actions.ts | 27 +- .../components/ActionNeededSection.tsx | 158 +--- .../src/pages/MyPortalPage/constants.ts | 4 +- .../BookingClearanceWorkflowBanner.tsx | 389 +++++++- .../BookingDetailPage/ReadonlyBookingView.tsx | 5 + .../pages/contracts/ContractClearanceFlow.tsx | 81 -- .../contracts/ContractClearancePanel.tsx | 355 ------- .../ContractClearanceWorkflowBanner.tsx | 347 ------- .../pages/contracts/ContractDetailPage.tsx | 727 +++----------- .../pages/contracts/ContractStepBanner.tsx | 6 +- .../contracts/contract-booking-action.ts | 31 +- .../src/pages/contracts/contract-ui.tsx | 3 +- .../portal/src/services/contracts.service.ts | 9 + packages/types/src/freight/contracts.ts | 15 + packages/types/src/freight/index.ts | 2 + reset-bookings-trains.sql | 76 ++ 83 files changed, 3083 insertions(+), 3729 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3000000000000-AddContractSuspension.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/contract-duplicate-guard.spec.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/contract-suspension.spec.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts delete mode 100644 apps/edr-freight-web/portal/src/components/customer-actions/ContractClearanceAction.tsx delete mode 100644 apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceFlow.tsx delete mode 100644 apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx delete mode 100644 apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceWorkflowBanner.tsx create mode 100644 reset-bookings-trains.sql diff --git a/apps/edr-freight-api/src/migrations/3000000000000-AddContractSuspension.ts b/apps/edr-freight-api/src/migrations/3000000000000-AddContractSuspension.ts new file mode 100644 index 000000000..0e0484345 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3000000000000-AddContractSuspension.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Backoffice contract suspension (reversible freeze at any post-signature step) + * and customer-initiated contract cancellation. + * + * Only one new column is needed: the status to restore when the suspension is + * lifted. The reason and the actor already have a home — contract_review_notes + * rows with note_type SUSPENSION / SUSPENSION_LIFTED / CANCELLATION. + */ +export class AddContractSuspension3000000000000 implements MigrationInterface { + name = 'AddContractSuspension3000000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS status_before_suspension varchar(40);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contracts DROP COLUMN IF EXISTS status_before_suspension;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts index 068ed53af..871d03c72 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -37,7 +37,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => { {} as never, // fileUploadSettingsService {} as never, // bookingBatchService bookingsService as never, - { isPhasedGeneralCustomsBooking: () => false } as never, + { isPhasedCustomsBooking: () => false } as never, {} as never, // workflowService {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, @@ -59,6 +59,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => { clearanceDocsUploadedToStaff: jest.fn(), dutySlipUploadedToStaff: jest.fn(), } as never, // notifier + { emit: jest.fn() } as never, // events ); return { service, bookingsRepository, ruleEngineService, contractService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index 890ae6344..0cc7e59e4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -46,7 +46,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => { fileUploadSettingsService as never, {} as never, // bookingBatchService bookingsService as never, - { isPhasedGeneralCustomsBooking: () => false } as never, + { isPhasedCustomsBooking: () => false } as never, {} as never, // workflowService {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, @@ -68,6 +68,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => { clearanceDocsUploadedToStaff: jest.fn(), dutySlipUploadedToStaff: jest.fn(), } as never, // notifier + { emit: jest.fn() } as never, // events ); return { service, bookingsRepository }; } @@ -149,7 +150,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', ( fileUploadSettingsService as never, {} as never, // bookingBatchService bookingsService as never, - { isPhasedGeneralCustomsBooking: () => false } as never, + { isPhasedCustomsBooking: () => false } as never, {} as never, // workflowService {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, @@ -171,6 +172,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', ( clearanceDocsUploadedToStaff: jest.fn(), dutySlipUploadedToStaff: jest.fn(), } as never, // notifier + { emit: jest.fn() } as never, // events ); return { service, bookingsRepository }; } @@ -238,7 +240,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields fileUploadSettingsService as never, {} as never, // bookingBatchService bookingsService as never, - { isPhasedGeneralCustomsBooking: () => false } as never, + { isPhasedCustomsBooking: () => false } as never, {} as never, // workflowService {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, @@ -260,6 +262,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields clearanceDocsUploadedToStaff: jest.fn(), dutySlipUploadedToStaff: jest.fn(), } as never, // notifier + { emit: jest.fn() } as never, // events ); return { service, bookingsRepository, filesService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index cbbb999ef..5f82a8e3b 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -48,7 +48,7 @@ describe('BookingTransitionService — operation review', () => { {} as never, // fileUploadSettingsService bookingBatchService as never, bookingsService as never, - { isPhasedGeneralCustomsBooking: () => false } as never, + { isPhasedCustomsBooking: () => false } as never, {} as never, // workflowService invoiceService as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, @@ -70,6 +70,7 @@ describe('BookingTransitionService — operation review', () => { clearanceDocsUploadedToStaff: jest.fn(), dutySlipUploadedToStaff: jest.fn(), } as never, // notifier + { emit: jest.fn() } as never, // events ); return { service, bookingsRepository, bookingBatchService, invoiceService }; } @@ -164,11 +165,12 @@ describe('BookingTransitionService — requestOperation export space gate', () = {} as never, // fileUploadSettingsService bookingBatchService as never, bookingsService as never, - { isPhasedGeneralCustomsBooking: () => false } as never, + { isPhasedCustomsBooking: () => false } as never, {} as never, // workflowService {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, notifier as never, + { emit: jest.fn() } as never, // events ); return { service, bookingsRepository, bookingBatchService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index ec6e466b2..d7b24d514 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -7,7 +7,7 @@ import { Logger, Optional, } from "@nestjs/common"; -import { OnEvent } from "@nestjs/event-emitter"; +import { EventEmitter2, OnEvent } from "@nestjs/event-emitter"; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { eatDay } from '../train-scheduling/batch-window.util'; @@ -56,11 +56,12 @@ export class BookingTransitionService { private readonly invoiceService: BookingInvoiceService, private readonly containerValidationService: ContainerValidationService, private readonly notifier: BookingLifecycleNotifierService, + private readonly events: EventEmitter2, @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} - private isPhasedGeneralCustoms(booking: Booking): boolean { - return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking); + private isPhasedCustoms(booking: Booking): boolean { + return this.bookingClearanceService.isPhasedCustomsBooking(booking); } /** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */ @@ -376,6 +377,8 @@ export class BookingTransitionService { } as never); const fresh = await this.bookingsService.findById(updated!.id); this.notifier.completed(fresh); + // A ONE_TIME contract closes on its single shipment being delivered. + this.events.emit('booking.completed', { bookingId }); // Customer tracking: close out the tail milestones so a finished shipment // never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are // implied by delivery; a storage invoice that was never raised is skipped @@ -491,7 +494,7 @@ export class BookingTransitionService { operationReady?: boolean; }> { const booking = await this.bookingsService.findById(bookingId); - if (this.isPhasedGeneralCustoms(booking)) { + if (this.isPhasedCustoms(booking)) { return this.bookingClearanceService.getClearanceView(bookingId); } const { inputCode, outputCode, includesCustoms } = @@ -650,7 +653,7 @@ export class BookingTransitionService { status: "DOCUMENTS_UNDER_REVIEW", } as never); - if (this.isPhasedGeneralCustoms(booking)) { + if (this.isPhasedCustoms(booking)) { await this.workflowService.onCustomerDocsUploadedForBooking( bookingId, booking.tradeDirection ?? 'IMPORT', @@ -732,7 +735,7 @@ export class BookingTransitionService { } if ( status === 'QUERIED' && - this.isPhasedGeneralCustoms(booking) && + this.isPhasedCustoms(booking) && booking.preClearanceFinalizedAt ) { throw new BadRequestException( @@ -755,7 +758,7 @@ export class BookingTransitionService { "CHANGES_REQUESTED", staffId, ); - if (this.isPhasedGeneralCustoms(booking)) { + if (this.isPhasedCustoms(booking)) { await this.workflowService.onDocumentReviewReopenedForBooking(bookingId); await this.bookingsRepository.update(bookingId, { clearanceCurrentPhase: ContractDocPhase.GlEtReview, @@ -767,7 +770,7 @@ export class BookingTransitionService { if (status === "QUERIED") { this.notifier.documentQueried(updated, fileKey, note ?? ''); } - if (this.isPhasedGeneralCustoms(updated)) { + if (this.isPhasedCustoms(updated)) { const allApproved = await this.isClearanceFullyApproved(updated); if (allApproved) { await this.workflowService.onAllDocsApprovedForBooking(bookingId); @@ -817,7 +820,7 @@ export class BookingTransitionService { */ async finalizeClearance(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - if (this.isPhasedGeneralCustoms(booking)) { + if (this.isPhasedCustoms(booking)) { throw new BadRequestException( 'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.', ); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index a0b3b4e0e..0d7708789 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1,8 +1,16 @@ import { BaseRepository } from '@edr/api-common'; import { SchedulingStatus } from '@edr/types'; -import { Injectable } from '@nestjs/common'; +import { ConflictException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm'; +import { + DataSource, + DeepPartial, + EntityManager, + FindOptionsWhere, + In, + Repository, + SelectQueryBuilder, +} from 'typeorm'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; @@ -26,6 +34,22 @@ import { import { FileRecord } from '../files/entities/file.entity'; import { ContainerWeightResult } from '../rule-engine/rule-engine.service'; +/** A booking is ready for a batch: commercial = signed, government = approved/paid. */ +const BATCH_POOL_READY = `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') + OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`; + +/** + * Suspending a contract freezes its bookings, so they drop out of every + * scheduling pool. Filtering here (rather than letting the write guard throw) + * keeps the batch crons quiet — a frozen contract simply stops being a + * candidate until the suspension is lifted. + */ +const NOT_ON_SUSPENDED_CONTRACT = `(booking.contract_id IS NULL + OR NOT EXISTS ( + SELECT 1 FROM freight.contracts c + WHERE c.id = booking.contract_id AND c.status = 'SUSPENDED' + ))`; + export interface BookingListFilterOptions { statuses?: string[]; status?: string; @@ -68,6 +92,42 @@ export class BookingsRepository extends BaseRepository { return this.repository.findOne({ where: { reference } }); } + /** + * Suspending a contract freezes its bookings too, so the single write path + * every booking mutation funnels through is the place to enforce it — one + * guard instead of one per transition method. + * + * The batch/scheduling pools filter suspended contracts out up front + * (see {@link excludeSuspendedContract}), so the engine and its crons never + * reach a frozen booking and this only ever fires on a user-initiated action. + * + * ponytail: the seven `manager.getRepository(Booking)` writes inside + * train-scheduling transactions bypass this — they only run on bookings the + * pool already handed out, which the filter above has excluded. Move them onto + * this repository if that ever stops holding. + */ + private async assertContractNotSuspended(id: string): Promise { + const row = await this.repository + .createQueryBuilder('booking') + .select('contract.status', 'status') + .innerJoin(Contract, 'contract', 'contract.id = booking.contract_id') + .where('booking.id = :id', { id }) + .getRawOne<{ status: string }>(); + if (row?.status === 'SUSPENDED') { + throw new ConflictException( + 'This shipment belongs to a suspended contract. EDR must lift the suspension before it can move.', + ); + } + } + + override async update( + id: string, + data: DeepPartial, + ): Promise { + await this.assertContractNotSuspended(id); + return super.update(id, data); + } + /** * Highest NNNNNN sequence already issued for `BK--…` references. * Includes soft-deleted bookings so the next number clears references that @@ -1032,7 +1092,8 @@ export class BookingsRepository extends BaseRepository { 'scheduleBooking.booking_id = booking.id', ) .where('booking.status = :paidStatus', { paidStatus: 'PAID' }) - .andWhere('scheduleBooking.id IS NULL'); + .andWhere('scheduleBooking.id IS NULL') + .andWhere(NOT_ON_SUSPENDED_CONTRACT); // Day-level pooling: customers no longer set train_schedule_id, so the wizard // surfaces the whole (route, EAT day) pool. Fall back to the legacy @@ -1091,10 +1152,8 @@ export class BookingsRepository extends BaseRepository { .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .andWhere('sb.id IS NULL') - .andWhere( - `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') - OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`, - ) + .andWhere(BATCH_POOL_READY) + .andWhere(NOT_ON_SUSPENDED_CONTRACT) .orderBy('booking.is_government', 'DESC') .addOrderBy('booking.priority_score', 'DESC') .addOrderBy('booking.fully_executed_at', 'ASC') @@ -1130,10 +1189,8 @@ export class BookingsRepository extends BaseRepository { { day }, ) .andWhere('sb.id IS NULL') - .andWhere( - `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') - OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`, - ) + .andWhere(BATCH_POOL_READY) + .andWhere(NOT_ON_SUSPENDED_CONTRACT) .orderBy('booking.is_government', 'DESC') .addOrderBy('booking.priority_score', 'DESC') .addOrderBy('booking.fully_executed_at', 'ASC') @@ -1170,10 +1227,8 @@ export class BookingsRepository extends BaseRepository { { day }, ) .andWhere('sb.id IS NULL') - .andWhere( - `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') - OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`, - ) + .andWhere(BATCH_POOL_READY) + .andWhere(NOT_ON_SUSPENDED_CONTRACT) .orderBy('booking.is_government', 'DESC') .addOrderBy('booking.priority_score', 'DESC') .addOrderBy('booking.fully_executed_at', 'ASC') diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts index 0e858e702..81e5c833a 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts @@ -62,13 +62,15 @@ describe('clearance.util — clearanceCodesForBooking (intercity)', () => { expect(direct.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE); }); - it('ONE_TIME contract drawdowns skip the per-booking set (contract collected it)', () => { + it('ONE_TIME contract shipments carry the same per-booking set', () => { + // Contracts no longer collect clearance documents — every shipment does, + // whatever kind of contract it draws on. const drawdown = clearanceCodesForBooking({ ...base, contractId: 'c1', contractKind: 'ONE_TIME', } as unknown as Booking); - expect(drawdown.inputCode).toBeNull(); + expect(drawdown.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE); expect(drawdown.outputCode).toBeNull(); }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts index 5a63beca6..242cee9d3 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts @@ -11,9 +11,8 @@ type Freight = 'container' | 'bulk'; /** * The single (admin-configured) document set intercity shipments upload. - * DOMESTIC has no customs, so one shared set serves contracts and bookings: - * ONE_TIME collects it at contract level, GENERAL per booking — Operations - * reviews either way. + * DOMESTIC has no customs, so one shared set serves every intercity booking — + * ONE_TIME and GENERAL alike, collected per booking and reviewed by Operations. */ export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents'; @@ -77,16 +76,6 @@ export function clearanceCodesForBooking(booking: Booking): { const includesCustoms = Boolean(booking.serviceType?.includesCustoms) || Boolean(booking.customsClearingEnabled); - // Intercity drawdowns under a ONE_TIME contract already cleared the intercity - // document set on the CONTRACT (post-signature); only GENERAL drawdowns and - // direct (contract-less) bookings carry the per-booking set. - if ( - booking.tradeDirection === 'DOMESTIC' && - booking.contractId && - booking.contractKind === 'ONE_TIME' - ) { - return { inputCode: null, outputCode: null, includesCustoms: false }; - } return { inputCode: clearanceSettingCode( booking.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 69773010c..3f0fc7abc 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -113,13 +113,10 @@ export class BookingClearanceService { private readonly notifier: BookingLifecycleNotifierService, ) {} - private async assertPhasedGeneralCustoms(booking: Booking): Promise { + private async assertPhasedCustoms(booking: Booking): Promise { if (!booking.customsClearingEnabled) { throw new BadRequestException('Phased clearance applies only to customs bookings.'); } - if (booking.contractKind !== 'GENERAL') { - throw new BadRequestException('Per-booking phased clearance applies to general contracts.'); - } if (!booking.contractId) { throw new BadRequestException('Booking is not linked to a contract.'); } @@ -127,7 +124,7 @@ export class BookingClearanceService { private async loadBooking(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - await this.assertPhasedGeneralCustoms(booking); + await this.assertPhasedCustoms(booking); return booking; } @@ -352,11 +349,10 @@ export class BookingClearanceService { ); } - isPhasedGeneralCustomsBooking(booking: Booking): boolean { + /** Any contract booking (ONE_TIME or GENERAL) whose service bundles customs. */ + isPhasedCustomsBooking(booking: Booking): boolean { return ( - Boolean(booking.customsClearingEnabled) && - booking.contractKind === 'GENERAL' && - Boolean(booking.contractId) + Boolean(booking.customsClearingEnabled) && Boolean(booking.contractId) ); } @@ -720,7 +716,7 @@ export class BookingClearanceService { ]); const filtered: Booking[] = []; for (const b of candidates) { - if (!this.isPhasedGeneralCustomsBooking(b)) continue; + if (!this.isPhasedCustomsBooking(b)) continue; const milestones = await this.workflowService.listMilestonesForBooking(b.id); if (belongsOnEtClearanceQueue(milestones)) filtered.push(b); } @@ -733,7 +729,7 @@ export class BookingClearanceService { ]); const filtered: Booking[] = []; for (const b of candidates) { - if (!this.isPhasedGeneralCustomsBooking(b)) continue; + if (!this.isPhasedCustomsBooking(b)) continue; const milestones = await this.workflowService.listMilestonesForBooking(b.id); if ( belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, { diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index 99fed335a..1ca7cd84f 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -60,6 +60,11 @@ export class BookingRequestService { 'This contract is completed — the full contracted quantity has been booked.', ); } + if (contract.status === 'SUSPENDED') { + throw new ConflictException( + 'This contract is suspended — shipment requests are on hold until EDR lifts the suspension.', + ); + } if (contract.status !== 'CONTRACT_ACTIVE') { throw new ConflictException( 'The contract must be active before requesting a shipment.', diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts index 563f056d2..c222ffc16 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -24,7 +24,6 @@ describe('ContractBookingService — quantity-cap completion', () => { {} as never, // containerTypesService {} as never, // ruleEngineService {} as never, // milestoneService - {} as never, // workflowService {} as never, // invoiceService { createdToStaff: jest.fn() } as never, // bookingNotifier {} as never, // dataSource @@ -132,6 +131,82 @@ describe('ContractBookingService — quantity-cap completion', () => { expect(contractsRepository.update).not.toHaveBeenCalled(); }); + describe('completion on booking delivery', () => { + function makeDeliveryService(contract: Partial) { + const contractsRepository = { + findById: jest.fn().mockResolvedValue(contract), + update: jest.fn().mockResolvedValue(undefined), + }; + const bookingsRepository = { + findById: jest + .fn() + .mockResolvedValue({ id: 'b-1', reference: 'BKG-1', contractId: 'c-1' }), + }; + const service = new ContractBookingService( + contractsRepository as never, + bookingsRepository as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + { createdToStaff: jest.fn() } as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + return { service, contractsRepository }; + } + + it('completes a ONE_TIME contract when its booking is delivered', async () => { + const { service, contractsRepository } = makeDeliveryService({ + id: 'c-1', + reference: 'CTR-1', + contractKind: 'ONE_TIME', + status: 'CONTRACT_ACTIVE', + }); + jest.spyOn(service, 'splitOutstanding').mockResolvedValue(null); + + await service.onBookingCompleted({ bookingId: 'b-1' }); + + expect(contractsRepository.update).toHaveBeenCalledWith('c-1', { + status: 'CONTRACT_CLOSED', + }); + }); + + it('keeps a split ONE_TIME contract open while a remainder is outstanding', async () => { + const { service, contractsRepository } = makeDeliveryService({ + id: 'c-1', + reference: 'CTR-1', + contractKind: 'ONE_TIME', + freightType: 'CONTAINER', + status: 'CONTRACT_ACTIVE', + }); + jest.spyOn(service, 'splitOutstanding').mockResolvedValue({ + bySize: new Map([['20ft', { total: 5, outstanding: 2 }]]), + bulk: null, + }); + + await service.onBookingCompleted({ bookingId: 'b-1' }); + + expect(contractsRepository.update).not.toHaveBeenCalled(); + }); + + it('leaves a GENERAL contract alone — it closes on cap or expiry', async () => { + const { service, contractsRepository } = makeDeliveryService({ + id: 'c-1', + contractKind: 'GENERAL', + status: 'CONTRACT_ACTIVE', + }); + + await service.onBookingCompleted({ bookingId: 'b-1' }); + + expect(contractsRepository.update).not.toHaveBeenCalled(); + }); + }); + it('reopens a completed contract when capacity was released', async () => { const { service, contractsRepository } = makeService(); contractsRepository.findByIdWithRelations.mockResolvedValue( diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts index 4bc768cc1..84465145d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -55,7 +55,6 @@ describe('ContractBookingService — drawdown consolidation gate', () => { {} as never, // containerTypesService {} as never, // ruleEngineService milestoneService as never, - {} as never, // workflowService invoiceService as never, { createdToStaff: jest.fn(), diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 7e657cc07..3a97331af 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -37,16 +37,20 @@ import { hasFreightPermission } from '../../common/freight-permission.util'; import { Contract } from './entities/contract.entity'; import { ContractRoute } from './entities/contract-route.entity'; -import { ContractsRepository } from './contracts.repository'; +import { + ContractsRepository, + TERMINAL_BOOKING_STATUSES, +} from './contracts.repository'; import { ClearanceMilestoneService } from './clearance-milestone.service'; -import { ClearanceWorkflowService } from './clearance-workflow.service'; +import { isEffectivelyExpired } from './utils/contract-expiry.util'; import { CreateBookingContainerLineDto, CreateBookingUnderContractDto, } from './dto/create-booking-under-contract.dto'; -/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */ -const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED']; +// TERMINAL_BOOKING_STATUSES (the statuses that free the ONE_TIME active-booking +// slot) lives in contracts.repository.ts — the contract cancel gate needs the +// same list. /** Bookings that never shipped release their quantity hold on the contract. */ const RELEASING_BOOKING_STATUSES = ['CANCELLED', 'REJECTED', 'EXPIRED']; @@ -94,7 +98,6 @@ export class ContractBookingService { private readonly containerTypesService: ContainerTypesService, private readonly ruleEngineService: RuleEngineService, private readonly milestoneService: ClearanceMilestoneService, - private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, private readonly bookingNotifier: BookingLifecycleNotifierService, private readonly dataSource: DataSource, @@ -187,21 +190,14 @@ export class ContractBookingService { const freightType = contract.freightType; - // GENERAL + customs (Path B) runs per-booking clearance: the booking starts - // in the clearance gate (AWAITING_DOCUMENTS) instead of going straight to - // operations, and there is NO contract-level clearance cycle to link. - const generalCustoms = - contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); - - // GENERAL without customs (Path A) ALSO clears per booking: the customer - // uploads his own clearance proof on each booking and Operations reviews it - // (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY → - // requestOperation machine). GENERAL intercity (DOMESTIC) follows the same - // per-booking gate with the intercity document set — ops finalize then puts - // the booking straight into the ride-along pool (FULLY_EXECUTED), since - // intercity has no shipment-day request step. - const generalSelfClear = - contract.contractKind === 'GENERAL' && !contract.customsClearingEnabled; + // EVERY contract booking clears per booking now — both contract kinds, both + // paths, intercity included. Customs (Path B): GL runs the phased ET/DJ + // workflow on this booking. Non-customs (Path A) and intercity: the customer + // uploads his own document set on the booking and Operations reviews it + // (AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY → + // requestOperation; intercity finalize goes straight to the ride-along pool). + // So the booking is always born in the clearance gate, never in the + // operations queue, and no contract-level clearance cycle exists to link. // Intercity (DOMESTIC) bookings ride on a passing import/export train: // there is no window and no date — staff accept them onto a train at @@ -218,24 +214,11 @@ export class ContractBookingService { throw new BadRequestException('A binding shipment day is required'); } - // Booking-window gate (config-driven): an operations booking may only be - // created while the route's booking window is open — import: the day's window - // (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours); - // export: within exportBookingLeadHours of departure. Bookings that enter the - // clearance gate first (Path B customs AND Path A per-booking self-clearance) - // are scheduled later, so they are not gated here. - if (!generalCustoms && !generalSelfClear && !isIntercity) { - await this.trainSchedulingService.assertBookingWindowOpen({ - originYardId: route?.originYardId ?? null, - destinationYardId: route?.destinationYardId ?? null, - scheduledDate: dto.scheduledDate ?? null, - direction: contract.tradeDirection ?? null, - }); - // EXPORT rides whole or not at all (no split concept): reject the booking - // up front when no single open train on the day can carry it, telling the - // customer how much space is still bookable. - await this.assertExportTrainSpace(contract, route, dto); - } + // No booking-window / export-space gate here any more: every contract + // booking enters the clearance gate first and is scheduled only once the + // documents are approved. Both checks run at that point instead — + // `completeUnderContract` (bare instances) and `requestOperation` (bookings + // created with cargo) — against the day the customer actually picks. // Hard capacity gate: a container line whose total weight exceeds the // container type's max capacity can never be booked — no surcharge path, @@ -265,10 +248,7 @@ export class ContractBookingService { companyProfileId: contract.companyProfileId ?? null, isGovernment: contract.isGovernment, governmentInstitution: contract.governmentInstitution ?? null, - status: - generalCustoms || generalSelfClear - ? 'AWAITING_DOCUMENTS' - : 'OPERATION_REQUEST_PENDING', + status: 'AWAITING_DOCUMENTS', bookingType: 'ONE_TIME', contractId: contract.id, contractRouteId: route?.id ?? null, @@ -375,10 +355,7 @@ export class ContractBookingService { // exactly once whether the booking parks for a partner or finalizes inline. this.bookingNotifier.createdToStaff(withContainers ?? booking); - const intendedStatus = - generalCustoms || generalSelfClear - ? 'AWAITING_DOCUMENTS' - : 'OPERATION_REQUEST_PENDING'; + const intendedStatus = 'AWAITING_DOCUMENTS'; if ( withContainers && freightType === 'CONTAINER' && @@ -404,11 +381,7 @@ export class ContractBookingService { } } - await this.finalizeContractBooking( - booking.id, - contract, - generalCustoms, - ); + await this.finalizeContractBooking(booking.id, contract); await this.maybeCompleteContract(contract); @@ -417,13 +390,20 @@ export class ContractBookingService { } /** - * Initiate a BARE booking instance under a GENERAL non-customs contract - * (Path A per-booking self-clearance). One click, zero input: no schedule - * date, no cargo, no window check, no pricing. The instance starts in the - * clearance gate (AWAITING_DOCUMENTS); the customer uploads clearance docs, - * Operations reviews and finalizes, and only then does the customer complete - * the booking (cargo + binding day + window check) via - * {@link completeUnderContract} — the same machinery a one-time shipment uses. + * Initiate a BARE booking instance under an import/export contract — ONE_TIME + * or GENERAL, customs or not. One click, zero input: no schedule date, no + * cargo, no window check, no pricing. The instance starts in the clearance + * gate (AWAITING_DOCUMENTS) and is where ALL clearance documents live: + * + * - Path A (self-clearance): the customer initiates, uploads his clearance + * proof, Operations reviews and finalizes. + * - Path B (customs): GL initiates on the customer's behalf, the customer + * uploads the GL-input documents on the instance, GL approves them and runs + * the phased ET/DJ workflow (pre-booking milestones are seeded here). + * + * Only after the clearance is finalized is the booking completed (cargo + + * binding day + window check) via {@link completeUnderContract} — by the + * customer on Path A, by GL on Path B. */ async initiateUnderContract( contractId: string, @@ -434,13 +414,12 @@ export class ContractBookingService { const contract = await this.contractsRepository.findByIdWithRelations(contractId); if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); - const generalSelfClear = - contract.contractKind === 'GENERAL' && - !contract.customsClearingEnabled && - contract.tradeDirection !== 'DOMESTIC'; - if (!generalSelfClear) { + // Intercity has no shipment day to defer to, so it is booked directly with + // its cargo (the documents still live on that booking). Everything else — + // ONE_TIME or GENERAL, customs or self-clear — starts as a bare instance. + if (contract.tradeDirection === 'DOMESTIC') { throw new BadRequestException( - 'Initiate booking applies only to general import/export contracts without customs clearing.', + 'Intercity shipments are booked directly with their cargo — there is no initiate step.', ); } @@ -453,12 +432,27 @@ export class ContractBookingService { const isGlActor = actorPermissions != null && hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking); + // Customs (Path B): GL initiates on the customer's behalf — assertGate + // rejects anyone else. Self-clearance (Path A): the customer initiates. const createdByRole = await this.assertGate(contract, isGlActor); if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) { throw new BadRequestException('Contract validity has expired — no new bookings.'); } + // ONE_TIME carries a single shipment at a time; a bare instance occupies the + // slot from the moment it is initiated (it is not a terminal status). The + // split chain is the one exception — a paid partial frees the slot and + // completion enforces that the next booking takes the whole remainder. + if (contract.contractKind === 'ONE_TIME' && !(await this.hasSplitBooking(contractId))) { + const active = await this.countActiveBookings(contractId); + if (active > 0) { + throw new BadRequestException( + 'This one-time contract already has an active booking.', + ); + } + } + const route = await this.resolveRoute(contract, dto.contractRouteId); // Bare instance: no cargo, no date, no price. Draws no contract capacity @@ -503,6 +497,16 @@ export class ContractBookingService { } as never), ); + // Customs: the instance runs the phased ET/DJ workflow, so its pre-booking + // milestones exist from initiation (the post-booking half is seeded when the + // booking is completed). Self-clearance has no milestone timeline. + if (contract.customsClearingEnabled) { + await this.milestoneService.seedPreBookingMilestonesOnBooking( + booking.id, + contract.tradeDirection, + ); + } + const result = await this.bookingsRepository.findByIdWithFiles(booking.id); this.bookingNotifier.createdToStaff(result ?? booking); return { booking: result ?? booking, warnings: [] }; @@ -718,6 +722,15 @@ export class ContractBookingService { // after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks // the shipment day. if (!hasCargo) { + // ONE_TIME split chain: the instance that follows a paid partial must take + // the WHOLE outstanding remainder — same rule a booking created with cargo + // passes at creation. + if ( + contract.contractKind === 'ONE_TIME' && + (await this.hasSplitBooking(contract.id)) + ) { + await this.assertExactRemainder(contract, dto); + } await this.assertWithinQuantityCap(contract, dto); if (freightType === 'CONTAINER') { await this.assertWithinMaxCapacity(contract, dto); @@ -817,10 +830,7 @@ export class ContractBookingService { // Invoice the now-priced booking and, for a customs instance, seed the // post-booking milestones (pre-booking ones exist since initiation — // ensure* fills only what is missing). Idempotent, non-blocking. - const generalCustoms = - contract.contractKind === 'GENERAL' && - Boolean(contract.customsClearingEnabled); - await this.finalizeContractBooking(booking.id, contract, generalCustoms); + await this.finalizeContractBooking(booking.id, contract); await this.maybeCompleteContract(contract); } else if (freightType === 'CONTAINER') { // Resubmit only re-picks the shipment day — the persisted container @@ -886,33 +896,17 @@ export class ContractBookingService { private async finalizeContractBooking( bookingId: string, contract: Contract, - generalCustoms: boolean, ): Promise { const booking = await this.bookingsRepository.findByIdWithFiles(bookingId); if (!booking || booking.status === 'PENDING_CONSOLIDATION') return; - // ONE_TIME customs (legacy contract-cycle path): link the contract clearance - // cycle to this booking, seed post-booking milestones, and lock the contract - // to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle - // and must stay CONTRACT_ACTIVE so further shipment requests can be accepted. - if (contract.customsClearingEnabled && !generalCustoms) { - const cycle = await this.contractsRepository.currentCycle(contract.id); - if (cycle) { - await this.contractsRepository.linkBooking(cycle.id, bookingId); - } - await this.milestoneService.seedPostBookingMilestones( - bookingId, - contract.tradeDirection, - ); - await this.contractsRepository.update(contract.id, { - status: 'ACTIVE_SHIPMENT_IN_PROGRESS', - clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS', - } as never); - } else if (generalCustoms) { - // Per-booking clearance: seed the full milestone timeline on the booking. - // ensure* skips codes that already exist — an initiated instance carries - // its pre-booking milestones from initiation, and a consolidation pairing - // replay must not duplicate the timeline. + // Customs runs per booking for BOTH contract kinds: seed the full milestone + // timeline on the booking. ensure* skips codes that already exist — an + // initiated instance carries its pre-booking milestones from initiation, and + // a consolidation pairing replay must not duplicate the timeline. The + // contract itself is never moved to ACTIVE_SHIPMENT_IN_PROGRESS any more; it + // holds no clearance state at all. + if (contract.customsClearingEnabled) { await this.milestoneService.ensureBookingMilestones( bookingId, contract.tradeDirection, @@ -982,10 +976,7 @@ export class ContractBookingService { booking.contractId, ); if (!contract) continue; - const generalCustoms = - contract.contractKind === 'GENERAL' && - Boolean(contract.customsClearingEnabled); - await this.finalizeContractBooking(id, contract, generalCustoms).catch( + await this.finalizeContractBooking(id, contract).catch( (err) => this.logger.error( `Failed to finalize paired contract booking ${booking.reference}: ${ @@ -1001,32 +992,27 @@ export class ContractBookingService { * allowed to create one for this contract's execution path. */ private async assertGate(contract: Contract, isGlActor: boolean): Promise { + // Suspended contracts are frozen for everyone, GL included — say so instead + // of letting the executed-status check below give a misleading reason. + if (contract.status === 'SUSPENDED') { + throw new BadRequestException( + 'This contract is suspended — no new shipments can be booked until EDR lifts the suspension.', + ); + } if (contract.customsClearingEnabled) { - // Path B — Global Logistics creates the booking ON BEHALF OF the customer. - // The customer never books a customs contract himself. + // Path B — Global Logistics initiates and completes the booking ON BEHALF + // OF the customer. The customer never books a customs contract himself; + // he only uploads documents on the instance GL opened for him. if (!isGlActor) { throw new ForbiddenException( 'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.', ); } - if (contract.contractKind === 'GENERAL') { - // GENERAL customs has NO contract clearance cycle — GL books per accepted - // shipment request while the contract is active; clearance is per booking. - if (contract.status !== 'CONTRACT_ACTIVE') { - throw new BadRequestException( - 'Contract must be active to book a shipment.', - ); - } - return 'GL_ET'; - } - // ONE_TIME customs — pre-booking boundary milestone must be complete. - const boundaryOk = await this.workflowService.isBoundaryComplete( - contract.id, - contract.tradeDirection, - ); - if (!boundaryOk) { + // No contract clearance cycle exists on either kind now — clearance runs + // on the booking, so an executed/active contract is the only gate here. + if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) { throw new BadRequestException( - 'Pre-booking clearance is not complete — booking cannot be created yet.', + 'Contract must be fully executed before booking a shipment.', ); } return 'GL_ET'; @@ -1041,6 +1027,40 @@ export class ContractBookingService { return isGlActor ? 'STAFF' : 'CUSTOMER'; } + /** + * GL worklist: executed ONE_TIME customs contracts with no live shipment + * instance yet. Customs contracts are initiated by GL on the customer's + * behalf, so without this list a signed contract would sit with nothing on any + * queue (clearance lives on the booking, and the booking does not exist yet). + * GENERAL customs is excluded — its instances are opened by shipment requests. + */ + async awaitingShipmentContracts(): Promise { + const { items } = await this.contractsRepository.findAllPaginated({ + page: 1, + pageSize: 500, + statuses: ['FULLY_EXECUTED'], + customsClearingEnabled: true, + contractKind: 'ONE_TIME', + sortBy: 'createdAt', + sortOrder: 'DESC', + } as never); + + const out: Contract[] = []; + for (const contract of items) { + if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) { + continue; + } + // A split chain frees the slot for the remainder, so those contracts stay + // on the list even while the paid partial booking still exists. + if (await this.hasSplitBooking(contract.id)) { + out.push(contract); + continue; + } + if ((await this.countActiveBookings(contract.id)) === 0) out.push(contract); + } + return out; + } + private async countActiveBookings(contractId: string): Promise { return this.dataSource .getRepository(Booking) @@ -1418,6 +1438,53 @@ export class ContractBookingService { ]; } + /** + * A ONE_TIME contract carries exactly one shipment: once that booking is + * delivered (COMPLETED) the contract is fulfilled and moves to + * CONTRACT_CLOSED — shown as "Completed" and greyed out in both portals, and + * blocking any further booking. A split ONE_TIME is the exception: its + * remainder chain must be rebooked and delivered first, so the contract stays + * open while the split remainder is outstanding. + * + * GENERAL contracts are untouched — they close on cap exhaustion or expiry. + * Best-effort: a status hiccup must never fail the booking that completed. + */ + @OnEvent('booking.completed') + async onBookingCompleted(payload: { bookingId: string }): Promise { + try { + const booking = await this.bookingsRepository.findById(payload.bookingId); + if (!booking?.contractId) return; + const contract = await this.contractsRepository.findById(booking.contractId); + if (!contract || contract.contractKind === 'GENERAL') return; + // Already closed/expired/cancelled — nothing to do. + if (isEffectivelyExpired(contract)) return; + + const outstanding = await this.splitOutstanding(contract); + if (outstanding) { + // 0.001 tolerance absorbs bulk-ton float rounding, same as the + // cap-exhaustion path below. + const exhausted = + contract.freightType === 'CONTAINER' + ? [...outstanding.bySize.values()].every((s) => s.outstanding <= 0) + : (outstanding.bulk?.outstanding ?? 0) <= 0.001; + if (!exhausted) return; + } + + await this.contractsRepository.update(contract.id, { + status: 'CONTRACT_CLOSED', + } as never); + this.logger.log( + `Contract ${contract.reference} completed — its one-time booking ${booking.reference} was delivered.`, + ); + } catch (err) { + this.logger.error( + `Could not close contract for completed booking ${payload.bookingId}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + /** * Complete the contract once its quantity cap is fully consumed. Runs after * every booking created under a GENERAL contract, and under a ONE_TIME diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 0ceea38ed..31fc1f252 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -34,7 +34,7 @@ import { Contract } from './entities/contract.entity'; import { ContractDocReviewStatus } from './entities/contract-document-review.entity'; import { FilterContractDto } from './dto/filter-contract.dto'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; -import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_CONTRACT_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util'; +import { buildWorkflowFiles, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util'; const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; @@ -1060,46 +1060,6 @@ export class ContractClearanceService { }); } - /** - * Operations queue: self-clearance (Path A) contracts awaiting Operations - * review of the customer's own clearance documents. - */ - /** - * Statuses a non-customs contract passes through around Operations - * clearance review — the set a caller may narrow {@link opsQueue} to. - */ - private static readonly OPS_CLEARANCE_STATUSES = [ - 'AWAITING_CLEARANCE_DOCUMENTS', - 'CLEARANCE_UNDER_REVIEW', - 'CLEARANCE_READY_FOR_BOOKING', - 'FULLY_EXECUTED', - 'CONTRACT_ACTIVE', - 'ACTIVE_SHIPMENT_IN_PROGRESS', - 'CONTRACT_CLOSED', - 'CANCELLED', - ]; - - async opsQueue(filter: FilterContractDto): Promise { - // Callers may narrow to any subset of the ops-clearance lifecycle (the - // hub's status filter sends an explicit list); anything outside the - // whitelist is dropped so this endpoint can't become a general contract - // browser. No statuses given → the original under-review queue. - const requested = (filter.statuses ?? filter.status ?? '') - .split(',') - .map((s) => s.trim()) - .filter((s) => - ContractClearanceService.OPS_CLEARANCE_STATUSES.includes(s), - ); - return this.contractsRepository.findAllPaginated({ - page: filter.page ?? 1, - pageSize: filter.pageSize ?? 100, - statuses: requested.length ? requested : ['CLEARANCE_UNDER_REVIEW'], - customsClearingEnabled: false, - search: filter.search, - sortBy: filter.sortBy, - sortOrder: filter.sortOrder, - }); - } /** GL ET history: contracts that completed Path B clearance. */ async history(filter: FilterContractDto): Promise { @@ -1693,80 +1653,4 @@ export class ContractClearanceService { return this.contractsService.findById(contractId); } - /** GL ET queue: customs ONE_TIME contracts in phased clearance (persistent after booking). */ - async etQueue(filter: FilterContractDto): Promise { - const base = await this.contractsRepository.findAllPaginated({ - page: 1, - pageSize: 500, - statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES], - customsClearingEnabled: true, - contractKind: 'ONE_TIME', - sortBy: filter.sortBy, - sortOrder: filter.sortOrder, - }); - - const filtered: typeof base.items = []; - for (const c of base.items) { - const milestones = await this.workflowService.listMilestones(c.id); - if (belongsOnEtClearanceQueue(milestones)) filtered.push(c); - } - - const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 50; - const start = (page - 1) * pageSize; - const items = filtered.slice(start, start + pageSize); - - return { - items, - total: filtered.length, - meta: { - page, - pageSize, - total: filtered.length, - totalPages: Math.ceil(filtered.length / pageSize) || 1, - hasNextPage: start + pageSize < filtered.length, - hasPreviousPage: page > 1, - }, - }; - } - - /** GL DJ queue: customs ONE_TIME contracts handed off to or handled by Djibouti GL. */ - async djQueue(filter: FilterContractDto): Promise { - const base = await this.contractsRepository.findAllPaginated({ - page: 1, - pageSize: 500, - statuses: [...DJ_CONTRACT_QUEUE_STATUSES], - customsClearingEnabled: true, - contractKind: 'ONE_TIME', - sortBy: filter.sortBy, - sortOrder: filter.sortOrder, - }); - - const filtered: typeof base.items = []; - for (const c of base.items) { - const cycle = await this.contractsRepository.currentCycle(c.id); - const milestones = await this.workflowService.listMilestones(c.id); - if (belongsOnDjClearanceQueue(c.tradeDirection, cycle, milestones)) { - filtered.push(c); - } - } - - const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 50; - const start = (page - 1) * pageSize; - const items = filtered.slice(start, start + pageSize); - - return { - items, - total: filtered.length, - meta: { - page, - pageSize, - total: filtered.length, - totalPages: Math.ceil(filtered.length / pageSize) || 1, - hasNextPage: start + pageSize < filtered.length, - hasPreviousPage: page > 1, - }, - }; - } } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-duplicate-guard.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-duplicate-guard.spec.ts new file mode 100644 index 000000000..1290b2e90 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-duplicate-guard.spec.ts @@ -0,0 +1,79 @@ +import { ConflictException } from '@nestjs/common'; + +import { ContractsService } from './contracts.service'; +import type { CreateContractDto } from './dto/create-contract.dto'; + +/** + * The duplicate guard blocks a new request only when EVERY commercial + * dimension matches a live contract — service type, operation type, contract + * kind, cargo scope and route. Any one differing must let the request through. + */ +describe('ContractsService duplicate guard', () => { + const LANE = { originYardId: 'yard-dj', destinationYardId: 'yard-mj' }; + + const existing = { + id: 'c-1', + reference: 'CTR-2026-00001', + status: 'PENDING_APPROVAL', + contractValidUntil: null, + tradeDirection: 'IMPORT', + contractKind: 'ONE_TIME', + freightType: 'CONTAINER', + routes: [LANE], + cargoScope: [{ containerSize: '20ft' }, { containerSize: '40ft' }], + }; + + const dto = (overrides: Partial = {}) => + ({ + serviceTypeId: 'svc-1', + tradeDirection: 'IMPORT', + contractKind: 'ONE_TIME', + freightType: 'CONTAINER', + routes: [LANE], + cargoScope: [{ containerSize: '20ft' }, { containerSize: '40ft' }], + ...overrides, + }) as CreateContractDto; + + const guard = (input: CreateContractDto) => { + const service = new ContractsService( + {} as never, + { findDuplicateCandidates: async () => [existing] } as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + return ( + service as unknown as { + assertNoDuplicateContract(companyId: string, dto: CreateContractDto): Promise; + } + ).assertNoDuplicateContract('company-1', input); + }; + + it('blocks an identical request', async () => { + await expect(guard(dto())).rejects.toBeInstanceOf(ConflictException); + }); + + it.each([ + ['operation type', { tradeDirection: 'EXPORT' }], + ['contract kind', { contractKind: 'GENERAL' }], + ['freight type', { freightType: 'BULK' }], + ['cargo scope', { cargoScope: [{ containerSize: '20ft' }] }], + ['route', { routes: [{ originYardId: 'yard-dj', destinationYardId: 'yard-aa' }] }], + ])('allows a request with a different %s', async (_label, overrides) => { + await expect(guard(dto(overrides as Partial))).resolves.toBeUndefined(); + }); + + it('ignores quantity caps when comparing cargo scope', async () => { + await expect( + guard( + dto({ + cargoScope: [ + { containerSize: '20ft', quantityCap: 10 }, + { containerSize: '40ft', quantityCap: 5 }, + ], + }), + ), + ).rejects.toBeInstanceOf(ConflictException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index 11681a28f..92a313569 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -143,6 +143,33 @@ export class ContractNotifierService { this.inApp(c, 'Contract rejected', msg); } + /** Backoffice froze the contract — every action on it is blocked until lifted. */ + suspended(c: Contract, reason: string): void { + const msg = + `Your contract ${c.reference} has been suspended. Reason: ${reason}. ` + + `No new shipments can be booked and existing shipments are on hold until the suspension is lifted.`; + void this.notifyContact(c, msg, 'SUSPENDED'); + this.inApp(c, 'Contract suspended', msg); + } + + /** Backoffice lifted the suspension — the contract resumes where it left off. */ + suspensionLifted(c: Contract, note?: string | null): void { + const msg = + `The suspension on your contract ${c.reference} has been lifted. ` + + `You can continue where you left off.${note ? ` Note: ${note}` : ''}`; + void this.notifyContact(c, msg, 'SUSPENSION LIFTED'); + this.inApp(c, 'Contract suspension lifted', msg); + } + + /** Customer cancelled their own contract — staff-side record. */ + cancelledByCustomer(c: Contract, reason: string): void { + this.inAppStaff( + c, + 'Contract cancelled by customer', + `Contract ${c.reference} was cancelled by the customer. Reason: ${reason}`, + ); + } + /** * A later approver sent the contract back to an earlier stage of the chain. * Staff-only: the customer is not involved in an internal send-back — their diff --git a/apps/edr-freight-api/src/modules/contracts/contract-suspension.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-suspension.spec.ts new file mode 100644 index 000000000..5cf24d747 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-suspension.spec.ts @@ -0,0 +1,132 @@ +import { ContractTransitionService } from './contract-transition.service'; +import type { Contract } from './entities/contract.entity'; + +/** + * Suspension is only worth having if it is reversible and if it actually + * freezes things, and the customer's own cancel is only safe while no shipment + * is running. Those three rules are the whole feature — everything else is + * plumbing. + */ +describe('ContractTransitionService — suspend / resume / customer cancel', () => { + const contract = (over: Partial = {}): Contract => + ({ + id: 'c-1', + reference: 'CTR-2026-00042', + companyId: 'co-1', + status: 'CONTRACT_ACTIVE', + freightType: 'CONTAINER', + ...over, + }) as Contract; + + let current: Contract; + let repo: { + update: jest.Mock; + createReviewNote: jest.Mock; + countActiveBookings: jest.Mock; + }; + let notifier: { + suspended: jest.Mock; + suspensionLifted: jest.Mock; + cancelledByCustomer: jest.Mock; + }; + let service: ContractTransitionService; + + /** A staff user holding the suspend key — authorization is tested elsewhere. */ + const staff = { + permissions: [{ key: 'edr_freight_app:contracts:suspend' }], + }; + + beforeEach(() => { + current = contract(); + repo = { + // Mirror the real repository: the update patches the row the next + // findById returns, so resume() reads what suspend() wrote. + update: jest.fn().mockImplementation((_id: string, patch: object) => { + current = { ...current, ...patch } as Contract; + return Promise.resolve(current); + }), + createReviewNote: jest.fn().mockResolvedValue(undefined), + countActiveBookings: jest.fn().mockResolvedValue(0), + }; + notifier = { + suspended: jest.fn(), + suspensionLifted: jest.fn(), + cancelledByCustomer: jest.fn(), + }; + // These three transitions touch only the repository, the read-back service + // and the notifier — the other 14 constructor deps stay unused, so the + // instance is built bare and only what is exercised is injected. + service = Object.create( + ContractTransitionService.prototype, + ) as ContractTransitionService; + Object.assign(service, { + contractsRepository: repo, + contractsService: { findById: () => Promise.resolve(current) }, + notifier, + }); + }); + + it('freezes at the current step and remembers where to come back to', async () => { + current = contract({ status: 'CLEARANCE_UNDER_REVIEW' }); + + await service.suspend('c-1', 'Unpaid demurrage', 'staff-1', staff as never); + + expect(repo.update).toHaveBeenCalledWith('c-1', { + status: 'SUSPENDED', + statusBeforeSuspension: 'CLEARANCE_UNDER_REVIEW', + }); + expect(notifier.suspended).toHaveBeenCalled(); + }); + + it('restores the pre-suspension status when the suspension is lifted', async () => { + current = contract({ status: 'ACTIVE_SHIPMENT_IN_PROGRESS' }); + await service.suspend('c-1', 'Docs missing', 'staff-1', staff as never); + + await service.resume('c-1', undefined, 'staff-1', staff as never); + + expect(repo.update).toHaveBeenLastCalledWith('c-1', { + status: 'ACTIVE_SHIPMENT_IN_PROGRESS', + statusBeforeSuspension: null, + }); + }); + + it('refuses to suspend a contract the customer has not signed yet', async () => { + current = contract({ status: 'PENDING_APPROVAL' }); + + await expect( + service.suspend('c-1', 'too early', 'staff-1', staff as never), + ).rejects.toThrow(/PENDING_APPROVAL/); + expect(repo.update).not.toHaveBeenCalled(); + }); + + it('lets the customer cancel a contract with no live shipment', async () => { + await service.cancelByCustomer('c-1', 'Changed supplier', 'user-1'); + + expect(repo.update).toHaveBeenCalledWith('c-1', { status: 'CANCELLED' }); + expect(repo.createReviewNote).toHaveBeenCalledWith( + 'c-1', + 'Changed supplier', + 'CANCELLATION', + 'user-1', + 'CUSTOMER', + ); + }); + + it('blocks the customer cancel while a shipment is still running', async () => { + repo.countActiveBookings.mockResolvedValue(2); + + await expect( + service.cancelByCustomer('c-1', undefined, 'user-1'), + ).rejects.toThrow(/2 active shipments/); + expect(repo.update).not.toHaveBeenCalled(); + }); + + it('refuses a customer cancel on a suspended contract — only staff can lift it', async () => { + current = contract({ status: 'SUSPENDED' }); + + await expect( + service.cancelByCustomer('c-1', undefined, 'user-1'), + ).rejects.toThrow(/suspended/); + expect(repo.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index b579acdfb..5f85cf663 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -28,6 +28,7 @@ import { FREIGHT_PERMS, forFreightType, } from '../../seed/freight-permissions.registry'; +import { TERMINAL_CONTRACT_STATUSES } from './utils/contract-expiry.util'; import { ContractDocumentHistoryService } from './contract-document-history.service'; import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service'; import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; @@ -38,10 +39,8 @@ import { OtpService } from '../otp/otp.service'; import { ContractTemplatesService } from '../contract-templates/contract-templates.service'; import { ContractPricingService } from './contract-pricing.service'; import { ContractNotifierService } from './contract-notifier.service'; -import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractsRepository } from './contracts.repository'; import { ContractsService } from './contracts.service'; -import { contractClearanceSettingCode } from './contract-clearance.util'; import { Contract, ContractDocumentArticle, @@ -131,6 +130,21 @@ function maskSignerContacts(contacts: { phone?: string; email?: string }): strin .join(' and '); } +/** + * Where the backoffice may freeze a contract: every step from the customer's + * signature onward, up to (but not including) the terminal states. Suspending + * an unsigned contract is meaningless — staff reject or request changes there. + */ +export const SUSPENDABLE_CONTRACT_STATUSES = [ + 'SIGNED_CUSTOMER', + 'FULLY_EXECUTED', + 'CONTRACT_ACTIVE', + 'AWAITING_CLEARANCE_DOCUMENTS', + 'CLEARANCE_UNDER_REVIEW', + 'CLEARANCE_READY_FOR_BOOKING', + 'ACTIVE_SHIPMENT_IN_PROGRESS', +] as const; + /** Status-machine guard mirroring booking-status.util. */ function assertContractStatus(contract: Contract, allowed: string[]): void { if (!allowed.includes(contract.status)) { @@ -154,7 +168,6 @@ export class ContractTransitionService { private readonly dropdownSettingsService: DropdownSettingsService, private readonly filesService: FilesService, private readonly signaturesService: SignaturesService, - private readonly milestoneService: ClearanceMilestoneService, private readonly documentViewModelBuilder: ContractDocumentViewModelBuilder, private readonly renderer: ContractRendererService, private readonly pdfService: ContractPdfService, @@ -1262,43 +1275,15 @@ export class ContractTransitionService { lockedAt: now, }; - // A clearance gate applies whenever a clearance doc set resolves — Path B - // (customs), Path A self-clearance (IMPORT/EXPORT without customs), or the - // intercity document set (DOMESTIC, ops-reviewed like Path A). - const clearanceCode = contractClearanceSettingCode( - contract.tradeDirection, - contract.freightType, - contract.customsClearingEnabled ?? false, - ); - - // GENERAL contracts run clearance PER BOOKING, not at the contract level — - // both paths. Customs (Path B): the customer files shipment requests, GL - // books each one and the booking carries its own clearance. Self-clearance - // (Path A): the customer books, then uploads the clearance docs on that - // booking for Operations to review. Only ONE_TIME contracts keep the - // contract-level cycle below. - const isGeneral = contract.contractKind === 'GENERAL'; - - if (clearanceCode && !isGeneral) { - // Open a clearance cycle, seed the pre-booking milestones, and route the - // customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the - // distinction is enforced at the review/finalize endpoints, not here. - const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1; - const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber); - await this.milestoneService.seedPreBookingMilestones(contract, cycle.id); - // No prepay gate: the customs clearance service fee (Path B) is billed on - // the booking invoice together with the freight, so the document step - // opens immediately. - updates.status = 'AWAITING_CLEARANCE_DOCUMENTS'; - updates.clearanceStatus = 'AWAITING_DOCUMENTS'; - updates.clearanceCycleNumber = cycleNumber; - } else { - // No contract-level clearance gate — DOMESTIC, or any GENERAL contract - // (which clears per booking). Ready for shipment requests / direct booking. - updates.status = - contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED'; - updates.clearanceStatus = 'NOT_APPLICABLE'; - } + // Clearance ALWAYS runs per booking — both contract kinds, both paths, and + // intercity. A signed contract carries no clearance cycle and collects no + // documents: the shipment instance created after signature does. Customs + // (Path B): GL initiates the booking, the customer uploads on it, GL + // reviews and completes it. Self-clearance (Path A) and intercity: the + // customer initiates/books and Operations reviews the booking documents. + updates.status = + contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED'; + updates.clearanceStatus = 'NOT_APPLICABLE'; await this.contractsRepository.update(contractId, updates as never); await this.regenerateContractPdf(contractId, contract.reference); @@ -1308,6 +1293,123 @@ export class ContractTransitionService { } /** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */ + /** + * Backoffice freeze, available at every step from the customer signature + * onward. The pre-suspension status is stashed so {@link resume} can put the + * contract back exactly where it was — a suspension you cannot lift is just a + * cancellation under another name. + * + * While SUSPENDED nothing moves: no new bookings or shipment requests + * (ContractBookingService / BookingRequestService), and no writes to the + * contract's existing bookings (BookingsRepository.update). + */ + async suspend( + contractId: string, + reason: string, + actorId: string, + user?: TCurrentUser | null, + ): Promise { + const contract = await this.contractsService.findById(contractId); + assertFreightPermission(user, FREIGHT_PERMS.contracts.suspend); + assertContractStatus(contract, [...SUSPENDABLE_CONTRACT_STATUSES]); + + await this.contractsRepository.createReviewNote( + contractId, + reason, + 'SUSPENSION', + actorId, + 'STAFF', + ); + await this.contractsRepository.update(contractId, { + status: 'SUSPENDED', + statusBeforeSuspension: contract.status, + } as never); + const updated = await this.contractsService.findById(contractId); + this.notifier.suspended(updated, reason); + return updated; + } + + /** Lift a suspension — the contract returns to the status it was frozen at. */ + async resume( + contractId: string, + note: string | undefined, + actorId: string, + user?: TCurrentUser | null, + ): Promise { + const contract = await this.contractsService.findById(contractId); + assertFreightPermission(user, FREIGHT_PERMS.contracts.suspend); + assertContractStatus(contract, ['SUSPENDED']); + + // Legacy safety net: a row suspended before the column existed has nothing + // to restore. CONTRACT_ACTIVE is the post-signature resting state for both + // contract kinds, so it is the only sane default. + const restored = contract.statusBeforeSuspension ?? 'CONTRACT_ACTIVE'; + + if (note?.trim()) { + await this.contractsRepository.createReviewNote( + contractId, + note.trim(), + 'SUSPENSION_LIFTED', + actorId, + 'STAFF', + ); + } + await this.contractsRepository.update(contractId, { + status: restored, + statusBeforeSuspension: null, + } as never); + const updated = await this.contractsService.findById(contractId); + this.notifier.suspensionLifted(updated, note ?? null); + return updated; + } + + /** + * Customer cancels their own contract so they can request a fresh one for the + * same lane — the duplicate-contract guard treats CANCELLED as released. + * Blocked while any booking on the contract is still live: cancelling a + * contract with cargo in motion would strand it. + */ + async cancelByCustomer( + contractId: string, + reason: string | undefined, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) { + throw new ConflictException( + `Contract is already ${contract.status.toLowerCase().replace(/_/g, ' ')}.`, + ); + } + if (contract.status === 'SUSPENDED') { + throw new ConflictException( + 'This contract is suspended by EDR — contact us to lift the suspension first.', + ); + } + + const active = await this.contractsRepository.countActiveBookings(contractId); + if (active > 0) { + throw new BadRequestException( + `This contract has ${active} active shipment${active === 1 ? '' : 's'}. ` + + 'Cancel or complete them before cancelling the contract.', + ); + } + + const body = reason?.trim() || 'Cancelled by the customer.'; + await this.contractsRepository.createReviewNote( + contractId, + body, + 'CANCELLATION', + userId, + 'CUSTOMER', + ); + await this.contractsRepository.update(contractId, { + status: 'CANCELLED', + } as never); + const updated = await this.contractsService.findById(contractId); + this.notifier.cancelledByCustomer(updated, body); + return updated; + } + async renew(contractId: string, userId?: string): Promise { const source = await this.contractsService.findById(contractId); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 74574ed28..9aa37a0a7 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -66,9 +66,12 @@ import { ContractListSummaryDto } from './dto/contract-list-summary.dto'; import { AcceptContractDto } from './dto/accept-contract.dto'; import { UpdateContractDocumentDto } from './dto/contract-document.dto'; import { + CancelContractDto, RejectContractDto, RejectStepDto, RequestChangesDto, + ResumeContractDto, + SuspendContractDto, } from './dto/approve-step.dto'; import { SignContractDto } from './dto/sign-contract.dto'; import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto'; @@ -453,6 +456,65 @@ export class ContractsController { ); } + @Post(':id/suspend') + @BookingStaff(FREIGHT_PERMS.contracts.suspend) + @ApiOperation({ + summary: 'Staff freeze a signed contract (reversible, any post-signature step)', + }) + suspend( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SuspendContractDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.transitionService.suspend( + id, + dto.reason, + resolveAuthUserId(user), + user, + ); + } + + @Post(':id/resume') + @BookingStaff(FREIGHT_PERMS.contracts.suspend) + @ApiOperation({ summary: 'Staff lift a suspension — contract returns to its prior status' }) + resume( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ResumeContractDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.transitionService.resume( + id, + dto.note, + resolveAuthUserId(user), + user, + ); + } + + @Post(':id/cancel') + @ApiOperation({ + summary: 'Customer cancels their own contract (blocked while a booking is live)', + }) + async cancel( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CancelContractDto, + @CurrentUser() user: TCurrentUser, + ) { + // Same ownership rule as renew: staff with bookings.view/contracts.view pass + // through, everyone else must own the contract's company. + const contract = await this.contractsService.findById(id); + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } + return this.transitionService.cancelByCustomer( + id, + dto.reason, + resolveAuthUserId(user), + ); + } + @Post(':id/approval-steps/:stepId/approve') @BookingStaff(FREIGHT_PERMS.contracts.view) @ApiOperation({ summary: 'Approve one approval step in sequence' }) @@ -924,31 +986,18 @@ export class ContractsController { return this.clearanceService.finalizeExportClearance(id, resolveAuthUserId(user)); } - @Get('clearance/et-queue') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) - @ApiOperation({ summary: 'GL Ethiopia phased clearance list (persistent after booking)' }) - etClearanceQueue(@Query() filter: FilterContractDto) { - return this.clearanceService.etQueue(filter); - } - - @Get('clearance/dj-queue') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ summary: 'GL Djibouti phased clearance list (persistent after booking)' }) - djClearanceQueue(@Query() filter: FilterContractDto) { - return this.clearanceService.djQueue(filter); + @Get('awaiting-shipment') + @BookingStaff(FREIGHT_PERMS.contracts.createBooking) + @ApiOperation({ + summary: + 'GL worklist: executed one-time customs contracts with no shipment instance yet — GL initiates the booking the customer then uploads documents on.', + }) + awaitingShipmentContracts() { + return this.contractBookingService.awaitingShipmentContracts(); } // ── Path A self-clearance — Operations reviews the customer's own docs ─────── - @Get('clearance/ops-queue') - @BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview) - @ApiOperation({ - summary: 'Operations queue: self-clearance (non-customs) contracts awaiting review', - }) - opsClearanceQueue(@Query() filter: FilterContractDto) { - return this.clearanceService.opsQueue(filter); - } - @Post(':id/clearance/ops-review') @BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview) @ApiOperation({ @@ -1017,7 +1066,7 @@ export class ContractsController { @Post(':id/bookings/initiate') @ApiOperation({ summary: - 'Initiate a bare booking instance under a GENERAL non-customs contract — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS).', + 'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). Customs contracts are initiated by GL Ethiopia.', }) initiateBooking( @Param('id', ParseUUIDPipe) id: string, @@ -1335,7 +1384,7 @@ export class ContractsController { ) { const file = (files ?? [])[0]; const booking = await this.bookingsService.findById(bookingId); - if (this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking)) { + if (this.bookingClearanceService.isPhasedCustomsBooking(booking)) { return this.bookingClearanceService.uploadDutySlip(bookingId, file); } return this.glOperationsService.uploadDutySlip(bookingId, file); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index b9c22d1af..49ef3a29f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm'; +import { Booking } from '../bookings/entities/booking.entity'; import { FileRecord } from '../files/entities/file.entity'; import { Contract } from './entities/contract.entity'; import { ContractApprovalStep } from './entities/contract-approval-step.entity'; @@ -16,6 +17,18 @@ import { ContractReviewNote, ContractReviewNoteType } from './entities/contract- import { ContractSignature, ContractSignerRole } from './entities/contract-signature.entity'; import { TERMINAL_CONTRACT_STATUSES } from './utils/contract-expiry.util'; +/** + * Booking statuses that release whatever the booking was holding — contract + * capacity, the one-time active slot, the cancel gate. Everything else counts + * as a live booking. + */ +export const TERMINAL_BOOKING_STATUSES = [ + 'EXPIRED', + 'CANCELLED', + 'COMPLETED', + 'REJECTED', +]; + export interface ContractListFilterOptions { statuses?: string[]; status?: string; @@ -68,8 +81,9 @@ export class ContractsRepository extends BaseRepository { } /** - * Non-terminal contracts for the same company + service type, with routes - * loaded — candidates for the duplicate-contract check on create(). Terminal + * Non-terminal contracts for the same company + service type, with routes and + * cargo scope loaded — candidates for the duplicate-contract check on + * create() (which also compares operation type, kind and scope). Terminal * filtering happens in JS via isEffectivelyExpired (also covers the * date-passed-but-not-yet-cron-flipped case). */ @@ -80,6 +94,7 @@ export class ContractsRepository extends BaseRepository { return this.repository .createQueryBuilder('contract') .leftJoinAndSelect('contract.routes', 'routes') + .leftJoinAndSelect('contract.cargoScope', 'cargoScope') .where('contract.deleted_at IS NULL') .andWhere('contract.company_id = :companyId', { companyId }) .andWhere('contract.service_type_id = :serviceTypeId', { serviceTypeId }) @@ -541,6 +556,23 @@ export class ContractsRepository extends BaseRepository { // ── Review notes ────────────────────────────────────────────────────────────── + /** + * Bookings on the contract that have not reached a terminal state. Gates the + * customer's own contract cancellation (a contract carrying live cargo may + * not be cancelled) and is surfaced on the detail response so the portal can + * disable the button instead of failing the call. + */ + async countActiveBookings(contractId: string): Promise { + return this.dataSource + .getRepository(Booking) + .createQueryBuilder('b') + .where('b.contract_id = :contractId', { contractId }) + .andWhere('b.status NOT IN (:...terminal)', { + terminal: TERMINAL_BOOKING_STATUSES, + }) + .getCount(); + } + async createReviewNote( contractId: string, body: string, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 43aab750f..4ef525ff7 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -73,6 +73,30 @@ function describeCargoScope(scope?: ContractCargoScope[]): string | null { .join(', '); } +/** + * Order-independent identity of a cargo scope — two contracts cover the same + * cargo only when they list the same container sizes / commodities. Quantity + * caps are deliberately ignored: they size a GENERAL contract, they don't make + * it a different scope. + */ +function cargoScopeKey( + scope?: Array< + Pick + > | null, +): string { + if (!scope?.length) return ''; + return scope + .map((row) => + [ + row.containerSize?.trim().toLowerCase() ?? '', + row.cargoTypeId ?? '', + row.cargoFreeText?.trim().toLowerCase() ?? '', + ].join('|'), + ) + .sort() + .join(','); +} + const NEEDS_ACTION_STATUSES = [ 'SUBMITTED', 'PENDING_APPROVAL', @@ -190,25 +214,34 @@ export class ContractsService { } /** - * Same customer + same service type + an overlapping route already has a - * non-expired contract → block. A route "overlaps" if any origin/destination - * pair matches — good enough today since ONE_TIME and GENERAL contracts both - * carry a single route in practice, and still correct if that changes. + * A live contract only blocks a new request when EVERY commercial dimension + * of the wizard matches it: service type, operation type (trade direction), + * contract kind, cargo scope and route. Change any one of them — a different + * lane, bulk instead of containers, GENERAL instead of ONE_TIME — and the + * customer may request another contract. + * + * A route "overlaps" if any origin/destination pair matches; cargo scope + * matches only when the two scope sets are identical (same freight type and + * the same container sizes / commodities). */ private async assertNoDuplicateContract( companyId: string, - serviceTypeId: string, - routes: CreateContractDto['routes'], + dto: CreateContractDto, ): Promise { const candidates = await this.contractsRepository.findDuplicateCandidates( companyId, - serviceTypeId, + dto.serviceTypeId, ); + const incomingScope = cargoScopeKey(dto.cargoScope); const duplicate = candidates.find( (c) => !isEffectivelyExpired(c) && + c.tradeDirection === dto.tradeDirection && + c.contractKind === dto.contractKind && + c.freightType === dto.freightType && + cargoScopeKey(c.cargoScope) === incomingScope && (c.routes ?? []).some((existingRoute) => - routes.some( + dto.routes.some( (r) => r.originYardId === existingRoute.originYardId && r.destinationYardId === existingRoute.destinationYardId, @@ -220,7 +253,7 @@ export class ContractsService { ? duplicate.contractValidUntil.toISOString().slice(0, 10) : 'its approval completes'; throw new ConflictException( - `An active contract already exists for this service type and route (${duplicate.reference}, valid until ${until}). A new request can't be submitted until it expires or is rejected/cancelled.`, + `An active contract already exists for this service type, operation type, contract kind, cargo scope and route (${duplicate.reference}, valid until ${until}). Change any one of them, or wait until this contract expires or is rejected/cancelled.`, ); } } @@ -257,7 +290,7 @@ export class ContractsService { this.assertRouteShape(dto.contractKind, dto.routes); await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes); if (companyId) { - await this.assertNoDuplicateContract(companyId, dto.serviceTypeId, dto.routes); + await this.assertNoDuplicateContract(companyId, dto); } // Stamp the operational profile for portal scoping. A forwarder contract @@ -813,6 +846,24 @@ export class ContractsService { } } + // Why the contract is frozen — shown to staff and customer alike. + if (contract.status === 'SUSPENDED') { + try { + const note = await this.contractsRepository.findLatestReviewNote( + contract.id, + 'SUSPENSION', + ); + contract.latestSuspensionNote = note?.body ?? null; + } catch { + contract.latestSuspensionNote = null; + } + } + + // Lets the portal disable "Cancel contract" instead of letting the customer + // click it and read a 400. The API re-checks on cancel regardless. + contract.activeBookingCount = + await this.contractsRepository.countActiveBookings(contract.id); + return contract; } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts index 9a86a5b4e..6aa36b13d 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts @@ -50,3 +50,17 @@ export class CancelContractDto { @IsString() reason?: string; } + +export class SuspendContractDto { + @ApiProperty({ description: 'Why the contract is being frozen — shown to the customer' }) + @IsString() + @MinLength(1) + reason!: string; +} + +export class ResumeContractDto { + @ApiPropertyOptional({ description: 'Optional note recorded when the suspension is lifted' }) + @IsOptional() + @IsString() + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts index 4d2a46365..5b64fe5f7 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts @@ -13,6 +13,12 @@ export const CONTRACT_REVIEW_NOTE_TYPES = [ * correct it. One row per round — the advice/dispute loop can repeat. */ 'DUTY_DISPUTE', + /** Backoffice froze the contract; body is the reason shown to the customer. */ + 'SUSPENSION', + /** Backoffice lifted a suspension; body is the optional lift note. */ + 'SUSPENSION_LIFTED', + /** Customer cancelled their own contract; body is their reason. */ + 'CANCELLATION', ] as const; export type ContractReviewNoteType = (typeof CONTRACT_REVIEW_NOTE_TYPES)[number]; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index 3fe48ea27..ddd0ee837 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -29,6 +29,8 @@ export const CONTRACT_STATUSES = [ 'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING', 'ACTIVE_SHIPMENT_IN_PROGRESS', + // Reversible backoffice freeze — see statusBeforeSuspension. + 'SUSPENDED', 'CONTRACT_CLOSED', 'EXPIRED', 'REJECTED', @@ -217,6 +219,14 @@ export class Contract extends BaseEntity { @Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' }) status!: string; + /** + * Status the contract held when the backoffice suspended it, restored when + * the suspension is lifted. Null unless the contract is (or once was) + * SUSPENDED. A suspension without this would just be a cancellation. + */ + @Column({ name: 'status_before_suspension', type: 'varchar', length: 40, nullable: true }) + statusBeforeSuspension?: string | null; + @Column({ name: 'clearance_status', type: 'varchar', length: 40, default: 'NOT_APPLICABLE' }) clearanceStatus!: string; @@ -343,4 +353,18 @@ export class Contract extends BaseEntity { * contract_review_notes, not a column here. */ latestSendBackNote?: string | null; + + /** + * Body of the most recent SUSPENSION review note, attached by + * ContractsService.findById while the contract is SUSPENDED so both sides see + * why it was frozen. Lives in contract_review_notes, not a column here. + */ + latestSuspensionNote?: string | null; + + /** + * Count of this contract's non-terminal bookings, attached by + * ContractsService.findById. The portal disables customer cancellation while + * it is > 0 (the API enforces the same). Not a column. + */ + activeBookingCount?: number; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index b308a5921..a402e6237 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -1,5 +1,6 @@ import { BookingBatchService } from './booking-batch.service'; import { Booking } from '../bookings/entities/booking.entity'; +import { WagonStockLedger } from './wagon-stock-ledger.util'; describe('BookingBatchService — PAID reconcile', () => { const scheduleId = 'schedule-1'; @@ -40,10 +41,12 @@ describe('BookingBatchService — PAID reconcile', () => { previewPaidBookingWagonShortage: jest.Mock; getBookableSchedules: jest.Mock; getWindowConfig: jest.Mock; + wagonStockForSchedule: jest.Mock; }; let dataSource: { getRepository: jest.Mock; transaction: jest.Mock; + query: jest.Mock; }; let notifier: { payNow: jest.Mock; @@ -90,6 +93,13 @@ describe('BookingBatchService — PAID reconcile', () => { }), // No shortage by default — paid bookings link as before. previewPaidBookingWagonShortage: jest.fn().mockResolvedValue(null), + // No physical stock configured → the wagon-type gate stands down and these + // specs keep testing the abstract capacity budget on its own. + wagonStockForSchedule: jest.fn().mockResolvedValue({ + mode: 'YARD', + remainingByTypeId: new Map(), + codesByTypeId: new Map(), + }), getBookableSchedules: jest.fn().mockResolvedValue([]), getWindowConfig: jest.fn().mockResolvedValue({ importWindowLeadDays: 3, @@ -116,6 +126,10 @@ describe('BookingBatchService — PAID reconcile', () => { }; await fn(manager); }), + // cargo/container type -> allowed wagon type lookups (loadAllowedWagonTypeIds). + // Empty = unresolvable, so the physical-stock gate stands down and these + // specs keep exercising the abstract capacity budget alone. + query: jest.fn().mockResolvedValue([]), }; notifier = { @@ -1237,6 +1251,7 @@ describe('BookingBatchService — built-train wagon capacity', () => { return genericRepo; }), transaction: jest.fn(), + query: jest.fn().mockResolvedValue([]), }; const service = new BookingBatchService( dataSource as never, @@ -1344,3 +1359,106 @@ describe('BookingBatchService — built-train wagon capacity', () => { }); }); }); + +/** + * The reported failure: a train advertising 20 free wagons where only 16 are of + * the type the booking can ride. Selecting all 20 took the customer's money for + * space that never existed and then stalled at allocation on wagon 17. + */ +describe('BookingBatchService — physical wagon-type gate', () => { + const NW5 = 'wagon-type-nw5'; + const PW2 = 'wagon-type-pw2'; + const WHOLE_LEG = { fromEdge: 0, toEdge: 1 }; + + /** 16 NW5 + 4 PW2 = 20 wagons on the train, but only 16 usable by an NW5 booking. */ + const mixedStock = () => new WagonStockLedger(new Map([[NW5, 16], [PW2, 4]]), 1); + + const internals = (svc: BookingBatchService) => + svc as unknown as { + hasWagonStock: ( + stock: WagonStockLedger, + ids: string[], + needed: number, + leg: { fromEdge: number; toEdge: number }, + ) => boolean; + maybeOfferPartial: ( + booking: Booking, + isPair: boolean, + candidates: unknown[], + need: { wagons: number; weightTons: number; lengthMeters: number }, + ids: string[], + ) => Promise; + tryPartialOffer: unknown; + isSplitEligible: unknown; + }; + + const service = () => + new BookingBatchService( + { getRepository: jest.fn(), transaction: jest.fn(), query: jest.fn() } as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + undefined, + { findOpenOffer: jest.fn() } as never, + ); + + it('refuses a 20-wagon NW5 booking on a train holding only 16 NW5', () => { + const svc = internals(service()); + const stock = mixedStock(); + expect(svc.hasWagonStock(stock, [NW5], 20, WHOLE_LEG)).toBe(false); + expect(svc.hasWagonStock(stock, [NW5], 16, WHOLE_LEG)).toBe(true); + // A booking that may ride either type sees all 20. + expect(svc.hasWagonStock(stock, [NW5, PW2], 20, WHOLE_LEG)).toBe(true); + }); + + it('stands down when the booking has no allowed wagon type configured', () => { + // Unresolvable configuration must not strand every booking that uses it — + // the abstract capacity budget still governs. + expect(internals(service()).hasWagonStock(mixedStock(), [], 999, WHOLE_LEG)).toBe(true); + }); + + it('sizes the split offer to the wagons that physically exist, not the free slots', async () => { + const svc = service(); + const inner = internals(svc); + // Isolate the sizing decision: eligibility and offer creation are covered + // elsewhere, what matters here is the room handed to tryPartialOffer. + (inner as { isSplitEligible: unknown }).isSplitEligible = () => true; + const tryPartial = jest + .fn() + .mockResolvedValue({ wagons: 16, weightTons: 1600, lengthMeters: 224 }); + (inner as { tryPartialOffer: unknown }).tryPartialOffer = tryPartial; + + const stock = mixedStock(); + const candidate = { + id: 'schedule-1', + // 20 abstract slots free, weight and length wide open. + budget: { + legOf: () => WHOLE_LEG, + remainingFor: () => ({ wagons: 20, weightTons: 99_999, lengthMeters: 99_999 }), + subtract: jest.fn(), + }, + armed: false, + stock, + }; + + const offered = await inner.maybeOfferPartial( + { id: 'b1', reference: 'BK-1', originYardId: 'a', destinationYardId: 'b' } as Booking, + false, + [candidate], + { wagons: 20, weightTons: 2000, lengthMeters: 280 }, + [NW5], + ); + + expect(offered).toBe(true); + // 16, not the 20 free slots — the customer is billed for what can be loaded. + expect(tryPartial.mock.calls[0][2]).toMatchObject({ wagons: 16 }); + // Those 16 are now held, so the next booking in the pass cannot re-take them. + expect(stock.availableFor([NW5], WHOLE_LEG)).toBe(0); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index e010b4db9..586ef2f7c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -87,6 +87,7 @@ import { OverageTolerance, stopYardsFor, } from './corridor-capacity.util'; +import { WagonStockLedger } from './wagon-stock-ledger.util'; export type { Capacity } from './corridor-capacity.util'; @@ -1619,6 +1620,8 @@ export class BookingBatchService implements OnModuleInit { const limits = await this.capacityLimits(locomotive); await this.syncScheduleMaxWagons(schedule, locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); + const stock = await this.stockLedgerFor(schedule, budget); + const allowedWagonTypes = await this.loadAllowedWagonTypeIds(); const minPerWagon = this.minPerWagonNeed(wagonDims); if (budget.isExhausted(minPerWagon)) { await this.setWindow(scheduleId, "FULL"); @@ -1655,14 +1658,19 @@ export class BookingBatchService implements OnModuleInit { // Consolidated partners always share one corridor, so the primary's leg // stands for the pair. const leg = budget.legForYards(booking.originYardId, booking.destinationYardId); + const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes); + // Abstract room AND real wagons of a type this booking can ride — see + // fillRouteDayInternal for why both gates are needed. + const stocked = this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg); - // Per-unit fit trace: which axis (wagons/weight/length) admits or rejects. + // Per-unit fit trace: which axis (wagons/weight/length/stock) admits or rejects. this.logger.debug( `[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` + - `roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)}`, + `roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)} ` + + `stocked=${stocked}`, ); - if (!budget.fits(need, leg)) { + if (!budget.fits(need, leg) || !stocked) { if (isGov) { const freed = await this.preemptForGovernment( scheduleId, @@ -1677,16 +1685,19 @@ export class BookingBatchService implements OnModuleInit { // Doesn't fit whole. A split-eligible import booking is offered the part // that fits in the remaining room (top-up path splits the boundary // booking, mirroring fillRouteDay); otherwise skip and try the next. - const cand: { id: string; budget: CorridorBudget; armed: boolean } = { - id: scheduleId, - budget, - armed, - }; - if (await this.maybeOfferPartial(booking, isPair, [cand], need)) { + const cand: { + id: string; + budget: CorridorBudget; + armed: boolean; + stock: WagonStockLedger; + } = { id: scheduleId, budget, armed, stock }; + if ( + await this.maybeOfferPartial(booking, isPair, [cand], need, wagonTypeIds) + ) { armed = cand.armed; continue; } - continue; // skip a unit that exceeds weight/length/wagons, try the next + continue; // skip a unit that exceeds weight/length/wagons/stock, try the next } } @@ -1704,6 +1715,8 @@ export class BookingBatchService implements OnModuleInit { commercialReserved += 1; } budget.subtract(need, leg); + // Hold the physical wagons too — the next unit must not re-count them. + stock.consume(wagonTypeIds, need.wagons, leg); reservedThisPass += 1; } catch (err) { this.logger.error( @@ -1823,11 +1836,14 @@ export class BookingBatchService implements OnModuleInit { } const wagonDims = await this.loadWagonDims(); + const allowedWagonTypes = await this.loadAllowedWagonTypeIds(); - // Live per-schedule corridor budget + arm/changed flags, in departure order. + // Live per-schedule corridor budget + physical wagon-type stock + arm/changed + // flags, in departure order. const trains: Array<{ id: string; budget: CorridorBudget; + stock: WagonStockLedger; armed: boolean; changed: boolean; }> = []; @@ -1844,7 +1860,8 @@ export class BookingBatchService implements OnModuleInit { const limits = await this.capacityLimits(locomotive); await this.syncScheduleMaxWagons(schedule, locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); - trains.push({ id, budget, armed: false, changed: false }); + const stock = await this.stockLedgerFor(schedule, budget); + trains.push({ id, budget, stock, armed: false, changed: false }); } if (trains.length === 0) return { scheduleIds, commercialReserved: 0 }; @@ -1884,12 +1901,20 @@ export class BookingBatchService implements OnModuleInit { const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null => t.budget.legOf(booking.originYardId, booking.destinationYardId); + // Consolidated pairs share one wagon set; the primary's types stand for both. + const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes); // First train (earliest departure) whose corridor carries this booking's - // leg and still fits it as-is. + // leg, still fits it as-is AND physically holds enough wagons of a type the + // booking can ride. Both gates matter: abstract room without the right + // wagon type is space the allocator can never turn into a loaded consist. let target = trains.find((t) => { const leg = legOn(t); - return leg != null && t.budget.fits(need, leg); + return ( + leg != null && + t.budget.fits(need, leg) && + this.hasWagonStock(t.stock, wagonTypeIds, need.wagons, leg) + ); }); // Per-unit trace: chosen train + each train's remaining room on this leg. @@ -1934,7 +1959,13 @@ export class BookingBatchService implements OnModuleInit { // already consumed most of the room). Consolidated pairs / government / // non-import never split — isSplitEligible guards that. Passing the live // `trains` entries lets maybeOfferPartial mutate the chosen budget/armed. - const offered = await this.maybeOfferPartial(booking, isPair, trains, need); + const offered = await this.maybeOfferPartial( + booking, + isPair, + trains, + need, + wagonTypeIds, + ); if (offered) { // A partial offer opens a real commercial pay window, same as reserve(). commercialReserved += 1; @@ -1964,6 +1995,9 @@ export class BookingBatchService implements OnModuleInit { commercialReserved += 1; } target.budget.subtract(need, legOn(target)!); + // Hold the physical wagons too, so the next unit in this pass sees them + // gone — otherwise two bookings both "fit" the same 16 NW5. + target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!); target.changed = true; reservedThisPass += 1; } catch (err) { @@ -2027,14 +2061,32 @@ export class BookingBatchService implements OnModuleInit { private async maybeOfferPartial( booking: Booking, isPair: boolean, - candidates: Array<{ id: string; budget: CorridorBudget; armed: boolean }>, + candidates: Array<{ + id: string; + budget: CorridorBudget; + armed: boolean; + stock?: WagonStockLedger; + }>, need: Capacity, + wagonTypeIds: string[] = [], ): Promise { if (!this.isSplitEligible(booking, isPair)) return false; const target = candidates .map((c) => { const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId); - return leg ? { c, leg, room: c.budget.remainingFor(leg) } : null; + if (!leg) return null; + const room = c.budget.remainingFor(leg); + // The offer may never exceed the wagons that physically exist in a type + // this booking can ride. This is what turns "20 free wagons, only 16 of + // them NW5" into an offer for 16 — the customer pays for 16 and the + // other 4 leave as the usual remainder booking, instead of paying for + // 20 and stalling at allocation on wagon 17. + const physical = wagonTypeIds.length + ? c.stock?.availableFor(wagonTypeIds, leg) + : undefined; + const wagons = + physical == null ? room.wagons : Math.min(room.wagons, physical); + return { c, leg, room: { ...room, wagons } }; }) .filter((x): x is NonNullable => x != null && x.room.wagons >= 1) .sort((a, b) => b.room.wagons - a.room.wagons)[0]; @@ -2047,6 +2099,7 @@ export class BookingBatchService implements OnModuleInit { ); if (!offered) return false; target.c.budget.subtract(offered, target.leg); + target.c.stock?.consume(wagonTypeIds, offered.wagons, target.leg); target.c.armed = true; return true; } @@ -3468,6 +3521,131 @@ export class BookingBatchService implements OnModuleInit { return dims.length ? dims : [fallback]; } + /** + * Physical wagon-type stock for one schedule, on the same corridor edges its + * {@link CorridorBudget} uses. Sourced from the scheduling service so the + * batch counts exactly the wagons the allocator will later plan against. + */ + private async stockLedgerFor( + schedule: TrainSchedule, + budget: CorridorBudget, + ): Promise { + const stock = await this.trainSchedulingService.wagonStockForSchedule( + schedule.id, + schedule.originStationId, + budget.stops, + ); + return new WagonStockLedger( + stock.remainingByTypeId, + Math.max(1, budget.stops.length - 1), + ); + } + + /** + * Whether the train holds enough PHYSICAL wagons of the types this booking may + * ride. Unresolvable configuration (no allowed wagon type) returns true: the + * abstract budget still governs, and a mis-configured cargo type must not + * silently strand every booking that uses it. + */ + private hasWagonStock( + stock: WagonStockLedger, + wagonTypeIds: string[], + wagonsNeeded: number, + leg: CorridorLeg, + ): boolean { + if (!wagonTypeIds.length) return true; + return stock.availableFor(wagonTypeIds, leg) >= wagonsNeeded; + } + + private allowedWagonTypeCache: { + byCargoTypeId: Map; + byContainerTypeId: Map; + expiresAt: number; + } | null = null; + + /** + * Wagon-type ids each cargo / container type may ride, read straight from the + * join tables. + * + * The batch pool finders deliberately do NOT join `cargoType.wagonTypes` / + * `containerType.wagonTypes` — those many-to-many joins multiply rows badly on + * a hot path. So the pool's booking entities carry the type FK but not the + * allowed list, and resolving it per booking through the relation would come + * back empty. Two small lookups, cached for a minute like {@link loadWagonDims}, + * give the same answer without touching the pool query. + */ + private async loadAllowedWagonTypeIds(): Promise<{ + byCargoTypeId: Map; + byContainerTypeId: Map; + }> { + if (this.allowedWagonTypeCache && this.allowedWagonTypeCache.expiresAt > Date.now()) { + return this.allowedWagonTypeCache; + } + // Inactive wagon types are excluded, matching loadAllowedWagonTypes() in the + // scheduling service — the allocator will not plan against them either. + const [cargoRows, containerRows]: [ + Array<{ typeId: string; wagonTypeId: string }>, + Array<{ typeId: string; wagonTypeId: string }>, + ] = await Promise.all([ + this.dataSource.query( + `SELECT ct.cargo_type_id AS "typeId", ct.wagon_type_id AS "wagonTypeId" + FROM freight.cargo_type_wagon_types ct + JOIN freight.wagon_types wt ON wt.id = ct.wagon_type_id + WHERE wt.is_active IS NOT FALSE`, + ), + this.dataSource.query( + `SELECT ct.container_type_id AS "typeId", ct.wagon_type_id AS "wagonTypeId" + FROM freight.container_type_wagon_types ct + JOIN freight.wagon_types wt ON wt.id = ct.wagon_type_id + WHERE wt.is_active IS NOT FALSE`, + ), + ]); + + const collect = (rows: Array<{ typeId: string; wagonTypeId: string }>) => { + const map = new Map(); + for (const row of rows) { + const list = map.get(row.typeId) ?? []; + list.push(row.wagonTypeId); + map.set(row.typeId, list); + } + return map; + }; + + const value = { + byCargoTypeId: collect(cargoRows), + byContainerTypeId: collect(containerRows), + }; + this.allowedWagonTypeCache = { ...value, expiresAt: Date.now() + 60_000 }; + return value; + } + + /** + * Every wagon-type id this booking may ride. Empty means "unresolvable" — the + * caller must then skip the physical-stock gate rather than block the booking + * on missing configuration. + */ + private allowedWagonTypeIdsFor( + booking: Booking, + allowed: { + byCargoTypeId: Map; + byContainerTypeId: Map; + }, + ): string[] { + if (booking.freightType === "BULK") { + const cargoTypeId = booking.cargoTypeId ?? booking.cargoType?.id; + return cargoTypeId ? (allowed.byCargoTypeId.get(cargoTypeId) ?? []) : []; + } + const ids = new Set(); + for (const line of booking.bookingContainers ?? []) { + const containerTypeId = line.containerTypeId ?? line.containerType?.id; + if (!containerTypeId) continue; + for (const id of allowed.byContainerTypeId.get(containerTypeId) ?? []) { + ids.add(id); + } + } + return [...ids]; + } + /** * Ordered stop yards of the schedule's route (origin → milestones → * destination); the legacy two-stop pseudo-route when milestones are absent. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index 264e6df84..b4520a43c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -5,6 +5,7 @@ import { NotFoundException, Optional, } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource, EntityManager, In } from 'typeorm'; import { Freight } from '@edr/types'; @@ -48,6 +49,7 @@ export class BookingJourneyService { @InjectDataSource() private readonly dataSource: DataSource, private readonly yardFacilities: YardFacilitiesService, private readonly facilityHandling: FacilityHandlingService, + private readonly events: EventEmitter2, @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} @@ -145,6 +147,12 @@ export class BookingJourneyService { }); }); + // Intercity ends here — a ONE_TIME contract closes on its shipment being + // delivered (import/export emit this from booking-transition.complete). + if (nextStatus === 'COMPLETED') { + this.events.emit('booking.completed', { bookingId }); + } + // Customer tracking: THIS booking arrived (train may still be rolling). void this.completeMilestones(booking, [ ...(booking.tradeDirection === 'IMPORT' @@ -303,6 +311,12 @@ export class BookingJourneyService { RETURNING b.id, b.trade_direction`, [schedule.id, schedule.destinationStationId, now], ); + // Intercity rows just completed — let a ONE_TIME contract close on delivery. + for (const row of rows) { + if (row.trade_direction === 'DOMESTIC') { + this.events.emit('booking.completed', { bookingId: row.id }); + } + } return rows.map((r) => r.id); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 8ad256a16..9dfea0781 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -34,11 +34,11 @@ export class CreateContainerTrainScheduleDto { type: [String], format: 'uuid', description: - 'Hand-picked locomotives pulling the train (minimum 2 — front and back). Ignored when trainId is provided.', + 'Hand-picked locomotives pulling the train (minimum 1). Ignored when trainId is provided.', }) @IsOptional() @IsArray() - @ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' }) + @ArrayMinSize(1, { message: 'A train must be pulled by at least one locomotive' }) @IsUUID('all', { each: true }) locomotiveIds?: string[]; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index 8342eae47..0b8d4ad05 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -5,7 +5,7 @@ import { consistViolations, deriveTrainCapacityFromLocomotive, grossWagonWeightTons, - minLocomotiveLimits, + combinedLocomotiveLimits, sizePartialOfferWagons, trainSetLocomotiveLimits, } from './train-capacity.util'; @@ -197,42 +197,75 @@ describe('train-capacity.util', () => { expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54); }); - it('takes the weakest locomotive across a multi-locomotive set', () => { - const limits = minLocomotiveLimits([ - { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, - { maxPullWeightTons: 4000, maxTrainLengthMeters: 760, overageToleranceTons: 20 }, + it('SUMS pull weight and weight tolerance across a multi-locomotive set', () => { + // Two units haul together: 1750 + 1750 = 3500T base, 90 + 90 = 180T overage. + const limits = combinedLocomotiveLimits([ + { maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, + { maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, ]); expect(limits?.maxPullWeightTons).toBe(3500); - expect(limits?.overageToleranceTons).toBe(20); + expect(limits?.overageToleranceTons).toBe(180); + // A single locomotive is just its own limit — no doubling, no halving. + expect( + combinedLocomotiveLimits([ + { maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, + ])?.maxPullWeightTons, + ).toBe(1750); + }); + + it('takes the MINIMUM train length — a second locomotive does not lengthen the siding', () => { + const limits = combinedLocomotiveLimits([ + { maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceMeters: 20 }, + { maxPullWeightTons: 1750, maxTrainLengthMeters: 700, overageToleranceMeters: 5 }, + ]); + expect(limits?.maxTrainLengthMeters).toBe(700); + expect(limits?.overageToleranceMeters).toBe(5); }); it('ignores unconfigured (null) tolerances instead of zeroing the set (S-2026-00024)', () => { // LOCO-019 had 90T tolerance, LOCO-020 had none configured: the set must - // keep the 90, not collapse to 0 and reject 3547.6T on a 3500T train. - const limits = minLocomotiveLimits([ - { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, - { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: null }, + // keep the 90 rather than collapse to 0 — an unset value abstains. + const limits = combinedLocomotiveLimits([ + { maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, + { maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: null }, ]); expect(limits?.overageToleranceTons).toBe(90); // All unconfigured → no tolerance. - const none = minLocomotiveLimits([ + const none = combinedLocomotiveLimits([ { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, ]); expect(none?.overageToleranceTons).toBe(0); }); + it('reports no pull limit when NO locomotive has one configured', () => { + // Summing must not turn "unset" into 0 and strand every booking; an + // all-unset set keeps the old "no opinion" behaviour. + const limits = combinedLocomotiveLimits([ + { maxPullWeightTons: 0, maxTrainLengthMeters: 760 }, + { maxPullWeightTons: 0, maxTrainLengthMeters: 760 }, + ]); + expect(limits?.maxPullWeightTons).toBe(Infinity); + // One configured, one not → only the configured one contributes. + expect( + combinedLocomotiveLimits([ + { maxPullWeightTons: 1750, maxTrainLengthMeters: 760 }, + { maxPullWeightTons: 0, maxTrainLengthMeters: 760 }, + ])?.maxPullWeightTons, + ).toBe(1750); + }); + it('trainSetLocomotiveLimits prefers link rows and falls back to the legacy single loco', () => { - const l1 = { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }; - const l2 = { maxPullWeightTons: 3600, maxTrainLengthMeters: 700, overageToleranceTons: null }; + const l1 = { maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 }; + const l2 = { maxPullWeightTons: 1800, maxTrainLengthMeters: 700, overageToleranceTons: null }; expect( trainSetLocomotiveLimits({ locomotive: null, locomotives: [{ locomotive: l1 }, { locomotive: l2 }] }), ).toEqual({ - maxPullWeightTons: 3500, + maxPullWeightTons: 3550, maxTrainLengthMeters: 700, overageToleranceTons: 90, overageToleranceMeters: 0, }); - expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(3500); + expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(1750); expect(trainSetLocomotiveLimits(null)).toBeNull(); expect(trainSetLocomotiveLimits({ locomotive: null, locomotives: [] })).toBeNull(); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index b4b3a64de..7f9586c3c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -256,27 +256,40 @@ function round3(value: number): number { } /** - * Effective pull limits for a train set with multiple locomotives: the weakest - * locomotive caps the train, so take the minimum pull weight and minimum length - * across all assigned locomotives. Returns null when no locomotives are given. + * Effective limits for a train set, per axis: + * + * - **Pull weight ADDS UP.** Locomotives haul together, so two 1750T units pull + * 3500T. Only CONFIGURED pull weights are summed; a set with none configured + * reports Infinity (no opinion), exactly as before. + * - **Weight tolerance ADDS UP**, following its axis — each locomotive brings its + * own overage allowance, so 2 × 90T gives the set 180T. Unset abstains (0). + * - **Length takes the MINIMUM.** Train length is a siding/loop constraint, not + * a haulage one: coupling a second locomotive does not lengthen the track, so + * the most restrictive locomotive still governs (and its tolerance with it). + * + * Returns null when no locomotives are given. */ -export function minLocomotiveLimits( +export function combinedLocomotiveLimits( locomotives: Array< Pick & Partial> >, ): LocomotiveLimits | null { if (!locomotives.length) return null; + const configuredPulls = locomotives + .map((l) => num(l.maxPullWeightTons)) + .filter((v) => v > 0); + return { - maxPullWeightTons: Math.min( - ...locomotives.map((l) => num(l.maxPullWeightTons, Infinity) || Infinity), - ), + maxPullWeightTons: configuredPulls.length + ? round3(configuredPulls.reduce((sum, v) => sum + v, 0)) + : Infinity, maxTrainLengthMeters: Math.min( ...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity), ), - // Weakest CONFIGURED tolerance governs the set — a locomotive with no - // tolerance set has no opinion, it does not zero out the others. - overageToleranceTons: minConfigured(locomotives.map((l) => l.overageToleranceTons)), + overageToleranceTons: sumConfigured(locomotives.map((l) => l.overageToleranceTons)), + // Paired with the length axis, so it stays the weakest CONFIGURED value — a + // locomotive with no tolerance set has no opinion, it does not zero the others. overageToleranceMeters: minConfigured(locomotives.map((l) => l.overageToleranceMeters)), }; } @@ -286,10 +299,16 @@ function minConfigured(values: Array): number { return configured.length ? Math.min(...configured) : 0; } +function sumConfigured(values: Array): number { + const configured = values.filter((v) => v != null).map((v) => num(v)); + return configured.length ? round3(configured.reduce((sum, v) => sum + v, 0)) : 0; +} + /** - * Effective limits for a whole train set: min across its linked locomotives, - * falling back to the legacy single `locomotive` column for sets created - * before multi-loco support. Null when the set has no locomotive at all. + * Effective limits for a whole train set: {@link combinedLocomotiveLimits} over + * its linked locomotives, falling back to the legacy single `locomotive` column + * for sets created before multi-loco support. Null when the set has no + * locomotive at all. */ export function trainSetLocomotiveLimits( trainSet?: { @@ -306,7 +325,7 @@ export function trainSetLocomotiveLimits( : trainSet.locomotive ? [trainSet.locomotive] : []; - return minLocomotiveLimits(pool); + return combinedLocomotiveLimits(pool); } /** Per-booking train length from wagon count and freight-specific wagon type length. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 15a542d31..418338257 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -133,7 +133,7 @@ import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util'; import { bookingCargoTons, deriveTrainCapacityFromLocomotive, - minLocomotiveLimits, + combinedLocomotiveLimits, trainSetLocomotiveLimits, wagonTypeDimensionsFromEntity, LocomotiveLimits, @@ -1300,9 +1300,9 @@ export class TrainSchedulingService { .slice() .sort((a, b) => a.sequenceNo - b.sequenceNo) .map((link) => link.locomotiveId); - if (locomotiveIds.length < 2) { + if (locomotiveIds.length < 1) { throw new BadRequestException( - `Train ${builtTrain.code} has fewer than two locomotives; rebuild it before scheduling`, + `Train ${builtTrain.code} has no locomotive; rebuild it before scheduling`, ); } if (builtTrain.currentYardId !== route.originYardId) { @@ -1323,8 +1323,8 @@ export class TrainSchedulingService { } } else { locomotiveIds = [...new Set(dto.locomotiveIds ?? [])]; - if (locomotiveIds.length < 2) { - throw new BadRequestException('A train must be pulled by at least two locomotives'); + if (locomotiveIds.length < 1) { + throw new BadRequestException('A train must be pulled by at least one locomotive'); } } @@ -1382,7 +1382,7 @@ export class TrainSchedulingService { builtTrain?.id ?? null, ); // Effective capacity is capped by the weakest locomotive in the set. - const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined; + const limitLoco = combinedLocomotiveLimits(lockedLocomotives) ?? undefined; const departure = new Date(dto.scheduleDate); // Every schedule starts with a CLOSED customer window; the window engine opens // it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT @@ -1572,7 +1572,7 @@ export class TrainSchedulingService { }; const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet); - const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined; + const limitLoco = combinedLocomotiveLimits(setLocomotives) ?? undefined; const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco); // Callers that add bookings without hand-picking container slots (the @@ -3971,36 +3971,12 @@ export class TrainSchedulingService { const builtTrainId = await this.builtTrainIdOfSchedule(targetScheduleId); const originYardId = dto.originStationId; - let stock: WagonStock; - if (builtTrainId) { - stock = await this.builtTrainStock(builtTrainId); - } else { - // Dynamic consist: a slot's physical wagon may ride from the train's origin - // OR already sit at the booking's own boarding yard and attach there — so - // the usable fleet is the union across the origin and every boarding yard. - const boardYardIds = [ - ...new Set( - [originYardId, ...bookings.map((b) => b.originYardId)].filter(Boolean), - ), - ]; - const fleetCountsByYard = await Promise.all( - boardYardIds.map((yardId) => - this.countFleetAvailability(yardId, targetScheduleId), - ), - ); - const remainingByTypeId = new Map(); - const codesByTypeId = new Map(); - for (const rows of fleetCountsByYard) { - for (const row of rows) { - remainingByTypeId.set( - row.wagonTypeId, - (remainingByTypeId.get(row.wagonTypeId) ?? 0) + row.available, - ); - codesByTypeId.set(row.wagonTypeId, row.wagonTypeCode); - } - } - stock = { mode: 'YARD', remainingByTypeId, codesByTypeId }; - } + const stock: WagonStock = await this.wagonStockForSchedule( + targetScheduleId, + originYardId, + bookings.map((b) => b.originYardId), + builtTrainId, + ); // Leg-aware stock: each booking consumes wagons only on the edges it rides, // so a ride-along on an empty leg never competes with cargo on a full one. @@ -4129,7 +4105,7 @@ export class TrainSchedulingService { // warning (it must arrive before dispatch), but a set too weak to pull the train // is a hard violation. const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId); - const setLimits = minLocomotiveLimits(assignedLocomotives); + const setLimits = combinedLocomotiveLimits(assignedLocomotives); if (offYard) { warnings.push( `Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`, @@ -4799,6 +4775,51 @@ export class TrainSchedulingService { * type. This is the whole plannable pool for its schedules — the plan is * full when every consist wagon is allocated. */ + /** + * The physical wagons a schedule can actually plan against, by wagon type. + * + * A schedule built from a Train Builder train plans against ONLY that train's + * own consist. A legacy/dynamic-consist schedule plans against the boarding + * yards' loose pool: a slot's wagon may ride from the train's origin OR + * already sit at the booking's own boarding yard and attach there, so the + * usable fleet is the union across the origin and every boarding yard. + * + * Public because batch fill needs the SAME stock the allocator will later + * validate against — selecting a booking the allocator cannot place is how + * customers ended up paying for wagons that were never there. + */ + async wagonStockForSchedule( + scheduleId: string | undefined, + originYardId: string, + boardingYardIds: Array = [], + preloadedBuiltTrainId?: string | null, + ): Promise { + const builtTrainId = + preloadedBuiltTrainId !== undefined + ? preloadedBuiltTrainId + : await this.builtTrainIdOfSchedule(scheduleId); + if (builtTrainId) return this.builtTrainStock(builtTrainId); + + const boardYardIds = [ + ...new Set([originYardId, ...boardingYardIds].filter((id): id is string => Boolean(id))), + ]; + const fleetCountsByYard = await Promise.all( + boardYardIds.map((yardId) => this.countFleetAvailability(yardId, scheduleId)), + ); + const remainingByTypeId = new Map(); + const codesByTypeId = new Map(); + for (const rows of fleetCountsByYard) { + for (const row of rows) { + remainingByTypeId.set( + row.wagonTypeId, + (remainingByTypeId.get(row.wagonTypeId) ?? 0) + row.available, + ); + codesByTypeId.set(row.wagonTypeId, row.wagonTypeCode); + } + } + return { mode: 'YARD', remainingByTypeId, codesByTypeId }; + } + private async builtTrainStock(builtTrainId: string): Promise { const wagons = await this.dataSource.getRepository(Wagon).find({ where: { trainId: builtTrainId }, @@ -5347,7 +5368,7 @@ export class TrainSchedulingService { * schedule-creation picker. Mirrors the locomotive picker's advance-scheduling * philosophy: nothing serviceable is filtered out — staff see the status, * whether the train sits at the origin yard yet, and its future schedules. - * Trains with fewer than two locomotives are omitted (never schedulable). + * Trains with no locomotive at all are omitted (never schedulable). */ async getAvailableTrainsForRoute(routeId: string) { const route = await this.getSchedulableRoute(routeId); @@ -5385,7 +5406,7 @@ export class TrainSchedulingService { const futureCounts = new Map(counts.map((c) => [c.train_id, Number(c.future_count)])); return trains - .filter((train) => (train.locomotives ?? []).length >= 2) + .filter((train) => (train.locomotives ?? []).length >= 1) .map((train) => { const wagons = train.wagons ?? []; return { @@ -5417,7 +5438,15 @@ export class TrainSchedulingService { totalLengthMeters: roundTons( wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0), ), - maxPullWeightTons: roundTons(Number(train.capacityTons)), + // Live from the coupled set — `capacity_tons` still holds the old + // single-locomotive figure on trains built before pull weight summed. + maxPullWeightTons: roundTons( + combinedLocomotiveLimits( + (train.locomotives ?? []) + .map((link) => link.locomotive) + .filter((loco): loco is Locomotive => Boolean(loco)), + )?.maxPullWeightTons ?? Number(train.capacityTons), + ), atOriginYard: train.currentYardId === route.originYardId, futureScheduleCount: futureCounts.get(train.id) ?? 0, }; @@ -5467,7 +5496,7 @@ export class TrainSchedulingService { ); const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(); - const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); + const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0)); const overageToleranceTons = roundTons(Number(limits?.overageToleranceTons) || 0); const maxTrainLengthMeters = roundTons(Number(limits?.maxTrainLengthMeters ?? 0)); @@ -5590,7 +5619,7 @@ export class TrainSchedulingService { .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) .map((slot) => slot.physicalWagonId as string), ); - const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); + const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); const pullCapTons = roundTons( Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0), ); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts new file mode 100644 index 000000000..47823cddd --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts @@ -0,0 +1,70 @@ +import { WagonStockLedger } from './wagon-stock-ledger.util'; + +const WHOLE = { fromEdge: 0, toEdge: 1 }; + +describe('WagonStockLedger', () => { + it('reports the wagons of a booking\'s OWN types, not the train total', () => { + // The reported case: 20 free wagons on the train, but only 16 of them NW5. + const ledger = new WagonStockLedger( + new Map([ + ['nw5', 16], + ['pw2', 4], + ]), + 1, + ); + expect(ledger.availableFor(['nw5'], WHOLE)).toBe(16); + expect(ledger.availableFor(['pw2'], WHOLE)).toBe(4); + // A cargo type mapped to both may ride either, so they add up. + expect(ledger.availableFor(['nw5', 'pw2'], WHOLE)).toBe(20); + // Duplicates must not double-count. + expect(ledger.availableFor(['nw5', 'nw5'], WHOLE)).toBe(16); + // An unconfigured type has no stock. + expect(ledger.availableFor(['unknown'], WHOLE)).toBe(0); + }); + + it('consumes what it can and reports the shortfall', () => { + const ledger = new WagonStockLedger(new Map([['nw5', 16]]), 1); + // A 20-wagon booking can only take 16 — the caller splits on that number. + expect(ledger.consume(['nw5'], 20, WHOLE)).toBe(16); + expect(ledger.availableFor(['nw5'], WHOLE)).toBe(0); + expect(ledger.consume(['nw5'], 1, WHOLE)).toBe(0); + }); + + it('drains the deepest stock first across candidate types', () => { + const ledger = new WagonStockLedger( + new Map([ + ['nw5', 10], + ['nw7', 3], + ]), + 1, + ); + expect(ledger.consume(['nw5', 'nw7'], 12, WHOLE)).toBe(12); + // 10 from NW5 then 2 from NW7 — one NW7 left. + expect(ledger.availableFor(['nw7'], WHOLE)).toBe(1); + expect(ledger.availableFor(['nw5'], WHOLE)).toBe(0); + }); + + it('frees stock past an alight yard — disjoint legs never compete', () => { + // Three stops (A→B→C) = two edges. An intercity booking riding A→B must + // not consume the wagon on B→C. + const ledger = new WagonStockLedger(new Map([['nw5', 5]]), 2); + const firstLeg = { fromEdge: 0, toEdge: 1 }; + const secondLeg = { fromEdge: 1, toEdge: 2 }; + + ledger.consume(['nw5'], 5, firstLeg); + expect(ledger.availableFor(['nw5'], firstLeg)).toBe(0); + expect(ledger.availableFor(['nw5'], secondLeg)).toBe(5); + + // A whole-route booking sees the busiest edge it crosses, so it is blocked. + expect(ledger.availableFor(['nw5'], { fromEdge: 0, toEdge: 2 })).toBe(0); + }); + + it('counts the busiest edge within a leg, not the sum of edges', () => { + const ledger = new WagonStockLedger(new Map([['nw5', 10]]), 3); + ledger.consume(['nw5'], 4, { fromEdge: 0, toEdge: 1 }); + ledger.consume(['nw5'], 6, { fromEdge: 1, toEdge: 2 }); + // Edge 0 uses 4, edge 1 uses 6 — a booking over both needs 10 free at once. + expect(ledger.availableFor(['nw5'], { fromEdge: 0, toEdge: 2 })).toBe(4); + expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 3 })).toBe(10); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts new file mode 100644 index 000000000..0e4f6949d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts @@ -0,0 +1,86 @@ +import type { CorridorLeg } from './corridor-capacity.util'; + +/** + * Physical wagon-type stock for one train, consumed per corridor edge. + * + * The {@link CorridorBudget} tracks ABSTRACT capacity — slots, pull weight, + * length. It cannot tell a NW5 from a PW2, so a train showing "20 free wagons" + * would admit a 20-wagon booking whose cargo only rides NW5 even when the yard + * holds 16 NW5 and 4 PW2. The batch selected all 20, the customer paid for 20, + * and allocation then failed on wagon 17 with "No NW5 wagon available at the + * yard" — money taken for space that never existed. + * + * This ledger is the missing axis: how many wagons of the types a booking may + * actually ride are free. Batch fill consults it alongside the budget, so a + * booking is admitted whole only when both agree, and is otherwise offered a + * split sized to the wagons that genuinely exist. + * + * Stock is consumed PER EDGE, mirroring `planWagonsWithStock`: a wagon freed at + * an alight yard is available again downstream, so an intercity ride-along on + * Gelan→Adama never competes for stock with an export on Adama→Doraleh. + */ +export class WagonStockLedger { + private readonly usedPerEdge = new Map(); + + constructor( + private readonly remainingByTypeId: Map, + private readonly edgeCount: number, + ) {} + + /** Free wagons of ONE type on a leg: total minus its busiest edge within that leg. */ + private availableForType(wagonTypeId: string, leg: CorridorLeg): number { + const total = this.remainingByTypeId.get(wagonTypeId) ?? 0; + const row = this.usedPerEdge.get(wagonTypeId); + if (!row) return total; + let busiest = 0; + for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) { + busiest = Math.max(busiest, row[edge] ?? 0); + } + return Math.max(0, total - busiest); + } + + /** + * Free wagons across every type a booking may ride. A cargo/container type + * mapped to several wagon types can use any of them, so they add up. + */ + availableFor(wagonTypeIds: readonly string[], leg: CorridorLeg): number { + let total = 0; + for (const id of new Set(wagonTypeIds)) { + total += this.availableForType(id, leg); + } + return total; + } + + /** + * Take `wagons` from the candidate types, deepest stock first so the consist + * drains evenly (same tie-break as the wagon planner). Returns how many were + * actually taken — less than asked when the stock is short. + */ + consume(wagonTypeIds: readonly string[], wagons: number, leg: CorridorLeg): number { + let outstanding = Math.max(0, Math.floor(wagons)); + const candidates = [...new Set(wagonTypeIds)]; + let taken = 0; + + while (outstanding > 0) { + const deepest = candidates + .map((id) => ({ id, free: this.availableForType(id, leg) })) + .filter((c) => c.free > 0) + .sort((a, b) => b.free - a.free)[0]; + if (!deepest) break; + + const take = Math.min(outstanding, deepest.free); + let row = this.usedPerEdge.get(deepest.id); + if (!row) { + row = new Array(this.edgeCount).fill(0); + this.usedPerEdge.set(deepest.id, row); + } + for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) { + row[edge] = (row[edge] ?? 0) + take; + } + outstanding -= take; + taken += take; + } + + return taken; + } +} diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts index 4ad52a226..5c26d2475 100644 --- a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts @@ -6,7 +6,7 @@ import { TrainSet } from './train-set.entity'; /** * Link row joining a train set to one of its locomotives. A train set must be - * pulled by at least two locomotives (front + back); `sequenceNo` is a plain + * pulled by at least one locomotive; `sequenceNo` is a plain * order index — no front/rear semantics are modelled yet. */ @Entity({ schema: 'freight', name: 'train_set_locomotives' }) diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts index c82cfd2eb..5f98ea608 100644 --- a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts @@ -29,7 +29,7 @@ export class TrainSet extends BaseEntity { @JoinColumn({ name: 'locomotive_id' }) locomotive?: Locomotive; - /** All locomotives pulling this train set (minimum 2). */ + /** All locomotives pulling this train set (minimum 1). */ @OneToMany(() => TrainSetLocomotive, (link) => link.trainSet) locomotives?: TrainSetLocomotive[]; diff --git a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts index 5ba77fb10..54322c789 100644 --- a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts +++ b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts @@ -33,10 +33,10 @@ export class BuildTrainDto { @ApiProperty({ type: [String], format: 'uuid', - description: 'Locomotives pulling the train (minimum 2 — front and back), in consist order', + description: 'Locomotives pulling the train (minimum 1), in consist order', }) @IsArray() - @ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' }) + @ArrayMinSize(1, { message: 'A train must be pulled by at least one locomotive' }) @IsUUID('all', { each: true }) locomotiveIds!: string[]; diff --git a/apps/edr-freight-api/src/modules/trains/dto/update-train-locomotives.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/update-train-locomotives.dto.ts index 36562e970..0fab5ec5b 100644 --- a/apps/edr-freight-api/src/modules/trains/dto/update-train-locomotives.dto.ts +++ b/apps/edr-freight-api/src/modules/trains/dto/update-train-locomotives.dto.ts @@ -5,10 +5,10 @@ export class UpdateTrainLocomotivesDto { @ApiProperty({ type: [String], format: 'uuid', - description: 'Full replacement locomotive set (minimum 2), in consist order', + description: 'Full replacement locomotive set (minimum 1), in consist order', }) @IsArray() - @ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' }) + @ArrayMinSize(1, { message: 'A train must be pulled by at least one locomotive' }) @IsUUID('all', { each: true }) locomotiveIds!: string[]; } diff --git a/apps/edr-freight-api/src/modules/trains/entities/train-locomotive.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train-locomotive.entity.ts index 681b39a55..0c834379e 100644 --- a/apps/edr-freight-api/src/modules/trains/entities/train-locomotive.entity.ts +++ b/apps/edr-freight-api/src/modules/trains/entities/train-locomotive.entity.ts @@ -6,7 +6,7 @@ import { Train } from './train.entity'; /** * Link row joining a built train to one of its locomotives. A train must be - * pulled by at least two locomotives (front + back); `sequenceNo` is the order + * pulled by at least one locomotive; `sequenceNo` is the order * in the consist — 0 is the lead locomotive. * * Mirrors `train_set_locomotives`, but for the persistent fleet `Train` built diff --git a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts index 493e564e8..7a1ea5b64 100644 --- a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts +++ b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts @@ -80,7 +80,7 @@ export class Train extends BaseEntity { @OneToMany(() => Wagon, (wagon) => wagon.train) wagons!: Wagon[]; - /** Locomotives pulling this train (minimum 2), ordered by sequenceNo. */ + /** Locomotives pulling this train (minimum 1), ordered by sequenceNo. */ @OneToMany(() => TrainLocomotive, (link) => link.train) locomotives?: TrainLocomotive[]; } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index 19d631fbc..2b74c0253 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -62,7 +62,7 @@ export class TrainBuilderController { @Put(':id/locomotives') @FleetManage(FREIGHT_PERMS.trains.update) - @ApiOperation({ summary: 'Replace the locomotive set (minimum 2, same yard)' }) + @ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' }) setLocomotives( @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTrainLocomotivesDto, diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 31f108740..6aed88f07 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -3,6 +3,7 @@ import { BadRequestException, ConflictException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { DataSource, EntityManager, ILike, In } from 'typeorm'; @@ -10,7 +11,7 @@ import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; -import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util'; +import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; @@ -55,12 +56,14 @@ export interface ActiveScheduleRef { */ @Injectable() export class TrainBuilderService { + private readonly logger = new Logger(TrainBuilderService.name); + constructor(private readonly dataSource: DataSource) {} async buildTrain(dto: BuildTrainDto) { const locomotiveIds = [...new Set(dto.locomotiveIds)]; - if (locomotiveIds.length < 2) { - throw new BadRequestException('A train must be pulled by at least two locomotives'); + if (locomotiveIds.length < 1) { + throw new BadRequestException('A train must be pulled by at least one locomotive'); } const trainId = await this.dataSource.transaction(async (manager) => { @@ -96,7 +99,7 @@ export class TrainBuilderService { ); // Effective haul capacity is capped by the weakest locomotive in the set. - const limits = minLocomotiveLimits(locomotives); + const limits = combinedLocomotiveLimits(locomotives); const train = await manager.getRepository(Train).save( manager.getRepository(Train).create({ code, @@ -283,7 +286,7 @@ export class TrainBuilderService { : null, })); - const limits = minLocomotiveLimits( + const limits = combinedLocomotiveLimits( (train.locomotives ?? []) .map((link) => link.locomotive) .filter((loco): loco is Locomotive => Boolean(loco)), @@ -339,11 +342,11 @@ export class TrainBuilderService { }; } - /** Replace the locomotive set (still minimum 2, same-yard rule applies). */ + /** Replace the locomotive set (minimum 1, same-yard rule applies). */ async setLocomotives(id: string, dto: UpdateTrainLocomotivesDto) { const locomotiveIds = [...new Set(dto.locomotiveIds)]; - if (locomotiveIds.length < 2) { - throw new BadRequestException('A train must be pulled by at least two locomotives'); + if (locomotiveIds.length < 1) { + throw new BadRequestException('A train must be pulled by at least one locomotive'); } await this.dataSource.transaction(async (manager) => { const train = await this.getEditableTrain(manager, id); @@ -360,7 +363,7 @@ export class TrainBuilderService { train.id, ); await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds); - const limits = minLocomotiveLimits(locomotives); + const limits = combinedLocomotiveLimits(locomotives); await manager .getRepository(Train) .update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) }); @@ -520,6 +523,28 @@ export class TrainBuilderService { sequenceNumber: null, status: WagonStatus.Maintenance, }); + // Audit row: which train it came off and when. The wagon does not change + // yard here, so from/to are the same — the ledger is the wagon's history + // surface, and a maintenance detach has to be in it. + const yardId = wagon.currentYardId ?? train.currentYardId ?? null; + if (yardId) { + await manager.getRepository(WagonMovement).save( + manager.getRepository(WagonMovement).create({ + wagonId: wagon.id, + fromYardId: yardId, + toYardId: yardId, + kind: WagonMovementKind.Maintenance, + note: `Sent to maintenance from train ${train.trainNumber ?? train.code}`, + occurredAt: new Date(), + }), + ); + } else { + // to_yard_id is NOT NULL — a yard-less wagon still goes to maintenance, + // it just cannot carry a ledger row. + this.logger.warn( + `Wagon ${wagon.wagonNumber} sent to maintenance with no yard — ledger row skipped`, + ); + } await this.resequenceWagons(manager, train.id); }); return this.getComposition(id); @@ -743,7 +768,16 @@ export class TrainBuilderService { totalLengthMeters: round( wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0), ), - maxPullWeightTons: round(train.capacityTons), + // Derived live from the coupled set, NOT from the stored capacity_tons. + // That column is written at build/re-couple time, so every train built + // before pull weight became additive still holds the old single-locomotive + // figure. Computing it here keeps the board honest without a backfill; + // the column self-heals the next time the locomotive set is saved. + maxPullWeightTons: round( + combinedLocomotiveLimits(locomotives)?.maxPullWeightTons ?? + Number(train.capacityTons) ?? + 0, + ), }; } @@ -878,7 +912,7 @@ export class TrainBuilderService { where: { trainId: train.id }, relations: { locomotive: true }, }); - const limits = minLocomotiveLimits( + const limits = combinedLocomotiveLimits( links .map((link) => link.locomotive) .filter((loco): loco is Locomotive => Boolean(loco)), diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 2924169eb..eaf2f1c02 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -103,6 +103,9 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [ // Each has its own permission so the two desks are genuinely separate people. perm('a3000001-0001-4000-8000-000000000019', 'edr_freight_app:contracts:hazardous_approval_one', 'Hazardous approval — first review'), perm('a3000001-0001-4000-8000-00000000001a', 'edr_freight_app:contracts:hazardous_approval_two', 'Hazardous approval — second review'), + // Freeze/unfreeze a signed contract. One key covers both directions — whoever + // may suspend must be able to lift it again. + perm('a3000001-0001-4000-8000-00000000001b', 'edr_freight_app:contracts:suspend', 'Suspend / resume a signed contract'), ]; // Existing per-slug view ids are kept as-is: position-type grants reference @@ -447,6 +450,7 @@ export const FREIGHT_PERMS = { clearanceEtActions: 'edr_freight_app:contracts:clearance_et_actions', clearanceDjActions: 'edr_freight_app:contracts:clearance_dj_actions', clearanceDutyAdvise: 'edr_freight_app:contracts:clearance_duty_advise', + suspend: 'edr_freight_app:contracts:suspend', }, trainScheduling: { view: 'edr_freight_app:train_scheduling:view', @@ -897,6 +901,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.approveLineStaff, FREIGHT_PERMS.contracts.generateContract, ...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff), + FREIGHT_PERMS.contracts.suspend, ], orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], } as const; diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 5e2e48f32..77e67f210 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -168,8 +168,8 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.bookings.view, }, - // Operations hub: clearance-document review for contracts WITHOUT - // customs clearing (contract-level for one-time, per-booking for general). + // Operations hub: per-shipment clearance-document review for services + // WITHOUT customs clearing (self-clearance) — bookings only. { label: "Clearance Documents", href: "/dashboard/contracts/clearance-documents", diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx index fcd04fb58..8c4833550 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx @@ -1,13 +1,15 @@ import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; -import { Button, Modal, Stack, Text, Textarea } from "@mantine/core"; +import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core"; import { Check, Eye, FilePen, FileSignature, MessageSquareWarning, + PauseCircle, + PlayCircle, ShieldCheck, XCircle, Zap, @@ -43,6 +45,21 @@ const CLEARANCE_REVIEW_STATUSES = [ "CLEARANCE_READY_FOR_BOOKING", ]; +/** + * Every step from the customer signature onward can be frozen. Mirrors + * SUSPENDABLE_CONTRACT_STATUSES on the API — the server is the authority, this + * list only decides whether the button is drawn. + */ +const SUSPENDABLE_STATUSES = [ + "SIGNED_CUSTOMER", + "FULLY_EXECUTED", + "CONTRACT_ACTIVE", + "AWAITING_CLEARANCE_DOCUMENTS", + "CLEARANCE_UNDER_REVIEW", + "CLEARANCE_READY_FOR_BOOKING", + "ACTIVE_SHIPMENT_IN_PROGRESS", +]; + /** Detail-page staff actions: accept / request changes / reject / generate / sign. */ export function ContractActionsToolbar({ contract, @@ -62,6 +79,8 @@ export function ContractActionsToolbar({ FREIGHT_PERMS.contracts.requestChanges[arm], ); const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]); + // One key both ways — whoever can freeze a contract can unfreeze it. + const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend); const [editorOpen, setEditorOpen] = useState(false); const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept"); @@ -70,6 +89,10 @@ export function ContractActionsToolbar({ const [changesNote, setChangesNote] = useState(""); const [rejectOpen, setRejectOpen] = useState(false); const [rejectReason, setRejectReason] = useState(""); + const [suspendOpen, setSuspendOpen] = useState(false); + const [suspendReason, setSuspendReason] = useState(""); + const [resumeOpen, setResumeOpen] = useState(false); + const [resumeNote, setResumeNote] = useState(""); // Whether the document is editable depends on WHO is viewing — only the // approver whose turn it is may edit — so the server decides, not the client. @@ -110,6 +133,86 @@ export function ContractActionsToolbar({ ); } + // Frozen: nothing on this contract moves — no new bookings, no progress on + // the shipments already under it — until the suspension is lifted, which + // returns the contract to the status it was suspended at. + if (status === "SUSPENDED") { + return ( + + + + This contract is frozen. New bookings are blocked and its existing + shipments cannot progress. + {contract.statusBeforeSuspension + ? ` Lifting the suspension returns it to ${contract.statusBeforeSuspension}.` + : ""} + + {contract.latestSuspensionNote && ( + + Reason: {contract.latestSuspensionNote} + + )} + {maySuspend ? ( + + ) : ( + + You do not have permission to lift a suspension. + + )} + + + setResumeOpen(false)} + title="Lift suspension?" + centered + > + + + Contract {contract.reference} will return to{" "} + {contract.statusBeforeSuspension ?? "CONTRACT_ACTIVE"} and + the customer will be notified. Bookings on it resume immediately. + +