Files
edr-platform/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts
Marshal 11c7f1bb74 add quantity cap for GENERAL contracts and implement capacity tracking
- Updated ContractClearanceService and ContractsController to remove region parameter from queue method.
- Enhanced ContractsRepository to attach contract files for download and added attachContractFiles method.
- Modified ContractsService to persist cargo scope with quantity cap based on contract kind.
- Introduced quantityCap field in CreateContractCargoScopeDto and ContractCargoScope entity.
- Implemented capacity tracking in the frontend with ContractCapacityNotice component to display remaining bookable quantities.
- Updated various components and services to support new capacity features, including hooks and API calls.
- Added migration to include quantity_cap column in contract_cargo_scope table.
2026-06-28 17:32:18 +00:00

556 lines
18 KiB
TypeScript

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;
createdFrom?: string;
createdTo?: string;
}
@Injectable()
export class ContractsRepository extends BaseRepository<Contract> {
constructor(
@InjectRepository(Contract)
repository: Repository<Contract>,
private readonly dataSource: DataSource,
) {
super(repository);
}
/** Find a contract by its human-readable reference number. */
findByReference(reference: string): Promise<Contract | null> {
return this.repository.findOne({ where: { reference } });
}
/** Count contracts created in a specific year. */
async countByYear(year: number): Promise<number> {
const startDate = new Date(year, 0, 1);
const endDate = new Date(year + 1, 0, 1);
return this.repository
.createQueryBuilder('contract')
.where('contract.created_at >= :startDate', { startDate })
.andWhere('contract.created_at < :endDate', { endDate })
.getCount();
}
/** Find a contract by ID with all child collections, service type, company and files. */
async findByIdWithRelations(id: string): Promise<Contract | null> {
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;
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);
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);
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<void> {
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<string, FileRecord[]>();
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) ?? [];
}
}
async getStatusCounts(): Promise<Record<string, number>> {
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<Contract>,
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.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<ContractApprovalStep | null> {
return this.dataSource.getRepository(ContractApprovalStep).findOne({
where: { contractId, status: 'PENDING' },
order: { stepOrder: 'ASC' },
});
}
async findApprovalStepById(
contractId: string,
stepId: string,
): Promise<ContractApprovalStep | null> {
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<void> {
await this.dataSource.getRepository(ContractApprovalStep).update(stepId, {
status,
actedByStaffId: actorId,
actedAt: new Date(),
note,
});
}
/** Check if all approval steps are approved. */
async allApprovalStepsComplete(contractId: string): Promise<boolean> {
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<ContractApprovalStep>,
): Promise<ContractApprovalStep> {
const repo = this.dataSource.getRepository(ContractApprovalStep);
return repo.save(repo.create(data));
}
// ── Signatures ──────────────────────────────────────────────────────────────
findSignatures(contractId: string): Promise<ContractSignature[]> {
return this.dataSource.getRepository(ContractSignature).find({
where: { contractId },
relations: ['signatureFile'],
order: { signedAt: 'ASC' },
});
}
findSignature(
contractId: string,
role: ContractSignerRole,
): Promise<ContractSignature | null> {
return this.dataSource.getRepository(ContractSignature).findOne({
where: { contractId, role },
relations: ['signatureFile'],
});
}
async saveSignature(data: Partial<ContractSignature>): Promise<ContractSignature> {
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<ContractReviewNote> {
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<ContractReviewNote | null> {
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<ContractDocumentReview[]> {
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<void> {
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<void> {
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<ContractClearanceCycle | null> {
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<ContractClearanceCycle> {
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'>
> = {},
): Promise<void> {
await this.dataSource
.getRepository(ContractClearanceCycle)
.update(cycleId, { status, ...fields } as never);
}
/** Link the GL-created booking to a clearance cycle. */
async linkBooking(cycleId: string, bookingId: string): Promise<void> {
await this.dataSource
.getRepository(ContractClearanceCycle)
.update(cycleId, { bookingId });
}
// ── Rate snapshots ──────────────────────────────────────────────────────────
async createRateSnapshot(
data: Partial<ContractRateSnapshot>,
): Promise<ContractRateSnapshot> {
const repo = this.dataSource.getRepository(ContractRateSnapshot);
return repo.save(repo.create(data));
}
async clearRateSnapshots(contractId: string): Promise<void> {
await this.dataSource.getRepository(ContractRateSnapshot).delete({ contractId });
}
findRateSnapshots(contractId: string): Promise<ContractRateSnapshot[]> {
return this.dataSource.getRepository(ContractRateSnapshot).find({
where: { contractId },
order: { createdAt: 'ASC' },
});
}
}