mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 08:32:54 +00:00
703 lines
25 KiB
TypeScript
703 lines
25 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { InjectDataSource } from '@nestjs/typeorm';
|
|
import { DataSource } from 'typeorm';
|
|
import { insertWithGeneratedReference } from '@edr/api-common';
|
|
|
|
import { YardCountry } from '@edr/types';
|
|
|
|
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
|
import { CompaniesService } from '../companies/companies.service';
|
|
import { ProfileType } from '../companies/entities/company-profile.entity';
|
|
import { CompanyStatus } from '../companies/entities/company.entity';
|
|
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
|
import { Yard } from '../rule-engine/entities/yard.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 seq = await this.contractsRepository.maxReferenceSequence(year);
|
|
return `CTR-${year}-${String(seq + 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');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Every route must match the contract's declared trade direction as derived
|
|
* from the yard countries (IMPORT = DJ→ET, EXPORT = ET→DJ, DOMESTIC =
|
|
* intercity). Intercity is Ethiopian-domestic only: both yards must be in
|
|
* Ethiopia — a Djibouti-internal pair is rejected. Direction mismatches
|
|
* (e.g. an export lane on an import contract) are rejected for every kind.
|
|
*/
|
|
private async assertRoutesMatchDirection(
|
|
tradeDirection: string,
|
|
routes: CreateContractDto['routes'],
|
|
): Promise<void> {
|
|
const yardIds = [
|
|
...new Set(routes.flatMap((r) => [r.originYardId, r.destinationYardId])),
|
|
];
|
|
const yards = await this.dataSource
|
|
.getRepository(Yard)
|
|
.find({ where: yardIds.map((id) => ({ id })) });
|
|
const yardById = new Map(yards.map((y) => [y.id, y]));
|
|
|
|
for (const route of routes) {
|
|
const origin = yardById.get(route.originYardId);
|
|
const destination = yardById.get(route.destinationYardId);
|
|
if (!origin || !destination) {
|
|
throw new BadRequestException('Route references a yard that does not exist');
|
|
}
|
|
const derived = deriveTradeDirection(origin, destination);
|
|
if (derived !== tradeDirection) {
|
|
throw new BadRequestException(
|
|
`Route ${origin.label} → ${destination.label} is ${derived === 'DOMESTIC' ? 'an intercity' : `an ${derived.toLowerCase()}`} lane and does not match the contract's ${tradeDirection === 'DOMESTIC' ? 'intercity' : tradeDirection.toLowerCase()} direction`,
|
|
);
|
|
}
|
|
if (
|
|
derived === 'DOMESTIC' &&
|
|
(origin.country !== YardCountry.ETHIOPIA ||
|
|
destination.country !== YardCountry.ETHIOPIA)
|
|
) {
|
|
throw new BadRequestException(
|
|
`Route ${origin.label} → ${destination.label}: intercity service only runs between Ethiopian yards`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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);
|
|
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
|
|
|
|
// 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);
|
|
// Intercity never crosses a border, so a customs-including service type is
|
|
// a contradiction — the wizard hides them, the API enforces it.
|
|
if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) {
|
|
throw new BadRequestException(
|
|
'Intercity contracts cannot use a service type that includes customs clearing',
|
|
);
|
|
}
|
|
|
|
// An explicit reference is caller-chosen — a collision there is a real
|
|
// conflict and should surface. Auto-generated references retry past a
|
|
// concurrent insert that grabbed the same sequence number.
|
|
const contract = dto.reference
|
|
? await this.insertContract(dto.reference, {
|
|
companyId,
|
|
companyProfileId,
|
|
isGovernment,
|
|
includesCustoms,
|
|
dto,
|
|
})
|
|
: await insertWithGeneratedReference(
|
|
() => this.generateReference(),
|
|
(reference) =>
|
|
this.insertContract(reference, {
|
|
companyId,
|
|
companyProfileId,
|
|
isGovernment,
|
|
includesCustoms,
|
|
dto,
|
|
}),
|
|
);
|
|
|
|
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's onboarding documents (TIN, licenses, IDs) and the
|
|
// profile's 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, companyId ?? null, companyProfileId);
|
|
|
|
return { contract: await this.findById(contract.id), warnings };
|
|
}
|
|
|
|
/** Insert one DRAFT contract row with the given reference (no children). */
|
|
private insertContract(
|
|
reference: string,
|
|
ctx: {
|
|
companyId: string | null | undefined;
|
|
companyProfileId: string | null;
|
|
isGovernment: boolean;
|
|
includesCustoms: boolean;
|
|
dto: CreateContractDto;
|
|
},
|
|
): Promise<Contract> {
|
|
const { companyId, companyProfileId, isGovernment, includesCustoms, dto } = ctx;
|
|
return 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);
|
|
}
|
|
|
|
/**
|
|
* Copy the company's onboarding documents (TIN certificate, commercial /
|
|
* investment license, national ID, passport — resource "companies", coded by
|
|
* the upload-setting fileKey) and the company profile's business-license
|
|
* documents (resource "company_profiles") onto a contract by reference (no
|
|
* byte re-upload). Idempotent: codes already present on the contract — user
|
|
* uploads or an earlier carry — are never duplicated or overwritten, so it is
|
|
* safe to run on every create and update. No-op when there is nothing to copy.
|
|
*/
|
|
private async attachProfileDocuments(
|
|
contractId: string,
|
|
companyId: string | null,
|
|
companyProfileId: string | null,
|
|
): Promise<void> {
|
|
if (!companyId && !companyProfileId) return;
|
|
|
|
const existingCodes = new Set(
|
|
(await this.filesService.findByResource(contractId, 'contracts')).map(
|
|
(r) => r.code,
|
|
),
|
|
);
|
|
const docs: Array<{
|
|
code: string;
|
|
name: string;
|
|
url: string;
|
|
size: number;
|
|
mimeType?: string;
|
|
}> = [];
|
|
|
|
if (companyId) {
|
|
// Company onboarding documents keep their fileKey codes (tin_certificate,
|
|
// commercial_license, …) so the portal can match them against the
|
|
// onboarding upload-setting fields. Re-uploads append rows, so keep only
|
|
// the newest record per code.
|
|
const companyRecords = await this.filesService.findByResource(
|
|
companyId,
|
|
'companies',
|
|
);
|
|
const latestByCode = new Map<string, (typeof companyRecords)[number]>();
|
|
for (const r of companyRecords) {
|
|
const prev = latestByCode.get(r.code);
|
|
if (!prev || r.createdAt > prev.createdAt) latestByCode.set(r.code, r);
|
|
}
|
|
for (const r of latestByCode.values()) {
|
|
if (existingCodes.has(r.code)) continue;
|
|
docs.push({
|
|
code: r.code,
|
|
name: r.name,
|
|
url: r.url,
|
|
size: r.size,
|
|
mimeType: r.mimeType,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (companyProfileId) {
|
|
// Business-license files are FileRecords (resource "company_profiles");
|
|
// carry the live ones by reference. Staged/pending uploads are excluded by
|
|
// code. The `business_license` prefix is preserved so the portal groups
|
|
// them under "Business license" instead of the clearance catch-all — the
|
|
// index suffix keeps multiple licences distinct.
|
|
const records = await this.filesService.findByResource(
|
|
companyProfileId,
|
|
'company_profiles',
|
|
);
|
|
|
|
records
|
|
.filter((r) => r.code === 'business_license')
|
|
.forEach((r, i) => {
|
|
const code = `business_license_${i + 1}`;
|
|
if (existingCodes.has(code)) return;
|
|
docs.push({
|
|
code,
|
|
name: r.name,
|
|
url: r.url,
|
|
size: r.size,
|
|
mimeType: r.mimeType,
|
|
});
|
|
});
|
|
}
|
|
|
|
if (docs.length === 0) return;
|
|
|
|
try {
|
|
await this.filesService.attachExistingFiles(contractId, 'contracts', docs);
|
|
} 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);
|
|
if (dto.routes) {
|
|
await this.assertRoutesMatchDirection(
|
|
dto.tradeDirection ?? existing.tradeDirection,
|
|
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,
|
|
);
|
|
if ((dto.tradeDirection ?? existing.tradeDirection) === 'DOMESTIC' && includesCustoms) {
|
|
throw new BadRequestException(
|
|
'Intercity contracts cannot use a service type that includes customs clearing',
|
|
);
|
|
}
|
|
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);
|
|
}
|
|
|
|
// Re-carry any company/profile document that is still missing from the
|
|
// contract (runs after the upload so fresh replacements keep their slot).
|
|
// Backfills contracts created before profile documents were carried over.
|
|
await this.attachProfileDocuments(
|
|
id,
|
|
existing.companyId ?? null,
|
|
existing.companyProfileId ?? null,
|
|
);
|
|
|
|
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,
|
|
search: filter.search,
|
|
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;
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Surface the staff "request changes" note so the portal can show the
|
|
// customer what to fix. Degrade to null on lookup failure — a missing note
|
|
// must never 500 a contract fetch.
|
|
if (contract.status === 'CHANGES_REQUESTED') {
|
|
try {
|
|
const note = await this.contractsRepository.findLatestReviewNote(
|
|
contract.id,
|
|
'CHANGES_REQUESTED',
|
|
);
|
|
contract.latestChangeRequestNote = note?.body ?? null;
|
|
} catch {
|
|
contract.latestChangeRequestNote = null;
|
|
}
|
|
}
|
|
|
|
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`);
|
|
}
|
|
}
|
|
}
|