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

527 lines
19 KiB
TypeScript

import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { CompaniesService } from '../companies/companies.service';
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyStatus } from '../companies/entities/company.entity';
import { ServiceType } from '../rule-engine/entities/service-type.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 { 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;
};
}
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,
) {}
/** 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')}`;
}
/** Whether a service type bundles customs clearance. */
private async resolveIncludesCustoms(serviceTypeId: string): Promise<boolean> {
const serviceType = await this.dataSource
.getRepository(ServiceType)
.findOne({ where: { id: 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');
}
}
/** 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);
if (company.status !== CompanyStatus.Active) {
throw new ForbiddenException(
"Your company is awaiting approval — you can't create contracts yet.",
);
}
companyId = company.id;
}
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) {
let fallbackType: ProfileType | null = null;
if (userId) {
try {
const { profile } =
await this.companiesService.getCompanyInfoByUserId(userId);
fallbackType = profile.activeProfileType ?? null;
} catch {
// No profile (e.g. staff creating on behalf) — fall back to mapping.
}
}
companyProfileId =
await this.companiesService.resolveCompanyProfileIdForBooking(
companyId,
dto.tradeDirection,
fallbackType,
);
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 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);
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 profile's onboarding / 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, companyProfileId);
return { contract: await this.findById(contract.id), warnings };
}
/**
* Copy a company profile's stored business-license / onboarding documents onto
* a contract by reference (no byte re-upload). Codes are slugged from each
* document name so they group under "Profile documents" on the contract detail
* page. No-op when the contract has no profile or the profile has no documents.
*/
private async attachProfileDocuments(
contractId: string,
companyProfileId: string | null,
): Promise<void> {
if (!companyProfileId) return;
const profile = await this.dataSource
.getRepository(CompanyProfile)
.findOne({ where: { id: companyProfileId } });
const docs = profile?.businessLicenseFiles ?? [];
if (docs.length === 0) return;
const slug = (name: string) =>
name
.toLowerCase()
.replace(/\.[a-z0-9]+$/, '')
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '') || 'profile_document';
try {
await this.filesService.attachExistingFiles(
contractId,
'contracts',
docs.map((d, i) => ({
code: `${slug(d.name)}_${i + 1}`,
name: d.name,
url: d.url,
size: d.size,
mimeType: d.mimeType,
})),
);
} 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[],
): 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);
const updates: Record<string, unknown> = {
contractKind,
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
freightType,
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
isHazardous: dto.isHazardous ?? existing.isHazardous,
isReefer: dto.isReefer ?? existing.isReefer,
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,
);
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);
}
return { contract: await this.findById(id), warnings };
}
/** 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,
): 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,
paymentCurrency: filter.paymentCurrency,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
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,
};
const [statusCounts, metrics] = await Promise.all([
this.contractsRepository.getStatusCounts(),
this.contractsRepository.getListSummaryMetrics({
...listFilter,
page,
pageSize,
needsActionStatuses: NEEDS_ACTION_STATUSES,
}),
]);
return { metrics, statusCounts };
}
/** 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`);
}
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;
}),
);
}
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`);
}
}
}