mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 06:40:57 +00:00
refactor contract handling to support single route per contract and improve reference generation logic
This commit is contained in:
@@ -45,16 +45,23 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
return this.repository.findOne({ where: { reference } });
|
||||
}
|
||||
|
||||
/** Count contracts created in a specific year. */
|
||||
async countByYear(year: number): Promise<number> {
|
||||
const startDate = new Date(year, 0, 1);
|
||||
const endDate = new Date(year + 1, 0, 1);
|
||||
|
||||
return this.repository
|
||||
/**
|
||||
* Highest NNNNN sequence already issued for `CTR-<year>-…` references.
|
||||
* Includes soft-deleted contracts — their references still occupy the unique
|
||||
* index, so the next number must move past them. (A created-at count drifts
|
||||
* below the issued sequence after any delete and then collides forever.)
|
||||
*/
|
||||
async maxReferenceSequence(year: number): Promise<number> {
|
||||
const row = await this.repository
|
||||
.createQueryBuilder('contract')
|
||||
.where('contract.created_at >= :startDate', { startDate })
|
||||
.andWhere('contract.created_at < :endDate', { endDate })
|
||||
.getCount();
|
||||
.withDeleted()
|
||||
.select(
|
||||
"COALESCE(MAX(CAST(SUBSTRING(contract.reference FROM '[0-9]+$') AS int)), 0)",
|
||||
'max',
|
||||
)
|
||||
.where('contract.reference LIKE :prefix', { prefix: `CTR-${year}-%` })
|
||||
.getRawOne<{ max: string | number | null }>();
|
||||
return Number(row?.max ?? 0);
|
||||
}
|
||||
|
||||
/** Find a contract by ID with all child collections, service type, company and files. */
|
||||
|
||||
@@ -57,8 +57,8 @@ export class ContractsService {
|
||||
/** Generate a unique contract reference number (CTR-YYYY-NNNNN). */
|
||||
private async generateReference(): Promise<string> {
|
||||
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')}`;
|
||||
}
|
||||
|
||||
/** Whether a service type bundles customs clearance. */
|
||||
@@ -144,8 +144,6 @@ export class ContractsService {
|
||||
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
|
||||
this.assertRouteShape(dto.contractKind, dto.routes);
|
||||
|
||||
const reference = dto.reference || (await this.generateReference());
|
||||
|
||||
// Stamp the operational profile (importer/exporter) for portal scoping.
|
||||
let companyProfileId: string | null = null;
|
||||
if (!isGovernment && companyId) {
|
||||
@@ -177,34 +175,28 @@ export class ContractsService {
|
||||
// Customs clearing is owned by the service type, not the customer.
|
||||
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
|
||||
|
||||
const contract = await 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);
|
||||
// An explicit reference is caller-chosen — a collision there is a real
|
||||
// conflict and should surface. Auto-generated references retry past a
|
||||
// concurrent insert that grabbed the same sequence number.
|
||||
const contract = dto.reference
|
||||
? await this.insertContract(dto.reference, {
|
||||
companyId,
|
||||
companyProfileId,
|
||||
isGovernment,
|
||||
includesCustoms,
|
||||
dto,
|
||||
})
|
||||
: await insertWithGeneratedReference(
|
||||
() => this.generateReference(),
|
||||
(reference) =>
|
||||
this.insertContract(reference, {
|
||||
companyId,
|
||||
companyProfileId,
|
||||
isGovernment,
|
||||
includesCustoms,
|
||||
dto,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.persistRoutes(contract.id, dto.routes);
|
||||
await this.persistCargoScope(contract.id, dto.cargoScope, contract.contractKind);
|
||||
|
||||
@@ -7,4 +7,9 @@ export const minioConfig = registerAs("minio", () => ({
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || "",
|
||||
secretKey: process.env.MINIO_SECRET_KEY || "",
|
||||
bucket: process.env.MINIO_BUCKET || "fhc",
|
||||
// Preset the region so presignedGetObject signs URLs locally. Without it the
|
||||
// minio client fires a live GetBucketLocation request to the endpoint on every
|
||||
// sign — which blocks (no timeout) when MinIO is slow/unreachable and hangs
|
||||
// API responses that reload a booking's files (e.g. staff accept).
|
||||
region: process.env.MINIO_REGION || "us-east-1",
|
||||
}));
|
||||
|
||||
@@ -29,6 +29,9 @@ export class MinioService {
|
||||
useSSL: config.useSSL,
|
||||
accessKey: config.accessKey,
|
||||
secretKey: config.secretKey,
|
||||
// Presetting the region keeps presignedGetObject fully local — no live
|
||||
// GetBucketLocation round-trip to the endpoint on each signed URL.
|
||||
region: config.region,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -108,8 +111,11 @@ export class MinioService {
|
||||
try {
|
||||
return await this.client.presignedGetObject(this.bucket, objectName, expirySeconds);
|
||||
} catch (error) {
|
||||
// Signing a file URL must never break a booking/transition response — the
|
||||
// caller only needs SOMETHING to link to. Degrade to the public object URL
|
||||
// and log, rather than throwing (which would 500 an otherwise-good load).
|
||||
this.logger.error(`Failed to generate signed URL for ${objectName}:`, error);
|
||||
throw error;
|
||||
return this.getPublicUrl(objectName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user