mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
993 lines
37 KiB
TypeScript
993 lines
37 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
ConflictException,
|
||
ForbiddenException,
|
||
Injectable,
|
||
NotFoundException,
|
||
} from '@nestjs/common';
|
||
import { InjectDataSource } from '@nestjs/typeorm';
|
||
import { DataSource } from 'typeorm';
|
||
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
|
||
|
||
import { YardCountry } from '@edr/types';
|
||
//
|
||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||
import { CompaniesService } from '../companies/companies.service';
|
||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||
import { FilesService } from '../files/files.service';
|
||
import { MinioService } from '../minio/minio.service';
|
||
import { ContractsRepository } from './contracts.repository';
|
||
import { CreateContractDto } from './dto/create-contract.dto';
|
||
import { UpdateContractDto } from './dto/update-contract.dto';
|
||
import { FilterContractDto } from './dto/filter-contract.dto';
|
||
import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
|
||
import { Contract, CONTRACT_STATUSES, CONTRACT_CUSTOMER_EDITABLE_STATUSES } from './entities/contract.entity';
|
||
import { ContractRoute } from './entities/contract-route.entity';
|
||
import { ContractCargoScope } from './entities/contract-cargo-scope.entity';
|
||
import { isEffectivelyExpired } from './utils/contract-expiry.util';
|
||
import { diffContractFields } from './contract-document-diff.util';
|
||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||
import { ContractPricingService } from './contract-pricing.service';
|
||
import { FileRecord } from '../files/entities/file.entity';
|
||
|
||
/** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */
|
||
export interface PaginatedContracts {
|
||
items: Contract[];
|
||
total: number;
|
||
meta: {
|
||
page: number;
|
||
pageSize: number;
|
||
total: number;
|
||
totalPages: number;
|
||
hasNextPage: boolean;
|
||
hasPreviousPage: boolean;
|
||
};
|
||
}
|
||
|
||
/** Route list as a readable lane string, e.g. "Nagad → Mojo, Mojo → Adama". */
|
||
function describeRoutes(routes?: ContractRoute[]): string | null {
|
||
if (!routes?.length) return null;
|
||
return [...routes]
|
||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||
.map(
|
||
(r) =>
|
||
`${r.originYard?.label ?? r.originYardId} → ${r.destinationYard?.label ?? r.destinationYardId}`,
|
||
)
|
||
.join(', ');
|
||
}
|
||
|
||
/** Cargo scope as a readable string, e.g. "20ft ×2, 40ft ×1" or "Wheat ×500". */
|
||
function describeCargoScope(scope?: ContractCargoScope[]): string | null {
|
||
if (!scope?.length) return null;
|
||
return scope
|
||
.map((row) => {
|
||
const label =
|
||
row.containerSize ??
|
||
row.cargoType?.cargoTypeName ??
|
||
row.cargoFreeText ??
|
||
row.cargoTypeId ??
|
||
'cargo';
|
||
return row.quantityCap != null ? `${label} ×${row.quantityCap}` : String(label);
|
||
})
|
||
.sort()
|
||
.join(', ');
|
||
}
|
||
|
||
/**
|
||
* Order-independent identity of a cargo scope — two contracts cover the same
|
||
* cargo only when they list the same container sizes / commodities. Quantity
|
||
* caps are deliberately ignored: they size a GENERAL contract, they don't make
|
||
* it a different scope.
|
||
*/
|
||
function cargoScopeKey(
|
||
scope?: Array<
|
||
Pick<ContractCargoScope, 'containerSize' | 'cargoTypeId' | 'cargoFreeText'>
|
||
> | null,
|
||
): string {
|
||
if (!scope?.length) return '';
|
||
return scope
|
||
.map((row) =>
|
||
[
|
||
row.containerSize?.trim().toLowerCase() ?? '',
|
||
row.cargoTypeId ?? '',
|
||
row.cargoFreeText?.trim().toLowerCase() ?? '',
|
||
].join('|'),
|
||
)
|
||
.sort()
|
||
.join(',');
|
||
}
|
||
|
||
const NEEDS_ACTION_STATUSES = [
|
||
'SUBMITTED',
|
||
'PENDING_APPROVAL',
|
||
'APPROVED_PENDING_SIGNATURE',
|
||
'SIGNED_CUSTOMER',
|
||
] as const;
|
||
|
||
@Injectable()
|
||
export class ContractsService {
|
||
constructor(
|
||
@InjectDataSource() private readonly dataSource: DataSource,
|
||
private readonly contractsRepository: ContractsRepository,
|
||
private readonly companiesService: CompaniesService,
|
||
private readonly filesService: FilesService,
|
||
private readonly minioService: MinioService,
|
||
private readonly documentHistory: ContractDocumentHistoryService,
|
||
private readonly pricingService: ContractPricingService,
|
||
) {}
|
||
|
||
/** Generate a unique contract reference number (CTR-YYYY-NNNNN). */
|
||
private async generateReference(): Promise<string> {
|
||
const year = new Date().getFullYear();
|
||
const seq = await this.contractsRepository.maxReferenceSequence(year);
|
||
return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`;
|
||
}
|
||
|
||
/** The service type a contract is sold under (null when the id is unknown). */
|
||
private resolveServiceType(serviceTypeId: string): Promise<ServiceType | null> {
|
||
return this.dataSource.getRepository(ServiceType).findOne({ where: { id: serviceTypeId } });
|
||
}
|
||
|
||
/** Whether a service type bundles customs clearance. */
|
||
private async resolveIncludesCustoms(serviceTypeId: string): Promise<boolean> {
|
||
const serviceType = await this.resolveServiceType(serviceTypeId);
|
||
return serviceType?.includesCustoms ?? false;
|
||
}
|
||
|
||
/** Validate cargo-scope rows against freight type (doc §5.4). */
|
||
private assertCargoScopeShape(
|
||
freightType: string,
|
||
cargoScope: CreateContractDto['cargoScope'],
|
||
): void {
|
||
if (freightType === 'CONTAINER') {
|
||
const sizes = cargoScope.filter((c) =>
|
||
['20ft', '40ft'].includes(c.containerSize ?? ''),
|
||
);
|
||
if (sizes.length === 0) {
|
||
throw new BadRequestException(
|
||
'CONTAINER contracts require at least one container size (20ft/40ft) in scope',
|
||
);
|
||
}
|
||
} else {
|
||
const bulk = cargoScope.filter((c) => c.cargoTypeId);
|
||
if (bulk.length !== 1) {
|
||
throw new BadRequestException(
|
||
'BULK contracts require exactly one cargo-type scope row',
|
||
);
|
||
}
|
||
if (cargoScope.some((c) => c.containerSize)) {
|
||
throw new BadRequestException('BULK contracts must not set a container size');
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Validate route count against contract kind (doc §5.3). */
|
||
private assertRouteShape(
|
||
contractKind: string,
|
||
routes: CreateContractDto['routes'],
|
||
): void {
|
||
if (contractKind === 'ONE_TIME' && routes.length !== 1) {
|
||
throw new BadRequestException('ONE_TIME contracts require exactly one route');
|
||
}
|
||
if (routes.length < 1) {
|
||
throw new BadRequestException('A contract requires at least one route');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Every route must match the contract's declared trade direction as derived
|
||
* from the yard countries (IMPORT = DJ→ET, EXPORT = ET→DJ, DOMESTIC =
|
||
* intercity). Intercity is Ethiopian-domestic only: both yards must be in
|
||
* Ethiopia — a Djibouti-internal pair is rejected. Direction mismatches
|
||
* (e.g. an export lane on an import contract) are rejected for every kind.
|
||
*/
|
||
private async assertRoutesMatchDirection(
|
||
tradeDirection: string,
|
||
routes: CreateContractDto['routes'],
|
||
): Promise<void> {
|
||
const yardIds = [
|
||
...new Set(routes.flatMap((r) => [r.originYardId, r.destinationYardId])),
|
||
];
|
||
const yards = await this.dataSource
|
||
.getRepository(Yard)
|
||
.find({ where: yardIds.map((id) => ({ id })) });
|
||
const yardById = new Map(yards.map((y) => [y.id, y]));
|
||
|
||
for (const route of routes) {
|
||
const origin = yardById.get(route.originYardId);
|
||
const destination = yardById.get(route.destinationYardId);
|
||
if (!origin || !destination) {
|
||
throw new BadRequestException('Route references a yard that does not exist');
|
||
}
|
||
const derived = deriveTradeDirection(origin, destination);
|
||
if (derived !== tradeDirection) {
|
||
throw new BadRequestException(
|
||
`Route ${origin.label} → ${destination.label} is ${derived === 'DOMESTIC' ? 'an intercity' : `an ${derived.toLowerCase()}`} lane and does not match the contract's ${tradeDirection === 'DOMESTIC' ? 'intercity' : tradeDirection.toLowerCase()} direction`,
|
||
);
|
||
}
|
||
if (
|
||
derived === 'DOMESTIC' &&
|
||
(origin.country !== YardCountry.ETHIOPIA ||
|
||
destination.country !== YardCountry.ETHIOPIA)
|
||
) {
|
||
throw new BadRequestException(
|
||
`Route ${origin.label} → ${destination.label}: intercity service only runs between Ethiopian yards`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A live contract only blocks a new request when EVERY commercial dimension
|
||
* of the wizard matches it: service type, operation type (trade direction),
|
||
* contract kind, cargo scope and route. Change any one of them — a different
|
||
* lane, bulk instead of containers, GENERAL instead of ONE_TIME — and the
|
||
* customer may request another contract.
|
||
*
|
||
* A route "overlaps" if any origin/destination pair matches; cargo scope
|
||
* matches only when the two scope sets are identical (same freight type and
|
||
* the same container sizes / commodities).
|
||
*/
|
||
private async assertNoDuplicateContract(
|
||
companyId: string,
|
||
dto: CreateContractDto,
|
||
): Promise<void> {
|
||
const candidates = await this.contractsRepository.findDuplicateCandidates(
|
||
companyId,
|
||
dto.serviceTypeId,
|
||
);
|
||
const incomingScope = cargoScopeKey(dto.cargoScope);
|
||
const duplicate = candidates.find(
|
||
(c) =>
|
||
!isEffectivelyExpired(c) &&
|
||
c.tradeDirection === dto.tradeDirection &&
|
||
c.contractKind === dto.contractKind &&
|
||
c.freightType === dto.freightType &&
|
||
cargoScopeKey(c.cargoScope) === incomingScope &&
|
||
(c.routes ?? []).some((existingRoute) =>
|
||
dto.routes.some(
|
||
(r) =>
|
||
r.originYardId === existingRoute.originYardId &&
|
||
r.destinationYardId === existingRoute.destinationYardId,
|
||
),
|
||
),
|
||
);
|
||
if (duplicate) {
|
||
const until = duplicate.contractValidUntil
|
||
? duplicate.contractValidUntil.toISOString().slice(0, 10)
|
||
: 'its approval completes';
|
||
throw new ConflictException(
|
||
`An active contract already exists for this service type, operation type, contract kind, cargo scope and route (${duplicate.reference}, valid until ${until}). Change any one of them, or wait until this contract expires or is rejected/cancelled.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/** Create a new contract (DRAFT) with its routes and cargo-scope rows. */
|
||
async create(
|
||
dto: CreateContractDto,
|
||
files: Express.Multer.File[],
|
||
userId?: string,
|
||
): Promise<{ contract: Contract; warnings: string[] }> {
|
||
const warnings: string[] = [];
|
||
const isGovernment = dto.isGovernment === true;
|
||
|
||
let companyId: string | null | undefined = dto.companyId;
|
||
if (isGovernment) {
|
||
if (!dto.governmentInstitution?.trim()) {
|
||
throw new BadRequestException(
|
||
'governmentInstitution is required for government contracts',
|
||
);
|
||
}
|
||
companyId = dto.companyId ?? null;
|
||
} else if (!companyId) {
|
||
if (!userId) {
|
||
throw new BadRequestException(
|
||
'companyId is required or must be resolvable from auth token',
|
||
);
|
||
}
|
||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||
this.companiesService.assertCompanyActiveFor(company, 'contracts');
|
||
companyId = company.id;
|
||
}
|
||
|
||
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
|
||
this.assertRouteShape(dto.contractKind, dto.routes);
|
||
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
|
||
if (companyId) {
|
||
await this.assertNoDuplicateContract(companyId, dto);
|
||
}
|
||
|
||
// Stamp the operational profile for portal scoping. A forwarder contract
|
||
// pins its profile explicitly (trade direction can't tell it apart from a
|
||
// direct import/export); everything else resolves from the trade direction.
|
||
let companyProfileId: string | null = null;
|
||
if (!isGovernment && companyId) {
|
||
if (dto.companyProfileId) {
|
||
const profile =
|
||
await this.companiesService.getActiveCompanyProfileForBooking(
|
||
companyId,
|
||
dto.companyProfileId,
|
||
);
|
||
companyProfileId = profile.id;
|
||
} else {
|
||
companyProfileId =
|
||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||
companyId,
|
||
dto.tradeDirection,
|
||
);
|
||
|
||
const customerSelfBooking = !dto.companyId && !!userId;
|
||
if (customerSelfBooking && companyProfileId) {
|
||
await this.companiesService.assertCompanyProfileApprovedForBooking(
|
||
companyProfileId,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Customs clearing is owned by the service type, not the customer.
|
||
const serviceType = await this.resolveServiceType(dto.serviceTypeId);
|
||
const includesCustoms = serviceType?.includesCustoms ?? false;
|
||
// Intercity never crosses a border, so a customs-including service type is
|
||
// a contradiction — the wizard hides them, the API enforces it.
|
||
if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) {
|
||
throw new BadRequestException(
|
||
'Intercity contracts cannot use a service type that includes customs clearing',
|
||
);
|
||
}
|
||
|
||
// Price the contract BEFORE anything persists: a lane with no configured
|
||
// rate 422s here and the wizard shows its blocking modal — with no orphan
|
||
// DRAFT row left behind for the customer to trip over on retry. The probe
|
||
// carries exactly the fields buildBreakdown prices from; relation-only
|
||
// niceties (cargoType labels) are absent, which only affects display
|
||
// lines, never the missing-rate gates.
|
||
await this.pricingService.buildBreakdown({
|
||
tradeDirection: dto.tradeDirection,
|
||
freightType: dto.freightType,
|
||
paymentCurrency: 'USD',
|
||
customsClearingEnabled: includesCustoms,
|
||
// Decides which customs fee the probe looks up (Ethiopian-only vs full).
|
||
serviceType,
|
||
isHazardous: dto.isHazardous ?? false,
|
||
isReefer: dto.isReefer ?? false,
|
||
equipmentReturn: dto.equipmentReturn ?? null,
|
||
firstMilePickupAddress: dto.firstMilePickupAddress ?? null,
|
||
lastMileDeliveryAddress: dto.lastMileDeliveryAddress ?? null,
|
||
routes: (dto.routes ?? []).map((r, i) => ({
|
||
originYardId: r.originYardId,
|
||
destinationYardId: r.destinationYardId,
|
||
sortOrder: r.sortOrder ?? i,
|
||
})),
|
||
cargoScope: (dto.cargoScope ?? []).map((c) => ({
|
||
containerSize: c.containerSize ?? null,
|
||
cargoTypeId: c.cargoTypeId ?? null,
|
||
})),
|
||
} as unknown as Contract);
|
||
|
||
// 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);
|
||
|
||
if (files.length > 0) {
|
||
try {
|
||
await this.filesService.uploadMany(contract.id, 'contracts', files);
|
||
} catch {
|
||
warnings.push('File upload failed — contract was created without attached files.');
|
||
}
|
||
}
|
||
|
||
// Attach the company's onboarding documents (TIN, licenses, IDs) and the
|
||
// profile's business-license documents to the contract by reference. The
|
||
// separate "Documents" intake step was removed — the profile documents are
|
||
// simply carried onto every contract automatically.
|
||
await this.attachProfileDocuments(contract.id, companyId ?? null, companyProfileId);
|
||
|
||
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,
|
||
// A contract is always QUOTED in USD — the billing currency is chosen per
|
||
// booking (or on the shipment request when GL books for the customer), so
|
||
// any client-supplied currency here is ignored. Contracts created before
|
||
// this rule keep whatever they stored; update() never rewrites it.
|
||
paymentCurrency: 'USD',
|
||
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,
|
||
// Hazard class / UN number only exist on a hazardous contract — a stale
|
||
// pair from an earlier draft must never survive the flag being turned off.
|
||
hazardClass: dto.isHazardous ? (dto.hazardClass ?? null) : null,
|
||
unNumber: dto.isHazardous ? (dto.unNumber ?? null) : null,
|
||
isReefer: dto.isReefer ?? false,
|
||
contractType: dto.contractType ?? null,
|
||
status: 'DRAFT',
|
||
clearanceStatus: 'NOT_APPLICABLE',
|
||
clearanceCycleNumber: 0,
|
||
} as never);
|
||
}
|
||
|
||
/**
|
||
* Copy the company's onboarding documents (TIN certificate, commercial /
|
||
* investment license, national ID, passport — resource "companies", coded by
|
||
* the upload-setting fileKey) and the company profile's business-license
|
||
* documents (resource "company_profiles") onto a contract by reference (no
|
||
* byte re-upload). Idempotent: codes already present on the contract — user
|
||
* uploads or an earlier carry — are never duplicated or overwritten, so it is
|
||
* safe to run on every create and update. No-op when there is nothing to copy.
|
||
*/
|
||
private async attachProfileDocuments(
|
||
contractId: string,
|
||
companyId: string | null,
|
||
companyProfileId: string | null,
|
||
): Promise<void> {
|
||
if (!companyId && !companyProfileId) return;
|
||
|
||
const existingCodes = new Set(
|
||
(await this.filesService.findByResource(contractId, 'contracts')).map(
|
||
(r) => r.code,
|
||
),
|
||
);
|
||
const docs: Array<{
|
||
code: string;
|
||
name: string;
|
||
url: string;
|
||
size: number;
|
||
mimeType?: string;
|
||
}> = [];
|
||
|
||
if (companyId) {
|
||
// Company onboarding documents keep their fileKey codes (tin_certificate,
|
||
// commercial_license, …) so the portal can match them against the
|
||
// onboarding upload-setting fields. Re-uploads append rows, so keep only
|
||
// the newest record per code.
|
||
const companyRecords = await this.filesService.findByResource(
|
||
companyId,
|
||
'companies',
|
||
);
|
||
const latestByCode = new Map<string, (typeof companyRecords)[number]>();
|
||
for (const r of companyRecords) {
|
||
const prev = latestByCode.get(r.code);
|
||
if (!prev || r.createdAt > prev.createdAt) latestByCode.set(r.code, r);
|
||
}
|
||
for (const r of latestByCode.values()) {
|
||
if (existingCodes.has(r.code)) continue;
|
||
docs.push({
|
||
code: r.code,
|
||
name: r.name,
|
||
url: r.url,
|
||
size: r.size,
|
||
mimeType: r.mimeType,
|
||
});
|
||
}
|
||
}
|
||
|
||
if (companyProfileId) {
|
||
// Business-license files are FileRecords (resource "company_profiles");
|
||
// carry the live ones by reference. Staged/pending uploads are excluded by
|
||
// code. The `business_license` prefix is preserved so the portal groups
|
||
// them under "Business license" instead of the clearance catch-all — the
|
||
// index suffix keeps multiple licences distinct.
|
||
const records = await this.filesService.findByResource(
|
||
companyProfileId,
|
||
'company_profiles',
|
||
);
|
||
|
||
records
|
||
.filter((r) => r.code === 'business_license')
|
||
.forEach((r, i) => {
|
||
const code = `business_license_${i + 1}`;
|
||
if (existingCodes.has(code)) return;
|
||
docs.push({
|
||
code,
|
||
name: r.name,
|
||
url: r.url,
|
||
size: r.size,
|
||
mimeType: r.mimeType,
|
||
});
|
||
});
|
||
}
|
||
|
||
if (docs.length === 0) return;
|
||
|
||
try {
|
||
await this.filesService.attachExistingFiles(contractId, 'contracts', docs);
|
||
} catch {
|
||
// Non-fatal — the contract is still valid without the carried documents.
|
||
}
|
||
}
|
||
|
||
private async persistRoutes(
|
||
contractId: string,
|
||
routes: CreateContractDto['routes'],
|
||
): Promise<void> {
|
||
const repo = this.dataSource.getRepository(ContractRoute);
|
||
await repo.save(
|
||
routes.map((r, i) =>
|
||
repo.create({
|
||
contractId,
|
||
originYardId: r.originYardId,
|
||
destinationYardId: r.destinationYardId,
|
||
km: r.km ?? null,
|
||
sortOrder: r.sortOrder ?? i,
|
||
}),
|
||
),
|
||
);
|
||
}
|
||
|
||
private async persistCargoScope(
|
||
contractId: string,
|
||
cargoScope: CreateContractDto['cargoScope'],
|
||
contractKind: string,
|
||
): Promise<void> {
|
||
const repo = this.dataSource.getRepository(ContractCargoScope);
|
||
// A quantity cap only governs GENERAL contracts (multi-shipment draw-down).
|
||
// ONE_TIME allows a single booking, so any cap on it is meaningless → null.
|
||
const isGeneral = contractKind === 'GENERAL';
|
||
await repo.save(
|
||
cargoScope.map((c) =>
|
||
repo.create({
|
||
contractId,
|
||
containerSize: c.containerSize ?? null,
|
||
cargoTypeId: c.cargoTypeId ?? null,
|
||
cargoFreeText: c.cargoFreeText ?? null,
|
||
quantityCap: isGeneral ? (c.quantityCap ?? null) : null,
|
||
}),
|
||
),
|
||
);
|
||
}
|
||
|
||
/** Update a DRAFT / CHANGES_REQUESTED contract. */
|
||
async update(
|
||
id: string,
|
||
dto: UpdateContractDto,
|
||
files: Express.Multer.File[],
|
||
actorId?: string,
|
||
): Promise<{ contract: Contract; warnings: string[] }> {
|
||
const existing = await this.findById(id);
|
||
if (!CONTRACT_CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
|
||
throw new BadRequestException(
|
||
'Only DRAFT or CHANGES_REQUESTED contracts can be updated',
|
||
);
|
||
}
|
||
|
||
const warnings: string[] = [];
|
||
const freightType = dto.freightType ?? existing.freightType;
|
||
const contractKind = dto.contractKind ?? existing.contractKind;
|
||
|
||
if (dto.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope);
|
||
if (dto.routes) this.assertRouteShape(contractKind, dto.routes);
|
||
if (dto.routes) {
|
||
await this.assertRoutesMatchDirection(
|
||
dto.tradeDirection ?? existing.tradeDirection,
|
||
dto.routes,
|
||
);
|
||
}
|
||
|
||
const updates: Record<string, unknown> = {
|
||
contractKind,
|
||
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
|
||
freightType,
|
||
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
|
||
// Never rewritten: grandfathered contracts keep the currency (and frozen
|
||
// snapshots) they were signed with.
|
||
paymentCurrency: existing.paymentCurrency,
|
||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||
isReefer: dto.isReefer ?? existing.isReefer,
|
||
// Same rule as create: clearing the flag clears the declaration with it.
|
||
hazardClass: (dto.isHazardous ?? existing.isHazardous)
|
||
? (dto.hazardClass ?? existing.hazardClass ?? null)
|
||
: null,
|
||
unNumber: (dto.isHazardous ?? existing.isHazardous)
|
||
? (dto.unNumber ?? existing.unNumber ?? null)
|
||
: null,
|
||
equipmentReturn: dto.equipmentReturn ?? existing.equipmentReturn,
|
||
firstMilePickupAddress: dto.firstMilePickupAddress ?? existing.firstMilePickupAddress,
|
||
firstMilePickupLat: dto.firstMilePickupLat ?? existing.firstMilePickupLat,
|
||
firstMilePickupLng: dto.firstMilePickupLng ?? existing.firstMilePickupLng,
|
||
lastMileDeliveryAddress:
|
||
dto.lastMileDeliveryAddress ?? existing.lastMileDeliveryAddress,
|
||
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? existing.lastMileDeliveryLat,
|
||
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? existing.lastMileDeliveryLng,
|
||
contractType: dto.contractType ?? existing.contractType,
|
||
};
|
||
if (dto.renewalOfId !== undefined) updates.renewalOfId = dto.renewalOfId ?? null;
|
||
|
||
// Customs clearing always mirrors the (possibly changed) service type.
|
||
const includesCustoms = await this.resolveIncludesCustoms(
|
||
dto.serviceTypeId ?? existing.serviceTypeId,
|
||
);
|
||
if ((dto.tradeDirection ?? existing.tradeDirection) === 'DOMESTIC' && includesCustoms) {
|
||
throw new BadRequestException(
|
||
'Intercity contracts cannot use a service type that includes customs clearing',
|
||
);
|
||
}
|
||
updates.customsClearingEnabled = includesCustoms;
|
||
updates.customsClearingAgent = includesCustoms
|
||
? null
|
||
: (dto.customsClearingAgent ?? existing.customsClearingAgent ?? null);
|
||
|
||
await this.contractsRepository.update(id, updates);
|
||
|
||
if (dto.routes) {
|
||
await this.dataSource.getRepository(ContractRoute).delete({ contractId: id });
|
||
await this.persistRoutes(id, dto.routes);
|
||
}
|
||
if (dto.cargoScope) {
|
||
await this.dataSource.getRepository(ContractCargoScope).delete({ contractId: id });
|
||
await this.persistCargoScope(id, dto.cargoScope, existing.contractKind);
|
||
}
|
||
|
||
if (files.length > 0) {
|
||
await this.filesService.uploadMany(id, 'contracts', files);
|
||
}
|
||
|
||
// Re-carry any company/profile document that is still missing from the
|
||
// contract (runs after the upload so fresh replacements keep their slot).
|
||
// Backfills contracts created before profile documents were carried over.
|
||
await this.attachProfileDocuments(
|
||
id,
|
||
existing.companyId ?? null,
|
||
existing.companyProfileId ?? null,
|
||
);
|
||
|
||
const updated = await this.findById(id);
|
||
// Audit what this edit actually changed. Runs after the writes so the
|
||
// "after" side is read back from the contract rather than from the DTO.
|
||
await this.recordFieldRevision(existing, updated, actorId);
|
||
|
||
return { contract: updated, warnings };
|
||
}
|
||
|
||
/** Fields worth auditing on a customer edit, read off a loaded contract. */
|
||
private auditableFields(contract: Contract): Record<string, unknown> {
|
||
return {
|
||
contractKind: contract.contractKind,
|
||
tradeDirection: contract.tradeDirection,
|
||
freightType: contract.freightType,
|
||
serviceType: contract.serviceType?.serviceName ?? contract.serviceTypeId,
|
||
paymentCurrency: contract.paymentCurrency,
|
||
contractType: contract.contractType,
|
||
isHazardous: contract.isHazardous,
|
||
hazardClass: contract.hazardClass,
|
||
unNumber: contract.unNumber,
|
||
isReefer: contract.isReefer,
|
||
equipmentReturn: contract.equipmentReturn,
|
||
customsClearingAgent: contract.customsClearingAgent,
|
||
firstMilePickupAddress: contract.firstMilePickupAddress,
|
||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress,
|
||
routes: describeRoutes(contract.routes),
|
||
cargoScope: describeCargoScope(contract.cargoScope),
|
||
};
|
||
}
|
||
|
||
/** Append a revision describing a customer's edit to the contract itself. */
|
||
private async recordFieldRevision(
|
||
before: Contract,
|
||
after: Contract,
|
||
actorId?: string,
|
||
): Promise<void> {
|
||
const changes = diffContractFields(
|
||
this.auditableFields(before),
|
||
this.auditableFields(after),
|
||
);
|
||
await this.documentHistory.recordChanges({
|
||
contractId: after.id,
|
||
changes,
|
||
actorId: actorId ?? null,
|
||
actorRole: 'Customer',
|
||
});
|
||
}
|
||
|
||
/** Parse comma-separated or repeated status query values. */
|
||
private parseStatusFilter(filter: FilterContractDto): {
|
||
statuses?: string[];
|
||
status?: string;
|
||
} {
|
||
const allowed = new Set<string>(CONTRACT_STATUSES);
|
||
const raw = filter.statuses;
|
||
const statusList = raw
|
||
? raw
|
||
.split(',')
|
||
.map((s) => s.trim())
|
||
.filter((s) => allowed.has(s))
|
||
: [];
|
||
|
||
if (statusList.length > 0) return { statuses: statusList };
|
||
if (filter.status && allowed.has(filter.status)) return { status: filter.status };
|
||
return {};
|
||
}
|
||
|
||
async findAll(
|
||
filter: FilterContractDto,
|
||
forceCompanyId?: string,
|
||
forceCompanyProfileId?: string,
|
||
tradeDirections?: string[],
|
||
): Promise<PaginatedContracts> {
|
||
const page = filter.page ?? 1;
|
||
const pageSize = filter.pageSize ?? 20;
|
||
const statusFilter = this.parseStatusFilter(filter);
|
||
|
||
return this.contractsRepository.findAllPaginated({
|
||
page,
|
||
pageSize,
|
||
...statusFilter,
|
||
companyId: forceCompanyId ?? filter.companyId,
|
||
companyProfileId: forceCompanyProfileId ?? filter.companyProfileId,
|
||
contractKind: filter.contractKind,
|
||
serviceTypeId: filter.serviceTypeId,
|
||
freightType: filter.freightType,
|
||
tradeDirection: filter.tradeDirection,
|
||
tradeDirections,
|
||
paymentCurrency: filter.paymentCurrency,
|
||
createdFrom: filter.createdFrom,
|
||
createdTo: filter.createdTo,
|
||
originYardId: filter.originYardId,
|
||
destinationYardId: filter.destinationYardId,
|
||
search: filter.search,
|
||
sortBy: filter.sortBy,
|
||
sortOrder: filter.sortOrder,
|
||
});
|
||
}
|
||
|
||
/** Aggregate metrics and status counts for the backoffice contract list. */
|
||
async getListSummary(filter: FilterContractDto): Promise<ContractListSummaryDto> {
|
||
const page = filter.page ?? 1;
|
||
const pageSize = filter.pageSize ?? 20;
|
||
const statusFilter = this.parseStatusFilter(filter);
|
||
const listFilter = {
|
||
...statusFilter,
|
||
companyId: filter.companyId,
|
||
contractKind: filter.contractKind,
|
||
serviceTypeId: filter.serviceTypeId,
|
||
freightType: filter.freightType,
|
||
tradeDirection: filter.tradeDirection,
|
||
paymentCurrency: filter.paymentCurrency,
|
||
createdFrom: filter.createdFrom,
|
||
createdTo: filter.createdTo,
|
||
originYardId: filter.originYardId,
|
||
destinationYardId: filter.destinationYardId,
|
||
};
|
||
|
||
const [facets, metrics] = await Promise.all([
|
||
this.contractsRepository.getFacets(listFilter),
|
||
this.contractsRepository.getListSummaryMetrics({
|
||
...listFilter,
|
||
page,
|
||
pageSize,
|
||
needsActionStatuses: NEEDS_ACTION_STATUSES,
|
||
}),
|
||
]);
|
||
|
||
// statusCounts kept for existing callers; now filter-scoped like every
|
||
// other facet instead of the unfiltered global count `getStatusCounts` gave.
|
||
const statusCounts = Object.fromEntries(
|
||
(facets.status ?? []).map((b) => [b.value, b.count]),
|
||
);
|
||
|
||
return { metrics, statusCounts, facets };
|
||
}
|
||
|
||
/** Get a single contract by ID with relations and signed file URLs. */
|
||
async findById(id: string): Promise<Contract> {
|
||
const contract = await this.contractsRepository.findByIdWithRelations(id);
|
||
if (!contract) {
|
||
throw new NotFoundException(`Contract ${id} not found`);
|
||
}
|
||
|
||
// Lazy expiry flip: the nightly cron only sweeps once a day, so a
|
||
// contract can be past contract_valid_until for hours before it shows
|
||
// EXPIRED. Flip it here so the detail page never shows a stale status.
|
||
if (isEffectivelyExpired(contract) && contract.status !== 'EXPIRED') {
|
||
const flipped = await this.contractsRepository.expireIfLapsed(id);
|
||
if (flipped) {
|
||
contract.status = 'EXPIRED';
|
||
}
|
||
}
|
||
// Entry state for every contract flow (submit, approve, sign, suspend…) —
|
||
// see the equivalent in BookingsService.findById.
|
||
logCtx(
|
||
{
|
||
id: contract.id,
|
||
reference: contract.reference,
|
||
statusAtEntry: contract.status,
|
||
companyId: contract.companyId,
|
||
},
|
||
{ path: "contract" },
|
||
);
|
||
|
||
if (contract.files && contract.files.length > 0) {
|
||
contract.files = await Promise.all(
|
||
contract.files.map(async (file: FileRecord) => {
|
||
const objectName = this.minioService.getObjectNameFromUrl(file.url);
|
||
const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
|
||
return { ...file, signedUrl } as FileRecord;
|
||
}),
|
||
);
|
||
}
|
||
|
||
// Surface the staff "request changes" note so the portal can show the
|
||
// customer what to fix. Degrade to null on lookup failure — a missing note
|
||
// must never 500 a contract fetch.
|
||
if (contract.status === 'CHANGES_REQUESTED') {
|
||
try {
|
||
const note = await this.contractsRepository.findLatestReviewNote(
|
||
contract.id,
|
||
'CHANGES_REQUESTED',
|
||
);
|
||
contract.latestChangeRequestNote = note?.body ?? null;
|
||
} catch {
|
||
contract.latestChangeRequestNote = null;
|
||
}
|
||
}
|
||
|
||
// Surface the rejection reason. The approval-step note is wiped on
|
||
// send-back resets, so the review-note trail is the only durable source.
|
||
if (contract.status === 'REJECTED') {
|
||
try {
|
||
const note = await this.contractsRepository.findLatestReviewNote(
|
||
contract.id,
|
||
'REJECTION',
|
||
);
|
||
contract.latestRejectionNote = note?.body ?? null;
|
||
} catch {
|
||
contract.latestRejectionNote = null;
|
||
}
|
||
}
|
||
|
||
// Surface the send-back reason to the returned-to approver, but only while
|
||
// it is still actionable: once any step acts after the send-back the note
|
||
// is stale and stays out of the response (the trail keeps it in the DB).
|
||
if (contract.status === 'PENDING_APPROVAL') {
|
||
try {
|
||
const note = await this.contractsRepository.findLatestReviewNote(
|
||
contract.id,
|
||
'STAFF_NOTE',
|
||
);
|
||
// Stale when any step acted after it (send-back resolved) or when the
|
||
// chain itself is newer than the note (fresh cycle after a resubmit).
|
||
const staleAfter = Math.max(
|
||
0,
|
||
...(contract.approvalSteps ?? []).flatMap((s) => [
|
||
s.actedAt ? new Date(s.actedAt).getTime() : 0,
|
||
s.createdAt ? new Date(s.createdAt).getTime() : 0,
|
||
]),
|
||
);
|
||
contract.latestSendBackNote =
|
||
note && new Date(note.createdAt).getTime() > staleAfter
|
||
? note.body
|
||
: null;
|
||
} catch {
|
||
contract.latestSendBackNote = null;
|
||
}
|
||
}
|
||
|
||
// Why the contract is frozen — shown to staff and customer alike.
|
||
if (contract.status === 'SUSPENDED') {
|
||
try {
|
||
const note = await this.contractsRepository.findLatestReviewNote(
|
||
contract.id,
|
||
'SUSPENSION',
|
||
);
|
||
contract.latestSuspensionNote = note?.body ?? null;
|
||
} catch {
|
||
contract.latestSuspensionNote = null;
|
||
}
|
||
}
|
||
|
||
// Lets the portal disable "Cancel contract" instead of letting the customer
|
||
// click it and read a 400. The API re-checks on cancel regardless.
|
||
contract.activeBookingCount =
|
||
await this.contractsRepository.countActiveBookings(contract.id);
|
||
|
||
return contract;
|
||
}
|
||
|
||
async findByReference(reference: string): Promise<Contract> {
|
||
const found = await this.contractsRepository.findByReference(reference);
|
||
if (!found) {
|
||
throw new NotFoundException(`Contract with reference "${reference}" not found`);
|
||
}
|
||
return this.findById(found.id);
|
||
}
|
||
|
||
/** Upload intake documents for a DRAFT contract. */
|
||
async uploadDocuments(
|
||
id: string,
|
||
files: Express.Multer.File[],
|
||
): Promise<Contract> {
|
||
const contract = await this.findById(id);
|
||
if (contract.status !== 'DRAFT') {
|
||
throw new BadRequestException(
|
||
'Documents can only be uploaded for DRAFT contracts',
|
||
);
|
||
}
|
||
await this.filesService.uploadMany(id, 'contracts', files);
|
||
return this.findById(id);
|
||
}
|
||
|
||
async remove(id: string): Promise<void> {
|
||
const contract = await this.findById(id);
|
||
if (contract.status !== 'DRAFT') {
|
||
throw new BadRequestException('Only DRAFT contracts can be deleted');
|
||
}
|
||
await this.contractsRepository.softDelete(id);
|
||
}
|
||
|
||
/** Resolve the company a customer user belongs to, for scoping their contracts. */
|
||
async resolveCustomerCompanyId(userId: string): Promise<string | null> {
|
||
try {
|
||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||
return company?.id ?? null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Authorize a customer's access to a single contract (hides as NotFound otherwise). */
|
||
async assertCustomerCanAccessContract(
|
||
userId: string | undefined,
|
||
contract: Contract,
|
||
): Promise<void> {
|
||
if (!userId) {
|
||
throw new ForbiddenException('Authentication required');
|
||
}
|
||
const companyId = await this.resolveCustomerCompanyId(userId);
|
||
if (!companyId || contract.companyId !== companyId) {
|
||
throw new NotFoundException(`Contract ${contract.id} not found`);
|
||
}
|
||
}
|
||
}
|