mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
refactor contract handling to support single route per contract and improve reference generation logic
This commit is contained in:
@@ -62,16 +62,23 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
return this.repository.findOne({ where: { reference } });
|
return this.repository.findOne({ where: { reference } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Count bookings created in a specific year. */
|
/**
|
||||||
async countByYear(year: number): Promise<number> {
|
* Highest NNNNNN sequence already issued for `BK-<year>-…` references.
|
||||||
const startDate = new Date(year, 0, 1);
|
* Includes soft-deleted bookings so the next number clears references that
|
||||||
const endDate = new Date(year + 1, 0, 1);
|
* still occupy the unique index. (A created-at count drifts below the issued
|
||||||
|
* sequence after any delete and then collides forever.)
|
||||||
return this.repository
|
*/
|
||||||
|
async maxReferenceSequence(year: number): Promise<number> {
|
||||||
|
const row = await this.repository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder('booking')
|
||||||
.where('booking.created_at >= :startDate', { startDate })
|
.withDeleted()
|
||||||
.andWhere('booking.created_at < :endDate', { endDate })
|
.select(
|
||||||
.getCount();
|
"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. */
|
/** Find a booking by reference with files and relations. */
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Freight, SchedulingStatus } from '@edr/types';
|
import { Freight, SchedulingStatus } from '@edr/types';
|
||||||
|
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||||
// import { CustomersService } from '../customers/customers.service';
|
// import { CustomersService } from '../customers/customers.service';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
import { ProfileType } from '../companies/entities/company-profile.entity';
|
||||||
@@ -207,8 +208,8 @@ export class BookingsService {
|
|||||||
/** Generate a unique booking reference number. */
|
/** Generate a unique booking reference number. */
|
||||||
private async generateReference(): Promise<string> {
|
private async generateReference(): Promise<string> {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const count = await this.bookingsRepository.countByYear(year);
|
const seq = await this.bookingsRepository.maxReferenceSequence(year);
|
||||||
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
|
return `BK-${year}-${String(seq + 1).padStart(6, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildCustomerTruckFreightOrderHtml(
|
private buildCustomerTruckFreightOrderHtml(
|
||||||
@@ -593,7 +594,6 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reference = dto.reference || (await this.generateReference());
|
|
||||||
const containers = dto.containers ?? [];
|
const containers = dto.containers ?? [];
|
||||||
assertFreightShape({
|
assertFreightShape({
|
||||||
freightType: dto.freightType,
|
freightType: dto.freightType,
|
||||||
@@ -688,7 +688,10 @@ export class BookingsService {
|
|||||||
// the customer clears it themselves and may name their broker.
|
// the customer clears it themselves and may name their broker.
|
||||||
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
|
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,
|
reference,
|
||||||
companyId,
|
companyId,
|
||||||
companyProfileId,
|
companyProfileId,
|
||||||
@@ -743,7 +746,14 @@ export class BookingsService {
|
|||||||
priorityScore: ruleResult.priorityScore,
|
priorityScore: ruleResult.priorityScore,
|
||||||
totalAmount: 0,
|
totalAmount: 0,
|
||||||
paymentStatus: 'PENDING',
|
paymentStatus: 'PENDING',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const booking = dto.reference
|
||||||
|
? await insertBooking(dto.reference)
|
||||||
|
: await insertWithGeneratedReference(
|
||||||
|
() => this.generateReference(),
|
||||||
|
insertBooking,
|
||||||
|
);
|
||||||
|
|
||||||
if (dto.freightType === 'CONTAINER') {
|
if (dto.freightType === 'CONTAINER') {
|
||||||
await this.bookingsRepository.createContainers(
|
await this.bookingsRepository.createContainers(
|
||||||
|
|||||||
@@ -48,8 +48,22 @@ export class BookingRequestRepository extends BaseRepository<BookingRequest> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Total rows — used to mint the next sequential reference. */
|
/**
|
||||||
async count(): Promise<number> {
|
* Highest NNNNNN sequence already issued for `SR-…` references (all-time —
|
||||||
return this.repository.count();
|
* 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<number> {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,8 +192,7 @@ export class BookingRequestService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async generateReference(): Promise<string> {
|
private async generateReference(): Promise<string> {
|
||||||
const count = await this.repo.count();
|
const seq = await this.repo.maxReferenceSequence();
|
||||||
const seq = String(count + 1).padStart(6, '0');
|
return `SR-${String(seq + 1).padStart(6, '0')}`;
|
||||||
return `SR-${seq}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
forwardRef,
|
forwardRef,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { BookingContainer } from '../bookings/entities/booking-container.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 route = await this.resolveRoute(contract, dto.contractRouteId);
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
const reference = await this.generateReference();
|
|
||||||
const freightType = contract.freightType;
|
const freightType = contract.freightType;
|
||||||
|
|
||||||
// GENERAL + customs (Path B) runs per-booking clearance: the booking starts
|
// 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.
|
// 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,
|
reference,
|
||||||
companyId: contract.companyId ?? null,
|
companyId: contract.companyId ?? null,
|
||||||
companyProfileId: contract.companyProfileId ?? null,
|
companyProfileId: contract.companyProfileId ?? null,
|
||||||
@@ -180,7 +184,8 @@ export class ContractBookingService {
|
|||||||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||||
lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
|
lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
|
||||||
lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
|
lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
|
||||||
} as never);
|
} as never),
|
||||||
|
);
|
||||||
|
|
||||||
// Persist container lines + per-unit container numbers (container freight only).
|
// Persist container lines + per-unit container numbers (container freight only).
|
||||||
if (freightType === 'CONTAINER') {
|
if (freightType === 'CONTAINER') {
|
||||||
@@ -825,8 +830,7 @@ export class ContractBookingService {
|
|||||||
|
|
||||||
private async generateReference(): Promise<string> {
|
private async generateReference(): Promise<string> {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const count = await this.bookingsRepository.countByYear(year);
|
const seq = await this.bookingsRepository.maxReferenceSequence(year);
|
||||||
const seq = String(count + 1).padStart(6, '0');
|
return `BK-${year}-${String(seq + 1).padStart(6, '0')}`;
|
||||||
return `BK-${year}-${seq}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Readable } from 'stream';
|
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 type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
|
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
|
||||||
@@ -611,8 +612,11 @@ export class ContractTransitionService {
|
|||||||
async renew(contractId: string, userId?: string): Promise<Contract> {
|
async renew(contractId: string, userId?: string): Promise<Contract> {
|
||||||
const source = await this.contractsService.findById(contractId);
|
const source = await this.contractsService.findById(contractId);
|
||||||
|
|
||||||
const reference = await this.generateRenewalReference();
|
// Retry past a concurrent insert that grabbed the same CTR sequence number.
|
||||||
const renewal = await this.contractsRepository.create({
|
const renewal = await insertWithGeneratedReference(
|
||||||
|
() => this.generateRenewalReference(),
|
||||||
|
(reference) =>
|
||||||
|
this.contractsRepository.create({
|
||||||
reference,
|
reference,
|
||||||
companyId: source.companyId,
|
companyId: source.companyId,
|
||||||
companyProfileId: source.companyProfileId,
|
companyProfileId: source.companyProfileId,
|
||||||
@@ -640,7 +644,8 @@ export class ContractTransitionService {
|
|||||||
status: 'RENEWAL_DRAFT',
|
status: 'RENEWAL_DRAFT',
|
||||||
clearanceStatus: 'NOT_APPLICABLE',
|
clearanceStatus: 'NOT_APPLICABLE',
|
||||||
clearanceCycleNumber: 0,
|
clearanceCycleNumber: 0,
|
||||||
} as never);
|
} as never),
|
||||||
|
);
|
||||||
|
|
||||||
void userId;
|
void userId;
|
||||||
return this.contractsService.findById(renewal.id);
|
return this.contractsService.findById(renewal.id);
|
||||||
@@ -648,7 +653,7 @@ export class ContractTransitionService {
|
|||||||
|
|
||||||
private async generateRenewalReference(): Promise<string> {
|
private async generateRenewalReference(): Promise<string> {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const count = await this.contractsRepository.countByYear(year);
|
const seq = await this.contractsRepository.maxReferenceSequence(year);
|
||||||
return `CTR-${year}-${String(count + 1).padStart(5, '0')}`;
|
return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||||
|
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
|
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
|
||||||
@@ -217,6 +218,48 @@ export class ContractsService {
|
|||||||
return { contract: await this.findById(contract.id), warnings };
|
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<Contract> {
|
||||||
|
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
|
* Copy a company profile's stored business-license / onboarding documents onto
|
||||||
* a contract by reference (no byte re-upload). Codes are slugged from each
|
* a contract by reference (no byte re-upload). Codes are slugged from each
|
||||||
|
|||||||
Reference in New Issue
Block a user