mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 00:45:41 +00:00
contrat,booking,global logestic
This commit is contained in:
@@ -0,0 +1,521 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, 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;
|
||||
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('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();
|
||||
|
||||
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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.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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user