Files
edr-platform/apps/edr-freight-api/src/modules/contracts/contracts.service.ts

993 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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`);
}
}
}