mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
527 lines
19 KiB
TypeScript
527 lines
19 KiB
TypeScript
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<string> {
|
|
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<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');
|
|
}
|
|
}
|
|
|
|
/** 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<void> {
|
|
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<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);
|
|
|
|
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,
|
|
);
|
|
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<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,
|
|
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;
|
|
}),
|
|
);
|
|
}
|
|
|
|
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`);
|
|
}
|
|
}
|
|
}
|