import { BaseRepository } from '@edr/api-common'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm'; import { FileRecord } from '../files/entities/file.entity'; import { Contract } from './entities/contract.entity'; import { ContractApprovalStep } from './entities/contract-approval-step.entity'; import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; import { ContractDocReviewStatus, ContractDocumentReview, } from './entities/contract-document-review.entity'; import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity'; import { ContractReviewNote, ContractReviewNoteType } from './entities/contract-review-note.entity'; import { ContractSignature, ContractSignerRole } from './entities/contract-signature.entity'; export interface ContractListFilterOptions { statuses?: string[]; status?: string; companyId?: string; companyProfileId?: string; contractKind?: string; serviceTypeId?: string; freightType?: string; tradeDirection?: string; paymentCurrency?: string; customsClearingEnabled?: boolean; /** true → only contracts with at least one uploaded clearance document. */ hasClearanceDocuments?: boolean; createdFrom?: string; createdTo?: string; } @Injectable() export class ContractsRepository extends BaseRepository { constructor( @InjectRepository(Contract) repository: Repository, private readonly dataSource: DataSource, ) { super(repository); } /** Find a contract by its human-readable reference number. */ findByReference(reference: string): Promise { return this.repository.findOne({ where: { reference } }); } /** * Highest NNNNN sequence already issued for `CTR--…` references. * Includes soft-deleted contracts — their references still occupy the unique * index, so the next number must move past them. (A created-at count drifts * below the issued sequence after any delete and then collides forever.) */ async maxReferenceSequence(year: number): Promise { const row = await this.repository .createQueryBuilder('contract') .withDeleted() .select( "COALESCE(MAX(CAST(SUBSTRING(contract.reference FROM '[0-9]+$') AS int)), 0)", 'max', ) .where('contract.reference LIKE :prefix', { prefix: `CTR-${year}-%` }) .getRawOne<{ max: string | number | null }>(); return Number(row?.max ?? 0); } /** Find a contract by ID with all child collections, service type, company and files. */ async findByIdWithRelations(id: string): Promise { if (!id) return null; const contract = await this.repository .createQueryBuilder('contract') .leftJoinAndSelect('contract.routes', 'routes') .leftJoinAndSelect('routes.originYard', 'routeOrigin') .leftJoinAndSelect('routes.destinationYard', 'routeDestination') .leftJoinAndSelect('contract.cargoScope', 'cargoScope') .leftJoinAndSelect('cargoScope.cargoType', 'cargoType') .leftJoinAndSelect('contract.rateSnapshots', 'rateSnapshots') .leftJoinAndSelect('contract.signatures', 'signatures') .leftJoinAndSelect('signatures.signatureFile', 'signatureFile') .leftJoinAndSelect('contract.approvalSteps', 'approvalSteps') .leftJoinAndSelect('contract.serviceType', 'serviceType') .leftJoinAndSelect('contract.company', 'company') .where('contract.id = :id', { id }) .leftJoinAndMapMany( 'contract.files', FileRecord, 'file', "file.resource_id = contract.id AND file.resource = 'contracts'", ) .getOne(); return contract ?? null; } /** Paginated list with optional multi-status filter (API tab queues). */ async findAllPaginated( options: ContractListFilterOptions & { page: number; pageSize: number; search?: string; sortBy?: string; sortOrder?: 'ASC' | 'DESC'; }, ): Promise<{ items: Contract[]; total: number; meta: { page: number; pageSize: number; total: number; totalPages: number; hasNextPage: boolean; hasPreviousPage: boolean; }; }> { const page = options.page; const pageSize = options.pageSize; const qb = this.repository .createQueryBuilder('contract') .leftJoinAndSelect('contract.company', 'company') .leftJoinAndSelect('contract.serviceType', 'serviceType') .leftJoinAndSelect('contract.routes', 'routes') .leftJoinAndSelect('routes.originYard', 'routeOrigin') .leftJoinAndSelect('routes.destinationYard', 'routeDestination') .leftJoinAndSelect('contract.cargoScope', 'cargoScope') .where('contract.deleted_at IS NULL'); this.applyListFilters(qb, options); // Free-text search across contract reference and customer (company) name. // Applied here (not in applyListFilters) because only this query joins the // `company` alias — the summary-metrics query builder does not. if (options.search) { qb.andWhere( '(contract.reference ILIKE :search OR company.name ILIKE :search)', { search: `%${options.search}%` }, ); } const sortField = options.sortBy === 'contractValidUntil' ? 'contract.contractValidUntil' : 'contract.createdAt'; qb.orderBy(sortField, options.sortOrder ?? 'DESC'); const [items, total] = await qb .skip((page - 1) * pageSize) .take(pageSize) .getManyAndCount(); // Attach the generated contract PDF to each row so list/home can offer a // direct download. Loaded separately to keep pagination counts correct. await this.attachContractFiles(items); await this.attachClearancePhases(items); await this.attachRejectionNotes(items); const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0; return { items, total, meta: { page, pageSize, total, totalPages, hasNextPage: page < totalPages, hasPreviousPage: page > 1, }, }; } /** * Load contract-resource files for the given contracts and attach them to * `contract.files`. Kept separate from the paginated query so the one-to-many * join doesn't inflate the page count. */ private async attachContractFiles(contracts: Contract[]): Promise { if (contracts.length === 0) return; const ids = contracts.map((c) => c.id); const files = await this.dataSource.getRepository(FileRecord).find({ where: { resource: 'contracts', resourceId: In(ids), deletedAt: IsNull() }, }); const byContract = new Map(); for (const file of files) { const list = byContract.get(file.resourceId) ?? []; list.push(file); byContract.set(file.resourceId, list); } for (const contract of contracts) { contract.files = byContract.get(contract.id) ?? []; } } /** * Attach each contract's persisted clearance phase (latest cycle's * current_phase) so list consumers can show step-accurate customer actions * ("Pay duty & upload slip" vs generic "Update clearance") without a * per-contract clearance-view request. One query per page, like * `attachContractFiles`. */ private async attachClearancePhases(contracts: Contract[]): Promise { if (contracts.length === 0) return; const ids = contracts.map((c) => c.id); const rows: Array<{ contract_id: string; current_phase: string | null; booking_id: string | null; booking_status: string | null; }> = await this.dataSource.query( `SELECT DISTINCT ON (ccc.contract_id) ccc.contract_id, ccc.current_phase, b.id AS booking_id, b.status AS booking_status FROM freight.contract_clearance_cycles ccc LEFT JOIN freight.bookings b ON b.id = ccc.booking_id WHERE ccc.contract_id = ANY($1) ORDER BY ccc.contract_id, ccc.cycle_number DESC`, [ids], ); const byContract = new Map(rows.map((r) => [r.contract_id, r])); for (const contract of contracts) { const row = byContract.get(contract.id); contract.clearancePhase = row?.current_phase ?? null; contract.latestCycleBookingId = row?.booking_id ?? null; contract.latestCycleBookingStatus = row?.booking_status ?? null; } } /** * Attach the latest REJECTION review-note body to each REJECTED contract so * list consumers (portal rows, backoffice queues) can show why without a * per-contract detail fetch. One query per page, like `attachContractFiles`. */ private async attachRejectionNotes(contracts: Contract[]): Promise { const rejected = contracts.filter((c) => c.status === 'REJECTED'); if (rejected.length === 0) return; const ids = rejected.map((c) => c.id); const rows: Array<{ contract_id: string; body: string }> = await this.dataSource.query( `SELECT DISTINCT ON (contract_id) contract_id, body FROM freight.contract_review_notes WHERE contract_id = ANY($1) AND note_type = 'REJECTION' AND deleted_at IS NULL ORDER BY contract_id, created_at DESC`, [ids], ); const byContract = new Map(rows.map((r) => [r.contract_id, r.body])); for (const contract of rejected) { contract.latestRejectionNote = byContract.get(contract.id) ?? null; } } async getStatusCounts(): Promise> { const rows = await this.repository .createQueryBuilder('contract') .select('contract.status', 'status') .addSelect('COUNT(*)::int', 'count') .where('contract.deleted_at IS NULL') .groupBy('contract.status') .getRawMany<{ status: string; count: string }>(); return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)])); } async getListSummaryMetrics( options: ContractListFilterOptions & { page: number; pageSize: number; needsActionStatuses: readonly string[]; }, ): Promise<{ inQueue: number; onThisPage: number; needsAction: number }> { const baseQb = () => { const qb = this.repository .createQueryBuilder('contract') .where('contract.deleted_at IS NULL'); this.applyListFilters(qb, options); return qb; }; const inQueue = await baseQb().getCount(); const needsAction = await baseQb() .andWhere('contract.status IN (:...needsActionStatuses)', { needsActionStatuses: [...options.needsActionStatuses], }) .getCount(); const offset = (options.page - 1) * options.pageSize; const onThisPage = Math.min(options.pageSize, Math.max(0, inQueue - offset)); return { inQueue, onThisPage, needsAction }; } private applyListFilters( qb: SelectQueryBuilder, options: ContractListFilterOptions, ): void { if (options.statuses?.length) { qb.andWhere('contract.status IN (:...statuses)', { statuses: options.statuses }); } else if (options.status) { qb.andWhere('contract.status = :status', { status: options.status }); } if (options.companyId) { qb.andWhere('contract.company_id = :companyId', { companyId: options.companyId }); } if (options.companyProfileId) { qb.andWhere('contract.company_profile_id = :companyProfileId', { companyProfileId: options.companyProfileId, }); } if (options.contractKind) { qb.andWhere('contract.contract_kind = :contractKind', { contractKind: options.contractKind, }); } if (options.customsClearingEnabled !== undefined) { qb.andWhere('contract.customs_clearing_enabled = :customsClearingEnabled', { customsClearingEnabled: options.customsClearingEnabled, }); } if (options.hasClearanceDocuments) { qb.andWhere( 'EXISTS (SELECT 1 FROM freight.contract_document_review cdr ' + 'WHERE cdr.contract_id = contract.id AND cdr.deleted_at IS NULL)', ); } if (options.serviceTypeId) { qb.andWhere('contract.service_type_id = :serviceTypeId', { serviceTypeId: options.serviceTypeId, }); } if (options.freightType) { qb.andWhere('contract.freight_type = :freightType', { freightType: options.freightType, }); } if (options.tradeDirection) { qb.andWhere('contract.trade_direction = :tradeDirection', { tradeDirection: options.tradeDirection, }); } if (options.paymentCurrency) { qb.andWhere('contract.payment_currency = :paymentCurrency', { paymentCurrency: options.paymentCurrency, }); } if (options.createdFrom) { qb.andWhere('contract.created_at >= :createdFrom', { createdFrom: options.createdFrom, }); } if (options.createdTo) { qb.andWhere('contract.created_at <= :createdTo', { createdTo: options.createdTo }); } } // ── Approval steps ───────────────────────────────────────────────────────── /** Lowest-order pending approval step (sequential enforcement). */ async findNextPendingApprovalStep( contractId: string, ): Promise { return this.dataSource.getRepository(ContractApprovalStep).findOne({ where: { contractId, status: 'PENDING' }, order: { stepOrder: 'ASC' }, }); } async findApprovalStepById( contractId: string, stepId: string, ): Promise { return this.dataSource.getRepository(ContractApprovalStep).findOne({ where: { contractId, id: stepId }, }); } /** Mark an approval step complete. */ async completeApprovalStep( stepId: string, actorId: string, status: 'APPROVED' | 'REJECTED', note?: string, ): Promise { await this.dataSource.getRepository(ContractApprovalStep).update(stepId, { status, actedByStaffId: actorId, actedAt: new Date(), note, }); } /** * Send-back reset: every step at or after `fromStepOrder` returns to PENDING * with its actor/verdict cleared, so the chain re-runs from that stage. The * send-back reason lives in the review-note trail, not on the wiped steps. */ async resetApprovalStepsFrom( contractId: string, fromStepOrder: number, ): Promise { await this.dataSource .getRepository(ContractApprovalStep) .createQueryBuilder() .update() .set({ status: 'PENDING', actedByStaffId: null, actedAt: null, note: null }) .where('contract_id = :contractId', { contractId }) .andWhere('step_order >= :fromStepOrder', { fromStepOrder }) .execute(); } /** Check if all approval steps are approved. */ async allApprovalStepsComplete(contractId: string): Promise { const pending = await this.dataSource.getRepository(ContractApprovalStep).count({ where: { contractId, status: 'PENDING' }, }); return pending === 0; } /** Persist a contract approval step (instantiated at staff accept). */ async createApprovalStep( data: Partial, ): Promise { const repo = this.dataSource.getRepository(ContractApprovalStep); return repo.save(repo.create(data)); } // ── Signatures ────────────────────────────────────────────────────────────── findSignatures(contractId: string): Promise { return this.dataSource.getRepository(ContractSignature).find({ where: { contractId }, relations: ['signatureFile'], order: { signedAt: 'ASC' }, }); } findSignature( contractId: string, role: ContractSignerRole, ): Promise { return this.dataSource.getRepository(ContractSignature).findOne({ where: { contractId, role }, relations: ['signatureFile'], }); } async saveSignature(data: Partial): Promise { const repo = this.dataSource.getRepository(ContractSignature); const existing = await repo.findOne({ where: { contractId: data.contractId!, role: data.role! }, }); if (existing) { Object.assign(existing, data); return repo.save(existing); } return repo.save(repo.create(data)); } // ── Review notes ────────────────────────────────────────────────────────────── async createReviewNote( contractId: string, body: string, noteType: ContractReviewNoteType, authorUserId?: string, authorRole?: string, ): Promise { const repo = this.dataSource.getRepository(ContractReviewNote); return repo.save( repo.create({ contractId, body, noteType, authorUserId: authorUserId ?? null, authorRole: authorRole ?? null, }), ); } async findLatestReviewNote( contractId: string, noteType?: ContractReviewNoteType, ): Promise { const repo = this.dataSource.getRepository(ContractReviewNote); return repo.findOne({ where: noteType ? { contractId, noteType } : { contractId }, order: { createdAt: 'DESC' }, }); } // ── Pre-booking clearance document reviews ──────────────────────────────────── findDocumentReviews( contractId: string, cycleId?: string | null, ): Promise { return this.dataSource.getRepository(ContractDocumentReview).find({ where: cycleId !== undefined ? { contractId, clearanceCycleId: cycleId === null ? IsNull() : cycleId } : { contractId }, order: { createdAt: 'ASC' }, }); } /** * Upsert a document-review row to PENDING for a freshly uploaded file. Resets * any prior QUERIED/APPROVED state so the GL re-reviews the new upload. Keyed * on (contractId, clearanceCycleId, settingCode, fileKey). */ async upsertDocumentReviewPending(input: { contractId: string; clearanceCycleId?: string | null; settingCode: string; fileKey: string; fileRecordId: string; uploadedByRole?: 'CUSTOMER' | 'GL_ET' | 'GL_DJ'; }): Promise { const repo = this.dataSource.getRepository(ContractDocumentReview); const cycleId = input.clearanceCycleId ?? null; const existing = await repo.findOne({ where: { contractId: input.contractId, clearanceCycleId: cycleId === null ? IsNull() : cycleId, settingCode: input.settingCode, fileKey: input.fileKey, }, }); if (existing) { await repo.update(existing.id, { fileRecordId: input.fileRecordId, status: 'PENDING', note: null, reviewedByStaffId: null, reviewedAt: null, }); return; } await repo.save( repo.create({ contractId: input.contractId, clearanceCycleId: cycleId, settingCode: input.settingCode, fileKey: input.fileKey, fileRecordId: input.fileRecordId, status: 'PENDING', uploadedByRole: input.uploadedByRole ?? 'CUSTOMER', }), ); } /** GL marks a document APPROVED or QUERIED (with an optional note). */ async setDocumentReviewStatus(input: { contractId: string; clearanceCycleId?: string | null; settingCode: string; fileKey: string; status: ContractDocReviewStatus; staffId: string; note?: string; }): Promise { const repo = this.dataSource.getRepository(ContractDocumentReview); const cycleId = input.clearanceCycleId ?? null; const existing = await repo.findOne({ where: { contractId: input.contractId, clearanceCycleId: cycleId === null ? IsNull() : cycleId, settingCode: input.settingCode, fileKey: input.fileKey, }, }); const patch = { status: input.status, note: input.note ?? null, reviewedByStaffId: input.staffId, reviewedAt: new Date(), }; if (existing) { await repo.update(existing.id, patch); return; } await repo.save( repo.create({ contractId: input.contractId, clearanceCycleId: cycleId, settingCode: input.settingCode, fileKey: input.fileKey, ...patch, }), ); } // ── Clearance cycles ────────────────────────────────────────────────────────── /** The current (latest, non-completed) clearance cycle for a contract. */ async currentCycle(contractId: string): Promise { return this.dataSource.getRepository(ContractClearanceCycle).findOne({ where: { contractId }, order: { cycleNumber: 'DESC' }, }); } /** Open a new clearance cycle (incrementing cycle_number). */ async openCycle( contractId: string, cycleNumber: number, ): Promise { const repo = this.dataSource.getRepository(ContractClearanceCycle); return repo.save( repo.create({ contractId, cycleNumber, status: 'AWAITING_DOCUMENTS', }), ); } async setCycleStatus( cycleId: string, status: string, fields: Partial< Pick< ContractClearanceCycle, | 'bookingId' | 'clearanceReadyAt' | 'completedAt' | 'dutyRequired' | 'vesselDepartureDate' | 'roAmendmentRequestedAt' | 'roHoldReason' | 'currentPhase' > > = {}, ): Promise { await this.dataSource .getRepository(ContractClearanceCycle) .update(cycleId, { status, ...fields } as never); } async updateCycle( cycleId: string, fields: Partial< Pick< ContractClearanceCycle, | 'dutyRequired' | 'vesselDepartureDate' | 'roAmendmentRequestedAt' | 'roHoldReason' | 'currentPhase' | 'status' | 'preClearanceFinalizedAt' | 'completedAt' > >, ): Promise { await this.dataSource.getRepository(ContractClearanceCycle).update(cycleId, fields as never); } /** Link the GL-created booking to a clearance cycle. */ async linkBooking(cycleId: string, bookingId: string): Promise { await this.dataSource .getRepository(ContractClearanceCycle) .update(cycleId, { bookingId }); } // ── Rate snapshots ────────────────────────────────────────────────────────── async createRateSnapshot( data: Partial, ): Promise { const repo = this.dataSource.getRepository(ContractRateSnapshot); return repo.save(repo.create(data)); } async clearRateSnapshots(contractId: string): Promise { await this.dataSource.getRepository(ContractRateSnapshot).delete({ contractId }); } findRateSnapshots(contractId: string): Promise { return this.dataSource.getRepository(ContractRateSnapshot).find({ where: { contractId }, order: { createdAt: 'ASC' }, }); } }