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 6dfe2b8b9..ccdab3379 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -62,16 +62,23 @@ export class BookingsRepository extends BaseRepository { return this.repository.findOne({ where: { reference } }); } - /** Count bookings created in a specific year. */ - async countByYear(year: number): Promise { - const startDate = new Date(year, 0, 1); - const endDate = new Date(year + 1, 0, 1); - - return this.repository + /** + * Highest NNNNNN sequence already issued for `BK--…` references. + * Includes soft-deleted bookings so the next number clears references that + * still occupy the unique index. (A created-at count drifts below the issued + * sequence after any delete and then collides forever.) + */ + async maxReferenceSequence(year: number): Promise { + const row = await this.repository .createQueryBuilder('booking') - .where('booking.created_at >= :startDate', { startDate }) - .andWhere('booking.created_at < :endDate', { endDate }) - .getCount(); + .withDeleted() + .select( + "COALESCE(MAX(CAST(SUBSTRING(booking.reference FROM '[0-9]+$') AS int)), 0)", + 'max', + ) + .where('booking.reference LIKE :prefix', { prefix: `BK-${year}-%` }) + .getRawOne<{ max: string | number | null }>(); + return Number(row?.max ?? 0); } /** Find a booking by reference with files and relations. */ 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 1f2c1a09e..7a6169b7b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -9,6 +9,7 @@ import { NotFoundException, } from '@nestjs/common'; import { Freight, SchedulingStatus } from '@edr/types'; +import { insertWithGeneratedReference } from '@edr/api-common'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; import { ProfileType } from '../companies/entities/company-profile.entity'; @@ -207,8 +208,8 @@ export class BookingsService { /** Generate a unique booking reference number. */ private async generateReference(): Promise { const year = new Date().getFullYear(); - const count = await this.bookingsRepository.countByYear(year); - return `BK-${year}-${String(count + 1).padStart(6, '0')}`; + const seq = await this.bookingsRepository.maxReferenceSequence(year); + return `BK-${year}-${String(seq + 1).padStart(6, '0')}`; } private buildCustomerTruckFreightOrderHtml( @@ -593,7 +594,6 @@ export class BookingsService { } } - const reference = dto.reference || (await this.generateReference()); const containers = dto.containers ?? []; assertFreightShape({ freightType: dto.freightType, @@ -688,7 +688,10 @@ export class BookingsService { // the customer clears it themselves and may name their broker. const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId); - const booking = await this.bookingsRepository.create({ + // Explicit reference is caller-chosen (a collision is a real conflict); + // auto-generated references retry past a concurrent same-sequence insert. + const insertBooking = (reference: string) => + this.bookingsRepository.create({ reference, companyId, companyProfileId, @@ -743,7 +746,14 @@ export class BookingsService { priorityScore: ruleResult.priorityScore, totalAmount: 0, paymentStatus: 'PENDING', - }); + }); + + const booking = dto.reference + ? await insertBooking(dto.reference) + : await insertWithGeneratedReference( + () => this.generateReference(), + insertBooking, + ); if (dto.freightType === 'CONTAINER') { await this.bookingsRepository.createContainers( diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts index 7a3695768..0ae829529 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts @@ -48,8 +48,22 @@ export class BookingRequestRepository extends BaseRepository { }); } - /** Total rows — used to mint the next sequential reference. */ - async count(): Promise { - return this.repository.count(); + /** + * Highest NNNNNN sequence already issued for `SR-…` references (all-time — + * these are not year-scoped). Includes soft-deleted rows so a cancel/delete + * can't make the next number reuse an earlier one. A plain row count drifts + * below the issued sequence after any delete and hands out duplicates. + */ + async maxReferenceSequence(): Promise { + const row = await this.repository + .createQueryBuilder('request') + .withDeleted() + .select( + "COALESCE(MAX(CAST(SUBSTRING(request.reference FROM '[0-9]+$') AS int)), 0)", + 'max', + ) + .where('request.reference LIKE :prefix', { prefix: 'SR-%' }) + .getRawOne<{ max: string | number | null }>(); + return Number(row?.max ?? 0); } } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index 199276862..88a4ec725 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -192,8 +192,7 @@ export class BookingRequestService { } private async generateReference(): Promise { - const count = await this.repo.count(); - const seq = String(count + 1).padStart(6, '0'); - return `SR-${seq}`; + const seq = await this.repo.maxReferenceSequence(); + return `SR-${String(seq + 1).padStart(6, '0')}`; } } 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 125cf5f3d..873daaf15 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 @@ -8,6 +8,7 @@ import { forwardRef, } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { insertWithGeneratedReference } from '@edr/api-common'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; @@ -110,7 +111,6 @@ export class ContractBookingService { const route = await this.resolveRoute(contract, dto.contractRouteId); const warnings: string[] = []; - const reference = await this.generateReference(); const freightType = contract.freightType; // GENERAL + customs (Path B) runs per-booking clearance: the booking starts @@ -146,7 +146,11 @@ export class ContractBookingService { } // Denormalize route/direction/freight onto the booking for the scheduling engine. - const booking = await this.bookingsRepository.create({ + // Retry past a concurrent insert that grabbed the same BK sequence number. + const booking = await insertWithGeneratedReference( + () => this.generateReference(), + (reference) => + this.bookingsRepository.create({ reference, companyId: contract.companyId ?? null, companyProfileId: contract.companyProfileId ?? null, @@ -180,7 +184,8 @@ export class ContractBookingService { lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null, lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null, - } as never); + } as never), + ); // Persist container lines + per-unit container numbers (container freight only). if (freightType === 'CONTAINER') { @@ -825,8 +830,7 @@ export class ContractBookingService { private async generateReference(): Promise { const year = new Date().getFullYear(); - const count = await this.bookingsRepository.countByYear(year); - const seq = String(count + 1).padStart(6, '0'); - return `BK-${year}-${seq}`; + const seq = await this.bookingsRepository.maxReferenceSequence(year); + return `BK-${year}-${String(seq + 1).padStart(6, '0')}`; } } 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 9bb4b2de6..e31392666 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 @@ -5,6 +5,7 @@ import { Logger, } from '@nestjs/common'; import { Readable } from 'stream'; +import { insertWithGeneratedReference } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder'; @@ -611,8 +612,11 @@ export class ContractTransitionService { async renew(contractId: string, userId?: string): Promise { const source = await this.contractsService.findById(contractId); - const reference = await this.generateRenewalReference(); - const renewal = await this.contractsRepository.create({ + // Retry past a concurrent insert that grabbed the same CTR sequence number. + const renewal = await insertWithGeneratedReference( + () => this.generateRenewalReference(), + (reference) => + this.contractsRepository.create({ reference, companyId: source.companyId, companyProfileId: source.companyProfileId, @@ -640,7 +644,8 @@ export class ContractTransitionService { status: 'RENEWAL_DRAFT', clearanceStatus: 'NOT_APPLICABLE', clearanceCycleNumber: 0, - } as never); + } as never), + ); void userId; return this.contractsService.findById(renewal.id); @@ -648,7 +653,7 @@ export class ContractTransitionService { private async generateRenewalReference(): Promise { const year = new Date().getFullYear(); - const count = await this.contractsRepository.countByYear(year); - return `CTR-${year}-${String(count + 1).padStart(5, '0')}`; + const seq = await this.contractsRepository.maxReferenceSequence(year); + return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`; } } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 975b977a6..73360f7f5 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; +import { insertWithGeneratedReference } from '@edr/api-common'; import { CompaniesService } from '../companies/companies.service'; import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity'; @@ -217,6 +218,48 @@ export class ContractsService { return { contract: await this.findById(contract.id), warnings }; } + /** Insert one DRAFT contract row with the given reference (no children). */ + private insertContract( + reference: string, + ctx: { + companyId: string | null | undefined; + companyProfileId: string | null; + isGovernment: boolean; + includesCustoms: boolean; + dto: CreateContractDto; + }, + ): Promise { + const { companyId, companyProfileId, isGovernment, includesCustoms, dto } = ctx; + return this.contractsRepository.create({ + reference, + companyId: companyId ?? null, + companyProfileId, + isGovernment, + governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null, + contractKind: dto.contractKind, + renewalOfId: dto.renewalOfId ?? null, + tradeDirection: dto.tradeDirection, + freightType: dto.freightType, + serviceTypeId: dto.serviceTypeId, + paymentCurrency: dto.paymentCurrency, + customsClearingEnabled: includesCustoms, + customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null), + equipmentReturn: dto.equipmentReturn ?? null, + firstMilePickupAddress: dto.firstMilePickupAddress ?? null, + firstMilePickupLat: dto.firstMilePickupLat ?? null, + firstMilePickupLng: dto.firstMilePickupLng ?? null, + lastMileDeliveryAddress: dto.lastMileDeliveryAddress ?? null, + lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null, + lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null, + isHazardous: dto.isHazardous ?? false, + isReefer: dto.isReefer ?? false, + contractType: dto.contractType ?? null, + status: 'DRAFT', + clearanceStatus: 'NOT_APPLICABLE', + clearanceCycleNumber: 0, + } as never); + } + /** * Copy a company profile's stored business-license / onboarding documents onto * a contract by reference (no byte re-upload). Codes are slugged from each