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