From f71bbbf782f8aacb482a703cedc854cd3a6ea6b1 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 16 Jul 2026 12:08:45 +0000 Subject: [PATCH 01/88] feat: setup account page for customer and centeralize the otps and phone usages to use the iam user --- .../src/modules/auth/account.controller.ts | 62 +++ .../src/modules/auth/account.service.ts | 226 +++++++++++ .../src/modules/auth/dto/account.dto.ts | 60 +++ .../modules/auth/forgot-password.service.ts | 9 +- .../src/modules/auth/freight-auth.module.ts | 14 +- .../src/modules/auth/mask-target.util.ts | 16 + .../booking-lifecycle-notifier.service.ts | 9 +- .../companies/company-notifier.service.ts | 7 +- .../contracts/contract-notifier.service.ts | 9 +- .../contracts/contract-transition.service.ts | 74 ++-- .../contracts/dto/sign-contract.dto.ts | 12 +- .../notifications/notify-company.util.ts | 11 +- .../resolve-company-phone.util.ts | 60 +++ .../booking-notifier.service.ts | 9 +- .../booking-window.service.ts | 7 +- .../warehouses/warehouse-inventory.service.ts | 13 +- .../warehouses/warehouse-invoice.service.ts | 7 +- .../portal/src/constants/URLS.ts | 9 + .../portal/src/pages/SettingsPage.tsx | 25 +- .../src/pages/contracts/ContractViewPage.tsx | 9 +- .../portal/src/pages/settings/TabAccount.tsx | 374 ++++++++++++++++++ .../portal/src/services/api.ts | 25 ++ .../portal/src/services/auth.service.ts | 35 ++ .../portal/src/services/bookings.service.ts | 2 - .../portal/src/services/contracts.service.ts | 7 +- apps/edr-freight-web/portal/src/types/auth.ts | 28 ++ 26 files changed, 1057 insertions(+), 62 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/auth/account.controller.ts create mode 100644 apps/edr-freight-api/src/modules/auth/account.service.ts create mode 100644 apps/edr-freight-api/src/modules/auth/dto/account.dto.ts create mode 100644 apps/edr-freight-api/src/modules/auth/mask-target.util.ts create mode 100644 apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts create mode 100644 apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx diff --git a/apps/edr-freight-api/src/modules/auth/account.controller.ts b/apps/edr-freight-api/src/modules/auth/account.controller.ts new file mode 100644 index 000000000..d7d7f15c3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/account.controller.ts @@ -0,0 +1,62 @@ +import { Body, Controller, Patch, Post, UseGuards } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator"; +import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; + +import { AccountService } from "./account.service"; +import { + SendContactOtpDto, + UpdateAccountNameDto, + UpdateContactDto, +} from "./dto/account.dto"; + +/** + * The caller's own account record. Everything here is scoped to the JWT's user + * id — there is no `:id` parameter to tamper with, so these routes need no + * permission key beyond being authenticated. + */ +@ApiTags("auth") +@Controller("me") +@ApiBearerAuth() +@UseGuards(JwtGuard) +export class AccountController { + constructor(private readonly accountService: AccountService) {} + + @Post("contact/otp") + @ApiOperation({ + summary: "Send a verification code to a new email/phone before changing it", + description: + "The code goes to the NEW value supplied here, proving the caller controls " + + "it. Returns the target masked — an unverified caller never gets it back in full.", + }) + sendContactOtp( + @CurrentUser() user: TCurrentUser, + @Body() dto: SendContactOtpDto, + ): Promise<{ sentTo: string }> { + return this.accountService.sendContactOtp(user.id, dto); + } + + @Patch("contact") + @ApiOperation({ + summary: "Change the account's email or phone, gated by a verification code", + description: + "Verifies the code and writes the new value in one call, so the API never " + + "has to take a client's word that verification happened.", + }) + updateContact( + @CurrentUser() user: TCurrentUser, + @Body() dto: UpdateContactDto, + ): Promise<{ success: true; value: string }> { + return this.accountService.updateContact(user.id, dto); + } + + @Patch("name") + @ApiOperation({ summary: "Change the account's display name" }) + updateName( + @CurrentUser() user: TCurrentUser, + @Body() dto: UpdateAccountNameDto, + ): Promise<{ success: true }> { + return this.accountService.updateName(user.id, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/account.service.ts b/apps/edr-freight-api/src/modules/auth/account.service.ts new file mode 100644 index 000000000..b7413a649 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/account.service.ts @@ -0,0 +1,226 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, +} from "@nestjs/common"; +import { InjectDataSource, InjectRepository } from "@nestjs/typeorm"; +import { DataSource, EntityManager, Repository } from "typeorm"; +import { isValidPhoneNumber } from "libphonenumber-js"; + +import { EUserVerifiedBy } from "@tria-plc/api-common/utils/enums/user.enum"; +import type { TCurrentTokenUser } from "@tria-plc/iamapi-common/types/current-user.type"; +import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity"; +import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; +import { OtpService, OtpTarget } from "../otp/otp.service"; +import { + ContactChannel, + SendContactOtpDto, + UpdateAccountNameDto, + UpdateContactDto, +} from "./dto/account.dto"; +import { maskOtpTarget } from "./mask-target.util"; + +/** How long a contact-change code stays valid before it must be re-requested. */ +const CONTACT_OTP_TTL_MS = 10 * 60 * 1000; + +/** Postgres unique-violation SQLSTATE. */ +const PG_UNIQUE_VIOLATION = "23505"; + +/** + * Self-serve management of the caller's own IAM user record. + * + * IAM ships `PATCH /api/auth/update-profile`, but it takes email + username + + * phone + name all at once (every field `@IsNotEmpty`) and performs no + * verification — it will move an account's phone to any number the caller + * types. These routes exist so a contact change is *proven*: the code goes to + * the NEW address and the write only lands once it comes back. + */ +@Injectable() +export class AccountService { + private readonly logger = new Logger(AccountService.name); + + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectDataSource() + private readonly dataSource: DataSource, + private readonly otpService: OtpService, + ) {} + + /** + * Send a code to the address the caller wants to move TO. Sending to the new + * value (rather than the one on file) is the whole point — it proves control + * of the destination before anything is written. + */ + async sendContactOtp( + userId: string, + dto: SendContactOtpDto, + ): Promise<{ sentTo: string }> { + const value = this.normalize(dto.channel, dto.value); + await this.assertNotTaken(dto.channel, value, userId); + + const target = this.targetFor(dto.channel, value); + await this.otpService.sendOtp(target); + + return { sentTo: maskOtpTarget(target) }; + } + + /** + * Verify the code, then write the new contact value. The verify and the write + * are one call: the API never has to trust that a client "already verified" + * — unlike the signup flow, where the OTP is client-orchestrated and + * `POST /api/otp/verify` is a separate public route the client may simply skip. + */ + async updateContact( + userId: string, + dto: UpdateContactDto, + ): Promise<{ success: true; value: string }> { + const value = this.normalize(dto.channel, dto.value); + await this.assertNotTaken(dto.channel, value, userId); + + await this.otpService.verifyOtpForAction( + this.targetFor(dto.channel, value), + dto.otp, + CONTACT_OTP_TTL_MS, + ); + + const isEmail = dto.channel === ContactChannel.Email; + const userPatch = isEmail + ? { email: value } + : { + phoneNumber: value, + // The number just passed an OTP, which is exactly what IAM's own + // phone-verification flag means. Set it here so the freight app stops + // needing its own parallel "verified phone" bookkeeping. + isPhoneNumberVerified: true, + verifiedBy: EUserVerifiedBy.PHONE_NUMBER, + }; + const sessionPatch: Partial = isEmail + ? { email: value } + : { phoneNumber: value, isPhoneNumberVerified: true }; + + try { + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(User).update({ id: userId }, userPatch); + await this.refreshSessions(manager, userId, sessionPatch); + }); + } catch (error) { + throw this.asConflict(error, dto.channel); + } + + this.logger.log(`Account ${dto.channel} updated for user ${userId}`); + return { success: true, value }; + } + + /** Rename the account. No OTP — a name change proves nothing and grants nothing. */ + async updateName( + userId: string, + dto: UpdateAccountNameDto, + ): Promise<{ success: true }> { + const en = dto.name.en?.trim(); + const name = { am: dto.name.am.trim(), ...(en ? { en } : {}) }; + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(User).update({ id: userId }, { name }); + // IAM mirrors the name onto the employee row. Portal customers are + // `individual` users with no employee row at all, so this is a no-op for + // them — hence an unconditional update() rather than a lookup-then-write. + await manager.getRepository(Employee).update({ userId }, { name }); + await this.refreshSessions(manager, userId, { name }); + }); + + return { success: true }; + } + + /** + * `GET /api/auth/me` serves `session.userInfo` — a snapshot IAM writes only + * when a session is created at login. Without patching it here, a saved change + * stays invisible to /me (and to anything reading the token's claims) until the + * user logs out and back in, which reads as "my edit didn't save". + */ + private async refreshSessions( + manager: EntityManager, + userId: string, + patch: Partial, + ): Promise { + const repo = manager.getRepository(Session); + const sessions = await repo.find({ where: { userId } }); + + await Promise.all( + sessions.map((session) => + repo.update( + { id: session.id }, + { userInfo: { ...session.userInfo, ...patch } }, + ), + ), + ); + } + + /** Canonicalise for the channel and reject anything malformed up front. */ + private normalize(channel: ContactChannel, value: string): string { + const raw = value.trim(); + + if (channel === ContactChannel.Email) { + const email = raw.toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + throw new BadRequestException("A valid email address is required"); + } + return email; + } + + if (!isValidPhoneNumber(raw)) { + throw new BadRequestException( + "A valid international phone number is required (E.164, e.g. +251911223344)", + ); + } + // Store the same canonical form the OTP is keyed by, so the code sent here + // is findable on verify regardless of how the number was typed. + return normalizeE164(raw) as string; + } + + private targetFor(channel: ContactChannel, value: string): OtpTarget { + return channel === ContactChannel.Email ? { email: value } : { phone: value }; + } + + /** + * `iam.users.email` and `.phone_number` are each independently UNIQUE, so a + * collision would otherwise surface as a raw 500 at write time. This is a + * courtesy check, not the guard — it races, so {@link asConflict} still has to + * catch the violation. + */ + private async assertNotTaken( + channel: ContactChannel, + value: string, + userId: string, + ): Promise { + const existing = await this.userRepository.findOne({ + where: + channel === ContactChannel.Email + ? { email: value } + : { phoneNumber: value }, + select: { id: true }, + }); + + if (existing && existing.id !== userId) { + throw this.takenError(channel); + } + } + + private asConflict(error: unknown, channel: ContactChannel): Error { + const code = (error as { code?: string } | null)?.code; + if (code === PG_UNIQUE_VIOLATION) return this.takenError(channel); + return error as Error; + } + + private takenError(channel: ContactChannel): ConflictException { + return new ConflictException( + channel === ContactChannel.Email + ? "That email address is already registered to another account" + : "That phone number is already registered to another account", + ); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/dto/account.dto.ts b/apps/edr-freight-api/src/modules/auth/dto/account.dto.ts new file mode 100644 index 000000000..363e39072 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/dto/account.dto.ts @@ -0,0 +1,60 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsEnum, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + ValidateNested, +} from "class-validator"; + +/** The contact channel being changed on the caller's own account. */ +export enum ContactChannel { + Email = "email", + Phone = "phone", +} + +export class SendContactOtpDto { + @ApiProperty({ enum: ContactChannel }) + @IsEnum(ContactChannel) + channel!: ContactChannel; + + @ApiProperty({ + description: + "The NEW email or phone to verify. The code is sent here, not to the " + + "address currently on the account — that is what proves the caller " + + "controls the number/inbox they are moving to.", + example: "+251911223344", + }) + @IsString() + @IsNotEmpty() + value!: string; +} + +export class UpdateContactDto extends SendContactOtpDto { + @ApiProperty({ description: "The 6-digit code sent to the new value" }) + @IsString() + @IsNotEmpty() + otp!: string; +} + +export class AccountNameDto { + @ApiProperty({ description: "Amharic name", example: "አበበ በቀለ" }) + @IsString() + @IsNotEmpty() + am!: string; + + @ApiPropertyOptional({ description: "English name", example: "Abebe Bekele" }) + @IsOptional() + @IsString() + en?: string; +} + +export class UpdateAccountNameDto { + @ApiProperty({ type: AccountNameDto }) + @IsObject() + @ValidateNested() + @Type(() => AccountNameDto) + name!: AccountNameDto; +} diff --git a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts index 42dc723d5..b357c2cfb 100644 --- a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts +++ b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts @@ -11,6 +11,7 @@ import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user import { OtpService, OtpTarget } from "../otp/otp.service"; import { ResetChannel } from "./dto/forgot-password.dto"; +import { maskOtpTarget } from "./mask-target.util"; /** * How long the reset ticket minted for `PATCH /api/auth/set-password` stays @@ -158,12 +159,6 @@ export class ForgotPasswordService { /** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */ maskTarget(target: OtpTarget): string { - if (target.email) { - const [local, domain] = target.email.split("@"); - const head = local.slice(0, 1); - return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`; - } - const phone = target.phone ?? ""; - return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`; + return maskOtpTarget(target); } } diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index 6415cf4c1..1a375d86f 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -1,11 +1,15 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { Employee } from '@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity'; +import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity'; import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity'; import { ExternalProfile } from '../companies/entities/external-profile.entity'; import { OtpModule } from '../otp/otp.module'; +import { AccountController } from './account.controller'; +import { AccountService } from './account.service'; import { CheckAvailabilityController } from './check-availability.controller'; import { CheckAvailabilityService } from './check-availability.service'; import { CustomerResetController } from './customer-reset.controller'; @@ -17,17 +21,25 @@ import { FreightMeService } from './freight-me.service'; @Module({ imports: [ - TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]), + TypeOrmModule.forFeature([ + User, + UserVerification, + ExternalProfile, + Session, + Employee, + ]), OtpModule, ], controllers: [ FreightMeController, + AccountController, CheckAvailabilityController, ForgotPasswordController, CustomerResetController, ], providers: [ FreightMeService, + AccountService, CheckAvailabilityService, ForgotPasswordService, CustomerResetService, diff --git a/apps/edr-freight-api/src/modules/auth/mask-target.util.ts b/apps/edr-freight-api/src/modules/auth/mask-target.util.ts new file mode 100644 index 000000000..213a14656 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/mask-target.util.ts @@ -0,0 +1,16 @@ +import { OtpTarget } from "../otp/otp.service"; + +/** + * Mask an OTP target for echoing back to the caller: `+251911234567` -> + * `+251•••••4567`; `ab@x.com` -> `a•@x.com`. Never return an unmasked target to + * a caller who has not yet proven possession of the channel. + */ +export function maskOtpTarget(target: OtpTarget): string { + if (target.email) { + const [local, domain] = target.email.split("@"); + const head = local.slice(0, 1); + return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`; + } + const phone = target.phone ?? ""; + return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`; +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index a4546e301..1cef9528c 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -1,4 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { NotificationAudience, NotificationType, @@ -8,6 +10,7 @@ import { import { Booking } from './entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; /** * Customer + staff notifications for the booking lifecycle: review, clearance @@ -27,6 +30,8 @@ export class BookingLifecycleNotifierService { constructor( private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} private ref(b: Booking): string { @@ -40,7 +45,9 @@ export class BookingLifecycleNotifierService { logLabel: string, ): Promise { this.logger.log(`${logLabel} — ${this.ref(b)}`); - const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null; + const phone = b.companyId + ? await resolveCompanyNotifyPhone(this.dataSource, b.companyId) + : null; const email = b.company?.email ?? b.company?.generalManagerEmail ?? null; if (phone) { diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index 167526988..43d4e9905 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -1,4 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource } from "typeorm"; import { NotificationAudience, NotificationPriority, @@ -8,6 +10,7 @@ import { import { Company, CompanyStatus } from "./entities/company.entity"; import { NotificationsService } from "../notifications/notifications.service"; import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; +import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util"; /** Account statuses that lock the customer out and therefore must be told to them. */ const PUNITIVE_STATUSES: readonly CompanyStatus[] = [ @@ -28,11 +31,13 @@ export class CompanyNotifierService { constructor( private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} /** Send SMS + email to the company contact; log-only on failure. */ private async notifyContact(company: Company, message: string): Promise { - const phone = company.contactPersonPhone ?? company.phone ?? null; + const phone = await resolveCompanyNotifyPhone(this.dataSource, company.id); const email = company.email ?? company.generalManagerEmail ?? null; if (phone) { 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 575767a87..e35bd2bf5 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 @@ -1,4 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { NotificationAudience, NotificationType, @@ -8,6 +10,7 @@ import { import { Contract } from './entities/contract.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; /** * Customer + staff notifications for the contract lifecycle. Every customer @@ -24,6 +27,8 @@ export class ContractNotifierService { constructor( private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} private ref(c: Contract): string { @@ -37,7 +42,9 @@ export class ContractNotifierService { logLabel: string, ): Promise { this.logger.log(`${logLabel} — ${this.ref(c)}`); - const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null; + const phone = c.companyId + ? await resolveCompanyNotifyPhone(this.dataSource, c.companyId) + : null; const email = c.company?.email ?? c.company?.generalManagerEmail ?? null; if (phone) { 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 60db03ff1..00c43b258 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 @@ -4,6 +4,8 @@ import { Injectable, Logger, } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { randomUUID } from 'node:crypto'; import { Readable } from 'stream'; import { insertWithGeneratedReference } from '@edr/api-common'; @@ -102,8 +104,41 @@ export class ContractTransitionService { private readonly notifier: ContractNotifierService, private readonly contractTemplates: ContractTemplatesService, private readonly clearanceFeeService: ClearanceFeeService, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} + /** + * The phone the signing OTP is sent to and verified against: the signer's own + * IAM account number. + * + * H12(b): resolved server-side from the authenticated user id, never from the + * request body — a caller-supplied number would let an attacker point the code + * at their own phone. Ownership is already gated separately by + * {@link ContractsService.assertCustomerCanAccessContract}, so this binds the + * signature to the *person* signing rather than to a company landline that may + * be shared, stale, or imported from eTrade. + */ + private async resolveSignerPhone(signerUserId?: string): Promise { + if (!signerUserId) { + // Unreachable in practice (the ownership gate rejects a missing user + // first), but never fall back to another number if it ever changes. + throw new BadRequestException('Authentication required to sign'); + } + const rows: Array<{ phone_number: string | null }> = + await this.dataSource.query( + `SELECT phone_number FROM iam.users WHERE id = $1 AND is_active = true`, + [signerUserId], + ); + const phone = rows[0]?.phone_number?.trim(); + if (!phone) { + throw new BadRequestException( + 'Your account has no registered phone number. Add one in Settings → Account before signing.', + ); + } + return phone; + } + /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ async submit(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); @@ -796,10 +831,10 @@ export class ContractTransitionService { } /** - * Send the sudo-mode signing OTP to the CONTRACT COMPANY's registered phone — - * the same number {@link sign} verifies against. The client never picks the - * number (that is the H12(b) trust property): it only asks us to send, and we - * resolve the phone from the contract. Returns a masked hint so the UI can + * Send the sudo-mode signing OTP to the SIGNER's own registered phone — the + * same number {@link sign} verifies against. The client never picks the number + * (that is the H12(b) trust property): it only asks us to send, and we resolve + * the phone from the authenticated user id. Returns a masked hint so the UI can * say where the code went without exposing the full number. */ async sendSigningOtp( @@ -815,14 +850,9 @@ export class ContractTransitionService { ); assertContractStatus(contract, ['CONTRACT_READY']); - const companyPhone = contract.company?.phone?.trim(); - if (!companyPhone) { - throw new BadRequestException( - 'The contract company has no registered phone on file to send the signing OTP to', - ); - } - await this.otpService.sendOtp({ phone: companyPhone }); - return { sentTo: maskPhone(companyPhone) }; + const signerPhone = await this.resolveSignerPhone(options.signerUserId); + await this.otpService.sendOtp({ phone: signerPhone }); + return { sentTo: maskPhone(signerPhone) }; } /** Customer signs the ready contract → SIGNED_CUSTOMER. */ @@ -848,20 +878,18 @@ export class ContractTransitionService { throw new BadRequestException('Customer has already signed this contract'); } // Sudo-mode gate: a fresh, single-use OTP must be verified before the - // signature is applied. H12(b): verify against the CONTRACT COMPANY's - // registered phone — never the caller-supplied dto.otpPhone, which an - // attacker could point at their own phone to sign someone else's - // contract. The OTP is issued to the company's registered number. - const companyPhone = contract.company?.phone?.trim(); - if (!companyPhone) { - throw new BadRequestException( - 'The contract company has no registered phone on file to verify the signing OTP against', - ); - } + // signature is applied. H12(b): verify against the SIGNER's own registered + // phone, resolved server-side from the authenticated user id — never a + // caller-supplied number, which an attacker could point at their own + // phone. Ownership is already asserted above, so this proves the specific + // person holding the account is present, not merely that someone reached a + // shared company line. Must resolve identically to sendSigningOtp, or send + // and verify would target different numbers. + const signerPhone = await this.resolveSignerPhone(options.signerUserId); if (!dto.otp) { throw new BadRequestException('OTP verification is required to sign the contract'); } - await this.otpService.verifyOtpForAction({ phone: companyPhone }, dto.otp); + await this.otpService.verifyOtpForAction({ phone: signerPhone }, dto.otp); await this.applySignature(contract, dto, options); await this.contractsRepository.update(contractId, { status: 'SIGNED_CUSTOMER', diff --git a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts index f0676b629..5ddffad6c 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts @@ -28,17 +28,13 @@ export class SignContractDto { consentText?: string; // Sudo-mode OTP challenge. Required when role=CUSTOMER: a fresh 6-digit code - // SMS'd to the signer's phone, verified server-side before the signature is - // applied. `otpPhone` is the number the code was sent to (the signed-in - // customer's registered phone). + // SMS'd to the signer's registered phone, verified server-side before the + // signature is applied. The number itself is deliberately NOT part of this + // DTO — the server resolves it from the authenticated user id, so a caller + // cannot redirect the challenge to a phone they control. @ApiPropertyOptional({ description: '6-digit OTP; required when role=CUSTOMER' }) @IsOptional() @IsString() @Matches(/^\d{6}$/, { message: 'otp must be 6 digits' }) otp?: string; - - @ApiPropertyOptional({ description: 'Phone the OTP was sent to; required when role=CUSTOMER' }) - @IsOptional() - @IsString() - otpPhone?: string; } diff --git a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts index 9d7f32c3d..9f121e18a 100644 --- a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts +++ b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts @@ -1,6 +1,10 @@ import { DataSource } from 'typeorm'; import { NotificationsService } from './notifications.service'; +import { + companyNotifyPhoneExpr, + primaryContactUserJoin, +} from './resolve-company-phone.util'; /** * Best-effort SMS + email fan-out to a company's contacts. Looks up the @@ -15,9 +19,10 @@ export async function sendCompanyChannels( ): Promise { const [contact]: Array<{ phone: string | null; email: string | null }> = await dataSource.query( - `SELECT COALESCE(phone, etrade_phone) AS phone, email - FROM freight.companies - WHERE id = $1 AND deleted_at IS NULL`, + `SELECT ${companyNotifyPhoneExpr('co')} AS phone, co.email + FROM freight.companies co + ${primaryContactUserJoin('co')} + WHERE co.id = $1 AND co.deleted_at IS NULL`, [companyId], ); if (contact?.phone) { diff --git a/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts b/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts new file mode 100644 index 000000000..511f3cf8c --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts @@ -0,0 +1,60 @@ +import { DataSource, EntityManager } from "typeorm"; + +/** + * Where a customer-facing SMS actually goes. + * + * The person who signs up, logs in, and receives OTPs is an IAM user, and + * `iam.users.phone_number` is the number they control and can change themselves + * (see the account settings flow). A company's own `phone` is business contact + * data — often a landline, a shared desk, or a stale eTrade import — so it is + * the fallback, not the source. + * + * `companies.contact_person_phone` is deliberately NOT consulted: the live write + * path stores that value in the `attributes` jsonb and has never populated the + * column, so every reader of it was silently falling through to `phone` anyway. + */ + +/** + * LEFT JOIN a company alias to its primary contact's IAM user, exposing + * `pc.phone_number`. + * + * LATERAL + LIMIT 1 rather than a plain join: nothing in the schema stops a + * company having two `is_primary_contact` rows, and a plain join would then + * duplicate the company row — which in a fan-out query means sending the same + * customer the same SMS twice. + * + * `alias` is always a code-controlled literal, never caller input. + */ +export function primaryContactUserJoin(alias: string): string { + return ` + LEFT JOIN LATERAL ( + SELECT u.phone_number + FROM freight.external_profiles ep + JOIN iam.users u ON u.id = ep.user_id AND u.is_active = true + WHERE ep.company_id = ${alias}.id + AND ep.is_primary_contact = true + AND ep.deleted_at IS NULL + ORDER BY ep.created_at + LIMIT 1 + ) pc ON true`; +} + +/** SQL expression for the company's SMS number, given the joined `pc` alias. */ +export function companyNotifyPhoneExpr(alias: string): string { + return `COALESCE(pc.phone_number, ${alias}.phone)`; +} + +/** The SMS number for one company, or null when neither source has one. */ +export async function resolveCompanyNotifyPhone( + db: DataSource | EntityManager, + companyId: string, +): Promise { + const rows: Array<{ phone: string | null }> = await db.query( + `SELECT ${companyNotifyPhoneExpr("co")} AS phone + FROM freight.companies co + ${primaryContactUserJoin("co")} + WHERE co.id = $1 AND co.deleted_at IS NULL`, + [companyId], + ); + return rows[0]?.phone ?? null; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index f3d7697f6..3d505e113 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -1,4 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { NotificationAudience, NotificationPriority, @@ -9,6 +11,7 @@ import { import { Booking } from '../bookings/entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { BATCH_TIMEZONE } from './booking-batch.constants'; @@ -20,6 +23,8 @@ export class BookingNotifierService { private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, private readonly trainSchedules: TrainSchedulesRepository, + @InjectDataSource() + private readonly dataSource: DataSource, ) {} /** @@ -60,7 +65,9 @@ export class BookingNotifierService { logLabel: string, ): Promise { this.logger.log(`${logLabel} — ${this.ref(b)}`); - const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null; + const phone = b.companyId + ? await resolveCompanyNotifyPhone(this.dataSource, b.companyId) + : null; const email = b.company?.email ?? b.company?.generalManagerEmail ?? null; if (phone) { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index f728afe6a..69f64fdf4 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -13,6 +13,10 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { + companyNotifyPhoneExpr, + primaryContactUserJoin, +} from '../notifications/resolve-company-phone.util'; import { BookingBatchService } from './booking-batch.service'; import { BookingWindowGateway } from './booking-window.gateway'; import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service'; @@ -527,7 +531,7 @@ export class BookingWindowService implements OnModuleInit { }> = await this.dataSource.query( `SELECT DISTINCT c.company_id, - COALESCE(co.contact_person_phone, co.phone) AS phone, + ${companyNotifyPhoneExpr('co')} AS phone, COALESCE(co.email, co.general_manager_email) AS email FROM freight.contract_routes cr JOIN freight.contracts c @@ -535,6 +539,7 @@ export class BookingWindowService implements OnModuleInit { AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') AND c.deleted_at IS NULL JOIN freight.companies co ON co.id = c.company_id + ${primaryContactUserJoin('co')} WHERE cr.origin_yard_id = $1 AND cr.destination_yard_id = $2 AND cr.deleted_at IS NULL`, 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 6dd941d26..aa04fb447 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 @@ -13,6 +13,10 @@ import type { InterchangeDocument } from '../interchange-documents/entities/inte import { LastMileService } from '../last-mile/last-mile.service'; import { NotificationsService } from '../notifications/notifications.service'; import { sendCompanyChannels } from '../notifications/notify-company.util'; +import { + companyNotifyPhoneExpr, + primaryContactUserJoin, +} from '../notifications/resolve-company-phone.util'; import { SignaturesService } from '../signatures/signatures.service'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto'; @@ -1145,7 +1149,7 @@ export class WarehouseInventoryService { b.company_id AS "customerId", company.name AS "customer", company.tin AS "customerTin", - COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + ${companyNotifyPhoneExpr('company')} AS "customerPhone", COALESCE(bcu.unit_numbers, bc.container_numbers) AS "containerNumber", bcu.seal_numbers AS "sealNumbers", bc.container_quantity AS "containerQuantity", @@ -1183,6 +1187,7 @@ export class WarehouseInventoryService { b.customer_truck_assigned_at AS "customerTruckAssignedAt" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id + ${primaryContactUserJoin('company')} LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id @@ -1288,7 +1293,7 @@ export class WarehouseInventoryService { b.cargo_total_weight_vgm AS "weight", company.name AS "customer", company.tin AS "customerTin", - COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + ${companyNotifyPhoneExpr('company')} AS "customerPhone", bc.container_numbers AS "containerNumber", bc.container_quantity AS "containerQuantity", bc.container_packaging_type AS "containerPackagingType", @@ -1317,6 +1322,7 @@ export class WarehouseInventoryService { OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id + ${primaryContactUserJoin('company')} LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.service_types st ON st.id = b.service_type_id @@ -4765,7 +4771,7 @@ export class WarehouseInventoryService { `SELECT b.reference AS "reference", company.name AS "customer", company.tin AS "customerTin", - COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + ${companyNotifyPhoneExpr('company')} AS "customerPhone", b.cargo_total_weight_vgm AS "weight", COALESCE(cargo_type.cargo_type_name, b.cargo_free_text) AS "cargoDescription", bc.container_numbers AS "containerNumber", @@ -4782,6 +4788,7 @@ export class WarehouseInventoryService { v.vehicle_type AS "firstMileTruckType" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id + ${primaryContactUserJoin('company')} LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = b.cargo_type_id LEFT JOIN LATERAL ( SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 2508e373a..6bf03c93d 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -25,6 +25,10 @@ import { InvoiceDocumentService, } from "../billing/documents/invoice-document.service"; import { NotificationsService } from "../notifications/notifications.service"; +import { + companyNotifyPhoneExpr, + primaryContactUserJoin, +} from "../notifications/resolve-company-phone.util"; import { WarehouseFeeService } from "./warehouse-fee.service"; import { WarehouseFeeInvoiceView, @@ -879,7 +883,7 @@ export class WarehouseInvoiceService { const [row] = await this.dataSource.query( `SELECT b.reference AS "bookingReference", company.name AS "customerName", - COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + ${companyNotifyPhoneExpr('company')} AS "customerPhone", COALESCE( NULLIF(TRIM(CONCAT(COALESCE(last_driver.first_name, ''), ' ', COALESCE(last_driver.last_name, ''))), ''), last_vehicle.assigned_driver_name, @@ -892,6 +896,7 @@ export class WarehouseInvoiceService { FROM freight.warehouse_inventory inv LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id + ${primaryContactUserJoin('company')} LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL LEFT JOIN freight.booking_container booking_container ON ( booking_container.booking_id = b.id diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index f0b3ee5a6..efd6af448 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -19,6 +19,15 @@ export const URL_CONSTANTS = { CHECK_AVAILABILITY: "/api/auth/check-availability", }, + // The signed-in user's own account record. Distinct from COMPANIES_API.PROFILE, + // which is the company's business profile — these are the identity fields that + // OTPs and SMS notifications are actually delivered to. + ACCOUNT: { + CONTACT_OTP: "/api/me/contact/otp", + CONTACT: "/api/me/contact", + NAME: "/api/me/name", + }, + OTP: { SEND: "/api/otp/send", VERIFY: "/api/otp/verify", diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index 5f6cf88d8..00b9c3043 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -29,17 +29,20 @@ import { ShieldCheck, User, UserCheck, + UserCog, } from "lucide-react"; import { useCallback, useEffect } from "react"; import { useSearchParams } from "react-router-dom"; +import useAuth from "@/hooks/useAuth"; import { rolesForCompanyType } from "./settings/companyRoles"; +import TabAccount from "./settings/TabAccount"; import TabCompanyProfile from "./settings/TabCompanyProfile"; import TabContactPerson from "./settings/TabContactPerson"; import TabDocuments from "./settings/TabDocuments"; import TabGeneralManager from "./settings/TabGeneralManager"; import TabPowerOfAttorney from "./settings/TabPowerOfAttorney"; -type SettingsTab = "company" | "contact" | "gm" | "poa" | "documents"; +type SettingsTab = "account" | "company" | "contact" | "gm" | "poa" | "documents"; /** A section is "incomplete" when its required fields aren't filled in yet. */ function tabIncomplete( @@ -62,6 +65,9 @@ function tabIncomplete( !profile.generalManagerEmail || !profile.generalManagerPhone ); + case "account": + // Account fields live on the IAM user, not the company profile, and are + // always populated (signup requires them) — nothing to nag about here. case "poa": case "documents": return false; @@ -69,6 +75,7 @@ function tabIncomplete( } const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [ + { id: "account", label: "Account", icon: }, { id: "company", label: "Company", icon: }, { id: "contact", label: "Contact Person", icon: }, { id: "gm", label: "General Manager", icon: }, @@ -175,6 +182,7 @@ function ProfileHeader({ profile }: { profile: ProfileResponse }) { export default function SettingsPage() { const queryClient = useQueryClient(); + const { user } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); const tab = (searchParams.get("tab") as SettingsTab) || "company"; @@ -313,6 +321,21 @@ export default function SettingsPage() { ))} + {/* Deliberately NOT wrapped in the `locked` fieldset below: that lock + is for company-profile edits awaiting review. Account identity is + the user's own login/notification details — they must stay editable + even mid-review, or a customer whose phone changed while pending + would be locked out of their own OTPs. */} + + {user ? ( + + ) : ( +
+ +
+ )} +
+ {/* While a change request is pending, every panel's inputs + submit buttons are disabled via the native fieldset; tab switching stays enabled so the customer can still review what they submitted. */} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx index 99bbf6332..d47151774 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx @@ -56,11 +56,10 @@ export default function ContractViewPage() { const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false); const [agreedToTerms, setAgreedToTerms] = useState(false); - // The signing OTP goes to the CONTRACT COMPANY's registered phone (the number - // the server verifies against), NOT the signed-in user's — those can differ, - // and sending to the user's phone left the code filed under a number verify - // never checks. The server owns the number; we only get back a masked hint of - // where it landed. + // The signing OTP goes to the signed-in user's own registered phone, resolved + // server-side from their account (the same number the server verifies + // against). The client never picks the number, so send and verify can't + // disagree; we only get back a masked hint of where it landed. const [otpSentTo, setOtpSentTo] = useState(null); const { data, isLoading, isError, refetch } = useQuery({ diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx new file mode 100644 index 000000000..87916f38c --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx @@ -0,0 +1,374 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { CheckCircle2, Save, ShieldCheck, UserCog, XCircle } from "lucide-react"; +import { + Alert, + Button, + Card, + Group, + Modal, + PinInput, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { api } from "@/services/api"; +import { + ControlledPhoneField, + isValidPhone, + toEthiopianE164, +} from "@/components/PhoneField"; +import type { AuthUser, ContactChannel } from "@/types/auth"; + +const schema = z.object({ + phoneNumber: z + .string() + .min(1, "Phone number is required") + .refine(isValidPhone, "Enter a valid phone number"), + email: z.string().min(1, "Email is required").email("Enter a valid email"), + nameEn: z.string().min(1, "Name is required"), + nameAm: z.string().min(1, "Amharic name is required"), +}); + +type FormData = z.infer; + +/** A contact change that still needs its code entered. */ +interface PendingChange { + channel: ContactChannel; + value: string; +} + +const CHANNEL_LABEL: Record = { + phone: "phone number", + email: "email address", +}; + +const normaliseEmail = (v: string) => v.trim().toLowerCase(); + +interface TabAccountProps { + user: AuthUser; +} + +/** + * The signed-in user's own account — the phone and email that OTPs and SMS + * notifications are actually delivered to. Distinct from the company profile + * tabs, which hold business contact details for the organisation. + * + * Changing phone or email is verified: the server sends a code to the NEW value + * and only writes it once the code comes back, so a typo'd number can never + * silently take over the account's notifications. Each code is bound to a single + * channel, so changing both walks the user through one verification per channel. + */ +export default function TabAccount({ user }: TabAccountProps) { + const queryClient = useQueryClient(); + // Head of the queue is the change currently being verified. Changing phone AND + // email in one save enqueues both — a code proves one channel, never two. + const [queue, setQueue] = useState([]); + const [sentTo, setSentTo] = useState(null); + const [completed, setCompleted] = useState([]); + const [otp, setOtp] = useState(""); + + const current = queue[0] ?? null; + const step = completed.length + 1; + const totalSteps = completed.length + queue.length; + + const defaultValues = useMemo( + (): FormData => ({ + // Normalise to the same E.164 shape the phone input emits. Some accounts + // store a local `0911…`; comparing raw against the field's `+2519…` would + // read as "changed" on every save and hijack the email's turn. + phoneNumber: toEthiopianE164(user.phoneNumber), + email: user.email ?? "", + nameEn: user.name?.en ?? "", + nameAm: user.name?.am ?? "", + }), + [user], + ); + + const { + register, + control, + handleSubmit, + reset, + formState: { errors, isDirty }, + } = useForm({ + resolver: zodResolver(schema), + values: defaultValues, + // Verifying one channel refetches the user, which re-syncs `values`. Without + // keepDirtyValues that resync would silently discard an edit the user has + // typed into the *other* field but not yet verified. + resetOptions: { keepDirtyValues: true }, + }); + + const refreshUser = () => + queryClient.invalidateQueries({ queryKey: api.auth.getMyInfo.queryKey() }); + + /** Name needs no proof of possession, so it saves straight through. */ + const nameMutation = useMutation({ + mutationFn: (data: FormData) => + api.account.updateName.call({ name: { en: data.nameEn, am: data.nameAm } }), + onSuccess: refreshUser, + }); + + /** Step 1 of a contact change: ask the server to code the new value. */ + const otpMutation = useMutation({ + mutationFn: (change: PendingChange) => api.account.sendContactOtp.call(change), + onSuccess: (res) => { + setOtp(""); + setSentTo(res.sentTo); + }, + onError: () => { + // Could not even send — drop the flow rather than strand the user in a + // modal asking for a code that was never issued. + setQueue([]); + setSentTo(null); + }, + }); + + /** Step 2: hand the code back; the server verifies and writes atomically. */ + const contactMutation = useMutation({ + mutationFn: (body: PendingChange & { otp: string }) => + api.account.updateContact.call(body), + onSuccess: async (_res, body) => { + setOtp(""); + setSentTo(null); + setCompleted((prev) => [...prev, body.channel]); + await refreshUser(); + + // Advance to the next queued channel, keeping the modal open so a + // both-changed save is one continuous flow. + const rest = queue.slice(1); + setQueue(rest); + if (rest[0]) otpMutation.mutate(rest[0]); + }, + }); + + const startQueue = (changes: PendingChange[]) => { + setCompleted([]); + setQueue(changes); + otpMutation.mutate(changes[0]); + }; + + const cancelQueue = () => { + setQueue([]); + setSentTo(null); + setOtp(""); + contactMutation.reset(); + }; + + const onSubmit = (data: FormData) => { + setCompleted([]); + + const changes: PendingChange[] = []; + if (toEthiopianE164(data.phoneNumber) !== defaultValues.phoneNumber) { + changes.push({ channel: "phone", value: data.phoneNumber }); + } + if (normaliseEmail(data.email) !== normaliseEmail(defaultValues.email)) { + changes.push({ channel: "email", value: normaliseEmail(data.email) }); + } + + // Name carries no verification, so it saves alongside rather than queueing. + if ( + data.nameEn !== defaultValues.nameEn || + data.nameAm !== defaultValues.nameAm + ) { + nameMutation.mutate(data); + } + + if (changes.length) startQueue(changes); + }; + + const errorMessage = (err: unknown): string => { + const res = (err as { response?: { data?: { message?: string | string[] } } }) + ?.response?.data?.message; + if (Array.isArray(res)) return res[0]; + return res ?? "Something went wrong. Please try again."; + }; + + const busy = + otpMutation.isPending || contactMutation.isPending || nameMutation.isPending; + + const savedSummary = + completed.length && !queue.length + ? `Your ${completed.map((c) => CHANNEL_LABEL[c]).join(" and ")} ${ + completed.length > 1 ? "were" : "was" + } verified and updated` + : null; + + return ( + + + + Account + + + Your login details. Verification codes and SMS notifications are sent to + the phone number below. + + +
+ + + + + + + + + + + + Changing your phone number or email requires a verification code sent + to the new one. + + + + + {savedSummary && ( + + + {savedSummary} + + )} + {nameMutation.isSuccess && !savedSummary && !queue.length && ( + + + Saved successfully + + )} + {(otpMutation.isError || nameMutation.isError) && ( + + + + {errorMessage(otpMutation.error ?? nameMutation.error)} + + + )} + + + + + + +
+ + + {current && ( + + {totalSteps > 1 && ( + + Step {step} of {totalSteps} + + )} + + } color="blue"> + {sentTo ? ( + <> + We sent a 6-digit code to {sentTo}. Enter it to confirm + your new {CHANNEL_LABEL[current.channel]}. + + ) : ( + <>Sending a code to your new {CHANNEL_LABEL[current.channel]}… + )} + + + {completed.length > 0 && queue.length > 0 && ( + + + + {CHANNEL_LABEL[completed[completed.length - 1]]} updated — one + more to confirm. + + + )} + + + + {contactMutation.isError && ( + + + {errorMessage(contactMutation.error)} + + )} + + + + + + + )} + +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index a82945ecf..c5d632dbd 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -81,6 +81,11 @@ import type { ForgotPasswordRequestPayload, ForgotPasswordVerifyPayload, ResetTicket, + SendContactOtpPayload, + SendContactOtpResponse, + UpdateAccountNamePayload, + UpdateContactPayload, + UpdateContactResponse, } from "@/types/auth"; // --------------------------------------------------------------------------- @@ -147,6 +152,26 @@ export const api = { logout: endpoint("auth", "logout", authService.logout), }, + // The signed-in user's own IAM account — the phone/email that OTPs and SMS + // actually go to. Separate from `companies`, which is business profile data. + account: { + sendContactOtp: endpoint( + "account", + "sendContactOtp", + authService.sendContactOtp, + ), + updateContact: endpoint( + "account", + "updateContact", + authService.updateContact, + ), + updateName: endpoint( + "account", + "updateName", + authService.updateAccountName, + ), + }, + companies: { getInfo: endpoint( "companies", diff --git a/apps/edr-freight-web/portal/src/services/auth.service.ts b/apps/edr-freight-web/portal/src/services/auth.service.ts index 3b113878d..032ce0277 100644 --- a/apps/edr-freight-web/portal/src/services/auth.service.ts +++ b/apps/edr-freight-web/portal/src/services/auth.service.ts @@ -11,11 +11,17 @@ import type { OtpPayload, OtpResponse, ResetTicket, + SendContactOtpPayload, + SendContactOtpResponse, SetPasswordPayload, SignupPayload, SignupResponse, + UpdateAccountNamePayload, + UpdateContactPayload, + UpdateContactResponse, } from "@/types/auth"; import { client } from "@/utils/api"; +import { unwrap } from "@/utils/endpoint"; import { ApiResponse } from "@edr/types"; export const authService = { @@ -105,6 +111,35 @@ export const authService = { return res.data.data; }, + // The three calls below manage the signed-in user's own account record + // (`/api/me`), which is what OTPs and SMS notifications are delivered to. + // Changing phone/email is OTP-gated server-side: the code goes to the NEW + // value, and the write only lands once it is verified. + + sendContactOtp: async (body: SendContactOtpPayload) => { + const res = await client.post>( + URL_CONSTANTS.ACCOUNT.CONTACT_OTP, + body, + ); + return unwrap(res.data); + }, + + updateContact: async (body: UpdateContactPayload) => { + const res = await client.patch>( + URL_CONSTANTS.ACCOUNT.CONTACT, + body, + ); + return unwrap(res.data); + }, + + updateAccountName: async (body: UpdateAccountNamePayload) => { + const res = await client.patch>( + URL_CONSTANTS.ACCOUNT.NAME, + body, + ); + return unwrap(res.data); + }, + refreshToken: async () => { const refreshTokenCookie = document.cookie .split("; ") diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 910ba89a0..eb69fa0bf 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -128,8 +128,6 @@ export interface SignContractPayload { consentText?: string; /** Sudo-mode OTP challenge; required when role=CUSTOMER. */ otp?: string; - /** Phone the OTP was sent to; required when role=CUSTOMER. */ - otpPhone?: string; } export interface ApproveDeliveryResponse { diff --git a/apps/edr-freight-web/portal/src/services/contracts.service.ts b/apps/edr-freight-web/portal/src/services/contracts.service.ts index b80c3ce36..606fad48a 100644 --- a/apps/edr-freight-web/portal/src/services/contracts.service.ts +++ b/apps/edr-freight-web/portal/src/services/contracts.service.ts @@ -268,9 +268,10 @@ export const contractsService = { return data.data ?? data; }, - // Ask the server to send the signing OTP to the CONTRACT COMPANY's registered - // phone. The client never picks the number (the server verifies against the - // same one), so send and verify can't disagree. Returns a masked hint. + // Ask the server to send the signing OTP to the signer's own registered phone. + // The client never picks the number (the server resolves it from the + // authenticated user and verifies against the same one), so send and verify + // can't disagree. Returns a masked hint. sendSigningOtp: async (id: string): Promise<{ sentTo: string }> => { const { data } = await client.post(C.CONTRACT_SEND_SIGNING_OTP(id)); return data.data ?? data; diff --git a/apps/edr-freight-web/portal/src/types/auth.ts b/apps/edr-freight-web/portal/src/types/auth.ts index 2e9a0b611..54df04f0a 100644 --- a/apps/edr-freight-web/portal/src/types/auth.ts +++ b/apps/edr-freight-web/portal/src/types/auth.ts @@ -45,6 +45,34 @@ export interface OtpResponse { message: string; } +/** The contact channel being changed on the signed-in user's own account. */ +export type ContactChannel = "email" | "phone"; + +export interface SendContactOtpPayload { + channel: ContactChannel; + /** The NEW value being moved to — the code is sent here, not to the old one. */ + value: string; +} + +export interface SendContactOtpResponse { + /** Masked hint of where the code landed, e.g. `+251•••••4567`. */ + sentTo: string; +} + +export interface UpdateContactPayload extends SendContactOtpPayload { + otp: string; +} + +export interface UpdateContactResponse { + success: true; + /** The canonical stored value (E.164 for phone, lowercased for email). */ + value: string; +} + +export interface UpdateAccountNamePayload { + name: { am: string; en?: string }; +} + export interface CheckAvailabilityPayload { email?: string; phone?: string; From e2f42136a859864ba81620cbf1eff44413991550 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 16 Jul 2026 12:37:52 +0000 Subject: [PATCH 02/88] fix: add password form to account --- .../portal/src/constants/URLS.ts | 1 + .../src/pages/settings/ChangePasswordCard.tsx | 165 ++++++++ .../portal/src/pages/settings/TabAccount.tsx | 351 ++++++++++-------- .../portal/src/services/api.ts | 6 + .../portal/src/services/auth.service.ts | 10 + apps/edr-freight-web/portal/src/types/auth.ts | 11 + 6 files changed, 381 insertions(+), 163 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/settings/ChangePasswordCard.tsx diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index efd6af448..c920e20d8 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -5,6 +5,7 @@ export const URL_CONSTANTS = { REFRESH_TOKEN: "/api/auth/refresh-token", LOGOUT: "/api/auth/logout", PROFILE: "/auth/profile", + CHANGE_PASSWORD: "/api/auth/change-password", FORGOT_PASSWORD_REQUEST: "/api/auth/forgot-password/request", FORGOT_PASSWORD_VERIFY: "/api/auth/forgot-password/verify", }, diff --git a/apps/edr-freight-web/portal/src/pages/settings/ChangePasswordCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/ChangePasswordCard.tsx new file mode 100644 index 000000000..eebfd50c1 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/ChangePasswordCard.tsx @@ -0,0 +1,165 @@ +import { useMutation } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { CheckCircle2, KeyRound, Save, XCircle } from "lucide-react"; +import { + Button, + Card, + Group, + PasswordInput, + Stack, + Text, + Title, +} from "@mantine/core"; +import { api } from "@/services/api"; + +/** + * Mirrors IAM's own `IsStrongPassword` rule on ChangePasswordDto — minLength 8, + * ≥1 lowercase, ≥1 number, ≥1 symbol, uppercase NOT required. Kept in step with + * the server so the user gets a precise message inline instead of a generic 400. + */ +const strongPassword = z + .string() + .min(8, "At least 8 characters") + .regex(/[a-z]/, "Include a lowercase letter") + .regex(/\d/, "Include a number") + .regex(/[^A-Za-z0-9]/, "Include a symbol"); + +const schema = z + .object({ + oldPassword: z.string().min(1, "Current password is required"), + newPassword: strongPassword, + confirmPassword: z.string().min(1, "Confirm your new password"), + }) + .refine((d) => d.newPassword === d.confirmPassword, { + path: ["confirmPassword"], + message: "Passwords do not match", + }) + .refine((d) => d.newPassword !== d.oldPassword, { + path: ["newPassword"], + message: "New password must be different from your current one", + }); + +type FormData = z.infer; + +/** IAM returns bare error codes; turn them into something a customer can act on. */ +const MESSAGES: Record = { + unable_to_change_password: "Your current password is incorrect.", + new_password_same_as_old: + "New password must be different from your current one.", + new_passwords_do_not_match: "The new passwords do not match.", + user_credentials_not_found: "This account has no password set.", +}; + +/** + * Password changes go straight to IAM's `PATCH /api/auth/change-password`, which + * verifies the old password and owns the credential write (argon hashing, + * retiring the previous credential). The freight app deliberately implements no + * part of that — it only collects the fields. + */ +export default function ChangePasswordCard() { + const { + register, + handleSubmit, + reset, + formState: { errors, isDirty }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { oldPassword: "", newPassword: "", confirmPassword: "" }, + }); + + const mutation = useMutation({ + mutationFn: (data: FormData) => api.auth.changePassword.call(data), + // Never leave the old password sitting in component state after a change. + onSuccess: () => reset(), + }); + + const errorMessage = (err: unknown): string => { + const raw = ( + err as { response?: { data?: { message?: string | string[] } } } + )?.response?.data?.message; + const code = Array.isArray(raw) ? raw[0] : raw; + if (!code) return "Could not change your password. Please try again."; + return MESSAGES[code] ?? code; + }; + + return ( + + + + Password + + + Change the password you use to sign in. You'll need your current one. + + +
mutation.mutate(d))}> + + + + + + + + + {mutation.isSuccess && ( + + + + Password changed + + + )} + {mutation.isError && ( + + + + {errorMessage(mutation.error)} + + + )} + + + + + + +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx index 87916f38c..68d41727c 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx @@ -3,7 +3,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { CheckCircle2, Save, ShieldCheck, UserCog, XCircle } from "lucide-react"; +import { + CheckCircle2, + Save, + ShieldCheck, + UserCog, + XCircle, +} from "lucide-react"; import { Alert, Button, @@ -23,6 +29,7 @@ import { toEthiopianE164, } from "@/components/PhoneField"; import type { AuthUser, ContactChannel } from "@/types/auth"; +import ChangePasswordCard from "./ChangePasswordCard"; const schema = z.object({ phoneNumber: z @@ -110,13 +117,16 @@ export default function TabAccount({ user }: TabAccountProps) { /** Name needs no proof of possession, so it saves straight through. */ const nameMutation = useMutation({ mutationFn: (data: FormData) => - api.account.updateName.call({ name: { en: data.nameEn, am: data.nameAm } }), + api.account.updateName.call({ + name: { en: data.nameEn, am: data.nameAm }, + }), onSuccess: refreshUser, }); /** Step 1 of a contact change: ask the server to code the new value. */ const otpMutation = useMutation({ - mutationFn: (change: PendingChange) => api.account.sendContactOtp.call(change), + mutationFn: (change: PendingChange) => + api.account.sendContactOtp.call(change), onSuccess: (res) => { setOtp(""); setSentTo(res.sentTo); @@ -183,14 +193,17 @@ export default function TabAccount({ user }: TabAccountProps) { }; const errorMessage = (err: unknown): string => { - const res = (err as { response?: { data?: { message?: string | string[] } } }) - ?.response?.data?.message; + const res = ( + err as { response?: { data?: { message?: string | string[] } } } + )?.response?.data?.message; if (Array.isArray(res)) return res[0]; return res ?? "Something went wrong. Please try again."; }; const busy = - otpMutation.isPending || contactMutation.isPending || nameMutation.isPending; + otpMutation.isPending || + contactMutation.isPending || + nameMutation.isPending; const savedSummary = completed.length && !queue.length @@ -200,175 +213,187 @@ export default function TabAccount({ user }: TabAccountProps) { : null; return ( - - - - Account - - - Your login details. Verification codes and SMS notifications are sent to - the phone number below. - - -
- - - - - - - - - - - - Changing your phone number or email requires a verification code sent - to the new one. + + + + + Account + + + Your login details. Verification codes and SMS notifications are sent + to the phone number below. - - - {savedSummary && ( - - - {savedSummary} - - )} - {nameMutation.isSuccess && !savedSummary && !queue.length && ( - - - Saved successfully - - )} - {(otpMutation.isError || nameMutation.isError) && ( - - - - {errorMessage(otpMutation.error ?? nameMutation.error)} - - - )} - - - - - - - - - - {current && ( +
- {totalSteps > 1 && ( - - Step {step} of {totalSteps} - - )} - - } color="blue"> - {sentTo ? ( - <> - We sent a 6-digit code to {sentTo}. Enter it to confirm - your new {CHANNEL_LABEL[current.channel]}. - - ) : ( - <>Sending a code to your new {CHANNEL_LABEL[current.channel]}… - )} - - - {completed.length > 0 && queue.length > 0 && ( - - - - {CHANNEL_LABEL[completed[completed.length - 1]]} updated — one - more to confirm. - - - )} - - - {contactMutation.isError && ( - - - {errorMessage(contactMutation.error)} - - )} + - + + + + + + + Changing your phone number or email requires a verification code + sent to the new one. + + + + + {savedSummary && ( + + + + {savedSummary} + + + )} + {nameMutation.isSuccess && !savedSummary && !queue.length && ( + + + + Saved successfully + + + )} + {(otpMutation.isError || nameMutation.isError) && ( + + + + {errorMessage(otpMutation.error ?? nameMutation.error)} + + + )} + + - - )} - - + +
+ + + {current && ( + + {totalSteps > 1 && ( + + Step {step} of {totalSteps} + + )} + + } color="blue"> + {sentTo ? ( + <> + We sent a 6-digit code to {sentTo}. Enter it to + confirm your new {CHANNEL_LABEL[current.channel]}. + + ) : ( + <> + Sending a code to your new {CHANNEL_LABEL[current.channel]}… + + )} + + + {completed.length > 0 && queue.length > 0 && ( + + + + {CHANNEL_LABEL[completed[completed.length - 1]]} updated — + one more to confirm. + + + )} + + + + {contactMutation.isError && ( + + + {errorMessage(contactMutation.error)} + + )} + + + + + + + )} + +
+ + +
); } diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index c5d632dbd..a984a49ae 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -68,6 +68,7 @@ import type { import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { AuthUser, + ChangePasswordPayload, CheckAvailabilityPayload, CheckAvailabilityResponse, GenerateVerificationCodePayload, @@ -149,6 +150,11 @@ export const api = { "verifyOTP", authService.verifyOTP, ), + changePassword: endpoint( + "auth", + "changePassword", + authService.changePassword, + ), logout: endpoint("auth", "logout", authService.logout), }, diff --git a/apps/edr-freight-web/portal/src/services/auth.service.ts b/apps/edr-freight-web/portal/src/services/auth.service.ts index 032ce0277..f72bbfaf9 100644 --- a/apps/edr-freight-web/portal/src/services/auth.service.ts +++ b/apps/edr-freight-web/portal/src/services/auth.service.ts @@ -1,6 +1,7 @@ import { URL_CONSTANTS } from "@/constants/URLS"; import type { AuthUser, + ChangePasswordPayload, CheckAvailabilityPayload, CheckAvailabilityResponse, ForgotPasswordRequestPayload, @@ -111,6 +112,15 @@ export const authService = { return res.data.data; }, + /** + * Change the signed-in user's password via IAM's own route: it verifies the + * old password with argon and owns the credential write (deactivating the + * previous one), so this app never touches password material. + */ + changePassword: async (body: ChangePasswordPayload) => { + await client.patch(URL_CONSTANTS.AUTH.CHANGE_PASSWORD, body); + }, + // The three calls below manage the signed-in user's own account record // (`/api/me`), which is what OTPs and SMS notifications are delivered to. // Changing phone/email is OTP-gated server-side: the code goes to the NEW diff --git a/apps/edr-freight-web/portal/src/types/auth.ts b/apps/edr-freight-web/portal/src/types/auth.ts index 54df04f0a..0dadac608 100644 --- a/apps/edr-freight-web/portal/src/types/auth.ts +++ b/apps/edr-freight-web/portal/src/types/auth.ts @@ -45,6 +45,17 @@ export interface OtpResponse { message: string; } +/** + * Change the signed-in user's password. The old password is the proof of + * possession — IAM verifies it server-side and owns the credential write, so the + * freight app never hashes or stores a password itself. + */ +export interface ChangePasswordPayload { + oldPassword: string; + newPassword: string; + confirmPassword: string; +} + /** The contact channel being changed on the signed-in user's own account. */ export type ContactChannel = "email" | "phone"; From 17c012316117362e8cba9bc5f5c036b27af84b33 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Thu, 16 Jul 2026 21:10:02 +0300 Subject: [PATCH 03/88] Add unique constraint to JourneySegment for seat bookings per schedule --- .../migration.sql | 18 ++++++++++++++++++ apps/edr-passenger-api/prisma/schema.prisma | 4 ++++ 2 files changed, 22 insertions(+) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql diff --git a/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql new file mode 100644 index 000000000..2608b4cbf --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql @@ -0,0 +1,18 @@ +-- Remove duplicate JourneySegment rows, keeping the one with the lowest id +-- (earliest created) per (scheduleId, seatId, departureStationId) group. +-- This cleans up any existing double-bookings before the unique index is applied. +DELETE FROM passenger."JourneySegment" +WHERE id NOT IN ( + SELECT MIN(id) + FROM passenger."JourneySegment" + WHERE "seatId" IS NOT NULL + GROUP BY "scheduleId", "seatId", "departureStationId" +) +AND "seatId" IS NOT NULL; + +-- Prevents two confirmed bookings from occupying the same seat on the same +-- schedule hop — the hard DB backstop against application-level race conditions. +-- Partial index: seatId IS NOT NULL excludes free-child rows that have no seat. +CREATE UNIQUE INDEX "JourneySegment_scheduleId_seatId_departureStationId_key" +ON passenger."JourneySegment" ("scheduleId", "seatId", "departureStationId") +WHERE "seatId" IS NOT NULL; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index d2dc44751..ccc68915d 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -991,6 +991,10 @@ model JourneySegment { arrivalStationId String journey Journey @relation(fields: [journeyId], references: [id]) schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) + + // Prevents two confirmed bookings from occupying the same seat on the same + // schedule hop — the hard DB backstop against application-level race conditions. + @@unique([scheduleId, seatId, departureStationId]) @@schema("passenger") } From 718c384a06944aa9b655b724b0cf043af4c0c284 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 16 Jul 2026 21:15:10 +0300 Subject: [PATCH 04/88] Update tickets.controller.ts --- .../src/modules/tickets/tickets.controller.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index 51d3b0630..b45b93a33 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, Se import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger'; import { TicketsService } from './tickets.service'; import { JwtGuard } from '../../common/jwt.guard'; -import { PassengerStaff } from '../../common/passenger-guards'; +import { PassengerStaff, PassengerAdmin } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Tickets') @@ -207,9 +207,9 @@ export class TicketsController { } @Delete(':id') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Delete ticket (admin only)', description: 'Permanently deletes a ticket record and removes associated seat blocks' }) From 35c53e3f36ea47beb70d58410e56e7adf96bd841 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 16 Jul 2026 20:03:35 +0000 Subject: [PATCH 05/88] changes --- .../src/modules/billing/billing.service.ts | 28 ++++----- .../bookings/booking-invoice.service.ts | 15 ++++- .../contracts/clearance-fee.service.ts | 15 +++++ .../contracts/contract-transition.service.ts | 8 +++ .../src/modules/intents/intents.service.ts | 63 +++++++++++++++---- 5 files changed, 99 insertions(+), 30 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 0f60876d4..774234000 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1016,7 +1016,7 @@ export class BillingService { // in the domain via `${source}.invoice.paid`. Neither billing nor the payment // service branches on a domain-specific reference type. referenceType: PaymentReferenceType.SHIPMENT, - orderRef: invoice.invoiceNumber.replace("-", "_"), + orderRef: invoice.invoiceNumber.replace(/-/g, "_"), amountMinor: Math.round(Number(invoice.balanceAmount)), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, @@ -1032,20 +1032,18 @@ export class BillingService { .getRepository(Invoice) .update({ id: invoice.id }, { paymentId: result.intentId }); - // DEMO: manually fire the gateway `payment.succeeded` callback here, without - // waiting for real gateway settlement. Runs AFTER the paymentId link above so - // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO: - // remove — real settlement flips this via the `${source}.invoice.paid` handler. - if (!result.immediateSuccess) { - await this.payment.handlePaymentEvent({ - eventType: "payment.succeeded", - eventId: `demo-${result.intentId}`, - referenceId: invoice.sourceId, - intentId: result.intentId, - providerTxnId: result.providerTxnId, - paidAt: (result.paidAt ?? new Date()).toISOString(), - }); - } + // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); + // billing must not simulate it. Kept commented for local demos only. + // if (!result.immediateSuccess) { + // await this.payment.handlePaymentEvent({ + // eventType: "payment.succeeded", + // eventId: `demo-${result.intentId}`, + // referenceId: invoice.sourceId, + // intentId: result.intentId, + // providerTxnId: result.providerTxnId, + // paidAt: (result.paidAt ?? new Date()).toISOString(), + // }); + // } if (result.immediateSuccess) { await this.settleByPaymentId( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index b0a3ad766..3d8222d63 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -16,6 +16,7 @@ import { InvoiceLineInput, } from "../billing/billing.service"; import { Invoice } from "../billing/entities/invoice.entity"; +import { CLEARANCE_BOOKING_INVOICE_TYPE } from "../contracts/clearance-fee.service"; import { FirstMileService } from "../first-mile/first-mile.service"; import { BookingBatchService } from "../train-scheduling/booking-batch.service"; import { PriceLineItemDto } from "./dto/generate-price-response.dto"; @@ -120,17 +121,27 @@ export class BookingInvoiceService { } /** - * Expire the booking's currently-open prepaid invoice when the booking is + * Expire the booking's currently-open invoices (freight PREPAID and the + * per-shipment clearance fee) when the booking is * cancelled or rejected — the counterpart to the pay-window-expiry path * (which also calls {@link BillingService.expirePayable}). Stops a terminated * booking from leaving a payable invoice open. No-op when the booking has no * open invoice (never invoiced, already paid/cancelled/expired). Pass a * caller `manager` to enlist in its transaction. */ - expireOpenInvoices( + async expireOpenInvoices( bookingId: string, manager?: EntityManager, ): Promise { + // The per-shipment clearance fee (GENERAL contracts) bills this same booking + // id under its own source/type — retire it alongside the freight invoice, or + // a cancelled shipment keeps a payable clearance invoice open. + await this.billing.expirePayable( + Freight.InvoiceSource.Clearance, + bookingId, + CLEARANCE_BOOKING_INVOICE_TYPE, + manager, + ); return this.billing.expirePayable( Freight.InvoiceSource.Booking, bookingId, diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts index 43ab9d22a..8de7aed87 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts @@ -161,6 +161,21 @@ export class ClearanceFeeService { return invoice; } + /** + * Retire (idempotently) the unpaid contract-level fee invoice when the + * contract reaches a terminal state — a dead contract must not leave a + * payable clearance invoice open for the customer to settle. No-op when the + * fee was already paid or never invoiced (mirrors the booking cancel path, + * {@link BillingService.expirePayable}). + */ + async expireForContract(contractId: string): Promise { + return this.billing.expirePayable( + Freight.InvoiceSource.Clearance, + contractId, + CLEARANCE_CONTRACT_INVOICE_TYPE, + ); + } + /** * Settlement branch point for `clearance`-source invoices: unlock the * document-upload step the fee was gating. Idempotent — a replayed event on 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 60db03ff1..4eede9352 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 @@ -419,6 +419,10 @@ export class ContractTransitionService { actorId, 'STAFF', ); + // Stop the open-invoice leak: a rejected contract must not leave a payable + // clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable). + await this.clearanceFeeService.expireForContract(contractId); + await this.contractsRepository.update(contractId, { status: 'REJECTED', } as never); @@ -457,6 +461,10 @@ export class ContractTransitionService { 'STAFF', ); + // Stop the open-invoice leak: a rejected contract must not leave a payable + // clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable). + await this.clearanceFeeService.expireForContract(contractId); + await this.contractsRepository.update(contractId, { status: 'REJECTED', } as never); 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 a14469dd1..c601395e8 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -96,9 +96,20 @@ export class IntentsService { `intent ${existing.id} retired (METHOD_CHANGED ${existing.provider} → ${request.provider}) for ` + `${request.service}/${request.referenceType}/${request.referenceId}`, ); + } else if ( + existing.status === ProviderPaymentStatus.REQUIRES_ACTION + ) { + // Same provider, payer re-initiated while a session is open (back button, + // abandoned checkout). Provider sessions are single-use, so re-serving the + // old clientAction hands the payer a dead checkout. Verify at the provider, + // then supersede: paid/processing intents are adopted, unpaid ones retired + // so a fresh session opens below. + const settled = await this.verifyThenSupersede(existing); + if (settled) return this.toSnapshot(settled); } else { - const reusable = await this.reuseOrRetire(existing); - if (reusable) return this.toSnapshot(reusable); + // PROCESSING (money in flight) or SUCCEEDED (already paid): never reopen — + // return the existing intent so the caller adopts its outcome. + return this.toSnapshot(existing); } } @@ -261,24 +272,50 @@ export class IntentsService { } /** - * Decide whether an existing active intent can be returned as-is. An expired - * REQUIRES_ACTION intent is retired (CANCELLED, no notification — nothing was paid) - * so a fresh provider session can be opened. + * Re-initiate guard for an open REQUIRES_ACTION intent on the same provider. + * Queries the provider first — the payer may have paid on the old session with + * the webhook still in flight. Paid/processing answers are applied through the + * state machine and the intent is returned for reuse. Anything still unpaid is + * 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 provider session. When the status query itself errors, + * the existing intent is reused unchanged: superseding blind could leave two + * live sessions and a double charge. */ - private async reuseOrRetire( + private async verifyThenSupersede( intent: PaymentIntent, ): Promise { - const expired = - intent.status === ProviderPaymentStatus.REQUIRES_ACTION && - intent.expiresAt != null && - intent.expiresAt.getTime() < Date.now(); - if (!expired) return intent; + let status: ProviderStatus; + try { + status = await this.queryProviderStatus(intent); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `verify-before-supersede: queryStatus failed for intent ${intent.id}: ${message}; reusing existing session`, + ); + return intent; + } + if ( + status.status === ProviderPaymentStatus.SUCCEEDED || + status.status === ProviderPaymentStatus.PROCESSING + ) { + await this.applyProviderResult(intent.id, this.fromProviderStatus(status)); + return (await this.intentsRepository.findById(intent.id)) ?? intent; + } + + const expired = + intent.expiresAt != null && intent.expiresAt.getTime() < Date.now(); await this.intentsRepository.update(intent.id, { status: ProviderPaymentStatus.CANCELLED, - failureCode: "EXPIRED", - failureMessage: "Provider session expired before the payer acted", + failureCode: expired ? "EXPIRED" : "SUPERSEDED", + failureMessage: expired + ? "Provider session expired before the payer acted" + : "Payer re-initiated; previous provider session superseded", }); + this.logger.log( + `intent ${intent.id} retired (${expired ? "EXPIRED" : "SUPERSEDED"}) — fresh session will be opened`, + ); return null; } From 71507b27fd73dec2959a58a5922006e544035ba1 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 16 Jul 2026 20:09:59 +0000 Subject: [PATCH 06/88] changes --- .../src/modules/billing/billing.service.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 774234000..2bdc6ee16 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1034,16 +1034,16 @@ export class BillingService { // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); // billing must not simulate it. Kept commented for local demos only. - // if (!result.immediateSuccess) { - // await this.payment.handlePaymentEvent({ - // eventType: "payment.succeeded", - // eventId: `demo-${result.intentId}`, - // referenceId: invoice.sourceId, - // intentId: result.intentId, - // providerTxnId: result.providerTxnId, - // paidAt: (result.paidAt ?? new Date()).toISOString(), - // }); - // } + if (!result.immediateSuccess) { + await this.payment.handlePaymentEvent({ + eventType: "payment.succeeded", + eventId: `demo-${result.intentId}`, + referenceId: invoice.sourceId, + intentId: result.intentId, + providerTxnId: result.providerTxnId, + paidAt: (result.paidAt ?? new Date()).toISOString(), + }); + } if (result.immediateSuccess) { await this.settleByPaymentId( From 969c524afea124dcf7a0256fb9d8e9819525afa2 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Thu, 16 Jul 2026 23:22:20 +0300 Subject: [PATCH 07/88] Added cron job that check duplicate seat and assign if free available --- .../modules/payments/payment-sync.service.ts | 109 ++++ .../src/modules/payments/payments.module.ts | 2 + .../src/modules/tasks/tasks.module.ts | 3 +- .../src/modules/tasks/tasks.service.ts | 475 +++++++++++++++--- 4 files changed, 523 insertions(+), 66 deletions(-) create mode 100644 apps/edr-passenger-api/src/modules/payments/payment-sync.service.ts diff --git a/apps/edr-passenger-api/src/modules/payments/payment-sync.service.ts b/apps/edr-passenger-api/src/modules/payments/payment-sync.service.ts new file mode 100644 index 000000000..7be85714a --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/payment-sync.service.ts @@ -0,0 +1,109 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { ModuleRef } from '@nestjs/core'; +import { PrismaService } from '../../common/prisma.service'; +import { PaymentClientService } from './payment-client.service'; +import { PaymentsService } from './payments.service'; +import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types'; + +// PaymentsService is request-scoped (AuditService.@Inject(REQUEST) bubbles up). +// This service keeps only singleton deps so its @Cron method registers correctly, +// then resolves PaymentsService per-tick via ModuleRef (same pattern as +// PaymentEventsConsumer). +@Injectable() +export class PaymentSyncService { + private readonly logger = new Logger(PaymentSyncService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly paymentClient: PaymentClientService, + private readonly moduleRef: ModuleRef, + ) {} + + // ───────────────────────────────────────────────────────────────────────── + // Every 1 min: poll the payment service for any PENDING_PAYMENT bookings + // whose payment intent has moved to SUCCEEDED on the gateway but whose + // confirmation event was never delivered (missed RabbitMQ message, network + // blip, etc.). finalizePaymentSuccess() is fully idempotent so re-running + // it for an already-confirmed booking is safe. + // + // Processes at most 50 bookings per cycle to avoid hammering the payment + // service; the next tick picks up the remainder. + // ───────────────────────────────────────────────────────────────────────── + @Cron('*/1 * * * *') + async syncPaymentStatuses() { + const BATCH_SIZE = 50; + + const bookings = await this.prisma.booking.findMany({ + where: { + status: 'PENDING_PAYMENT', + paymentIntent: { status: { in: ['REQUIRES_ACTION', 'PROCESSING'] } }, + }, + include: { paymentIntent: true }, + take: BATCH_SIZE, + orderBy: { createdAt: 'asc' }, + }); + + if (bookings.length === 0) return; + + let confirmed = 0; + let failed = 0; + let errored = 0; + + // resolve() (not get()) because PaymentsService is scoped — same pattern + // as PaymentEventsConsumer. + const paymentsService = await this.moduleRef.resolve( + PaymentsService, + undefined, + { strict: false }, + ); + + for (const booking of bookings) { + if (!booking.paymentIntent) continue; + + try { + const snapshot = await this.paymentClient.getIntentByReference( + PaymentReferenceType.BOOKING, + booking.id, + ); + + if (!snapshot) continue; + + if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { + const result = await paymentsService.finalizePaymentSuccess({ + intentId: booking.paymentIntent.id, + providerTxnId: snapshot.providerTxnId, + paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, + }); + if (!result.alreadyFinalized) { + this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`); + confirmed++; + } + } else if ( + snapshot.status === ProviderPaymentStatus.FAILED || + snapshot.status === ProviderPaymentStatus.CANCELLED + ) { + this.logger.warn( + `Payment sync: ${booking.bookingRef} intent is ${snapshot.status} — ` + + `booking will be auto-cancelled at payment deadline`, + ); + failed++; + } + // REQUIRES_ACTION / PROCESSING → still pending, retry next cycle + } catch (err) { + this.logger.error( + `Payment sync error for ${booking.bookingRef}: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + errored++; + } + } + + if (confirmed > 0 || failed > 0 || errored > 0) { + this.logger.log( + `Payment sync run: ${bookings.length} checked, ` + + `${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`, + ); + } + } +} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 2a3906398..07f452e43 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -16,6 +16,7 @@ import { SupplementaryChargesService } from "./supplementary-charges.service"; import { InternalPaymentsController } from "./internal-payments.controller"; import { PaymentClientService } from "./payment-client.service"; import { PaymentEventsConsumer } from "./payment-events.consumer"; +import { PaymentSyncService } from "./payment-sync.service"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { SeatsModule } from "../seats/seats.module"; import { TicketsModule } from "../tickets/tickets.module"; @@ -70,6 +71,7 @@ function rabbitMQImport(): DynamicModule[] { SupplementaryChargesService, PaymentClientService, PaymentEventsConsumer, + PaymentSyncService, ServiceAuthGuard, ], exports: [PaymentClientService, PaymentsService], diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts index 335d11f81..fb8a29a6e 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts @@ -2,11 +2,10 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../../common/prisma.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { CurrencyModule } from '../currency/currency.module'; -import { PaymentsModule } from '../payments/payments.module'; import { TasksService } from './tasks.service'; @Module({ - imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule], + imports: [PrismaModule, NotificationsModule, CurrencyModule], providers: [TasksService], }) export class TasksModule {} diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index 7a9208cae..ab275ee09 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -1,11 +1,9 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; +import { SeatStatus } from '@prisma/client'; import { PrismaService } from '../../common/prisma.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { CurrencyService } from '../currency/currency.service'; -import { PaymentsService } from '../payments/payments.service'; -import { PaymentClientService } from '../payments/payment-client.service'; -import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types'; import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; // Retention windows @@ -31,8 +29,6 @@ export class TasksService { private readonly prisma: PrismaService, private readonly sms: SmsClientService, private readonly currencyService: CurrencyService, - private readonly paymentsService: PaymentsService, - private readonly paymentClient: PaymentClientService, ) {} // ───────────────────────────────────────────────────────────────────────── @@ -259,84 +255,435 @@ export class TasksService { } // ───────────────────────────────────────────────────────────────────────── - // Every 1 min: poll the payment service for any PENDING_PAYMENT bookings - // whose payment intent has moved to SUCCEEDED on the gateway but whose - // confirmation event was never delivered (missed RabbitMQ message, network - // blip, etc.). finalizePaymentSuccess() is fully idempotent so re-running - // it for an already-confirmed booking is safe. + // Every 1 min: detect and resolve duplicate seat assignments. // - // Processes at most 50 bookings per cycle to avoid hammering the payment - // service; the next tick picks up the remainder. + // Root cause: a stale RabbitMQ message, delivered after system recovery, + // re-confirmed a cancelled booking whose seat had already been assigned to + // a new booking — leaving two CONFIRMED bookings holding the same seat on + // the same schedule. + // + // Resolution (FCFS): + // • Earliest confirmed booking keeps the original seat. + // • All later duplicates are reassigned to the next free seat within the + // SAME coach type (same coach preferred; any coach of same type as + // fallback). + // • If no seat is available in that coach type the booking is flagged for + // manual intervention and logged as unresolved. + // + // Idempotent: after reassignment the BookingSeat/JourneySegment rows no + // longer share the same (seatId, scheduleId) key, so the next tick finds + // nothing to do for the same pair. + // + // Scope: only schedules departing in the last 24 h or in the future, to + // keep the per-tick DB scan bounded. // ───────────────────────────────────────────────────────────────────────── @Cron('*/1 * * * *') - async syncPaymentStatuses() { - const BATCH_SIZE = 50; + async resolveDuplicateSeatAssignments() { + const BATCH_SIZE = 20; + const since = new Date(Date.now() - 24 * 60 * 60 * 1000); - const bookings = await this.prisma.booking.findMany({ + // Fetch all BookingSeat rows for CONFIRMED bookings on upcoming/recent schedules. + const confirmedSeats = await this.prisma.bookingSeat.findMany({ where: { - status: 'PENDING_PAYMENT', - paymentIntent: { status: { in: ['REQUIRES_ACTION', 'PROCESSING'] } }, + booking: { + status: 'CONFIRMED', + schedule: { departureAt: { gte: since } }, + }, + }, + include: { + booking: { + select: { + id: true, + bookingRef: true, + scheduleId: true, + createdAt: true, + contactPhone: true, + schedule: { + include: { + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + }, + }, + }, + }, + seat: { + include: { + coach: { + include: { coachType: { select: { id: true, name: true } } }, + }, + }, + }, }, - include: { paymentIntent: true }, - take: BATCH_SIZE, - orderBy: { createdAt: 'asc' }, }); - if (bookings.length === 0) return; + // Group by (seatId, scheduleId). BookingSeat.scheduleId is per-leg for + // round-trips; fall back to Booking.scheduleId for single-leg bookings. + const groups = new Map(); + for (const bs of confirmedSeats) { + if (!bs.seatId) continue; + const scheduleId = bs.scheduleId ?? bs.booking.scheduleId; + if (!scheduleId) continue; + const key = `${bs.seatId}:${scheduleId}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(bs); + } - let confirmed = 0; - let failed = 0; - let errored = 0; + const duplicateGroups = [...groups.values()] + .filter(g => g.length > 1) + .slice(0, BATCH_SIZE); - for (const booking of bookings) { - if (!booking.paymentIntent) continue; + if (duplicateGroups.length === 0) return; - try { - const snapshot = await this.paymentClient.getIntentByReference( - PaymentReferenceType.BOOKING, - booking.id, - ); + this.logger.warn(`Seat dedup: ${duplicateGroups.length} duplicate seat group(s) detected`); - if (!snapshot) continue; + // Track seats newly assigned within this run to prevent double-assignment. + const newlyAssigned = new Map>(); // scheduleId → Set + let resolved = 0; + let unresolved = 0; - if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { - const result = await this.paymentsService.finalizePaymentSuccess({ - intentId: booking.paymentIntent.id, - providerTxnId: snapshot.providerTxnId, - paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, - }); - if (!result.alreadyFinalized) { - this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`); - confirmed++; - } - } else if ( - snapshot.status === ProviderPaymentStatus.FAILED || - snapshot.status === ProviderPaymentStatus.CANCELLED - ) { - // The payment deadline enforcer will cancel the booking when its - // window expires; log now so operations can see failed intents early. - this.logger.warn( - `Payment sync: ${booking.bookingRef} intent is ${snapshot.status} — ` + - `booking will be auto-cancelled at payment deadline`, + for (const group of duplicateGroups) { + // FCFS: earliest confirmed booking keeps the seat. + const sorted = [...group].sort( + (a, b) => + new Date(a.booking.createdAt as Date).getTime() - + new Date(b.booking.createdAt as Date).getTime(), + ); + const [keeper, ...duplicates] = sorted; + + for (const dup of duplicates) { + const scheduleId = (dup.scheduleId ?? dup.booking.scheduleId)!; + const coachTypeId = dup.seat?.coach?.coachTypeId; + const oldCoachId = dup.seat?.coachId; + + if (!coachTypeId) { + this.logger.error( + `Seat dedup: missing coachTypeId for BookingSeat ${dup.id}, booking ${dup.booking.bookingRef}`, ); - failed++; + unresolved++; + continue; + } + + if (!newlyAssigned.has(scheduleId)) newlyAssigned.set(scheduleId, new Set()); + const takenThisRun = newlyAssigned.get(scheduleId)!; + + // All seats already taken: confirmed bookings + those assigned this tick. + const occupiedIds = new Set([ + ...confirmedSeats + .filter(bs => (bs.scheduleId ?? bs.booking.scheduleId) === scheduleId && bs.seatId) + .map(bs => bs.seatId as string), + ...takenThisRun, + ]); + + try { + const newSeat = await this.findReplacementSeat(scheduleId, coachTypeId, oldCoachId, occupiedIds); + + if (!newSeat) { + this.logger.warn( + `Seat dedup: no available seat for booking ${dup.booking.bookingRef} ` + + `(schedule ${scheduleId}, coachType ${coachTypeId}) — manual intervention required`, + ); + unresolved++; + continue; + } + + await this.prisma.$transaction(async (tx) => { + // 1. Update BookingSeat to the new seat. + await tx.bookingSeat.update({ + where: { id: dup.id }, + data: { seatId: newSeat.id, seatLabelSnapshot: newSeat.seatNumber }, + }); + + // 2. Update JourneySegment — look up journeyId first to avoid a + // nested-relation filter in updateMany (not supported in all Prisma versions). + const journey = await tx.journey.findUnique({ + where: { bookingId: dup.booking.id } as any, + select: { id: true }, + }); + if (journey) { + await tx.journeySegment.updateMany({ + where: { journeyId: journey.id, seatId: dup.seatId!, scheduleId }, + data: { seatId: newSeat.id, coachId: newSeat.coachId }, + }); + } + + // 3. Update Ticket seat reference (QR payload regeneration is out of scope + // here; the backoffice can trigger that separately if required). + await tx.ticket.updateMany({ + where: { bookingId: dup.booking.id, seatId: dup.seatId! }, + data: { seatId: newSeat.id }, + }); + }); + + takenThisRun.add(newSeat.id); + + const oldLabel = dup.seat?.seatNumber ?? dup.seatId ?? '?'; + const newCoach = (newSeat as any).coach; + const coachTypeName = newCoach?.coachType?.name ?? ''; + const coachNumber = newCoach?.number ?? ''; + const origin = dup.booking.schedule?.originStation?.name ?? ''; + const dest = dup.booking.schedule?.destinationStation?.name ?? ''; + + if (dup.booking.contactPhone) { + const message = + `EDR: Your booking ${dup.booking.bookingRef} (${origin} → ${dest}): ` + + `your seat has been changed from ${oldLabel} to seat ${newSeat.seatNumber} ` + + `in coach ${coachNumber} (${coachTypeName}). ` + + `We apologize for the inconvenience.`; + await this.sms.sendSms({ to: dup.booking.contactPhone, message }).catch(() => null); + } + + this.logger.log( + `Seat dedup resolved: booking ${dup.booking.bookingRef} ` + + `seat ${oldLabel} → ${newSeat.seatNumber} (coach ${coachNumber}, ${coachTypeName}), ` + + `keeper: ${keeper.booking.bookingRef}`, + ); + resolved++; + } catch (err) { + this.logger.error( + `Seat dedup error for booking ${dup.booking.bookingRef}: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + unresolved++; } - // REQUIRES_ACTION / PROCESSING → still pending, retry next cycle - } catch (err) { - this.logger.error( - `Payment sync error for ${booking.bookingRef}: ` + - `${err instanceof Error ? err.message : String(err)}`, - ); - errored++; } } - if (confirmed > 0 || failed > 0 || errored > 0) { - this.logger.log( - `Payment sync run: ${bookings.length} checked, ` + - `${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`, - ); + this.logger.log(`Seat dedup run: ${resolved} resolved, ${unresolved} unresolved`); + } + + private async findReplacementSeat( + scheduleId: string, + coachTypeId: string, + preferredCoachId: string | undefined, + occupiedIds: Set, + ) { + const includeCoach = { + coach: { include: { coachType: { select: { id: true, name: true } } } }, + }; + const baseWhere = (coachId?: string) => ({ + ...(coachId ? { coachId } : {}), + seatNumber: { not: '' }, + id: { notIn: [...occupiedIds] }, + coach: { coachTypeId, assignments: { some: { scheduleId } } }, + NOT: [ + { seatNumber: { startsWith: '-' } }, + { status: SeatStatus.BLOCKED }, + ], + }); + + // 1. Prefer the exact same coach. + if (preferredCoachId) { + const seat = await this.prisma.seat.findFirst({ + where: baseWhere(preferredCoachId), + include: includeCoach, + orderBy: [{ row: 'asc' }, { col: 'asc' }], + }); + if (seat) return seat; } + + // 2. Any coach of the same coach type assigned to this schedule. + return this.prisma.seat.findFirst({ + where: baseWhere(), + include: includeCoach, + orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }], + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // Every 1 min: detect and resolve duplicate seat assignments caused by + // RabbitMQ-recovered events re-confirming already-cancelled bookings. + // + // Detection: group confirmed BookingSeat rows by (scheduleId, seatId, leg). + // Any group with >1 row means multiple bookings share the same physical seat. + // + // Resolution (FCFS): the booking created first keeps the seat; all later + // bookings are reassigned to an available seat in: + // 1. Same coach + same coach type (preferred) + // 2. Same coach type, any coach (fallback) + // 3. No seat available → logged, needs manual intervention + // + // Idempotency: once a duplicate's BookingSeat is updated to a new seatId it + // no longer appears in the duplicate group on the next tick — naturally safe + // to re-run without any extra flag. + // ───────────────────────────────────────────────────────────────────────── + @Cron('*/1 * * * *') + async deduplicateSeatAssignments() { + this.logger.log('Seat dedup cron started'); + // Scan at most 500 confirmed seat rows per run to stay lightweight. + const confirmedSeats = await this.prisma.bookingSeat.findMany({ + where: { booking: { status: { in: ['CONFIRMED', 'BOARDED'] } } }, + select: { + id: true, + seatId: true, + scheduleId: true, + leg: true, + passengerName: true, + booking: { + select: { + id: true, + bookingRef: true, + scheduleId: true, + createdAt: true, + contactPhone: true, + }, + }, + seat: { + select: { + id: true, + seatNumber: true, + coachId: true, + coach: { + select: { + id: true, + number: true, + coachTypeId: true, + coachType: { select: { id: true, name: true } }, + }, + }, + }, + }, + }, + take: 500, + }); + + // Group by (effectiveScheduleId :: seatId :: leg) + type BsRow = (typeof confirmedSeats)[number]; + const groups = new Map(); + for (const bs of confirmedSeats) { + const schedId = bs.scheduleId ?? bs.booking.scheduleId; + if (!schedId) continue; + const key = `${schedId}::${bs.seatId}::${bs.leg}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(bs); + } + + const duplicateGroups = [...groups.values()].filter(g => g.length > 1); + if (duplicateGroups.length === 0) return; + + this.logger.warn(`Seat dedup: ${duplicateGroups.length} conflict(s) detected`); + + // Build taken-seat sets keyed by (scheduleId::leg) — used when finding + // a replacement seat so we don't assign an already-occupied seat. + const takenByScheduleLeg = new Map>(); + for (const bs of confirmedSeats) { + const schedId = bs.scheduleId ?? bs.booking.scheduleId; + if (!schedId) continue; + const key = `${schedId}::${bs.leg}`; + if (!takenByScheduleLeg.has(key)) takenByScheduleLeg.set(key, new Set()); + takenByScheduleLeg.get(key)!.add(bs.seatId); + } + + let resolved = 0; + let unresolved = 0; + + for (const group of duplicateGroups) { + // FCFS: earliest booking keeps the seat + group.sort((a, b) => + new Date(a.booking.createdAt).getTime() - new Date(b.booking.createdAt).getTime(), + ); + + const [winner, ...duplicates] = group; + const schedId = winner.scheduleId ?? winner.booking.scheduleId; + const coachTypeId = winner.seat.coach.coachTypeId; + const origCoachId = winner.seat.coachId; + const taken = takenByScheduleLeg.get(`${schedId}::${winner.leg}`) ?? new Set(); + + for (const dup of duplicates) { + try { + // 1st choice: same coach + same coach type + const newSeat = + (await this.prisma.seat.findFirst({ + where: { + id: { notIn: [...taken] }, + status: { not: SeatStatus.BLOCKED }, + coachId: origCoachId, + coach: { + coachTypeId, + assignments: { some: { scheduleId: schedId } }, + }, + }, + select: { + id: true, seatNumber: true, coachId: true, + coach: { select: { number: true, coachType: { select: { name: true } } } }, + }, + })) ?? + // 2nd choice: any coach within same coach type + (await this.prisma.seat.findFirst({ + where: { + id: { notIn: [...taken] }, + status: { not: SeatStatus.BLOCKED }, + coach: { + coachTypeId, + assignments: { some: { scheduleId: schedId } }, + }, + }, + select: { + id: true, seatNumber: true, coachId: true, + coach: { select: { number: true, coachType: { select: { name: true } } } }, + }, + })); + + if (!newSeat) { + this.logger.warn( + `Seat dedup: no available seat in coach type for ` + + `booking ${dup.booking.bookingRef} (${dup.passengerName}) — manual intervention required`, + ); + unresolved++; + continue; + } + + // Atomically update BookingSeat + Ticket + JourneySegment + await this.prisma.$transaction(async (tx) => { + await tx.bookingSeat.update({ + where: { id: dup.id }, + data: { seatId: newSeat!.id, seatLabelSnapshot: newSeat!.seatNumber }, + }); + await tx.ticket.updateMany({ + where: { bookingId: dup.booking.id, seatId: dup.seatId, leg: dup.leg }, + data: { seatId: newSeat!.id }, + }); + await tx.journeySegment.updateMany({ + where: { + journey: { bookingId: dup.booking.id }, + seatId: dup.seatId, + scheduleId: schedId, + }, + data: { seatId: newSeat!.id, coachId: newSeat!.coachId }, + }); + }); + + // Claim the new seat so subsequent duplicates in this run don't use it + taken.add(newSeat.id); + + const message = + `EDR: Your seat for booking ${dup.booking.bookingRef} has been updated ` + + `due to a system correction. ` + + `New seat: ${newSeat.seatNumber}, Coach: ${newSeat.coach.number} ` + + `(${newSeat.coach.coachType.name}). We apologize for the inconvenience.`; + + if (dup.booking.contactPhone) { + await this.sms.sendSms({ to: dup.booking.contactPhone, message }).catch(() => null); + } + + this.logger.log( + `Seat dedup: booking ${dup.booking.bookingRef} (${dup.passengerName}) ` + + `seat ${dup.seat.seatNumber} → ${newSeat.seatNumber} (coach ${newSeat.coach.number})`, + ); + resolved++; + } catch (err) { + this.logger.error( + `Seat dedup error for ${dup.booking.bookingRef}: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + unresolved++; + } + } + } + + this.logger.log( + `Seat dedup complete: ${resolved} reassigned, ${unresolved} unresolved ` + + `across ${duplicateGroups.length} conflict(s)`, + ); } // ───────────────────────────────────────────────────────────────────────── From e59b79747754e8eeecfade076f0a6b8b0ffc054d Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Fri, 17 Jul 2026 00:49:35 +0300 Subject: [PATCH 08/88] Minor updates --- apps/edr-passenger-api/src/main.ts | 2 +- apps/edr-passenger-web/backoffice/src/app/login/page.tsx | 2 +- apps/edr-passenger-web/backoffice/src/app/settings/page.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index f48a228d9..486094c3f 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -322,7 +322,7 @@ Payment providers send notifications to: - \`POST /payments/webhooks/card\` (International) ## Support -- **Email:** support@edr-platform.com +- **Email:** edr_@edrsc.com - **Documentation:** https://docs.edr-platform.com - **Status Page:** https://status.edr-platform.com `, diff --git a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx index c0d84cae8..4f0098792 100644 --- a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx @@ -365,7 +365,7 @@ export default function LoginPage() { Back-office · v1.0 - Need help? support@edr.com + Need help? edr_@edrsc.com diff --git a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx index 3f0a266fd..873e81578 100644 --- a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx @@ -94,11 +94,11 @@ export default function SettingsPage() {
- +
- +
From 2366b28610f496e4abf2e174751bdd91342b4bc6 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Fri, 17 Jul 2026 08:42:42 +0300 Subject: [PATCH 09/88] remove the cron job --- .../src/modules/tasks/tasks.service.ts | 433 ------------------ 1 file changed, 433 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index ab275ee09..4fb3a4f0f 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -1,6 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; -import { SeatStatus } from '@prisma/client'; import { PrismaService } from '../../common/prisma.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { CurrencyService } from '../currency/currency.service'; @@ -254,438 +253,6 @@ export class TasksService { } } - // ───────────────────────────────────────────────────────────────────────── - // Every 1 min: detect and resolve duplicate seat assignments. - // - // Root cause: a stale RabbitMQ message, delivered after system recovery, - // re-confirmed a cancelled booking whose seat had already been assigned to - // a new booking — leaving two CONFIRMED bookings holding the same seat on - // the same schedule. - // - // Resolution (FCFS): - // • Earliest confirmed booking keeps the original seat. - // • All later duplicates are reassigned to the next free seat within the - // SAME coach type (same coach preferred; any coach of same type as - // fallback). - // • If no seat is available in that coach type the booking is flagged for - // manual intervention and logged as unresolved. - // - // Idempotent: after reassignment the BookingSeat/JourneySegment rows no - // longer share the same (seatId, scheduleId) key, so the next tick finds - // nothing to do for the same pair. - // - // Scope: only schedules departing in the last 24 h or in the future, to - // keep the per-tick DB scan bounded. - // ───────────────────────────────────────────────────────────────────────── - @Cron('*/1 * * * *') - async resolveDuplicateSeatAssignments() { - const BATCH_SIZE = 20; - const since = new Date(Date.now() - 24 * 60 * 60 * 1000); - - // Fetch all BookingSeat rows for CONFIRMED bookings on upcoming/recent schedules. - const confirmedSeats = await this.prisma.bookingSeat.findMany({ - where: { - booking: { - status: 'CONFIRMED', - schedule: { departureAt: { gte: since } }, - }, - }, - include: { - booking: { - select: { - id: true, - bookingRef: true, - scheduleId: true, - createdAt: true, - contactPhone: true, - schedule: { - include: { - originStation: { select: { name: true } }, - destinationStation: { select: { name: true } }, - }, - }, - }, - }, - seat: { - include: { - coach: { - include: { coachType: { select: { id: true, name: true } } }, - }, - }, - }, - }, - }); - - // Group by (seatId, scheduleId). BookingSeat.scheduleId is per-leg for - // round-trips; fall back to Booking.scheduleId for single-leg bookings. - const groups = new Map(); - for (const bs of confirmedSeats) { - if (!bs.seatId) continue; - const scheduleId = bs.scheduleId ?? bs.booking.scheduleId; - if (!scheduleId) continue; - const key = `${bs.seatId}:${scheduleId}`; - if (!groups.has(key)) groups.set(key, []); - groups.get(key)!.push(bs); - } - - const duplicateGroups = [...groups.values()] - .filter(g => g.length > 1) - .slice(0, BATCH_SIZE); - - if (duplicateGroups.length === 0) return; - - this.logger.warn(`Seat dedup: ${duplicateGroups.length} duplicate seat group(s) detected`); - - // Track seats newly assigned within this run to prevent double-assignment. - const newlyAssigned = new Map>(); // scheduleId → Set - let resolved = 0; - let unresolved = 0; - - for (const group of duplicateGroups) { - // FCFS: earliest confirmed booking keeps the seat. - const sorted = [...group].sort( - (a, b) => - new Date(a.booking.createdAt as Date).getTime() - - new Date(b.booking.createdAt as Date).getTime(), - ); - const [keeper, ...duplicates] = sorted; - - for (const dup of duplicates) { - const scheduleId = (dup.scheduleId ?? dup.booking.scheduleId)!; - const coachTypeId = dup.seat?.coach?.coachTypeId; - const oldCoachId = dup.seat?.coachId; - - if (!coachTypeId) { - this.logger.error( - `Seat dedup: missing coachTypeId for BookingSeat ${dup.id}, booking ${dup.booking.bookingRef}`, - ); - unresolved++; - continue; - } - - if (!newlyAssigned.has(scheduleId)) newlyAssigned.set(scheduleId, new Set()); - const takenThisRun = newlyAssigned.get(scheduleId)!; - - // All seats already taken: confirmed bookings + those assigned this tick. - const occupiedIds = new Set([ - ...confirmedSeats - .filter(bs => (bs.scheduleId ?? bs.booking.scheduleId) === scheduleId && bs.seatId) - .map(bs => bs.seatId as string), - ...takenThisRun, - ]); - - try { - const newSeat = await this.findReplacementSeat(scheduleId, coachTypeId, oldCoachId, occupiedIds); - - if (!newSeat) { - this.logger.warn( - `Seat dedup: no available seat for booking ${dup.booking.bookingRef} ` + - `(schedule ${scheduleId}, coachType ${coachTypeId}) — manual intervention required`, - ); - unresolved++; - continue; - } - - await this.prisma.$transaction(async (tx) => { - // 1. Update BookingSeat to the new seat. - await tx.bookingSeat.update({ - where: { id: dup.id }, - data: { seatId: newSeat.id, seatLabelSnapshot: newSeat.seatNumber }, - }); - - // 2. Update JourneySegment — look up journeyId first to avoid a - // nested-relation filter in updateMany (not supported in all Prisma versions). - const journey = await tx.journey.findUnique({ - where: { bookingId: dup.booking.id } as any, - select: { id: true }, - }); - if (journey) { - await tx.journeySegment.updateMany({ - where: { journeyId: journey.id, seatId: dup.seatId!, scheduleId }, - data: { seatId: newSeat.id, coachId: newSeat.coachId }, - }); - } - - // 3. Update Ticket seat reference (QR payload regeneration is out of scope - // here; the backoffice can trigger that separately if required). - await tx.ticket.updateMany({ - where: { bookingId: dup.booking.id, seatId: dup.seatId! }, - data: { seatId: newSeat.id }, - }); - }); - - takenThisRun.add(newSeat.id); - - const oldLabel = dup.seat?.seatNumber ?? dup.seatId ?? '?'; - const newCoach = (newSeat as any).coach; - const coachTypeName = newCoach?.coachType?.name ?? ''; - const coachNumber = newCoach?.number ?? ''; - const origin = dup.booking.schedule?.originStation?.name ?? ''; - const dest = dup.booking.schedule?.destinationStation?.name ?? ''; - - if (dup.booking.contactPhone) { - const message = - `EDR: Your booking ${dup.booking.bookingRef} (${origin} → ${dest}): ` + - `your seat has been changed from ${oldLabel} to seat ${newSeat.seatNumber} ` + - `in coach ${coachNumber} (${coachTypeName}). ` + - `We apologize for the inconvenience.`; - await this.sms.sendSms({ to: dup.booking.contactPhone, message }).catch(() => null); - } - - this.logger.log( - `Seat dedup resolved: booking ${dup.booking.bookingRef} ` + - `seat ${oldLabel} → ${newSeat.seatNumber} (coach ${coachNumber}, ${coachTypeName}), ` + - `keeper: ${keeper.booking.bookingRef}`, - ); - resolved++; - } catch (err) { - this.logger.error( - `Seat dedup error for booking ${dup.booking.bookingRef}: ` + - `${err instanceof Error ? err.message : String(err)}`, - ); - unresolved++; - } - } - } - - this.logger.log(`Seat dedup run: ${resolved} resolved, ${unresolved} unresolved`); - } - - private async findReplacementSeat( - scheduleId: string, - coachTypeId: string, - preferredCoachId: string | undefined, - occupiedIds: Set, - ) { - const includeCoach = { - coach: { include: { coachType: { select: { id: true, name: true } } } }, - }; - const baseWhere = (coachId?: string) => ({ - ...(coachId ? { coachId } : {}), - seatNumber: { not: '' }, - id: { notIn: [...occupiedIds] }, - coach: { coachTypeId, assignments: { some: { scheduleId } } }, - NOT: [ - { seatNumber: { startsWith: '-' } }, - { status: SeatStatus.BLOCKED }, - ], - }); - - // 1. Prefer the exact same coach. - if (preferredCoachId) { - const seat = await this.prisma.seat.findFirst({ - where: baseWhere(preferredCoachId), - include: includeCoach, - orderBy: [{ row: 'asc' }, { col: 'asc' }], - }); - if (seat) return seat; - } - - // 2. Any coach of the same coach type assigned to this schedule. - return this.prisma.seat.findFirst({ - where: baseWhere(), - include: includeCoach, - orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }], - }); - } - - // ───────────────────────────────────────────────────────────────────────── - // Every 1 min: detect and resolve duplicate seat assignments caused by - // RabbitMQ-recovered events re-confirming already-cancelled bookings. - // - // Detection: group confirmed BookingSeat rows by (scheduleId, seatId, leg). - // Any group with >1 row means multiple bookings share the same physical seat. - // - // Resolution (FCFS): the booking created first keeps the seat; all later - // bookings are reassigned to an available seat in: - // 1. Same coach + same coach type (preferred) - // 2. Same coach type, any coach (fallback) - // 3. No seat available → logged, needs manual intervention - // - // Idempotency: once a duplicate's BookingSeat is updated to a new seatId it - // no longer appears in the duplicate group on the next tick — naturally safe - // to re-run without any extra flag. - // ───────────────────────────────────────────────────────────────────────── - @Cron('*/1 * * * *') - async deduplicateSeatAssignments() { - this.logger.log('Seat dedup cron started'); - // Scan at most 500 confirmed seat rows per run to stay lightweight. - const confirmedSeats = await this.prisma.bookingSeat.findMany({ - where: { booking: { status: { in: ['CONFIRMED', 'BOARDED'] } } }, - select: { - id: true, - seatId: true, - scheduleId: true, - leg: true, - passengerName: true, - booking: { - select: { - id: true, - bookingRef: true, - scheduleId: true, - createdAt: true, - contactPhone: true, - }, - }, - seat: { - select: { - id: true, - seatNumber: true, - coachId: true, - coach: { - select: { - id: true, - number: true, - coachTypeId: true, - coachType: { select: { id: true, name: true } }, - }, - }, - }, - }, - }, - take: 500, - }); - - // Group by (effectiveScheduleId :: seatId :: leg) - type BsRow = (typeof confirmedSeats)[number]; - const groups = new Map(); - for (const bs of confirmedSeats) { - const schedId = bs.scheduleId ?? bs.booking.scheduleId; - if (!schedId) continue; - const key = `${schedId}::${bs.seatId}::${bs.leg}`; - if (!groups.has(key)) groups.set(key, []); - groups.get(key)!.push(bs); - } - - const duplicateGroups = [...groups.values()].filter(g => g.length > 1); - if (duplicateGroups.length === 0) return; - - this.logger.warn(`Seat dedup: ${duplicateGroups.length} conflict(s) detected`); - - // Build taken-seat sets keyed by (scheduleId::leg) — used when finding - // a replacement seat so we don't assign an already-occupied seat. - const takenByScheduleLeg = new Map>(); - for (const bs of confirmedSeats) { - const schedId = bs.scheduleId ?? bs.booking.scheduleId; - if (!schedId) continue; - const key = `${schedId}::${bs.leg}`; - if (!takenByScheduleLeg.has(key)) takenByScheduleLeg.set(key, new Set()); - takenByScheduleLeg.get(key)!.add(bs.seatId); - } - - let resolved = 0; - let unresolved = 0; - - for (const group of duplicateGroups) { - // FCFS: earliest booking keeps the seat - group.sort((a, b) => - new Date(a.booking.createdAt).getTime() - new Date(b.booking.createdAt).getTime(), - ); - - const [winner, ...duplicates] = group; - const schedId = winner.scheduleId ?? winner.booking.scheduleId; - const coachTypeId = winner.seat.coach.coachTypeId; - const origCoachId = winner.seat.coachId; - const taken = takenByScheduleLeg.get(`${schedId}::${winner.leg}`) ?? new Set(); - - for (const dup of duplicates) { - try { - // 1st choice: same coach + same coach type - const newSeat = - (await this.prisma.seat.findFirst({ - where: { - id: { notIn: [...taken] }, - status: { not: SeatStatus.BLOCKED }, - coachId: origCoachId, - coach: { - coachTypeId, - assignments: { some: { scheduleId: schedId } }, - }, - }, - select: { - id: true, seatNumber: true, coachId: true, - coach: { select: { number: true, coachType: { select: { name: true } } } }, - }, - })) ?? - // 2nd choice: any coach within same coach type - (await this.prisma.seat.findFirst({ - where: { - id: { notIn: [...taken] }, - status: { not: SeatStatus.BLOCKED }, - coach: { - coachTypeId, - assignments: { some: { scheduleId: schedId } }, - }, - }, - select: { - id: true, seatNumber: true, coachId: true, - coach: { select: { number: true, coachType: { select: { name: true } } } }, - }, - })); - - if (!newSeat) { - this.logger.warn( - `Seat dedup: no available seat in coach type for ` + - `booking ${dup.booking.bookingRef} (${dup.passengerName}) — manual intervention required`, - ); - unresolved++; - continue; - } - - // Atomically update BookingSeat + Ticket + JourneySegment - await this.prisma.$transaction(async (tx) => { - await tx.bookingSeat.update({ - where: { id: dup.id }, - data: { seatId: newSeat!.id, seatLabelSnapshot: newSeat!.seatNumber }, - }); - await tx.ticket.updateMany({ - where: { bookingId: dup.booking.id, seatId: dup.seatId, leg: dup.leg }, - data: { seatId: newSeat!.id }, - }); - await tx.journeySegment.updateMany({ - where: { - journey: { bookingId: dup.booking.id }, - seatId: dup.seatId, - scheduleId: schedId, - }, - data: { seatId: newSeat!.id, coachId: newSeat!.coachId }, - }); - }); - - // Claim the new seat so subsequent duplicates in this run don't use it - taken.add(newSeat.id); - - const message = - `EDR: Your seat for booking ${dup.booking.bookingRef} has been updated ` + - `due to a system correction. ` + - `New seat: ${newSeat.seatNumber}, Coach: ${newSeat.coach.number} ` + - `(${newSeat.coach.coachType.name}). We apologize for the inconvenience.`; - - if (dup.booking.contactPhone) { - await this.sms.sendSms({ to: dup.booking.contactPhone, message }).catch(() => null); - } - - this.logger.log( - `Seat dedup: booking ${dup.booking.bookingRef} (${dup.passengerName}) ` + - `seat ${dup.seat.seatNumber} → ${newSeat.seatNumber} (coach ${newSeat.coach.number})`, - ); - resolved++; - } catch (err) { - this.logger.error( - `Seat dedup error for ${dup.booking.bookingRef}: ` + - `${err instanceof Error ? err.message : String(err)}`, - ); - unresolved++; - } - } - } - - this.logger.log( - `Seat dedup complete: ${resolved} reassigned, ${unresolved} unresolved ` + - `across ${duplicateGroups.length} conflict(s)`, - ); - } - // ───────────────────────────────────────────────────────────────────────── // Daily at 02:00 EAT: purge expired/stale records to enforce data retention. // ───────────────────────────────────────────────────────────────────────── From 610580e15e1445cbe67b3525cd88fabb4c5d8100 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Fri, 17 Jul 2026 10:20:35 +0300 Subject: [PATCH 10/88] Added discrepancy management for duplicate seats --- .../src/modules/seats/duplicate-seats.dto.ts | 33 ++ .../src/modules/seats/seats.controller.ts | 88 +++ .../src/modules/seats/seats.module.ts | 3 +- .../src/modules/seats/seats.service.ts | 414 ++++++++++++++ .../backoffice/src/app/discrepancy/layout.tsx | 5 + .../backoffice/src/app/discrepancy/page.tsx | 519 ++++++++++++++++++ .../src/components/layout/Sidebar.tsx | 4 +- .../backoffice/src/lib/api/index.ts | 4 + 8 files changed, 1068 insertions(+), 2 deletions(-) create mode 100644 apps/edr-passenger-api/src/modules/seats/duplicate-seats.dto.ts create mode 100644 apps/edr-passenger-web/backoffice/src/app/discrepancy/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx diff --git a/apps/edr-passenger-api/src/modules/seats/duplicate-seats.dto.ts b/apps/edr-passenger-api/src/modules/seats/duplicate-seats.dto.ts new file mode 100644 index 000000000..9018d77fb --- /dev/null +++ b/apps/edr-passenger-api/src/modules/seats/duplicate-seats.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsDateString, IsOptional, IsUUID } from 'class-validator'; + +export class GetDuplicateSeatsQuery { + @ApiProperty({ example: '2026-07-17', description: 'Schedule date (YYYY-MM-DD)' }) + @IsDateString() + date: string; + + @ApiPropertyOptional({ description: 'Filter to a specific schedule ID' }) + @IsOptional() + @IsUUID() + scheduleId?: string; +} + +export class ResolveDuplicatesDto { + @ApiProperty({ + description: 'BookingSeat IDs of the duplicate bookings to reassign', + type: [String], + example: ['uuid-booking-seat-1', 'uuid-booking-seat-2'], + }) + @IsArray() + @IsUUID(undefined, { each: true }) + bookingSeatIds: string[]; + + @ApiProperty({ + description: 'Coach IDs to source replacement seats from (searched in order; first available seat per coach is used)', + type: [String], + example: ['uuid-coach-1', 'uuid-coach-2'], + }) + @IsArray() + @IsUUID(undefined, { each: true }) + coachIds: string[]; +} diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index 596c88bb3..834973c71 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -17,9 +17,11 @@ import { ApiParam, ApiQuery, ApiResponse, + ApiBody, } from "@nestjs/swagger"; import { SeatsService } from "./seats.service"; import { HoldSeatsDto, ReleaseHoldDto } from "./seats.dto"; +import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto"; import { JwtGuard } from "../../common/jwt.guard"; import { PassengerStaff } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; @@ -306,4 +308,90 @@ This makes it clear which segment of the route each seat is held for, enabling s ) { return this.service.importSeatsCSV(body.scheduleId, body.csv, body.commit); } + + // ── Duplicate seat management (backoffice) ──────────────────────────────── + + @Get("duplicates") + @UseGuards(JwtGuard) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ + summary: "List duplicate seat assignments by schedule date", + description: + "Returns all schedules on the given date that have bookings sharing " + + "the same seat, grouped by coach. Each coach entry includes the duplicate " + + "groups (with full booking info) and the list of currently available seats " + + "that can be used for reassignment.", + }) + @ApiQuery({ name: "date", example: "2026-07-17", description: "Schedule date (YYYY-MM-DD)" }) + @ApiQuery({ name: "scheduleId", required: false, description: "Filter to a specific schedule" }) + @ApiResponse({ + status: 200, + description: "Duplicate seat report grouped by schedule → coach", + schema: { + example: { + date: "2026-07-17", + totalDuplicates: 1, + schedules: [{ + scheduleId: "uuid", + departureAt: "2026-07-17T06:00:00.000Z", + origin: "Addis Ababa", + destination: "Dire Dawa", + coaches: [{ + coachId: "uuid", + coachNumber: "C1", + coachTypeName: "SBC", + duplicates: [{ + seatId: "uuid", + seatNumber: "12A", + leg: 1, + bookings: [ + { bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "ATPC9F", passengerName: "Abebe", contactPhone: "+251911000000", createdAt: "2026-07-16T10:00:00.000Z" }, + { bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "XYZ123", passengerName: "Kebede", contactPhone: "+251922000000", createdAt: "2026-07-16T11:00:00.000Z" }, + ], + }], + availableSeats: [ + { seatId: "uuid", seatNumber: "14B" }, + { seatId: "uuid", seatNumber: "15A" }, + ], + }], + }], + }, + }, + }) + getDuplicateSeats(@Query() query: GetDuplicateSeatsQuery) { + return this.service.getDuplicateSeats(query.date, query.scheduleId); + } + + @Post("duplicates/resolve") + @UseGuards(JwtGuard) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ + summary: "Auto-assign duplicate bookings to seats in selected coaches", + description: + "Staff selects which duplicate BookingSeat IDs to fix and which coaches to pull replacement seats from. " + + "The system automatically picks the first available (non-blocked, non-occupied) seat in the given coaches " + + "for each booking, updates BookingSeat + Ticket + JourneySegment atomically so the seatmap reflects the " + + "change immediately, then sends an SMS notification to the passenger. " + + "Coaches are searched in the order provided; seats within each coach are assigned by row then column.", + }) + @ApiBody({ type: ResolveDuplicatesDto }) + @ApiResponse({ + status: 200, + description: "Resolution summary — resolved count, unresolved count, per-booking results", + schema: { + example: { + resolved: 2, + unresolved: 0, + results: [ + { bookingRef: "XYZ123", oldSeatNumber: "1A", newSeatNumber: "14B", contactPhone: "+251922000000" }, + { bookingRef: "ABC456", oldSeatNumber: "1A", newSeatNumber: "15A", contactPhone: "+251933000000" }, + ], + }, + }, + }) + @ApiResponse({ status: 400, description: "Booking not in CONFIRMED/BOARDED status" }) + @ApiResponse({ status: 404, description: "BookingSeat ID not found" }) + resolveDuplicateSeats(@Body() dto: ResolveDuplicatesDto) { + return this.service.resolveDuplicateSeats(dto.bookingSeatIds, dto.coachIds); + } } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.module.ts b/apps/edr-passenger-api/src/modules/seats/seats.module.ts index 0725f4393..2a3c4ee26 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.module.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.module.ts @@ -5,9 +5,10 @@ import { SeatsService } from './seats.service'; import { SegmentsModule } from '../segments/segments.module'; import { SystemConfigModule } from '../system-config/system-config.module'; import { AuditModule } from '../../common/audit.module'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule], + imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule, NotificationsModule], controllers: [SeatsController], providers: [SeatsService], exports: [SeatsService], 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 10368f380..29d03206c 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -5,6 +5,7 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { SegmentsService } from '../segments/segments.service'; import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; import { AuditService } from '../../common/audit.service'; +import { SmsClientService } from '../notifications/sms-client.service'; import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; import { checkDirectionConflict } from '../../common/utils/journey-direction.utils'; @@ -17,6 +18,7 @@ export class SeatsService { private segmentsService: SegmentsService, private systemConfig: SystemConfigService, private auditService: AuditService, + private sms: SmsClientService, ) {} async getSeatMap(scheduleId: string, coachTypeId?: string, journeyDirection?: JourneyDirection, originStationId?: string, destinationStationId?: string) { @@ -960,4 +962,416 @@ export class SeatsService { skippedSeatIds: Array.from(skippedSeatIds), // kept for logging/API compat; no DB writes needed }; } + + // ───────────────────────────────────────────────────────────────────────── + // Duplicate-seat management (backoffice) + // ───────────────────────────────────────────────────────────────────────── + + async getDuplicateSeats(date: string, scheduleId?: string) { + const dayStart = new Date(`${date}T00:00:00.000Z`); + const dayEnd = new Date(`${date}T23:59:59.999Z`); + + const schedules = await this.prisma.trainSchedule.findMany({ + where: { + departureAt: { gte: dayStart, lte: dayEnd }, + ...(scheduleId ? { id: scheduleId } : {}), + }, + orderBy: { departureAt: 'asc' }, + include: { + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + coachAssignments: { + orderBy: { positionNumber: 'asc' }, + include: { + coach: { + include: { + coachType: { select: { name: true } }, + seats: { + orderBy: [{ row: 'asc' }, { col: 'asc' }], + select: { id: true, seatNumber: true, status: true, coachId: true }, + }, + }, + }, + }, + }, + }, + }); + + const result = []; + + for (const schedule of schedules) { + // All confirmed BookingSeat rows for this schedule + const bookingSeats = await this.prisma.bookingSeat.findMany({ + where: { + OR: [ + { scheduleId: schedule.id }, + { scheduleId: null, booking: { scheduleId: schedule.id } }, + ], + booking: { status: { in: ['CONFIRMED', 'BOARDED'] } }, + }, + select: { + id: true, seatId: true, scheduleId: true, leg: true, passengerName: true, + seat: { select: { coachId: true } }, + booking: { + select: { + id: true, bookingRef: true, scheduleId: true, + createdAt: true, contactPhone: true, + }, + }, + }, + }); + + // Seats occupied by any confirmed journey on this schedule (source of truth) + const journeySegments = await this.prisma.journeySegment.findMany({ + where: { + scheduleId: schedule.id, + seatId: { not: null }, + journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, + }, + select: { seatId: true }, + }); + const occupiedIds = new Set(journeySegments.map(js => js.seatId!)); + + // Group BookingSeat rows by (seatId::leg) to detect duplicates + type BS = (typeof bookingSeats)[number]; + const groups = new Map(); + for (const bs of bookingSeats) { + const key = `${bs.seatId}::${bs.leg}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(bs); + } + + // All seats held by any confirmed BookingSeat — union of JourneySegment-based + // occupancy AND BookingSeat-based occupancy so that seats whose JourneySegments + // are missing (e.g. created via enhanced-seats path without bookingId) are still + // excluded from the available list. + const bookedSeatIds = new Set([ + ...occupiedIds, + ...bookingSeats.map(bs => bs.seatId).filter((id): id is string => id !== null && id !== undefined), + ]); + + const coachReports = []; + + for (const assignment of schedule.coachAssignments) { + const coach = assignment.coach; + + // Duplicate groups whose seat belongs to this coach + const duplicates = []; + for (const [key, group] of groups) { + if (group.length <= 1) continue; + if (group[0].seat.coachId !== coach.id) continue; + const [seatId] = key.split('::'); + const seat = coach.seats.find(s => s.id === seatId); + duplicates.push({ + seatId, + seatNumber: seat?.seatNumber ?? seatId, + leg: group[0].leg, + bookings: group.map(bs => ({ + bookingSeatId: bs.id, + bookingId: bs.booking.id, + bookingRef: bs.booking.bookingRef, + passengerName: bs.passengerName, + contactPhone: bs.booking.contactPhone, + createdAt: bs.booking.createdAt, + })), + }); + } + + // Free seats in this coach — excludes BLOCKED, all confirmed BookingSeat + // assignments, and all confirmed JourneySegment occupancies. + const availableSeats = coach.seats + .filter(s => + (s.status as string) !== 'BLOCKED' && + !s.seatNumber.startsWith('-') && + !bookedSeatIds.has(s.id), + ) + .map(s => ({ seatId: s.id, seatNumber: s.seatNumber })); + + coachReports.push({ + coachId: coach.id, + coachNumber: coach.number, + coachTypeName: coach.coachType.name, + duplicates, + availableSeats, + }); + } + + if (coachReports.some(c => c.duplicates.length > 0)) { + result.push({ + scheduleId: schedule.id, + departureAt: schedule.departureAt, + origin: schedule.originStation.name, + destination: schedule.destinationStation.name, + coaches: coachReports, + }); + } + } + + const totalDuplicates = result.reduce( + (sum, s) => sum + s.coaches.reduce((cs, c) => cs + c.duplicates.length, 0), + 0, + ); + + return { date, schedules: result, totalDuplicates }; + } + + async resolveDuplicateSeats(bookingSeatIds: string[], coachIds: string[]) { + if (bookingSeatIds.length === 0) return { resolved: 0, unresolved: 0, results: [] }; + + // Load BookingSeat rows with full booking + schedule context + const bookingSeats = await this.prisma.bookingSeat.findMany({ + where: { id: { in: bookingSeatIds } }, + select: { + id: true, seatId: true, leg: true, scheduleId: true, + seat: { select: { seatNumber: true } }, + booking: { + select: { + id: true, bookingRef: true, scheduleId: true, + status: true, contactPhone: true, passengerId: true, + totalMinor: true, currency: true, + originStationId: true, destinationStationId: true, + schedule: { + select: { + originStationId: true, + destinationStationId: true, + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + departureAt: true, + }, + }, + }, + }, + }, + }); + + if (bookingSeats.length !== bookingSeatIds.length) { + const found = new Set(bookingSeats.map(bs => bs.id)); + const missing = bookingSeatIds.filter(id => !found.has(id)); + throw new NotFoundException(`BookingSeat(s) not found: ${missing.join(', ')}`); + } + + const invalid = bookingSeats.filter(bs => !['CONFIRMED', 'BOARDED'].includes(bs.booking.status)); + if (invalid.length > 0) { + throw new BadRequestException( + `Bookings must be CONFIRMED or BOARDED: ${invalid.map(bs => bs.booking.bookingRef).join(', ')}`, + ); + } + + // Load all non-blocked, non-removed seats from the selected coaches (ordered for deterministic pick) + const coachSeats = await this.prisma.seat.findMany({ + where: { + coachId: { in: coachIds }, + status: { not: 'BLOCKED' }, + NOT: { seatNumber: { startsWith: '-' } }, + }, + select: { id: true, seatNumber: true, coachId: true, row: true, col: true }, + orderBy: [{ coachId: 'asc' }, { row: 'asc' }, { col: 'asc' }], + }); + + // Build occupied-seat sets per schedule from confirmed JourneySegments + const scheduleIds = [ + ...new Set( + bookingSeats + .map(bs => bs.scheduleId ?? bs.booking.scheduleId) + .filter((id): id is string => id !== null && id !== undefined), + ), + ]; + + const occupiedBySchedule = new Map>(); + await Promise.all( + scheduleIds.map(async scheduleId => { + const segments = await this.prisma.journeySegment.findMany({ + where: { + scheduleId, + seatId: { not: null }, + journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT', 'BOARDED'] } }, + }, + select: { seatId: true }, + }); + occupiedBySchedule.set(scheduleId, new Set(segments.map(s => s.seatId!))); + }), + ); + + // Track seats assigned within this batch to prevent double-assignment + const assignedInBatch = new Set(); + + const results: { bookingRef: string; oldSeatNumber: string; newSeatNumber: string; contactPhone: string | null }[] = []; + const unresolved: { bookingRef: string; reason: string }[] = []; + + for (const bs of bookingSeats) { + const scheduleId = (bs.scheduleId ?? bs.booking.scheduleId)!; + const occupied = occupiedBySchedule.get(scheduleId) ?? new Set(); + + // Pick the first available seat across the selected coaches + const newSeat = coachSeats.find( + seat => + !occupied.has(seat.id) && + !assignedInBatch.has(seat.id) && + seat.id !== bs.seatId, + ); + + if (!newSeat) { + unresolved.push({ + bookingRef: bs.booking.bookingRef, + reason: 'No available seat found in selected coaches', + }); + this.logger.warn( + `Duplicate resolve: no seat available for ${bs.booking.bookingRef} (schedule ${scheduleId})`, + ); + continue; + } + + await this.prisma.$transaction(async tx => { + // 1. Change the seat on the booking and ticket. + await tx.bookingSeat.update({ + where: { id: bs.id }, + data: { seatId: newSeat.id, seatLabelSnapshot: newSeat.seatNumber }, + }); + await tx.ticket.updateMany({ + where: { bookingId: bs.booking.id, seatId: bs.seatId, leg: bs.leg }, + data: { seatId: newSeat.id }, + }); + + // 2. Point the existing JourneySegments to the new seat. + // The Journey is already linked to this booking via bookingId; + // just update the seatId in its hop rows for this schedule. + const journey = await tx.journey.findFirst({ + where: { bookingId: bs.booking.id }, + select: { id: true }, + }); + + if (!journey) { + // No Journey/JourneySegment for this booking (e.g. duplicate that was never + // processed by finalizePaymentSuccess). Create them now using the same logic, + // scoped to the booking's origin→destination leg so the seatmap shows BOOKED + // only for the correct range of stops. + const originId = bs.booking.originStationId ?? bs.booking.schedule?.originStationId; + const destId = bs.booking.destinationStationId ?? bs.booking.schedule?.destinationStationId; + + const stopTimes = await tx.tripStopTime.findMany({ + where: { scheduleId }, + orderBy: { sequence: 'asc' }, + select: { stationId: true }, + }); + + const originIdx = originId ? stopTimes.findIndex(s => s.stationId === originId) : 0; + const destIdx = destId ? stopTimes.findIndex(s => s.stationId === destId) : stopTimes.length - 1; + const fromIdx = originIdx >= 0 ? originIdx : 0; + const toIdx = destIdx >= 0 ? destIdx : stopTimes.length - 1; + + const newJourney = await tx.journey.create({ + data: { + passengerId: bs.booking.passengerId, + bookingId: bs.booking.id, + status: 'CONFIRMED', + totalMinor: bs.booking.totalMinor, + currency: bs.booking.currency, + } as any, + }); + + const segments = []; + for (let i = fromIdx; i < toIdx; i++) { + segments.push({ + journeyId: newJourney.id, + scheduleId, + segmentOrder: i - fromIdx, + seatId: newSeat.id, + coachId: newSeat.coachId, + departureStationId: stopTimes[i].stationId, + arrivalStationId: stopTimes[i + 1].stationId, + }); + } + if (segments.length > 0) { + await tx.journeySegment.createMany({ data: segments, skipDuplicates: true }); + } + this.logger.log( + `No Journey for ${bs.booking.bookingRef} — created Journey + ${segments.length} segment(s) for seat ${newSeat.seatNumber}`, + ); + return; + } + + const { count } = await tx.journeySegment.updateMany({ + where: { journeyId: journey.id, scheduleId, seatId: bs.seatId }, + data: { seatId: newSeat.id }, + }); + + // Journey exists but had no segments (e.g. booking confirmed via a path + // that skipped JourneySegment creation). Create them now for the new seat + // so the seatmap reflects BOOKED. + if (count === 0) { + const originId = bs.booking.originStationId ?? bs.booking.schedule?.originStationId; + const destId = bs.booking.destinationStationId ?? bs.booking.schedule?.destinationStationId; + const stopTimes = await tx.tripStopTime.findMany({ + where: { scheduleId }, + orderBy: { sequence: 'asc' }, + select: { stationId: true }, + }); + const originIdx = originId ? stopTimes.findIndex(s => s.stationId === originId) : 0; + const destIdx = destId ? stopTimes.findIndex(s => s.stationId === destId) : stopTimes.length - 1; + const fromIdx = originIdx >= 0 ? originIdx : 0; + const toIdx = destIdx >= 0 ? destIdx : stopTimes.length - 1; + const segments = []; + for (let i = fromIdx; i < toIdx; i++) { + segments.push({ + journeyId: journey.id, + scheduleId, + segmentOrder: i - fromIdx, + seatId: newSeat.id, + coachId: newSeat.coachId, + departureStationId: stopTimes[i].stationId, + arrivalStationId: stopTimes[i + 1].stationId, + }); + } + if (segments.length > 0) { + await tx.journeySegment.createMany({ data: segments, skipDuplicates: true }); + } + this.logger.log( + `Seat reassigned: ${bs.booking.bookingRef} ` + + `${bs.seat?.seatNumber ?? bs.seatId} → ${newSeat.seatNumber} ` + + `(0 existing segments — created ${segments.length} new hop(s))`, + ); + } else { + this.logger.log( + `Seat reassigned: ${bs.booking.bookingRef} ` + + `${bs.seat?.seatNumber ?? bs.seatId} → ${newSeat.seatNumber} ` + + `(${count} segment hop(s) updated)`, + ); + } + }); + + // Mark as taken so the next booking in this batch doesn't get the same seat + assignedInBatch.add(newSeat.id); + occupied.add(newSeat.id); + + const oldSeatNumber = bs.seat?.seatNumber ?? '?'; + const origin = bs.booking.schedule?.originStation?.name ?? ''; + const dest = bs.booking.schedule?.destinationStation?.name ?? ''; + + if (bs.booking.contactPhone) { + const message = + `EDR: Your booking ${bs.booking.bookingRef} (${origin} → ${dest}): ` + + `your seat has been changed from seat ${oldSeatNumber} to seat ${newSeat.seatNumber}. ` + + `We apologize for any inconvenience.`; + await this.sms.sendSms({ to: bs.booking.contactPhone, message }).catch(() => null); + } + + this.logger.log( + `Duplicate resolved: ${bs.booking.bookingRef} seat ${oldSeatNumber} → ${newSeat.seatNumber}`, + ); + + results.push({ + bookingRef: bs.booking.bookingRef, + oldSeatNumber, + newSeatNumber: newSeat.seatNumber, + contactPhone: bs.booking.contactPhone, + }); + } + + return { + resolved: results.length, + unresolved: unresolved.length, + results, + ...(unresolved.length > 0 ? { unresolvedDetails: unresolved } : {}), + }; + } } diff --git a/apps/edr-passenger-web/backoffice/src/app/discrepancy/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/discrepancy/layout.tsx new file mode 100644 index 000000000..4b98bd933 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/discrepancy/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function DiscrepancyLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx b/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx new file mode 100644 index 000000000..9545c1d2a --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx @@ -0,0 +1,519 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { Search, Layers, ChevronDown, ChevronUp, CheckSquare, Square, AlertCircle, CheckCircle2, X, Loader2 } from 'lucide-react'; +import { seatsApi } from '@/lib/api'; +import { formatDateTime } from '@/lib/utils'; + +// ── Types ───────────────────────────────────────────────────────────────── + +interface DuplicateBooking { + bookingSeatId: string; + bookingId: string; + bookingRef: string; + passengerName: string; + contactPhone: string | null; + createdAt: string; +} + +interface DuplicateSeatGroup { + seatId: string; + seatNumber: string; + leg: number; + bookings: DuplicateBooking[]; +} + +interface AvailableSeat { + seatId: string; + seatNumber: string; +} + +interface CoachReport { + coachId: string; + coachNumber: string; + coachTypeName: string; + duplicates: DuplicateSeatGroup[]; + availableSeats: AvailableSeat[]; +} + +interface ScheduleReport { + scheduleId: string; + origin: string; + destination: string; + departureAt: string; + coaches: CoachReport[]; +} + +interface DuplicatesResponse { + date: string; + schedules: ScheduleReport[]; + totalDuplicates: number; +} + +// ── Helpers ─────────────────────────────────────────────────────────────── + +function today() { + return new Date().toISOString().slice(0, 10); +} + +function scheduleDuplicateCount(s: ScheduleReport) { + return s.coaches.reduce((sum, c) => sum + c.duplicates.length, 0); +} + +// ── Resolve modal ───────────────────────────────────────────────────────── + +interface ResolveModalProps { + schedule: ScheduleReport; + coach: CoachReport; + onClose: () => void; + onSuccess: () => void; +} + +function ResolveModal({ schedule, coach, onClose, onSuccess }: ResolveModalProps) { + // Default: pre-select all-but-first passenger in every duplicate group + const defaultSelected = new Set( + coach.duplicates.flatMap(g => g.bookings.slice(1).map(b => b.bookingSeatId)), + ); + const [selectedSeats, setSelectedSeats] = useState>(defaultSelected); + + // Coaches that have at least one available seat (pre-select all) + const coachesWithSeats = schedule.coaches.filter(c => c.availableSeats.length > 0); + const [selectedCoachIds, setSelectedCoachIds] = useState>( + new Set(coachesWithSeats.map(c => c.coachId)), + ); + + const [successMsg, setSuccessMsg] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + + const mutation = useMutation({ + mutationFn: (data: { bookingSeatIds: string[]; coachIds: string[] }) => + seatsApi.resolveDuplicates(data), + onSuccess: (res) => { + setSuccessMsg( + `${res.resolved ?? 0} passenger(s) successfully reassigned.` + + (res.unresolved > 0 ? ` ${res.unresolved} could not be resolved (no available seat).` : ''), + ); + setErrorMsg(null); + onSuccess(); + }, + onError: (err: any) => { + setErrorMsg(err?.response?.data?.message ?? 'Failed to resolve duplicates.'); + }, + }); + + function toggleBookingSeat(id: string) { + setSelectedSeats(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + } + + function toggleCoach(id: string) { + setSelectedCoachIds(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + } + + function handleAssign() { + setErrorMsg(null); + if (selectedSeats.size === 0) { + setErrorMsg('Select at least one passenger to reassign.'); + return; + } + if (selectedCoachIds.size === 0) { + setErrorMsg('Select at least one coach to source the replacement seat from.'); + return; + } + mutation.mutate({ + bookingSeatIds: [...selectedSeats], + coachIds: [...selectedCoachIds], + }); + } + + return ( +
+
+ + {/* Header */} +
+
+

+ Resolve Duplicates — {coach.coachNumber} +

+

+ {schedule.origin} → {schedule.destination} · {formatDateTime(schedule.departureAt)} +

+
+ +
+ +
+ + {/* Duplicate seat groups */} +
+

+ Duplicate seat assignments +

+

+ Check the passengers you want to reassign to a new seat. Unchecked passengers keep their current seat. +

+ + {coach.duplicates.map(group => ( +
+
+ + + Seat {group.seatNumber} — {group.bookings.length} passengers assigned + +
+
+ {group.bookings.map((b, idx) => { + const checked = selectedSeats.has(b.bookingSeatId); + return ( + + ); + })} +
+
+ ))} +
+ + {/* Coach selection */} +
+

+ Reassign to seats in +

+

+ The system picks the first available seat in the selected coaches. +

+
+ {coachesWithSeats.length === 0 ? ( +

No coaches have available seats on this schedule.

+ ) : ( + coachesWithSeats.map(c => ( + + )) + )} +
+
+ + {/* Feedback */} + {errorMsg && ( +
+ +

{errorMsg}

+
+ )} + {successMsg && ( +
+ +

{successMsg}

+
+ )} +
+ + {/* Footer */} +
+ + {!successMsg && ( + + )} +
+
+
+ ); +} + +// ── Coach card ──────────────────────────────────────────────────────────── + +interface CoachCardProps { + coach: CoachReport; + schedule: ScheduleReport; + onResolve: () => void; +} + +function CoachCard({ coach, schedule, onResolve }: CoachCardProps) { + const [expanded, setExpanded] = useState(false); + const hasDuplicates = coach.duplicates.length > 0; + + return ( +
+ {/* Card header */} +
+
+
+ {coach.coachNumber} + {coach.coachTypeName} +
+
+ {coach.availableSeats.length} available seats + {hasDuplicates && ( + + + {coach.duplicates.length} duplicate{coach.duplicates.length > 1 ? 's' : ''} + + )} +
+
+ +
+ {hasDuplicates && ( + + )} + {hasDuplicates && ( + + )} +
+
+ + {/* Expanded passenger list */} + {expanded && hasDuplicates && ( +
+ {coach.duplicates.map(group => ( +
+

+ Seat {group.seatNumber} — {group.bookings.length} passengers +

+
+ {group.bookings.map((b, idx) => ( +
+ + {idx + 1} + +
+ {b.passengerName || '—'} + {b.bookingRef} +
+ {b.contactPhone ?? '—'} +
+ ))} +
+
+ ))} +
+ )} +
+ ); +} + +// ── Main page ───────────────────────────────────────────────────────────── + +export default function DiscrepancyPage() { + const [date, setDate] = useState(today()); + const [searchDate, setSearchDate] = useState(''); + const [resolveTarget, setResolveTarget] = useState<{ schedule: ScheduleReport; coach: CoachReport } | null>(null); + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ['seat-duplicates', searchDate], + queryFn: () => seatsApi.getDuplicates(searchDate), + enabled: !!searchDate, + }); + + function handleSearch() { + if (date) setSearchDate(date); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter') handleSearch(); + } + + return ( +
+ + {/* Page header */} +
+
+ +
+
+

Seat Discrepancy

+

+ Detect and resolve duplicate seat assignments by schedule date +

+
+
+ + {/* Date picker */} +
+ setDate(e.target.value)} + onKeyDown={handleKeyDown} + className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + +
+ + {/* Error */} + {isError && ( +
+ + Failed to load duplicate seat data. Please try again. +
+ )} + + {/* Summary banner */} + {data && ( +
0 + ? 'bg-orange-50 dark:bg-orange-950/20 border-orange-200 dark:border-orange-800' + : 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800' + }`}> + {data.totalDuplicates > 0 ? ( + + ) : ( + + )} + 0 + ? 'text-orange-800 dark:text-orange-300' + : 'text-green-800 dark:text-green-300' + }`}> + {data.totalDuplicates > 0 + ? `${data.totalDuplicates} duplicate seat assignment${data.totalDuplicates > 1 ? 's' : ''} found across ${data.schedules.length} schedule${data.schedules.length > 1 ? 's' : ''} on ${data.date}` + : `No duplicate seat assignments found on ${data.date}`} + +
+ )} + + {/* Results per schedule */} + {data?.schedules.map(schedule => ( +
+ {/* Schedule header */} +
+
+

+ {schedule.origin} → {schedule.destination} +

+

+ {formatDateTime(schedule.departureAt)} · {scheduleDuplicateCount(schedule)} duplicate{scheduleDuplicateCount(schedule) !== 1 ? 's' : ''} +

+
+
+ + {/* Coach cards grid */} +
+ {schedule.coaches.map(coach => ( + setResolveTarget({ schedule, coach })} + /> + ))} +
+
+ ))} + + {/* Empty state when searched but no results */} + {data && data.schedules.length === 0 && data.totalDuplicates === 0 && searchDate && ( +
+ +

All seats are correctly assigned for {data.date}

+
+ )} + + {/* Resolve modal */} + {resolveTarget && ( + setResolveTarget(null)} + onSuccess={() => { + refetch(); + // Keep modal open to show success message; user closes manually + }} + /> + )} +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 45da6d8ca..62fa655c7 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -37,6 +37,7 @@ import { Banknote, Activity, Smartphone, + Layers, } from 'lucide-react'; import { useAuthStore } from '@/lib/auth-store'; import { cn } from '@/lib/utils'; @@ -64,7 +65,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view }, { name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view }, { name: 'Boarding', href: '/boarding', icon: LogIn, permission: PERMS.tickets.view }, - { name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view }, + { name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view }, + { name: 'Discrepancy', href: '/discrepancy', icon: Layers, permission: PERMS.seats.manage }, ] }, { diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index e8ed56195..2453eafa2 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -159,6 +159,10 @@ export const seatsApi = { undoRemove: (seatId: string) => apiClient.patch(`/seats/${seatId}/undo-remove`, {}), setMaintenance: (seatId: string, reason: string) => apiClient.post(`/seats/${seatId}/maintenance`, { reason }), clearMaintenance: (seatId: string) => apiClient.delete(`/seats/${seatId}/maintenance`), + getDuplicates: (date: string, scheduleId?: string) => + apiClient.get(`/seats/duplicates?date=${date}${scheduleId ? `&scheduleId=${scheduleId}` : ''}`), + resolveDuplicates: (data: { bookingSeatIds: string[]; coachIds: string[] }) => + apiClient.post('/seats/duplicates/resolve', data), }; // Payments API From 5a1e0dba4ddc063403d087fb59db12c0f3bb81a1 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 17 Jul 2026 07:43:17 +0000 Subject: [PATCH 11/88] Comment out payment event handling for local demos in BillingService --- .../src/modules/billing/billing.service.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 2bdc6ee16..774234000 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1034,16 +1034,16 @@ export class BillingService { // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); // billing must not simulate it. Kept commented for local demos only. - if (!result.immediateSuccess) { - await this.payment.handlePaymentEvent({ - eventType: "payment.succeeded", - eventId: `demo-${result.intentId}`, - referenceId: invoice.sourceId, - intentId: result.intentId, - providerTxnId: result.providerTxnId, - paidAt: (result.paidAt ?? new Date()).toISOString(), - }); - } + // if (!result.immediateSuccess) { + // await this.payment.handlePaymentEvent({ + // eventType: "payment.succeeded", + // eventId: `demo-${result.intentId}`, + // referenceId: invoice.sourceId, + // intentId: result.intentId, + // providerTxnId: result.providerTxnId, + // paidAt: (result.paidAt ?? new Date()).toISOString(), + // }); + // } if (result.immediateSuccess) { await this.settleByPaymentId( From e115b9b7533712a07c70a16b79039b2e467c84b2 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 12:43:54 +0000 Subject: [PATCH 12/88] fix(warehouse): resolve Load-to-Train bookings from wagon allocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Load-to-Train queue was permanently empty for real traffic. loadableTrains() and trainLoadableItems() gated on freight.train_schedule_bookings, but nothing in the application writes that table — only the demo seeders do. Real wagon allocation writes wagon_booking_allocations, reached via train_schedules -> train_sets -> train_set_wagons, so an allocated export booking never satisfied the EXISTS gate and no train ever appeared. Both queries now resolve a schedule's bookings through a shared sched_bookings CTE that unions the wagon-allocation chain with train_schedule_bookings, so real allocations show up and the seeded demo scenarios keep working. The panel already groups the returned rows by booking with their containers, so the queue now lists the train, its bookings and their containers for selection. Export flow this serves: booked -> paid -> received at the warehouse (first-mile or self-haul) -> GRN -> loaded onto the wagons allocated to the booking. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/warehouse-inventory.service.ts | 58 ++++++++++++++----- 1 file changed, 43 insertions(+), 15 deletions(-) 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 07c720cff..70abf635b 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 @@ -1542,11 +1542,38 @@ export class WarehouseInventoryService { // their already-allocated wagons. Reuses the single-item load() machinery. /** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */ + /** + * The bookings riding a train schedule. + * + * Export flow: booked -> paid -> received at the warehouse (first-mile or + * self-haul) -> GRN -> loaded onto the wagons allocated to it. A booking + * actually reaches a train through WAGON ALLOCATION + * (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations), + * which is what the allocation UI writes. `train_schedule_bookings` is only + * ever written by the demo seeders — keying off it alone left this queue + * permanently empty for real traffic — so both sources are unioned. + */ + private readonly SCHEDULE_BOOKINGS_CTE = ` + sched_bookings AS ( + SELECT ts.id AS schedule_id, wba.booking_id + FROM freight.train_schedules ts + JOIN freight.train_set_wagons tsw + ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL + JOIN freight.wagon_booking_allocations wba + ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL + WHERE ts.deleted_at IS NULL + UNION + SELECT tsb.train_schedule_id, tsb.booking_id + FROM freight.train_schedule_bookings tsb + WHERE tsb.deleted_at IS NULL + )`; + async loadableTrains(): Promise { const rows: Array< LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null } > = await this.dataSource.query( - `SELECT ts.id AS "scheduleId", + `WITH ${this.SCHEDULE_BOOKINGS_CTE} + SELECT ts.id AS "scheduleId", ts.train_number AS "trainNumber", oy.code AS "origin", dy.code AS "destination", @@ -1554,15 +1581,15 @@ export class WarehouseInventoryService { dy.country AS "destinationCountry", ts.status AS "status", ts.scheduled_departure_date AS "departureTime", - (SELECT count(*) FROM freight.train_schedule_bookings tsb + (SELECT count(*) FROM sched_bookings sb JOIN freight.warehouse_inventory inv - ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL - WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL + ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL + WHERE sb.schedule_id = ts.id AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount", - (SELECT count(*) FROM freight.train_schedule_bookings tsb + (SELECT count(*) FROM sched_bookings sb JOIN freight.warehouse_inventory inv - ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL - WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL + ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL + WHERE sb.schedule_id = ts.id AND inv.status = 'LOADED') AS "loadedCount" FROM freight.train_schedules ts LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id @@ -1570,10 +1597,10 @@ export class WarehouseInventoryService { WHERE ts.deleted_at IS NULL AND ts.status = ANY($1) AND EXISTS ( - SELECT 1 FROM freight.train_schedule_bookings tsb2 + SELECT 1 FROM sched_bookings sb2 JOIN freight.warehouse_inventory inv2 - ON inv2.booking_id = tsb2.booking_id AND inv2.deleted_at IS NULL - WHERE tsb2.train_schedule_id = ts.id AND tsb2.deleted_at IS NULL + ON inv2.booking_id = sb2.booking_id AND inv2.deleted_at IS NULL + WHERE sb2.schedule_id = ts.id AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') ) ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, @@ -1599,7 +1626,8 @@ export class WarehouseInventoryService { */ async trainLoadableItems(scheduleId: string): Promise { const rows: Array> = await this.dataSource.query( - `SELECT inv.id AS "id", + `WITH ${this.SCHEDULE_BOOKINGS_CTE} + SELECT inv.id AS "id", inv.booking_id AS "bookingId", b.reference AS "bookingReference", company.name AS "customerName", @@ -1612,9 +1640,9 @@ export class WarehouseInventoryService { wl.wagon_id AS "wagonId", wl.wagon_number AS "wagonNumber", wl.sequence_no AS "sequenceNo" - FROM freight.train_schedule_bookings tsb - JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id - JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL + FROM sched_bookings sb + JOIN freight.train_schedules ts ON ts.id = sb.schedule_id + JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id @@ -1631,7 +1659,7 @@ export class WarehouseInventoryService { ORDER BY tsw.sequence_no ASC NULLS LAST LIMIT 1 ) wl ON true - WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + WHERE sb.schedule_id = $1 AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`, [scheduleId], From 2798fed6d8bd488c67e4f2446ed16178db6f4a2f Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 13:09:20 +0000 Subject: [PATCH 13/88] fix(train-scheduling): block dispatch when allocated cargo is not loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dispatchSchedule guarded status, Djibouti departure rules, locomotives and wagons — but never checked the cargo. A train could be dispatched while the bookings allocated to it sat received in the warehouse, silently leaving them behind. Dispatch now refuses when an allocated booking has warehouse inventory in RECEIVED/STORED/READY_FOR_LOADING, naming the bookings and pointing at the two ways out: load them, or drop the wagon allocation so they ride a later train. Bookings with no inventory at all are not blocked — allocating a wagon before the goods arrive is normal planning. Also drops RESERVED from the Load-to-Train filters: reserved stock is not awaiting loading. The sched_bookings CTE moves to common/schedule-bookings.sql so the warehouse loading queue and this dispatch guard resolve a train's bookings identically — if they drift, a train departs leaving cargo the warehouse still expects to load. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/common/schedule-bookings.sql.ts | 28 +++++++++++++++ .../train-scheduling.service.ts | 34 +++++++++++++++++++ .../warehouses/warehouse-inventory.service.ts | 34 +++++-------------- 3 files changed, 70 insertions(+), 26 deletions(-) create mode 100644 apps/edr-freight-api/src/common/schedule-bookings.sql.ts diff --git a/apps/edr-freight-api/src/common/schedule-bookings.sql.ts b/apps/edr-freight-api/src/common/schedule-bookings.sql.ts new file mode 100644 index 000000000..177b8549b --- /dev/null +++ b/apps/edr-freight-api/src/common/schedule-bookings.sql.ts @@ -0,0 +1,28 @@ +/** + * SQL CTE resolving the bookings riding a train schedule, as `sched_bookings + * (schedule_id, booking_id)`. Use as: `WITH ${SCHEDULE_BOOKINGS_CTE} SELECT ...`. + * + * A booking reaches a train through WAGON ALLOCATION + * (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations), + * which is what the allocation UI writes. `train_schedule_bookings` is only ever + * written by the demo seeders, so both sources are unioned: real allocations work + * and the seeded scenarios keep working. + * + * Shared so the warehouse loading queue and the train dispatch guard agree on + * exactly which bookings are on a train — if they drift, a train can be + * dispatched leaving cargo the warehouse still thinks it should load. + */ +export const SCHEDULE_BOOKINGS_CTE = ` + sched_bookings AS ( + SELECT ts.id AS schedule_id, wba.booking_id + FROM freight.train_schedules ts + JOIN freight.train_set_wagons tsw + ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL + JOIN freight.wagon_booking_allocations wba + ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL + WHERE ts.deleted_at IS NULL + UNION + SELECT tsb.train_schedule_id, tsb.booking_id + FROM freight.train_schedule_bookings tsb + WHERE tsb.deleted_at IS NULL + )`; 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 b92a7ea90..97984be5f 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 @@ -19,6 +19,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; +import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql'; import { DataSource, EntityManager, @@ -2029,6 +2030,37 @@ export class TrainSchedulingService { return this.getTrainScheduleById(scheduleId); } + /** + * A train must not leave carrying nothing while its cargo sits in the shed. + * Blocks dispatch when a booking allocated to this train has warehouse + * inventory that never made it onto a wagon (received / stored / ready but not + * LOADED). Either load it from the warehouse Load-to-Train queue, or drop the + * booking's wagon allocation so it travels on a later train. + * + * Bookings with no warehouse inventory at all are NOT blocked — allocating a + * wagon before the goods arrive is normal planning; they simply aren't aboard. + */ + private async assertAllocatedCargoLoaded(scheduleId: string): Promise { + const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query( + `WITH ${SCHEDULE_BOOKINGS_CTE} + SELECT DISTINCT b.reference AS "reference", inv.status AS "status" + FROM sched_bookings sb + JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL + JOIN freight.warehouse_inventory inv + ON inv.booking_id = b.id AND inv.deleted_at IS NULL + WHERE sb.schedule_id = $1 + AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`, + [scheduleId], + ); + if (rows.length) { + const refs = [...new Set(rows.map((r) => r.reference ?? '?'))].join(', '); + throw new BadRequestException( + `Cannot dispatch: cargo for booking(s) ${refs} is in the warehouse but not loaded onto a wagon. ` + + `Load it from the warehouse Load-to-Train queue, or remove the booking's wagon allocation so it travels on a later train.`, + ); + } + } + async dispatchSchedule(scheduleId: string) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { @@ -2038,6 +2070,8 @@ export class TrainSchedulingService { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } await this.assertImportDjiboutiMayDepart(schedule); + // Don't leave received cargo behind on the platform. + await this.assertAllocatedCargoLoaded(scheduleId); // A locomotive may sit on many future schedules, but it can only pull one train // at a time — block dispatch while any set locomotive is out on a dispatched train. const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); 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 70abf635b..3ad71b6fc 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 @@ -3,6 +3,7 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; +import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql'; import { Booking } from '../bookings/entities/booking.entity'; import { Cargo } from '../cargoes/entities/cargoes.entity'; import { Company } from '../companies/entities/company.entity'; @@ -1543,30 +1544,11 @@ export class WarehouseInventoryService { /** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */ /** - * The bookings riding a train schedule. - * - * Export flow: booked -> paid -> received at the warehouse (first-mile or - * self-haul) -> GRN -> loaded onto the wagons allocated to it. A booking - * actually reaches a train through WAGON ALLOCATION - * (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations), - * which is what the allocation UI writes. `train_schedule_bookings` is only - * ever written by the demo seeders — keying off it alone left this queue - * permanently empty for real traffic — so both sources are unioned. + * Export flow this queue serves: booked -> paid -> received at the warehouse + * (first-mile or self-haul) -> GRN -> loaded onto the wagons allocated to the + * booking. Which bookings ride a train comes from the shared CTE. */ - private readonly SCHEDULE_BOOKINGS_CTE = ` - sched_bookings AS ( - SELECT ts.id AS schedule_id, wba.booking_id - FROM freight.train_schedules ts - JOIN freight.train_set_wagons tsw - ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL - JOIN freight.wagon_booking_allocations wba - ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL - WHERE ts.deleted_at IS NULL - UNION - SELECT tsb.train_schedule_id, tsb.booking_id - FROM freight.train_schedule_bookings tsb - WHERE tsb.deleted_at IS NULL - )`; + private readonly SCHEDULE_BOOKINGS_CTE = SCHEDULE_BOOKINGS_CTE; async loadableTrains(): Promise { const rows: Array< @@ -1585,7 +1567,7 @@ export class WarehouseInventoryService { JOIN freight.warehouse_inventory inv ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL WHERE sb.schedule_id = ts.id - AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount", + AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING')) AS "readyCount", (SELECT count(*) FROM sched_bookings sb JOIN freight.warehouse_inventory inv ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL @@ -1601,7 +1583,7 @@ export class WarehouseInventoryService { JOIN freight.warehouse_inventory inv2 ON inv2.booking_id = sb2.booking_id AND inv2.deleted_at IS NULL WHERE sb2.schedule_id = ts.id - AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') + AND inv2.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED') ) ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, [['DRAFT', 'SCHEDULED']], @@ -1660,7 +1642,7 @@ export class WarehouseInventoryService { LIMIT 1 ) wl ON true WHERE sb.schedule_id = $1 - AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') + AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED') ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`, [scheduleId], ); From d9db4cbc0cd663683acb13aa83510efd102f12bf Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 13:21:55 +0000 Subject: [PATCH 14/88] fix(train-scheduling): scope the not-loaded dispatch guard to EXPORT only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard fired on every dispatch. Loading out of an origin warehouse is an export concept — import cargo isn't loaded from a warehouse, so its warehouse inventory says nothing about what's aboard and the check would have blocked legitimate import dispatches. Derive the route direction (reusing deriveTradeDirection, as the warehouse loading queue does) and return early for anything that isn't EXPORT. Import and domestic behave exactly as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../train-scheduling.service.ts | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) 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 97984be5f..bb85b4422 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 @@ -2031,16 +2031,37 @@ export class TrainSchedulingService { } /** - * A train must not leave carrying nothing while its cargo sits in the shed. - * Blocks dispatch when a booking allocated to this train has warehouse - * inventory that never made it onto a wagon (received / stored / ready but not - * LOADED). Either load it from the warehouse Load-to-Train queue, or drop the - * booking's wagon allocation so it travels on a later train. + * EXPORT ONLY. An export train must not leave carrying nothing while its cargo + * sits in the shed: the goods are received into the origin warehouse, GRN'd and + * loaded onto the wagons allocated to the booking, so anything still in the + * warehouse at dispatch is being left behind. Blocks dispatch when an allocated + * booking has warehouse inventory that never made it onto a wagon (received / + * stored / ready but not LOADED) — either load it from the Load-to-Train queue, + * or drop the booking's wagon allocation so it rides a later train. + * + * Import/domestic are untouched: their cargo isn't loaded out of an origin + * warehouse, so warehouse inventory says nothing about what's aboard. * * Bookings with no warehouse inventory at all are NOT blocked — allocating a * wagon before the goods arrive is normal planning; they simply aren't aboard. */ private async assertAllocatedCargoLoaded(scheduleId: string): Promise { + const [route]: Array<{ originCountry: string | null; destinationCountry: string | null }> = + await this.dataSource.query( + `SELECT oy.country AS "originCountry", dy.country AS "destinationCountry" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.id = $1 AND ts.deleted_at IS NULL`, + [scheduleId], + ); + if (!route) return; + const direction = deriveTradeDirection( + { country: route.originCountry }, + { country: route.destinationCountry }, + ); + if (direction !== 'EXPORT') return; + const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query( `WITH ${SCHEDULE_BOOKINGS_CTE} SELECT DISTINCT b.reference AS "reference", inv.status AS "status" @@ -2070,7 +2091,7 @@ export class TrainSchedulingService { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } await this.assertImportDjiboutiMayDepart(schedule); - // Don't leave received cargo behind on the platform. + // Export only: don't leave received cargo behind in the warehouse. await this.assertAllocatedCargoLoaded(scheduleId); // A locomotive may sit on many future schedules, but it can only pull one train // at a time — block dispatch while any set locomotive is out on a dispatched train. From a26fa61d6685fd867a72766601fb036847a52522 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 13:32:38 +0000 Subject: [PATCH 15/88] feat(warehouse): require a GRN before export cargo can be loaded onto a train MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export chain is: paid -> received into the warehouse -> GRN raised on arrival -> loaded onto the allocated wagon. Receipt was already structural (the inventory row only exists once receive() runs) and the wagon was already required, but the GRN was merely displayed, never enforced — so cargo could be loaded and dispatched without one. - loadable now also requires a GRN, so the queue won't offer un-GRN'd cargo. - loadItemsOntoTrain skips items with no GRN, so the rule holds server-side and a hand-made API call can't bypass it. - Read the GRN from inv.grn_number (what receive() stamps) and fall back to the note only for legacy/seeded rows; it previously read the note alone, which the real receive path merely mirrors. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/warehouse-inventory.service.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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 3ad71b6fc..19b1c0159 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 @@ -1616,7 +1616,12 @@ export class WarehouseInventoryService { ct.container_number AS "containerNumber", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", inv.weight AS "weight", - substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber", + -- receive() stamps the GRN onto the row and mirrors it into the + -- note; prefer the column and fall back for legacy/seeded rows. + COALESCE( + inv.grn_number, + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) AS "grnNumber", inv.inspection_status AS "inspectionStatus", inv.status AS "status", wl.wagon_id AS "wagonId", @@ -1649,7 +1654,11 @@ export class WarehouseInventoryService { return rows.map((r) => ({ ...r, - loadable: r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId), + // Export flow: received at the warehouse -> GRN -> loaded onto its wagon. + // The row only exists once the goods were received, so requiring a GRN and + // an allocated wagon completes the chain. + loadable: + r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId) && Boolean(r.grnNumber), })); } @@ -1702,6 +1711,9 @@ export class WarehouseInventoryService { if (!item) { skip('Not assigned to this train'); continue; } if (item.status === 'LOADED') { skip('Already loaded'); continue; } if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; } + // Export: the GRN is raised when the goods arrive at the warehouse, and + // nothing rides a train without one. + if (!item.grnNumber) { skip('No GRN — receive the goods and generate the GRN first'); continue; } if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; } try { From a95667fde45c3805ecb4179783389fac38bd9b3e Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 13:44:02 +0000 Subject: [PATCH 16/88] fix(warehouse): raise a GRN on every warehouse receipt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requiring a GRN before loading only works if every path into the warehouse issues one. Two did not: autoUnloadArrived and unloadBooking created RECEIVED inventory with a null grn_number, so cargo that genuinely arrived — by first mile or self haul — would have been stuck un-loadable behind the new gate. Both now stamp a GRN, derived from the booking's trade direction, matching receive/bulkReceive/autoUnloadArrivedBookings. unloadBooking keeps an already-issued GRN when it re-unloads an existing row rather than reissuing one. Every path that creates warehouse inventory now issues a GRN, so the chain is seamless: booking arrives (first mile or self haul) -> received -> GRN -> loadable onto its allocated wagon. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/warehouse-inventory.service.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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 19b1c0159..3630aa9de 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 @@ -1032,6 +1032,8 @@ export class WarehouseInventoryService { result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' }); continue; } + // Goods reaching the warehouse always get a GRN, whichever path brought + // them in — nothing loads onto a train without one. const saved = await this.inventoryRepository.create({ warehouseId: location.warehouseId, yardId: location.yardId, @@ -1041,6 +1043,11 @@ export class WarehouseInventoryService { weight: Number(booking.weight) || 0, status: 'RECEIVED', arrivedAt: new Date(), + grnNumber: this.generateGrnNumber( + booking.tradeDirection ?? 'WH', + booking.id, + new Date(), + ), notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue', }); result.processedCount += 1; @@ -1061,6 +1068,14 @@ export class WarehouseInventoryService { /** Unload a single arrived booking into a chosen (or default) location. */ async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise { const existing = await this.inventoryRepository.findAll({ where: { bookingId } }); + // Goods reaching the warehouse always get a GRN, whichever path brought them + // in — nothing loads onto a train without one. + 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], + ); + const grnDirection = bookingRow?.tradeDirection ?? 'WH'; let location: DefaultLocation | null = dto.warehouseId && dto.yardId && dto.zoneId @@ -1081,6 +1096,10 @@ export class WarehouseInventoryService { zoneId: location.zoneId, status: 'RECEIVED', arrivedAt, + // Keep an already-issued GRN; only raise one if this row never got it. + ...(existing[0].grnNumber + ? {} + : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }), notes: dto.notes ?? existing[0].notes ?? 'Unloaded', }); return this.findById(existing[0].id); @@ -1095,6 +1114,7 @@ export class WarehouseInventoryService { weight: 0, status: 'RECEIVED', arrivedAt, + grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt), notes: dto.notes ?? 'Unloaded', }); return this.findById(saved.id); From fc636fbe4f77945c0ae3368041e789f8ce5a61b2 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 13:45:50 +0000 Subject: [PATCH 17/88] fix(warehouse): scope the arrival GRN stamp to EXPORT only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit made autoUnloadArrived and unloadBooking raise a GRN for any direction, which changed import behaviour. Import keeps its own GRN handling (autoUnloadArrivedBookings) and is left exactly as it was. Both paths now stamp a GRN only when the booking is EXPORT — the direction whose cargo needs one to be loaded onto a train. Import and domestic behave as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/warehouse-inventory.service.ts | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) 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 3630aa9de..c489b6f89 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 @@ -1032,8 +1032,8 @@ export class WarehouseInventoryService { result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' }); continue; } - // Goods reaching the warehouse always get a GRN, whichever path brought - // them in — nothing loads onto a train without one. + // EXPORT goods get their GRN on arrival at the warehouse — nothing loads + // onto a train without one. Import GRN handling is left untouched. const saved = await this.inventoryRepository.create({ warehouseId: location.warehouseId, yardId: location.yardId, @@ -1043,11 +1043,9 @@ export class WarehouseInventoryService { weight: Number(booking.weight) || 0, status: 'RECEIVED', arrivedAt: new Date(), - grnNumber: this.generateGrnNumber( - booking.tradeDirection ?? 'WH', - booking.id, - new Date(), - ), + ...(booking.tradeDirection === 'EXPORT' + ? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) } + : {}), notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue', }); result.processedCount += 1; @@ -1068,14 +1066,14 @@ export class WarehouseInventoryService { /** Unload a single arrived booking into a chosen (or default) location. */ async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise { const existing = await this.inventoryRepository.findAll({ where: { bookingId } }); - // Goods reaching the warehouse always get a GRN, whichever path brought them - // in — nothing loads onto a train without one. + // EXPORT goods get their GRN on arrival at the warehouse — nothing loads onto + // a train without one. Import GRN handling is left untouched. 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], ); - const grnDirection = bookingRow?.tradeDirection ?? 'WH'; + const isExport = bookingRow?.tradeDirection === 'EXPORT'; let location: DefaultLocation | null = dto.warehouseId && dto.yardId && dto.zoneId @@ -1096,10 +1094,10 @@ export class WarehouseInventoryService { zoneId: location.zoneId, status: 'RECEIVED', arrivedAt, - // Keep an already-issued GRN; only raise one if this row never got it. - ...(existing[0].grnNumber - ? {} - : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }), + // Export only, and keep an already-issued GRN rather than reissuing. + ...(isExport && !existing[0].grnNumber + ? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) } + : {}), notes: dto.notes ?? existing[0].notes ?? 'Unloaded', }); return this.findById(existing[0].id); @@ -1114,7 +1112,9 @@ export class WarehouseInventoryService { weight: 0, status: 'RECEIVED', arrivedAt, - grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt), + ...(isExport + ? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) } + : {}), notes: dto.notes ?? 'Unloaded', }); return this.findById(saved.id); From bd81bbfc2c4baff04a81194c652189b219f80099 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 17 Jul 2026 11:15:58 +0300 Subject: [PATCH 18/88] style: sidebar improvmetn --- apps/edr-freight-web/backoffice/src/App.tsx | 432 +++++++++--------- .../src/components/layout/FreightSidebar.tsx | 34 +- .../backoffice/src/components/layout/types.ts | 2 +- 3 files changed, 237 insertions(+), 231 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 0da9fe3f0..81a8552e7 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -18,6 +18,7 @@ import { Send, Settings, ShieldCheck, + Settings2, Ship, SlidersHorizontal, Train, @@ -142,14 +143,9 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , }, { - label: "Staff", - href: "/user-management", - icon: , - }, - { - label: "Bookings", - href: "/dashboard/booking-requests", - icon: , + label: "Customers", + href: "/dashboard/customers", + icon: , }, { label: "Contracts", @@ -157,6 +153,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.contracts.view, }, + { + label: "Bookings", + href: "/dashboard/booking-requests", + icon: , + }, // Operations hub: clearance-document review for contracts WITHOUT // customs clearing (contract-level for one-time, per-booking for general). { @@ -165,11 +166,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.contracts.opsClearanceReview, }, - { - label: "Customers", - href: "/dashboard/customers", - icon: , - }, { label: "Payments", href: "/dashboard/payments", @@ -186,183 +182,185 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ ], }, { - title: "Operations", + // title: "Port & Terminal", items: [ { - label: "Clearance", - href: "/dashboard/contracts/clearance", - icon: , - permission: [ - FREIGHT_PERMS.contracts.clearanceReview, - FREIGHT_PERMS.contracts.clearanceEtActions, + label: "Operations", + icon: , + children: [ + { + label: "Clearance", + href: "/dashboard/contracts/clearance", + icon: , + permission: [ + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + ], + }, + { + label: "Shipment Requests", + href: "/dashboard/shipment-requests", + icon: , + permission: FREIGHT_PERMS.contracts.createBooking, + }, + // Operations Path A queue: per-booking self-clearance review for + // GENERAL non-customs booking instances (and legacy self-clear bookings). + { + label: "Self-Clearance Review", + href: "/dashboard/contracts/ops-clearance", + icon: , + permission: FREIGHT_PERMS.contracts.opsClearanceReview, + }, + { + label: "GL Djibouti Clearance", + href: "/dashboard/gl-djibouti/clearance", + icon: , + permission: FREIGHT_PERMS.contracts.clearanceDjActions, + }, + { + label: "Train Schedules", + href: "/dashboard/operations/train-scheduling-v2", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "Batch Board", + href: "/dashboard/operations/batch-board", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "First Mile", + href: "/dashboard/operations/first-mile", + icon: , + permission: FREIGHT_PERMS.firstMile.view, + }, + { + label: "Last Mile", + href: "/dashboard/operations/last-mile", + icon: , + permission: FREIGHT_PERMS.lastMile.view, + }, ], }, { - label: "Shipment Requests", - href: "/dashboard/shipment-requests", - icon: , - permission: FREIGHT_PERMS.contracts.createBooking, - }, - // Operations Path A queue: per-booking self-clearance review for - // GENERAL non-customs booking instances (and legacy self-clear bookings). - { - label: "Self-Clearance Review", - href: "/dashboard/contracts/ops-clearance", - icon: , - permission: FREIGHT_PERMS.contracts.opsClearanceReview, - }, - { - label: "GL Djibouti Clearance", - href: "/dashboard/gl-djibouti/clearance", - icon: , - permission: FREIGHT_PERMS.contracts.clearanceDjActions, - }, - { - label: "Train Schedules", - href: "/dashboard/operations/train-scheduling-v2", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "Batch Board", - href: "/dashboard/operations/batch-board", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "First Mile", - href: "/dashboard/operations/first-mile", + label: "Fleet Management", icon: , - permission: FREIGHT_PERMS.firstMile.view, - }, - { - label: "Last Mile", - href: "/dashboard/operations/last-mile", - icon: , - permission: FREIGHT_PERMS.lastMile.view, - }, - ], - }, - { - title: "Fleet Management", - items: [ - { - label: "Fleet Dashboard", - href: "/dashboard/fleet-dashboard", - icon: , - permission: FREIGHT_PERMS.fleetDashboard.view, - }, - { - label: "Routes", - href: "/dashboard/routes", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Locomotives", - href: "/dashboard/locomotives", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Train Builder", - href: "/dashboard/train-builder", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, + children: [ + { + label: "Fleet Dashboard", + href: "/dashboard/fleet-dashboard", + icon: , + permission: FREIGHT_PERMS.fleetDashboard.view, + }, + { + label: "Routes", + href: "/dashboard/routes", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Locomotives", + href: "/dashboard/locomotives", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Train Builder", + href: "/dashboard/train-builder", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, - // { - // label: "Wagon types", - // href: "/dashboard/wagon-types", - // icon: , - // }, - { - label: "Wagons", - href: "/dashboard/wagons", - icon: , - permission: FREIGHT_PERMS.fleet.view, + // { + // label: "Wagon types", + // href: "/dashboard/wagon-types", + // icon: , + // }, + { + label: "Wagons", + href: "/dashboard/wagons", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Vehicles", + href: "/dashboard/vehicles", + icon: , + permission: FREIGHT_PERMS.vehicles.view, + }, + { + label: "Drivers", + href: "/dashboard/drivers", + icon: , + permission: FREIGHT_PERMS.drivers.view, + }, + { + label: "Track Vehicles", + href: "/dashboard/tracking", + icon: , + permission: FREIGHT_PERMS.tracking.view, + }, + { + label: "Fuel Purchases", + href: "/dashboard/fuel-purchases", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Fuel Analytics", + href: "/dashboard/fuel-stats", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Maintenance", + href: "/dashboard/maintenance", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Work Orders", + href: "/dashboard/work-orders", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Compliance & Alerts", + href: "/dashboard/compliance", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Incidents", + href: "/dashboard/incidents", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Procurement", + href: "/dashboard/procurement", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Financial Reports", + href: "/dashboard/financial-reports", + icon: , + permission: FREIGHT_PERMS.fleetReports.view, + }, + // { + // label: "Containers", + // href: "/dashboard/containers", + // icon: , + // }, + // { + // label: "Cargoes", + // href: "/dashboard/cargoes", + // icon: , + // }, + ], }, - { - label: "Vehicles", - href: "/dashboard/vehicles", - icon: , - permission: FREIGHT_PERMS.vehicles.view, - }, - { - label: "Drivers", - href: "/dashboard/drivers", - icon: , - permission: FREIGHT_PERMS.drivers.view, - }, - { - label: "Track Vehicles", - href: "/dashboard/tracking", - icon: , - permission: FREIGHT_PERMS.tracking.view, - }, - { - label: "Fuel Purchases", - href: "/dashboard/fuel-purchases", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Fuel Analytics", - href: "/dashboard/fuel-stats", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Maintenance", - href: "/dashboard/maintenance", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Work Orders", - href: "/dashboard/work-orders", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Compliance & Alerts", - href: "/dashboard/compliance", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Incidents", - href: "/dashboard/incidents", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Procurement", - href: "/dashboard/procurement", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Financial Reports", - href: "/dashboard/financial-reports", - icon: , - permission: FREIGHT_PERMS.fleetReports.view, - }, - // { - // label: "Containers", - // href: "/dashboard/containers", - // icon: , - // }, - // { - // label: "Cargoes", - // href: "/dashboard/cargoes", - // icon: , - // }, - ], - }, - { - title: "Port & Terminal", - items: [ { label: "Imports", href: "/dashboard/import-warehouse", @@ -437,35 +435,37 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, ], }, - ], - }, - { - title: "Warehouse Management", - items: [ { - label: "Warehouse Dashboard", - href: "/dashboard/warehouse-dashboard", - icon: , - }, - { - label: "Warehouses", - href: "/dashboard/warehouses", + label: "Warehouse Management", icon: , - }, - { - label: "Allocation & Fees", - href: "/dashboard/warehouse-rules", - icon: , - }, - { - label: "Fee Invoices", - href: "/dashboard/warehouse-fee-invoices", - icon: , + children: [ + { + label: "Warehouse Dashboard", + href: "/dashboard/warehouse-dashboard", + icon: , + }, + { + label: "Warehouses", + href: "/dashboard/warehouses", + icon: , + }, + { + label: "Allocation & Fees", + href: "/dashboard/warehouse-rules", + icon: , + }, + { + label: "Fee Invoices", + href: "/dashboard/warehouse-fee-invoices", + icon: , + }, + ], }, ], }, { - title: "Administration", + title: "Freight configuration", + mutedTitle: true, items: [ { label: "File settings", @@ -485,12 +485,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.admin, }, - ], - }, - { - title: "Freight configuration", - mutedTitle: true, - items: [ { label: "Configuration", href: "/dashboard/configuration", @@ -513,6 +507,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , children: getCategorySidebarChildren("rules"), }, + + { + label: "Staff", + href: "/user-management", + icon: , + }, ], }, ]; @@ -599,7 +599,10 @@ const findActiveSidebarLabel = ( ): string | undefined => { const path = pathname.toLowerCase(); const candidates = flattenSidebarItems(sections) - .map(({ href, label }) => ({ label, href: href.split("?")[0].toLowerCase() })) + .map(({ href, label }) => ({ + label, + href: href.split("?")[0].toLowerCase(), + })) .sort((a, b) => b.href.length - a.href.length); return candidates.find( @@ -674,10 +677,7 @@ const App = () => { } /> {/* } /> */} - } - /> + } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx index 698b0fc2c..91429356c 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -92,7 +92,9 @@ const FreightSidebar = ({ walk(item.children, key); }); }; - sections.forEach((section) => walk(section.items, section.title)); + sections.forEach((section, i) => + walk(section.items, section?.title ?? "" + i++), + ); return acc; }, [sections, isHrefActive, branchActive]); @@ -126,6 +128,7 @@ const FreightSidebar = ({ opened={isOpen} classNames={navClassNames(active)} onClick={() => toggle(key)} + childrenOffset="sm" rightSection={ @@ -166,6 +169,7 @@ const FreightSidebar = ({ active={active} component={Link} classNames={navClassNames(active)} + onClick={onClose} to={item.href!} /> ); @@ -177,19 +181,21 @@ const FreightSidebar = ({ () => sections.map((section) => ( - - {section.title} - + {section.title && ( + + {section.title} + + )} {section.items.map((item, i) => - renderItem(item, itemKey(section.title, item, i)), + renderItem(item, itemKey(section.title ?? "" + i, item, i)), )} @@ -257,7 +263,7 @@ const FreightSidebar = ({ px="sm" pb="md" > - {renderedSections} + {renderedSections} ); diff --git a/apps/edr-freight-web/backoffice/src/components/layout/types.ts b/apps/edr-freight-web/backoffice/src/components/layout/types.ts index 051f28839..2129e05e5 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/types.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/types.ts @@ -12,7 +12,7 @@ export interface SidebarItem { export interface SidebarSection { /** Section label shown above a group of nav items (e.g. "Main menu"). */ - title: string; + title?: string; items: SidebarItem[]; /** When true, section title uses muted grey instead of dark text. */ mutedTitle?: boolean; From b4118e1bd42c989107df56a5fd0f7a2af152240d Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 17 Jul 2026 08:47:52 +0000 Subject: [PATCH 19/88] add export/inport number --- .../2270000000000-AddWagonTrainNumbers.ts | 28 +++++++++++++++++++ .../modules/wagons/dto/create-wagon.dto.ts | 10 +++++++ .../modules/wagons/entities/wagon.entity.ts | 8 ++++++ .../src/modules/wagons/wagons.service.ts | 2 ++ .../src/pages/fleet/config/resources.ts | 18 ++++++++++++ 5 files changed, 66 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts diff --git a/apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts b/apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts new file mode 100644 index 000000000..7050c1c40 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-wagon EXPORT/IMPORT run numbers, editable from the wagon form. + * + * Nullable with no default: a wagon is not on a run until an operator says so. + * Mirrors the width of trains.export_train_number / trains.import_train_number + * (varchar 20) so the two stay comparable. + */ +export class AddWagonTrainNumbers2270000000000 implements MigrationInterface { + name = 'AddWagonTrainNumbers2270000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS export_train_number varchar(20), + ADD COLUMN IF NOT EXISTS import_train_number varchar(20); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP COLUMN IF EXISTS export_train_number, + DROP COLUMN IF EXISTS import_train_number; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts index d1939c9f5..408b13be5 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts @@ -20,6 +20,16 @@ export class CreateWagonDto { // Tare weight and payload capacity are not accepted here: they belong to the // wagon type and are resolved through wagonTypeId. + /** EXPORT run number — odd, Ethiopia → Djibouti (e.g. 8001). */ + @IsOptional() + @IsString() + exportTrainNumber?: string; + + /** IMPORT run number — even, Djibouti → Ethiopia (e.g. 8002). */ + @IsOptional() + @IsString() + importTrainNumber?: string; + @IsOptional() @IsEnum(WagonStatus) status?: WagonStatus; diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts index b66fc3f9d..7bfb59e1d 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -43,6 +43,14 @@ export class Wagon extends BaseEntity { // Tare weight and payload capacity are properties of the wagon TYPE — read them // through `wagonType`, never off the individual wagon. + /** EXPORT run number — odd, Ethiopia → Djibouti (e.g. 8001). Null until set. */ + @Column({ name: 'export_train_number', type: 'varchar', length: 20, nullable: true }) + exportTrainNumber!: string | null; + + /** IMPORT run number — even, Djibouti → Ethiopia (e.g. 8002). Null until set. */ + @Column({ name: 'import_train_number', type: 'varchar', length: 20, nullable: true }) + importTrainNumber!: string | null; + @Column({ type: 'varchar', length: 20, default: WagonStatus.Available }) status!: WagonStatusType; 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 8c5dd83c8..a43ff0e67 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -38,6 +38,8 @@ export class WagonsService { if (dto.trainId === undefined) wagon.trainId = null; if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null; if (dto.currentYardId === undefined) wagon.currentYardId = null; + if (dto.exportTrainNumber === undefined) wagon.exportTrainNumber = null; + if (dto.importTrainNumber === undefined) wagon.importTrainNumber = null; return this.wagonRepo.save(wagon); } diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 82c1eaa58..b5bc5d415 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -271,6 +271,22 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ { id: "status", header: "Status", accessorKey: "status", format: "statusBadge" }, ], formFields: [ + // Run numbers are optional — a wagon sits in the fleet unassigned to any + // run until an operator fills these in. + { + name: "exportTrainNumber", + label: "Export train number", + type: "text", + description: "Odd — Ethiopia → Djibouti runs", + placeholder: "e.g. 8001", + }, + { + name: "importTrainNumber", + label: "Import train number", + type: "text", + description: "Even — Djibouti → Ethiopia runs", + placeholder: "e.g. 8002", + }, { name: "wagonNumber", label: "Wagon number", type: "text", required: true }, { name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" }, { name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" }, @@ -278,6 +294,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ { name: "notes", label: "Notes", type: "textarea" }, ], emptyValues: { + exportTrainNumber: "", + importTrainNumber: "", wagonNumber: "", wagonTypeId: "", currentYardId: "", From ef74bc442ab60ee779d0ad9b9f3f05ecee7e46c3 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 17 Jul 2026 08:54:24 +0000 Subject: [PATCH 20/88] fix: prevent the backoffice from approve the user before he submits --- .../modules/companies/companies.repository.ts | 74 ++++++++++++++++--- .../modules/companies/companies.service.ts | 25 +++++++ .../dto/company-stats-response.dto.ts | 3 + .../companies/dto/list-companies-query.dto.ts | 12 ++- .../companies/dto/response-company.dto.ts | 11 +++ .../src/components/customers/badges.tsx | 19 +++++ .../pages/customers/CustomerDetailPage.tsx | 50 +++++++++++-- .../src/pages/customers/CustomersPage.tsx | 49 ++++++++++-- .../backoffice/src/types/customer.ts | 33 +++++++++ 9 files changed, 253 insertions(+), 23 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index 15ca85c73..6a0365854 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -8,6 +8,27 @@ import { CompanyStatsResponseDto } from './dto/company-stats-response.dto'; @Injectable() export class CompaniesRepository extends BaseRepository { + /** + * A company still being filled in by its owner in the portal wizard: it was + * self-registered (so it has an external profile) and nobody has submitted + * onboarding yet. The row exists from the wizard's first click, carrying a + * placeholder name + TIN, so it must not be offered up for review. + * Staff-created companies have no external profiles and are never drafts. + */ + private static readonly DRAFT_SQL = `( + EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = company.id + AND ep.deleted_at IS NULL + ) + AND NOT EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = company.id + AND ep.deleted_at IS NULL + AND ep.onboarding_completed = true + ) + )`; + constructor( @InjectRepository(Company) repo: Repository, @@ -38,11 +59,22 @@ export class CompaniesRepository extends BaseRepository { async findPaginated( query: ListCompaniesQueryDto, ): Promise<{ items: Company[]; total: number }> { - const { page = 1, pageSize = 20, search, type, kind, status } = query; + const { + page = 1, + pageSize = 20, + search, + type, + kind, + status, + onboardingCompleted, + } = query; const qb = this.repository .createQueryBuilder('company') .leftJoinAndSelect('company.companyProfiles', 'companyProfiles') + // External profiles carry onboardingCompleted, which the backoffice list + // uses to flag customers still mid-onboarding (not yet reviewable). + .leftJoinAndSelect('company.profiles', 'profiles') .where('company.deleted_at IS NULL'); if (type) { @@ -57,6 +89,14 @@ export class CompaniesRepository extends BaseRepository { qb.andWhere('company.status = :status', { status }); } + if (onboardingCompleted !== undefined) { + qb.andWhere( + onboardingCompleted + ? `NOT ${CompaniesRepository.DRAFT_SQL}` + : CompaniesRepository.DRAFT_SQL, + ); + } + if (search) { const term = `%${search.trim()}%`; qb.andWhere( @@ -83,21 +123,35 @@ export class CompaniesRepository extends BaseRepository { } async getStats(): Promise { - const rows: { status: string; count: string }[] = await this.repository - .createQueryBuilder('company') - .select('company.status', 'status') - .addSelect('COUNT(*)', 'count') - .where('company.deleted_at IS NULL') - .groupBy('company.status') - .getRawMany(); + // Drafts are counted separately rather than under `pending`: they carry + // status=pending from creation, which would otherwise inflate the review + // queue's KPI with customers who haven't submitted anything yet. + const rows: { status: string; is_draft: boolean; count: string }[] = + await this.repository + .createQueryBuilder('company') + .select('company.status', 'status') + .addSelect(CompaniesRepository.DRAFT_SQL, 'is_draft') + .addSelect('COUNT(*)', 'count') + .where('company.deleted_at IS NULL') + .groupBy('company.status') + .addGroupBy(CompaniesRepository.DRAFT_SQL) + .getRawMany(); - const map = new Map(rows.map((r) => [r.status, parseInt(r.count, 10)])); - const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0); + const map = new Map(); + let onboarding = 0; + let total = 0; + for (const row of rows) { + const count = parseInt(row.count, 10); + total += count; + if (row.is_draft) onboarding += count; + else map.set(row.status, (map.get(row.status) ?? 0) + count); + } return { total, active: map.get('active') ?? 0, pending: map.get('pending') ?? 0, + onboarding, suspended: map.get('suspended') ?? 0, blacklisted: map.get('blacklisted') ?? 0, }; diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 1027de955..04b8790cd 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -372,6 +372,9 @@ export class CompaniesService { const company = await this.companiesRepo.findById(id); if (!company) throw new NotFoundException(`Company ${id} not found`); company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id); + // External profiles carry the onboarding flag the backoffice gates + // approval decisions on (see ResponseCompanyDto.onboardingCompleted). + company.profiles = await this.profilesRepo.findByCompanyId(id); return company; } @@ -962,6 +965,28 @@ export class CompaniesService { if (!existing) throw new NotFoundException(`Company profile ${profileId} not found`); + // A self-registered company is only reviewable once its owner submits the + // onboarding wizard (markOnboardingComplete) — until then its profiles are + // half-filled drafts and approving one would mint a reference against an + // application that doesn't exist yet. Staff-created companies have no + // external profiles and are exempt. + // + // Only the review decision itself is gated (a profile still awaiting one: + // Pending, or Rejected and awaiting re-approval). Profiles already in + // service stay managable so staff can suspend/blacklist them — including to + // undo an approval granted before this guard existed. + const awaitingReview = + existing.status === ProfileStatus.Pending || + existing.status === ProfileStatus.Rejected; + if (awaitingReview) { + const owners = await this.profilesRepo.findByCompanyId(existing.companyId); + if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) { + throw new BadRequestException( + "This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.", + ); + } + } + // A reference number is only minted the first time a profile is approved // (status → Active). Pending/unapproved profiles carry no reference. const patch: Partial = { status }; diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts index a6b8b3b6e..c054b3531 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts @@ -1,7 +1,10 @@ export class CompanyStatsResponseDto { total!: number; active!: number; + /** Submitted applications awaiting review. Excludes drafts. */ pending!: number; + /** Self-registered companies still working through the onboarding wizard. */ + onboarding!: number; suspended!: number; blacklisted!: number; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index 4dbb932cb..adaa12479 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; +import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; import { Transform } from "class-transformer"; import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity"; @@ -37,4 +37,14 @@ export class ListCompaniesQueryDto { @IsOptional() @IsIn(Object.values(CompanyStatus)) status?: CompanyStatus; + + @ApiPropertyOptional({ + description: + "Filter by onboarding submission. `true` = reviewable applications; " + + "`false` = drafts still in the portal wizard. Omit for both.", + }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => value === "true" || value === true) + @IsBoolean() + onboardingCompleted?: boolean; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 0c783cbcf..a05812558 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -62,6 +62,13 @@ export class ResponseCompanyDto { attributes?: Record | null; profiles?: ResponseExternalProfileDto[]; companyProfiles?: ResponseCompanyProfileDto[]; + /** + * Whether the owning portal user has submitted the onboarding wizard. + * Approval decisions are blocked while this is false. Staff-created + * companies (no external profiles) count as completed. Undefined when the + * external profiles weren't loaded. + */ + onboardingCompleted?: boolean; createdAt: Date; updatedAt: Date; @@ -84,6 +91,10 @@ export class ResponseCompanyDto { this.companyProfiles = company.companyProfiles?.map( (p) => new ResponseCompanyProfileDto(p), ); + this.onboardingCompleted = company.profiles + ? company.profiles.length === 0 || + company.profiles.some((p) => p.onboardingCompleted) + : undefined; this.createdAt = company.createdAt; this.updatedAt = company.updatedAt; } diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 76555a014..6cb6759e7 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -280,13 +280,20 @@ export function InvoiceStatusBadge({ * Transitions: pending → approve / reject-with-note | rejected → approve (override) | * active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate. * Rejecting captures a note the customer sees so they can fix and reapply. + * + * `locked` (customer hasn't submitted onboarding) withholds the review decision + * only — there's no application to judge yet, and the API rejects the call + * regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so + * an already-active profile is still managable. */ export function ProfileApprovalActions({ profileId, status, + locked = false, }: { profileId: string; status: ProfileStatus; + locked?: boolean; }) { const { mutate, isPending } = useMutation( api.customers.setProfileStatus.mutationOptions(), @@ -346,6 +353,18 @@ export function ProfileApprovalActions({ ); + // Pending/rejected are the two states awaiting a reviewer's decision — the + // exact pair the API gates on until the customer submits. + if (locked && (status === "pending" || status === "rejected")) { + return ( + + + Awaiting submission + + + ); + } + if (status === "pending") { return ( <> diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index dc682f1c7..9ae5a771b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -1,5 +1,6 @@ import { ActionIcon, + Alert, Anchor, Badge, Box, @@ -22,6 +23,7 @@ import { Download, Eye, FileText, + Hourglass, IdCard, LayoutGrid, Package, @@ -60,6 +62,7 @@ import type { CustomerDocument, CustomerPayment, } from "@/types/customer"; +import { hasSubmittedOnboarding, isOnboardingDraft } from "@/types/customer"; import type { Invoice } from "@/types/invoice"; import { DataTable, @@ -166,6 +169,13 @@ export default function CustomerDetailPage() { ); const paidCurrency = payments[0]?.currency ?? "ETB"; + // The company row is created on the wizard's first click, so a draft reaches + // this page with a placeholder name/TIN. `stillOnboarding` drives the banner + // and badge; `canReview` gates the approve/reject buttons and mirrors the + // API's rule exactly, so no button is offered that the server would reject. + const stillOnboarding = company ? isOnboardingDraft(company) : false; + const canReview = company ? hasSubmittedOnboarding(company) : true; + const profileColumns: ColumnDef[] = useMemo( () => [ { @@ -273,11 +283,12 @@ export default function CustomerDetailPage() { ), }, ], - [view], + [view, canReview], ); const bookingColumns: ColumnDef[] = useMemo( @@ -602,7 +613,13 @@ export default function CustomerDetailPage() { meta={ - + {stillOnboarding ? ( + + Onboarding in progress + + ) : ( + + )} } @@ -631,6 +648,21 @@ export default function CustomerDetailPage() { {/* OVERVIEW */} + {stillOnboarding && ( + } + title="This customer hasn't submitted their application yet" + > + They're still filling in the onboarding wizard, so the details + below are an unfinished draft — the company name and TIN are + placeholders until they reach those steps. Role profiles become + reviewable once the application is submitted. + + )} + p.status === "pending", - ).length, + // A draft's profiles are all `pending` by construction, which + // would read as a review backlog that doesn't exist yet. + label: stillOnboarding + ? "Awaiting submission" + : "Pending approval", + value: stillOnboarding + ? "—" + : company.companyProfiles.filter( + (p) => p.status === "pending", + ).length, icon: IdCard, color: "yellow", }, diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index aff353055..89325ae79 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -16,6 +16,7 @@ import { Building2, CheckCircle2, Clock, + Hourglass, Mail, Phone, RefreshCw, @@ -36,6 +37,7 @@ import { import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { api } from "@/services/api"; import type { Company, CompanyStatus } from "@/types/customer"; +import { isOnboardingDraft } from "@/types/customer"; import { DataTable, DataTableFooter, @@ -43,22 +45,39 @@ import { type ColumnDef, } from "@edr/ui-common"; +/** + * The list's segmented views. "Pending approval" means submitted-and-awaiting- + * review, so it excludes drafts — a company row exists from the onboarding + * wizard's first click and would otherwise pad the review queue. Those drafts + * get their own view instead of disappearing, so staff can still chase them. + */ +type CustomerView = "all" | "pending" | "onboarding" | "active"; + +const VIEW_FILTERS: Record< + CustomerView, + { status?: CompanyStatus; onboardingCompleted?: boolean } +> = { + all: {}, + pending: { status: "pending", onboardingCompleted: true }, + onboarding: { onboardingCompleted: false }, + active: { status: "active" }, +}; + export default function CustomersPage() { const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); const [debouncedQuery] = useDebouncedValue(query, 300); - // "" = all; otherwise a CompanyStatus to narrow the list (e.g. pending review). - const [statusFilter, setStatusFilter] = useState<"" | CompanyStatus>(""); + const [view, setView] = useState("all"); const filter = useMemo( () => ({ page: pagination.pageIndex + 1, pageSize: pagination.pageSize, search: debouncedQuery, - status: statusFilter || undefined, + ...VIEW_FILTERS[view], }), - [pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter], + [pagination.pageIndex, pagination.pageSize, debouncedQuery, view], ); const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} })); @@ -114,6 +133,17 @@ export default function CustomersPage() { id: "status", header: "Status", cell: ({ row }) => { + // A draft's profiles are all `pending` by construction, so the + // "N pending" review hint would be a lie until they submit. + if (isOnboardingDraft(row.original)) { + return ( + + + Onboarding + + + ); + } const pending = (row.original.companyProfiles ?? []).filter( (p) => p.status === "pending", ).length; @@ -206,6 +236,12 @@ export default function CustomersPage() { { label: "Companies", value: stats?.total ?? "—", icon: Users, color: "edr-green" }, { label: "Active", value: stats?.active ?? "—", icon: CheckCircle2, color: "edr-green" }, { label: "Pending", value: stats?.pending ?? "—", icon: Clock, color: "yellow" }, + { + label: "Onboarding", + value: stats?.onboarding ?? "—", + icon: Hourglass, + color: "gray", + }, { label: "Blacklisted", value: stats?.blacklisted ?? "—", @@ -243,14 +279,15 @@ export default function CustomersPage() { { - setStatusFilter(v === "all" ? "" : (v as CompanyStatus)); + setView(v as CustomerView); setPagination((prev) => ({ ...prev, pageIndex: 0 })); }} data={[ { label: "All", value: "all" }, { label: "Pending approval", value: "pending" }, + { label: "Onboarding", value: "onboarding" }, { label: "Active", value: "active" }, ]} /> diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 8fb03cf86..ef88403a9 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -146,10 +146,38 @@ export interface Company { website?: string | null; attributes?: Record | null; companyProfiles: CompanyProfile[]; + /** + * Whether the customer submitted their onboarding application. A company row + * is created on the wizard's first click, so a `pending` company with this + * false is a half-filled draft — not reviewable. Staff-created companies are + * always true. Undefined on endpoints that don't load external profiles. + */ + onboardingCompleted?: boolean; createdAt: string; updatedAt: string; } +/** + * Whether the customer has submitted their onboarding application. Mirrors the + * API's review gate (`setCompanyProfileStatus`): until this is true, a role + * awaiting a decision cannot be approved or rejected. Companies loaded without + * external profiles (`undefined`) are treated as submitted — absence of the + * flag must not lock staff out. + */ +export function hasSubmittedOnboarding(company: Company): boolean { + return company.onboardingCompleted !== false; +} + +/** + * A pristine draft: still `pending` and never submitted, so its name/TIN are + * placeholders and there is nothing to review. Drives presentation only — the + * approval gate is `hasSubmittedOnboarding`, which also covers the (corrupted) + * case of a company activated before that gate existed. + */ +export function isOnboardingDraft(company: Company): boolean { + return company.status === "pending" && !hasSubmittedOnboarding(company); +} + /** Query parameters for the company list. */ export interface CompanyListFilter { page: number; @@ -158,6 +186,8 @@ export interface CompanyListFilter { type?: CompanyType; kind?: CompanyKind; status?: CompanyStatus; + /** `true` = submitted applications only; `false` = drafts only; omit for both. */ + onboardingCompleted?: boolean; } /** Standard paginated list envelope (matches the bookings service shape). */ @@ -170,7 +200,10 @@ export interface PaginatedCompanies { export interface CompanyStats { total: number; active: number; + /** Submitted applications awaiting review. Excludes drafts. */ pending: number; + /** Self-registered companies still working through the onboarding wizard. */ + onboarding: number; suspended: number; blacklisted: number; } From 1abf71306ea3f05b5ed089fcb85887b0f6d931d2 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 17 Jul 2026 09:15:21 +0000 Subject: [PATCH 21/88] add export/inport number --- .../src/components/fleet/FleetFormDialog.tsx | 27 +++++++++++- .../src/pages/fleet/config/resources.ts | 41 ++++++++++++++++++- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx index 7262e7927..dfb27ed36 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx @@ -271,8 +271,14 @@ const FleetFormDialog = ({ return; } if (!validate()) return; + // Derived fields are never edited, so form state for them can be stale (or + // seeded from the record) — recompute before building the payload. + const submitted: Record = { ...values }; + fields.forEach((field) => { + if (field.derivedValue) submitted[field.name] = field.derivedValue(values); + }); const payload = Object.fromEntries( - Object.entries(values) + Object.entries(submitted) .map(([key, value]) => { if (value === FLEET_SELECT_NONE || value === "") return [key, undefined]; @@ -294,6 +300,23 @@ const FleetFormDialog = ({ // only by verification and never hand-edited. const isDisabled = Boolean(field.disabled || field.faydaLocked); + // Computed from other fields (e.g. the import run implied by the export + // run) — read-only, and recomputed here rather than read from form state. + if (field.derivedValue) { + return ( + + ); + } + if (field.type === "radio") { return ( ) => string; /** * Field is owned by the Fayda identity — populated only by verification and * never hand-edited. Rendered disabled in the form. @@ -116,6 +123,32 @@ const WAGON_STATUS_OPTIONS = [ { label: "Detained", value: Freight.WagonStatus.Detained }, ]; +/** + * EDR run-number pairs, keyed by the odd EXPORT run (Ethiopia → Djibouti). The + * even IMPORT run (Djibouti → Ethiopia) is fixed by the export run, so choosing + * an export number fully determines the import one. Listed explicitly rather + * than computed as export+1, so a pair that ever breaks that convention stays + * correct here. + */ +const TRAIN_RUN_PAIRS: Record = { + "8001": "8002", + "81001": "81002", + "82001": "82002", + "83001": "83002", + "84001": "84002", + "85001": "85002", + "86001": "86002", + "87001": "87002", + "88001": "88002", + "8901": "8902", + "9001": "9002", +}; + +const EXPORT_TRAIN_OPTIONS = Object.keys(TRAIN_RUN_PAIRS).map((run) => ({ + label: run, + value: run, +})); + export const FLEET_RESOURCES: FleetResourceConfig[] = [ @@ -272,13 +305,15 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ ], formFields: [ // Run numbers are optional — a wagon sits in the fleet unassigned to any - // run until an operator fills these in. + // run until an operator picks an export run. The import run is fixed by + // that choice, so it is derived rather than typed. { name: "exportTrainNumber", label: "Export train number", - type: "text", + type: "select", description: "Odd — Ethiopia → Djibouti runs", placeholder: "e.g. 8001", + options: EXPORT_TRAIN_OPTIONS, }, { name: "importTrainNumber", @@ -286,6 +321,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ type: "text", description: "Even — Djibouti → Ethiopia runs", placeholder: "e.g. 8002", + derivedValue: (values) => + TRAIN_RUN_PAIRS[String(values.exportTrainNumber ?? "")] ?? "", }, { name: "wagonNumber", label: "Wagon number", type: "text", required: true }, { name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" }, From 7b7e7c3f620c0bc1d6cc966bd36e75ba7f368c0c Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 17 Jul 2026 09:16:35 +0000 Subject: [PATCH 22/88] fix schule issue and contianer type issue --- ...290000000000-DropContainerWagonsPerUnit.ts | 29 +++++ .../bookings/booking-pricing.service.ts | 8 +- .../booking-reference-data.service.ts | 1 - .../modules/bookings/bookings.repository.ts | 8 +- .../src/modules/bookings/bookings.service.ts | 3 +- .../modules/bookings/consolidation.service.ts | 10 +- .../dto/booking-reference-data.dto.ts | 3 - .../contracts/contract-booking.service.ts | 7 +- .../rule-engine/container-type.util.ts | 15 +++ .../priority-configs.controller.ts | 3 +- .../dto/create-container-type.dto.ts | 9 +- .../entities/container-type.entity.ts | 3 - .../services/container-types.service.ts | 1 - .../services/priority-configs.range.spec.ts | 46 +++---- .../services/priority-configs.service.ts | 41 ++---- .../booking-batch.service.spec.ts | 109 +++++++++++++++- .../train-scheduling/booking-batch.service.ts | 118 +++++++++++++++--- .../train-scheduling/fleet-plan.util.ts | 2 +- .../train-scheduling.service.ts | 51 +++++++- .../wagon-plan-flex.util.spec.ts | 1 - .../train-scheduling/wagon-plan.util.spec.ts | 6 +- .../train-scheduling/wagon-plan.util.ts | 33 ++--- .../scripts/seed-gate-pass-train-scenarios.ts | 1 - .../seed-negad-indode-arrived-train.ts | 1 - .../seed-warehouse-export-receive-ready.ts | 3 +- ...ved-first-lastmile-demo-bookings.seeder.ts | 4 +- .../src/seed/demo-bookings.seeder.ts | 4 +- .../paid-import-export-mile-demo.seeder.ts | 4 +- .../src/seed/pricing-data.seeder.ts | 4 - apps/edr-freight-web/backoffice/src/App.tsx | 24 ++-- .../trainScheduling/AdjustConsistModal.tsx | 90 ++++++++++++- .../containerPlacement.util.spec.ts | 2 - .../src/pages/bookings/NewBookingPage.tsx | 1 - .../src/pages/ruleEngine/config/resources.ts | 2 +- .../src/pages/ruleEngine/priorityRuleRange.ts | 26 ++-- .../backoffice/src/services/api.ts | 3 +- .../src/services/trainBuilder.service.ts | 17 ++- .../backoffice/src/types/trainScheduling.ts | 1 - packages/types/src/freight/index.ts | 1 - 39 files changed, 508 insertions(+), 187 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/container-type.util.ts diff --git a/apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts b/apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts new file mode 100644 index 000000000..ed66c37d3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * container_types.wagons_per_unit is no longer stored: the wagon fraction is + * derived from size_ft everywhere (40ft = 1.00 wagon, 20ft = 0.50 — two per + * wagon; see rule-engine/container-type.util.ts). The stored value duplicated + * that rule and could silently drift from it. + */ +export class DropContainerWagonsPerUnit2290000000000 implements MigrationInterface { + name = 'DropContainerWagonsPerUnit2290000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagons_per_unit; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.container_types + ADD COLUMN IF NOT EXISTS wagons_per_unit numeric(4,2); + `); + // Backfill from the same size rule the code now derives from. + await queryRunner.query(` + UPDATE freight.container_types + SET wagons_per_unit = CASE WHEN size_ft >= 40 THEN 1.00 ELSE 0.50 END; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 091232611..9a1f127e8 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -10,11 +10,9 @@ import { BookingEvaluationInput, RuleEngineService, } from '../rule-engine/rule-engine.service'; +import { containersPerWagonForSize } from '../rule-engine/container-type.util'; import { BookingsRepository } from './bookings.repository'; -import { - containersPerWagon, - wagonRemainder, -} from './consolidation.service'; +import { wagonRemainder } from './consolidation.service'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; @@ -308,7 +306,7 @@ export class BookingPricingService { totalVgmTons: qty * vgm, isReefer: ct.isReefer, }, - perWagon: containersPerWagon(Number(ct.wagonsPerUnit)), + perWagon: containersPerWagonForSize(ct.sizeFt), quantity: qty, }; }), diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index 6e96dc6f8..a978fd22b 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -110,7 +110,6 @@ export function groupContainersBySize( name: ct.label?.trim() ? ct.label : ct.code, code: ct.code, is_reefer: ct.isReefer ?? false, - wagons_per_unit: Number(ct.wagonsPerUnit ?? 1), }), ), })); 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 72211925f..aeae7d10a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -4,6 +4,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Contract } from '../contracts/entities/contract.entity'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; @@ -149,7 +150,7 @@ export class BookingsRepository extends BaseRepository { for (const item of containers) { const ct = await typeRepo.findOne({ where: { id: item.containerTypeId } }); - const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1; + const wagonsPerUnit = wagonsPerUnitForSize(ct?.sizeFt); const totalVgm = item.quantity * item.vgmPerUnitTons; const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit); // A per-line breakdown can never exceed the line's own quantity. @@ -179,7 +180,10 @@ export class BookingsRepository extends BaseRepository { async calculateWagonCount(bookingId: string): Promise { const result = await this.dataSource .createQueryBuilder() - .select('CEILING(SUM(bc.quantity * ct.wagons_per_unit))', 'total') + .select( + 'CEILING(SUM(bc.quantity * CASE WHEN ct.size_ft >= 40 THEN 1 WHEN ct.size_ft > 0 THEN 0.5 ELSE 1 END))', + 'total', + ) .from(BookingContainer, 'bc') .innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id') .where('bc.booking_id = :bookingId', { bookingId }) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 588e4f1ed..d9dd53f94 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -18,6 +18,7 @@ import { TrainSchedulingService } from '../train-scheduling/train-scheduling.ser import { eatDay } from '../train-scheduling/batch-window.util'; import { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { BookingEvaluationInput, @@ -438,7 +439,7 @@ export class BookingsService { vgmPerUnitTons: c.vgmPerUnitTons, totalVgmTons, isReefer: ct.isReefer, - wagonsRequired: c.quantity * (Number(ct.wagonsPerUnit) || 1), + wagonsRequired: c.quantity * wagonsPerUnitForSize(ct.sizeFt), }; }), ); diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts index e16b97997..9a87054e2 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { containersPerWagonForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { Booking } from './entities/booking.entity'; @@ -19,13 +20,6 @@ export interface ConsolidationAttemptResult { messages: string[]; } -/** Containers that fit on one wagon for a given container type (inverse of wagons_per_unit). */ -export function containersPerWagon(wagonsPerUnit: number): number { - const wpu = Number(wagonsPerUnit); - if (!wpu || wpu <= 0) return 1; - return Math.max(1, Math.round(1 / wpu)); -} - export function wagonRemainder(quantity: number, perWagon: number): number { const r = quantity % perWagon; return r; @@ -73,7 +67,7 @@ export class ConsolidationService { const slots: ConsolidationSlot[] = []; for (const [containerTypeId, quantity] of quantityByType) { const ct = await this.containerTypesService.findById(containerTypeId); - const perWagon = containersPerWagon(Number(ct.wagonsPerUnit)); + const perWagon = containersPerWagonForSize(ct.sizeFt); const remainder = wagonRemainder(quantity, perWagon); if (remainder === 0) continue; slots.push({ diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts index c930d7aa1..a793cc558 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts @@ -27,9 +27,6 @@ export class BookingReferenceContainerTypeDto { @ApiProperty() is_reefer!: boolean; - - @ApiProperty({ example: 0.5, description: 'Wagon fraction per container' }) - wagons_per_unit!: number; } export class BookingReferenceContainerSizeGroupDto { 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 a0c2b42a4..826bc33b0 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 @@ -25,6 +25,7 @@ import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; @@ -1053,7 +1054,7 @@ export class ContractBookingService { bc.quantity = line.quantity; bc.containerTypeId = ct.id; bc.containerType = ct; - bc.wagonsRequired = Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)); + bc.wagonsRequired = Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)); bc.totalVgmTons = (line.units ?? []).reduce( (sum, u) => sum + Number(u.vgmTons ?? 0), 0, @@ -1513,7 +1514,7 @@ export class ContractBookingService { : 0, vgmPerUnitTons: vgmPerUnit, totalVgmTons: totalVgm, - wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)), + wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)), isOverweight: false, overweightExcessTons: null, } as Partial), @@ -1651,7 +1652,7 @@ export class ContractBookingService { : 0, vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0, totalVgmTons, - wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)), + wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)), }), ), }) as Booking; diff --git a/apps/edr-freight-api/src/modules/rule-engine/container-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/container-type.util.ts new file mode 100644 index 000000000..ed64c1edb --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/container-type.util.ts @@ -0,0 +1,15 @@ +/** + * Wagon fraction one container occupies, derived from its size: 40ft = 1 wagon, + * 20ft = 0.5 (two per wagon). Unknown size reads as a whole wagon so counts + * never under-book. + */ +export function wagonsPerUnitForSize(sizeFt?: number | null): number { + const size = Number(sizeFt); + if (!Number.isFinite(size) || size <= 0) return 1; + return size >= 40 ? 1 : 0.5; +} + +/** Containers that fit on one wagon for a given container size (inverse of the wagon fraction). */ +export function containersPerWagonForSize(sizeFt?: number | null): number { + return Math.max(1, Math.round(1 / wagonsPerUnitForSize(sizeFt))); +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts index 6424f013e..0b3334431 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts @@ -29,8 +29,7 @@ export class PriorityConfigsController { @Get('next-range') @RuleEngineView('priority-configs') @ApiOperation({ - summary: - "Where the next contiguous range for a type (and currency) must start, plus the type's ceiling", + summary: 'Where the next contiguous range for a type (and currency) must start', }) nextRange( @Query('type') type: 'WAGON' | 'CURRENCY' | 'CUSTOMS', diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts index a01ba4b5b..379997e6d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts @@ -1,6 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; +import { IsArray, IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; export class CreateContainerTypeDto { @ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 }) @@ -14,12 +13,6 @@ export class CreateContainerTypeDto { @Max(40) sizeFt!: number; - @ApiProperty({ description: 'Wagon fraction per container: 0.50 for 20ft, 1.00 for 40ft' }) - @IsNumber() - @Min(0.01) - @Transform(({ value }) => Number(value)) - wagonsPerUnit!: number; - @ApiPropertyOptional({ default: false, description: 'True if this is a reefer (refrigerated) container' }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts index 2347426ca..642f6942e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts @@ -16,9 +16,6 @@ export class ContainerType extends BaseEntity { @Column({ name: 'size_ft', type: 'smallint', nullable: true }) sizeFt!: number; - @Column({ name: 'wagons_per_unit', type: 'numeric', precision: 4, scale: 2, nullable: true }) - wagonsPerUnit!: number; - @Column({ name: 'is_reefer', type: 'boolean', default: false, nullable: true }) isReefer!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts index 42ce389e1..4c40cae4b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts @@ -48,7 +48,6 @@ export class ContainerTypesService { code, label: dto.label, sizeFt: dto.sizeFt, - wagonsPerUnit: dto.wagonsPerUnit, isReefer: dto.isReefer ?? false, isOpenTop: dto.isOpenTop ?? false, isActive: dto.isActive ?? true, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts index 53cb7ed83..0b0ef1b0d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts @@ -5,9 +5,8 @@ import { PriorityConfigsService } from './priority-configs.service'; /** * Contiguous-range rules for priority configs: per type (per currency for - * CURRENCY), ranges run 1..cap with no gaps and no overlaps; the next range - * must start at the lowest uncovered wagon count. Caps: WAGON 50, - * CURRENCY 35, CUSTOMS 15. + * CURRENCY), ranges run from 1 with no gaps and no overlaps; the next range + * must start at the lowest uncovered wagon count. There is no upper ceiling. */ describe('PriorityConfigsService range validation', () => { const rule = ( @@ -118,41 +117,47 @@ describe('PriorityConfigsService range validation', () => { ).rejects.toThrow(/overlaps existing rule/); }); - it('enforces the per-type ceilings (WAGON 50, CURRENCY 35, CUSTOMS 15)', async () => { + it('imposes no upper ceiling on any type', async () => { await expect( - attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 51 }), - ).rejects.toThrow(/may not exceed 50/); + attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 5000 }), + ).resolves.toBeUndefined(); await expect( attempt(serviceWith([]), { type: 'CURRENCY', currency: 'USD', minWagonCount: 1, - maxWagonCount: 36, + maxWagonCount: 5000, }), - ).rejects.toThrow(/may not exceed 35/); + ).resolves.toBeUndefined(); await expect( attempt(serviceWith([]), { type: 'CUSTOMS', minWagonCount: 1, - maxWagonCount: 16, + maxWagonCount: 5000, }), - ).rejects.toThrow(/may not exceed 15/); + ).resolves.toBeUndefined(); }); - it('rejects any new rule once the chain covers the full range', async () => { + it('keeps extending the chain past the old caps', async () => { await expect( attempt(serviceWith([rule('WAGON', 1, 50)]), { minWagonCount: 51, - maxWagonCount: 51, + maxWagonCount: 120, }), - ).rejects.toThrow(/may not exceed 50/); + ).resolves.toBeUndefined(); await expect( attempt(serviceWith([rule('CUSTOMS', 1, 15)]), { type: 'CUSTOMS', - minWagonCount: 1, - maxWagonCount: 1, + minWagonCount: 16, + maxWagonCount: 99, }), - ).rejects.toThrow(/already cover the full 1–15 range/); + ).resolves.toBeUndefined(); + }); + + it('still rejects a min greater than the max', async () => { + await expect( + attempt(serviceWith([]), { minWagonCount: 9, maxWagonCount: 4 }), + ).rejects.toThrow(BadRequestException); }); it('tracks CURRENCY chains per currency — USD and ETB are independent', async () => { @@ -214,16 +219,13 @@ describe('PriorityConfigsService range validation', () => { it('reports the next-range prefill for the form', async () => { const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]); - await expect(svc.nextRange('WAGON')).resolves.toEqual({ - nextMin: 6, - maxCap: 50, - }); + await expect(svc.nextRange('WAGON')).resolves.toEqual({ nextMin: 6 }); + // Past the old CUSTOMS cap of 15 the chain simply continues. await expect( serviceWith([rule('CUSTOMS', 1, 15)]).nextRange('CUSTOMS'), - ).resolves.toEqual({ nextMin: null, maxCap: 15 }); + ).resolves.toEqual({ nextMin: 16 }); await expect(serviceWith([]).nextRange('CURRENCY', 'USD')).resolves.toEqual({ nextMin: 1, - maxCap: 35, }); }); }); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts index ff3711e42..274ce687f 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts @@ -10,28 +10,19 @@ import { } from '../interfaces/priority-configs.repository.interface'; import { DisplayOrderService } from './display-order.service'; -/** Hard ceiling of each type's wagon-count chain (1..cap, contiguous). */ -export const RANGE_CAPS: Record<'WAGON' | 'CURRENCY' | 'CUSTOMS', number> = { - WAGON: 50, - CURRENCY: 35, - CUSTOMS: 15, -}; - /** * Lowest wagon count ≥ 1 not covered by any of `rules` — where the next range - * must start. Null when the chain is already complete up to the type's cap. + * must start. The chain is unbounded above, so there is always a next start. */ function nextRangeStart( rules: Pick[], -): number | null { - const cap = rules.length ? RANGE_CAPS[rules[0].type] : null; +): number { const sorted = [...rules].sort((a, b) => a.minWagonCount - b.minWagonCount); let next = 1; for (const r of sorted) { if (r.minWagonCount > next) break; // gap before this rule — fill it next = Math.max(next, r.maxWagonCount + 1); } - if (cap != null && next > cap) return null; return next; } @@ -102,8 +93,8 @@ export class PriorityConfigsService { * - ranges never overlap — a booking matches at most one rule per type; * - ranges are contiguous from 1: a new range must START at the lowest * wagon count not yet covered (after 1–5 the next is 6–…; deleting a - * middle rule opens a gap and the next create must fill it first); - * - each type has a hard ceiling: WAGON 50, CURRENCY 35, CUSTOMS 15. + * middle rule opens a gap and the next create must fill it first). + * There is no upper ceiling — max wagon count is unbounded. * Ranges are inclusive on both ends. */ async assertNoRangeCollision(input: { @@ -118,14 +109,6 @@ export class PriorityConfigsService { 'Min wagon count cannot be greater than max wagon count', ); } - const cap = RANGE_CAPS[input.type]; - if (input.maxWagonCount > cap) { - throw new BadRequestException( - `${input.type} ranges may not exceed ${cap} — ` + - `${input.minWagonCount}–${input.maxWagonCount} goes past the ceiling.`, - ); - } - const siblings = ( await this.repository.findAll({ where: { type: input.type } }) ).filter( @@ -142,12 +125,6 @@ export class PriorityConfigsService { const currentStart = input.excludeId ? (await this.repository.findById(input.excludeId))?.minWagonCount ?? null : null; - if (expectedStart == null && currentStart == null) { - throw new BadRequestException( - `${input.type} rules already cover the full 1–${cap} range — ` + - 'delete or shrink an existing rule first.', - ); - } if ( input.minWagonCount !== expectedStart && input.minWagonCount !== currentStart @@ -174,21 +151,21 @@ export class PriorityConfigsService { } /** - * Where the next range for a type/currency must start, and the type's - * ceiling — feeds the create form so the min field is auto-filled and - * locked. `nextMin` is null when the chain already covers 1..cap. + * Where the next range for a type/currency must start — feeds the create + * form so the min field is auto-filled and locked. Always a number: the + * chain has no ceiling, so another range always fits. */ async nextRange( type: 'WAGON' | 'CURRENCY' | 'CUSTOMS', currency?: string | null, - ): Promise<{ nextMin: number | null; maxCap: number }> { + ): Promise<{ nextMin: number }> { const siblings = ( await this.repository.findAll({ where: { type } }) ).filter( (s) => type !== 'CURRENCY' || (s.currency ?? null) === (currency ?? null), ); - return { nextMin: nextRangeStart(siblings), maxCap: RANGE_CAPS[type] }; + return { nextMin: nextRangeStart(siblings) }; } async remove(id: string): Promise { 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 8c3c92193..c19140d8a 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 @@ -887,7 +887,7 @@ describe('BookingBatchService — wagonsFor', () => { freightType: 'CONTAINER', cargoTotalWeightVgm: 210, bookingContainers: [ - { quantity: 2, wagonsRequired: 2, containerType: { wagonsPerUnit: 1, sizeFt: 40 } }, + { quantity: 2, wagonsRequired: 2, containerType: { sizeFt: 40 } }, ], }; expect(service.wagonsFor(booking, dims)).toBe(3); @@ -899,7 +899,7 @@ describe('BookingBatchService — wagonsFor', () => { freightType: 'CONTAINER', cargoTotalWeightVgm: 40, bookingContainers: [ - { quantity: 4, wagonsRequired: 2, containerType: { wagonsPerUnit: 0.5, sizeFt: 20 } }, + { quantity: 4, wagonsRequired: 2, containerType: { sizeFt: 20 } }, ], }; expect(service.wagonsFor(booking, dims)).toBe(2); @@ -939,7 +939,7 @@ describe('BookingBatchService — wagonsFor', () => { { quantity: 2, wagonsRequired: 2, - containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] }, + containerType: { sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] }, }, ], }; @@ -950,3 +950,106 @@ describe('BookingBatchService — wagonsFor', () => { }); }); }); + +describe('BookingBatchService — built-train wagon capacity', () => { + // A schedule created from a built train is capped by its PHYSICAL consist: + // wagon count only. The locomotive here is deliberately tiny (1T / 1m) — the + // old weight/length math would call every one of these trains FULL, so any + // assertion below that says "not full" proves those axes are ignored. + const scheduleId = 'schedule-built'; + + const reservedBooking = (id: string) => + ({ + id, + freightType: 'BULK', + cargoTotalWeightVgm: 50, // 1 wagon at the 60T default bulk payload + bookingContainers: [], + originYardId: 'yard-a', + destinationYardId: 'yard-b', + }) as unknown as Booking; + + const buildService = (opts: { + physicalWagons: number; + reserved: Booking[]; + maxWagons?: number; + }) => { + const schedule = { + id: scheduleId, + maxWagons: opts.maxWagons ?? 44, // stale locomotive-derived cap on purpose + bookingWindowStatus: 'OPEN', + originStationId: 'yard-a', + destinationStationId: 'yard-b', + routeId: null, + scheduleBookings: [], + trainSet: { + locomotive: { + maxPullWeightTons: 1, + maxTrainLengthMeters: 1, + overageToleranceTons: 0, + overageToleranceMeters: 0, + }, + train: { id: 'train-built-1' }, + }, + }; + const wagonRepo = { count: jest.fn().mockResolvedValue(opts.physicalWagons) }; + const genericRepo = { + find: jest.fn().mockResolvedValue([]), + update: jest.fn().mockResolvedValue(undefined), + }; + const dataSource = { + getRepository: jest.fn((entity: { name?: string }) => + entity?.name === 'Wagon' ? wagonRepo : genericRepo, + ), + transaction: jest.fn(), + }; + const service = new BookingBatchService( + dataSource as never, + { + findReservedForSchedule: jest.fn().mockResolvedValue(opts.reserved), + } as never, + { + findByIdWithFullGraph: jest.fn().mockResolvedValue(schedule), + findById: jest.fn().mockResolvedValue(schedule), + } as never, + null as never, + null as never, + null as never, + null as never, + null as never, + { emitPhase: jest.fn() } as never, + null as never, + ); + return { service, wagonRepo }; + }; + + it('is FULL when bookings hold every physical wagon, even with loco-derived slots free', async () => { + const { service } = buildService({ + physicalWagons: 2, + reserved: [reservedBooking('b1'), reservedBooking('b2')], + maxWagons: 44, // stale: the old slot cap would say 42 slots remain + }); + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true); + }); + + it('is NOT full while physical wagons remain, ignoring weight/length limits', async () => { + const { service } = buildService({ + physicalWagons: 3, + reserved: [reservedBooking('b1'), reservedBooking('b2')], + }); + // 1T pull cap would have been exhausted long ago under the old math. + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); + }); + + it('reports over-allocation when the consist is trimmed below committed bookings', async () => { + const { service } = buildService({ + physicalWagons: 1, + reserved: [reservedBooking('b1'), reservedBooking('b2')], + }); + await expect(service.scheduleWagonUsage(scheduleId)).resolves.toEqual({ + maxWagons: 1, + allocatedWagons: 2, + remainingSlots: 0, + overAllocatedBy: 1, + }); + }); +}); 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 ef39ab138..5beb920d3 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 @@ -68,6 +68,7 @@ import { wagonTypeDimensionsFromEntity, } from './train-capacity.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { Wagon } from '../wagons/entities/wagon.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { BookingSplitService } from './booking-split.service'; import { BookingWindowGateway } from './booking-window.gateway'; @@ -305,6 +306,9 @@ export class BookingBatchService implements OnModuleInit { private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository, private readonly notifier: BookingNotifierService, private readonly scheduler: SchedulerRegistry, + // forwardRef: TrainSchedulingService injects this service back (window + // refresh after adjust-consist), so the classes load in a cycle. + @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, private readonly billing: BillingService, private readonly bookingWindowGateway: BookingWindowGateway, @@ -2915,7 +2919,7 @@ export class BookingBatchService implements OnModuleInit { ? Math.ceil(booking.wagonsRequired) : 0; - // TEU-aware: two 20ft share one wagon (wagonsPerUnit = 0.5). The old fallback + // TEU-aware: two 20ft share one wagon (half a wagon each). The old fallback // summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10. const byLength = containerWagonsForLines(booking.bookingContainers ?? []); @@ -2993,17 +2997,20 @@ export class BookingBatchService implements OnModuleInit { } /** - * Keep schedule.max_wagons aligned with the train's boarding limit: the - * locomotive's length-derived slot count. The physical wagons currently in - * the train set do NOT cap this — bookings are admitted on length/weight - * alone and yard staff attach the wagons manually before departure. + * Keep schedule.max_wagons aligned with the train's boarding limit. A built + * train's limit is its physical consist — the wagon count staff marshalled + * (and may change via adjust-consist). Only schedules WITHOUT a built train + * fall back to the locomotive's length-derived slot count, where bookings + * are admitted on length/weight alone and yard staff attach the wagons + * manually before departure. */ private async syncScheduleMaxWagons( schedule: TrainSchedule, locomotive: Locomotive, ): Promise { - const limits = await this.capacityLimits(locomotive); - const maxWagons = limits.base.wagons; + const physicalWagons = await this.builtTrainWagonCount(schedule); + const maxWagons = + physicalWagons ?? (await this.capacityLimits(locomotive)).base.wagons; if ((schedule.maxWagons ?? 0) !== maxWagons) { await this.dataSource .getRepository(TrainSchedule) @@ -3122,16 +3129,31 @@ export class BookingBatchService implements OnModuleInit { * reserved bookings already use ON THEIR OWN LEGS. A booking riding only * Dire→Djibouti leaves the Addis→Dire edges untouched. * - * The wagon axis is the locomotive's length-derived slot count only — the - * physical wagons currently marshalled in the train set do NOT cap it. - * Bookings are admitted on length/weight capacity and yard staff attach - * the missing wagons manually before wagon assignment. + * Two capacity regimes, decided by the schedule's train: + * - Built train (Train Builder consist with physical wagons): the consist IS + * the capacity. Wagon slots = physical wagon count; weight and length are + * NOT re-checked here — the builder and adjust-consist already enforced the + * locomotive's pull/length limits when the consist was assembled. + * - No built train (legacy schedules): the locomotive's length-derived slot + * count plus its weight/length budgets, as before — yard staff attach the + * missing wagons manually before wagon assignment. */ private async remainingBudget( schedule: TrainSchedule, limits: TrainLimits, wagonDims: WagonDims, ): Promise { + const physicalWagons = await this.builtTrainWagonCount(schedule); + if (physicalWagons != null) { + limits = { + base: { + wagons: physicalWagons, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }, + tolerance: { weightTons: 0, lengthMeters: 0 }, + }; + } const stops = await this.stopsForSchedule(schedule); const budget = new CorridorBudget(stops, limits.base, limits.tolerance); const allocated = (schedule.scheduleBookings ?? []) @@ -3149,6 +3171,23 @@ export class BookingBatchService implements OnModuleInit { return budget; } + /** + * Physical wagons marshalled in the schedule's built train, or null when the + * schedule has no built train (or the consist is still empty) and the legacy + * locomotive-derived capacity must apply. This count is what caps a built + * train's bookings: 50 wagons coupled → 50 wagon slots, no more. + */ + private async builtTrainWagonCount( + schedule: TrainSchedule, + ): Promise { + const trainId = schedule.trainSet?.train?.id; + if (!trainId) return null; + const count = await this.dataSource + .getRepository(Wagon) + .count({ where: { trainId } }); + return count > 0 ? count : null; + } + /** * Wagon slots still boardable somewhere on the corridor (most-open edge). * ≤ 0 means no leg can take another booking. Slot axis ONLY — the train-wide @@ -3219,11 +3258,14 @@ export class BookingBatchService implements OnModuleInit { } /** - * FULL on ANY capacity axis: out of wagon slots, or out of pull weight / - * train length for even one more loaded wagon. The old slot-only check let - * a weight-bound train (PW2: weight binds at 37 wagons = 3522.4T of - * 3500+90T, slots bind at 44) cycle its booking window forever instead of - * finalizing — 7 phantom slots kept it "not full" while nothing could board. + * Built train: FULL when every physical wagon slot is taken — the consist is + * the capacity, weight/length were settled at build time. + * No built train: FULL on ANY capacity axis — out of wagon slots, or out of + * pull weight / train length for even one more loaded wagon. The old + * slot-only check let a weight-bound train (PW2: weight binds at 37 wagons = + * 3522.4T of 3500+90T, slots bind at 44) cycle its booking window forever + * instead of finalizing — 7 phantom slots kept it "not full" while nothing + * could board. */ async isScheduleFull(scheduleId: string): Promise { const schedule = @@ -3232,9 +3274,53 @@ export class BookingBatchService implements OnModuleInit { return this.isTrainFull(schedule); } + /** + * Wagon-slot usage snapshot for staff UIs (adjust-consist dialog): the + * schedule's slot capacity, how many slots allocated + reserved bookings + * already hold on the busiest edge, how many are still free on the most-open + * edge, and by how many slots the consist has been trimmed BELOW what is + * already committed (0 when nothing is over-allocated). + */ + async scheduleWagonUsage(scheduleId: string): Promise<{ + maxWagons: number; + allocatedWagons: number; + remainingSlots: number; + overAllocatedBy: number; + } | null> { + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) return null; + const capacity = + (await this.builtTrainWagonCount(schedule)) ?? schedule.maxWagons ?? 0; + const wagonDims = await this.loadWagonDims(); + const budget = await this.remainingBudget( + schedule, + { + base: { + wagons: capacity, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }, + tolerance: { weightTons: 0, lengthMeters: 0 }, + }, + wagonDims, + ); + const tightest = budget.remainingFor(budget.fullLeg()).wagons; + return { + maxWagons: capacity, + allocatedWagons: capacity - tightest, + remainingSlots: Math.max(0, budget.maxRemaining().wagons), + overAllocatedBy: Math.max(0, -tightest), + }; + } + /** See {@link isScheduleFull} — same check for callers that already hold the full graph. */ private async isTrainFull(schedule: TrainSchedule): Promise { if ((await this.remainingWagons(schedule)) <= 0) return true; + // Built train: the physical consist is the only capacity axis. Weight and + // length were enforced when the consist was assembled (builder / + // adjust-consist), so a free wagon slot means the train genuinely has room. + if ((await this.builtTrainWagonCount(schedule)) != null) return false; const locomotive = schedule.trainSet?.locomotive; if (!locomotive) return false; // no weight/length limits to bind against const wagonDims = await this.loadWagonDims(); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index 9cca4d213..4d408689b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -56,7 +56,7 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n } // TEU-aware, ceiled once at the booking level (40ft = 1 wagon, two 20ft = 1 - // wagon). Honors containerType.wagonsPerUnit; falls back to the line's stored + // wagon). Derived from containerType.sizeFt; falls back to the line's stored // fraction. Ceiling per line would over-count split 20ft lines. return Math.max(1, containerWagonsForLines(booking.bookingContainers ?? [])); } 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 b92a7ea90..614c4fabd 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 @@ -12,6 +12,8 @@ import { BadRequestException, ConflictException, + forwardRef, + Inject, Injectable, Logger, NotFoundException, @@ -95,6 +97,7 @@ import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto'; import { type BookingWindowConfig } from './booking-window.config'; import { BookingWindowGateway } from './booking-window.gateway'; import { BookingNotifierService } from './booking-notifier.service'; +import { BookingBatchService } from './booking-batch.service'; import { computeFleetAvailability, summarizeFleetWarnings, @@ -317,6 +320,11 @@ export class TrainSchedulingService { private readonly bookingNotifier: BookingNotifierService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, private readonly configService?: ConfigService, + // forwardRef: BookingBatchService injects this service back; @Optional so + // existing specs that construct the service without it keep working. + @Optional() + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatchService?: BookingBatchService, ) {} /** @@ -5054,6 +5062,11 @@ export class TrainSchedulingService { wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0), ); + // Wagon-slot picture for the dialog: the consist IS the schedule's booking + // capacity, so trimming/coupling wagons moves the FULL line live. + const wagonUsage = + (await this.bookingBatchService?.scheduleWagonUsage(scheduleId)) ?? null; + const mapWagon = (wagon: Wagon) => ({ id: wagon.id, wagonNumber: wagon.wagonNumber, @@ -5092,6 +5105,12 @@ export class TrainSchedulingService { grossTons: roundTons(cargoTons + consistTareTons), consistLengthMeters, }, + scheduleCapacity: wagonUsage + ? { + ...wagonUsage, + bookingWindowStatus: schedule.bookingWindowStatus ?? null, + } + : null, wagons: wagons.map((wagon) => ({ ...mapWagon(wagon), loaded: loadedWagonIds.has(wagon.id), @@ -5284,7 +5303,37 @@ export class TrainSchedulingService { ); }); - return this.getScheduleConsist(scheduleId); + // The consist IS the schedule's booking capacity, so an edit moves the + // FULL line: freeing slots on a FULL schedule reopens its window, taking + // the last slot closes it. Staff may shrink below what is already + // committed — allowed, but reported back as a warning (never silently). + const warnings: string[] = []; + const wasFull = schedule.bookingWindowStatus === 'FULL'; + const usage = await this.bookingBatchService?.scheduleWagonUsage(scheduleId); + if (usage) { + const nowFull = usage.remainingSlots <= 0; + if (usage.overAllocatedBy > 0) { + warnings.push( + `The consist now has ${usage.maxWagons} wagon slot(s) but bookings already hold ` + + `${usage.allocatedWagons} — ${usage.overAllocatedBy} wagon(s) over capacity. ` + + 'Couple more wagons or free bookings before departure.', + ); + } + if (wasFull && !nowFull) { + await this.bookingBatchService?.refreshWindowStatus(scheduleId); + warnings.push( + `This schedule was FULL — the consist change freed ${usage.remainingSlots} wagon slot(s), ` + + 'so it is no longer FULL and can take bookings again.', + ); + } else if (!wasFull && nowFull) { + await this.bookingBatchService?.setWindow(scheduleId, 'FULL'); + warnings.push( + 'Every wagon slot is now taken — the schedule is FULL and stops accepting bookings.', + ); + } + } + + return { ...(await this.getScheduleConsist(scheduleId)), warnings }; } /** diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts index 157666f55..bb9adf319 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts @@ -77,7 +77,6 @@ describe('planWagonsWithStock — shortage detail', () => { fortyFooter.bookingContainers![0]!.containerType = { code: '40GP', sizeFt: 40, - wagonsPerUnit: 1, } as never; const result = planWagonsWithStock({ bookings: [fortyFooter], diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts index 19e19dca3..c3d48f286 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts @@ -106,7 +106,7 @@ describe('wagon-plan.util', () => { }); it('6×20ft containers = 3 wagon slots (2 per wagon)', () => { - // 20ft containers have wagonsPerUnit = 0.5, so 6 * 0.5 = 3 wagons + // 20ft containers take half a wagon each, so 6 * 0.5 = 3 wagons const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]); expect(sumWagonsRequired(booking)).toBe(3); const plan = buildContainerWagonPlan([booking], nw5); @@ -227,7 +227,7 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => const line = (quantity: number, wagonsPerUnit: number, wagonsRequired?: number) => ({ quantity, wagonsRequired: wagonsRequired ?? quantity * wagonsPerUnit, - containerType: { wagonsPerUnit, sizeFt: wagonsPerUnit >= 1 ? 40 : 20 }, + containerType: { sizeFt: wagonsPerUnit >= 1 ? 40 : 20 }, }); it('20×20ft = 10 wagons (not 20)', () => { @@ -266,7 +266,7 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => expect(containerWagonsForLines([line(21, 1)])).toBe(21); }); - it('falls back to line wagonsRequired when containerType/wagonsPerUnit missing', () => { + it('falls back to line wagonsRequired when containerType/sizeFt missing', () => { // No containerType relation loaded → use the stored (0.5-aware) fraction. expect( containerWagonsForLines([ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 78cd8bf54..bb5ce890c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -1,6 +1,7 @@ import { AllocationLoadType } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; +import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { consistViolations } from './train-capacity.util'; @@ -61,7 +62,6 @@ export type ContainerUnitRow = { label: string; grossWeightTons: number; sizeFt?: number; - wagonsPerUnit?: number; containersPerWagon?: number; teuSlots?: number; containerNumber?: string | null; @@ -95,33 +95,28 @@ export function teuSlotsForSizeFt(sizeFt: number): number { return sizeFt >= 40 ? 2 : 1; } -export function containersPerWagonFromType(wagonsPerUnit: number): number { - const wpu = Number(wagonsPerUnit); - if (!wpu || wpu <= 0) return 1; - return Math.max(1, Math.round(1 / wpu)); -} - type ContainerLine = { quantity?: number | null; wagonsRequired?: number | null; - containerType?: { wagonsPerUnit?: number | null; sizeFt?: number | null } | null; + containerType?: { sizeFt?: number | null } | null; }; /** - * RAW (un-ceiled) wagon fraction one container line occupies: qty × wagonsPerUnit - * (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept fractional so - * the BOOKING total is ceiled once — ceiling per line over-counts a booking that - * splits its 20ft units across several lines (3×20 + 3×20 = 3 wagons, not 4). + * RAW (un-ceiled) wagon fraction one container line occupies: qty × size-derived + * fraction (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept + * fractional so the BOOKING total is ceiled once — ceiling per line over-counts a + * booking that splits its 20ft units across several lines (3×20 + 3×20 = 3 + * wagons, not 4). */ function lineWagonsRaw(line: ContainerLine): number { const qty = Number(line.quantity ?? 0); if (qty <= 0) return 0; - const wpu = Number(line.containerType?.wagonsPerUnit); - if (Number.isFinite(wpu) && wpu > 0) { - return qty * wpu; + const sizeFt = Number(line.containerType?.sizeFt); + if (Number.isFinite(sizeFt) && sizeFt > 0) { + return qty * wagonsPerUnitForSize(sizeFt); } - // No wagonsPerUnit on the type: fall back to the line's stored fraction, else - // treat the whole line as one wagon. + // No size on the type: fall back to the line's stored fraction, else treat + // the whole line as one wagon. const stored = Number(line.wagonsRequired); return Number.isFinite(stored) && stored > 0 ? stored : 1; } @@ -250,8 +245,7 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR const qty = Number(line.quantity ?? 0); const code = line.containerType?.code ?? line.containerType?.label ?? 'Container'; const sizeFt = Number(line.containerType?.sizeFt ?? (code.includes('40') ? 40 : 20)); - const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5)); - const perWagon = containersPerWagonFromType(wagonsPerUnit); + const perWagon = containersPerWagonForSize(sizeFt); const teuSlots = teuSlotsForSizeFt(sizeFt); // The REAL per-container numbers/weights entered at booking time. Unit i of // the line maps to units[i] (sortOrder order); the line-level number is only @@ -271,7 +265,6 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR label: `${booking.reference} · ${i + 1}/${qty} · ${code}`, grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons), sizeFt, - wagonsPerUnit, containersPerWagon: perWagon, teuSlots, containerNumber: diff --git a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts index a8abee67b..dbfd5cee6 100644 --- a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts +++ b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts @@ -209,7 +209,6 @@ async function ensureReferences(manager: any) { code: '40FT', label: '40FT', sizeFt: 40, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, diff --git a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts index 4f801330a..9cf1ef0cd 100644 --- a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts +++ b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts @@ -118,7 +118,6 @@ async function main() { code: '40FT', label: '40FT', sizeFt: 40, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, diff --git a/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts b/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts index 367b79b44..b14ac8c15 100644 --- a/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts +++ b/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts @@ -12,6 +12,7 @@ import { Booking } from '../modules/bookings/entities/booking.entity'; import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; +import { wagonsPerUnitForSize } from '../modules/rule-engine/container-type.util'; import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; @@ -115,7 +116,7 @@ async function main() { reeferQuantity: 0, vgmPerUnitTons: Number((weightKg / containerQuantity / 1000).toFixed(3)), totalVgmTons: Number((weightKg / 1000).toFixed(3)), - wagonsRequired: Math.max(1, containerQuantity * Number(containerType!.wagonsPerUnit ?? 1)), + wagonsRequired: Math.max(1, containerQuantity * wagonsPerUnitForSize(containerType!.sizeFt)), isOverweight: false, }), ); diff --git a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts index 989e18bf1..2e234bcf0 100644 --- a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts @@ -11,6 +11,7 @@ import { } from '../modules/companies/entities/company.entity'; import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; +import { wagonsPerUnitForSize } from '../modules/rule-engine/container-type.util'; import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; @@ -223,7 +224,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder { await manager.getRepository(ContainerType).upsert( CONTAINER_TYPES.map((containerType, index) => ({ ...containerType, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, @@ -276,7 +276,7 @@ export class ApprovedFirstLastMileDemoBookingsSeeder { } const wagonsRequired = - Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt); const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity; await manager.getRepository(Booking).upsert( diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index 3720c0e2a..20d2a3f8b 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -14,6 +14,7 @@ import { ServiceType } from "../modules/rule-engine/entities/service-type.entity import { Yard } from "../modules/rule-engine/entities/yard.entity"; import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity"; import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity"; +import { wagonsPerUnitForSize } from "../modules/rule-engine/container-type.util"; import { ContainerType } from "../modules/rule-engine/entities/container-type.entity"; import { Container } from "../modules/container-management/entities/container.entity"; import { Route } from "../modules/routes/entities/route.entity"; @@ -300,7 +301,6 @@ export class DemoBookingsSeeder { await manager.getRepository(ContainerType).upsert( CONTAINER_TYPES.map((containerType, index) => ({ ...containerType, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, @@ -400,7 +400,7 @@ export class DemoBookingsSeeder { .getRepository(BookingContainer) .delete({ bookingId: booking.id }); const wagonsRequired = - Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt); await manager.getRepository(BookingContainer).insert({ id: randomUUID(), diff --git a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts index e1c46d168..f60d7bb75 100644 --- a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -7,6 +7,7 @@ import { Booking } from '../modules/bookings/entities/booking.entity'; import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity'; import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; +import { wagonsPerUnitForSize } from '../modules/rule-engine/container-type.util'; import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; @@ -145,7 +146,6 @@ export class PaidImportExportMileDemoSeeder { await manager.getRepository(ContainerType).upsert( CONTAINER_TYPES.map((containerType, index) => ({ ...containerType, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, @@ -199,7 +199,7 @@ export class PaidImportExportMileDemoSeeder { const isImport = demoBooking.tradeDirection === 'IMPORT'; const wagonsRequired = - Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt); const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity; await manager.getRepository(Booking).upsert( diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 872e909bb..147f2ae54 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -115,7 +115,6 @@ export class PricingDataSeeder { code: "20FT", label: "20FT Standard", sizeFt: 20, - wagonsPerUnit: 0.5, isReefer: false, isOpenTop: false, isActive: true, @@ -125,7 +124,6 @@ export class PricingDataSeeder { code: "40FT", label: "40FT Standard", sizeFt: 40, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, @@ -135,7 +133,6 @@ export class PricingDataSeeder { code: "20FT_REEFER", label: "20FT Reefer", sizeFt: 20, - wagonsPerUnit: 0.5, isReefer: true, isOpenTop: false, isActive: true, @@ -145,7 +142,6 @@ export class PricingDataSeeder { code: "40FT_REEFER", label: "40FT Reefer", sizeFt: 40, - wagonsPerUnit: 1, isReefer: true, isOpenTop: false, isActive: true, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 0da9fe3f0..03449fbb2 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -197,20 +197,20 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ FREIGHT_PERMS.contracts.clearanceEtActions, ], }, - { - label: "Shipment Requests", - href: "/dashboard/shipment-requests", - icon: , - permission: FREIGHT_PERMS.contracts.createBooking, - }, + // { + // label: "Shipment Requests", + // href: "/dashboard/shipment-requests", + // icon: , + // permission: FREIGHT_PERMS.contracts.createBooking, + // }, // Operations Path A queue: per-booking self-clearance review for // GENERAL non-customs booking instances (and legacy self-clear bookings). - { - label: "Self-Clearance Review", - href: "/dashboard/contracts/ops-clearance", - icon: , - permission: FREIGHT_PERMS.contracts.opsClearanceReview, - }, + // { + // label: "Self-Clearance Review", + // href: "/dashboard/contracts/ops-clearance", + // icon: , + // permission: FREIGHT_PERMS.contracts.opsClearanceReview, + // }, { label: "GL Djibouti Clearance", href: "/dashboard/gl-djibouti/clearance", diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx index 1ca91e01f..14dd60912 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx @@ -65,7 +65,10 @@ export default function AdjustConsistModal({ } }, [opened]); - // Live projection: gross = cargo + tare of (consist − trims + adds). + // Live projection: gross = cargo + tare of (consist − trims + adds), plus + // the schedule's wagon-slot picture — the consist IS the booking capacity + // (weight/length only bind while assembling the consist), so trims/adds + // move the FULL line in real time. const projection = useMemo(() => { if (!data) return null; const removed = new Set(removeIds); @@ -79,8 +82,11 @@ export default function AdjustConsistModal({ const tare = keptTare + addedWagons.reduce((s, w) => s + tareOf(w), 0); const length = keptLength + addedWagons.reduce((s, w) => s + lengthOf(w), 0); const gross = round2(data.totals.cargoTons + tare); + const wagonCount = data.totals.wagonCount - removeIds.length + addIds.length; + const cap = data.scheduleCapacity; + const freeSlots = cap ? wagonCount - cap.allocatedWagons : null; return { - wagonCount: data.totals.wagonCount - removeIds.length + addIds.length, + wagonCount, tare: round2(tare), gross, length: round2(length), @@ -93,16 +99,33 @@ export default function AdjustConsistModal({ overWeight: data.limits.pullCapTons > 0 && gross > data.limits.pullCapTons, overLength: data.limits.lengthCapMeters > 0 && length > data.limits.lengthCapMeters, + slots: + cap && freeSlots != null + ? { + allocated: cap.allocatedWagons, + free: freeSlots, + pct: + wagonCount > 0 + ? Math.round((cap.allocatedWagons / wagonCount) * 100) + : null, + isFullNow: cap.bookingWindowStatus === "FULL", + willBeFull: freeSlots <= 0, + overAllocated: freeSlots < 0, + willReopen: cap.bookingWindowStatus === "FULL" && freeSlots > 0, + } + : null, }; }, [data, removeIds, addIds]); + const hasChanges = removeIds.length > 0 || addIds.length > 0; + const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) => setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id))); const handleSubmit = async () => { if (!removeIds.length && !addIds.length) return; try { - await adjust.mutateAsync({ + const result = await adjust.mutateAsync({ scheduleId, payload: { ...(addIds.length ? { addWagonIds: addIds } : {}), @@ -114,6 +137,18 @@ export default function AdjustConsistModal({ removeIds.length && addIds.length ? ", " : "" }${addIds.length ? `${addIds.length} added` : ""}`, }); + // Schedule-impact warnings from the API: window reopened / now FULL / + // consist trimmed below what bookings already hold. + for (const warning of result.warnings ?? []) { + toast({ + title: "Schedule capacity", + description: warning, + duration: 8000, + ...(warning.includes("over capacity") + ? { variant: "destructive" as const } + : {}), + }); + } setRemoveIds([]); setAddIds([]); } catch (err) { @@ -169,8 +204,57 @@ export default function AdjustConsistModal({ over={projection?.overLength ?? false} /> + {projection?.slots ? ( + + 0 + ? ` — ${projection.slots.free} free` + : projection.slots.free === 0 + ? " — none free (FULL)" + : "" + }`} + pct={projection.slots.pct} + over={projection.slots.overAllocated} + /> + + ) : null} + {projection?.slots?.isFullNow && !hasChanges ? ( + }> + This schedule is FULL — all {projection.wagonCount} wagon slots are + taken. You can still edit the train: coupling wagons adds capacity + and reopens booking; trimming free wagons keeps it FULL. + + ) : null} + {hasChanges && projection?.slots?.overAllocated ? ( + }> + This change leaves {-projection.slots.free} booked wagon(s) without + a slot — bookings already hold {projection.slots.allocated} of the{" "} + {projection.wagonCount} remaining. You can apply it, but couple + wagons back or free bookings before departure. + + ) : null} + {hasChanges && + projection?.slots && + !projection.slots.overAllocated && + projection.slots.willBeFull && + !projection.slots.isFullNow ? ( + }> + This change takes the last free wagon slot — the schedule becomes + FULL and stops accepting bookings. + + ) : null} + {hasChanges && projection?.slots?.willReopen ? ( + }> + This schedule is currently FULL — applying frees{" "} + {projection.slots.free} wagon slot(s) and reopens its booking + window. + + ) : null} + diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/containerPlacement.util.spec.ts b/apps/edr-freight-web/backoffice/src/components/trainScheduling/containerPlacement.util.spec.ts index 3d42c8684..5072ce34a 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/containerPlacement.util.spec.ts +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/containerPlacement.util.spec.ts @@ -5,7 +5,6 @@ import type { ContainerUnitRow } from '@/types/trainScheduling'; function makeUnits(containerType: string, sizeFt: number, quantity: number): ContainerUnitRow[] { const units: ContainerUnitRow[] = []; const containersPerWagon = sizeFt >= 40 ? 1 : 2; - const wagonsPerUnit = sizeFt >= 40 ? 1 : 0.5; for (let i = 0; i < quantity; i++) { units.push({ @@ -18,7 +17,6 @@ function makeUnits(containerType: string, sizeFt: number, quantity: number): Con label: `${containerType} ${i + 1}/${quantity}`, grossWeightTons: 25, sizeFt, - wagonsPerUnit, containersPerWagon, teuSlots: sizeFt >= 40 ? 2 : 1, }); diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx index 07a6c72cd..30fdcb9c4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx @@ -61,7 +61,6 @@ interface RefContainerType { name: string; code: string; is_reefer?: boolean; - wagons_per_unit?: number; } interface RefContainerGroup { size: string; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index c7c76b91b..902d23427 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -384,7 +384,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ label: "Max wagon count", type: "number", required: true, - description: "Ceiling per type: WAGON 50 · CURRENCY 35 · CUSTOMS 15", + description: "No upper limit — must be at least the min wagon count", }, { name: "scorePoints", label: "Score points", type: "number", required: true }, { name: "isActive", label: "Active", type: "boolean" }, diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts index f387fc8dc..9357e1967 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts @@ -1,20 +1,14 @@ /** * Client mirror of the backend's contiguous-range rules for priority configs * (see PriorityConfigsService.assertNoRangeCollision): ranges per type — per - * currency for CURRENCY — run 1..cap with no gaps and no overlaps, so the next - * range always starts at the lowest uncovered wagon count. The backend - * re-validates on submit AND on approval; this only drives the form prefill. + * currency for CURRENCY — run from 1 with no gaps and no overlaps, so the next + * range always starts at the lowest uncovered wagon count. There is no upper + * ceiling. The backend re-validates on submit AND on approval; this only + * drives the form prefill. */ export type PriorityRuleType = "WAGON" | "CURRENCY" | "CUSTOMS"; -/** Hard ceiling of each type's chain — keep in sync with the API's RANGE_CAPS. */ -export const PRIORITY_RANGE_CAPS: Record = { - WAGON: 50, - CURRENCY: 35, - CUSTOMS: 15, -}; - export interface PriorityRangeRule { id?: unknown; type?: unknown; @@ -23,10 +17,13 @@ export interface PriorityRangeRule { maxWagonCount?: unknown; } +const PRIORITY_RULE_TYPES: PriorityRuleType[] = ["WAGON", "CURRENCY", "CUSTOMS"]; + /** * Where the next range for `type` (+`currency`) must start, excluding - * `excludeId` (the rule being edited). Null when the chain already covers - * 1..cap — no further rule fits. + * `excludeId` (the rule being edited). Null only when `type` is not yet a + * known priority rule type — the chain itself is unbounded, so a next start + * always exists. */ export function nextPriorityRangeStart( rules: PriorityRangeRule[], @@ -34,8 +31,7 @@ export function nextPriorityRangeStart( currency: string | null | undefined, excludeId?: string, ): number | null { - const cap = PRIORITY_RANGE_CAPS[type as PriorityRuleType]; - if (!cap) return null; + if (!PRIORITY_RULE_TYPES.includes(type as PriorityRuleType)) return null; const scoped = rules .filter( @@ -56,5 +52,5 @@ export function nextPriorityRangeStart( if (r.min > next) break; // gap before this rule — fill it first next = Math.max(next, r.max + 1); } - return next > cap ? null : next; + return next; } diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 7a2a478c1..71db16fb5 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -185,6 +185,7 @@ import { trainService, type Train } from "./trains.service"; import { trainBuilderService, type AdjustConsistPayload, + type AdjustConsistResult, type AvailableTrain, type BuildTrainPayload, type BuiltTrainListFilters, @@ -338,7 +339,7 @@ export const api = { adjustConsist: endpoint< { scheduleId: string; payload: AdjustConsistPayload }, - ScheduleConsist + AdjustConsistResult >( "train-scheduling", "adjust-consist", diff --git a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts index 62ae9f7a3..083122a10 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts @@ -235,6 +235,18 @@ export interface ScheduleConsist { occurredAt: string; }>; editable: boolean; + /** + * Wagon-slot picture of the schedule: the consist IS the booking capacity + * (weight/length only bind while building the consist), so the dialog can + * project FULL / reopen / over-allocation live. Null on legacy schedules. + */ + scheduleCapacity: { + maxWagons: number; + allocatedWagons: number; + remainingSlots: number; + overAllocatedBy: number; + bookingWindowStatus: string | null; + } | null; } export interface AdjustConsistPayload { @@ -242,6 +254,9 @@ export interface AdjustConsistPayload { removeWagonIds?: string[]; } +/** Adjust response: fresh consist + schedule-impact warnings to surface. */ +export type AdjustConsistResult = ScheduleConsist & { warnings: string[] }; + export const trainBuilderService = { list: (filters: BuiltTrainListFilters = {}) => apiClient.get(`${BASE}${toQuery(filters)}`), @@ -275,7 +290,7 @@ export const trainBuilderService = { apiClient.get(`/train-scheduling/schedules/${scheduleId}/consist`), /** Permanently trim/add wagons on the schedule's built train. */ adjustConsist: (scheduleId: string, payload: AdjustConsistPayload) => - apiClient.post( + apiClient.post( `/train-scheduling/schedules/${scheduleId}/adjust-consist`, payload, ), diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 31e1c6a04..b83aa33ee 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -72,7 +72,6 @@ export interface ContainerUnitRow { label: string; grossWeightTons: number; sizeFt?: number; - wagonsPerUnit?: number; containersPerWagon?: number; teuSlots?: number; containerNumber?: string | null; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index dc973d9c1..e84fa6ca1 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -853,7 +853,6 @@ export interface BookingReferenceContainerType { name: string; code: string; is_reefer: boolean; - wagons_per_unit: number; } export interface BookingReferenceContainerSizeGroup { From 18311f22f7cc169787e21aacf8d5655efb8b8928 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 17 Jul 2026 09:20:36 +0000 Subject: [PATCH 23/88] Comment out payment event handling for local demos in BillingService --- apps/edr-freight-api/src/modules/payment/payment.service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 5b8a3ddca..d96bcf492 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -190,12 +190,16 @@ export class PaymentService { */ async initiate(input: InitiateIntentInput): Promise { try { + + + const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.FREIGHT, referenceType: PaymentReferenceType.SHIPMENT, referenceId: input.referenceId, orderRef: input.orderRef, - amountMinor: input.amountMinor, + // amountMinor: input.amountMinor, + amountMinor:1, currency: input.currency, provider: input.method as ProviderMethod, platform: input.platform, From 84d5add58e87e22e99ab6963ca3aaadac90f5a62 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 17 Jul 2026 09:47:37 +0000 Subject: [PATCH 24/88] fix four digit --- .../src/pages/fleet/config/resources.ts | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index baeaa99a5..7a473fdb1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -126,20 +126,22 @@ const WAGON_STATUS_OPTIONS = [ /** * EDR run-number pairs, keyed by the odd EXPORT run (Ethiopia → Djibouti). The * even IMPORT run (Djibouti → Ethiopia) is fixed by the export run, so choosing - * an export number fully determines the import one. Listed explicitly rather - * than computed as export+1, so a pair that ever breaks that convention stays - * correct here. + * an export number fully determines the import one. + * + * Run numbers are always 4 digits (8401, never 84001). Pairs are listed out + * rather than computed from the 8001/+100/+1 pattern, so a run that ever breaks + * the convention stays correct here. */ const TRAIN_RUN_PAIRS: Record = { "8001": "8002", - "81001": "81002", - "82001": "82002", - "83001": "83002", - "84001": "84002", - "85001": "85002", - "86001": "86002", - "87001": "87002", - "88001": "88002", + "8101": "8102", + "8201": "8202", + "8301": "8302", + "8401": "8402", + "8501": "8502", + "8601": "8602", + "8701": "8702", + "8801": "8802", "8901": "8902", "9001": "9002", }; @@ -294,12 +296,23 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ ], cardTitleKey: "wagonNumber", cardSubtitleKey: "currentYard", - searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "currentYardId"], + searchKeys: [ + "wagonNumber", + "wagonTypeId", + "trainId", + "exportTrainNumber", + "importTrainNumber", + "status", + "currentYardId", + ], columns: [ // Tare weight and payload capacity are not wagon columns — they belong to the // wagon type and are shown through it (see WagonsCrudPage in FleetCrudPages). { id: "wagonNumber", header: "Number", accessorKey: "wagonNumber", format: "code" }, { id: "wagonTypeId", header: "Type", accessorKey: "wagonTypeId", format: "entityLabel" }, + // Unset on a wagon that is not on a run — renders as a dimmed dash. + { id: "exportTrainNumber", header: "Export train no.", accessorKey: "exportTrainNumber", format: "code" }, + { id: "importTrainNumber", header: "Import train no.", accessorKey: "importTrainNumber", format: "code" }, { id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge" }, ], From a9d65e04fc34aa4d3ebf385a9e7a5842ec4eb8c4 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 17 Jul 2026 10:12:43 +0000 Subject: [PATCH 25/88] fix number of wagon --- .../2280000000000-SeedWagonRunNumbers.ts | 206 ++++++++++++++++++ .../src/scripts/seed-edr-wagons.ts | 34 ++- 2 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2280000000000-SeedWagonRunNumbers.ts diff --git a/apps/edr-freight-api/src/migrations/2280000000000-SeedWagonRunNumbers.ts b/apps/edr-freight-api/src/migrations/2280000000000-SeedWagonRunNumbers.ts new file mode 100644 index 000000000..dae700883 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2280000000000-SeedWagonRunNumbers.ts @@ -0,0 +1,206 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Assign EDR export/import run numbers to the wagon fleet. + * + * Runs AFTER SeedEdrWagonFleetErNumbering2260000000000, which recreates every + * wagon with NULL run numbers — so this must stay later in timestamp order. + * + * Source data below is the operator-supplied roster, kept verbatim rather than + * pre-resolved so its quirks stay visible: + * - ER0697 is listed twice under run 8101 (deduped here -> 49, not 50). + * - Four wagons are claimed by two runs each. A wagon holds a single run, so + * FIRST-LISTED WINS, which is why four runs land one short of their listed + * count: + * ER0484 8301 over 8401 + * ER0451 8401 over 8701 + * ER0887 8701 over 9001 + * ER0936 8801 over 8901 + * + * Wagons outside this roster (PW2 ER0001-0220 and ER0941-1100) keep NULL runs. + */ + +/** Odd EXPORT run (Ethiopia -> Djibouti) -> the wagons rostered to it. */ +const RUN_WAGONS: Record = { + '8001': [ + 'ER0744', 'ER0734', 'ER0791', 'ER0885', 'ER0410', 'ER0901', + 'ER0692', 'ER0784', 'ER0663', 'ER0547', 'ER0635', 'ER0840', + 'ER0660', 'ER0541', 'ER0850', 'ER0764', 'ER0786', 'ER0694', + 'ER0656', 'ER0432', 'ER0666', 'ER0879', 'ER0724', 'ER0868', + 'ER0835', 'ER0650', 'ER0926', 'ER0915', 'ER0858', 'ER0826', + 'ER0474', 'ER0539', 'ER0419', 'ER0695', 'ER0462', 'ER0825', + 'ER0820', 'ER0790', 'ER0905', 'ER0557', 'ER0712', 'ER0782', + 'ER0816', 'ER0447', 'ER0674', 'ER0424', 'ER0544', 'ER0519', + 'ER0479', 'ER0440', + ], + '8101': [ + 'ER0458', 'ER0600', 'ER0521', 'ER0559', 'ER0846', 'ER0459', + 'ER0863', 'ER0925', 'ER0746', 'ER0821', 'ER0914', 'ER0768', + 'ER0676', 'ER0470', 'ER0697', 'ER0697', 'ER0923', 'ER0937', + 'ER0431', 'ER0412', 'ER0254', 'ER0555', 'ER0527', 'ER0590', + 'ER0480', 'ER0723', 'ER0316', 'ER0800', 'ER0648', 'ER0435', + 'ER0844', 'ER0939', 'ER0747', 'ER0654', 'ER0752', 'ER0633', + 'ER0725', 'ER0567', 'ER0838', 'ER0920', 'ER0843', 'ER0520', + 'ER0646', 'ER0407', 'ER0515', 'ER0760', 'ER0703', 'ER0880', + 'ER0422', 'ER0852', + ], + '8201': [ + 'ER0322', 'ER0314', 'ER0274', 'ER0514', 'ER0505', 'ER0618', + 'ER0812', 'ER0776', 'ER0698', 'ER0662', 'ER0888', 'ER0625', + 'ER0568', 'ER0596', 'ER0918', 'ER0524', 'ER0684', 'ER0231', + 'ER0907', 'ER0445', 'ER0839', 'ER0430', 'ER0799', 'ER0464', + 'ER0491', 'ER0833', 'ER0855', 'ER0571', 'ER0452', 'ER0733', + 'ER0606', 'ER0822', 'ER0845', 'ER0771', 'ER0542', 'ER0588', + 'ER0443', 'ER0585', 'ER0624', 'ER0538', 'ER0642', 'ER0928', + 'ER0411', 'ER0794', 'ER0564', 'ER0906', 'ER0348', 'ER0236', + 'ER0933', 'ER0456', + ], + '8301': [ + 'ER0264', 'ER0691', 'ER0562', 'ER0686', 'ER0881', 'ER0780', + 'ER0400', 'ER0420', 'ER0475', 'ER0425', 'ER0396', 'ER0818', + 'ER0537', 'ER0917', 'ER0421', 'ER0766', 'ER0728', 'ER0485', + 'ER0830', 'ER0804', 'ER0935', 'ER0898', 'ER0577', 'ER0762', + 'ER0558', 'ER0612', 'ER0484', 'ER0566', 'ER0876', 'ER0528', + 'ER0292', 'ER0630', 'ER0761', 'ER0849', 'ER0578', 'ER0232', + 'ER0673', 'ER0870', 'ER0575', 'ER0250', 'ER0599', 'ER0622', + 'ER0801', 'ER0806', 'ER0594', 'ER0831', 'ER0513', + ], + '8401': [ + 'ER0616', 'ER0730', 'ER0415', 'ER0522', 'ER0454', 'ER0758', + 'ER0715', 'ER0658', 'ER0602', 'ER0649', 'ER0540', 'ER0434', + 'ER0678', 'ER0550', 'ER0402', 'ER0636', 'ER0500', 'ER0740', + 'ER0664', 'ER0397', 'ER0565', 'ER0704', 'ER0720', 'ER0787', + 'ER0884', 'ER0573', 'ER0755', 'ER0392', 'ER0739', 'ER0530', + 'ER0437', 'ER0484', 'ER0653', 'ER0502', 'ER0615', 'ER0563', + 'ER0641', 'ER0391', 'ER0789', 'ER0451', 'ER0819', 'ER0442', + 'ER0798', 'ER0729', 'ER0772', 'ER0940', 'ER0682', 'ER0614', + 'ER0561', 'ER0393', + ], + '8501': [ + 'ER0807', 'ER0289', 'ER0587', 'ER0902', 'ER0877', 'ER0748', + 'ER0837', 'ER0408', 'ER0307', 'ER0759', 'ER0847', 'ER0433', + 'ER0498', 'ER0492', 'ER0735', 'ER0503', 'ER0461', 'ER0508', + 'ER0243', 'ER0583', 'ER0924', 'ER0395', 'ER0707', 'ER0572', + 'ER0536', 'ER0796', 'ER0929', 'ER0713', 'ER0603', 'ER0814', + 'ER0756', 'ER0398', 'ER0853', 'ER0276', 'ER0405', 'ER0418', + 'ER0517', 'ER0919', 'ER0781', 'ER0516', 'ER0417', 'ER0702', + 'ER0857', 'ER0486', 'ER0637', 'ER0736', 'ER0859', 'ER0483', + 'ER0824', 'ER0640', 'ER0714', + ], + '8601': [ + 'ER0455', 'ER0930', 'ER0293', 'ER0294', 'ER0677', 'ER0808', + 'ER0785', 'ER0628', 'ER0545', 'ER0551', 'ER0644', 'ER0922', + 'ER0670', 'ER0864', 'ER0629', 'ER0306', 'ER0494', 'ER0496', + 'ER0679', 'ER0874', 'ER0921', 'ER0910', 'ER0621', 'ER0667', + 'ER0262', 'ER0774', 'ER0488', 'ER0300', 'ER0234', 'ER0711', + 'ER0605', 'ER0897', 'ER0841', 'ER0778', 'ER0769', 'ER0487', + 'ER0556', 'ER0526', 'ER0795', 'ER0268', 'ER0266', 'ER0257', + ], + '8701': [ + 'ER0263', 'ER0661', 'ER0282', 'ER0394', 'ER0423', 'ER0665', + 'ER0598', 'ER0909', 'ER0481', 'ER0854', 'ER0471', 'ER0582', + 'ER0671', 'ER0466', 'ER0788', 'ER0934', 'ER0683', 'ER0680', + 'ER0890', 'ER0531', 'ER0647', 'ER0823', 'ER0608', 'ER0900', + 'ER0467', 'ER0607', 'ER0554', 'ER0233', 'ER0911', 'ER0726', + 'ER0675', 'ER0291', 'ER0313', 'ER0619', 'ER0775', 'ER0705', + 'ER0548', 'ER0891', 'ER0560', 'ER0904', 'ER0429', 'ER0655', + 'ER0224', 'ER0700', 'ER0797', 'ER0706', 'ER0533', 'ER0861', + 'ER0580', 'ER0449', 'ER0409', 'ER0613', 'ER0645', 'ER0315', + 'ER0718', 'ER0553', 'ER0444', 'ER0593', 'ER0499', 'ER0693', + 'ER0525', 'ER0451', 'ER0634', 'ER0689', 'ER0878', 'ER0518', + 'ER0887', + ], + '8801': [ + 'ER0811', 'ER0652', 'ER0889', 'ER0886', 'ER0936', 'ER0476', + 'ER0832', 'ER0626', 'ER0669', 'ER0404', 'ER0546', 'ER0501', + 'ER0894', 'ER0460', 'ER0805', 'ER0465', 'ER0717', 'ER0601', + 'ER0751', 'ER0777', 'ER0504', 'ER0749', 'ER0827', 'ER0896', + 'ER0903', 'ER0591', 'ER0436', 'ER0552', 'ER0716', 'ER0895', + 'ER0463', 'ER0809', 'ER0473', 'ER0883', 'ER0569', 'ER0610', + 'ER0275', 'ER0333', 'ER0344', 'ER0469', + ], + '8901': [ + 'ER0913', 'ER0310', 'ER0873', 'ER0448', 'ER0763', 'ER0441', + 'ER0936', 'ER0767', 'ER0416', 'ER0413', 'ER0589', 'ER0453', + 'ER0507', 'ER0287', 'ER0414', 'ER0406', 'ER0584', 'ER0866', + 'ER0893', 'ER0627', 'ER0227', 'ER0403', 'ER0428', 'ER0908', + 'ER0349', 'ER0221', 'ER0271', 'ER0659', 'ER0765', 'ER0478', + 'ER0511', 'ER0506', 'ER0743', 'ER0512', 'ER0916', 'ER0497', + 'ER0643', 'ER0638', 'ER0468', 'ER0597', + ], + '9001': [ + 'ER0446', 'ER0802', 'ER0570', 'ER0836', 'ER0576', 'ER0672', + 'ER0631', 'ER0490', 'ER0851', 'ER0450', 'ER0872', 'ER0912', + 'ER0815', 'ER0882', 'ER0738', 'ER0899', 'ER0620', 'ER0399', + 'ER0685', 'ER0477', 'ER0842', 'ER0529', 'ER0617', 'ER0865', + 'ER0754', 'ER0737', 'ER0753', 'ER0732', 'ER0623', 'ER0574', + 'ER0803', 'ER0651', 'ER0489', 'ER0668', 'ER0741', 'ER0699', + 'ER0592', 'ER0225', 'ER0229', 'ER0298', 'ER0270', 'ER0259', + 'ER0337', 'ER0770', 'ER0327', 'ER0251', 'ER0285', 'ER0927', + 'ER0810', 'ER0681', 'ER0887', + ], +}; + +/** + * Even IMPORT run (Djibouti -> Ethiopia) for each export run. Listed rather + * than computed as export+1 so a run that ever breaks the convention stays + * correct. Run numbers are always 4 digits (8401, never 84001). + */ +const IMPORT_RUN: Record = { + '8001': '8002', + '8101': '8102', + '8201': '8202', + '8301': '8302', + '8401': '8402', + '8501': '8502', + '8601': '8602', + '8701': '8702', + '8801': '8802', + '8901': '8902', + '9001': '9002', +}; + +export class SeedWagonRunNumbers2280000000000 implements MigrationInterface { + name = 'SeedWagonRunNumbers2280000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Idempotent: clear the roster's runs first so a re-run cannot leave a + // wagon on a run it was since moved off of. + await queryRunner.query(` + UPDATE freight.wagons + SET export_train_number = NULL, import_train_number = NULL + WHERE export_train_number IS NOT NULL; + `); + + const claimed = new Set(); + + for (const [exportRun, wagons] of Object.entries(RUN_WAGONS)) { + const importRun = IMPORT_RUN[exportRun]; + if (!importRun) throw new Error(`import_run_missing:${exportRun}`); + + // First-listed wins — skip any wagon an earlier run already claimed. + const fresh = wagons.filter((w) => !claimed.has(w)); + fresh.forEach((w) => claimed.add(w)); + if (!fresh.length) continue; + + await queryRunner.query( + ` + UPDATE freight.wagons + SET export_train_number = $1, + import_train_number = $2, + updated_at = now() + WHERE wagon_number = ANY($3::text[]); + `, + [exportRun, importRun, fresh], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.wagons + SET export_train_number = NULL, import_train_number = NULL + WHERE export_train_number IS NOT NULL; + `); + } +} diff --git a/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts b/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts index 716532165..eb39bbc87 100644 --- a/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts +++ b/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts @@ -1,5 +1,7 @@ import { AppDataSource } from '../data-source'; import { SeedEdrWagonFleetErNumbering2260000000000 } from '../migrations/2260000000000-SeedEdrWagonFleetErNumbering'; +import { AddWagonTrainNumbers2270000000000 } from '../migrations/2270000000000-AddWagonTrainNumbers'; +import { SeedWagonRunNumbers2280000000000 } from '../migrations/2280000000000-SeedWagonRunNumbers'; async function seedEdRWagons() { await AppDataSource.initialize(); @@ -10,7 +12,12 @@ async function seedEdRWagons() { await queryRunner.connect(); await queryRunner.startTransaction(); + // Fleet first (recreates every wagon with NULL runs), then the columns are + // ensured to exist, then the run roster is applied on top. Same order the + // migrations run in, so the script and a fresh migrate agree. await new SeedEdrWagonFleetErNumbering2260000000000().up(queryRunner); + await new AddWagonTrainNumbers2270000000000().up(queryRunner); + await new SeedWagonRunNumbers2280000000000().up(queryRunner); const summary = await queryRunner.query(` SELECT @@ -20,7 +27,8 @@ async function seedEdRWagons() { MIN(w.wagon_number) AS first_wagon, MAX(w.wagon_number) AS last_wagon, COUNT(*) FILTER (WHERE w.status = 'AVAILABLE')::int AS available, - COUNT(*) FILTER (WHERE w.current_yard_id IS NULL)::int AS unassigned_yard + COUNT(*) FILTER (WHERE w.current_yard_id IS NULL)::int AS unassigned_yard, + COUNT(*) FILTER (WHERE w.export_train_number IS NOT NULL)::int AS on_a_run FROM freight.wagons w JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER1100' @@ -29,13 +37,33 @@ async function seedEdRWagons() { `); const [totals] = await queryRunner.query(` - SELECT COUNT(*)::int AS total FROM freight.wagons; + SELECT + COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE export_train_number IS NOT NULL)::int AS on_a_run + FROM freight.wagons; + `); + + const runs = await queryRunner.query(` + SELECT + export_train_number AS export_run, + import_train_number AS import_run, + COUNT(*)::int AS wagons + FROM freight.wagons + WHERE export_train_number IS NOT NULL + GROUP BY export_train_number, import_train_number + ORDER BY export_train_number; `); await queryRunner.commitTransaction(); + console.log('\nFleet by wagon type:'); console.table(summary); - console.log(`Seeded EDR wagon fleet — ${totals.total} wagons total (expected 1100).`); + console.log('Run roster (export/import pairs):'); + console.table(runs); + console.log( + `Seeded EDR wagon fleet — ${totals.total} wagons total (expected 1100), ` + + `${totals.on_a_run} on a run (expected 533).`, + ); } catch (error) { await queryRunner.rollbackTransaction(); throw error; From 801872c1065b7597a0b16e2be238fddbef134892 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 17 Jul 2026 10:15:26 +0000 Subject: [PATCH 26/88] fix rate edit --- .../src/common/rule-engine-guards.ts | 11 + .../2300000000000-CreateRateChangeRequests.ts | 47 ++++ .../rate-change-requests.controller.ts | 58 ++++ .../dto/rate-change-request.dto.ts | 28 ++ .../entities/rate-change-request.entity.ts | 54 ++++ .../modules/rule-engine/rule-engine.module.ts | 6 + .../rate-change-requests.service.spec.ts | 213 +++++++++++++++ .../services/rate-change-requests.service.ts | 241 +++++++++++++++++ .../rule-engine/services/rates.service.ts | 58 +++- ...pdate-train-scheduling-global-rules.dto.ts | 28 -- .../train-scheduling.service.ts | 29 +- .../src/seed/freight-permissions.registry.ts | 17 ++ .../backoffice/src/constants/QUERY_KEYS.ts | 1 + .../src/hooks/rule-engine/useRuleEngine.ts | 66 +++++ .../backoffice/src/lib/permissions.ts | 15 ++ .../pages/ruleEngine/RateApprovalsSection.tsx | 247 ++++++++++++++++++ .../ruleEngine/RuleEngineResourcePage.tsx | 92 ++++++- .../TrainSchedulingGlobalRulesPage.tsx | 62 ----- .../services/ruleEngine/ruleEngine.service.ts | 62 +++++ .../backoffice/src/types/trainScheduling.ts | 4 - 20 files changed, 1227 insertions(+), 112 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/rate-change-request.dto.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/entities/rate-change-request.entity.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx diff --git a/apps/edr-freight-api/src/common/rule-engine-guards.ts b/apps/edr-freight-api/src/common/rule-engine-guards.ts index 12ba30e11..14c0385ee 100644 --- a/apps/edr-freight-api/src/common/rule-engine-guards.ts +++ b/apps/edr-freight-api/src/common/rule-engine-guards.ts @@ -4,6 +4,7 @@ import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { FreightPermissionGuard } from './freight-permission.guard'; import { FREIGHT_PERMS, + type RuleEngineApprovableSlug, type RuleEngineResourceSlug, } from '../seed/freight-permissions.registry'; @@ -16,3 +17,13 @@ export const RuleEngineManage = (slug: RuleEngineResourceSlug) => applyDecorators( UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])), ); + +/** + * Deciding a filed change — a step above `manage`, which only lets a staff + * member propose one. Super admins pass any freight permission check, so + * approvals work before the permission is granted to a director role. + */ +export const RuleEngineApprove = (slug: RuleEngineApprovableSlug) => + applyDecorators( + UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.approve(slug)])), + ); diff --git a/apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts b/apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts new file mode 100644 index 000000000..a3e36f0b9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Approval workflow for edits to LIVE rates. A LIVE rate is what pricing + * charges, so it is never edited in place: the edit is filed here as PENDING + * and the live row keeps its value until an approver applies it. + * + * `payload` holds the changed fields only; `previous_values` snapshots what + * they were at submit time so the approver sees a real before→after diff. + */ +export class CreateRateChangeRequests2300000000000 implements MigrationInterface { + name = 'CreateRateChangeRequests2300000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.rate_change_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + rate_id uuid NOT NULL REFERENCES freight.rates (id), + payload jsonb NOT NULL, + previous_values jsonb NOT NULL, + status varchar(10) NOT NULL DEFAULT 'PENDING', + requested_by_user_id uuid NULL, + decided_by_user_id uuid NULL, + decided_at timestamptz NULL, + decision_note text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_rcr_status + ON freight.rate_change_requests (status) + `); + // At most one pending edit per rate — two racing requests would both pass + // validation and the second would silently overwrite the first on approval. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_rcr_one_pending_per_rate + ON freight.rate_change_requests (rate_id) + WHERE status = 'PENDING' AND deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.rate_change_requests`); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts new file mode 100644 index 000000000..9972ab06a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts @@ -0,0 +1,58 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { isSuperAdmin } from '../../../common/freight-permission.util'; +import { RuleEngineApprove, RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { DecideRateChangeDto, SubmitRateChangeDto } from '../dto/rate-change-request.dto'; +import { RateChangeStatus } from '../entities/rate-change-request.entity'; +import { RateChangeRequestsService } from '../services/rate-change-requests.service'; + +/** + * Edits to LIVE rates. Staff with `manage` propose (submit); only holders of + * `approve` decide. Until a change is approved the live rate keeps its current + * value, so pricing never moves on an unapproved edit. + */ +@ApiTags('rate-change-requests') +@Controller('rate-change-requests') +@ApiBearerAuth() +export class RateChangeRequestsController { + constructor(private readonly service: RateChangeRequestsService) {} + + @Post() + @RuleEngineManage('rates') + @ApiOperation({ summary: 'Propose a change to a LIVE rate' }) + submit(@Body() dto: SubmitRateChangeDto, @CurrentUser() user: TCurrentUser) { + return this.service.submit(dto, user?.id); + } + + @Get() + @RuleEngineView('rates') + @ApiOperation({ summary: 'List rate change requests, optionally by status' }) + list(@Query('status') status?: RateChangeStatus) { + return this.service.list(status); + } + + @Post(':id/approve') + @RuleEngineApprove('rates') + @ApiOperation({ summary: 'Approve a rate change and put it into effect' }) + approve( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: DecideRateChangeDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.approve(id, user?.id, dto.decisionNote, isSuperAdmin(user)); + } + + @Post(':id/reject') + @RuleEngineApprove('rates') + @ApiOperation({ summary: 'Reject a rate change — the rate keeps its current value' }) + reject( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: DecideRateChangeDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.reject(id, user?.id, dto.decisionNote); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/rate-change-request.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/rate-change-request.dto.ts new file mode 100644 index 000000000..6c5dc3fbe --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/rate-change-request.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator'; + +import { UpdateRateDto } from './update-rate.dto'; + +export class SubmitRateChangeDto { + @ApiProperty({ description: 'The LIVE rate to reprice' }) + @IsUUID() + rateId!: string; + + @ApiProperty({ + description: + 'Proposed field changes. The live rate keeps its current values until this is approved.', + type: UpdateRateDto, + }) + @ValidateNested() + @Type(() => UpdateRateDto) + update!: UpdateRateDto; +} + +export class DecideRateChangeDto { + @ApiPropertyOptional({ description: 'Optional note shown to the requester' }) + @IsOptional() + @IsString() + @MaxLength(1000) + decisionNote?: string; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-change-request.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-change-request.entity.ts new file mode 100644 index 000000000..00a1c0ba3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-change-request.entity.ts @@ -0,0 +1,54 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Rate } from './rate.entity'; + +export type RateChangeStatus = 'PENDING' | 'APPROVED' | 'REJECTED'; + +/** + * One proposed edit to a LIVE rate, awaiting approval. + * + * A LIVE rate is what pricing actually charges, so it is never mutated in + * place: the edit is filed here and the live row keeps its old value until an + * approver applies it. `payload` holds only the changed fields (an + * UpdateRateDto patch), `rateId` the rate being repriced. + * + * DRAFT rates are not covered — nothing prices off a draft, so those still + * edit directly and reach LIVE through the existing submit/approve flow. + */ +@Entity({ schema: 'freight', name: 'rate_change_requests' }) +@Index(['status']) +export class RateChangeRequest extends BaseEntity { + @Column({ name: 'rate_id', type: 'uuid' }) + rateId!: string; + + @ManyToOne(() => Rate, { nullable: false }) + @JoinColumn({ name: 'rate_id' }) + rate?: Rate | null; + + /** Proposed field changes — an UpdateRateDto patch, changed keys only. */ + @Column({ name: 'payload', type: 'jsonb' }) + payload!: Record; + + /** + * The rate's values at submit time, for the approver's before→after diff. + * Snapshotted because the live row can move on between submit and decision. + */ + @Column({ name: 'previous_values', type: 'jsonb' }) + previousValues!: Record; + + @Column({ name: 'status', type: 'varchar', length: 10, default: 'PENDING' }) + status!: RateChangeStatus; + + @Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true }) + requestedByUserId?: string | null; + + @Column({ name: 'decided_by_user_id', type: 'uuid', nullable: true }) + decidedByUserId?: string | null; + + @Column({ name: 'decided_at', type: 'timestamptz', nullable: true }) + decidedAt?: Date | null; + + @Column({ name: 'decision_note', type: 'text', nullable: true }) + decisionNote?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 7edcf0bbf..39a5450ef 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -6,6 +6,7 @@ import { CargoTypesController } from './controllers/cargo-types.controller'; import { ContainerTypesController } from './controllers/container-types.controller'; import { PriorityConfigsController } from './controllers/priority-configs.controller'; import { PriorityRuleChangeRequestsController } from './controllers/priority-rule-change-requests.controller'; +import { RateChangeRequestsController } from './controllers/rate-change-requests.controller'; import { RatesController } from './controllers/rates.controller'; import { ServiceTypesController } from './controllers/service-types.controller'; import { ShippingLinesController } from './controllers/shipping-lines.controller'; @@ -17,6 +18,7 @@ import { CargoType } from './entities/cargo-type.entity'; import { ContainerType } from './entities/container-type.entity'; import { PriorityConfig } from './entities/priority-config.entity'; import { PriorityRuleChangeRequest } from './entities/priority-rule-change-request.entity'; +import { RateChangeRequest } from './entities/rate-change-request.entity'; import { Rate } from './entities/rate.entity'; import { ServiceType } from './entities/service-type.entity'; import { ShippingLine } from './entities/shipping-line.entity'; @@ -49,6 +51,7 @@ import { CargoTypesService } from './services/cargo-types.service'; import { ContainerTypesService } from './services/container-types.service'; import { PriorityConfigsService } from './services/priority-configs.service'; import { PriorityRuleChangeRequestsService } from './services/priority-rule-change-requests.service'; +import { RateChangeRequestsService } from './services/rate-change-requests.service'; import { RatesService } from './services/rates.service'; import { ServiceTypesService } from './services/service-types.service'; import { ShippingLinesService } from './services/shipping-lines.service'; @@ -72,6 +75,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ContainerType, PriorityConfig, PriorityRuleChangeRequest, + RateChangeRequest, ServiceType, WeightLimitRule, Yard, @@ -91,6 +95,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ContainerTypesController, PriorityConfigsController, PriorityRuleChangeRequestsController, + RateChangeRequestsController, ServiceTypesController, WeightLimitRulesController, YardsController, @@ -121,6 +126,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ContainerTypesService, PriorityConfigsService, PriorityRuleChangeRequestsService, + RateChangeRequestsService, ServiceTypesService, WeightLimitRulesService, YardsService, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts new file mode 100644 index 000000000..6c3adce66 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts @@ -0,0 +1,213 @@ +import { BadRequestException, ConflictException, ForbiddenException } from '@nestjs/common'; + +import { RateChangeRequest } from '../entities/rate-change-request.entity'; +import { Rate } from '../entities/rate.entity'; +import { RateChangeRequestsService } from './rate-change-requests.service'; + +/** + * The guarantee under test: editing a LIVE rate never moves the live value. + * A rate at 100 keeps charging 100 while a change to 200 sits PENDING; only + * approval applies it, and only then through RatesService (so every rate rule + * is re-checked against the state at approval time). + */ +describe('RateChangeRequestsService', () => { + const liveRate = (overrides: Partial = {}): Rate => + ({ + id: 'rate-1', + status: 'LIVE', + rateType: 'OCEAN_FREIGHT', + appliesTo: 'CONTAINER', + trigger: 'ALWAYS', + currency: 'USD', + // Postgres numeric comes back as a string — the no-op check must cope. + rateValue: '100.0000' as unknown as number, + rateUnit: 'PER_CONTAINER', + containerTypeId: null, + cargoTypeId: null, + tradeDirection: null, + proposedByStaffId: 'staff-1', + ...overrides, + }) as unknown as Rate; + + const build = (opts: { + rate?: Rate; + pending?: RateChangeRequest | null; + applyThrows?: Error; + } = {}) => { + const rate = opts.rate ?? liveRate(); + const saved: RateChangeRequest[] = []; + + const repo = { + findOne: jest.fn(async ({ where }: { where: Record }) => { + if (where.status === 'PENDING' && where.rateId) return opts.pending ?? null; + return saved.find((r) => r.id === where.id) ?? opts.pending ?? null; + }), + create: jest.fn((data: Partial) => ({ id: 'req-1', ...data })), + save: jest.fn(async (entity: RateChangeRequest) => { + saved.push(entity); + return entity; + }), + find: jest.fn(async () => saved), + }; + + const rates = { + findById: jest.fn(async () => rate), + assertUpdateValid: jest.fn(async () => undefined), + applyApprovedUpdate: jest.fn(async () => { + if (opts.applyThrows) throw opts.applyThrows; + return rate; + }), + }; + + const inbox = { notify: jest.fn(async () => undefined) }; + + const service = new RateChangeRequestsService( + repo as never, + rates as never, + inbox as never, + ); + // `pending` is the very object approve/reject mutate — assert on it, not a copy. + return { service, repo, rates, inbox, pending: opts.pending }; + }; + + describe('submit', () => { + it('files a pending request instead of touching the live rate', async () => { + const { service, rates } = build(); + + const request = await service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }); + + expect(request.status).toBe('PENDING'); + expect(request.payload).toEqual({ rateValue: 200 }); + // The old value is snapshotted for the approver's diff... + expect(request.previousValues).toEqual({ rateValue: '100.0000' }); + // ...and nothing wrote to the rate itself. + expect(rates.applyApprovedUpdate).not.toHaveBeenCalled(); + }); + + it('keeps only the fields that actually changed', async () => { + const { service } = build(); + + // A form posts every field back; only rateValue differs from the live rate. + const request = await service.submit({ + rateId: 'rate-1', + update: { + rateValue: 200, + currency: 'USD', + rateUnit: 'PER_CONTAINER', + appliesTo: 'CONTAINER', + }, + }); + + expect(request.payload).toEqual({ rateValue: 200 }); + }); + + it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => { + const { service } = build(); + await expect( + service.submit({ rateId: 'rate-1', update: { rateValue: 100 } }), + ).rejects.toThrow(/Nothing changed/); + }); + + it('refuses a rate that is not LIVE — those edit directly', async () => { + const { service } = build({ rate: liveRate({ status: 'DRAFT' }) }); + await expect( + service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }), + ).rejects.toThrow(BadRequestException); + }); + + it('refuses a second pending change for the same rate', async () => { + const { service } = build({ + pending: { id: 'req-0', status: 'PENDING' } as unknown as RateChangeRequest, + }); + await expect( + service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }), + ).rejects.toThrow(ConflictException); + }); + + it('validates up front so the requester hears about a bad patch, not the approver', async () => { + const { service, rates } = build(); + rates.assertUpdateValid.mockRejectedValueOnce( + new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'), + ); + await expect( + service.submit({ rateId: 'rate-1', update: { rateUnit: 'PER_TON' } }), + ).rejects.toThrow(/not valid for this rate/); + }); + }); + + describe('approve', () => { + const pendingRequest = (): RateChangeRequest => + ({ + id: 'req-1', + rateId: 'rate-1', + payload: { rateValue: 200 }, + previousValues: { rateValue: '100.0000' }, + status: 'PENDING', + requestedByUserId: 'staff-1', + }) as unknown as RateChangeRequest; + + it('applies the change through RatesService and marks it approved', async () => { + const { service, rates } = build({ pending: pendingRequest() }); + + const decided = await service.approve('req-1', 'approver-1', 'Agreed'); + + expect(rates.applyApprovedUpdate).toHaveBeenCalledWith('rate-1', { rateValue: 200 }); + expect(decided.status).toBe('APPROVED'); + expect(decided.decidedByUserId).toBe('approver-1'); + expect(decided.decisionNote).toBe('Agreed'); + }); + + it('blocks the requester from approving their own change', async () => { + const { service, rates } = build({ pending: pendingRequest() }); + await expect(service.approve('req-1', 'staff-1')).rejects.toThrow(ForbiddenException); + expect(rates.applyApprovedUpdate).not.toHaveBeenCalled(); + }); + + it('lets a super admin self-approve', async () => { + const { service } = build({ pending: pendingRequest() }); + await expect(service.approve('req-1', 'staff-1', undefined, true)).resolves.toMatchObject({ + status: 'APPROVED', + }); + }); + + it('stays PENDING when applying now fails — never marks a change that did not land', async () => { + const { service, pending, repo } = build({ + pending: pendingRequest(), + applyThrows: new ConflictException('A rate for this exact combination already exists.'), + }); + + await expect(service.approve('req-1', 'approver-1')).rejects.toThrow(/already exists/); + // Apply runs first, so a failure leaves the request untouched and re-decidable. + expect(pending!.status).toBe('PENDING'); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('refuses to decide an already-decided request', async () => { + const { service } = build({ + pending: { ...pendingRequest(), status: 'APPROVED' } as unknown as RateChangeRequest, + }); + await expect(service.approve('req-1', 'approver-1')).rejects.toThrow(ConflictException); + }); + }); + + describe('reject', () => { + it('never touches the rate — it simply keeps its current value', async () => { + const { service, rates } = build({ + pending: { + id: 'req-1', + rateId: 'rate-1', + payload: { rateValue: 200 }, + previousValues: { rateValue: '100.0000' }, + status: 'PENDING', + requestedByUserId: 'staff-1', + } as unknown as RateChangeRequest, + }); + + const decided = await service.reject('req-1', 'approver-1', 'Too steep'); + + expect(decided.status).toBe('REJECTED'); + expect(decided.decisionNote).toBe('Too steep'); + expect(rates.applyApprovedUpdate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts new file mode 100644 index 000000000..357c67f95 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts @@ -0,0 +1,241 @@ +import { NotificationAudience, NotificationType } from '@edr/types'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { NotificationInboxService } from '../../notification-inbox/notification-inbox.service'; +import { SubmitRateChangeDto } from '../dto/rate-change-request.dto'; +import { UpdateRateDto } from '../dto/update-rate.dto'; +import { + RateChangeRequest, + RateChangeStatus, +} from '../entities/rate-change-request.entity'; +import { Rate } from '../entities/rate.entity'; +import { RatesService } from './rates.service'; + +/** Backoffice page where both the queue and the rates live. */ +const RATES_LINK = '/dashboard/rules/rates'; + +/** Fields a change request may carry — anything else in the patch is ignored. */ +const DIFFABLE_FIELDS = [ + 'rateValue', + 'currency', + 'rateUnit', + 'appliesTo', + 'trigger', + 'tradeDirection', + 'containerTypeId', + 'cargoTypeId', +] as const; + +/** + * Approval workflow for edits to LIVE rates. + * + * A LIVE rate is what pricing charges right now, so it is never edited in + * place. The edit is filed here as a PENDING request and the live row keeps + * its old value — a rate at 100 USD keeps quoting 100 while a change to 200 + * waits. Approval replays the edit through RatesService, so every rule + * (unit validity, pattern uniqueness) is re-checked against whatever is true + * at approval time, not at submit time. + */ +@Injectable() +export class RateChangeRequestsService { + private readonly logger = new Logger(RateChangeRequestsService.name); + + constructor( + @InjectRepository(RateChangeRequest) + private readonly repo: Repository, + private readonly rates: RatesService, + private readonly inbox: NotificationInboxService, + ) {} + + /** + * File an edit against a LIVE rate. Validated up front so the requester + * hears about a bad unit or a pattern clash immediately rather than the + * approver hitting it days later. + */ + async submit(dto: SubmitRateChangeDto, userId?: string | null): Promise { + const rate = await this.rates.findById(dto.rateId); + if (rate.status !== 'LIVE') { + throw new BadRequestException( + `Only LIVE rates go through approval — this rate is ${rate.status} and can be edited directly.`, + ); + } + + const payload = this.changedFieldsOnly(rate, dto.update); + if (Object.keys(payload).length === 0) { + throw new BadRequestException('Nothing changed — the proposed values match the live rate.'); + } + + // One pending edit per rate: two racing requests would both validate, then + // the second would silently overwrite the first on approval. + const inFlight = await this.repo.findOne({ + where: { rateId: dto.rateId, status: 'PENDING' }, + }); + if (inFlight) { + throw new ConflictException( + 'This rate already has a change awaiting approval. Have it approved or rejected first.', + ); + } + + await this.rates.assertUpdateValid(dto.rateId, payload as UpdateRateDto); + + const request = await this.repo.save( + this.repo.create({ + rateId: dto.rateId, + payload, + previousValues: this.snapshot(rate, payload), + status: 'PENDING', + requestedByUserId: userId ?? null, + }), + ); + + this.notifyTeam( + 'Rate change submitted', + `A change to a LIVE rate was submitted and awaits approval. The current rate stays in effect until it is approved.`, + request, + ); + return request; + } + + async list(status?: RateChangeStatus): Promise { + return this.repo.find({ + where: status ? { status } : {}, + relations: { rate: true }, + order: { createdAt: 'DESC' }, + }); + } + + /** + * Approve and apply. The live mutation runs FIRST — if it now fails (someone + * created a clashing rate since submit), the request stays PENDING and the + * approver sees the real error instead of a request marked approved that + * never landed. + */ + async approve( + id: string, + userId?: string | null, + decisionNote?: string, + canSelfApprove = false, + ): Promise { + const request = await this.findPending(id); + + // Separation of duties: the requester cannot approve their own repricing — + // except super admins, who have full backoffice authority. + if (!canSelfApprove && userId && userId === request.requestedByUserId) { + throw new ForbiddenException('You cannot approve a rate change you submitted'); + } + + await this.rates.applyApprovedUpdate(request.rateId, request.payload as UpdateRateDto); + + request.status = 'APPROVED'; + request.decidedByUserId = userId ?? null; + request.decidedAt = new Date(); + request.decisionNote = decisionNote ?? null; + const saved = await this.repo.save(request); + + this.notifyTeam( + 'Rate change approved', + `The rate change was approved and is now live.` + + (decisionNote ? ` Note: ${decisionNote}` : ''), + saved, + ); + return saved; + } + + /** Reject — the live rate is never touched, so it simply keeps its value. */ + async reject( + id: string, + userId?: string | null, + decisionNote?: string, + ): Promise { + const request = await this.findPending(id); + request.status = 'REJECTED'; + request.decidedByUserId = userId ?? null; + request.decidedAt = new Date(); + request.decisionNote = decisionNote ?? null; + const saved = await this.repo.save(request); + + this.notifyTeam( + 'Rate change rejected', + `The rate change was rejected — the rate keeps its current value.` + + (decisionNote ? ` Note: ${decisionNote}` : ''), + saved, + ); + return saved; + } + + /** + * Keep only fields the requester actually changed. A form posts every field + * back, so without this the diff would list untouched values as changes. + */ + private changedFieldsOnly(rate: Rate, update: UpdateRateDto): Record { + const patch: Record = {}; + for (const field of DIFFABLE_FIELDS) { + const proposed = (update as Record)[field]; + if (proposed === undefined) continue; + if (this.sameValue(proposed, (rate as unknown as Record)[field])) continue; + patch[field] = proposed; + } + return patch; + } + + /** The live values the patch would overwrite — the "before" side of the diff. */ + private snapshot(rate: Rate, payload: Record): Record { + const before: Record = {}; + for (const field of Object.keys(payload)) { + before[field] = (rate as unknown as Record)[field] ?? null; + } + return before; + } + + /** + * rateValue arrives as a string from Postgres `numeric` but as a number from + * the form, so 100 and "100.0000" must compare equal or every submit would + * look like a change. + */ + private sameValue(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a == null && b == null) return true; + if (a == null || b == null) return false; + const numA = Number(a); + const numB = Number(b); + if (!Number.isNaN(numA) && !Number.isNaN(numB) && a !== '' && b !== '') { + return numA === numB; + } + return String(a) === String(b); + } + + private async findPending(id: string): Promise { + const request = await this.repo.findOne({ where: { id }, relations: { rate: true } }); + if (!request) throw new NotFoundException(`Rate change request ${id} not found`); + if (request.status !== 'PENDING') { + throw new ConflictException(`Rate change request is already ${request.status.toLowerCase()}`); + } + return request; + } + + /** Fire-and-forget — a notification failure never blocks the workflow. */ + private notifyTeam(title: string, body: string, request: RateChangeRequest): void { + void this.inbox + .notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title, + body, + link: RATES_LINK, + data: { rateChangeRequestId: request.id, rateId: request.rateId }, + }) + .catch((err) => + this.logger.warn(`Rate-change notification failed: ${(err as Error).message}`), + ); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 3cd17cf6e..b9a5e7873 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -119,12 +119,62 @@ export class RatesService { }); } - /** Update a DRAFT rate. */ + /** + * Update a DRAFT rate in place. Nothing prices off a draft, so a direct edit + * is safe. A LIVE rate cannot take this path — see `applyApprovedUpdate`. + */ async update(id: string, dto: UpdateRateDto): Promise { const existing = await this.findById(id); if (existing.status !== 'DRAFT') { - throw new BadRequestException('Only DRAFT rates can be updated'); + throw new BadRequestException( + existing.status === 'LIVE' + ? 'A LIVE rate cannot be edited directly — file a rate change request so an approver can apply it.' + : 'Only DRAFT rates can be updated', + ); } + return this.applyUpdate(existing, dto); + } + + /** + * Apply an approved change request to a LIVE rate. Same validation as a + * DRAFT edit — it just skips the DRAFT guard, because a LIVE rate reaching + * here has already been through approval. Only ever called by + * RateChangeRequestsService.approve. + */ + async applyApprovedUpdate(id: string, dto: UpdateRateDto): Promise { + const existing = await this.findById(id); + if (existing.status !== 'LIVE') { + throw new BadRequestException( + `Rate change requests apply to LIVE rates only — this rate is ${existing.status}.`, + ); + } + return this.applyUpdate(existing, dto); + } + + /** + * Validate a proposed patch against a rate without writing anything — lets a + * change request be refused at submit time instead of surprising the + * approver. Throws exactly what applying it would throw. + */ + async assertUpdateValid(id: string, dto: UpdateRateDto): Promise { + await this.buildUpdate(await this.findById(id), dto); + } + + private async applyUpdate(existing: Rate, dto: UpdateRateDto): Promise { + const updates = await this.buildUpdate(existing, dto); + const updated = await this.repository.update(existing.id, updates); + if (!updated) throw new NotFoundException(`Rate ${existing.id} not found`); + return updated; + } + + /** + * The shared edit body: re-derives rateType, re-validates the unit against + * the (possibly changed) shape, and guards pattern uniqueness. Status is + * never touched — an approved edit to a LIVE rate stays LIVE. Pure apart + * from the uniqueness read, so it doubles as the dry-run validator. + */ + private async buildUpdate(existing: Rate, dto: UpdateRateDto): Promise> { + const id = existing.id; const updates: Partial = {}; const appliesTo = (dto.appliesTo as Rate['appliesTo']) ?? existing.appliesTo; @@ -179,9 +229,7 @@ export class RatesService { updates.currency = dto.currency ?? existing.currency ?? 'USD'; if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue; - const updated = await this.repository.update(id, updates); - if (!updated) throw new NotFoundException(`Rate ${id} not found`); - return updated; + return updates; } /** Submit a DRAFT rate for CEO approval. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts index 2948b874d..c5e6fa65f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -3,20 +3,6 @@ import { Type } from 'class-transformer'; import { IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator'; export class UpdateTrainSchedulingGlobalRulesDto { - @ApiPropertyOptional({ example: 760 }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(1) - maxTrainLengthMeters?: number; - - @ApiPropertyOptional({ example: 3500 }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(1) - maxTrainWeightTons?: number; - @ApiPropertyOptional({ example: 53 }) @IsOptional() @Type(() => Number) @@ -24,20 +10,6 @@ export class UpdateTrainSchedulingGlobalRulesDto { @Min(1) maxWagonsPerTrain?: number; - @ApiPropertyOptional({ example: 30 }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(0.001) - max20ftContainerWeightTons?: number; - - @ApiPropertyOptional({ example: 10 }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(0) - max20ftPairWeightDiffTons?: number; - @ApiPropertyOptional({ example: 3, description: 'Days before departure the import booking-window day falls on' }) @IsOptional() @Type(() => Number) 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 a08b12378..0d9bf63c9 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 @@ -600,7 +600,24 @@ export class TrainSchedulingService { } async getTrainSchedulingGlobalRules() { - return this.loadGlobalRulesRow(); + return this.toPublicGlobalRules(await this.loadGlobalRulesRow()); + } + + /** + * Train length/weight and 20ft weight caps are engine-internal (wagon + * planning still reads them off the row); they are no longer exposed or + * editable through the global-rules endpoints. + */ + private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) { + if (!row) return row; + const { + maxTrainLengthMeters: _len, + maxTrainWeightTons: _wt, + max20ftContainerWeightTons: _cw, + max20ftPairWeightDiffTons: _pd, + ...pub + } = row; + return pub; } async updateTrainSchedulingGlobalRules(dto: UpdateTrainSchedulingGlobalRulesDto) { @@ -608,15 +625,7 @@ export class TrainSchedulingService { if (!row) { throw new NotFoundException('Train scheduling global rules not configured'); } - if (dto.maxTrainLengthMeters != null) row.maxTrainLengthMeters = dto.maxTrainLengthMeters; - if (dto.maxTrainWeightTons != null) row.maxTrainWeightTons = dto.maxTrainWeightTons; if (dto.maxWagonsPerTrain != null) row.maxWagonsPerTrain = dto.maxWagonsPerTrain; - if (dto.max20ftContainerWeightTons != null) { - row.max20ftContainerWeightTons = dto.max20ftContainerWeightTons; - } - if (dto.max20ftPairWeightDiffTons != null) { - row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons; - } if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays; if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours; if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour; @@ -654,7 +663,7 @@ export class TrainSchedulingService { await this.restampPendingWindows(); } - return saved; + return this.toPublicGlobalRules(saved); } /** 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 6e4ec81b1..d3b5bbb00 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -99,13 +99,28 @@ const RULE_ENGINE_PERMISSION_IDS: Record> = { + rates: 'b2000001-0001-4000-8000-000000000017', +}; + +export type RuleEngineApprovableSlug = 'rates'; + export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESOURCE_SLUGS.flatMap( (slug) => { const resource = slugToResourceKey(slug); const ids = RULE_ENGINE_PERMISSION_IDS[slug]; + const approveId = RULE_ENGINE_APPROVE_PERMISSION_IDS[slug]; return [ perm(ids.view, `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`), perm(ids.manage, `edr_freight_app:rule_engine:${resource}:manage`, `Manage ${slug}`), + ...(approveId + ? [perm(approveId, `edr_freight_app:rule_engine:${resource}:approve`, `Approve ${slug} changes`)] + : []), ]; }, ); @@ -389,6 +404,8 @@ export const FREIGHT_PERMS = { `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`, manage: (slug: RuleEngineResourceSlug) => `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`, + approve: (slug: RuleEngineApprovableSlug) => + `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`, }, allocation: { manage: 'edr_freight_app:allocation:manage', diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 164094cd3..160e0a61d 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -167,6 +167,7 @@ export const QUERY_KEYS = { orderList: (resource: RuleEngineResourceSlug | string) => ["rule-engine", "order-list", resource] as const, priorityRuleChanges: ["rule-engine", "priority-rule-changes"] as const, + rateChanges: ["rule-engine", "rate-changes"] as const, }, OVERVIEW: { diff --git a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts index 5ae7bac72..d0b34cb5b 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts @@ -7,6 +7,7 @@ import { ruleEngineService, type RuleEngineListParams, type SubmitPriorityRuleChangePayload, + type SubmitRateChangePayload, } from "@/services/ruleEngine/ruleEngine.service"; import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources"; import type { @@ -318,6 +319,71 @@ export const usePriorityRuleWorkflow = ( return { pending, submit, approve, reject }; }; +/** + * Approval workflow for edits to LIVE rates. The live rate keeps its current + * value until a change is approved, so the rates list is invalidated on every + * outcome — including reject, which restores the row's "no pending" state. + */ +export const useRateChangeWorkflow = ( + enabled: boolean, + onErrorMessage?: (message: string) => void, +) => { + const qc = useQueryClient(); + + const showError = (err: unknown, fallback: string) => { + const raw = (err as { response?: { data?: { message?: string | string[] } } }) + ?.response?.data?.message; + const message = (Array.isArray(raw) ? raw.join(", ") : raw) || fallback; + if (onErrorMessage) onErrorMessage(message); + else toast.error(message); + }; + + const pending = useQuery({ + queryKey: QUERY_KEYS.RULE_ENGINE.rateChanges, + queryFn: () => ruleEngineService.listRateChanges("PENDING"), + enabled, + }); + + const invalidate = async () => { + await qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.rateChanges }); + await invalidateRuleEngineList(qc, "rates"); + }; + + const submit = useMutation({ + mutationFn: (payload: SubmitRateChangePayload) => + ruleEngineService.submitRateChange(payload), + onSuccess: async () => { + toast.success( + "Change submitted for approval — the rate keeps its current value until approved", + ); + await invalidate(); + }, + onError: (err) => showError(err, "Failed to submit rate change"), + }); + + const approve = useMutation({ + mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) => + ruleEngineService.approveRateChange(id, decisionNote), + onSuccess: async () => { + toast.success("Rate change approved — the new rate is now live"); + await invalidate(); + }, + onError: (err) => showError(err, "Failed to approve rate change"), + }); + + const reject = useMutation({ + mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) => + ruleEngineService.rejectRateChange(id, decisionNote), + onSuccess: async () => { + toast.success("Rate change rejected — the rate keeps its current value"); + await invalidate(); + }, + onError: (err) => showError(err, "Failed to reject rate change"), + }); + + return { pending, submit, approve, reject }; +}; + export const useRateWorkflow = () => { const qc = useQueryClient(); diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 9bf9aab6e..98f998bc0 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -446,6 +446,21 @@ export function ruleEngineManageKey(slug: RuleEngineResourceSlug): string { return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`; } +/** + * Deciding a filed change — a step above `manage`, which only lets a staff + * member propose one. Only resources with an approval workflow have it. + */ +export function ruleEngineApproveKey(slug: "rates"): string { + return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`; +} + +export function canApproveRuleEngineChange( + user: AuthUser | null | undefined, + slug: "rates", +): boolean { + return hasPermission(user, ruleEngineApproveKey(slug)); +} + export function canAccessRuleEngineResource( user: AuthUser | null | undefined, slug: RuleEngineResourceSlug, diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx new file mode 100644 index 000000000..6156762a0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx @@ -0,0 +1,247 @@ +import { useState } from "react"; +import { + Badge, + Button, + Card, + Collapse, + Group, + Stack, + Text, + Textarea, + Tooltip, +} from "@mantine/core"; +import type { UseMutationResult } from "@tanstack/react-query"; +import { ArrowRight, CheckCircle2, Clock, XCircle } from "lucide-react"; + +import type { RateChangeRequest } from "@/services/ruleEngine/ruleEngine.service"; + +/** Field labels for the diff — anything not listed falls back to the raw key. */ +const FIELD_LABELS: Record = { + rateValue: "Rate", + currency: "Currency", + rateUnit: "Unit", + appliesTo: "Applies to", + trigger: "Trigger", + tradeDirection: "Direction", + containerTypeId: "Container type", + cargoTypeId: "Cargo type", +}; + +const fmtDateTime = (iso: string) => + new Date(iso).toLocaleString("en-GB", { + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + +const fmtValue = (field: string, value: unknown): string => { + if (value === null || value === undefined || value === "") return "—"; + if (field === "rateValue") { + const num = Number(value); + return Number.isNaN(num) ? String(value) : num.toLocaleString(); + } + return String(value).replace(/_/g, " "); +}; + +/** "Ocean freight · 40HC" — what rate this change targets. */ +const rateSummary = (r: RateChangeRequest): string => { + const rate = (r.rate ?? {}) as Record; + const parts = [ + rate.rateType ? String(rate.rateType).replace(/_/g, " ") : null, + rate.appliesTo ? String(rate.appliesTo) : null, + rate.trigger && rate.trigger !== "ALWAYS" ? String(rate.trigger) : null, + ].filter(Boolean); + return parts.join(" · ") || "Rate"; +}; + +/** The headline change, so the queue is scannable without expanding: "100 → 200 USD". */ +const headline = (r: RateChangeRequest): string | null => { + if (!("rateValue" in r.payload)) return null; + const currency = String(r.payload.currency ?? r.previousValues.currency ?? (r.rate as Record | undefined)?.currency ?? ""); + const before = fmtValue("rateValue", r.previousValues.rateValue); + const after = fmtValue("rateValue", r.payload.rateValue); + return `${before} → ${after}${currency ? ` ${currency}` : ""}`; +}; + +type Decide = UseMutationResult< + RateChangeRequest, + unknown, + { id: string; decisionNote?: string } +>; + +interface RateApprovalsSectionProps { + requests: RateChangeRequest[]; + /** Whether this user holds the rates approve permission. */ + canDecide: boolean; + approve: Decide; + reject: Decide; +} + +/** + * Pending edits to LIVE rates. Each row is a before→after diff: the left value + * is what pricing charges right now and keeps charging until someone approves. + * Rendered above the rates table. + */ +const RateApprovalsSection = ({ + requests, + canDecide, + approve, + reject, +}: RateApprovalsSectionProps) => { + const [openId, setOpenId] = useState(null); + const [notes, setNotes] = useState>({}); + + if (requests.length === 0) return null; + + const decidingId = approve.variables?.id ?? reject.variables?.id ?? null; + + return ( + + + + Pending rate changes + + {requests.length} + + + + Each rate below still charges its current value. Nothing changes until approved. + + + + {requests.map((r) => { + const isOpen = openId === r.id; + const fields = Object.keys(r.payload); + const summaryLine = headline(r); + // Only the row being decided shows a spinner — the mutation's + // isPending is shared across every row. + const busy = decidingId === r.id; + + return ( + + + + + + update + + + {rateSummary(r)} + + + + {summaryLine ? ( + + + {fmtValue("rateValue", r.previousValues.rateValue)} + + + + {fmtValue("rateValue", r.payload.rateValue)} + + + {String( + r.payload.currency ?? + r.previousValues.currency ?? + (r.rate as Record | undefined)?.currency ?? + "", + )} + + + ) : null} + + + + Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "} + {fields.length === 1 ? "field" : "fields"} changed + + + + + + {canDecide ? ( + + + + + ) : ( + + + Awaiting approver + + + )} + + + + + {fields.map((field) => ( + + + {FIELD_LABELS[field] ?? field} + + + {fmtValue(field, r.previousValues[field])} + + + + {fmtValue(field, r.payload[field])} + + + ))} + {canDecide ? ( + +

Supports any format: one per line, comma-separated, or {REF1,REF2} groups.

+ +
+ + + + +
+
+
+
+
+ +
+
+ +
+ + + + + +
+
+ + + + + + + +
+ +
+ + + + + + + + +
JourneyDuplicate Bookings
+
+
+ + + + + diff --git a/booking-extractor.html b/booking-extractor.html new file mode 100644 index 000000000..844c98b2c --- /dev/null +++ b/booking-extractor.html @@ -0,0 +1,256 @@ + + + + + + EDR Booking Extractor + + + + +

EDR Booking Extractor

+ +
+ +
+ + Drop bookings.json here or click to browse +
+

Accepts a JSON array of bookings or an object with a bookings key.

+
+ + + +
+
+ +
+
+
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + +
#Booking RefStatusBooking TypePhoneEmailDepartureOriginDestinationPassenger(s)Coach - SeatPayment MethodPayment StatusTotal (DJF)Created At
+
+
+ + + + + diff --git a/booking-proxy.mjs b/booking-proxy.mjs new file mode 100644 index 000000000..27f9248ee --- /dev/null +++ b/booking-proxy.mjs @@ -0,0 +1,53 @@ +import http from 'http'; +import https from 'https'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const PORT = 8080; +const __dir = path.dirname(fileURLToPath(import.meta.url)); + +const server = http.createServer((req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + + // Serve any .html file in the same directory + if (req.url === '/' || req.url.endsWith('.html')) { + const filename = req.url === '/' ? 'booking-checker.html' : req.url.slice(1); + const filepath = path.join(__dir, filename); + if (fs.existsSync(filepath)) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + fs.createReadStream(filepath).pipe(res); + } else { + res.writeHead(404); res.end('Not found'); + } + return; + } + + // Proxy /proxy?url= + if (req.url.startsWith('/proxy?url=')) { + const target = decodeURIComponent(req.url.slice('/proxy?url='.length)); + const parsed = new URL(target); + const mod = parsed.protocol === 'https:' ? https : http; + const options = { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname + parsed.search, + method: req.method, + headers: { ...req.headers, host: parsed.hostname }, + }; + const proxy = mod.request(options, (apiRes) => { + res.writeHead(apiRes.statusCode, apiRes.headers); + apiRes.pipe(res); + }); + proxy.on('error', (e) => { res.writeHead(502); res.end(e.message); }); + req.pipe(proxy); + return; + } + + res.writeHead(404); res.end(); +}); + +server.listen(PORT, () => console.log(`Booking checker: http://localhost:${PORT}/booking-checker.html`)); diff --git a/ticket-extractor.html b/ticket-extractor.html new file mode 100644 index 000000000..17be60576 --- /dev/null +++ b/ticket-extractor.html @@ -0,0 +1,239 @@ + + + + + + EDR Ticket Extractor + + + + +

EDR Ticket Extractor

+ +
+ +
+ + Drop tickets.json here or click to browse +
+

Accepts a JSON array of tickets or an object with a tickets key.

+
+ + + +
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + +
#Ticket No.Booking RefPassengerPhoneEmailJourney TypeOriginDestinationSeat ClassCoachSeat
+
+
+ + + + + From 282bb949bae8525bb460914313ccc1757618b8de Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 11:45:35 +0300 Subject: [PATCH 61/88] Migration issue resolution --- .../migration.sql | 4 +++ .../migration.sql | 4 +++ .../migration.sql | 4 +++ .../migration.sql | 33 +++++++++++++++++++ 4 files changed, 45 insertions(+) diff --git a/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql index 2608b4cbf..83a49ef67 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql @@ -1,3 +1,4 @@ +<<<<<<< Updated upstream -- Remove duplicate JourneySegment rows, keeping the one with the lowest id -- (earliest created) per (scheduleId, seatId, departureStationId) group. -- This cleans up any existing double-bookings before the unique index is applied. @@ -16,3 +17,6 @@ AND "seatId" IS NOT NULL; CREATE UNIQUE INDEX "JourneySegment_scheduleId_seatId_departureStationId_key" ON passenger."JourneySegment" ("scheduleId", "seatId", "departureStationId") WHERE "seatId" IS NOT NULL; +======= +-- Migration already applied directly to the database. +>>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql index 375f40f7e..e04c02621 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql @@ -1,3 +1,4 @@ +<<<<<<< Updated upstream -- Rename StopStatus enum values to reflect segment-level booking lifecycle. -- UPCOMING → OPEN (segment is bookable) -- APPROACHING → CHECKIN_CLOSED (within check-in cutoff, no new bookings) @@ -10,3 +11,6 @@ ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'CURRENT' TO 'BOARDED'; -- Add per-route check-in window. Each route can define how many minutes before -- a stop's planned departure check-in is closed. Defaults to 30 minutes. ALTER TABLE "passenger"."Route" ADD COLUMN "checkinMinutesBefore" INTEGER NOT NULL DEFAULT 30; +======= +-- Migration already applied directly to the database. +>>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql index 0b0995292..5fd9ae0f5 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql @@ -1 +1,5 @@ +<<<<<<< Updated upstream ALTER TABLE "passenger"."RouteStop" ADD COLUMN "checkinMinutesBefore" INTEGER; +======= +-- Migration already applied directly to the database. +>>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql index 2a9a1be65..8553fc81b 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql @@ -1,13 +1,21 @@ -- Make scheduleId non-nullable: backfill from the parent booking, then add NOT NULL. UPDATE passenger."BookingSeat" bs +<<<<<<< Updated upstream SET schedule_id = b.schedule_id FROM passenger."Booking" b WHERE bs.booking_id = b.id AND bs.schedule_id IS NULL +======= +SET "scheduleId" = b."scheduleId" +FROM passenger."Booking" b +WHERE bs."bookingId" = b.id + AND bs."scheduleId" IS NULL +>>>>>>> Stashed changes AND bs.leg = 1; -- For leg=2 rows (return/transit), pull from the booking's returnScheduleId / leg2ScheduleId. UPDATE passenger."BookingSeat" bs +<<<<<<< Updated upstream SET schedule_id = COALESCE( (b.return_schedule_id), (b.leg2_schedule_id), @@ -31,3 +39,28 @@ ALTER TABLE passenger."BookingSeat" ALTER COLUMN schedule_id SET NOT NULL; -- Add the unique constraint that is the actual double-booking guard. CREATE UNIQUE INDEX "BookingSeat_scheduleId_seatId_key" ON passenger."BookingSeat"(schedule_id, seat_id); +======= +SET "scheduleId" = COALESCE( + b."returnScheduleId", + b."leg2ScheduleId", + b."scheduleId" +) +FROM passenger."Booking" b +WHERE bs."bookingId" = b.id + AND bs."scheduleId" IS NULL + AND bs.leg = 2; + +-- Catch any remaining NULLs using the booking's schedule. +UPDATE passenger."BookingSeat" bs +SET "scheduleId" = b."scheduleId" +FROM passenger."Booking" b +WHERE bs."bookingId" = b.id + AND bs."scheduleId" IS NULL; + +-- Now enforce NOT NULL. +ALTER TABLE passenger."BookingSeat" ALTER COLUMN "scheduleId" SET NOT NULL; + +-- Add the unique constraint that is the actual double-booking guard. +CREATE UNIQUE INDEX "BookingSeat_scheduleId_seatId_key" + ON passenger."BookingSeat"("scheduleId", "seatId"); +>>>>>>> Stashed changes From d5e87449b511dff8246d5b5da49de868f0d5ad7f Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 18 Jul 2026 09:04:34 +0000 Subject: [PATCH 62/88] fix gps --- .../2300000000000-RepairGpsTrackingTables.ts | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/2300000000000-RepairGpsTrackingTables.ts diff --git a/apps/edr-freight-api/src/migrations/2300000000000-RepairGpsTrackingTables.ts b/apps/edr-freight-api/src/migrations/2300000000000-RepairGpsTrackingTables.ts new file mode 100644 index 000000000..f4628db36 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2300000000000-RepairGpsTrackingTables.ts @@ -0,0 +1,83 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Repair for environments missing the GPS tracking tables. + * + * AddGpsTracking2000000000000 creates freight.gps_devices / gps_positions, but + * some databases have it RECORDED in public.migrations without the tables ever + * landing. TypeORM never re-runs a recorded migration, so those environments + * stay broken through any number of restarts — the GT06 listener accepts tracker + * packets on its TCP port regardless of schema state and fails per packet with + * `relation "freight.gps_devices" does not exist`, dropping position fixes. + * + * This re-issues the same DDL under a new name so it is applied afresh. Every + * statement is IF NOT EXISTS, so it is a no-op where the tables already exist + * and safe on every environment. + * + * Kept byte-identical to the original DDL on purpose: this must converge on the + * schema the entities expect, not a variant of it. + */ +export class RepairGpsTrackingTables2300000000000 implements MigrationInterface { + name = "RepairGpsTrackingTables2300000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.gps_devices ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + imei varchar(20) NOT NULL UNIQUE, + name varchar, + vehicle_id uuid REFERENCES freight.vehicles(id), + status varchar(16) NOT NULL DEFAULT 'REGISTERED', + last_seen_at timestamptz, + last_lat numeric(10,6), + last_lng numeric(10,6), + last_speed numeric(6,2), + last_course int, + last_fix_at timestamptz, + voltage_level int, + gsm_level int, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_GPS_DEVICES_VEHICLE" + ON freight.gps_devices (vehicle_id) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.gps_positions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + device_id uuid NOT NULL, + imei varchar(20) NOT NULL, + vehicle_id uuid, + lat numeric(10,6) NOT NULL, + lng numeric(10,6) NOT NULL, + speed numeric(6,2) NOT NULL DEFAULT 0, + course int NOT NULL DEFAULT 0, + satellites int NOT NULL DEFAULT 0, + positioned boolean NOT NULL DEFAULT false, + gps_time timestamptz NOT NULL, + alarm int NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_DEVICE_TIME" + ON freight.gps_positions (device_id, gps_time) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_VEHICLE_TIME" + ON freight.gps_positions (vehicle_id, gps_time) + `); + } + + public async down(): Promise { + // No-op: dropping the tables would discard tracker history on environments + // where this migration was the one that created them. AddGpsTracking owns + // the teardown. + } +} From 406fbf6c45c57e22e277addec69e8a1adaf4fb29 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 18 Jul 2026 09:12:52 +0000 Subject: [PATCH 63/88] split export --- .../contract-rate-schedule.builder.spec.ts | 98 +++++ ...00000000-RefreshContractPricingArticles.ts | 69 ++++ .../bookings/booking-transition.service.ts | 21 +- .../contracts/contract-booking.service.ts | 42 ++- .../rule-engine/dto/create-rate.dto.ts | 9 +- .../rule-engine/services/rates.service.ts | 22 +- .../train-scheduling/booking-batch.service.ts | 230 +++++++++++- .../booking-notifier.service.ts | 27 +- .../remainder-placement.service.spec.ts | 211 +++++++++++ .../remainder-placement.service.ts | 342 ++++++++++++++++++ .../train-scheduling.module.ts | 2 + .../backoffice/src/auth/http.ts | 22 +- .../pages/contracts/GlClearanceDetailPage.tsx | 24 +- .../src/services/contracts.service.ts | 14 +- 14 files changed, 1092 insertions(+), 41 deletions(-) create mode 100644 apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts create mode 100644 apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts new file mode 100644 index 000000000..a7b007617 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts @@ -0,0 +1,98 @@ +import { ContractRateScheduleBuilder } from './contract-rate-schedule.builder'; +import { Rate } from '../modules/rule-engine/entities/rate.entity'; + +/** Minimal Rate factory for the builder unit tests. */ +function rate(partial: Partial): Rate { + return { + trigger: 'ALWAYS', + appliesTo: 'CONTAINER', + tradeDirection: 'IMPORT', + rateType: 'CONTAINER_IMPORT', + currency: 'USD', + rateValue: 200, + rateUnit: 'PER_CONTAINER', + ...partial, + } as Rate; +} + +describe('ContractRateScheduleBuilder', () => { + const LIVE: Rate[] = [ + rate({ + appliesTo: 'CONTAINER', + tradeDirection: 'IMPORT', + rateType: 'CONTAINER_IMPORT', + rateValue: 200, + rateUnit: 'PER_CONTAINER', + originYard: { label: 'Negad' } as never, + destinationYard: { label: 'Mojo Dry Port' } as never, + containerType: { label: '40ft GP' } as never, + }), + rate({ + appliesTo: 'CONTAINER', + tradeDirection: 'EXPORT', // wrong direction — must be filtered out for import + rateType: 'CONTAINER_EXPORT', + rateValue: 819, + originYard: { label: 'GMP' } as never, + destinationYard: { label: 'SGTD' } as never, + }), + rate({ + appliesTo: 'BULK', // wrong freight — filtered out for a container contract + tradeDirection: 'IMPORT', + rateType: 'BULK_IMPORT', + rateUnit: 'PER_WAGON', + rateValue: 100, + }), + rate({ + appliesTo: 'FIRST_MILE', + trigger: 'ALWAYS', + tradeDirection: null, + rateUnit: 'PER_CONTAINER', + rateValue: 50, + }), + rate({ + appliesTo: 'OTHER', + trigger: 'CUSTOMS_CLEARANCE', + tradeDirection: null, + rateType: 'CUSTOMS_CLEARANCE', + rateUnit: 'FLAT', + rateValue: 120, + }), + ]; + + const build = (dir: 'IMP' | 'EXP' | 'DOM', freight: 'CON' | 'BULK') => { + const service = { findLiveRatesDetailed: jest.fn().mockResolvedValue(LIVE) }; + return new ContractRateScheduleBuilder(service as never).build(dir, freight); + }; + + it('shows only import container lanes for an import container contract', async () => { + const s = await build('IMP', 'CON'); + expect(s.freightLanes).toHaveLength(1); + expect(s.freightLanes[0]).toMatchObject({ + route: 'Negad → Mojo Dry Port', + cargo: '40ft GP', + currency: 'USD', + amount: '200', + unit: 'per container', + }); + }); + + it('always lists route-agnostic services and surcharges', async () => { + const s = await build('IMP', 'CON'); + expect(s.additionalServices).toHaveLength(1); + expect(s.additionalServices[0].route).toBe('First-mile pickup by truck'); + expect(s.surcharges).toHaveLength(1); + expect(s.surcharges[0].route).toBe('Customs clearance service'); + }); + + it('excludes container lanes from a bulk contract', async () => { + const s = await build('IMP', 'BULK'); + expect(s.freightLanes).toHaveLength(1); + expect(s.freightLanes[0]).toMatchObject({ amount: '100', unit: 'per wagon' }); + }); + + it('flags an empty schedule when nothing priced matches', async () => { + const service = { findLiveRatesDetailed: jest.fn().mockResolvedValue([]) }; + const s = await new ContractRateScheduleBuilder(service as never).build('DOM', 'CON'); + expect(s.isEmpty).toBe(true); + }); +}); diff --git a/apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts b/apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts new file mode 100644 index 000000000..566d0308e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults'; + +/** + * Refresh the `pricing` article body of the six seeded contract templates to + * the live-rate-schedule wording. The per-lane figures (e.g. "USD 400 per + * wagon") are now rendered from the LIVE rate config instead of frozen prose, + * so any template whose pricing article still carries a hardcoded price token + * is rewritten to the current seed text. + * + * The guard `body ~ '(USD|ETB) [0-9]'` identifies the auto-seeded original + * prose (which always quoted a currency + figure) and matches neither an + * already-migrated body nor a hand-edited one that adopted the schedule + * wording — so admin edits are preserved. Idempotent: after the rewrite the + * price token is gone, so a re-run is a no-op. Fresh databases seed the new + * text directly (CreateContractTemplates imports the same seed), making this + * a targeted backfill for databases seeded before the seed changed. + */ +const HARDCODED_PRICE_TOKEN = '(USD|ETB) [0-9]'; + +export class RefreshContractPricingArticles2360000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + for (const seed of CONTRACT_TEMPLATE_DEFAULTS) { + const pricing = seed.articles.find((a) => a.id === 'pricing'); + if (!pricing) continue; + + // Rewrite only the article whose id = 'pricing', in place, and only when + // its body still quotes a hardcoded currency figure. jsonb_agg keeps the + // rest of the article (id/title/order) and every other article intact. + await queryRunner.query( + ` + UPDATE freight.contract_templates AS t + SET articles = ( + SELECT jsonb_agg( + CASE + WHEN elem->>'id' = 'pricing' + THEN jsonb_set(elem, '{body}', to_jsonb($2::text), true) + ELSE elem + END + ORDER BY ord + ) + FROM jsonb_array_elements(t.articles) WITH ORDINALITY AS a(elem, ord) + ), + updated_at = now() + WHERE t.code = $1 + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements(t.articles) AS x + WHERE x->>'id' = 'pricing' + AND x->>'body' ~ $3 + ); + `, + [seed.code, pricing.body, HARDCODED_PRICE_TOKEN], + ); + } + } + + /** + * Irreversible in practice — the original per-lane figures are not restored. + * A no-op down keeps the migration reversible-by-contract without + * resurrecting stale hardcoded prices. + */ + public async down(): Promise { + // intentionally empty + } +} 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 690b66385..aacc68012 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 @@ -42,6 +42,7 @@ export class BookingTransitionService { private readonly bookingsRepository: BookingsRepository, private readonly ruleEngineService: RuleEngineService, private readonly pricingService: BookingPricingService, + @Inject(forwardRef(() => BookingContractService)) private readonly contractService: BookingContractService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, @@ -1049,7 +1050,25 @@ export class BookingTransitionService { booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType); if (isExportTrain) { - await this.bookingBatchService.pickExportSchedule(scheduledBooking); + // With export split ON the booking no longer has to ride ONE train whole: + // the largest fitting part is offered and the leftover rebooks on the next + // train. So the day is only unbookable when NO export train that day has + // any room at all — reject on the day total, not on a single-train fit. + // With the flag off this stays the strict whole-booking gate. + if (process.env.FREIGHT_EXPORT_SPLIT === "true") { + const fitting = await this.bookingBatchService.fittingTrainsForDay( + scheduledBooking, + eatDay(date), + "EXPORT", + ); + if (!fitting.length) { + throw new ConflictException( + "No export train on this day has space left — pick another shipment day.", + ); + } + } else { + await this.bookingBatchService.pickExportSchedule(scheduledBooking); + } } await this.bookingsRepository.update(bookingId, { 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 826bc33b0..8ef819eaf 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 @@ -25,6 +25,7 @@ import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { eatDay } from '../train-scheduling/batch-window.util'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; @@ -54,6 +55,18 @@ export interface CreateBookingUnderContractResult { warnings: string[]; } +/** + * Outstanding split remainder of a contract: what was booked in the first split + * booking's pre-split snapshot MINUS everything currently booked. Container + * contracts report per size; bulk reports one tonnage figure. `null` when the + * contract has no live split chain. Consumed by the remainder-placement engine + * to size the auto-created remainder booking. + */ +export type SplitOutstanding = { + bySize: Map; + bulk: { total: number; outstanding: number } | null; +}; + /** * The single create path for shipment bookings under a contract. * @@ -966,9 +979,11 @@ export class ContractBookingService { * (CANCELLED / REJECTED / EXPIRED) release their share. Null when the * contract has no live split booking. */ - private async splitOutstanding( - contract: Contract, - ): Promise<{ bySize: Map; bulk: { total: number; outstanding: number } | null } | null> { + /** + * Public: the remainder-placement engine reads this to size the auto-created + * remainder booking. Returns `null` when there is no live split chain. + */ + async splitOutstanding(contract: Contract): Promise { const first = await this.dataSource .getRepository(Booking) .createQueryBuilder('b') @@ -1015,6 +1030,25 @@ export class ContractBookingService { const probe = await this.buildExportProbe(contract, route, dto, yards); const report = await this.bookingBatchService.exportSpaceReport(probe); if (report.scheduleId) return; + + // With export split ON a booking no longer has to ride ONE train whole: the + // largest fitting part is offered and the leftover is rebooked on the next + // train. Rejecting on the single-train fit here would block exactly the + // bookings the split exists to serve — including the auto-created remainder, + // which by definition did not fit the train it was split off. Fall back to + // the day total: unbookable only when NO export train that day has room. + if (process.env.FREIGHT_EXPORT_SPLIT === 'true') { + const fitting = await this.bookingBatchService.fittingTrainsForDay( + probe, + eatDay(new Date(dto.scheduledDate)), + 'EXPORT', + ); + if (fitting.length > 0) return; + throw new BadRequestException( + 'No export train on this day has space left — pick another shipment day.', + ); + } + throw new BadRequestException( report.fullMessage ?? 'Not enough train space for this day.', ); @@ -1636,6 +1670,8 @@ export class ContractBookingService { isGovernment: contract.isGovernment, shippingLineId: null, contractRouteId: route?.id ?? null, + originYardId: route?.originYardId ?? null, + destinationYardId: route?.destinationYardId ?? null, cargoTotalWeightVgm: this.resolveBulkTons(dto), firstMilePickupAddress: contract.firstMilePickupAddress ?? null, lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index 5507692d9..41971bbf1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -74,9 +74,14 @@ export class CreateRateDto { @Transform(({ value }) => Number(value)) rateValue!: number; - @ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' }) + @ApiPropertyOptional({ + enum: RATE_UNITS, + description: + 'Unit basis for the rate. Optional for shapes with a forced unit (overweight is always PER_TON — the admin form hides the field and omits it); required otherwise.', + }) + @IsOptional() @IsIn([...RATE_UNITS]) - rateUnit!: string; + rateUnit?: string; } export class SubmitRateForApprovalDto { diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index edd9ac9f0..488865f38 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -68,15 +68,21 @@ export class RatesService { private resolveRateUnit( appliesTo: Rate['appliesTo'], trigger: Rate['trigger'], - requestedUnit: Rate['rateUnit'], + requestedUnit: Rate['rateUnit'] | undefined, ): Rate['rateUnit'] { - // Overweight is per-ton, full stop. + // Overweight is per-ton, full stop — the admin form hides the unit field + // for it and omits rateUnit from the payload entirely. if (trigger === 'OVERWEIGHT') return 'PER_TON'; - if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) { - const allowed = allowedRateUnits({ appliesTo, trigger }).join(', '); + const allowed = allowedRateUnits({ appliesTo, trigger }); + if (!requestedUnit) { throw new BadRequestException( - `Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed}.`, + `Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`, + ); + } + if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) { + throw new BadRequestException( + `Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`, ); } return requestedUnit; @@ -272,7 +278,11 @@ export class RatesService { tradeDirection, isBulk: this.resolvesToBulk(appliesTo, intercityKind), }); - const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']); + const rateUnit = this.resolveRateUnit( + appliesTo, + trigger, + dto.rateUnit as Rate['rateUnit'] | undefined, + ); await this.assertNoDuplicatePattern({ rateType, 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 866885074..c06f0ee81 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 @@ -71,6 +71,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { BookingSplitService } from './booking-split.service'; +import { RemainderPlacementService } from './remainder-placement.service'; import { BookingWindowGateway } from './booking-window.gateway'; import { MAX_TEU_SLOTS_PER_WAGON, @@ -317,9 +318,29 @@ export class BookingBatchService implements OnModuleInit { @Optional() private readonly milestoneService?: ClearanceMilestoneService, @Optional() private readonly splitService?: BookingSplitService, + @Optional() + @Inject(forwardRef(() => RemainderPlacementService)) + private readonly remainderPlacement?: RemainderPlacementService, ) {} + /** + * Auto-place a paid booking's split remainder onto the next fitting train. + * Gated so it can ship dark: off unless FREIGHT_AUTO_REMAINDER=true. + */ + private get autoRemainderEnabled(): boolean { + return process.env.FREIGHT_AUTO_REMAINDER === "true"; + } + + /** + * Let EXPORT bookings split (offer the largest fitting part, leftover rebooks + * on the next train). Separate flag from auto-remainder: export touches the + * FCFS money path, so partial-offer can be enabled independently. + */ + private get exportSplitEnabled(): boolean { + return process.env.FREIGHT_EXPORT_SPLIT === "true"; + } + /** On boot, reconcile OPEN route-days and re-arm settle timers. */ async onModuleInit(): Promise { const groups = await this.openRouteDayGroups(); @@ -496,6 +517,34 @@ export class BookingBatchService implements OnModuleInit { // to the offered part before it boards (remainder returns to the contract cap). if (this.splitService) { await this.splitService.applySplit(bookingId); + + // The split only happens on payment (here) — so auto-placing the remainder + // also only happens once the customer has accepted+paid. Re-read to see if + // applySplit actually reduced this booking (an open offer existed); if so, + // auto-create + place the remainder booking on the next fitting train. + // applySplit committed its own transaction before returning, so this reads + // the reduced lines. Best-effort: a placement failure never blocks the + // paid booking from boarding — the remainder falls back to manual rebook. + if (this.autoRemainderEnabled && this.remainderPlacement) { + const split = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId } }); + // Export remainders only auto-place when export split is on — otherwise + // an export booking never splits in the first place. + const directionOn = + split?.tradeDirection !== "EXPORT" || this.exportSplitEnabled; + if (split?.isSplit && directionOn) { + await this.remainderPlacement + .placeRemainder(split) + .catch((err) => + this.logger.error( + `Auto-place remainder failed for ${split.reference}: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + ); + } + } } const linked = @@ -731,6 +780,76 @@ export class BookingBatchService implements OnModuleInit { ); } + /** + * Trains that can carry a booking's leg on a given day, earliest departure + * first, each with the largest number of wagons it could still admit for the + * booking's wagon type. Direction-filtered: EXPORT bookings see export trains, + * IMPORT/DOMESTIC see non-export trains. Measures against the booking's FULL + * allowed wagon-type set ({@link dimsForAllowed}) so a train stocking a + * non-primary allowed type still counts. The remainder placer uses this to + * pick the next fitting train; the `free` wagon count is the best across the + * allowed types (a train fits under whichever allowed type gives most room). + */ + async fittingTrainsForDay( + booking: Booking, + day: string, + direction: "IMPORT" | "EXPORT", + ): Promise> { + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft }, + { status: TrainScheduleStatusEnum.Scheduled }, + ], + }); + const candidates = corridor + .filter( + (s) => + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + s.bookingWindowStatus !== "FULL" && + (direction === "EXPORT" + ? s.direction === "EXPORT" + : s.direction !== "EXPORT"), + ) + .sort( + (a, b) => + a.scheduledDepartureDate!.getTime() - + b.scheduledDepartureDate!.getTime(), + ); + + const wagonDims = await this.loadWagonDims(); + const dimsOptions = this.dimsForAllowed(booking, wagonDims); + const out: Array<{ scheduleId: string; departure: Date; freeWagons: number }> = []; + + for (const candidate of candidates) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( + candidate.id, + ); + const locomotive = schedule?.trainSet?.locomotive; + if (!schedule || !locomotive) continue; + const limits = await this.capacityLimits(locomotive); + const budget = await this.remainingBudget(schedule, limits, wagonDims); + const leg = budget.legOf(booking.originYardId, booking.destinationYardId); + if (!leg) continue; // this train's route doesn't carry the booking's leg + const room = budget.remainingFor(leg); + // Best usable wagons across the allowed types — a train fits under + // whichever configured wagon type gives it the most room. + let freeWagons = 0; + for (const dims of dimsOptions) { + const w = this.bookableWithin(room, dims).wagons; + if (w > freeWagons) freeWagons = w; + } + if (freeWagons > 0) { + out.push({ + scheduleId: schedule.id, + departure: schedule.scheduledDepartureDate!, + freeWagons, + }); + } + } + return out; + } + /** * Advisory free-wagon count for an IMPORT/DOMESTIC booking on a given day, * summed across every train on the booking's corridor that day. Unlike the @@ -789,6 +908,58 @@ export class BookingBatchService implements OnModuleInit { return { freeWagons, need, trainsForDay }; } + /** + * Export split: no single train carries the whole booking, so offer the + * largest fitting part on the export train with the most room for its leg. + * Returns true when an offer was opened (the caller must NOT then reserve — + * the offer already opened its own pay window), false when the booking fits + * whole somewhere (normal FCFS path) or no meaningful partial exists. + * + * Only the offer is written here: the booking is reduced to the offered part + * on payment (applySplit), and the leftover is auto-placed afterwards. So an + * unpaid export booking stays whole and the customer may still cancel it. + */ + private async tryExportPartialOffer(booking: Booking): Promise { + if (!this.splitService) return false; + const report = await this.exportSpaceReport(booking); + // A train fits it whole — nothing to split, take the normal path. + if (report.scheduleId) return false; + if (!report.bestAvailable || report.bestAvailable.wagons < 1) return false; + + if (!booking.scheduledDate) return false; + const day = eatDay(new Date(booking.scheduledDate)); + const fitting = await this.fittingTrainsForDay(booking, day, "EXPORT"); + if (!fitting.length) return false; + // Most room first — the largest single part ships now, the smallest leftover + // is what has to find another train. + const target = [...fitting].sort((a, b) => b.freeWagons - a.freeWagons)[0]; + + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( + target.scheduleId, + ); + const locomotive = schedule?.trainSet?.locomotive; + if (!schedule || !locomotive) return false; + const wagonDims = await this.loadWagonDims(); + const limits = await this.capacityLimits(locomotive); + const budget = await this.remainingBudget(schedule, limits, wagonDims); + const leg = budget.legOf(booking.originYardId, booking.destinationYardId); + if (!leg) return false; + + const offered = await this.tryPartialOffer( + booking, + schedule.id, + budget.remainingFor(leg), + report.need, + ); + if (!offered) return false; + this.logger.log( + `[EXPORT SPLIT] offered partial to ${booking.reference} on schedule ` + + `${schedule.id} — leftover rebooks on the next train once paid.`, + ); + this.notifyBoardChanged(schedule.id, "batch_fill"); + return true; + } + /** * Accept an export booking into the FCFS flow. Solo bookings reserve immediately. * A consolidated booking reserves as a pair only once BOTH partners are ready @@ -800,6 +971,15 @@ export class BookingBatchService implements OnModuleInit { async acceptExportBooking(booking: Booking): Promise { const partnerId = booking.consolidationPartnerId ?? null; if (!partnerId) { + // Export split: when no single train carries the whole booking, offer the + // largest fitting part instead of failing the accept. The customer pays + // that part; on payment applySplit reduces this booking to it and the + // leftover is auto-placed as its own booking on the next train. Pairs are + // excluded (handled below) — a shared wagon is never split. + if (this.exportSplitEnabled && this.isSplitEligible(booking, false)) { + const offered = await this.tryExportPartialOffer(booking); + if (offered) return; + } const scheduleId = await this.pickExportSchedule(booking); await this.reserveOnExport([booking], scheduleId); return; @@ -1852,15 +2032,23 @@ export class BookingBatchService implements OnModuleInit { } /** - * A lone commercial IMPORT booking on a GENERAL or ONE_TIME contract may be - * offered a partial (split-on-payment). Consolidated pairs never split (both-or- - * neither shared wagon) and government bookings never split (they preempt). + * A lone commercial booking on a GENERAL or ONE_TIME contract may be offered a + * partial (split-on-payment). Consolidated pairs never split (both-or-neither + * shared wagon) and government bookings never split (they preempt). + * + * IMPORT is always eligible. EXPORT is eligible only when export split is + * enabled: export historically rides one train whole, so splitting it changes + * the FCFS money path — each split part still rides ONE train whole, and the + * leftover becomes its own booking on the next train. */ private isSplitEligible(booking: Booking, isPair: boolean): boolean { + const directionOk = + booking.tradeDirection === "IMPORT" || + (booking.tradeDirection === "EXPORT" && this.exportSplitEnabled); return ( !isPair && !booking.isGovernment && - booking.tradeDirection === "IMPORT" && + directionOk && (booking.contractKind === "GENERAL" || booking.contractKind === "ONE_TIME") && this.splitService != null ); @@ -3174,6 +3362,40 @@ export class BookingBatchService implements OnModuleInit { }; } + /** + * EVERY wagon-type dimension a booking may ride — its cargo/container type's + * full allowed (many-to-many) wagon-type list, not just the first like + * {@link dimsFor}. The remainder placer needs the whole set so a train that + * stocks a non-primary allowed type still counts as fitting: a container type + * mapped to both NW5 and (say) NW7 must be measured against whichever a given + * train actually has free. Deduped by wagon-type id; falls back to the single + * representative dims when no allowed type is configured. + */ + private dimsForAllowed(booking: Booking, wagonDims: WagonDims): PerWagonDims[] { + const fallback = + booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container; + const ids = + booking.freightType === "BULK" + ? (booking.cargoType?.wagonTypes ?? []).map((wt) => wt.id) + : (booking.bookingContainers ?? []) + .flatMap((line) => line.containerType?.wagonTypes ?? []) + .map((wt) => wt.id); + const seen = new Set(); + const dims: PerWagonDims[] = []; + for (const id of ids) { + if (!id || seen.has(id)) continue; + seen.add(id); + const d = wagonDims.byWagonTypeId.get(id); + if (d) { + dims.push({ + ...d, + capacityTons: d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons, + }); + } + } + return dims.length ? dims : [fallback]; + } + /** * 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-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index 3d505e113..e17445b4d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -150,10 +150,18 @@ export class BookingNotifierService { ): Promise { const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000)); const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); + const leftover = totalWagons - offeredWagons; + // With auto-placement on, the leftover is booked FOR the customer on another + // train (its own invoice) — telling them to rebook it themselves would be + // wrong. Without it, the leftover returns to the contract to rebook. + const leftoverCopy = + process.env.FREIGHT_AUTO_REMAINDER === 'true' + ? `The remaining ${leftover} will be booked for you on another train, with its own invoice. ` + : `The remaining ${leftover} return${leftover === 1 ? 's' : ''} to your contract — book them yourself in a later window. `; const msg = `Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` + `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now. ` + - `The remaining ${totalWagons - offeredWagons} return${totalWagons - offeredWagons === 1 ? 's' : ''} to your contract — book them yourself in a later window. ` + + leftoverCopy + `If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`; await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)'); // HIGH: a split is a change to what the customer ordered AND a live payment @@ -164,6 +172,23 @@ export class BookingNotifierService { }); } + /** + * The wagons that did not fit the train the customer just paid for have been + * auto-booked as their own booking (`remainder`) — they ride another train and + * are billed separately. Sent instead of leaving the customer to rebook. + */ + remainderPlaced(remainder: Booking, parentReference: string): void { + const msg = + `The wagons left over from booking ${parentReference} have been booked as ` + + `${remainder.reference ?? remainder.id} on another train. ` + + `It carries its own invoice — pay it to secure that slot.`; + void this.notifyContact(remainder, msg, 'REMAINDER BOOKED'); + this.inApp(remainder, 'Leftover wagons booked', msg, { + type: NotificationType.INVOICE_ISSUED, + priority: NotificationPriority.HIGH, + }); + } + secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void { void (async () => { const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.spec.ts new file mode 100644 index 000000000..55cacaf03 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.spec.ts @@ -0,0 +1,211 @@ +import { RemainderPlacementService } from './remainder-placement.service'; + +/** + * The remainder placer reconstructs the outstanding split remainder as a new + * booking. The delicate parts under test: bulk sizes from the outstanding tons; + * container recovers real numbers from the SOFT-DELETED units (never fabricates) + * and throws on a shortfall; and nothing is placed when there's no outstanding + * or no fitting train. + */ +describe('RemainderPlacementService', () => { + const DAY = '2026-07-20'; + + function make(opts: { + freightType: 'CONTAINER' | 'BULK'; + contractKind?: 'ONE_TIME' | 'GENERAL'; + outstanding: unknown; + createThrows?: Error; + deferredUnits?: Array<{ + containerNumber: string; + vgmTons: number; + isHazardous?: boolean; + isReefer?: boolean; + }>; + fittingTrains?: Array<{ scheduleId: string }>; + }) { + const contract = { + id: 'c-1', + freightType: opts.freightType, + contractKind: opts.contractKind ?? 'ONE_TIME', + }; + const contractsRepository = { + findByIdWithRelations: jest.fn().mockResolvedValue(contract), + }; + const createUnderContract = opts.createThrows + ? jest.fn().mockRejectedValue(opts.createThrows) + : jest + .fn() + .mockResolvedValue({ booking: { id: 'rem-1', reference: 'BKG-R' }, warnings: [] }); + const contractBookingService = { + splitOutstanding: jest.fn().mockResolvedValue(opts.outstanding), + createUnderContract, + }; + const bookingBatchService = { + fittingTrainsForDay: jest + .fn() + .mockResolvedValue(opts.fittingTrains ?? [{ scheduleId: 's-2' }]), + }; + // getRepository is only hit on the container path (recoverDeferredUnits). + const lineRepo = { + find: jest.fn().mockResolvedValue([{ id: 'line-1' }]), + }; + const unitRepo = { + find: jest.fn().mockResolvedValue(opts.deferredUnits ?? []), + }; + const dataSource = { + getRepository: jest.fn((entity: { name?: string }) => { + const n = entity?.name ?? ''; + if (n.includes('Unit')) return unitRepo; + return lineRepo; + }), + }; + const notifier = { remainderPlaced: jest.fn() }; + const service = new RemainderPlacementService( + dataSource as never, + contractsRepository as never, + contractBookingService as never, + bookingBatchService as never, + notifier as never, + ); + return { + service, + createUnderContract, + contractBookingService, + bookingBatchService, + notifier, + }; + } + + const splitBooking = { + id: 'bk-1', + reference: 'BKG-1', + contractId: 'c-1', + scheduledDate: new Date('2026-07-20T06:00:00Z'), + createdByUserId: 'u-1', + } as never; + + it('sizes a BULK remainder from the outstanding tons', async () => { + const { service, createUnderContract } = make({ + freightType: 'BULK', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBe('rem-1'); + const dto = createUnderContract.mock.calls[0][1]; + expect(dto.bulkLines).toEqual([{ cargoWeightTons: 40 }]); + expect(dto.scheduledDate).toBe(DAY); + }); + + it('rebuilds a CONTAINER remainder from the soft-deleted units', async () => { + const deferredUnits = [ + { containerNumber: 'ABCD1234567', vgmTons: 12, isReefer: true }, + { containerNumber: 'ABCD7654321', vgmTons: 10, isHazardous: true }, + ]; + const { service, createUnderContract } = make({ + freightType: 'CONTAINER', + outstanding: { + bySize: new Map([['40ft', { total: 5, outstanding: 2 }]]), + bulk: null, + }, + deferredUnits, + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBe('rem-1'); + const dto = createUnderContract.mock.calls[0][1]; + expect(dto.containers).toHaveLength(1); + const line = dto.containers[0]; + expect(line.containerSize).toBe('40ft'); + expect(line.quantity).toBe(2); + expect(line.units.map((u: { containerNumber: string }) => u.containerNumber)).toEqual([ + 'ABCD1234567', + 'ABCD7654321', + ]); + expect(line.reeferQuantity).toBe(1); + expect(line.hazardousQuantity).toBe(1); + }); + + it('throws (→ no placement) when fewer units are recoverable than outstanding — never fabricates', async () => { + const { service, createUnderContract } = make({ + freightType: 'CONTAINER', + outstanding: { + bySize: new Map([['40ft', { total: 5, outstanding: 3 }]]), + bulk: null, + }, + deferredUnits: [{ containerNumber: 'ABCD1234567', vgmTons: 12 }], // only 1, need 3 + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBeNull(); + expect(createUnderContract).not.toHaveBeenCalled(); + }); + + it('is a no-op when there is no outstanding remainder', async () => { + const { service, createUnderContract } = make({ + freightType: 'BULK', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 0 } }, + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBeNull(); + expect(createUnderContract).not.toHaveBeenCalled(); + }); + + it('tells the customer the leftover wagons were booked on another train', async () => { + const { service, notifier } = make({ + freightType: 'BULK', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, + }); + await service.placeRemainder(splitBooking); + expect(notifier.remainderPlaced).toHaveBeenCalledWith( + expect.objectContaining({ id: 'rem-1' }), + 'BKG-1', + ); + }); + + it('never double-books the leftover when two payments land together', async () => { + const { service, createUnderContract } = make({ + freightType: 'BULK', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, + }); + // Both callers enter before either create commits. + await Promise.all([ + service.placeRemainder(splitBooking), + service.placeRemainder(splitBooking), + ]); + expect(createUnderContract).toHaveBeenCalledTimes(1); + }); + + // splitOutstanding subtracts a CONTRACT-WIDE booked total from ONE booking's + // snapshot — coherent only for ONE_TIME. On GENERAL that mixes scopes and + // either drops a real remainder or double-draws the cap, so we must not place. + it('never auto-places on a GENERAL contract (cap ledger mismatch)', async () => { + const { service, createUnderContract, contractBookingService } = make({ + freightType: 'BULK', + contractKind: 'GENERAL', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBeNull(); + expect(createUnderContract).not.toHaveBeenCalled(); + expect(contractBookingService.splitOutstanding).not.toHaveBeenCalled(); + }); + + // The paid booking has already boarded — a create-gate rejection (e.g. the + // export whole-train gate) must leave the remainder rebookable, not escape. + it('swallows a create rejection and leaves the remainder for manual rebook', async () => { + const { service } = make({ + freightType: 'BULK', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, + createThrows: new Error('Not enough train space for this day.'), + }); + await expect(service.placeRemainder(splitBooking)).resolves.toBeNull(); + }); + + it('is a no-op when the contract has no split chain', async () => { + const { service, createUnderContract } = make({ + freightType: 'BULK', + outstanding: null, + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBeNull(); + expect(createUnderContract).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts new file mode 100644 index 000000000..85b6d0878 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts @@ -0,0 +1,342 @@ +import { Injectable, Logger, forwardRef, Inject } from '@nestjs/common'; +import { DataSource, IsNull, Not } from 'typeorm'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; +import { Contract } from '../contracts/entities/contract.entity'; +import { + ContractBookingService, + SplitOutstanding, +} from '../contracts/contract-booking.service'; +import { ContractsRepository } from '../contracts/contracts.repository'; +import { + CreateBookingUnderContractDto, + CreateContainerUnitDto, +} from '../contracts/dto/create-booking-under-contract.dto'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { BookingBatchService } from './booking-batch.service'; +import { BookingNotifierService } from './booking-notifier.service'; +import { eatDay } from './batch-window.util'; + +/** + * Auto-creates and places the OUTSTANDING split remainder of a contract as a new + * booking, so the customer doesn't have to manually rebook the wagons that did + * not fit the train they just paid for. + * + * Fired (feature-flagged) right after `applySplit` runs on payment — i.e. only + * once the customer has actually accepted+paid the offered part. Before payment + * nothing is split: the booking stays whole and the customer may still edit or + * cancel it. See the split lifecycle in {@link BookingSplitService.applySplit}. + * + * IMPORT/DOMESTIC: the remainder booking is created with the next fitting + * shipment day set and then follows the normal windowed batch flow (train + * assigned at window close, paid in its own window). It is NOT force-reserved on + * a specific train — import is not FCFS. + * + * Container reconstruction is HYBRID: the remainder's quantities come from the + * split snapshot (`splitOutstanding`), but the actual container numbers / VGM / + * seals are read back from the units `applySplit` SOFT-DELETED off the parent + * (they survive as valid ISO records). We never `restore()` those rows — the new + * booking gets fresh rows — so the contract cap is never double-counted. + */ +@Injectable() +export class RemainderPlacementService { + private readonly logger = new Logger(RemainderPlacementService.name); + + constructor( + private readonly dataSource: DataSource, + private readonly contractsRepository: ContractsRepository, + @Inject(forwardRef(() => ContractBookingService)) + private readonly contractBookingService: ContractBookingService, + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatchService: BookingBatchService, + private readonly notifier: BookingNotifierService, + ) {} + + /** + * Create + place the outstanding split remainder of the contract that owns + * `splitBooking`. No-op when there is no live remainder or no fitting day. + * Returns the created remainder booking id, or null when nothing was placed + * (residual falls back to the customer's manual rebook, as today). + */ + async placeRemainder(splitBooking: Booking): Promise { + if (!splitBooking.contractId) return null; + // Two payment webhooks for the same contract landing together would both see + // the remainder as unbooked (the placing create has not committed yet) and + // each create one — double-booking the leftover. Serialize per contract: the + // second caller returns immediately and the first one's create is what the + // (now smaller) outstanding reflects. + if (this.inFlight.has(splitBooking.contractId)) { + this.logger.debug( + `Remainder placement already running for contract ${splitBooking.contractId} — skipped.`, + ); + return null; + } + this.inFlight.add(splitBooking.contractId); + try { + return await this.placeRemainderInner(splitBooking); + } finally { + this.inFlight.delete(splitBooking.contractId); + } + } + + /** Contracts with a placement in flight — see {@link placeRemainder}. */ + private readonly inFlight = new Set(); + + private async placeRemainderInner( + splitBooking: Booking, + ): Promise { + const contract = await this.contractsRepository.findByIdWithRelations( + splitBooking.contractId!, + ); + if (!contract) return null; + + // ONE_TIME only. `splitOutstanding` subtracts a CONTRACT-WIDE booked total + // from a SINGLE booking's pre-split snapshot, which is only coherent when + // the contract has exactly one live chain — that is the ONE_TIME invariant + // (enforced by hasSplitBooking → assertExactRemainder). On a GENERAL + // contract with other live bookings the subtraction mixes scopes: it either + // clamps to 0 and silently drops a real remainder, or sizes one that then + // draws the quantity cap a second time. GENERAL remainders keep the existing + // manual-rebook behaviour until the remainder can be derived from the + // offer's own dropped lines rather than from the contract-wide ledger. + if (contract.contractKind !== 'ONE_TIME') { + this.logger.debug( + `Contract ${contract.id} is ${contract.contractKind} — remainder left ` + + `for manual rebook (auto-placement is ONE_TIME only).`, + ); + return null; + } + + const outstanding = await this.contractBookingService.splitOutstanding( + contract, + ); + if (!outstanding || !this.hasOutstanding(contract, outstanding)) { + return null; + } + + // The next fitting day: the earliest day on/after the split booking's own day + // that still has an import train with room for this cargo type. We reuse the + // split booking as the capacity probe — it carries the leg + cargo relations. + const day = await this.nextFittingDay(splitBooking); + if (!day) { + this.logger.warn( + `No train with room for the remainder of contract ${contract.id} ` + + `(booking ${splitBooking.reference}) — left for manual rebook.`, + ); + return null; + } + + let dto: CreateBookingUnderContractDto; + try { + dto = await this.buildRemainderDto( + contract, + outstanding, + splitBooking.id, + day, + ); + } catch (err) { + // A reconstruction shortfall (fewer recoverable units than outstanding) + // must NOT fabricate container numbers — fail loudly, leave manual rebook. + this.logger.error( + `Could not reconstruct the remainder of contract ${contract.id}: ` + + `${err instanceof Error ? err.message : String(err)} — left for manual rebook.`, + ); + return null; + } + + // Any create-gate rejection (no train space, cap, container clash) must not + // escape: the customer's paid booking has already boarded, and a thrown + // error here would only be logged upstream while the remainder vanished + // silently. Fall back to leaving it rebookable, which is the pre-feature + // behaviour, and say so in the log. + let created: Awaited< + ReturnType + >; + try { + created = await this.contractBookingService.createUnderContract( + contract.id, + dto, + { id: splitBooking.createdByUserId ?? undefined }, + // System actor: a permission-bag carrying the contract create-booking key + // so the GL gate (isGlActor → hasFreightPermission) passes for GL Path B + // contracts; harmless for customer (Path A) contracts. + { permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] }, + ); + } catch (err) { + this.logger.error( + `Could not create the remainder booking for contract ${contract.id} ` + + `(from ${splitBooking.reference}): ${ + err instanceof Error ? err.message : String(err) + } — left for manual rebook.`, + ); + return null; + } + // EXPORT is FCFS — there is no window to wait for, so the remainder is + // reserved on the next export train right away (its own pay window opens). + // If it does not fit one train whole either, the export accept offers it a + // partial and the chain repeats on ITS payment: each pass leaves a strictly + // smaller remainder, so it terminates at the day's train count. + // IMPORT/DOMESTIC deliberately does NOT force a train: it carries the next + // fitting day and rides the normal windowed batch flow. + if (splitBooking.tradeDirection === 'EXPORT') { + const fresh = await this.dataSource + .getRepository(Booking) + .findOne({ + where: { id: created.booking.id }, + relations: { + company: true, + bookingContainers: { containerType: true }, + cargoType: true, + }, + }); + if (fresh) { + await this.bookingBatchService + .acceptExportBooking(fresh) + .catch((err) => + // No export train took it — it stays created and rebookable, which + // is the same place a customer-driven rebook would leave it. + this.logger.warn( + `Export remainder ${fresh.reference} created but not reserved: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + ); + } + } + + this.notifier.remainderPlaced( + created.booking, + splitBooking.reference ?? splitBooking.id, + ); + this.logger.log( + `Auto-placed split remainder of contract ${contract.id} as booking ` + + `${created.booking.reference} on ${day}.`, + ); + return created.booking.id; + } + + private hasOutstanding( + contract: Contract, + outstanding: SplitOutstanding, + ): boolean { + if (contract.freightType === 'CONTAINER') { + return [...outstanding.bySize.values()].some((s) => s.outstanding > 0); + } + return (outstanding.bulk?.outstanding ?? 0) > 0.001; + } + + /** + * The shipment day to create the remainder on — the split booking's own day. + * + * EXPORT is FCFS and must actually board a train that day, so a day with NO + * export train having room is rejected (null → left for manual rebook on a day + * the customer picks). IMPORT/DOMESTIC keeps the day regardless: its train is + * assigned by the batch engine at window close, not now, and the window may + * still free up — forcing a different day here would override the customer's + * binding shipment day. + */ + private async nextFittingDay(booking: Booking): Promise { + if (!booking.scheduledDate) return null; + const day = eatDay(new Date(booking.scheduledDate)); + if (booking.tradeDirection !== 'EXPORT') return day; + + const fitting = await this.bookingBatchService.fittingTrainsForDay( + booking, + day, + 'EXPORT', + ); + return fitting.length > 0 ? day : null; + } + + /** + * Build the create-DTO for the WHOLE outstanding remainder. Bulk uses the + * outstanding tonnage directly. Container reads the deferred (soft-deleted) + * units of the split booking back into real unit records. + */ + private async buildRemainderDto( + contract: Contract, + outstanding: SplitOutstanding, + splitBookingId: string, + day: string, + ): Promise { + const dto: CreateBookingUnderContractDto = { scheduledDate: day }; + + if (contract.freightType !== 'CONTAINER') { + const tons = outstanding.bulk?.outstanding ?? 0; + dto.bulkLines = [{ cargoWeightTons: tons }]; + return dto; + } + + // Container: recover the deferred units per size from the split booking's + // soft-deleted rows and reshape into DTO units. + const containers: NonNullable = []; + for (const [size, { outstanding: need }] of outstanding.bySize) { + if (need <= 0) continue; + const units = await this.recoverDeferredUnits(splitBookingId, size, need); + if (units.length < need) { + throw new Error( + `size ${size}: recovered ${units.length} deferred container(s) but ` + + `${need} are outstanding`, + ); + } + const line: NonNullable[number] = { + containerSize: size, + quantity: need, + units, + }; + line.hazardousQuantity = units.filter((u) => u.isHazardous).length; + line.reeferQuantity = units.filter((u) => u.isReefer).length; + containers.push(line); + } + dto.containers = containers; + return dto; + } + + /** + * The `need` deferred container units of a given size for the split booking, + * read from the SOFT-DELETED unit rows (oldest sortOrder first — mirroring the + * LIFO trim in applySplit so the same physical containers deferred are the + * ones rebooked). Returns them as DTO units; does NOT restore the rows. + */ + private async recoverDeferredUnits( + splitBookingId: string, + containerSize: string, + need: number, + ): Promise { + // The line ids of this booking for this size (live + soft-deleted): units + // key on bookingContainerId, so gather every line of the size first. + const lines = await this.dataSource + .getRepository(BookingContainer) + .find({ + where: { bookingId: splitBookingId, containerSize }, + withDeleted: true, + select: { id: true }, + }); + const lineIds = lines.map((l) => l.id); + if (!lineIds.length) return []; + + // Only the DELETED units are the deferred ones (live units stayed on the + // paid part). Oldest-first to match the deferred set. + const deferred = await this.dataSource + .getRepository(BookingContainerUnit) + .find({ + where: lineIds.map((bookingContainerId) => ({ + bookingContainerId, + deletedAt: Not(IsNull()), + })), + withDeleted: true, + order: { sortOrder: 'ASC', createdAt: 'ASC' }, + take: need, + }); + + return deferred.map((u) => ({ + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? undefined, + vgmTons: Number(u.vgmTons), + isHazardous: u.isHazardous, + isReefer: u.isReefer, + })); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 1e1eb1695..3b2b257c8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -32,6 +32,7 @@ import { IntercityService } from './intercity.service'; import { WsAuthService } from '../notification-inbox/ws-auth.service'; import { BookingJourneyService } from './booking-journey.service'; import { BookingSplitService } from './booking-split.service'; +import { RemainderPlacementService } from './remainder-placement.service'; import { BookingBatchOffer } from './entities/booking-batch-offer.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { NotificationsModule } from '../notifications/notifications.module'; @@ -79,6 +80,7 @@ import { ContractsModule } from '../contracts/contracts.module'; WsAuthService, BookingWindowService, BookingSplitService, + RemainderPlacementService, IntercityService, BookingJourneyService, ], diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts index e43351015..9fe86ab1e 100644 --- a/apps/edr-freight-web/backoffice/src/auth/http.ts +++ b/apps/edr-freight-web/backoffice/src/auth/http.ts @@ -15,10 +15,23 @@ import { } from "./cookies"; import type { AuthTokens } from "./types"; +declare module "axios" { + export interface AxiosRequestConfig { + /** + * When true, the response interceptor does NOT raise the global error modal + * for this request's failure. For calls the caller handles itself — e.g. a + * probe that is expected to 404 before falling back (GL clearance detail + * tries /contracts/:id then /bookings/:id). The rejection still propagates. + */ + suppressErrorModal?: boolean; + } +} + type RetriableRequest = { _retry?: boolean; headers?: Record; url?: string; + suppressErrorModal?: boolean; }; const api = axios.create({ @@ -100,8 +113,13 @@ api.interceptors.response.use( originalRequest.url?.includes("/auth/refresh-token") ) { // Surface the server's actual error message in the global error modal - // (401s are handled by the session-refresh flow, so skip them). - if (error.response && error.response.status !== 401) { + // (401s are handled by the session-refresh flow, so skip them). A request + // may opt out via `suppressErrorModal` when it handles the failure itself. + if ( + error.response && + error.response.status !== 401 && + !originalRequest?.suppressErrorModal + ) { const payload = extractApiErrorPayload(error); if (payload) emitApiError(payload); } diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx index daff48939..f68d6562b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useParams } from "react-router-dom"; +import { useParams } from "react-router-dom"; import { Alert, Badge, @@ -18,7 +18,6 @@ import { AlertCircle, ClipboardList, FileText, - PackagePlus, Upload, } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -61,9 +60,12 @@ type GlClearanceDetail = async function loadGlClearanceDetail(id: string): Promise { try { + // Probe the contract endpoints first; a booking-id row 404s here by design + // and falls back to the booking lookup below. Suppress the global error + // modal so that expected 404 never surfaces to the user. const [clearance, contract] = await Promise.all([ - contractsService.getClearance(id), - contractsService.getById(id), + contractsService.getClearance(id, { suppressErrorModal: true }), + contractsService.getById(id, { suppressErrorModal: true }), ]); return { kind: "contract", @@ -89,7 +91,6 @@ async function loadGlClearanceDetail(id: string): Promise { /** Djibouti GL clearance detail — RO/DO upload and read-only upstream context. */ export default function GlClearanceDetailPage() { const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); const { user } = useAuth(); const { view, viewer } = useFileViewer(); const [uploadKind, setUploadKind] = useState(null); @@ -197,19 +198,6 @@ export default function GlClearanceDetailPage() { {hasRo ? "Replace RO" : "Upload RO"} )} - {canCompleteBooking && shipmentBooking ? ( - - ) : null} } /> diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index eb027dde7..7e07d2345 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -164,8 +164,11 @@ export const contractsService = { }; }, - getById: async (id: string): Promise => { - const response = await client.get(C.BY_ID(id)); + getById: async ( + id: string, + opts?: { suppressErrorModal?: boolean }, + ): Promise => { + const response = await client.get(C.BY_ID(id), opts); return unwrap(response.data) as Freight.IContract; }, @@ -258,8 +261,11 @@ export const contractsService = { }; }, - getClearance: async (id: string): Promise => { - const response = await client.get(C.CLEARANCE(id)); + getClearance: async ( + id: string, + opts?: { suppressErrorModal?: boolean }, + ): Promise => { + const response = await client.get(C.CLEARANCE(id), opts); return unwrap(response.data) as Freight.ContractClearanceView; }, From a7041ee70f3c999ee594654149df7cf310446003 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 18 Jul 2026 09:28:41 +0000 Subject: [PATCH 64/88] split export --- .../bookings/new-booking-form/LocationPicker.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx index b2f14b16f..6a53be5cc 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx @@ -409,7 +409,11 @@ function LocationPickerInline({ const geocoder = useGeocoder(); const places = usePlacesSearch(); const placesLib = useMapsLibrary("places"); - const [query, setQuery] = useState(""); + // `null` means "not editing" (show the saved address); any string — including + // "" after the user clears the field — is live edit state. A plain `query || + // value.address` fallback would snap the saved address back the moment the + // user cleared the input, making it impossible to retype the location. + const [query, setQuery] = useState(null); const [results, setResults] = useState([]); const [searching, setSearching] = useState(false); const [resolving, setResolving] = useState(false); @@ -430,7 +434,7 @@ function LocationPickerInline({ // than one per keystroke. While it runs, the input shows a spinner; the // dropdown itself only appears once there are predictions to show. useEffect(() => { - const q = query.trim(); + const q = (query ?? "").trim(); if (q.length < MIN_QUERY_LEN) { setResults([]); setSearching(false); @@ -469,7 +473,7 @@ function LocationPickerInline({ async (prediction: PlacePrediction) => { // Clear the query/results immediately so the pending debounce can't fire // a search for the picked address and pop the dropdown back open. - setQuery(""); + setQuery(null); setResults([]); // Predictions carry no coordinates — resolve them now via Place Details. if (!places) return; @@ -498,6 +502,9 @@ function LocationPickerInline({ const handlePin = useCallback( async (lat: number, lng: number) => { // Show the pin immediately; fill the address once reverse geocoding lands. + // Leave edit mode so the input reflects the reverse-geocoded address + // instead of whatever half-typed query the user abandoned for the map. + setQuery(null); onChange({ address: value.address, lat, lng }); if (!geocoder) return; // Mark any in-flight reverse lookup stale — only the latest pin counts. @@ -525,7 +532,7 @@ function LocationPickerInline({ [handlePin], ); - const inputValue = query || value.address; + const inputValue = query ?? value.address; const center = hasPin ? { lat: value.lat as number, lng: value.lng as number } : DEFAULT_CENTER; From 56057a1e1621b52042ba9be84162790ede96194c Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 12:31:19 +0300 Subject: [PATCH 65/88] Migration conflict issues resolution --- .../migration.sql | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/edr-passenger-api/prisma/migrations/{20260717000003_booking_seat_schedule_unique => 20260717000004_booking_seat_schedule_unique}/migration.sql (100%) diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql similarity index 100% rename from apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql rename to apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql From e55dd01c500f6e56516d778df13866c1335648d8 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 12:47:05 +0300 Subject: [PATCH 66/88] Migration issues resolution --- .../migration.sql | 21 ------ .../migration.sql | 15 ----- .../migration.sql | 4 -- .../migration.sql | 1 + .../migration.sql | 66 ------------------- 5 files changed, 1 insertion(+), 106 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql diff --git a/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql index 83a49ef67..bcea78e50 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql @@ -1,22 +1 @@ -<<<<<<< Updated upstream --- Remove duplicate JourneySegment rows, keeping the one with the lowest id --- (earliest created) per (scheduleId, seatId, departureStationId) group. --- This cleans up any existing double-bookings before the unique index is applied. -DELETE FROM passenger."JourneySegment" -WHERE id NOT IN ( - SELECT MIN(id) - FROM passenger."JourneySegment" - WHERE "seatId" IS NOT NULL - GROUP BY "scheduleId", "seatId", "departureStationId" -) -AND "seatId" IS NOT NULL; - --- Prevents two confirmed bookings from occupying the same seat on the same --- schedule hop — the hard DB backstop against application-level race conditions. --- Partial index: seatId IS NOT NULL excludes free-child rows that have no seat. -CREATE UNIQUE INDEX "JourneySegment_scheduleId_seatId_departureStationId_key" -ON passenger."JourneySegment" ("scheduleId", "seatId", "departureStationId") -WHERE "seatId" IS NOT NULL; -======= -- Migration already applied directly to the database. ->>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql index e04c02621..bcea78e50 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql @@ -1,16 +1 @@ -<<<<<<< Updated upstream --- Rename StopStatus enum values to reflect segment-level booking lifecycle. --- UPCOMING → OPEN (segment is bookable) --- APPROACHING → CHECKIN_CLOSED (within check-in cutoff, no new bookings) --- CURRENT → BOARDED (train has departed this stop) --- COMPLETED stays as-is -ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'UPCOMING' TO 'OPEN'; -ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'APPROACHING' TO 'CHECKIN_CLOSED'; -ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'CURRENT' TO 'BOARDED'; - --- Add per-route check-in window. Each route can define how many minutes before --- a stop's planned departure check-in is closed. Defaults to 30 minutes. -ALTER TABLE "passenger"."Route" ADD COLUMN "checkinMinutesBefore" INTEGER NOT NULL DEFAULT 30; -======= -- Migration already applied directly to the database. ->>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql index 5fd9ae0f5..bcea78e50 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql @@ -1,5 +1 @@ -<<<<<<< Updated upstream -ALTER TABLE "passenger"."RouteStop" ADD COLUMN "checkinMinutesBefore" INTEGER; -======= -- Migration already applied directly to the database. ->>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql new file mode 100644 index 000000000..bcea78e50 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql @@ -0,0 +1 @@ +-- Migration already applied directly to the database. diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql deleted file mode 100644 index 8553fc81b..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql +++ /dev/null @@ -1,66 +0,0 @@ --- Make scheduleId non-nullable: backfill from the parent booking, then add NOT NULL. -UPDATE passenger."BookingSeat" bs -<<<<<<< Updated upstream -SET schedule_id = b.schedule_id -FROM passenger."Booking" b -WHERE bs.booking_id = b.id - AND bs.schedule_id IS NULL -======= -SET "scheduleId" = b."scheduleId" -FROM passenger."Booking" b -WHERE bs."bookingId" = b.id - AND bs."scheduleId" IS NULL ->>>>>>> Stashed changes - AND bs.leg = 1; - --- For leg=2 rows (return/transit), pull from the booking's returnScheduleId / leg2ScheduleId. -UPDATE passenger."BookingSeat" bs -<<<<<<< Updated upstream -SET schedule_id = COALESCE( - (b.return_schedule_id), - (b.leg2_schedule_id), - b.schedule_id -) -FROM passenger."Booking" b -WHERE bs.booking_id = b.id - AND bs.schedule_id IS NULL - AND bs.leg = 2; - --- Catch any remaining NULLs (leg 3/4 from ROUND_TRIP_TRANSIT) using the booking's schedule. -UPDATE passenger."BookingSeat" bs -SET schedule_id = b.schedule_id -FROM passenger."Booking" b -WHERE bs.booking_id = b.id - AND bs.schedule_id IS NULL; - --- Now enforce NOT NULL. -ALTER TABLE passenger."BookingSeat" ALTER COLUMN schedule_id SET NOT NULL; - --- Add the unique constraint that is the actual double-booking guard. -CREATE UNIQUE INDEX "BookingSeat_scheduleId_seatId_key" - ON passenger."BookingSeat"(schedule_id, seat_id); -======= -SET "scheduleId" = COALESCE( - b."returnScheduleId", - b."leg2ScheduleId", - b."scheduleId" -) -FROM passenger."Booking" b -WHERE bs."bookingId" = b.id - AND bs."scheduleId" IS NULL - AND bs.leg = 2; - --- Catch any remaining NULLs using the booking's schedule. -UPDATE passenger."BookingSeat" bs -SET "scheduleId" = b."scheduleId" -FROM passenger."Booking" b -WHERE bs."bookingId" = b.id - AND bs."scheduleId" IS NULL; - --- Now enforce NOT NULL. -ALTER TABLE passenger."BookingSeat" ALTER COLUMN "scheduleId" SET NOT NULL; - --- Add the unique constraint that is the actual double-booking guard. -CREATE UNIQUE INDEX "BookingSeat_scheduleId_seatId_key" - ON passenger."BookingSeat"("scheduleId", "seatId"); ->>>>>>> Stashed changes From 594aaf17abd46ce1901b7e12a5263d9516c33e92 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 17:53:44 +0300 Subject: [PATCH 67/88] Migration issue resolution --- .../migration.sql | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql diff --git a/apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql new file mode 100644 index 000000000..2b67ec9cf --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[scheduleId,seatId,departureStationId]` on the table `JourneySegment` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "JourneySegment_scheduleId_seatId_departureStationId_key" ON "JourneySegment"("scheduleId", "seatId", "departureStationId"); From f366e834e765c20e016ec391a8119e7445f287d6 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 18:05:41 +0300 Subject: [PATCH 68/88] Migration issue resolution --- .github/workflows/deploy.yml | 11 +++++++++++ .../migration.sql | 14 +++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 44be07550..912c6bbd7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -162,6 +162,17 @@ jobs: -t "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" \ . + - name: Resolve failed migrations for ${{ matrix.service }} + if: matrix.service == 'passenger-api' + run: | + set -euo pipefail + docker run --rm --env-file "${SERVICE_ENV_FILE}" \ + --entrypoint npx \ + "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" \ + prisma migrate resolve \ + --applied 20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id \ + || true + - name: Run migrations for ${{ matrix.service }} if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) run: | diff --git a/apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql index 2b67ec9cf..a512eeccd 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id/migration.sql @@ -4,5 +4,17 @@ - A unique constraint covering the columns `[scheduleId,seatId,departureStationId]` on the table `JourneySegment` will be added. If there are existing duplicate values, this will fail. */ +-- Deduplicate before applying the unique index. +-- Keeps the row with the lowest id per (scheduleId, seatId, departureStationId) group. +DELETE FROM passenger."JourneySegment" +WHERE id NOT IN ( + SELECT MIN(id) + FROM passenger."JourneySegment" + WHERE "seatId" IS NOT NULL + GROUP BY "scheduleId", "seatId", "departureStationId" +) +AND "seatId" IS NOT NULL; + -- CreateIndex -CREATE UNIQUE INDEX IF NOT EXISTS "JourneySegment_scheduleId_seatId_departureStationId_key" ON "JourneySegment"("scheduleId", "seatId", "departureStationId"); +CREATE UNIQUE INDEX IF NOT EXISTS "JourneySegment_scheduleId_seatId_departureStationId_key" + ON "JourneySegment"("scheduleId", "seatId", "departureStationId"); From 3fc36b358f8c2d185cc7fab619e7784e9f9458eb Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 18:21:15 +0300 Subject: [PATCH 69/88] Passengers report updates --- .../src/modules/reports/reports.controller.ts | 6 + .../src/modules/reports/reports.service.ts | 29 ++ .../src/app/reports/passengers/page.tsx | 404 +++++++++++------- 3 files changed, 282 insertions(+), 157 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 82a07f98f..74bad3514 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -24,6 +24,12 @@ export class ReportsController { return this.service.listSchedulesForPicker(); } + @Get('passengers/list') + @ApiOperation({ summary: 'Flat passenger list for a specific schedule' }) + getPassengerList(@Query('scheduleId') scheduleId: string) { + return this.service.getPassengerList(scheduleId); + } + @Get('passengers') @ApiOperation({ summary: 'Passengers report for a specific schedule' }) getOccupancyReport(@Query('scheduleId') scheduleId: string) { diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 9037188e2..daf1a7538 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -286,6 +286,35 @@ export class ReportsService { }; } + async getPassengerList(scheduleId: string) { + const seats = await this.prisma.bookingSeat.findMany({ + where: { + scheduleId, + booking: { status: { in: ['CONFIRMED', 'BOARDED'] } }, + }, + include: { + booking: { select: { bookingRef: true, status: true, originStationId: true, destinationStationId: true } }, + seat: { include: { coach: { select: { number: true, coachType: { select: { name: true } } } } } }, + }, + orderBy: [{ seat: { coach: { number: 'asc' } } }], + }); + + return seats.map(bs => ({ + bookingRef: bs.booking.bookingRef, + bookingStatus: bs.booking.status, + passengerName: bs.passengerName, + dateOfBirth: bs.dateOfBirth, + passengerCategory: bs.passengerCategory, + idDocumentType: bs.idDocumentType, + idDocumentNumber: bs.idDocumentNumber, + passportNumber: bs.passportNumber, + passportCountry: bs.passportCountry, + seatLabel: bs.seatLabelSnapshot, + coachNumber: bs.seat?.coach?.number ?? null, + coachType: (bs.seat?.coach as any)?.coachType?.name ?? null, + })); + } + async listSchedulesForPicker() { const schedules = await this.prisma.trainSchedule.findMany({ select: { diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx index 83b803331..fe39e7e51 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -18,42 +18,82 @@ interface PassengersReport { byDestination: { stationName: string; passengers: number }[]; } +interface PassengerRow { + bookingRef: string; + bookingStatus: string; + passengerName: string; + dateOfBirth: string | null; + passengerCategory: string; + idDocumentType: string | null; + idDocumentNumber: string | null; + passportNumber: string | null; + passportCountry: string | null; + seatLabel: string | null; + coachNumber: string | null; + coachType: string | null; +} + +type Tab = 'occupancy' | 'list'; + export default function PassengersReportPage() { const [scheduleId, setScheduleId] = useState(''); - const [search, setSearch] = useState(''); - const [submittedId, setSubmittedId] = useState(''); + const [tab, setTab] = useState('occupancy'); + const [listSearch, setListSearch] = useState(''); const { data: schedules = [], isLoading: loadingSchedules } = useQuery({ queryKey: ['report-schedules'], queryFn: () => apiClient.get('/reports/schedules'), }); - const filtered = search.trim() - ? schedules.filter(s => s.label.toLowerCase().includes(search.toLowerCase())) - : schedules; - - const selected = schedules.find(s => s.id === scheduleId); - const { data, isLoading, isError } = useQuery({ - queryKey: ['passengers-report', submittedId], - queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${submittedId}`), - enabled: !!submittedId, + queryKey: ['passengers-report', scheduleId], + queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`), + enabled: !!scheduleId, }); - const doExport = () => { - if (!data) return; - const rows = data.byCoach.map((c) => [c.coachNumber, c.coachType, String(c.totalSeats), String(c.booked), `${c.occupancyRate}%`]); - const headers = ['Coach', 'Type', 'Total Seats', 'Booked', 'Occupancy']; - const csv = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n'); + const { data: passengerList = [], isLoading: listLoading } = useQuery({ + queryKey: ['passengers-list', scheduleId], + queryFn: () => apiClient.get(`/reports/passengers/list?scheduleId=${scheduleId}`), + enabled: !!scheduleId, + }); + + const filteredList = listSearch.trim() + ? passengerList.filter(p => + p.passengerName.toLowerCase().includes(listSearch.toLowerCase()) || + p.bookingRef.toLowerCase().includes(listSearch.toLowerCase()) || + (p.idDocumentNumber ?? '').toLowerCase().includes(listSearch.toLowerCase()) || + (p.passportNumber ?? '').toLowerCase().includes(listSearch.toLowerCase()), + ) + : passengerList; + + const downloadCsv = (csv: string, filename: string) => { const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); - a.href = url; - a.download = `passengers-report-${submittedId}.csv`; - a.click(); + a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); }; + const doExportOccupancy = () => { + if (!data) return; + const rows = data.byCoach.map(c => [c.coachNumber, c.coachType, String(c.totalSeats), String(c.booked), `${c.occupancyRate}%`]); + const csv = [['Coach', 'Type', 'Total Seats', 'Booked', 'Occupancy'].join(','), ...rows.map(r => r.join(','))].join('\n'); + downloadCsv(csv, `occupancy-${scheduleId}.csv`); + }; + + const doExportList = () => { + if (!passengerList.length) return; + const headers = ['Booking Ref', 'Status', 'Name', 'DOB', 'Category', 'ID Type', 'ID Number', 'Passport', 'Country', 'Seat', 'Coach', 'Class']; + const rows = passengerList.map(p => [ + p.bookingRef, p.bookingStatus, p.passengerName, p.dateOfBirth ?? '', + p.passengerCategory, p.idDocumentType ?? '', p.idDocumentNumber ?? '', + p.passportNumber ?? '', p.passportCountry ?? '', + p.seatLabel ?? '', p.coachNumber ?? '', p.coachType ?? '', + ].map(v => `"${String(v).replace(/"/g, '""')}"`)); + const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n'); + downloadCsv(csv, `passengers-${scheduleId}.csv`); + }; + return (
@@ -61,52 +101,31 @@ export default function PassengersReportPage() {

Occupancy and passenger breakdown for a schedule

- {/* Schedule picker */} + {/* Schedule selector */}
-
- { setSearch(e.target.value); setScheduleId(''); }} - onFocus={(e) => { setSearch(e.target.value); }} - /> - {(search || scheduleId) && ( - - )} -
- {search && !scheduleId && ( -
- {filtered.length === 0 - ?

No schedules found

- : filtered.map(s => ( - - )) - } -
- )} +
- setSubmittedId(scheduleId)} disabled={!scheduleId || isLoading}> - Load Report - - {data && ( - - Export CSV - + {data && tab === 'occupancy' && ( + Export CSV + )} + {passengerList.length > 0 && tab === 'list' && ( + Export CSV )}
- {isLoading &&

Loading…

} + {(isLoading || listLoading) &&

Loading…

} {isError &&

Failed to load report.

}
@@ -122,118 +141,189 @@ export default function PassengersReportPage() {
- {/* Summary cards */} -
-
-
-

Total Seats

-
-
-

{data.summary.totalSeats}

-
-
-
-

Passengers

-
-
-

{data.summary.totalPassengers}

-
-
-
-

Occupancy Rate

-
-
-

{data.summary.occupancyRate}%

-
-
-
-
+ {/* Tabs */} +
+ {(['occupancy', 'list'] as Tab[]).map(t => ( + + ))}
- {/* By Coach */} -
-

By Coach

-
- - - - - - - - - - - - {data.byCoach.map((c) => ( - - - - - - - - ))} - -
CoachTypeSeatsBookedOccupancy
{c.coachNumber}{c.coachType}{c.totalSeats}{c.booked} + {/* Occupancy tab */} + {tab === 'occupancy' && ( +
+
+
+
+

Total Seats

+
+
+

{data.summary.totalSeats}

+
+
+
+

Passengers

+
+
+

{data.summary.totalPassengers}

+
+
+
+

Occupancy Rate

+
+
+

{data.summary.occupancyRate}%

+
+
+
+
+
+ +
+

By Coach

+
+ + + + + + + + + + {data.byCoach.map(c => ( + + + + + + + + ))} + +
CoachTypeSeatsBookedOccupancy
{c.coachNumber}{c.coachType}{c.totalSeats}{c.booked} +
+
+
+
+ {c.occupancyRate}% +
+
+
+
+ +
+
+

By Class

+
+ {data.byClass.map(c => ( +
+
+ {c.className} + {c.booked}/{c.totalSeats} +
-
+
- {c.occupancyRate}% + {c.occupancyRate}%
-
-
-
- - {/* By Class + By Origin/Destination */} -
-
-

By Class

-
- {data.byClass.map((c) => ( -
-
- {c.className} - {c.booked}/{c.totalSeats} -
-
-
-
- {c.occupancyRate}% -
+ ))}
- ))} +
+
+

By Boarding Station

+
+ {data.byOrigin.map(o => ( +
+ {o.stationName} + {o.passengers} +
+ ))} + {data.byOrigin.length === 0 &&

No data

} +
+
+
+

By Alighting Station

+
+ {data.byDestination.map(d => ( +
+ {d.stationName} + {d.passengers} +
+ ))} + {data.byDestination.length === 0 &&

No data

} +
+
+ )} -
-

By Boarding Station

-
- {data.byOrigin.map((o) => ( -
- {o.stationName} - {o.passengers} -
- ))} - {data.byOrigin.length === 0 &&

No data

} + {/* Passenger List tab */} + {tab === 'list' && ( +
+ setListSearch(e.target.value)} + /> +
+ + + + + + + + + + + + + + + {filteredList.map((p, i) => ( + + + + + + + + + + + ))} + {filteredList.length === 0 && ( + + )} + +
#NameCategoryID / PassportSeatCoachBooking RefStatus
{i + 1}{p.passengerName} + + {p.passengerCategory} + + + {p.idDocumentNumber ?? p.passportNumber ?? '—'} + {p.passportCountry && ({p.passportCountry})} + {p.seatLabel ?? '—'} + {p.coachNumber ?? '—'} + {p.coachType && ({p.coachType})} + {p.bookingRef} + + {p.bookingStatus} + +
No passengers found
- -
-

By Alighting Station

-
- {data.byDestination.map((d) => ( -
- {d.stationName} - {d.passengers} -
- ))} - {data.byDestination.length === 0 &&

No data

} -
-
-
+ )} )}
From e8408ef68451e9c6ed85b8d33adc19ffd0249310 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 18:30:03 +0300 Subject: [PATCH 70/88] Migration issue resolution job removed --- .github/workflows/deploy.yml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 912c6bbd7..44be07550 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -162,17 +162,6 @@ jobs: -t "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" \ . - - name: Resolve failed migrations for ${{ matrix.service }} - if: matrix.service == 'passenger-api' - run: | - set -euo pipefail - docker run --rm --env-file "${SERVICE_ENV_FILE}" \ - --entrypoint npx \ - "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" \ - prisma migrate resolve \ - --applied 20260718144855_unique_constraint_schedule_id_seat_id_departure_station_id \ - || true - - name: Run migrations for ${{ matrix.service }} if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) run: | From 82ba2df33cb941f743b13136b0bd0302c5213312 Mon Sep 17 00:00:00 2001 From: "Stephanos A." Date: Sat, 18 Jul 2026 18:43:57 +0300 Subject: [PATCH 71/88] Revert "Quickfix" From c1858e76d4ba17cdf240bc26e249e6aeb23bbee1 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Sat, 18 Jul 2026 21:51:54 +0300 Subject: [PATCH 72/88] Added stops departure and arrival datetime --- .../migration.sql | 2 + .../migration.sql | 4 + apps/edr-passenger-api/prisma/schema.prisma | 2 + .../src/modules/packages/packages.dto.ts | 9 + .../src/modules/packages/packages.service.ts | 27 +- .../src/modules/schedules/routes.dto.ts | 4 + .../src/modules/schedules/routes.service.ts | 6 + .../src/modules/schedules/schedules.dto.ts | 3 + .../modules/schedules/schedules.service.ts | 147 +++++-- .../src/modules/tasks/tasks.service.ts | 73 ++++ .../edr-passenger-web/backoffice/package.json | 1 + .../backoffice/src/app/routes/page.tsx | 82 +++- .../backoffice/src/app/schedules/page.tsx | 377 ++++++++++++++++-- .../src/components/layout/Sidebar.tsx | 14 +- .../src/components/ui/DateTimePicker.tsx | 358 +++++++++++++++++ pnpm-lock.yaml | 11 + 16 files changed, 1026 insertions(+), 94 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260718000001_add_route_stop_planned_times/migration.sql create mode 100644 apps/edr-passenger-api/prisma/migrations/20260718000002_change_route_stop_planned_times_to_datetime/migration.sql create mode 100644 apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx diff --git a/apps/edr-passenger-api/prisma/migrations/20260718000001_add_route_stop_planned_times/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260718000001_add_route_stop_planned_times/migration.sql new file mode 100644 index 000000000..012ec84db --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260718000001_add_route_stop_planned_times/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "passenger"."RouteStop" ADD COLUMN "plannedArrivalTime" INTEGER; +ALTER TABLE "passenger"."RouteStop" ADD COLUMN "plannedDepartureTime" INTEGER; diff --git a/apps/edr-passenger-api/prisma/migrations/20260718000002_change_route_stop_planned_times_to_datetime/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260718000002_change_route_stop_planned_times_to_datetime/migration.sql new file mode 100644 index 000000000..4ff235be2 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260718000002_change_route_stop_planned_times_to_datetime/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "passenger"."RouteStop" DROP COLUMN "plannedArrivalTime"; +ALTER TABLE "passenger"."RouteStop" DROP COLUMN "plannedDepartureTime"; +ALTER TABLE "passenger"."RouteStop" ADD COLUMN "plannedArrivalTime" TIMESTAMP(3); +ALTER TABLE "passenger"."RouteStop" ADD COLUMN "plannedDepartureTime" TIMESTAMP(3); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index fb9db346f..838ee8826 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -1050,6 +1050,8 @@ model RouteStop { sequence Int distanceKm Float? checkinMinutesBefore Int? + plannedArrivalTime DateTime? + plannedDepartureTime DateTime? createdAt DateTime @default(now()) route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) diff --git a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts index d7257f179..95b3a916b 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts @@ -128,4 +128,13 @@ export class BookPackageDto { /** Number of child passengers (<5 years). Derived from passengers array if omitted. */ @ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() @Min(0) childCount?: number; + + /** + * SeatHold UUID returned by POST /seats/hold when the user selected seats on the + * seatmap before proceeding to book. When provided, the hold's expiry is extended + * to the payment deadline so the specific seat stays reserved on the seatmap for + * the full payment window, matching the behaviour of normal bookings. + */ + @ApiPropertyOptional({ description: 'SeatHold ID from seatmap selection' }) + @IsOptional() @IsUUID() holdId?: string; } diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index 9ddc1fb0a..43d96f86c 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -6,6 +6,7 @@ import { Currency } from '@prisma/client'; import { BookingsService } from '../bookings/bookings.service'; import { GuestBookingService } from '../bookings/guest-booking.service'; import { AuditService } from '../../common/audit.service'; +import { computePaymentDeadline, CUTOFF_MINUTES } from '../../common/utils/payment-deadline.utils'; /** Package-specific fare rules */ const PKG_MAX_ADULTS = 5; @@ -382,7 +383,10 @@ export class PackagesService { async book(dto: BookPackageDto, passengerId?: string) { const pkg = await this.prisma.travelPackage.findUnique({ where: { id: dto.packageId }, - include: { priceTiers: true }, + include: { + priceTiers: true, + outboundSchedule: { select: { departureAt: true, route: { select: { checkinMinutesBefore: true } } } }, + }, }); if (!pkg) throw new NotFoundException('Package not found'); if (pkg.status !== 'ACTIVE') throw new BadRequestException('Package is not available for booking'); @@ -477,6 +481,27 @@ export class PackagesService { ]); }); + // Extend the seatmap SeatHold (if one was passed) to the payment deadline so the + // specific seat remains visually reserved on the seatmap during the full payment + // window — matching the behaviour of normal bookings (which call confirmSeats). + if (dto.holdId) { + const dep = (pkg as any).outboundSchedule?.departureAt as Date | undefined; + if (dep) { + const checkinMinutes = (pkg as any).outboundSchedule?.route?.checkinMinutesBefore ?? CUTOFF_MINUTES; + const paymentDeadline = computePaymentDeadline(booking.createdAt as Date, dep, checkinMinutes); + const hold = await this.prisma.seatHold.findUnique({ + where: { id: dto.holdId }, + select: { expiresAt: true }, + }); + if (hold && paymentDeadline > hold.expiresAt) { + await this.prisma.seatHold.update({ + where: { id: dto.holdId }, + data: { expiresAt: paymentDeadline }, + }); + } + } + } + return { ...booking, fareBreakdown: { diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts index f2db36e7c..fefa26ad1 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts @@ -7,6 +7,8 @@ export class RouteStopInputDto { @ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number; @ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number; @ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; + @ApiPropertyOptional({ example: '2026-06-15T06:30:00Z', description: 'Template planned arrival time at this stop. Only the time-of-day (EAT) is used when auto-populating new schedules. Omit for first stop.' }) @IsOptional() @IsDateString() plannedArrivalTime?: string; + @ApiPropertyOptional({ example: '2026-06-15T06:45:00Z', description: 'Template planned departure time from this stop. Only the time-of-day (EAT) is used when auto-populating new schedules. Omit for last stop.' }) @IsOptional() @IsDateString() plannedDepartureTime?: string; } export class CreateRouteDto { @@ -37,6 +39,8 @@ export class AddRouteStopDto { @ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number; @ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number; @ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; + @ApiPropertyOptional({ example: '2026-06-15T06:30:00Z', description: 'Template planned arrival time at this stop (only time-of-day is used)' }) @IsOptional() @IsDateString() plannedArrivalTime?: string; + @ApiPropertyOptional({ example: '2026-06-15T06:45:00Z', description: 'Template planned departure time from this stop (only time-of-day is used)' }) @IsOptional() @IsDateString() plannedDepartureTime?: string; } export class UpdateRouteDto { diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts index 58e647180..dcbdbc3b0 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -37,6 +37,8 @@ export class RoutesService { sequence: s.sequence, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, checkinMinutesBefore: s.checkinMinutesBefore ?? null, + plannedArrivalTime: s.plannedArrivalTime ? new Date(s.plannedArrivalTime) : null, + plannedDepartureTime: s.plannedDepartureTime ? new Date(s.plannedDepartureTime) : null, })), }, }, @@ -106,6 +108,8 @@ export class RoutesService { sequence: s.sequence, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, checkinMinutesBefore: s.checkinMinutesBefore ?? null, + plannedArrivalTime: s.plannedArrivalTime ?? null, + plannedDepartureTime: s.plannedDepartureTime ?? null, })), }); } @@ -225,6 +229,8 @@ export class RoutesService { sequence: dto.sequence, distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null, checkinMinutesBefore: dto.checkinMinutesBefore ?? null, + plannedArrivalTime: dto.plannedArrivalTime ?? null, + plannedDepartureTime: dto.plannedDepartureTime ?? null, }, }); } 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 675168cf9..f56883464 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -60,6 +60,9 @@ export class UpdateScheduleDto { @ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus; @ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>; @ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean; + @ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Planned times per stop — when provided, replaces all existing stop times for the schedule' }) + @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto) + plannedTimes?: PlannedStopTimeDto[]; } export class UpdateStopTimeDto { 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 7cca548aa..ecdf51666 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -133,26 +133,62 @@ export class SchedulesService { let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { - const totalDuration = arr.getTime() - dep.getTime(); - const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; + const hasRouteTimes = route.stops.some( + s => (s as any).plannedArrivalTime != null || (s as any).plannedDepartureTime != null, + ); - plannedTimes = route.stops.map((stop, index) => { - let stopTime: Date; - if (index === 0) { - stopTime = dep; - } else if (index === route.stops.length - 1) { - stopTime = arr; - } else { - const stopDistance = stop.distanceKm || 0; - const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); - stopTime = new Date(dep.getTime() + totalDuration * progress); - } - return { - sequence: stop.sequence, - plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), - plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), + if (hasRouteTimes) { + // Extract EAT time-of-day from a template DateTime and anchor to the schedule's EAT date. + const EAT_MS = 3 * 60 * 60 * 1000; + const depEATMs = dep.getTime() + EAT_MS; + const depMsIntoDay = depEATMs % (24 * 60 * 60 * 1000); + const eatMidnightUTC = dep.getTime() - depMsIntoDay; + + const templateToScheduleUTC = (templateDt: Date): Date => { + // Pull the time-of-day in EAT from the template DateTime + const templateEATMs = templateDt.getTime() + EAT_MS; + const timeOfDayMs = templateEATMs % (24 * 60 * 60 * 1000); + const candidate = new Date(eatMidnightUTC + timeOfDayMs); + // Overnight: if the stop time lands before departure, move to next day + if (candidate < dep) return new Date(candidate.getTime() + 24 * 60 * 60 * 1000); + return candidate; }; - }); + + plannedTimes = route.stops.map((stop, index) => { + const arrDt: Date | null = (stop as any).plannedArrivalTime ?? null; + const depDt: Date | null = (stop as any).plannedDepartureTime ?? null; + return { + sequence: stop.sequence, + plannedArrivalAt: index > 0 && arrDt != null + ? templateToScheduleUTC(arrDt).toISOString() + : undefined, + plannedDepartureAt: index < route.stops.length - 1 && depDt != null + ? templateToScheduleUTC(depDt).toISOString() + : undefined, + }; + }); + } else { + const totalDuration = arr.getTime() - dep.getTime(); + const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; + + plannedTimes = route.stops.map((stop, index) => { + let stopTime: Date; + if (index === 0) { + stopTime = dep; + } else if (index === route.stops.length - 1) { + stopTime = arr; + } else { + const stopDistance = stop.distanceKm || 0; + const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); + stopTime = new Date(dep.getTime() + totalDuration * progress); + } + return { + sequence: stop.sequence, + plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), + plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), + }; + }); + } } const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence)); @@ -304,26 +340,59 @@ export class SchedulesService { let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { - const totalDuration = arr.getTime() - dep.getTime(); - const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; + const hasRouteTimes = route.stops.some( + s => (s as any).plannedArrivalTime != null || (s as any).plannedDepartureTime != null, + ); - plannedTimes = route.stops.map((stop, index) => { - let stopTime: Date; - if (index === 0) { - stopTime = dep; - } else if (index === route.stops.length - 1) { - stopTime = arr; - } else { - const stopDistance = stop.distanceKm || 0; - const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); - stopTime = new Date(dep.getTime() + totalDuration * progress); - } - return { - sequence: stop.sequence, - plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), - plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), + if (hasRouteTimes) { + const EAT_MS = 3 * 60 * 60 * 1000; + const depEATMs = dep.getTime() + EAT_MS; + const depMsIntoDay = depEATMs % (24 * 60 * 60 * 1000); + const eatMidnightUTC = dep.getTime() - depMsIntoDay; + + const templateToScheduleUTC = (templateDt: Date): Date => { + const templateEATMs = templateDt.getTime() + EAT_MS; + const timeOfDayMs = templateEATMs % (24 * 60 * 60 * 1000); + const candidate = new Date(eatMidnightUTC + timeOfDayMs); + if (candidate < dep) return new Date(candidate.getTime() + 24 * 60 * 60 * 1000); + return candidate; }; - }); + + plannedTimes = route.stops.map((stop, index) => { + const arrDt: Date | null = (stop as any).plannedArrivalTime ?? null; + const depDt: Date | null = (stop as any).plannedDepartureTime ?? null; + return { + sequence: stop.sequence, + plannedArrivalAt: index > 0 && arrDt != null + ? templateToScheduleUTC(arrDt).toISOString() + : undefined, + plannedDepartureAt: index < route.stops.length - 1 && depDt != null + ? templateToScheduleUTC(depDt).toISOString() + : undefined, + }; + }); + } else { + const totalDuration = arr.getTime() - dep.getTime(); + const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; + + plannedTimes = route.stops.map((stop, index) => { + let stopTime: Date; + if (index === 0) { + stopTime = dep; + } else if (index === route.stops.length - 1) { + stopTime = arr; + } else { + const stopDistance = stop.distanceKm || 0; + const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); + stopTime = new Date(dep.getTime() + totalDuration * progress); + } + return { + sequence: stop.sequence, + plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), + plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), + }; + }); + } } const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); @@ -678,6 +747,12 @@ export class SchedulesService { } } + if (dto.plannedTimes && dto.plannedTimes.length > 0 && schedule.routeId) { + await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } }); + const plannedTimesMap = Object.fromEntries(dto.plannedTimes.map(t => [t.sequence, t])); + await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap); + } + return this.getSchedule(id); } diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index 2355485d4..4767c7eba 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -146,6 +146,7 @@ export class TasksService { await Promise.all([ this.sendPaymentReminders(now), this.cancelExpiredPendingBookings(now), + this.cancelExpiredPendingPackageBookings(now), ]); } @@ -333,6 +334,78 @@ export class TasksService { } } + // ── Cancel PackageBookings whose payment deadline has passed ────────────── + private async cancelExpiredPendingPackageBookings(now: Date) { + const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000); + const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000); + + const expiredBookings = await this.prisma.packageBooking.findMany({ + where: { + status: 'PENDING_PAYMENT', + OR: [ + { createdAt: { lte: twoHoursAgo } }, + { package: { outboundSchedule: { departureAt: { lte: departureCutoff } } } }, + ], + }, + include: { + package: { + include: { + outboundSchedule: { + include: { route: { select: { checkinMinutesBefore: true } } }, + }, + }, + }, + }, + }); + + let cancelledCount = 0; + + for (const booking of expiredBookings) { + try { + const createdAt = booking.createdAt as Date; + const dep = (booking.package as any).outboundSchedule.departureAt as Date; + const checkinMinutes = (booking.package as any).outboundSchedule.route?.checkinMinutesBefore ?? CUTOFF_MINUTES; + const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes); + if (now < paymentDeadline) continue; + + // Revert the tier's seat counters that were incremented when the booking was created. + const seatsReserved = booking.adultCount + Math.max(0, booking.childCount - booking.adultCount); + await this.prisma.packagePriceTier.update({ + where: { id: booking.priceTierId }, + data: { + bookedSeats: { decrement: seatsReserved }, + availableSeats: { increment: seatsReserved }, + }, + }); + + await this.prisma.packageBooking.update({ + where: { id: booking.id }, + data: { status: 'CANCELLED' }, + }); + + const message = + `EDR: Your package booking ${booking.bookingRef} ` + + `(departs ${fmtTime(dep)}) has been cancelled ` + + `because payment was not completed by ${fmtTime(paymentDeadline)}.`; + + if (booking.contactPhone) { + await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null); + } + + this.logger.log(`Auto-cancelled package booking: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})`); + cancelledCount++; + } catch (err) { + this.logger.error( + `Auto-cancel failed for package booking ${(booking as any).bookingRef}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + if (cancelledCount > 0) { + this.logger.log(`Auto-cancelled ${cancelledCount} expired pending package booking(s)`); + } + } + // ───────────────────────────────────────────────────────────────────────── // Daily at 02:00 EAT: purge expired/stale records to enforce data retention. // ───────────────────────────────────────────────────────────────────────── diff --git a/apps/edr-passenger-web/backoffice/package.json b/apps/edr-passenger-web/backoffice/package.json index 858fcd781..9d4327a42 100644 --- a/apps/edr-passenger-web/backoffice/package.json +++ b/apps/edr-passenger-web/backoffice/package.json @@ -19,6 +19,7 @@ "lucide-react": "^0.446.0", "next": "^14.2.0", "react": "^18.3.1", + "react-day-picker": "^9.14.0", "react-dom": "^18.3.1", "recharts": "^2.12.0", "socket.io-client": "^4.8.3", diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index feca7e3b5..5fa655a5e 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -10,6 +10,14 @@ import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { routesApi } from '@/lib/api/routes'; import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api'; +import DateTimePicker from '@/components/ui/DateTimePicker'; + +// EAT ↔ UTC helpers (same as schedules page) +const EAT_MS = 3 * 60 * 60 * 1000; +const isoToEAT = (iso: string): string => + new Date(new Date(iso).getTime() + EAT_MS).toISOString().slice(0, 16); +const eatToISO = (local: string): string => + new Date(new Date(local + ':00Z').getTime() - EAT_MS).toISOString(); interface RouteStop { stationId: string; @@ -17,6 +25,8 @@ interface RouteStop { distanceKm?: number; distanceFromOrigin?: number; checkinMinutesBefore?: number; + plannedArrivalTime?: string; + plannedDepartureTime?: string; } type Tab = 'routes' | 'coaches'; @@ -175,6 +185,8 @@ export default function RoutesPage() { const [destinationDistance, setDestinationDistance] = useState(undefined); const [originCheckinMinutes, setOriginCheckinMinutes] = useState(undefined); const [destinationCheckinMinutes, setDestinationCheckinMinutes] = useState(undefined); + const [originDepartureTime, setOriginDepartureTime] = useState(''); + const [destinationArrivalTime, setDestinationArrivalTime] = useState(''); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null }); const [search, setSearch] = useState(''); const queryClient = useQueryClient(); @@ -245,18 +257,27 @@ export default function RoutesPage() { // distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm) const stopsArray = [ - { stationId: originStationId, sequence: 1, distanceKm: 0, checkinMinutesBefore: originCheckinMinutes ?? undefined }, + { + stationId: originStationId, + sequence: 1, + distanceKm: 0, + checkinMinutesBefore: originCheckinMinutes ?? undefined, + plannedDepartureTime: originDepartureTime ? eatToISO(originDepartureTime) : undefined, + }, ...sortedMiddleStops.map((stop, idx) => ({ stationId: stop.stationId, sequence: idx + 2, distanceKm: stop.distanceFromOrigin || 0, checkinMinutesBefore: stop.checkinMinutesBefore ?? undefined, + plannedArrivalTime: stop.plannedArrivalTime ? eatToISO(stop.plannedArrivalTime) : undefined, + plannedDepartureTime: stop.plannedDepartureTime ? eatToISO(stop.plannedDepartureTime) : undefined, })), { stationId: destinationStationId, sequence: sortedMiddleStops.length + 2, distanceKm: destinationDistance || 0, checkinMinutesBefore: destinationCheckinMinutes ?? undefined, + plannedArrivalTime: destinationArrivalTime ? eatToISO(destinationArrivalTime) : undefined, }, ]; @@ -387,8 +408,10 @@ export default function RoutesPage() { const destStop = routeStops[routeStops.length - 1]; setOriginStationId(originStop.stationId); setOriginCheckinMinutes(originStop.checkinMinutesBefore ?? undefined); + setOriginDepartureTime(originStop.plannedDepartureTime ? isoToEAT(originStop.plannedDepartureTime) : ''); setDestinationStationId(destStop.stationId); setDestinationCheckinMinutes(destStop.checkinMinutesBefore ?? undefined); + setDestinationArrivalTime(destStop.plannedArrivalTime ? isoToEAT(destStop.plannedArrivalTime) : ''); setDestinationDistance(destStop.distanceKm || 0); setStops(routeStops.slice(1, -1).map((s: any) => ({ stationId: s.stationId, @@ -396,6 +419,8 @@ export default function RoutesPage() { distanceKm: s.distanceKm, distanceFromOrigin: s.distanceKm || 0, checkinMinutesBefore: s.checkinMinutesBefore ?? undefined, + plannedArrivalTime: s.plannedArrivalTime ? isoToEAT(s.plannedArrivalTime) : '', + plannedDepartureTime: s.plannedDepartureTime ? isoToEAT(s.plannedDepartureTime) : '', }))); } setShowModal(true); @@ -433,8 +458,10 @@ export default function RoutesPage() { setEditingRoute(null); setOriginStationId(''); setOriginCheckinMinutes(undefined); + setOriginDepartureTime(''); setDestinationStationId(''); setDestinationCheckinMinutes(undefined); + setDestinationArrivalTime(''); setDestinationDistance(undefined); setStops([]); setShowModal(true); @@ -519,7 +546,7 @@ export default function RoutesPage() { setSearch(''); }} title={`${editingRoute ? 'Edit' : 'Add'} Route`} - size="lg" + size="xl" >
{editingRoute && ( @@ -665,7 +692,7 @@ export default function RoutesPage() {
- Drag to rearrange · Cutoff min overrides route check-in window per stop (leave blank to inherit) + Drag to rearrange · Cutoff overrides check-in · Arr/Dep time sets default times (auto-filled on schedule creation)
@@ -683,7 +710,7 @@ export default function RoutesPage() { Select origin station above )}
-
+
-
0 km
+
+ +
+
0 km
{stops.map((stop, index) => ( @@ -729,7 +764,7 @@ export default function RoutesPage() { ))}
-
+
-
+
updateStop(index, 'checkinMinutesBefore', e.target.value ? parseInt(e.target.value) : undefined)} min={1} title="Check-in cutoff override (minutes) for this stop" />
+
+ updateStop(index, 'plannedDepartureTime', v)} + placeholder="Dep time" + label="Planned Departure" + /> +
+
+ updateStop(index, 'plannedArrivalTime', v)} + placeholder="Arr time" + label="Planned Arrival" + /> +
-
+
{destinationStationId && ( )}
-
+
+
+ {destinationStationId && ( + + )} +
+
{destinationStationId && ( + new Date(new Date(iso).getTime() + EAT_MS).toISOString().slice(0, 16); +// EAT "YYYY-MM-DDTHH:mm" → UTC ISO string for API submission +const eatToISO = (local: string): string => + new Date(new Date(local + ':00Z').getTime() - EAT_MS).toISOString(); +// Extract HH:mm in EAT from a UTC ISO datetime (e.g. route stop planned time) +const isoToEATTimePart = (iso: string): string | null => { + if (!iso) return null; + const eatMs = new Date(iso).getTime() + EAT_MS; + const msIntoDay = eatMs % (24 * 60 * 60 * 1000); + const h = Math.floor(msIntoDay / 3600000); + const m = Math.floor((msIntoDay % 3600000) / 60000); + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +}; + interface Schedule { id: string; trainId: string; @@ -24,6 +43,13 @@ interface Schedule { destinationStation?: { id: string; name: string }; coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>; isPackageOnly?: boolean; + stopTimes?: Array<{ + sequence: number; + stationId: string; + plannedDepartureAt: string | null; + plannedArrivalAt: string | null; + station?: { name: string }; + }>; } interface Train { @@ -72,6 +98,8 @@ export default function SchedulesPage() { const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); const [addCoachRows, setAddCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]); + const [addStopTimes, setAddStopTimes] = useState<{ sequence: number; stationName: string; plannedArrivalAt: string; plannedDepartureAt: string }[]>([]); + const [editStopTimes, setEditStopTimes] = useState<{ sequence: number; stationName: string; plannedArrivalAt: string; plannedDepartureAt: string }[]>([]); const { data: singleRouteTemplate, isLoading: singleTemplateLoading } = useQuery({ queryKey: ['route-coaches', addForm.routeId], @@ -79,12 +107,42 @@ export default function SchedulesPage() { enabled: !!addForm.routeId, }); + const { data: addRouteDetail } = useQuery({ + queryKey: ['route-detail', addForm.routeId], + queryFn: () => apiClient.get(`/routes/${addForm.routeId}`), + enabled: !!addForm.routeId, + }); + + const { data: editRouteDetail } = useQuery({ + queryKey: ['route-detail', editingSchedule?.routeId], + queryFn: () => apiClient.get(`/routes/${editingSchedule!.routeId}`), + enabled: !!editingSchedule?.routeId, + }); + useEffect(() => { if (!addForm.routeId) { setAddCoachRows([]); return; } const rows: any[] = Array.isArray(singleRouteTemplate) ? singleRouteTemplate : (singleRouteTemplate as any)?.coaches ?? []; setAddCoachRows(rows.length ? rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber })) : []); }, [singleRouteTemplate, addForm.routeId]); + useEffect(() => { + const stops: any[] = (addRouteDetail as any)?.stops ?? []; + if (!stops.length) { setAddStopTimes([]); return; } + + const eatDateStr = addForm.departureAt ? addForm.departureAt.slice(0, 10) : null; + + setAddStopTimes(stops.map((s: any) => { + const arrTimePart = s.plannedArrivalTime ? isoToEATTimePart(s.plannedArrivalTime) : null; + const depTimePart = s.plannedDepartureTime ? isoToEATTimePart(s.plannedDepartureTime) : null; + return { + sequence: s.sequence, + stationName: s.station?.name ?? `Stop ${s.sequence}`, + plannedArrivalAt: eatDateStr && arrTimePart ? `${eatDateStr}T${arrTimePart}` : '', + plannedDepartureAt: eatDateStr && depTimePart ? `${eatDateStr}T${depTimePart}` : '', + }; + })); + }, [addRouteDetail, addForm.departureAt]); + // Fetch route coach template when route changes const { data: routeTemplate, isLoading: templateLoading } = useQuery({ queryKey: ['route-coaches', bulkForm.routeId], @@ -110,6 +168,24 @@ export default function SchedulesPage() { isPackageOnly: false, }); + useEffect(() => { + const stops: any[] = (editRouteDetail as any)?.stops ?? []; + if (!stops.length || !editingSchedule) return; + const hasRouteTimes = stops.some((s: any) => s.plannedArrivalTime || s.plannedDepartureTime); + if (!hasRouteTimes) return; + const eatDateStr = editForm.departureAt ? editForm.departureAt.slice(0, 10) : null; + setEditStopTimes(stops.map((s: any) => { + const arrTimePart = s.plannedArrivalTime ? isoToEATTimePart(s.plannedArrivalTime) : null; + const depTimePart = s.plannedDepartureTime ? isoToEATTimePart(s.plannedDepartureTime) : null; + return { + sequence: s.sequence, + stationName: s.station?.name ?? `Stop ${s.sequence}`, + plannedArrivalAt: eatDateStr && arrTimePart ? `${eatDateStr}T${arrTimePart}` : '', + plannedDepartureAt: eatDateStr && depTimePart ? `${eatDateStr}T${depTimePart}` : '', + }; + })); + }, [editRouteDetail, editingSchedule?.id, editForm.departureAt]); + const [filters, setFilters] = useState({ search: '', trainId: '', @@ -175,6 +251,7 @@ export default function SchedulesPage() { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); + setAddStopTimes([]); setError(null); }, onError: (err: any) => { @@ -189,6 +266,7 @@ export default function SchedulesPage() { queryClient.invalidateQueries({ queryKey: ['schedules'] }); setShowEditModal(false); setEditingSchedule(null); + setEditStopTimes([]); setError(null); }, onError: (err: any) => { @@ -255,15 +333,30 @@ export default function SchedulesPage() { const handleAddSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); - const dep = new Date(addForm.departureAt); - const arr = new Date(addForm.arrivalAt); - if (arr <= dep) { setError('Arrival must be after departure'); return; } + if (!addForm.departureAt || !addForm.arrivalAt) { + setError('Please select departure and arrival date & time'); + return; + } + if (new Date(addForm.arrivalAt + ':00Z') <= new Date(addForm.departureAt + ':00Z')) { + setError('Arrival must be after departure'); return; + } + + const filledStops = addStopTimes.filter(s => s.plannedDepartureAt || s.plannedArrivalAt); + const plannedTimes = filledStops.length === addStopTimes.length && addStopTimes.length > 0 + ? addStopTimes.map(s => ({ + sequence: s.sequence, + ...(s.plannedArrivalAt ? { plannedArrivalAt: eatToISO(s.plannedArrivalAt) } : {}), + ...(s.plannedDepartureAt ? { plannedDepartureAt: eatToISO(s.plannedDepartureAt) } : {}), + })) + : undefined; + const validCoaches = addCoachRows.filter((r) => r.coachId); await createScheduleMutation.mutateAsync({ trainId: addForm.trainId, routeId: addForm.routeId, - departureAt: dep.toISOString(), - arrivalAt: arr.toISOString(), + departureAt: eatToISO(addForm.departureAt), + arrivalAt: eatToISO(addForm.arrivalAt), + ...(plannedTimes ? { plannedTimes } : {}), ...(validCoaches.length > 0 && { coachIds: validCoaches.map((r) => r.coachId) }), }); }; @@ -274,24 +367,35 @@ export default function SchedulesPage() { if (!editingSchedule) return; - // Convert local datetime-local values to UTC for API - const depLocal = new Date(editForm.departureAt); - const arrLocal = new Date(editForm.arrivalAt); - - if (arrLocal <= depLocal) { + if (!editForm.departureAt || !editForm.arrivalAt) { + setError('Please select departure and arrival date & time'); + return; + } + + if (new Date(editForm.arrivalAt + ':00Z') <= new Date(editForm.departureAt + ':00Z')) { setError('Arrival time must be after departure time'); return; } + const filledEditStops = editStopTimes.filter(s => s.plannedDepartureAt || s.plannedArrivalAt); + const editPlannedTimes = filledEditStops.length === editStopTimes.length && editStopTimes.length > 0 + ? editStopTimes.map(s => ({ + sequence: s.sequence, + ...(s.plannedArrivalAt ? { plannedArrivalAt: eatToISO(s.plannedArrivalAt) } : {}), + ...(s.plannedDepartureAt ? { plannedDepartureAt: eatToISO(s.plannedDepartureAt) } : {}), + })) + : undefined; + const payload: any = { - departureAt: depLocal.toISOString(), - arrivalAt: arrLocal.toISOString(), + departureAt: eatToISO(editForm.departureAt), + arrivalAt: eatToISO(editForm.arrivalAt), status: editForm.status, isPackageOnly: editForm.isPackageOnly, coaches: editForm.coachIds.map((coachId: string, idx: number) => ({ coachId, positionNumber: idx + 1, })), + ...(editPlannedTimes ? { plannedTimes: editPlannedTimes } : {}), }; await updateScheduleMutation.mutateAsync({ @@ -327,26 +431,26 @@ export default function SchedulesPage() { const handleEditClick = (schedule: Schedule) => { setEditingSchedule(schedule); - // Convert UTC dates to local time for datetime-local input - // datetime-local expects local time (no timezone info) - const dep = new Date(schedule.departureAt); - const arr = new Date(schedule.arrivalAt); - - // Convert to local time by adding the timezone offset - const depLocal = new Date(dep.getTime() + dep.getTimezoneOffset() * 60000); - const arrLocal = new Date(arr.getTime() + arr.getTimezoneOffset() * 60000); - - // Format for datetime-local input (YYYY-MM-DDTHH:mm) - const depStr = depLocal.toISOString().slice(0, 16); - const arrStr = arrLocal.toISOString().slice(0, 16); - setEditForm({ - departureAt: depStr, - arrivalAt: arrStr, + departureAt: isoToEAT(schedule.departureAt), + arrivalAt: isoToEAT(schedule.arrivalAt), status: schedule.status, coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [], isPackageOnly: schedule.isPackageOnly ?? false, }); + + if (schedule.stopTimes && schedule.stopTimes.length > 0) { + const toDatetimeLocal = (iso: string | null) => iso ? isoToEAT(iso) : ''; + setEditStopTimes(schedule.stopTimes.map(st => ({ + sequence: st.sequence, + stationName: st.station?.name ?? `Stop ${st.sequence}`, + plannedArrivalAt: toDatetimeLocal(st.plannedArrivalAt), + plannedDepartureAt: toDatetimeLocal(st.plannedDepartureAt), + }))); + } else { + setEditStopTimes([]); + } + setError(null); setShowEditModal(true); }; @@ -672,7 +776,7 @@ export default function SchedulesPage() { { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }} + onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setAddStopTimes([]); setError(null); }} title="Add Schedule" size="lg" > @@ -699,14 +803,114 @@ export default function SchedulesPage() {
- setAddForm({ ...addForm, departureAt: e.target.value })} required /> + setAddForm({ ...addForm, departureAt: v })} + placeholder="Select departure" + />
- setAddForm({ ...addForm, arrivalAt: e.target.value })} required /> + setAddForm({ ...addForm, arrivalAt: v })} + placeholder="Select arrival" + />
+ {addStopTimes.length > 0 && ( +
+
+
+ +

+ Set planned times for each stop. Leave all blank to auto-generate from distance. +

+
+ +
+
+ + + + + + + + + + + {addStopTimes.map((stop, i) => { + const isFirst = i === 0; + const isLast = i === addStopTimes.length - 1; + return ( + + + + + + + ); + })} + +
#StationPlanned ArrivalPlanned Departure
{stop.sequence}{stop.stationName} + {isFirst ? ( + + ) : ( + { + const updated = [...addStopTimes]; + updated[i] = { ...updated[i], plannedArrivalAt: v }; + setAddStopTimes(updated); + }} + placeholder="Pick arrival" + /> + )} + + {isLast ? ( + + ) : ( + { + const updated = [...addStopTimes]; + updated[i] = { ...updated[i], plannedDepartureAt: v }; + setAddStopTimes(updated); + }} + placeholder="Pick departure" + /> + )} +
+
+
+ )} +
@@ -1006,6 +1210,7 @@ export default function SchedulesPage() { onClose={() => { setShowEditModal(false); setEditingSchedule(null); + setEditStopTimes([]); setError(null); }} title={`Edit Schedule: ${editingSchedule?.originStation?.name ?? ''} → ${editingSchedule?.destinationStation?.name ?? ''}`} @@ -1022,23 +1227,19 @@ export default function SchedulesPage() {
- setEditForm({ ...editForm, departureAt: e.target.value })} - className="input" - required + onChange={(v) => setEditForm({ ...editForm, departureAt: v })} + placeholder="Select departure" />
- setEditForm({ ...editForm, arrivalAt: e.target.value })} - className="input" - required + onChange={(v) => setEditForm({ ...editForm, arrivalAt: v })} + placeholder="Select arrival" />
@@ -1072,6 +1273,101 @@ export default function SchedulesPage() {
+ {editStopTimes.length > 0 && ( +
+
+
+ +

+ Edit planned times for each stop. All stops must be filled to update. +

+
+ +
+
+ + + + + + + + + + + {editStopTimes.map((stop, i) => { + const isFirst = i === 0; + const isLast = i === editStopTimes.length - 1; + return ( + + + + + + + ); + })} + +
#StationPlanned ArrivalPlanned Departure
{stop.sequence}{stop.stationName} + {isFirst ? ( + + ) : ( + { + const updated = [...editStopTimes]; + updated[i] = { ...updated[i], plannedArrivalAt: v }; + setEditStopTimes(updated); + }} + placeholder="Pick arrival" + /> + )} + + {isLast ? ( + + ) : ( + { + const updated = [...editStopTimes]; + updated[i] = { ...updated[i], plannedDepartureAt: v }; + setEditStopTimes(updated); + }} + placeholder="Pick departure" + /> + )} +
+
+
+ )} +
@@ -1129,6 +1425,7 @@ export default function SchedulesPage() { onClick={() => { setShowEditModal(false); setEditingSchedule(null); + setEditStopTimes([]); setError(null); }} > diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 62fa655c7..0f9666695 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -80,13 +80,13 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { title: 'Master Data', items: [ - { name: 'Stations', href: '/stations', icon: MapPin, permission: PERMS.stations.view }, - { name: 'Trains', href: '/trains', icon: Train, permission: PERMS.trains.view }, - { name: 'Coaches', href: '/coaches', icon: Grid3x3, permission: PERMS.coaches.view }, - { name: 'Seats', href: '/seats', icon: Armchair, permission: PERMS.seats.view }, - { name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view }, - { name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view }, - { name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view }, + { name: 'Stations', href: '/stations', icon: MapPin }, + { name: 'Trains', href: '/trains', icon: Train }, + { name: 'Coaches', href: '/coaches', icon: Grid3x3 }, + { name: 'Seats', href: '/seats', icon: Armchair }, + { name: 'Classes', href: '/classes', icon: Settings }, + { name: 'Routes', href: '/routes', icon: Route }, + { name: 'Schedules', href: '/schedules', icon: Calendar }, ] }, { diff --git a/apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx b/apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx new file mode 100644 index 000000000..6e71fc268 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx @@ -0,0 +1,358 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { DayPicker } from 'react-day-picker'; +import { ChevronLeft, ChevronRight, Calendar, ChevronUp, ChevronDown, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface DateTimePickerProps { + value: string; // YYYY-MM-DDTHH:mm (datetime-local format) + onChange: (value: string) => void; + required?: boolean; + id?: string; + placeholder?: string; + label?: string; +} + +function parseLocalString(s: string) { + if (!s) return null; + const [datePart, timePart] = s.split('T'); + if (!datePart || !timePart) return null; + const [yyyy, mm, dd] = datePart.split('-').map(Number); + const [h, m] = timePart.split(':').map(Number); + if (isNaN(yyyy) || isNaN(mm) || isNaN(dd) || isNaN(h) || isNaN(m)) return null; + const period: 'AM' | 'PM' = h >= 12 ? 'PM' : 'AM'; + const hours12 = h % 12 === 0 ? 12 : h % 12; + const date = new Date(yyyy, mm - 1, dd); + return { date, hours12, minutes: m, period }; +} + +function toLocalString(date: Date, hours12: number, minutes: number, period: 'AM' | 'PM') { + let h = hours12 % 12; + if (period === 'PM') h += 12; + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + const hh = String(h).padStart(2, '0'); + const min = String(minutes).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}T${hh}:${min}`; +} + +function formatDisplay(parsed: ReturnType): string { + if (!parsed) return ''; + const { date, hours12, minutes, period } = parsed; + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const dateStr = `${months[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`; + const timeStr = `${String(hours12).padStart(2, '0')}:${String(minutes).padStart(2, '0')} ${period}`; + return `${dateStr} ${timeStr}`; +} + +export default function DateTimePicker({ + value, + onChange, + id, + placeholder = 'Select date & time', + label, +}: DateTimePickerProps) { + const [open, setOpen] = useState(false); + const [mounted, setMounted] = useState(false); + + useEffect(() => { setMounted(true); }, []); + + const parsed = parseLocalString(value); + const [selectedDate, setSelectedDate] = useState(parsed?.date); + const [hours12, setHours12] = useState(parsed?.hours12 ?? 12); + const [minutes, setMinutes] = useState(parsed?.minutes ?? 0); + const [period, setPeriod] = useState<'AM' | 'PM'>(parsed?.period ?? 'AM'); + + // Sync internal state when value changes externally + useEffect(() => { + const p = parseLocalString(value); + if (p) { + setSelectedDate(p.date); + setHours12(p.hours12); + setMinutes(p.minutes); + setPeriod(p.period); + } + }, [value]); + + // Close on Escape + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [open]); + + const emit = useCallback( + (date: Date | undefined, h: number, m: number, p: 'AM' | 'PM') => { + if (!date) return; + onChange(toLocalString(date, h, m, p)); + }, + [onChange], + ); + + const handleDaySelect = (date: Date | undefined) => { + setSelectedDate(date); + if (date) emit(date, hours12, minutes, period); + }; + + const cycleHour = (dir: 1 | -1) => { + const next = hours12 + dir; + const h = next > 12 ? 1 : next < 1 ? 12 : next; + setHours12(h); + emit(selectedDate, h, minutes, period); + }; + + const cycleMinute = (dir: 1 | -1) => { + const next = minutes + dir; + const m = next > 59 ? 0 : next < 0 ? 59 : next; + setMinutes(m); + emit(selectedDate, hours12, m, period); + }; + + const togglePeriod = (p: 'AM' | 'PM') => { + setPeriod(p); + emit(selectedDate, hours12, minutes, p); + }; + + const handleHourInput = (raw: string) => { + const h = parseInt(raw); + if (isNaN(h)) return; + const clamped = Math.max(1, Math.min(12, h)); + setHours12(clamped); + emit(selectedDate, clamped, minutes, period); + }; + + const handleMinuteInput = (raw: string) => { + const m = parseInt(raw); + if (isNaN(m)) return; + const clamped = Math.max(0, Math.min(59, m)); + setMinutes(clamped); + emit(selectedDate, hours12, clamped, period); + }; + + const modal = open && mounted ? createPortal( +
+ {/* Backdrop */} +
setOpen(false)} + /> + + {/* Panel */} +
+ {/* Header */} +
+

+ {label ?? placeholder} +

+ +
+ + {/* Calendar */} + + orientation === 'left' ? ( + + ) : ( + + ), + DayButton: ({ day, modifiers, className, ...props }) => ( + + handleHourInput(e.target.value)} + onFocus={e => e.target.select()} + className="w-10 text-center text-base font-mono border border-border rounded-lg py-1.5 bg-background focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + +
+ + : + + {/* Minute spinner */} +
+ + handleMinuteInput(e.target.value)} + onFocus={e => e.target.select()} + className="w-10 text-center text-base font-mono border border-border rounded-lg py-1.5 bg-background focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + +
+ + {/* AM / PM */} +
+ + +
+
+
+ + {/* Confirm */} + +
+
, + document.body, + ) : null; + + const displayText = parsed ? formatDisplay(parsed) : placeholder; + + return ( +
+ + {modal} +
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 108b45d8b..6ace365ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -953,6 +953,9 @@ importers: react: specifier: ^18.3.1 version: 18.3.1 + react-day-picker: + specifier: ^9.14.0 + version: 9.14.0(react@18.3.1) react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) @@ -22123,6 +22126,14 @@ snapshots: date-fns: 3.6.0 react: 19.2.6 + react-day-picker@9.14.0(react@18.3.1): + dependencies: + '@date-fns/tz': 1.5.0 + '@tabby_ai/hijri-converter': 1.0.5 + date-fns: 4.4.0 + date-fns-jalali: 4.1.0-0 + react: 18.3.1 + react-day-picker@9.14.0(react@19.2.6): dependencies: '@date-fns/tz': 1.5.0 From 0dead281ce559a111c800a1057f8eb87a96ffe59 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 18 Jul 2026 19:20:45 +0000 Subject: [PATCH 73/88] add per-container handling options for hazardous, reefer, and return services - Introduced new boolean fields (isHazardous, isReefer, isReturn) in UnitDraft and related interfaces to allow individual container handling options. - Updated emptyUnit function to initialize these new fields. - Modified GlCreateBookingForm to handle and display these options for each container. - Adjusted calculations for hazardous, reefer, and return quantities based on the new handling options. - Updated the schema for container units and booking container lines to include handling options. - Added migration to support the new return flag in the database. - Enhanced various components to reflect gross weight calculations, ensuring consistency across the application. --- ...370000000000-AddContainerUnitReturnFlag.ts | 28 ++ .../bookings/booking-pricing.service.ts | 4 + .../entities/booking-container-unit.entity.ts | 4 + .../contracts/contract-booking.service.ts | 83 ++++-- .../dto/create-booking-under-contract.dto.ts | 9 + .../entities/rate-type.util.spec.ts | 28 ++ .../rule-engine/entities/rate-type.util.ts | 6 + .../rule-engine/rule-engine.service.ts | 35 ++- .../booking-batch.service.spec.ts | 40 ++- .../train-scheduling/booking-batch.service.ts | 39 ++- .../train-scheduling/intercity.service.ts | 19 +- .../train-scheduling.service.ts | 34 ++- .../contracts/GlCreateBookingForm.tsx | 159 +++++++----- .../trainScheduling/ScheduleWarningsAlert.tsx | 6 +- .../ScheduleWorkspacePanel.tsx | 5 +- .../TrainCompositionDiagram.tsx | 17 +- .../trainScheduling/WagonPlanGrid.tsx | 24 +- .../compositionEditor/BookingDetailModal.tsx | 2 +- .../InteractiveTrainConsist.tsx | 7 +- .../compositionEditor/RemoveBookingModal.tsx | 4 +- .../compositionEditor/TrainConsistView.tsx | 8 +- .../compositionEditor/TrainStatsBar.tsx | 2 +- .../UnassignedBookingsPanel.tsx | 4 +- .../compositionEditor/WagonCard.tsx | 8 +- .../backoffice/src/types/trainScheduling.ts | 6 + .../src/pages/contracts/NewShipmentPage.tsx | 241 +++++++++--------- .../contracts/new-shipment-form/schema.ts | 5 + packages/types/src/freight/contracts.ts | 8 + 28 files changed, 585 insertions(+), 250 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts diff --git a/apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts b/apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts new file mode 100644 index 000000000..1971f6166 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-container handling opt-in: each physical container can now be marked + * hazardous / reefer / with-return individually, next to its VGM. The hazardous + * and reefer flags already existed on the unit row; only the return leg was + * missing, so a booking of 20 containers with 10 returning empty can bill the + * WITH_RETURN surcharge on 10 instead of all 20. + * + * Backfill: existing rows keep false. The line-level counts + * (booking_container.return_quantity etc.) stay authoritative for bookings made + * before this change — the rule engine falls back to them when no unit is flagged. + */ +export class AddContainerUnitReturnFlag2370000000000 implements MigrationInterface { + name = 'AddContainerUnitReturnFlag2370000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."booking_container_units" ADD COLUMN IF NOT EXISTS "is_return" boolean NOT NULL DEFAULT false`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."booking_container_units" DROP COLUMN IF EXISTS "is_return"`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index d3fe02d5f..cbb630794 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -305,6 +305,10 @@ export class BookingPricingService { vgmPerUnitTons: vgm, totalVgmTons: qty * vgm, isReefer: ct.isReefer, + // Per-container opt-ins — PER_CONTAINER surcharges bill these. + hazardousQuantity: Number(bc.hazardousQuantity ?? 0), + reeferQuantity: Number(bc.reeferQuantity ?? 0), + returnQuantity: Number(bc.returnQuantity ?? 0), }, perWagon: containersPerWagonForSize(ct.sizeFt), quantity: qty, diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts index 619013280..217ffe5f8 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts @@ -32,6 +32,10 @@ export class BookingContainerUnit extends BaseEntity { @Column({ name: 'is_reefer', type: 'boolean', default: false }) isReefer!: boolean; + /** This container ships back empty after unloading (equipment return). */ + @Column({ name: 'is_return', type: 'boolean', default: false }) + isReturn!: boolean; + @Column({ name: 'sort_order', type: 'smallint', default: 0 }) sortOrder!: number; 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 8ef819eaf..97dc7767f 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 @@ -40,7 +40,10 @@ import { ContractsRepository } from './contracts.repository'; import { ClearanceFeeService } from './clearance-fee.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceWorkflowService } from './clearance-workflow.service'; -import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; +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']; @@ -283,8 +286,8 @@ export class ContractBookingService { tradeDirection: contract.tradeDirection, freightType, cargoTypeId: this.resolveCargoTypeId(contract, dto), - isHazardous: contract.isHazardous, - isReefer: contract.isReefer, + isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'), + isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'), cargoTotalWeightVgm: this.resolveBulkTons(dto), firstMilePickupAddress: contract.firstMilePickupAddress ?? null, firstMilePickupLat: contract.firstMilePickupLat ?? null, @@ -1451,6 +1454,53 @@ export class ContractBookingService { ); } + /** + * Per-line handling counts. Each physical container carries its own hazardous + * / reefer / return switch (entered next to its VGM), so the count is however + * many units opted in. Forms that predate per-unit switches send line-level + * counts and no unit flags — those are honoured as-is. + */ + private handlingCounts(line: CreateBookingContainerLineDto): { + hazardousQuantity: number; + reeferQuantity: number; + returnQuantity: number; + } { + const units = line.units ?? []; + const flagged = units.some((u) => u.isHazardous || u.isReefer || u.isReturn); + if (!flagged) { + return { + hazardousQuantity: Number(line.hazardousQuantity ?? 0), + reeferQuantity: Number(line.reeferQuantity ?? 0), + returnQuantity: Number(line.returnQuantity ?? 0), + }; + } + return { + hazardousQuantity: units.filter((u) => u.isHazardous).length, + reeferQuantity: units.filter((u) => u.isReefer).length, + returnQuantity: units.filter((u) => u.isReturn).length, + }; + } + + /** + * Booking-level hazardous / reefer flags. The CONTRACT gates the service; the + * per-container opt-ins decide whether THIS shipment actually uses it. A + * container contract that allows hazardous but a booking where nobody ticked + * the switch is not a hazardous booking, and must not fire the surcharge. + * Bulk keeps the contract flag — it has its own bulk*Quantity fields. + */ + private resolveShipmentHandlingFlag( + contract: Contract, + dto: CreateBookingUnderContractDto, + field: 'hazardousQuantity' | 'reeferQuantity', + ): boolean { + const gated = field === 'hazardousQuantity' ? contract.isHazardous : contract.isReefer; + if (!gated) return false; + if (contract.freightType !== 'CONTAINER') return true; + const lines = dto.containers ?? []; + if (!lines.length) return Boolean(gated); + return lines.some((l) => this.handlingCounts(l)[field] > 0); + } + /** * Resolve the booking's equipment return from the per-line return quantities * (container freight). The CONTRACT gates the service — like hazardous: @@ -1470,7 +1520,7 @@ export class ContractBookingService { const lines = dto.containers ?? []; for (const line of lines) { - const qty = Number(line.returnQuantity ?? 0); + const qty = this.handlingCounts(line).returnQuantity; if (qty === 0) continue; if (contract.equipmentReturn !== 'WITH_RETURN') { throw new BadRequestException( @@ -1486,7 +1536,7 @@ export class ContractBookingService { } if (contract.equipmentReturn === 'WITH_RETURN') { - const anyReturn = lines.some((l) => Number(l.returnQuantity ?? 0) > 0); + const anyReturn = lines.some((l) => this.handlingCounts(l).returnQuantity > 0); return anyReturn ? 'WITH_RETURN' : 'WITHOUT_RETURN'; } return legacy; @@ -1524,9 +1574,10 @@ export class ContractBookingService { ); } + const counts = this.handlingCounts(line); const containerType = await this.resolveContainerTypeForSize( line.containerSize, - contract.isReefer || (line.reeferQuantity ?? 0) > 0, + contract.isReefer || counts.reeferQuantity > 0, ); const vgmPerUnit = line.units.length @@ -1540,12 +1591,10 @@ export class ContractBookingService { containerTypeId: containerType.id, containerSize: line.containerSize, quantity: line.quantity, - hazardousQuantity: line.hazardousQuantity ?? 0, - reeferQuantity: line.reeferQuantity ?? 0, + hazardousQuantity: counts.hazardousQuantity, + reeferQuantity: counts.reeferQuantity, returnQuantity: - contract.equipmentReturn === 'WITH_RETURN' - ? (line.returnQuantity ?? 0) - : 0, + contract.equipmentReturn === 'WITH_RETURN' ? counts.returnQuantity : 0, vgmPerUnitTons: vgmPerUnit, totalVgmTons: totalVgm, wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)), @@ -1564,6 +1613,8 @@ export class ContractBookingService { vgmTons: unit.vgmTons, isHazardous: unit.isHazardous ?? false, isReefer: unit.isReefer ?? false, + isReturn: + contract.equipmentReturn === 'WITH_RETURN' && (unit.isReturn ?? false), sortOrder: sortOrder++, }), ); @@ -1664,8 +1715,8 @@ export class ContractBookingService { paymentCurrency: contract.paymentCurrency, serviceTypeId: contract.serviceTypeId, cargoTypeId: this.resolveCargoTypeId(contract, dto), - isHazardous: contract.isHazardous, - isReefer: contract.isReefer, + isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'), + isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'), equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), isGovernment: contract.isGovernment, shippingLineId: null, @@ -1680,11 +1731,11 @@ export class ContractBookingService { containerTypeId: ct.id, containerSize: line.containerSize, quantity: line.quantity, - hazardousQuantity: line.hazardousQuantity ?? 0, - reeferQuantity: line.reeferQuantity ?? 0, + hazardousQuantity: this.handlingCounts(line).hazardousQuantity, + reeferQuantity: this.handlingCounts(line).reeferQuantity, returnQuantity: contract.equipmentReturn === 'WITH_RETURN' - ? (line.returnQuantity ?? 0) + ? this.handlingCounts(line).returnQuantity : 0, vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0, totalVgmTons, diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 256b11b63..3b5a30ca0 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -50,6 +50,15 @@ export class CreateContainerUnitDto { @IsBoolean() @Transform(({ value }) => value === 'true' || value === true) isReefer?: boolean; + + @ApiPropertyOptional({ + default: false, + description: 'This container ships back empty (equipment return).', + }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isReturn?: boolean; } export class CreateBookingContainerLineDto { diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts new file mode 100644 index 000000000..480b2c484 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts @@ -0,0 +1,28 @@ +import { deriveRateType } from './rate-type.util'; + +describe('deriveRateType — surcharge triggers', () => { + // Every surcharge trigger must land on its own rateType. A trigger with no + // mapping falls through to the base-freight branch and is silently stored as + // CANCELLATION_FEE, which both mislabels the booking's rate snapshot and + // hides the rate from contract pricing (which looks rateTypes up by name). + it.each([ + ['HAZARDOUS', 'HAZARD_SURCHARGE'], + ['REEFER', 'REEFER_SURCHARGE'], + ['WITH_RETURN', 'RETURN_SURCHARGE'], + ['OVERWEIGHT', 'OVERWEIGHT_PER_TON'], + ['SHIPPING_LINE', 'DOUBLE_HANDLING'], + ['CONSOLIDATION', 'LASHING'], + ['CANCELLATION', 'CANCELLATION_FEE'], + ['DEMURRAGE', 'DEMURRAGE'], + ['PIL_EXTRA_FEE', 'PIL_EXTRA_FEE'], + ['CUSTOMS_CLEARANCE', 'CUSTOMS_CLEARANCE'], + ] as const)('maps trigger %s to %s', (trigger, expected) => { + expect(deriveRateType({ appliesTo: 'OTHER', trigger })).toBe(expected); + }); + + it('does not fall back to CANCELLATION_FEE for the empty-return service', () => { + expect(deriveRateType({ appliesTo: 'OTHER', trigger: 'WITH_RETURN' })).not.toBe( + 'CANCELLATION_FEE', + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts index 09f35c458..a5b5bfc30 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts @@ -25,6 +25,12 @@ export function deriveRateType(input: { return 'HAZARD_SURCHARGE'; case 'REEFER': return 'REEFER_SURCHARGE'; + // Empty-container return service. Contract pricing looks this rateType up + // by name, so without the mapping a WITH_RETURN rate fell through to the + // base-freight branch and was stored as CANCELLATION_FEE — invisible to + // the contract, and mislabelled on the booking's snapshot. + case 'WITH_RETURN': + return 'RETURN_SURCHARGE'; case 'OVERWEIGHT': return 'OVERWEIGHT_PER_TON'; case 'SHIPPING_LINE': diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 03be1b3d0..d1715d9fd 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -42,6 +42,14 @@ export interface BookingContainerEvalInput { isReefer?: boolean; isOverweight?: boolean; overweightExcessTons?: number | null; + /** + * How many individual containers on this line opted into each handling + * service. PER_CONTAINER surcharges bill these counts, not the line + * quantity — 20 containers with 10 hazardous bill hazard on 10. + */ + hazardousQuantity?: number; + reeferQuantity?: number; + returnQuantity?: number; } export interface BookingEvaluationInput { @@ -270,6 +278,27 @@ export class RuleEngineService { (sum, r) => sum + (r.overweightExcessTons ?? 0), 0, ); + /** + * Containers that opted into this trigger's handling service, summed + * across lines. null when the trigger isn't per-container handling (or + * no line carries a count) so the caller falls back to the full count. + */ + const optedInCount = (trigger: string | null): number | null => { + const field = + trigger === 'HAZARDOUS' + ? 'hazardousQuantity' + : trigger === 'REEFER' + ? 'reeferQuantity' + : trigger === 'WITH_RETURN' + ? 'returnQuantity' + : null; + if (!field) return null; + const total = input.containers.reduce( + (sum, c) => sum + Number(c[field] ?? 0), + 0, + ); + return total > 0 ? total : null; + }; let triggerValue: number | null = null; let calculatedAmount: number; @@ -285,7 +314,11 @@ export class RuleEngineService { calculatedAmount = triggerValue * rateValue; break; case 'PER_CONTAINER': - triggerValue = containerCount; + // Handling surcharges bill only the containers that opted in, not the + // whole line — 20 containers with 10 hazardous bill hazard on 10. + // Legacy bookings carry no per-container counts (all 0) while their + // booking-level flag is set, so fall back to the full count there. + triggerValue = optedInCount(rate.trigger) ?? containerCount; calculatedAmount = triggerValue * rateValue; break; case 'PER_WAGON': 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 c19140d8a..fc97f772b 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 @@ -958,20 +958,21 @@ describe('BookingBatchService — built-train wagon capacity', () => { // assertion below that says "not full" proves those axes are ignored. const scheduleId = 'schedule-built'; - const reservedBooking = (id: string) => + const reservedBooking = (id: string, leg?: { origin: string; dest: string }) => ({ id, freightType: 'BULK', cargoTotalWeightVgm: 50, // 1 wagon at the 60T default bulk payload bookingContainers: [], - originYardId: 'yard-a', - destinationYardId: 'yard-b', + originYardId: leg?.origin ?? 'yard-a', + destinationYardId: leg?.dest ?? 'yard-b', }) as unknown as Booking; const buildService = (opts: { physicalWagons: number; reserved: Booking[]; maxWagons?: number; + routeStops?: string[]; }) => { const schedule = { id: scheduleId, @@ -979,7 +980,7 @@ describe('BookingBatchService — built-train wagon capacity', () => { bookingWindowStatus: 'OPEN', originStationId: 'yard-a', destinationStationId: 'yard-b', - routeId: null, + routeId: opts.routeStops ? 'route-1' : null, scheduleBookings: [], trainSet: { locomotive: { @@ -992,14 +993,23 @@ describe('BookingBatchService — built-train wagon capacity', () => { }, }; const wagonRepo = { count: jest.fn().mockResolvedValue(opts.physicalWagons) }; + const milestoneRepo = { + find: jest + .fn() + .mockResolvedValue( + (opts.routeStops ?? []).map((yardId, i) => ({ yardId, sequenceNo: i + 1 })), + ), + }; const genericRepo = { find: jest.fn().mockResolvedValue([]), update: jest.fn().mockResolvedValue(undefined), }; const dataSource = { - getRepository: jest.fn((entity: { name?: string }) => - entity?.name === 'Wagon' ? wagonRepo : genericRepo, - ), + getRepository: jest.fn((entity: { name?: string }) => { + if (entity?.name === 'Wagon') return wagonRepo; + if (entity?.name === 'RouteMilestone') return milestoneRepo; + return genericRepo; + }), transaction: jest.fn(), }; const service = new BookingBatchService( @@ -1040,6 +1050,22 @@ describe('BookingBatchService — built-train wagon capacity', () => { await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); }); + it('is FULL when sub-leg bookings hold every physical wagon of a milestone route', async () => { + // Regression: 50 wagons sold Negad→Mojo on a Doraleh→…→Dire Dawa corridor + // left the pass-through edges reading "free" in the per-edge budget, so the + // full train's window cycled OPEN forever and the day pool never expired. + // A wagon is committed for the whole trip — leg-free edges are not capacity. + const { service } = buildService({ + physicalWagons: 2, + routeStops: ['yard-a', 'yard-m1', 'yard-m2', 'yard-b'], + reserved: [ + reservedBooking('b1', { origin: 'yard-m1', dest: 'yard-m2' }), + reservedBooking('b2', { origin: 'yard-m1', dest: 'yard-m2' }), + ], + }); + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true); + }); + it('reports over-allocation when the consist is trimmed below committed bookings', async () => { const { service } = buildService({ physicalWagons: 1, 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 c06f0ee81..5837e4cd2 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 @@ -3607,11 +3607,19 @@ export class BookingBatchService implements OnModuleInit { /** See {@link isScheduleFull} — same check for callers that already hold the full graph. */ private async isTrainFull(schedule: TrainSchedule): Promise { + // Built train: the physical consist is the only capacity axis, and a wagon + // is committed to its booking for the WHOLE trip — wagon allocation has no + // leg concept, so a wagon hauling Negad→Mojo cargo can never be re-sold for + // the Doraleh→Negad edge it merely passes through. Count commitments + // train-wide, not per corridor edge: the per-edge budget read "free slots" + // on pass-through legs of a sold-out consist, so the window of a full train + // cycled OPEN forever instead of concluding DONE (and the day pool's + // leftover bookings were never expired). + const physicalWagons = await this.builtTrainWagonCount(schedule); + if (physicalWagons != null) { + return (await this.committedWagons(schedule)) >= physicalWagons; + } if ((await this.remainingWagons(schedule)) <= 0) return true; - // Built train: the physical consist is the only capacity axis. Weight and - // length were enforced when the consist was assembled (builder / - // adjust-consist), so a free wagon slot means the train genuinely has room. - if ((await this.builtTrainWagonCount(schedule)) != null) return false; const locomotive = schedule.trainSet?.locomotive; if (!locomotive) return false; // no weight/length limits to bind against const wagonDims = await this.loadWagonDims(); @@ -3620,6 +3628,29 @@ export class BookingBatchService implements OnModuleInit { return budget.isExhausted(this.minPerWagonNeed(wagonDims)); } + /** + * Wagons the schedule's allocated + reserved bookings occupy train-wide, + * regardless of which corridor leg each rides. Deduped by booking id — a + * booking mid-settle can momentarily be both linked and reserved. + */ + private async committedWagons(schedule: TrainSchedule): Promise { + const wagonDims = await this.loadWagonDims(); + const allocated = (schedule.scheduleBookings ?? []) + .map((sb) => sb.booking) + .filter((b): b is Booking => Boolean(b)); + const reserved = await this.bookingsRepository.findReservedForSchedule( + schedule.id, + ); + const byId = new Map( + [...allocated, ...reserved].map((b) => [b.id, b] as const), + ); + let total = 0; + for (const booking of byId.values()) { + total += this.wagonsFor(booking, wagonDims); + } + return total; + } + /** * Smallest gross weight / shortest length one more wagon could add: the * lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index d2f1dd7b1..2f539bb43 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -64,15 +64,15 @@ export class IntercityService { booking.destinationYardId, ); return { - ...this.mapBooking(booking), + ...this.mapBooking(booking, need), need, fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)), }; }), - accepted: accepted.map((booking) => ({ - ...this.mapBooking(booking), - need: capacity?.needFor(booking) ?? null, - })), + accepted: accepted.map((booking) => { + const need = capacity?.needFor(booking) ?? null; + return { ...this.mapBooking(booking, need), need }; + }), }; } @@ -294,7 +294,12 @@ export class IntercityService { return { schedule, booking }; } - private mapBooking(booking: Booking) { + /** + * `need` carries the GROSS weight (cargo + wagon tare) the capacity budget is + * spent in. Prefer it, so the row's weight sits on the same axis as the + * remaining-capacity figure shown beside it; cargo VGM is the fallback. + */ + private mapBooking(booking: Booking, need?: { weightTons: number } | null) { return { id: booking.id, reference: booking.reference, @@ -310,7 +315,7 @@ export class IntercityService { booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination', - weightTons: Number(booking.cargoTotalWeightVgm ?? 0), + weightTons: need?.weightTons ?? Number(booking.cargoTotalWeightVgm ?? 0), paymentDeadline: booking.paymentDeadline?.toISOString() ?? null, }; } 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 eb3fd7201..38bc21156 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 @@ -267,6 +267,8 @@ export interface CompositionUnassignedBookingRow { freightType: string | null; priorityScore: number; cargoTotalWeightVgm: number; + /** GROSS: cargo VGM + tare of every wagon the booking occupies. */ + grossWeightTons: number; status: string | null; schedulingStatus: string | null; wagonsRequired: number; @@ -3866,11 +3868,18 @@ export class TrainSchedulingService { } const totalWeightTons = totalAssignedWeight(fittingBookings); + // Every weight limit below (global max, loco pull) is a GROSS axis, so the + // figure spent against it must be gross too — cargo alone under-reports the + // train by the full consist tare and disagrees with the assign path. + const totalTareTons = roundTons( + wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0), + ); + const grossWeightTons = roundTons(totalWeightTons + totalTareTons); const totalLengthMeters = roundTons( wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0), ); - if (totalWeightTons > trainLimits.maxWeightTons) { - const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`; + if (grossWeightTons > trainLimits.maxWeightTons) { + const message = `Total gross weight ${grossWeightTons}T (${totalWeightTons}T cargo + ${totalTareTons}T wagon tare) exceeds max train weight ${trainLimits.maxWeightTons}T`; if (!violations.includes(message) && !warnings.includes(message)) { pushLimit([message]); } @@ -3897,7 +3906,7 @@ export class TrainSchedulingService { if ( setLimits && (setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) < - totalWeightTons || + grossWeightTons || setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) < totalLengthMeters) ) { @@ -3918,7 +3927,7 @@ export class TrainSchedulingService { !inServiceLocomotives.some( (l) => Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >= - totalWeightTons && + grossWeightTons && Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >= totalLengthMeters, ) @@ -3940,6 +3949,9 @@ export class TrainSchedulingService { summary: { totalBookings: fittingBookings.length, totalWeightTons, + /** GROSS: cargo + the tare of every wagon in the plan. */ + grossWeightTons, + totalTareTons, // Human-readable wagon type(s) of the plan — mixed consists list all. wagonType: plannedTypeCodes.join('/') || 'NONE', wagonsNeeded: wagonPlan.length, @@ -7036,6 +7048,15 @@ export class TrainSchedulingService { shortfall: 0, })); + // Gross weight needs the scheduling graph (containers, cargo type, wagon + // types) that the trimmed select above deliberately skips. + const tareDims = await this.loadWagonTareDims(); + const fullById = new Map( + (await this.bookingsRepository.findByIdsForScheduling(unassigned.map((b) => b.id))).map( + (b) => [b.id, b], + ), + ); + const bookings = await Promise.all( unassigned.map(async (b) => { const assignability = await this.previewUnassignedBookingAssignability( @@ -7050,6 +7071,11 @@ export class TrainSchedulingService { freightType: b.freightType ?? null, priorityScore: b.priorityScore ?? 0, cargoTotalWeightVgm: Number(b.cargoTotalWeightVgm ?? 0), + // GROSS: cargo + tare of the wagons the booking occupies. + grossWeightTons: this.grossBookingWeightTons( + (fullById.get(b.id) ?? b) as Booking, + tareDims, + ), status: b.status ?? null, schedulingStatus: b.schedulingStatus ?? null, ...assignability, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 5633745aa..5225dc6b9 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -128,6 +128,10 @@ interface UnitDraft { containerNumber: string; sealNumber: string; vgmTons: string; + /** Handling is per physical container; the line counts roll these up. */ + isHazardous: boolean; + isReefer: boolean; + isReturn: boolean; } /** Mirrors the portal shipment form's container line: line-level quantity + @@ -150,7 +154,14 @@ interface BulkDraft { } function emptyUnit(): UnitDraft { - return { containerNumber: "", sealNumber: "", vgmTons: "" }; + return { + containerNumber: "", + sealNumber: "", + vgmTons: "", + isHazardous: false, + isReefer: false, + isReturn: false, + }; } function emptyLine(size: string): ContainerLineDraft { @@ -285,6 +296,20 @@ export default function GlCreateBookingForm() { // Legacy contracts (no equipment return chosen at creation) keep the old // booking-level toggle. const legacyReturnToggle = isContainer && !contract?.equipmentReturn; + /** + * Handling switches offered on each container row — only the services this + * contract was created with, since the server rejects the others. + */ + const handlingColumns = ( + [ + contract?.isHazardous && { key: "isHazardous", label: "Hazardous" }, + contract?.isReefer && { key: "isReefer", label: "Refrigerated" }, + contractWithReturn && { key: "isReturn", label: "With return" }, + ] as Array + ).filter(Boolean) as Array<{ + key: "isHazardous" | "isReefer" | "isReturn"; + label: string; + }>; // Intercity shipments ride a passing import/export train staff pick at // finalize time — no shipment day is chosen and no window gate applies. const isIntercity = contract?.tradeDirection === "DOMESTIC"; @@ -488,6 +513,18 @@ export default function GlCreateBookingForm() { enabled: cargoQuery !== null && !isIntercity, }); + /** + * Line handling totals are a roll-up of the per-container switches — the + * count is however many containers ticked each service. Recomputed on every + * unit change so the price estimate and payload follow the switches. + */ + const withDerivedCounts = (line: ContainerLineDraft): ContainerLineDraft => ({ + ...line, + hazardousQuantity: String(line.units.filter((u) => u.isHazardous).length), + reeferQuantity: String(line.units.filter((u) => u.isReefer).length), + returnQuantity: String(line.units.filter((u) => u.isReturn).length), + }); + // Keep the units array length in sync with the entered quantity. const syncUnits = (lineIdx: number, qty: number) => { setContainerLines((prev) => @@ -496,7 +533,7 @@ export default function GlCreateBookingForm() { const next = [...line.units]; while (next.length < qty) next.push(emptyUnit()); next.length = Math.max(0, qty); - return { ...line, units: next }; + return withDerivedCounts({ ...line, units: next }); }), ); }; @@ -511,11 +548,16 @@ export default function GlCreateBookingForm() { unitIdx: number, patch: Partial, ) => - patchLine(lineIdx, { - units: containerLines[lineIdx].units.map((u, i) => - i === unitIdx ? { ...u, ...patch } : u, + setContainerLines((prev) => + prev.map((l, i) => + i === lineIdx + ? withDerivedCounts({ + ...l, + units: l.units.map((u, j) => (j === unitIdx ? { ...u, ...patch } : u)), + }) + : l, ), - }); + ); // Same client-side validation as the customer portal shipment form // (new-shipment-form/schema.ts): ISO container numbers unique within the @@ -560,10 +602,15 @@ export default function GlCreateBookingForm() { hazardousQuantity: String(imported.filter((r) => r.hazardous).length), reeferQuantity: String(imported.filter((r) => r.reefer).length), returnQuantity: String(imported.filter((r) => r.withReturn).length), + // The spreadsheet marks handling per row — carry it onto the + // container it belongs to rather than collapsing it to a line count. units: imported.map((r) => ({ containerNumber: r.containerNumber, sealNumber: r.sealNumber, vgmTons: String(r.vgmTons), + isHazardous: Boolean(r.hazardous), + isReefer: Boolean(r.reefer), + isReturn: Boolean(r.withReturn), })), }; }), @@ -742,6 +789,11 @@ export default function GlCreateBookingForm() { containerNumber: u.containerNumber.trim().toUpperCase(), ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), vgmTons: Number(u.vgmTons) || 0, + // Per-container handling — the server rolls these into the line + // counts and bills each surcharge on the ticked containers only. + isHazardous: Boolean(u.isHazardous), + isReefer: Boolean(u.isReefer), + ...(contractWithReturn ? { isReturn: Boolean(u.isReturn) } : {}), })), })); } else { @@ -1175,73 +1227,24 @@ export default function GlCreateBookingForm() { radius={10} styles={fieldStyles} /> - {contract.isHazardous && ( - - patchLine(lineIdx, { - hazardousQuantity: e.currentTarget.value, - }) - } - radius={10} - styles={fieldStyles} - /> - )} - {contract.isReefer && ( - - patchLine(lineIdx, { - reeferQuantity: e.currentTarget.value, - }) - } - radius={10} - styles={fieldStyles} - /> - )} - {contractWithReturn && ( - - patchLine(lineIdx, { - returnQuantity: e.currentTarget.value, - }) - } - radius={10} - styles={fieldStyles} - /> - )} Per-container details + {handlingColumns.length > 0 ? ( + + Tick the services each individual container needs — + charges apply only to the containers ticked + {handlingColumns + .map((col) => { + const count = line.units.filter( + (u) => u[col.key], + ).length; + return count > 0 ? ` · ${count} ${col.label.toLowerCase()}` : ""; + }) + .join("")} + . + + ) : null} {line.units.map((unit, unitIdx) => ( @@ -1296,6 +1299,22 @@ export default function GlCreateBookingForm() { radius={10} styles={fieldStyles} /> + {handlingColumns.map((col) => ( + + patchUnit(lineIdx, unitIdx, { + [col.key]: e.currentTarget.checked, + }) + } + label={unitIdx === 0 ? col.label : undefined} + labelPosition="right" + size="sm" + mt={unitIdx === 0 ? 26 : 6} + /> + ))} ))} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWarningsAlert.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWarningsAlert.tsx index 25bf2480f..d84d4e8be 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWarningsAlert.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWarningsAlert.tsx @@ -40,17 +40,21 @@ export function PreviewSummary({ summary?: { totalBookings: number; totalWeightTons: number; + grossWeightTons?: number; + totalTareTons?: number; wagonType: string; wagonsNeeded: number; totalLengthMeters: number; }; }) { if (!summary) return null; + // GROSS — the axis every train limit is spent against. + const gross = summary.grossWeightTons ?? summary.totalWeightTons; const stats = [ { label: "Bookings", value: String(summary.totalBookings) }, { label: "Wagons", value: String(summary.wagonsNeeded) }, { label: "Wagon type", value: summary.wagonType }, - { label: "Total weight", value: `${summary.totalWeightTons}T` }, + { label: "Gross weight", value: `${gross}T` }, { label: "Train length", value: `${summary.totalLengthMeters}m` }, ]; return ( diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index 867a0f895..c7fc49b18 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -99,9 +99,8 @@ function usedWeight(schedule: TrainScheduleDetail): number { /** * Pull capacity of the set = the WEAKEST locomotive's max pull weight (0 when * unknown). The API caps at the weakest loco, not the sum of all locos — a - * consist can only pull as hard as its weakest engine. Note: the API also adds - * the consist tare to the used weight when it checks this cap; tare isn't - * available client-side, so this meter compares cargo-only load against pull. + * consist can only pull as hard as its weakest engine. Both sides of this meter + * are gross: `usedWeight` sums per-booking gross (cargo + wagon tare). */ function pullCapacity(schedule: TrainScheduleDetail): number { const set = schedule.trainSet; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx index 2805ba60a..af8917e00 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx @@ -46,6 +46,8 @@ type NormalizedWagon = { const CAR_WIDTH = 150; // car body + coupler footprint +const round1 = (n: number) => Math.round(n * 10) / 10; + function normalizeWagon(w: DiagramWagonInput, freightType?: string | null): NormalizedWagon { const allocations = w.allocations ?? []; const firstLoad = ( @@ -268,10 +270,11 @@ const CONTAINER_BORDERS = [ ]; function WagonCar({ wagon }: { wagon: NormalizedWagon }) { + // GROSS on both sides: cargo + tare vs rated payload + tare. + const grossTons = round1(wagon.assignedWeightTons + wagon.tareWeightTons); + const maxGrossTons = round1(wagon.capacityTons + wagon.tareWeightTons); const utilization = - wagon.capacityTons > 0 - ? Math.min(100, Math.round((wagon.assignedWeightTons / wagon.capacityTons) * 100)) - : 0; + maxGrossTons > 0 ? Math.min(100, Math.round((grossTons / maxGrossTons) * 100)) : 0; const accent = wagon.isEmpty ? "gray" : wagon.isBulk ? "orange" : "cyan"; const accentVar = `var(--mantine-color-${accent}-6)`; @@ -281,8 +284,8 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) { wagon.bookingRefs.length ? wagon.bookingRefs.join(", ") : "" }${ wagon.containerNumbers.length ? `\nContainers: ${wagon.containerNumbers.join(", ")}` : "" - }${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nLoad: ${wagon.assignedWeightTons}/${wagon.capacityTons}T (${utilization}%)${ - wagon.tareWeightTons ? `\nTare: ${wagon.tareWeightTons}T` : "" + }${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nGross: ${grossTons}/${maxGrossTons}T (${utilization}%)\nCargo: ${wagon.assignedWeightTons}T${ + wagon.tareWeightTons ? ` · Tare: ${wagon.tareWeightTons}T` : "" }`; // container blocks: one per container number (cap visual at 2 = TEU per wagon) @@ -369,7 +372,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) { /> - {wagon.assignedWeightTons}/{wagon.capacityTons}T + {grossTons}/{maxGrossTons}T ) : ( @@ -442,7 +445,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) { {!wagon.isEmpty ? ( - {wagon.assignedWeightTons}T + {grossTons}T ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/WagonPlanGrid.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/WagonPlanGrid.tsx index 086f78ddc..e5b63ea24 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/WagonPlanGrid.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/WagonPlanGrid.tsx @@ -6,6 +6,7 @@ type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | { sequenceNo: number; capacityTons: number; assignedWeightTons: number; + tareWeightTons?: number | null; slotLoadType?: string; wagonType?: { code: string } | null; wagonTypeCode?: string; @@ -21,6 +22,8 @@ type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | { }>; }; +const round1 = (n: number) => Math.round(n * 10) / 10; + function loadTypeColor(loadType: string | undefined, freightType?: string | null) { const normalized = loadType?.toUpperCase() ?? ""; if (normalized.includes("BULK")) return "orange"; @@ -68,8 +71,16 @@ export function WagonPlanGrid({ ); } - const totalCapacity = wagonPlan.reduce((sum, w) => sum + w.capacityTons, 0); - const totalAssigned = wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0); + // GROSS on both sides: cargo + tare vs rated payload + tare. + const totalTare = round1( + wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0), + ); + const totalCapacity = round1( + wagonPlan.reduce((sum, w) => sum + w.capacityTons, 0) + totalTare, + ); + const totalAssigned = round1( + wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0) + totalTare, + ); const usedSlots = wagonPlan.filter((w) => (w.allocations?.length ?? 0) > 0).length; const isBulk = freightType === "BULK" || wagonPlan.every((w) => w.slotLoadType === "BULK" || (!w.slotLoadType && w.allocations?.[0]?.loadType === "Bulk")); @@ -82,7 +93,7 @@ export function WagonPlanGrid({ {isBulk ? ( - Load: {totalAssigned} / {totalCapacity}T + Gross: {totalAssigned} / {totalCapacity}T ) : null} @@ -90,8 +101,9 @@ export function WagonPlanGrid({ {wagonPlan.map((wagon) => { const seq = wagon.sequenceNo; - const capacity = wagon.capacityTons; - const assigned = wagon.assignedWeightTons; + const tare = Number(wagon.tareWeightTons) || 0; + const capacity = round1(wagon.capacityTons + tare); + const assigned = round1(wagon.assignedWeightTons + tare); const allocations = wagon.allocations ?? []; const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0; const label = slotLabel(wagon, freightType); @@ -149,7 +161,7 @@ export function WagonPlanGrid({ {label === "BULK" ? ( - {alloc.allocatedWeightTons}T + {alloc.allocatedWeightTons}T cargo ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx index 00df4700c..9e7062ad6 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx @@ -132,7 +132,7 @@ export const BookingDetailModal = ({ /> } - label="Weight" + label="Gross weight" value={ {booking.weightTons != null ? `${booking.weightTons.toFixed(1)} T` : "—"} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx index 8af9a6e34..8ac374c1a 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx @@ -161,8 +161,11 @@ function WagonCar({ const allocation = wagon.allocations?.[0]; const isEmpty = !allocation; const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK"); - const assigned = allocation?.allocatedWeightTons ?? wagon.assignedWeightTons ?? 0; - const capacity = wagon.capacityTons ?? 0; + // GROSS on both sides: cargo + tare vs rated payload + tare. + const tare = wagon.tareWeightTons ?? 0; + const assigned = + (allocation?.allocatedWeightTons ?? wagon.assignedWeightTons ?? 0) + tare; + const capacity = (wagon.capacityTons ?? 0) + tare; const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0; const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan"; const accentVar = `var(--mantine-color-${accent}-6)`; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx index 4d2b1369c..80cbeaad6 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx @@ -21,6 +21,8 @@ export const RemoveBookingModal = ({ if (!wagon || !wagon.allocations?.[0]) return null; const allocation = wagon.allocations[0]; + // GROSS: allocated cargo + the tare of the wagon it sits on. + const grossTons = (allocation.allocatedWeightTons ?? 0) + (wagon.tareWeightTons ?? 0); return ( @@ -40,7 +42,7 @@ export const RemoveBookingModal = ({ - Weight: {allocation.allocatedWeightTons?.toFixed(2) || 0} T + Gross weight: {grossTons.toFixed(2)} T Wagon Slot: #{wagon.sequenceNo} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx index 6ece98ef2..e5b12fe1c 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx @@ -93,10 +93,14 @@ export const TrainConsistView = ({ } }; - const weightUsed = wagons.reduce( - (sum, w) => sum + (w.allocations?.[0]?.allocatedWeightTons ?? 0), + // GROSS: cargo on every allocation + the tare of every wagon in the consist. + // maxPullWeightTons is a gross limit, so the numerator must be gross too. + const cargoUsed = wagons.reduce( + (sum, w) => sum + (w.allocations ?? []).reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0), 0, ); + const tareUsed = wagons.reduce((sum, w) => sum + (w.tareWeightTons ?? 0), 0); + const weightUsed = cargoUsed + tareUsed; const lengthUsed = wagons.reduce((sum, w) => sum + (w.lengthMeters ?? 0), 0); return ( diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx index 8ec1c7f4e..3403258ef 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx @@ -98,7 +98,7 @@ export const TrainStatsBar = ({ } - label="Weight" + label="Gross weight" pct={weightPct} current={weightUsed.toFixed(1)} max={weightMax?.toFixed(1) ?? "∞"} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx index e5225f8dd..3af00fd88 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx @@ -130,7 +130,9 @@ export const UnassignedBookingsPanel = ({ {bookings.map((booking) => { const isActive = selectedBookingId === booking.id; - const weight = Number(booking.cargoTotalWeightVgm ?? 0); + // GROSS (cargo + wagon tare) so this badge shares the axis every other + // weight on the page uses — cargo-only here read ~25% light. + const weight = Number(booking.grossWeightTons ?? booking.cargoTotalWeightVgm ?? 0); const fits = booking.canAssign; const blockReason = booking.blockReason; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx index f735e6474..915cfa629 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx @@ -36,8 +36,12 @@ export const WagonCard = ({ const hasAllocations = Boolean(allocation); const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK"); - const weightUsed = allocation?.allocatedWeightTons ?? 0; - const weightMax = wagon.capacityTons ?? 0; + // GROSS on both sides: loaded cargo + wagon tare, against the wagon's max + // gross (rated payload + tare). Keeps the wagon axis identical to the train + // axis in TrainStatsBar. + const tare = wagon.tareWeightTons ?? 0; + const weightUsed = (allocation?.allocatedWeightTons ?? 0) + tare; + const weightMax = (wagon.capacityTons ?? 0) + tare; const weightPercent = weightMax ? (weightUsed / weightMax) * 100 : 0; const wagonType = wagon.wagonType?.code || "UNKNOWN"; diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index e8aaf71ec..40337a4cd 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -134,7 +134,11 @@ export interface TrainSchedulePreviewResponse { deferredBookings?: DeferredBookingRow[]; summary: { totalBookings: number; + /** Cargo VGM only — display gross instead. */ totalWeightTons: number; + /** GROSS: cargo + the tare of every wagon in the plan. */ + grossWeightTons: number; + totalTareTons: number; wagonType: string; wagonsNeeded: number; totalLengthMeters: number; @@ -845,6 +849,8 @@ export interface CompositionUnassignedBooking { freightType: FreightType | null; priorityScore: number; cargoTotalWeightVgm: number; + /** GROSS: cargo VGM + tare of every wagon the booking occupies. */ + grossWeightTons: number; status: string | null; schedulingStatus: SchedulingStatus | null; wagonsRequired: number; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index ea83c8163..7bdb06f84 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -59,8 +59,6 @@ import { StepCard, StepHeader, StepLabel, - ToggleRow, - UnitCountToggles, fieldStyles, } from "./new-contract-form/shared"; import { formatRateUnit } from "./new-contract-form/unit-rates"; @@ -362,6 +360,11 @@ function NewShipmentBookingForm({ containerNumber: u.containerNumber, sealNumber: u.sealNumber || undefined, vgmTons: Number(u.vgmTons), + // Per-container handling — the server rolls these up into the + // line counts and bills each surcharge on the ticked containers. + isHazardous: Boolean(u.isHazardous), + isReefer: Boolean(u.isReefer), + ...(withReturnService ? { isReturn: Boolean(u.isReturn) } : {}), })), })), } @@ -1133,7 +1136,7 @@ function CargoStep({ hazardousQuantity: "0", reeferQuantity: "0", returnQuantity: "0", - units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }], + units: [emptyUnit()], })), { shouldValidate: false }, ); @@ -1177,7 +1180,7 @@ function CargoStep({ hazardousQuantity: "0", reeferQuantity: "0", returnQuantity: "0", - units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }], + units: [emptyUnit()], } ); } @@ -1187,10 +1190,15 @@ function CargoStep({ hazardousQuantity: String(imported.filter((r) => r.hazardous).length), reeferQuantity: String(imported.filter((r) => r.reefer).length), returnQuantity: String(imported.filter((r) => r.withReturn).length), + // The spreadsheet already marks handling per row — carry it onto the + // container it belongs to rather than collapsing it to a line count. units: imported.map((r) => ({ containerNumber: r.containerNumber, sealNumber: r.sealNumber, vgmTons: r.vgmTons, + isHazardous: Boolean(r.hazardous), + isReefer: Boolean(r.reefer), + isReturn: Boolean(r.withReturn), })), }; }); @@ -1511,6 +1519,16 @@ function NotesSection({ form }: { form: ShipmentForm }) { ); } +/** A blank container row — handling switches start off. */ +const emptyUnit = () => ({ + containerNumber: "", + sealNumber: "", + vgmTons: "", + isHazardous: false, + isReefer: false, + isReturn: false, +}); + function ContainerLineEditor({ form, index, @@ -1536,76 +1554,81 @@ function ContainerLineEditor({ const current = form.getValues(`containers.${index}.units`) ?? []; const next = [...current]; while (next.length < qty) - next.push({ containerNumber: "", sealNumber: "", vgmTons: "" }); + next.push({ + containerNumber: "", + sealNumber: "", + vgmTons: "", + isHazardous: false, + isReefer: false, + isReturn: false, + }); next.length = Math.max(0, qty); form.setValue(`containers.${index}.units`, next, { shouldValidate: false }); + syncHandlingCounts(next); }; - // Lowering the line quantity must pull every cargo-handling count back within - // it, or a stale count silently exceeds the line and fails validation on a - // field the customer can no longer see a cause for. - const clampHandlingCounts = (qty: number) => { - (["hazardousQuantity", "reeferQuantity", "returnQuantity"] as const).forEach( - (key) => { - const path = `containers.${index}.${key}` as const; - const current = Number(form.getValues(path) || 0); - if (current > qty) - form.setValue(path, String(Math.max(0, qty)), { - shouldDirty: true, - shouldValidate: true, - }); - }, - ); + /** + * Line totals are a roll-up of the per-container switches — the count is + * however many containers ticked each service. Kept in form state so the + * price estimate and the submitted payload stay in step with the switches. + */ + const syncHandlingCounts = ( + units: Array<{ isHazardous?: boolean; isReefer?: boolean; isReturn?: boolean }>, + ) => { + const set = ( + key: "hazardousQuantity" | "reeferQuantity" | "returnQuantity", + count: number, + ) => + form.setValue(`containers.${index}.${key}`, String(count), { + shouldDirty: true, + shouldValidate: true, + }); + set("hazardousQuantity", units.filter((u) => u.isHazardous).length); + set("reeferQuantity", units.filter((u) => u.isReefer).length); + set("returnQuantity", units.filter((u) => u.isReturn).length); }; - /** Switch state is derived from the count — a line is hazardous iff qty > 0. */ - const handlingToggle = ( - key: "hazardousQuantity" | "reeferQuantity" | "returnQuantity", - opts: { - icon: ReactNode; - iconBg: string; - iconColor: string; - title: string; - description: string; - pickLabel: string; - activeBg: string; - activeBorder: string; - activeColor: string; + /** Flip one container's handling switch, then re-roll the line totals. */ + const toggleUnitHandling = ( + unitIndex: number, + key: "isHazardous" | "isReefer" | "isReturn", + on: boolean, + ) => { + form.setValue(`containers.${index}.units.${unitIndex}.${key}`, on, { + shouldDirty: true, + }); + syncHandlingCounts(form.getValues(`containers.${index}.units`) ?? []); + }; + + /** + * The handling columns offered on each container row — only the services this + * contract was created with, since the server rejects quantities for the others. + */ + const handlingColumns = [ + isHazardous && { + key: "isHazardous" as const, + label: "Hazardous", + icon: , + color: "#C0392B", }, - ) => ( - ( - 0} - onChange={(on) => field.onChange(on ? "1" : "0")} - > -
- - {fieldState.error?.message ? ( - - {fieldState.error.message} - - ) : null} -
-
- )} - /> - ); + isReefer && { + key: "isReefer" as const, + label: "Refrigerated", + icon: , + color: "#2E5B96", + }, + withReturnService && { + key: "isReturn" as const, + label: "With return", + icon: , + color: "#0A6F4D", + }, + ].filter(Boolean) as Array<{ + key: "isHazardous" | "isReefer" | "isReturn"; + label: string; + icon: ReactNode; + color: string; + }>; return ( - {/* Cargo handling — only the services this contract was created with are - offered, since the server rejects quantities for the others. Each - switch reveals a bounded picker: tap the containers it applies to. */} - {(isHazardous || isReefer || withReturnService) && quantity > 0 && ( - <> - Cargo handling -
- {isHazardous && - handlingToggle("hazardousQuantity", { - icon: , - iconBg: "#FBEAE7", - iconColor: "#C0392B", - title: "Hazardous", - description: "Some of these containers carry hazardous cargo.", - pickLabel: "Tap the hazardous containers", - activeBg: "#FBEAE7", - activeBorder: "#E4A69B", - activeColor: "#C0392B", - })} - {isReefer && - handlingToggle("reeferQuantity", { - icon: , - iconBg: "#E9F0F8", - iconColor: "#2E5B96", - title: "Refrigerated", - description: "Some of these containers need reefer transport.", - pickLabel: "Tap the refrigerated containers", - activeBg: "#E9F0F8", - activeBorder: "#A9C2E0", - activeColor: "#2E5B96", - })} - {withReturnService && - handlingToggle("returnQuantity", { - icon: , - iconBg: "#ECF6F1", - iconColor: "#0A6F4D", - title: "With return", - description: "Some of these containers come back to EDR empty.", - pickLabel: "Tap the containers EDR returns", - activeBg: "#ECF6F1", - activeBorder: "#A9D6C2", - activeColor: "#0A6F4D", - })} -
- - )} - Per-container details + {handlingColumns.length > 0 && quantity > 0 ? ( + + Tick the services each individual container needs — charges apply only + to the containers you tick. + + ) : null} {Array.from({ length: Math.max(quantity, units.length) }).map((_, u) => ( @@ -1739,6 +1721,37 @@ function ContainerLineEditor({ /> )} /> + {handlingColumns.map((col) => ( + ( + + toggleUnitHandling(u, col.key, e.currentTarget.checked) + } + label={ + u === 0 ? ( + + + {col.icon} + + + {col.label} + + + ) : undefined + } + labelPosition="right" + size="sm" + mt={u === 0 ? 26 : 6} + /> + )} + /> + ))} ))} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts index 8610753c9..9c7efb8e3 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts @@ -48,6 +48,11 @@ const containerUnitSchema = z.object({ .string() .refine((v) => v.trim().length > 0, "VGM is required.") .refine((v) => !Number.isNaN(Number(v)) && Number(v) > 0, "Enter a valid VGM."), + // Handling is per physical container, recorded next to its VGM. The line + // totals below are derived from these. + isHazardous: z.boolean().default(false), + isReefer: z.boolean().default(false), + isReturn: z.boolean().default(false), }); const containerLineSchema = z.object({ diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 9aa36374a..362d3c6ef 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -729,14 +729,22 @@ export interface CreateContainerUnitDto { containerNumber: string; sealNumber?: string; vgmTons: number; + /** Per-container handling opt-ins, entered alongside this container's VGM. */ isHazardous?: boolean; isReefer?: boolean; + /** This container ships back empty (equipment return). */ + isReturn?: boolean; } export interface CreateBookingContainerLineDto { /** "20ft" | "40ft" — must be in the contract's cargo scope. */ containerSize: string; quantity: number; + /** + * Line totals, derived from the per-unit switches above. The API recomputes + * them from `units` whenever any unit carries a flag, so they are only + * authoritative for callers that don't send per-unit flags. + */ hazardousQuantity?: number; reeferQuantity?: number; /** From 20718d5b98cb22543f72a65a841f2fa7eeabacfa Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sun, 19 Jul 2026 09:08:30 +0300 Subject: [PATCH 74/88] Build issue resolution --- .../src/app/reports/passengers/page.tsx | 538 +++++------------- .../backoffice/src/app/schedules/page.tsx | 2 +- 2 files changed, 148 insertions(+), 392 deletions(-) diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx index 0170a3b8e..3a9a65353 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -1,264 +1,129 @@ -"use client"; +'use client'; -import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { Users, Armchair, TrendingUp, Train, Download } from "lucide-react"; -import { - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - Cell, -} from "recharts"; -import { apiClient } from "@/lib/api-client"; -import { formatDateTime } from "@/lib/utils"; -import ActionButton from "@/components/ui/ActionButton"; +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Users, Armchair, BarChart3, Train, Download } from 'lucide-react'; +import { apiClient } from '@/lib/api-client'; +import { formatDateTime } from '@/lib/utils'; +import ActionButton from '@/components/ui/ActionButton'; -const COLORS = [ - "#10b981", - "#3b82f6", - "#f59e0b", - "#8b5cf6", - "#ef4444", - "#06b6d4", -]; +interface ScheduleOption { id: string; label: string; } -function StatCard({ - label, - value, - sub, - icon: Icon, - color, -}: { - label: string; - value: string | number; - sub?: string; - icon: any; - color: string; -}) { - return ( -
-
-

- {label} -

-
- -
-
-

{value}

- {sub &&

{sub}

} -
- ); +interface PassengersReport { + schedule: { id: string; trainName: string; origin: string; destination: string; departureAt: string; arrivalAt: string; }; + summary: { totalSeats: number; totalPassengers: number; occupancyRate: number }; + byCoach: { coachNumber: string; coachType: string; totalSeats: number; booked: number; occupancyRate: number }[]; + byClass: { className: string; totalSeats: number; booked: number; occupancyRate: number }[]; + byOrigin: { stationName: string; passengers: number }[]; + byDestination: { stationName: string; passengers: number }[]; } interface PassengerRow { bookingRef: string; - bookingStatus: string; passengerName: string; - passengerCategory: string; - idDocumentType: string | null; - idDocumentNumber: string | null; - passportNumber: string | null; - passportCountry: string | null; - seatLabel: string | null; - coachNumber: string | null; - coachType: string | null; + coachSeat: string; + origin: string; + destination: string; + departureAt: string | null; } -type Tab = "occupancy" | "list"; +type Tab = 'occupancy' | 'list'; export default function PassengersReportPage() { - const [scheduleId, setScheduleId] = useState(""); - const [tab, setTab] = useState("occupancy"); - const [listSearch, setListSearch] = useState(""); + const [scheduleId, setScheduleId] = useState(''); + const [tab, setTab] = useState('occupancy'); + const [listSearch, setListSearch] = useState(''); - const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery< - ScheduleOption[] - >({ - queryKey: ["report-schedules"], - queryFn: () => apiClient.get("/reports/schedules"), + const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery({ + queryKey: ['report-schedules'], + queryFn: () => apiClient.get('/reports/schedules'), }); const schedules = schedulesRaw ?? []; const { data, isLoading, isError } = useQuery({ - queryKey: ["passengers-report", scheduleId], - queryFn: () => - apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`), + queryKey: ['passengers-report', scheduleId], + queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`), enabled: !!scheduleId, }); - const { data: passengerList = [], isLoading: listLoading } = useQuery< - PassengerRow[] - >({ - queryKey: ["passengers-list", scheduleId], - queryFn: () => - apiClient.get(`/reports/passengers/list?scheduleId=${scheduleId}`), + const { data: passengerList = [], isLoading: listLoading } = useQuery({ + queryKey: ['passengers-list', scheduleId], + queryFn: () => apiClient.get(`/reports/passengers/list?scheduleId=${scheduleId}`), enabled: !!scheduleId, }); const filteredList = listSearch.trim() - ? passengerList.filter( - (p) => - p.passengerName.toLowerCase().includes(listSearch.toLowerCase()) || - p.bookingRef.toLowerCase().includes(listSearch.toLowerCase()) || - (p.idDocumentNumber ?? "") - .toLowerCase() - .includes(listSearch.toLowerCase()) || - (p.passportNumber ?? "") - .toLowerCase() - .includes(listSearch.toLowerCase()), + ? passengerList.filter(p => + p.passengerName.toLowerCase().includes(listSearch.toLowerCase()) || + p.bookingRef.toLowerCase().includes(listSearch.toLowerCase()), ) : passengerList; const downloadCsv = (csv: string, filename: string) => { - const blob = new Blob([csv], { type: "text/csv" }); + const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); + const a = document.createElement('a'); + a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); }; const doExportOccupancy = () => { if (!data) return; - const rows = data.byCoach.map((c) => [ - c.coachNumber, - c.coachType, - String(c.totalSeats), - String(c.booked), - `${c.occupancyRate}%`, - ]); - downloadCsv( - [ - ["Coach", "Type", "Total Seats", "Booked", "Occupancy"].join(","), - ...rows.map((r) => r.join(",")), - ].join("\n"), - `occupancy-${scheduleId}.csv`, - ); + const rows = data.byCoach.map(c => [c.coachNumber, c.coachType, String(c.totalSeats), String(c.booked), `${c.occupancyRate}%`]); + downloadCsv([['Coach', 'Type', 'Total Seats', 'Booked', 'Occupancy'].join(','), ...rows.map(r => r.join(','))].join('\n'), `occupancy-${scheduleId}.csv`); }; const doExportList = () => { if (!passengerList.length) return; - const headers = [ - "Booking Ref", - "Status", - "Name", - "Category", - "ID Type", - "ID Number", - "Passport", - "Country", - "Seat", - "Coach", - "Class", - ]; - const rows = passengerList.map((p) => - [ - p.bookingRef, - p.bookingStatus, - p.passengerName, - p.passengerCategory, - p.idDocumentType ?? "", - p.idDocumentNumber ?? "", - p.passportNumber ?? "", - p.passportCountry ?? "", - p.seatLabel ?? "", - p.coachNumber ?? "", - p.coachType ?? "", - ].map((v) => `"${String(v).replace(/"/g, '""')}"`), - ); - downloadCsv( - [headers.join(","), ...rows.map((r) => r.join(","))].join("\n"), - `passengers-${scheduleId}.csv`, + const headers = ['#', 'Name', 'Coach·Seat', 'Origin', 'Destination', 'Date', 'Booking Ref']; + const rows = passengerList.map((p, i) => + [String(i + 1), p.passengerName, p.coachSeat, p.origin, p.destination, p.departureAt ? formatDateTime(p.departureAt) : '—', p.bookingRef] + .map(v => `"${String(v).replace(/"/g, '""')}"`) ); + downloadCsv([headers.join(','), ...rows.map(r => r.join(','))].join('\n'), `passengers-${scheduleId}.csv`); }; return (
-

- Passengers Report -

-

- Select a schedule to view passenger occupancy breakdown -

+

Passengers Report

+

Occupancy and passenger breakdown for a schedule

{/* Schedule selector */}
-
-
+
+
- {data && tab === "occupancy" && ( - - Export CSV - - )} - {passengerList.length > 0 && tab === "list" && ( - - Export CSV - + {data && tab === 'occupancy' && ( + Export CSV )}
- {(isLoading || listLoading) && ( -

Loading…

- )} - {isError && ( -

Failed to load report.

- )} + {(isLoading || listLoading) &&

Loading…

} + {isError &&

Failed to load report.

}
- {isFetching && ( -
- Loading passengers data… -
- )} - - {report && ( + {data && ( <> - {/* Schedule Info */} + {/* Schedule info */}
-

{report.schedule.trainName}

+

{data.schedule.trainName}

- {report.schedule.origin} → {report.schedule.destination} · - Departure: {formatDateTime(report.schedule.departureAt)} + {data.schedule.origin} → {data.schedule.destination} · Departure: {formatDateTime(data.schedule.departureAt)}

@@ -266,75 +131,51 @@ export default function PassengersReportPage() { {/* Tabs */}
{/* Occupancy tab */} - {tab === "occupancy" && ( + {tab === 'occupancy' && (
-

- Total Seats -

-
- -
+

Total Seats

+
-

- {data.summary.totalSeats} -

+

{data.summary.totalSeats}

-

- Passengers -

-
- -
+

Passengers

+
-

- {data.summary.totalPassengers} -

+

{data.summary.totalPassengers}

-

- Occupancy Rate -

-
- -
+

Occupancy Rate

+
-

- {data.summary.occupancyRate}% -

+

{data.summary.occupancyRate}%

-
+
-

- By Coach -

+

By Coach

@@ -347,31 +188,18 @@ export default function PassengersReportPage() { - {data.byCoach.map((c) => ( + {data.byCoach.map(c => ( - - - - + + + + @@ -383,77 +211,46 @@ export default function PassengersReportPage() {
-

- By Class -

+

By Class

- {data.byClass.map((c) => ( + {data.byClass.map(c => (
{c.className} - - {c.booked}/{c.totalSeats} - + {c.booked}/{c.totalSeats}
-
+
- - {c.occupancyRate}% - + {c.occupancyRate}%
))}
-

- By Boarding Station -

+

By Boarding Station

- {data.byOrigin.map((o) => ( -
- - {o.stationName} - - - {o.passengers} - + {data.byOrigin.map(o => ( +
+ {o.stationName} + {o.passengers}
))} - {data.byOrigin.length === 0 && ( -

No data

- )} + {data.byOrigin.length === 0 &&

No data

}
-

- By Alighting Station -

+

By Alighting Station

- {data.byDestination.map((d) => ( -
- - {d.stationName} - - - {d.passengers} - + {data.byDestination.map(d => ( +
+ {d.stationName} + {d.passengers}
))} - {data.byDestination.length === 0 && ( -

No data

- )} + {data.byDestination.length === 0 &&

No data

}
@@ -461,101 +258,60 @@ export default function PassengersReportPage() { )} {/* Passenger List tab */} - {tab === "list" && ( -
- setListSearch(e.target.value)} - /> -
-
- {c.coachNumber} - - {c.coachType} - - {c.totalSeats} - - {c.booked} - {c.coachNumber}{c.coachType}{c.totalSeats}{c.booked}
-
+
- - {c.occupancyRate}% - + {c.occupancyRate}%
- - - - - - - - - - - - - - {filteredList.map((p, i) => ( - - - - - - - - - + {tab === 'list' && ( +
+
+ setListSearch(e.target.value)} + /> + {passengerList.length > 0 && ( + Export CSV + )} +
+
+
+
#NameCategoryID / PassportSeatCoachBooking RefStatus
- {i + 1} - - {p.passengerName} - - - {p.passengerCategory} - - - {p.idDocumentNumber ?? p.passportNumber ?? "—"} - {p.passportCountry && ( - - ({p.passportCountry}) - - )} - - {p.seatLabel ?? "—"} - - {p.coachNumber ?? "—"} - {p.coachType && ( - - ({p.coachType}) - - )} - - {p.bookingRef} - - - {p.bookingStatus} - -
+ + + + + + + + + - ))} - {filteredList.length === 0 && ( - - - - )} - -
#NameCoach · SeatOriginDestinationDateBooking Ref
- No passengers found -
+ + + {filteredList.map((p, i) => ( + + {i + 1} + {p.passengerName} + {p.coachSeat} + {p.origin} + {p.destination} + {p.departureAt ? formatDateTime(p.departureAt) : '—'} + {p.bookingRef} + + ))} + {filteredList.length === 0 && ( + No passengers found + )} + + +
)} )} - {!report && !isFetching && scheduleId && ( -
- No data found for this schedule. -
+ {!data && !isLoading && scheduleId && ( +
No data found for this schedule.
)} {!scheduleId && ( 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 d48e10d51..2298ebf9f 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -184,7 +184,7 @@ export default function SchedulesPage() { plannedDepartureAt: eatDateStr && depTimePart ? `${eatDateStr}T${depTimePart}` : '', }; })); - }, [editRouteDetail, editingSchedule?.id, editForm.departureAt]); + }, [editRouteDetail, editingSchedule?.id, editForm.departureAt]); // eslint-disable-line react-hooks/exhaustive-deps const [filters, setFilters] = useState({ search: '', From 856d14387467551221ac561b315e90b043f123d2 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sun, 19 Jul 2026 09:16:36 +0300 Subject: [PATCH 75/88] Reports update --- .../src/modules/reports/reports.service.ts | 50 ++++++++++++------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 9c16ff5b0..6bab9a316 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -266,7 +266,7 @@ export class ReportsService { where: { status: { in: ["CONFIRMED", "BOARDED"] } }, include: { seats: { - where: { scheduleId }, + where: { leg: 1 }, include: { seat: { include: { coach: { include: { coachType: true } } } }, }, @@ -432,33 +432,45 @@ export class ReportsService { async getPassengerList(scheduleId: string) { const seats = await this.prisma.bookingSeat.findMany({ where: { - scheduleId, - booking: { status: { in: ["CONFIRMED", "BOARDED"] } }, + leg: 1, + booking: { scheduleId, status: { in: ["CONFIRMED", "BOARDED"] } }, }, include: { - booking: { select: { bookingRef: true, status: true } }, - seat: { - include: { - coach: { - select: { number: true, coachType: { select: { name: true } } }, - }, + booking: { + select: { + bookingRef: true, + status: true, + originStationId: true, + destinationStationId: true, }, }, + seat: { include: { coach: { select: { number: true } } } }, }, - orderBy: [{ seat: { coach: { number: "asc" } } }], + orderBy: [{ seat: { coach: { number: "asc" } } }, { seat: { seatNumber: "asc" } }], }); + + const stationIds = [...new Set( + seats.flatMap(bs => [bs.booking.originStationId, bs.booking.destinationStationId]).filter(Boolean) as string[], + )]; + const stations = stationIds.length > 0 + ? await this.prisma.station.findMany({ where: { id: { in: stationIds } }, select: { id: true, name: true } }) + : []; + const stationName = new Map(stations.map(s => [s.id, s.name])); + + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + select: { departureAt: true }, + }); + return seats.map((bs) => ({ bookingRef: bs.booking.bookingRef, - bookingStatus: bs.booking.status, passengerName: bs.passengerName, - passengerCategory: bs.passengerCategory, - idDocumentType: bs.idDocumentType, - idDocumentNumber: bs.idDocumentNumber, - passportNumber: bs.passportNumber, - passportCountry: bs.passportCountry, - seatLabel: bs.seatLabelSnapshot, - coachNumber: bs.seat?.coach?.number ?? null, - coachType: (bs.seat?.coach as any)?.coachType?.name ?? null, + coachSeat: bs.seat?.coach?.number && bs.seatLabelSnapshot + ? `${bs.seat.coach.number}·${bs.seatLabelSnapshot}` + : (bs.seatLabelSnapshot ?? '—'), + origin: bs.booking.originStationId ? (stationName.get(bs.booking.originStationId) ?? '—') : '—', + destination: bs.booking.destinationStationId ? (stationName.get(bs.booking.destinationStationId) ?? '—') : '—', + departureAt: schedule?.departureAt ?? null, })); } From 17dc505d503ec604bb878a22fbf2863da7d2bfdf Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 19 Jul 2026 07:06:58 +0000 Subject: [PATCH 76/88] streamline booking detail components and enhance journey visualization - Removed the BookingClearanceWorkflowBanner from ClearanceCard as the clearance progress is now integrated into the unified journey wizard. - Eliminated the MilestoneTimeline component from DocumentsTab, consolidating customs progress into the JourneyWizard. - Updated PageHeader to include a ContractReferenceLink for better navigation to contract details. - Simplified ShipmentTrackingCard to focus on duty/tax payment slip upload, removing unnecessary milestone display. - Integrated JourneyWizard component to visualize the booking journey, replacing the previous progress tracker. - Enhanced ContractDetailPage to better categorize documents and improve user experience with clearer sections for profile, business license, clearance, and other documents. - Introduced CSS for contracts table to manage column sizing and sticky headers effectively. - Added ContractReferenceLink component for backoffice to link to contract details, ensuring consistent navigation across applications. --- .../bookings/ContractReferenceLink.tsx | 43 ++++ .../bookings/detail/BookingRequestHero.tsx | 13 +- .../features/bookings/mapBookingListRow.ts | 1 + .../backoffice/src/lib/queryClient.ts | 3 +- .../pages/bookings/BookingRequestsPage.tsx | 13 +- .../backoffice/src/types/booking.ts | 2 + .../MyPortalPage/components/BookingRow.tsx | 2 + .../components/ClearanceCard.tsx | 6 +- .../components/DocumentsTab.tsx | 124 +--------- .../components/JourneyWizard.tsx | 211 ++++++++++++++++++ .../components/PageHeader.tsx | 14 +- .../components/ShipmentTrackingCard.tsx | 99 +------- .../components/StatusHero.tsx | 140 ++---------- .../src/pages/bookings/booking-display.tsx | 35 +++ .../pages/contracts/ContractDetailPage.tsx | 171 ++++++++------ .../src/pages/contracts/ContractsList.tsx | 14 +- .../src/pages/contracts/NewShipmentPage.tsx | 1 - .../src/pages/contracts/contracts-table.css | 78 +++++++ 18 files changed, 548 insertions(+), 422 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/ContractReferenceLink.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/JourneyWizard.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/contracts-table.css diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/ContractReferenceLink.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/ContractReferenceLink.tsx new file mode 100644 index 000000000..94061eee2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/ContractReferenceLink.tsx @@ -0,0 +1,43 @@ +import { Link } from "react-router-dom"; + +/** + * The parent contract's reference, linking to that contract's detail page. + * + * Backoffice-local on purpose: the contract detail route differs per app + * (`/dashboard/contract-requests/:id` here vs `/contracts/:id` in the portal), + * so the portal keeps its own copy in `pages/bookings/booking-display.tsx` + * rather than the two sharing a component that would have to take the route as + * a prop at every call site. + * + * Renders nothing when either field is missing: `contractId` is nullable on the + * booking, and only the bookings list/detail endpoints join `contractReference` + * — other endpoints (warehouse, fleet, payments) return booking rows without it, + * and a link with no id would be a dead one. + * + * `stopPropagation` matters: booking rows are click-to-navigate, so without it a + * click here would race the row handler and land on the booking instead. + */ +export function ContractReferenceLink({ + contractId, + contractReference, + className, +}: { + contractId?: string | null; + contractReference?: string | null; + className?: string; +}) { + if (!contractId || !contractReference) return null; + + return ( + e.stopPropagation()} + className={ + className ?? + "block truncate font-mono text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground" + } + > + {contractReference} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx index aa14fe8cd..3ee5d72a0 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx @@ -24,6 +24,7 @@ import type { LucideIcon } from "lucide-react"; import type { BookingDetail } from "@/types/booking"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; +import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink"; import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; import { NextStepBanner } from "@/components/bookings/NextStepBanner"; @@ -94,9 +95,15 @@ export function BookingRequestHero({ Booking reference - - {booking.reference} - + + + {booking.reference} + + + {booking.schedulingStatus ? ( diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts index bc10fd064..a0594094c 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts @@ -19,6 +19,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow { id: booking.id, reference: booking.reference, contractReference: booking.contractReference ?? null, + contractId: booking.contractId ?? null, approvalSteps: booking.approvalSteps, customerLabel: booking.isGovernment ? (booking.governmentInstitution ?? "Government") diff --git a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts index 5b2bcb255..0a270372e 100644 --- a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts +++ b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts @@ -27,7 +27,8 @@ export const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, - staleTime: 30_000, + // staleTime: 30_000, + staleTime:0, // Data freshness is driven by mutation invalidation (MutationCache above), // socket pushes, and explicit polling — not by tab focus. Focus refetch // just re-fires every mounted query each time the window is refocused. diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 72c16215b..3eb775e95 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -48,6 +48,7 @@ import { useBookingList, useBookingListSummary, } from "@/hooks/bookings/useBookings"; +import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink"; import { api } from "@/services/api"; import type { BookingListFilter } from "@/services/bookings.service"; import type { BookingListRow } from "@/types/booking"; @@ -308,7 +309,17 @@ export default function BookingRequestsPage() { return (
{ref ? ( - {ref} + // Fall back to plain text when the id is missing — the reference is + // still worth showing, it just has nowhere to link to. + (row.original.contractId ? ( + + ) : ( + {ref} + )) ) : ( )} diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index ec31e11fa..4b5b5257b 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -231,6 +231,8 @@ export interface BookingListRow { id: string; reference: string; contractReference?: string | null; + /** Needed to link the reference to the contract's detail page. */ + contractId?: string | null; customerLabel: string; approvalSteps?: BookingApprovalStep[]; status: BookingStatus; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx index 007fc7fe9..56226594e 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx @@ -10,6 +10,7 @@ import { bookingIsSignable, } from "@/pages/bookings/contract/ContractSignButton"; import { ApproveDeliveryButton } from "@/pages/bookings/delivery/ApproveDeliveryButton"; +import { ContractReferenceLink } from "@/pages/bookings/booking-display"; interface BookingRowProps { booking: any; @@ -73,6 +74,7 @@ export const BookingRow = memo(function BookingRow({ {booking.reference} + {commodity} · {origin} → {dest} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx index dcc455ada..a8cc43e14 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx @@ -13,7 +13,6 @@ import type { Freight } from "@edr/types"; import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal"; import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction"; -import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner"; import { CardTitle, SectionCard } from "./layout"; @@ -65,8 +64,9 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { return ( - - + {/* The "Clearance progress" stepper moved into the unified journey + wizard at the top of the page — this card keeps only the actions. */} + Clearance documents {action && (