mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 04:50:54 +00:00
contrat,booking,global logestic
This commit is contained in:
@@ -24,12 +24,6 @@ import { ContractViewDto } from './dto/contract-view.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
|
||||
|
||||
/**
|
||||
* Default ordering window (months) for a general contract activated on
|
||||
* counter-sign. Mirrors GeneralContractService.DEFAULT_CONTRACT_PERIOD_MONTHS;
|
||||
* defined locally to avoid a circular module dependency on booking-orders.
|
||||
*/
|
||||
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
|
||||
@@ -240,23 +234,9 @@ export class BookingContractService {
|
||||
includesCustoms,
|
||||
);
|
||||
|
||||
const isGeneralContract = booking.bookingType === 'GENERAL_CONTRACT';
|
||||
|
||||
if (role === 'CUSTOMER') {
|
||||
updates.status = 'SIGNED_CUSTOMER';
|
||||
updates.customerSignedAt = now;
|
||||
} else if (isGeneralContract) {
|
||||
// A general contract is NOT paid up front — each drawdown order is priced
|
||||
// and paid on its own. So on counter-sign it becomes ACTIVE directly and
|
||||
// opens its ordering window; orders spawn their own priced child bookings.
|
||||
const expiresAt = new Date(now);
|
||||
expiresAt.setMonth(expiresAt.getMonth() + DEFAULT_CONTRACT_PERIOD_MONTHS);
|
||||
updates.fullyExecutedAt = now;
|
||||
updates.marketingApprovedAt = now;
|
||||
updates.marketingApprovedById = options.signerUserId ?? null;
|
||||
updates.lockedAt = now;
|
||||
updates.status = 'CONTRACT_ACTIVE';
|
||||
updates.expiresAt = expiresAt;
|
||||
} else {
|
||||
updates.fullyExecutedAt = now;
|
||||
updates.marketingApprovedAt = now;
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre
|
||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { isRoadService } from '../booking-orders/road.util';
|
||||
import { isRoadService } from './road.util';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||
|
||||
@@ -34,7 +34,6 @@ export interface BookingListFilterOptions {
|
||||
serviceTypeId?: string;
|
||||
cargoTypeId?: string;
|
||||
freightType?: string;
|
||||
bookingType?: string;
|
||||
tradeDirection?: string;
|
||||
paymentCurrency?: string;
|
||||
paymentStatus?: string;
|
||||
@@ -693,11 +692,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
freightType: options.freightType,
|
||||
});
|
||||
}
|
||||
if (options.bookingType) {
|
||||
qb.andWhere('booking.booking_type = :bookingType', {
|
||||
bookingType: options.bookingType,
|
||||
});
|
||||
}
|
||||
if (options.createdFrom) {
|
||||
qb.andWhere('booking.created_at >= :createdFrom', {
|
||||
createdFrom: options.createdFrom,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
forwardRef,
|
||||
GoneException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
@@ -27,7 +28,6 @@ import { DataSource, In } from 'typeorm';
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { ContractRouteLine } from '../booking-orders/entities/contract-route-line.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
@@ -283,6 +283,15 @@ export class BookingsService {
|
||||
): Promise<{ booking: Booking; warnings: string[] }> {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Contract–booking separation: contracts are no longer created through the
|
||||
// booking endpoint. Legacy GENERAL_CONTRACT creation is deprecated — clients
|
||||
// must use POST /contracts (and create shipments via POST /contracts/:id/bookings).
|
||||
if (dto.bookingType === 'GENERAL_CONTRACT') {
|
||||
throw new GoneException(
|
||||
'General contracts are no longer created here. Use POST /contracts instead.',
|
||||
);
|
||||
}
|
||||
|
||||
// let customerId = dto.customerId;
|
||||
// if (!customerId) {
|
||||
// if (!userId) {
|
||||
@@ -295,7 +304,6 @@ export class BookingsService {
|
||||
// }
|
||||
|
||||
const isGovernment = dto.isGovernment === true;
|
||||
const isGeneralContract = dto.bookingType === 'GENERAL_CONTRACT';
|
||||
|
||||
let companyId: string | null | undefined = dto.companyId;
|
||||
if (isGovernment) {
|
||||
@@ -442,7 +450,6 @@ export class BookingsService {
|
||||
trainId: dto.trainId,
|
||||
trainScheduleId: dto.trainScheduleId ?? null,
|
||||
contractType: dto.contractType,
|
||||
previousContractId: dto.previousContractId,
|
||||
serviceTypeId: dto.serviceTypeId,
|
||||
firstMilePickupAddress: dto.firstMilePickupAddress,
|
||||
firstMilePickupLat: dto.firstMilePickupLat ?? null,
|
||||
@@ -469,7 +476,6 @@ export class BookingsService {
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
pnrCode: dto.pnrCode,
|
||||
financialTerms: dto.financialTerms,
|
||||
bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME',
|
||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||
estimatedShipmentDate: dto.estimatedShipmentDate
|
||||
? new Date(dto.estimatedShipmentDate)
|
||||
@@ -496,27 +502,6 @@ export class BookingsService {
|
||||
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
||||
}
|
||||
|
||||
// Multi-route general contracts: persist the contracted routes (lanes). Routes
|
||||
// carry NO quantity — the contract has a single shared pool (the cargo-step
|
||||
// total / container quantities). Each drawdown order picks one lane for
|
||||
// scheduling + road billing and draws from that shared pool. `quantity` on the
|
||||
// route line is retained for legacy rows but is no longer meaningful (0).
|
||||
if (isGeneralContract && dto.routes?.length) {
|
||||
const routeRepo = this.dataSource.getRepository(ContractRouteLine);
|
||||
await routeRepo.save(
|
||||
dto.routes.map((r) =>
|
||||
routeRepo.create({
|
||||
contractBookingId: booking.id,
|
||||
originYardId: r.originYardId,
|
||||
destinationYardId: r.destinationYardId,
|
||||
containerTypeId: null,
|
||||
quantity: 0,
|
||||
km: r.km ?? null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
await this.filesService.uploadMany(booking.id, 'bookings', files);
|
||||
@@ -808,7 +793,6 @@ export class BookingsService {
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
freightType: filter.freightType,
|
||||
bookingType: filter.bookingType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
paymentStatus: filter.paymentStatus,
|
||||
@@ -1017,7 +1001,6 @@ export class BookingsService {
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
freightType: filter.freightType,
|
||||
bookingType: filter.bookingType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
paymentStatus: filter.paymentStatus,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { BookingContainer } from './booking-container.entity';
|
||||
|
||||
/**
|
||||
* One physical container under a booking_container line — its number, seal, and
|
||||
* per-unit VGM. Entered at booking time (by the customer in Path A or by GL ET
|
||||
* in Path B). See §5.10.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_container_units' })
|
||||
@Index(['bookingContainerId'])
|
||||
export class BookingContainerUnit extends BaseEntity {
|
||||
@Column({ name: 'booking_container_id', type: 'uuid' })
|
||||
bookingContainerId!: string;
|
||||
|
||||
@ManyToOne(() => BookingContainer, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'booking_container_id' })
|
||||
bookingContainer?: BookingContainer;
|
||||
|
||||
@Column({ name: 'container_number', type: 'varchar', length: 64 })
|
||||
containerNumber!: string;
|
||||
|
||||
@Column({ name: 'seal_number', type: 'varchar', length: 64, nullable: true })
|
||||
sealNumber?: string | null;
|
||||
|
||||
@Column({ name: 'vgm_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||
vgmTons!: number;
|
||||
|
||||
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
||||
isHazardous!: boolean;
|
||||
|
||||
@Column({ name: 'is_reefer', type: 'boolean', default: false })
|
||||
isReefer!: boolean;
|
||||
|
||||
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
|
||||
sortOrder!: number;
|
||||
}
|
||||
@@ -25,9 +25,21 @@ export class BookingContainer extends BaseEntity {
|
||||
@Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true })
|
||||
containerNumber?: string | null;
|
||||
|
||||
/** Contract container size this line covers (20ft | 40ft). Null for legacy rows. */
|
||||
@Column({ name: 'container_size', type: 'varchar', length: 10, nullable: true })
|
||||
containerSize?: string | null;
|
||||
|
||||
@Column({ name: 'quantity', type: 'smallint' })
|
||||
quantity!: number;
|
||||
|
||||
/** How many units of this line are hazardous (≤ quantity). */
|
||||
@Column({ name: 'hazardous_quantity', type: 'smallint', default: 0 })
|
||||
hazardousQuantity!: number;
|
||||
|
||||
/** How many units of this line are refrigerated (≤ quantity). */
|
||||
@Column({ name: 'reefer_quantity', type: 'smallint', default: 0 })
|
||||
reeferQuantity!: number;
|
||||
|
||||
@Column({ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||
vgmPerUnitTons!: number;
|
||||
|
||||
|
||||
@@ -144,13 +144,24 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
|
||||
status!: string;
|
||||
|
||||
/**
|
||||
* ONE_TIME for a normal single-shipment booking; GENERAL_CONTRACT for an
|
||||
* umbrella contract that is signed/paid once and then drawn down by many
|
||||
* orders (each order spawns its own ONE_TIME child booking).
|
||||
*/
|
||||
@Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' })
|
||||
bookingType!: string;
|
||||
/** The contract this shipment booking was created under (contract–booking split). */
|
||||
@Column({ name: 'contract_id', type: 'uuid', nullable: true })
|
||||
contractId?: string | null;
|
||||
|
||||
/** The contract route (lane) this shipment uses. */
|
||||
@Column({ name: 'contract_route_id', type: 'uuid', nullable: true })
|
||||
contractRouteId?: string | null;
|
||||
|
||||
/** Denormalized contract kind (ONE_TIME | GENERAL) for the single-active-booking index. */
|
||||
@Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true })
|
||||
contractKind?: string | null;
|
||||
|
||||
/** Who created this booking: CUSTOMER (Path A), GL_ET (Path B), or STAFF. */
|
||||
@Column({ name: 'created_by_role', type: 'varchar', length: 20, default: 'CUSTOMER', nullable: true })
|
||||
createdByRole?: string | null;
|
||||
|
||||
@Column({ name: 'created_by_user_id', type: 'uuid', nullable: true })
|
||||
createdByUserId?: string | null;
|
||||
|
||||
/**
|
||||
* Nullable: general contracts have no shipment date at creation — the date is
|
||||
@@ -220,13 +231,6 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'contract_type', type: 'varchar', length: 20 })
|
||||
contractType!: string;
|
||||
|
||||
@Column({ name: 'previous_contract_id', type: 'uuid', nullable: true })
|
||||
previousContractId?: string | null;
|
||||
|
||||
@ManyToOne(() => Booking, { nullable: true })
|
||||
@JoinColumn({ name: 'previous_contract_id' })
|
||||
previousContract?: Booking | null;
|
||||
|
||||
@Column({ name: 'service_type_id', type: 'uuid' })
|
||||
serviceTypeId!: string;
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { IMPORT_MILESTONES, EXPORT_MILESTONES } from '@edr/types';
|
||||
import type { MilestoneOwnerRegion } from './entities/clearance-milestone.entity';
|
||||
|
||||
/**
|
||||
* Static catalog of clearance milestones per trade direction (doc §11.3, §12.2).
|
||||
* Drives the rows seeded onto a contract clearance cycle (pre-booking) and
|
||||
* booking (post-booking). `phaseBoundaryAfter` marks the last pre-booking
|
||||
* milestone — everything after it tracks on the booking.
|
||||
*/
|
||||
export interface MilestoneDef {
|
||||
code: string;
|
||||
label: string;
|
||||
ownerRegion: MilestoneOwnerRegion;
|
||||
triggeredByDoc: boolean;
|
||||
}
|
||||
|
||||
const IMPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
|
||||
IMPORT_DOCS_UPLOADED: { label: 'Import Documents Uploaded', ownerRegion: 'CUST', triggeredByDoc: false },
|
||||
PENDING_DOCUMENT_REVIEW: { label: 'Pending Document Review', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
DOCUMENTS_APPROVED: { label: 'Documents Approved', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
UNDER_CUSTOMS_CLEARANCE: { label: 'Under Customs Clearance', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
DUTY_TAX_PAID: { label: 'Duty and Tax Paid', ownerRegion: 'CUST', triggeredByDoc: true },
|
||||
DO_COLLECTED: { label: 'DO Collected', ownerRegion: 'DJ', triggeredByDoc: true },
|
||||
WAGON_REQUESTED: { label: 'Wagon Allocation Requested', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled (freight)', ownerRegion: 'CUST', triggeredByDoc: true },
|
||||
WAGON_ALLOCATED: { label: 'Wagon Allocated', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
GATEPASS_GRANTED: { label: 'Gatepass Granted', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||
READY_FOR_LOADING: { label: 'Ready for Loading', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||
LOADED: { label: 'Loaded', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||
DEPARTED_FROM_DJIBOUTI: { label: 'Departed from Djibouti', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||
ARRIVED_ETHIOPIA: { label: 'Arrived at Port in Ethiopia', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
OFFLOADED: { label: 'Offloaded', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
RISK_ASSIGNED: { label: 'Risk Assigned', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
IMPORT_RELEASE_GRANTED: { label: 'Import Release Granted', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
IMPORT_PROCESS_COMPLETED: { label: 'Import Process Completed', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
STORAGE_INVOICE_RAISED: { label: 'Storage Invoice Raised', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
EXIT_NOTE_GENERATED: { label: 'Exit Note Generated', ownerRegion: 'OPS', triggeredByDoc: true },
|
||||
};
|
||||
|
||||
const EXPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
|
||||
EXPORT_DOCS_UPLOADED: { label: 'Export Documents Uploaded', ownerRegion: 'CUST', triggeredByDoc: false },
|
||||
PENDING_DOCUMENT_REVIEW: { label: 'Pending Document Review', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
DOCUMENTS_APPROVED: { label: 'Documents Approved', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
RELEASE_ORDER_SECURED: { label: 'Release Order Secured', ownerRegion: 'DJ', triggeredByDoc: true },
|
||||
UNDER_CUSTOMS_CLEARANCE: { label: 'Under Customs Clearance', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
EXPORT_RELEASED: { label: 'Export Released', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
WAGON_REQUESTED: { label: 'Wagon Requested', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
FREIGHT_PAYMENT_PENDING: { label: 'Pending Payment', ownerRegion: 'CUST', triggeredByDoc: false },
|
||||
FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled', ownerRegion: 'CUST', triggeredByDoc: true },
|
||||
WAGON_ALLOCATED: { label: 'Wagon Allocated', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
CARGO_ARRIVED: { label: 'Cargo Arrived', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
READY_FOR_LOADING: { label: 'Ready for Loading', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
LOADED: { label: 'Loaded', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
DEPARTED_TO_DJIBOUTI: { label: 'Departed to Djibouti', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
ARRIVED_AT_DJIBOUTI: { label: 'Arrived at Djibouti', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||
GATEPASS_GRANTED: { label: 'Gatepass Granted', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||
OFFLOADED: { label: 'Offloaded', ownerRegion: 'DJ', triggeredByDoc: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* Codes BEFORE (and including) this one are pre-booking — they attach to the
|
||||
* contract clearance cycle. From the next code onward, milestones attach to the
|
||||
* booking GL creates. Per doc §11.3 the booking is created right after DO_COLLECTED
|
||||
* (import) / EXPORT_RELEASED (export), i.e. before WAGON_REQUESTED.
|
||||
*/
|
||||
const IMPORT_PRE_BOOKING_LAST = 'DO_COLLECTED';
|
||||
const EXPORT_PRE_BOOKING_LAST = 'EXPORT_RELEASED';
|
||||
|
||||
function buildDefs(
|
||||
codes: readonly string[],
|
||||
defs: Record<string, Omit<MilestoneDef, 'code'>>,
|
||||
): MilestoneDef[] {
|
||||
return codes.map((code) => ({ code, ...defs[code] }));
|
||||
}
|
||||
|
||||
export function milestonesForDirection(tradeDirection: string): MilestoneDef[] {
|
||||
if (tradeDirection === 'IMPORT') return buildDefs(IMPORT_MILESTONES, IMPORT_DEFS);
|
||||
if (tradeDirection === 'EXPORT') return buildDefs(EXPORT_MILESTONES, EXPORT_DEFS);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Split the milestone list into pre-booking (contract) and post-booking (booking). */
|
||||
export function splitMilestones(tradeDirection: string): {
|
||||
preBooking: MilestoneDef[];
|
||||
postBooking: MilestoneDef[];
|
||||
} {
|
||||
const all = milestonesForDirection(tradeDirection);
|
||||
const boundary =
|
||||
tradeDirection === 'IMPORT' ? IMPORT_PRE_BOOKING_LAST : EXPORT_PRE_BOOKING_LAST;
|
||||
const idx = all.findIndex((m) => m.code === boundary);
|
||||
if (idx < 0) return { preBooking: all, postBooking: [] };
|
||||
return { preBooking: all.slice(0, idx + 1), postBooking: all.slice(idx + 1) };
|
||||
}
|
||||
|
||||
/** The handoff milestone that flips primary ownership ET ↔ DJ (doc §11.5/§12.3). */
|
||||
export const HANDOFF_MILESTONES = ['DEPARTED_FROM_DJIBOUTI', 'DEPARTED_TO_DJIBOUTI'];
|
||||
@@ -0,0 +1,140 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import {
|
||||
HANDOFF_MILESTONES,
|
||||
MilestoneDef,
|
||||
milestonesForDirection,
|
||||
splitMilestones,
|
||||
} from './clearance-milestone.catalog';
|
||||
|
||||
/**
|
||||
* Seeds and advances the GL clearance milestones (18–23 per direction). Pre-booking
|
||||
* milestones attach to the contract clearance cycle; post-booking milestones attach
|
||||
* to the booking. See docs/new-doc.md §5.12, §5.16, §11.3, §12.2.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ClearanceMilestoneService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
private get repo() {
|
||||
return this.dataSource.getRepository(ClearanceMilestone);
|
||||
}
|
||||
|
||||
/** Seed the pre-booking milestones onto a contract's current clearance cycle. */
|
||||
async seedPreBookingMilestones(
|
||||
contract: Contract,
|
||||
clearanceCycleId: string,
|
||||
): Promise<void> {
|
||||
const { preBooking } = splitMilestones(contract.tradeDirection);
|
||||
await this.seed(preBooking, {
|
||||
contractId: contract.id,
|
||||
clearanceCycleId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Seed the post-booking milestones onto a freshly created booking. */
|
||||
async seedPostBookingMilestones(
|
||||
bookingId: string,
|
||||
tradeDirection: string,
|
||||
): Promise<void> {
|
||||
const { postBooking } = splitMilestones(tradeDirection);
|
||||
await this.seed(postBooking, { bookingId });
|
||||
}
|
||||
|
||||
private async seed(
|
||||
defs: MilestoneDef[],
|
||||
scope: { contractId?: string; clearanceCycleId?: string; bookingId?: string },
|
||||
): Promise<void> {
|
||||
if (!defs.length) return;
|
||||
const rows = defs.map((def, i) =>
|
||||
this.repo.create({
|
||||
...scope,
|
||||
milestoneCode: def.code,
|
||||
milestoneLabel: def.label,
|
||||
ownerRegion: def.ownerRegion,
|
||||
triggeredByDoc: def.triggeredByDoc,
|
||||
status: 'PENDING',
|
||||
sortOrder: i,
|
||||
}),
|
||||
);
|
||||
await this.repo.save(rows);
|
||||
}
|
||||
|
||||
/** List milestones for a contract cycle or a booking. */
|
||||
async listForContract(contractId: string): Promise<ClearanceMilestone[]> {
|
||||
return this.repo.find({
|
||||
where: { contractId },
|
||||
order: { sortOrder: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async listForBooking(bookingId: string): Promise<ClearanceMilestone[]> {
|
||||
return this.repo.find({
|
||||
where: { bookingId },
|
||||
order: { sortOrder: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark a milestone complete (by code) on a booking. */
|
||||
async completeForBooking(
|
||||
bookingId: string,
|
||||
code: string,
|
||||
userId?: string,
|
||||
note?: string,
|
||||
): Promise<ClearanceMilestone> {
|
||||
const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
|
||||
if (!milestone) {
|
||||
throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`);
|
||||
}
|
||||
if (milestone.status === 'COMPLETED') {
|
||||
throw new BadRequestException(`Milestone ${code} is already completed.`);
|
||||
}
|
||||
milestone.status = 'COMPLETED';
|
||||
milestone.triggeredAt = new Date();
|
||||
milestone.triggeredByUserId = userId ?? null;
|
||||
if (note) milestone.note = note;
|
||||
const saved = await this.repo.save(milestone);
|
||||
|
||||
if (HANDOFF_MILESTONES.includes(code)) {
|
||||
await this.onHandoff(bookingId, code);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** Complete a doc-triggered milestone when its document is uploaded/approved. */
|
||||
async completeByDocTrigger(
|
||||
scope: { bookingId?: string; contractId?: string },
|
||||
code: string,
|
||||
): Promise<void> {
|
||||
const where = scope.bookingId
|
||||
? { bookingId: scope.bookingId, milestoneCode: code }
|
||||
: { contractId: scope.contractId, milestoneCode: code };
|
||||
const milestone = await this.repo.findOne({ where });
|
||||
if (!milestone || milestone.status === 'COMPLETED') return;
|
||||
milestone.status = 'COMPLETED';
|
||||
milestone.triggeredAt = new Date();
|
||||
await this.repo.save(milestone);
|
||||
}
|
||||
|
||||
/**
|
||||
* ET ↔ DJ ownership handoff (doc §11.5/§12.3). On DEPARTED_FROM_DJIBOUTI the
|
||||
* lead transfers to GL Ethiopia + Operations; on DEPARTED_TO_DJIBOUTI to GL
|
||||
* Djibouti. Notifications are handled by the notification layer (out of scope);
|
||||
* here we only record the ownership flip on subsequent pending milestones.
|
||||
*/
|
||||
private async onHandoff(bookingId: string, code: string): Promise<void> {
|
||||
void bookingId;
|
||||
void code;
|
||||
// Ownership region is already encoded per-milestone in the catalog; no
|
||||
// mutation is required. This hook exists for the notification dispatch that
|
||||
// the GL US-09 handoff requires once the notification module lands.
|
||||
}
|
||||
|
||||
/** Catalog passthrough for the frontend timeline (labels + owners). */
|
||||
catalogForDirection(tradeDirection: string): MilestoneDef[] {
|
||||
return milestonesForDirection(tradeDirection);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { hasFreightPermission } from '../../common/freight-permission.util';
|
||||
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractRoute } from './entities/contract-route.entity';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||
|
||||
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
|
||||
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
|
||||
|
||||
export interface CreateBookingUnderContractResult {
|
||||
booking: Booking;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The single create path for shipment bookings under a contract.
|
||||
*
|
||||
* - Path A (transport only): the customer creates the booking once the contract
|
||||
* is FULLY_EXECUTED / CONTRACT_ACTIVE and customs is NOT bundled.
|
||||
* - Path B (customs clearance): only GL Ethiopia creates the booking, once the
|
||||
* contract reaches CLEARANCE_READY_FOR_BOOKING; the customer never enters
|
||||
* shipment data.
|
||||
*
|
||||
* From booking creation onward the existing batch/payment/allocation pipeline
|
||||
* runs unchanged. See docs/new-doc.md §8, §13.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ContractBookingService {
|
||||
constructor(
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly bookingPricingService: BookingPricingService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async createUnderContract(
|
||||
contractId: string,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
user?: { id?: string } | null,
|
||||
actorPermissions?: unknown,
|
||||
): Promise<CreateBookingUnderContractResult> {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
||||
|
||||
// GL Ethiopia is identified by the dedicated contract create-booking permission
|
||||
// (granted to the edr_gl_ethiopia preset).
|
||||
const isGlActor =
|
||||
actorPermissions != null &&
|
||||
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
||||
|
||||
const createdByRole = await this.assertGate(contract, isGlActor);
|
||||
|
||||
// Validity window must still be open.
|
||||
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||
}
|
||||
|
||||
// ONE_TIME: only one active booking at a time (also enforced by partial unique index).
|
||||
if (contract.contractKind === 'ONE_TIME') {
|
||||
const active = await this.countActiveBookings(contractId);
|
||||
if (active > 0) {
|
||||
throw new BadRequestException(
|
||||
'This one-time contract already has an active booking.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
||||
const warnings: string[] = [];
|
||||
|
||||
const reference = await this.generateReference();
|
||||
const freightType = contract.freightType;
|
||||
|
||||
// Denormalize route/direction/freight onto the booking for the scheduling engine.
|
||||
const booking = await this.bookingsRepository.create({
|
||||
reference,
|
||||
companyId: contract.companyId ?? null,
|
||||
companyProfileId: contract.companyProfileId ?? null,
|
||||
isGovernment: contract.isGovernment,
|
||||
governmentInstitution: contract.governmentInstitution ?? null,
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
bookingType: 'ONE_TIME',
|
||||
contractId: contract.id,
|
||||
contractRouteId: route?.id ?? null,
|
||||
contractKind: contract.contractKind,
|
||||
createdByRole,
|
||||
createdByUserId: user?.id ?? null,
|
||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||
serviceTypeId: contract.serviceTypeId,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
contractType: 'NEW',
|
||||
customsClearingEnabled: contract.customsClearingEnabled,
|
||||
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||
equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN',
|
||||
originYardId: route?.originYardId ?? null,
|
||||
destinationYardId: route?.destinationYardId ?? null,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
freightType,
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
isHazardous: contract.isHazardous,
|
||||
isReefer: contract.isReefer,
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
firstMilePickupLat: contract.firstMilePickupLat ?? null,
|
||||
firstMilePickupLng: contract.firstMilePickupLng ?? null,
|
||||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||
lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
|
||||
lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
|
||||
} as never);
|
||||
|
||||
// Persist container lines + per-unit container numbers (container freight only).
|
||||
if (freightType === 'CONTAINER') {
|
||||
await this.persistContainers(booking.id, contract, dto);
|
||||
}
|
||||
|
||||
// Reload with containers to compute the total from contract unit rates × qty.
|
||||
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
if (loaded) {
|
||||
if (freightType === 'CONTAINER') {
|
||||
await this.applyWeightResults(loaded);
|
||||
}
|
||||
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
totalAmount: computed.totalAmount,
|
||||
priorityScore: computed.priorityScore,
|
||||
pricingBreakdown: {
|
||||
lineItems: computed.lineItems,
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
} as never);
|
||||
await this.bookingPricingService.createPricingSnapshots(
|
||||
booking.id,
|
||||
computed.usedRates,
|
||||
computed.appliedModifiers,
|
||||
);
|
||||
warnings.push(...computed.warnings);
|
||||
}
|
||||
|
||||
// Path B side effects: link the clearance cycle, seed post-booking
|
||||
// milestones onto the booking, and advance the contract.
|
||||
if (contract.customsClearingEnabled) {
|
||||
const cycle = await this.contractsRepository.currentCycle(contract.id);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.linkBooking(cycle.id, booking.id);
|
||||
}
|
||||
await this.milestoneService.seedPostBookingMilestones(
|
||||
booking.id,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
await this.contractsRepository.update(contract.id, {
|
||||
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
} as never);
|
||||
}
|
||||
|
||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
return { booking: result ?? booking, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the role to stamp on the booking, or throws if the caller is not
|
||||
* allowed to create one for this contract's execution path.
|
||||
*/
|
||||
private async assertGate(contract: Contract, isGlActor: boolean): Promise<string> {
|
||||
if (contract.customsClearingEnabled) {
|
||||
// Path B — GL Ethiopia only.
|
||||
if (!isGlActor) {
|
||||
throw new ForbiddenException(
|
||||
'Only Global Logistics Ethiopia can create bookings for customs-clearance contracts.',
|
||||
);
|
||||
}
|
||||
if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') {
|
||||
throw new BadRequestException(
|
||||
'Contract clearance is not ready for booking yet.',
|
||||
);
|
||||
}
|
||||
return 'GL_ET';
|
||||
}
|
||||
|
||||
// Path A — customer (or staff) once the contract is executed.
|
||||
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
|
||||
throw new BadRequestException(
|
||||
'Contract must be fully executed before booking a shipment.',
|
||||
);
|
||||
}
|
||||
return isGlActor ? 'STAFF' : 'CUSTOMER';
|
||||
}
|
||||
|
||||
private async countActiveBookings(contractId: string): Promise<number> {
|
||||
return this.dataSource
|
||||
.getRepository(Booking)
|
||||
.createQueryBuilder('b')
|
||||
.where('b.contract_id = :contractId', { contractId })
|
||||
.andWhere('b.status NOT IN (:...terminal)', { terminal: TERMINAL_BOOKING_STATUSES })
|
||||
.getCount();
|
||||
}
|
||||
|
||||
private async resolveRoute(
|
||||
contract: Contract,
|
||||
contractRouteId?: string,
|
||||
): Promise<ContractRoute | null> {
|
||||
const routes = contract.routes ?? [];
|
||||
if (contractRouteId) {
|
||||
const found = routes.find((r) => r.id === contractRouteId);
|
||||
if (!found) {
|
||||
throw new BadRequestException('Selected route is not part of this contract.');
|
||||
}
|
||||
return found;
|
||||
}
|
||||
// ONE_TIME (or single-route GENERAL): auto-select the only route.
|
||||
if (routes.length === 1) return routes[0];
|
||||
if (routes.length === 0) return null;
|
||||
throw new BadRequestException(
|
||||
'contractRouteId is required for multi-route general contracts.',
|
||||
);
|
||||
}
|
||||
|
||||
private resolveCargoTypeId(
|
||||
contract: Contract,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
): string | null {
|
||||
if (contract.freightType === 'BULK') {
|
||||
const bulk = dto.bulkLines?.[0];
|
||||
if (bulk?.cargoTypeId) return bulk.cargoTypeId;
|
||||
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||
return scope?.cargoTypeId ?? null;
|
||||
}
|
||||
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||
return scope?.cargoTypeId ?? null;
|
||||
}
|
||||
|
||||
private resolveBulkTons(dto: CreateBookingUnderContractDto): number {
|
||||
if (!dto.bulkLines?.length) return 0;
|
||||
return dto.bulkLines.reduce(
|
||||
(sum, l) => sum + Number(l.cargoWeightTons ?? l.itemCount ?? 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map each contract-scope container size to a concrete container type and
|
||||
* persist the booking_container line + its per-unit container numbers. Weight
|
||||
* rule results are filled in afterward by {@link applyWeightResults} once all
|
||||
* lines exist (a single rule-engine pass over the booking).
|
||||
*/
|
||||
private async persistContainers(
|
||||
bookingId: string,
|
||||
contract: Contract,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
): Promise<void> {
|
||||
const lines = dto.containers ?? [];
|
||||
if (!lines.length) {
|
||||
throw new BadRequestException('At least one container line is required.');
|
||||
}
|
||||
|
||||
const allowedSizes = new Set(
|
||||
(contract.cargoScope ?? [])
|
||||
.map((c) => c.containerSize)
|
||||
.filter((s): s is string => !!s),
|
||||
);
|
||||
|
||||
const containerRepo = this.dataSource.getRepository(BookingContainer);
|
||||
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
|
||||
|
||||
for (const line of lines) {
|
||||
if (allowedSizes.size && !allowedSizes.has(line.containerSize)) {
|
||||
throw new BadRequestException(
|
||||
`Container size ${line.containerSize} is outside the contract scope.`,
|
||||
);
|
||||
}
|
||||
|
||||
const containerType = await this.resolveContainerTypeForSize(
|
||||
line.containerSize,
|
||||
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
|
||||
);
|
||||
|
||||
const vgmPerUnit = line.units.length
|
||||
? line.units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0) / line.units.length
|
||||
: 0;
|
||||
const totalVgm = line.units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0);
|
||||
|
||||
const containerRow = await containerRepo.save(
|
||||
containerRepo.create({
|
||||
bookingId,
|
||||
containerTypeId: containerType.id,
|
||||
containerSize: line.containerSize,
|
||||
quantity: line.quantity,
|
||||
hazardousQuantity: line.hazardousQuantity ?? 0,
|
||||
reeferQuantity: line.reeferQuantity ?? 0,
|
||||
vgmPerUnitTons: vgmPerUnit,
|
||||
totalVgmTons: totalVgm,
|
||||
wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)),
|
||||
isOverweight: false,
|
||||
overweightExcessTons: null,
|
||||
} as Partial<BookingContainer>),
|
||||
);
|
||||
|
||||
let sortOrder = 0;
|
||||
for (const unit of line.units) {
|
||||
await unitRepo.save(
|
||||
unitRepo.create({
|
||||
bookingContainerId: containerRow.id,
|
||||
containerNumber: unit.containerNumber,
|
||||
sealNumber: unit.sealNumber ?? null,
|
||||
vgmTons: unit.vgmTons,
|
||||
isHazardous: unit.isHazardous ?? false,
|
||||
isReefer: unit.isReefer ?? false,
|
||||
sortOrder: sortOrder++,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the rule engine once over the freshly-created booking and persist the
|
||||
* overweight result per container line (same ordering the engine returns).
|
||||
*/
|
||||
private async applyWeightResults(booking: Booking): Promise<void> {
|
||||
const evalInput = await this.bookingPricingService.buildEvalInputForBooking(booking);
|
||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
const containerRepo = this.dataSource.getRepository(BookingContainer);
|
||||
for (let i = 0; i < containers.length; i++) {
|
||||
const wr = ruleResult.containerWeightResults[i];
|
||||
if (!wr) continue;
|
||||
await containerRepo.update(containers[i].id, {
|
||||
weightLimitRuleId: wr.weightLimitRuleId,
|
||||
isOverweight: wr.isOverweight,
|
||||
overweightExcessTons: wr.overweightExcessTons,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Pick the default container type for a size; prefer reefer when requested. */
|
||||
private async resolveContainerTypeForSize(
|
||||
size: string,
|
||||
preferReefer: boolean,
|
||||
): Promise<ContainerType> {
|
||||
const sizeFt = parseInt(size, 10);
|
||||
const { data } = await this.containerTypesService.findAll({ pageSize: 200 });
|
||||
const types = data.filter((t) => Number(t.sizeFt) === sizeFt && t.isActive !== false);
|
||||
if (!types.length) {
|
||||
throw new BadRequestException(`No container type configured for size ${size}.`);
|
||||
}
|
||||
if (preferReefer) {
|
||||
const reefer = types.find((t) => t.isReefer);
|
||||
if (reefer) return reefer;
|
||||
}
|
||||
return types.find((t) => !t.isReefer) ?? types[0];
|
||||
}
|
||||
|
||||
private async generateReference(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const count = await this.bookingsRepository.countByYear(year);
|
||||
const seq = String(count + 1).padStart(6, '0');
|
||||
return `BK-${year}-${seq}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||
|
||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractsService, PaginatedContracts } from './contracts.service';
|
||||
import { contractClearanceCodes } from './contract-clearance.util';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||
|
||||
export interface ContractClearanceDocument {
|
||||
fileKey: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
uploadedBy: 'customer' | 'gl';
|
||||
settingCode: string;
|
||||
file: { id: string; name: string; url: string } | null;
|
||||
reviewStatus: ContractDocReviewStatus | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface ContractClearanceView {
|
||||
contractId: string;
|
||||
status: string;
|
||||
clearanceStatus: string;
|
||||
cycleNumber: number;
|
||||
includesCustoms: boolean;
|
||||
inputCode: string | null;
|
||||
outputCode: string | null;
|
||||
documents: ContractClearanceDocument[];
|
||||
allApproved: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContractClearanceService {
|
||||
constructor(
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly contractsService: ContractsService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
) {}
|
||||
|
||||
/** The pre-booking clearance document grid for a contract (Path B). */
|
||||
async getClearanceView(contractId: string): Promise<ContractClearanceView> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
const { inputCode, outputCode, includesCustoms } = contractClearanceCodes(contract);
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
|
||||
const files = await this.filesService.findByResource(contractId, 'contracts');
|
||||
const fileByCode = new Map(files.map((f) => [f.code, f]));
|
||||
const reviews = await this.contractsRepository.findDocumentReviews(
|
||||
contractId,
|
||||
cycle?.id ?? null,
|
||||
);
|
||||
const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]));
|
||||
|
||||
const documents: ContractClearanceDocument[] = [];
|
||||
|
||||
const pushSetting = async (code: string | null, uploadedBy: 'customer' | 'gl') => {
|
||||
if (!code) return;
|
||||
let setting;
|
||||
try {
|
||||
setting = await this.fileUploadSettingsService.getByCode(code);
|
||||
} catch {
|
||||
return; // setting not seeded — skip gracefully
|
||||
}
|
||||
for (const field of setting.fields ?? []) {
|
||||
const file = fileByCode.get(field.fileKey) ?? null;
|
||||
const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null;
|
||||
documents.push({
|
||||
fileKey: field.fileKey,
|
||||
label: field.fileLabel,
|
||||
required: field.isRequired,
|
||||
uploadedBy,
|
||||
settingCode: code,
|
||||
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
||||
reviewStatus: review?.status ?? null,
|
||||
note: review?.note ?? null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await pushSetting(inputCode, 'customer');
|
||||
await pushSetting(outputCode, 'gl');
|
||||
|
||||
// Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set.
|
||||
for (const f of files) {
|
||||
if (!f.code?.startsWith('custom_')) continue;
|
||||
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
||||
documents.push({
|
||||
fileKey: f.code,
|
||||
label: f.name,
|
||||
required: false,
|
||||
uploadedBy: 'customer',
|
||||
settingCode: 'custom',
|
||||
file: { id: f.id, name: f.name, url: f.url },
|
||||
reviewStatus: review?.status ?? null,
|
||||
note: review?.note ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const allApproved = await this.isClearanceFullyApproved(contract);
|
||||
|
||||
return {
|
||||
contractId,
|
||||
status: contract.status,
|
||||
clearanceStatus: contract.clearanceStatus,
|
||||
cycleNumber: cycle?.cycleNumber ?? contract.clearanceCycleNumber,
|
||||
includesCustoms,
|
||||
inputCode,
|
||||
outputCode,
|
||||
documents,
|
||||
allApproved,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when every REQUIRED customer-input field has an APPROVED review row in
|
||||
* the current cycle. The 100% gate before clearance can be finalized.
|
||||
*/
|
||||
private async isClearanceFullyApproved(contract: Contract): Promise<boolean> {
|
||||
const { inputCode } = contractClearanceCodes(contract);
|
||||
if (!inputCode) return true;
|
||||
let setting;
|
||||
try {
|
||||
setting = await this.fileUploadSettingsService.getByCode(inputCode);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
||||
if (required.length === 0) return true;
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contract.id);
|
||||
const reviews = await this.contractsRepository.findDocumentReviews(
|
||||
contract.id,
|
||||
cycle?.id ?? null,
|
||||
);
|
||||
return required.every((field) =>
|
||||
reviews.some(
|
||||
(r) =>
|
||||
r.settingCode === inputCode &&
|
||||
r.fileKey === field.fileKey &&
|
||||
r.status === 'APPROVED',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer uploads clearance documents on the contract. When every required
|
||||
* input is present, auto-advance to CLEARANCE_UNDER_REVIEW for GL ET.
|
||||
*/
|
||||
async uploadDocuments(
|
||||
contractId: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (
|
||||
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
|
||||
contract.status !== 'CLEARANCE_UNDER_REVIEW'
|
||||
) {
|
||||
throw new ConflictException(
|
||||
`Cannot upload clearance documents on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
const { inputCode } = contractClearanceCodes(contract);
|
||||
if (!inputCode) {
|
||||
throw new BadRequestException('This contract has no document-clearance step');
|
||||
}
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No documents uploaded');
|
||||
}
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
|
||||
// First submission: every required input field must be present.
|
||||
if (contract.status === 'AWAITING_CLEARANCE_DOCUMENTS') {
|
||||
await this.assertRequiredInputsPresent(contractId, inputCode, files);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const record = await this.filesService.upsertByCode({
|
||||
resourceId: contractId,
|
||||
resource: 'contracts',
|
||||
code: file.fieldname,
|
||||
file,
|
||||
});
|
||||
const settingCode = file.fieldname.startsWith('custom_') ? 'custom' : inputCode;
|
||||
await this.contractsRepository.upsertDocumentReviewPending({
|
||||
contractId,
|
||||
clearanceCycleId: cycle?.id ?? null,
|
||||
settingCode,
|
||||
fileKey: file.fieldname,
|
||||
fileRecordId: record.id,
|
||||
uploadedByRole: 'CUSTOMER',
|
||||
});
|
||||
}
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CLEARANCE_UNDER_REVIEW',
|
||||
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
|
||||
} as never);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW');
|
||||
}
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
private async assertRequiredInputsPresent(
|
||||
contractId: string,
|
||||
inputCode: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<void> {
|
||||
let setting;
|
||||
try {
|
||||
setting = await this.fileUploadSettingsService.getByCode(inputCode);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
||||
if (required.length === 0) return;
|
||||
|
||||
const existing = await this.filesService.findByResource(contractId, 'contracts');
|
||||
const presentKeys = new Set<string>([
|
||||
...existing.map((f) => f.code),
|
||||
...files.map((f) => f.fieldname),
|
||||
]);
|
||||
|
||||
const missing = required.filter((f) => !presentKeys.has(f.fileKey));
|
||||
if (missing.length > 0) {
|
||||
const labels = missing.map((f) => f.fileLabel).join(', ');
|
||||
throw new BadRequestException(
|
||||
`Please upload all required documents before submitting: ${labels}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** GL ET reviews a single document: APPROVED or QUERIED (→ back to upload). */
|
||||
async reviewDocument(
|
||||
contractId: string,
|
||||
fileKey: string,
|
||||
status: 'APPROVED' | 'QUERIED',
|
||||
staffId: string,
|
||||
note?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||
throw new ConflictException(
|
||||
`Cannot review clearance documents on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
if (status === 'QUERIED' && !note?.trim()) {
|
||||
throw new BadRequestException('A note is required when querying a document');
|
||||
}
|
||||
|
||||
const { inputCode, outputCode } = contractClearanceCodes(contract);
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
const reviews = await this.contractsRepository.findDocumentReviews(
|
||||
contractId,
|
||||
cycle?.id ?? null,
|
||||
);
|
||||
const match = reviews.find((r) => r.fileKey === fileKey);
|
||||
const settingCode =
|
||||
match?.settingCode ??
|
||||
(fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));
|
||||
|
||||
await this.contractsRepository.setDocumentReviewStatus({
|
||||
contractId,
|
||||
clearanceCycleId: cycle?.id ?? null,
|
||||
settingCode,
|
||||
fileKey,
|
||||
status,
|
||||
staffId,
|
||||
note,
|
||||
});
|
||||
|
||||
if (status === 'QUERIED') {
|
||||
await this.contractsRepository.createReviewNote(
|
||||
contractId,
|
||||
`Document "${fileKey}" queried: ${note}`,
|
||||
'CHANGES_REQUESTED',
|
||||
staffId,
|
||||
'GL_ET',
|
||||
);
|
||||
// Return the contract to the customer to re-upload the queried document.
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
clearanceStatus: 'AWAITING_DOCUMENTS',
|
||||
} as never);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
|
||||
}
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/** GL uploads customs output documents (IM4/IM5/EX3/etc.) during clearance. */
|
||||
async uploadOutputDocuments(
|
||||
contractId: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||
throw new ConflictException(
|
||||
`Cannot upload output documents on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
const { outputCode } = contractClearanceCodes(contract);
|
||||
if (!outputCode) {
|
||||
throw new BadRequestException('This contract has no customs output documents');
|
||||
}
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No documents uploaded');
|
||||
}
|
||||
for (const file of files) {
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: contractId,
|
||||
resource: 'contracts',
|
||||
code: file.fieldname,
|
||||
file,
|
||||
});
|
||||
}
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL ET finalizes pre-booking clearance: requires every customer document
|
||||
* APPROVED (and required output docs present) → CLEARANCE_READY_FOR_BOOKING.
|
||||
*/
|
||||
async finalize(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||
throw new ConflictException(
|
||||
`Cannot finalize clearance on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
|
||||
const approved = await this.isClearanceFullyApproved(contract);
|
||||
if (!approved) {
|
||||
throw new BadRequestException(
|
||||
'All required documents must be approved before clearance can be finalized',
|
||||
);
|
||||
}
|
||||
|
||||
const { outputCode } = contractClearanceCodes(contract);
|
||||
if (outputCode) {
|
||||
const setting = await this.fileUploadSettingsService.getByCode(outputCode);
|
||||
const files = await this.filesService.findByResource(contractId, 'contracts');
|
||||
const uploaded = new Set(files.map((f) => f.code));
|
||||
const missing = (setting.fields ?? []).filter(
|
||||
(f) => f.isRequired && !uploaded.has(f.fileKey),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Upload all required customs output documents first: ${missing
|
||||
.map((m) => m.fileLabel)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CLEARANCE_READY_FOR_BOOKING',
|
||||
clearanceStatus: 'CLEARANCE_READY_FOR_BOOKING',
|
||||
} as never);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.setCycleStatus(
|
||||
cycle.id,
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
{ clearanceReadyAt: new Date() },
|
||||
);
|
||||
}
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL ET queue: contracts awaiting pre-booking document review. Scoped to
|
||||
* CLEARANCE_UNDER_REVIEW (customs contracts only).
|
||||
*/
|
||||
async queue(
|
||||
filter: FilterContractDto,
|
||||
region?: string,
|
||||
): Promise<PaginatedContracts> {
|
||||
void region; // single ET pre-booking queue today; region reserved for split
|
||||
return this.contractsRepository.findAllPaginated({
|
||||
page: filter.page ?? 1,
|
||||
pageSize: filter.pageSize ?? 100,
|
||||
statuses: ['CLEARANCE_UNDER_REVIEW'],
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Contract } from './entities/contract.entity';
|
||||
|
||||
/**
|
||||
* Resolves which seeded clearance FileUploadSetting applies to a contract during
|
||||
* the CONTRACT pre-booking phase (Path B). Mirrors clearance.util.ts but emits
|
||||
* `contract_clearance_*` codes keyed on (tradeDirection, freightType, customs).
|
||||
*/
|
||||
|
||||
type Op = 'import' | 'export';
|
||||
type Freight = 'container' | 'bulk';
|
||||
|
||||
/** Trade direction → clearance operation. DOMESTIC has no customs clearance. */
|
||||
function operationFor(tradeDirection: string): Op | null {
|
||||
if (tradeDirection === 'IMPORT') return 'import';
|
||||
if (tradeDirection === 'EXPORT') return 'export';
|
||||
return null; // DOMESTIC / intercity — no clearance gate
|
||||
}
|
||||
|
||||
function freightFor(freightType: string): Freight {
|
||||
return freightType === 'BULK' ? 'bulk' : 'container';
|
||||
}
|
||||
|
||||
/** The customer-input clearance setting code, or null when no gate applies. */
|
||||
export function contractClearanceSettingCode(
|
||||
tradeDirection: string,
|
||||
freightType: string,
|
||||
includesCustoms: boolean,
|
||||
): string | null {
|
||||
if (!includesCustoms) return null;
|
||||
const op = operationFor(tradeDirection);
|
||||
if (!op) return null;
|
||||
const freight = freightFor(freightType);
|
||||
return `contract_clearance_${op}_${freight}`;
|
||||
}
|
||||
|
||||
/** The GL-output (customs output) setting code; only container customs sets exist. */
|
||||
export function contractClearanceOutputSettingCode(
|
||||
tradeDirection: string,
|
||||
freightType: string,
|
||||
includesCustoms: boolean,
|
||||
): string | null {
|
||||
if (!includesCustoms) return null;
|
||||
const op = operationFor(tradeDirection);
|
||||
if (!op) return null;
|
||||
if (freightFor(freightType) !== 'container') return null;
|
||||
return `contract_clearance_output_${op}_container`;
|
||||
}
|
||||
|
||||
/** Convenience: resolve both codes for a loaded contract. */
|
||||
export function contractClearanceCodes(contract: Contract): {
|
||||
inputCode: string | null;
|
||||
outputCode: string | null;
|
||||
includesCustoms: boolean;
|
||||
} {
|
||||
const includesCustoms = contract.customsClearingEnabled ?? false;
|
||||
return {
|
||||
inputCode: contractClearanceSettingCode(
|
||||
contract.tradeDirection,
|
||||
contract.freightType,
|
||||
includesCustoms,
|
||||
),
|
||||
outputCode: contractClearanceOutputSettingCode(
|
||||
contract.tradeDirection,
|
||||
contract.freightType,
|
||||
includesCustoms,
|
||||
),
|
||||
includesCustoms,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
|
||||
/** A single unit-rate line at contract phase — NO quantities, NO totals. */
|
||||
export interface ContractUnitRateLineItem {
|
||||
code: string;
|
||||
label: string;
|
||||
unit: 'per_container' | 'per_ton' | 'per_item' | 'per_km' | 'flat';
|
||||
unitPrice: number;
|
||||
containerSize?: string | null;
|
||||
conditionalOn?: string | null;
|
||||
cargoTypeCode?: string | null;
|
||||
}
|
||||
|
||||
/** The contract `pricing_breakdown` shape (doc §9.1). */
|
||||
export interface ContractPricingBreakdown {
|
||||
displayMode: 'UNIT_RATES';
|
||||
currency: string;
|
||||
lineItems: ContractUnitRateLineItem[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
/** Map a rate's storage unit to the contract-display unit. */
|
||||
function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
|
||||
switch (rateUnit) {
|
||||
case 'PER_TON':
|
||||
return 'per_ton';
|
||||
case 'PER_KM':
|
||||
return 'per_km';
|
||||
case 'PER_CONTAINER':
|
||||
case 'PER_WAGON':
|
||||
return 'per_container';
|
||||
default:
|
||||
return 'flat';
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContractPricingService {
|
||||
constructor(
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly ratesService: RatesService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly exchangeService: ExchangeService,
|
||||
) {}
|
||||
|
||||
/** Base rail rate type for the contract's direction + freight. */
|
||||
private baseRateType(contract: Contract): string {
|
||||
const isBulk = contract.freightType === 'BULK';
|
||||
if (contract.tradeDirection === 'IMPORT') {
|
||||
return isBulk ? 'BULK_IMPORT' : 'CONTAINER_IMPORT';
|
||||
}
|
||||
if (contract.tradeDirection === 'EXPORT') {
|
||||
return isBulk ? 'BULK_EXPORT' : 'CONTAINER_EXPORT';
|
||||
}
|
||||
return isBulk ? 'INTERCITY_BULK' : 'INTERCITY_CONTAINER';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the unit-rate breakdown from live rates. Emits per-unit prices only
|
||||
* (one per container size, conditional hazard/reefer surcharges, and bulk
|
||||
* commodity rate) — NO totals or quantities (doc §9.1).
|
||||
*/
|
||||
async buildBreakdown(contract: Contract): Promise<ContractPricingBreakdown> {
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const currency = contract.paymentCurrency;
|
||||
const isEtb = currency === 'ETB';
|
||||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd);
|
||||
|
||||
const lineItems: ContractUnitRateLineItem[] = [];
|
||||
const baseType = this.baseRateType(contract);
|
||||
|
||||
if (contract.freightType === 'CONTAINER') {
|
||||
const sizes = (contract.cargoScope ?? [])
|
||||
.map((c) => c.containerSize)
|
||||
.filter((s): s is string => !!s);
|
||||
const { data: containerTypes } = await this.containerTypesService.findAll({
|
||||
isActive: true,
|
||||
pageSize: 500,
|
||||
});
|
||||
for (const size of sizes) {
|
||||
const sizeFt = size === '40ft' ? 40 : 20;
|
||||
const matchedTypes = containerTypes.filter((ct) => ct.sizeFt === sizeFt);
|
||||
const matchedIds = new Set(matchedTypes.map((ct) => ct.id));
|
||||
const rate =
|
||||
liveRates.find(
|
||||
(r) =>
|
||||
r.rateType === baseType &&
|
||||
r.currency === 'USD' &&
|
||||
r.containerTypeId &&
|
||||
matchedIds.has(r.containerTypeId),
|
||||
) ??
|
||||
liveRates.find(
|
||||
(r) => r.rateType === baseType && r.currency === 'USD' && !r.containerTypeId,
|
||||
);
|
||||
if (!rate) continue;
|
||||
lineItems.push({
|
||||
code: `CONTAINER_${size.toUpperCase()}`,
|
||||
label: `${size} container`,
|
||||
unit: toContractUnit(rate.rateUnit),
|
||||
unitPrice: convert(Number(rate.rateValue)),
|
||||
containerSize: size,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const bulkRate =
|
||||
liveRates.find((r) => r.rateType === baseType && r.currency === 'USD') ?? null;
|
||||
const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||
if (bulkRate) {
|
||||
lineItems.push({
|
||||
code: 'BULK_FREIGHT',
|
||||
label: cargoScope?.cargoType?.cargoTypeName ?? 'Bulk cargo',
|
||||
unit: toContractUnit(bulkRate.rateUnit),
|
||||
unitPrice: convert(Number(bulkRate.rateValue)),
|
||||
cargoTypeCode: cargoScope?.cargoType?.code ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Conditional surcharges — shown only when the contract toggles them on.
|
||||
if (contract.isHazardous) {
|
||||
const hazard = liveRates.find(
|
||||
(r) => r.rateType === 'HAZARD_SURCHARGE' && r.currency === 'USD',
|
||||
);
|
||||
if (hazard) {
|
||||
lineItems.push({
|
||||
code: 'HAZARD_SURCHARGE',
|
||||
label: 'Hazardous surcharge',
|
||||
unit: toContractUnit(hazard.rateUnit),
|
||||
unitPrice: convert(Number(hazard.rateValue)),
|
||||
conditionalOn: 'is_hazardous',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (contract.isReefer) {
|
||||
const reefer = liveRates.find(
|
||||
(r) => r.rateType === 'REEFER_SURCHARGE' && r.currency === 'USD',
|
||||
);
|
||||
if (reefer) {
|
||||
lineItems.push({
|
||||
code: 'REEFER_SURCHARGE',
|
||||
label: 'Reefer surcharge',
|
||||
unit: toContractUnit(reefer.rateUnit),
|
||||
unitPrice: convert(Number(reefer.rateValue)),
|
||||
conditionalOn: 'is_reefer',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
displayMode: 'UNIT_RATES',
|
||||
currency,
|
||||
lineItems,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Generate (and persist) the unit-rate breakdown for a contract. */
|
||||
async generatePrice(contractId: string): Promise<ContractPricingBreakdown> {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||
if (!contract) {
|
||||
throw new Error(`Contract ${contractId} not found`);
|
||||
}
|
||||
const breakdown = await this.buildBreakdown(contract);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
pricingBreakdown: breakdown as never,
|
||||
pricingDisplayMode: 'UNIT_RATES',
|
||||
} as never);
|
||||
return breakdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze the contract's unit rates into contract_rate_snapshots (one row per
|
||||
* rate line) at submit time. The booking later computes totals from these.
|
||||
*/
|
||||
async freezeRateSnapshots(contractId: string): Promise<void> {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||
if (!contract) return;
|
||||
const breakdown =
|
||||
(contract.pricingBreakdown as ContractPricingBreakdown | null) ??
|
||||
(await this.buildBreakdown(contract));
|
||||
|
||||
await this.contractsRepository.clearRateSnapshots(contractId);
|
||||
for (const line of breakdown.lineItems) {
|
||||
await this.contractsRepository.createRateSnapshot({
|
||||
contractId,
|
||||
rateCode: line.code,
|
||||
description: line.label,
|
||||
unitPrice: line.unitPrice,
|
||||
unitOfMeasure: line.unit,
|
||||
currency: breakdown.currency,
|
||||
containerSize: line.containerSize ?? null,
|
||||
isSurcharge: !!line.conditionalOn,
|
||||
conditionalOn: line.conditionalOn ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Readable } from 'stream';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
|
||||
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { ContractViewModel } from '../../contracts/contract-view-model.builder';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
|
||||
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractsService } from './contracts.service';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractSignerRole } from './entities/contract-signature.entity';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
|
||||
/** Status-machine guard mirroring booking-status.util. */
|
||||
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
||||
if (!allowed.includes(contract.status)) {
|
||||
throw new ConflictException(
|
||||
`Cannot perform this action on status "${contract.status}". Allowed: ${allowed.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContractTransitionService {
|
||||
private readonly logger = new Logger(ContractTransitionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly contractsService: ContractsService,
|
||||
private readonly pricingService: ContractPricingService,
|
||||
private readonly approvalRulesService: ApprovalRulesService,
|
||||
private readonly cargoTypesService: CargoTypesService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly signaturesService: SignaturesService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly documentViewModelBuilder: ContractDocumentViewModelBuilder,
|
||||
private readonly renderer: ContractRendererService,
|
||||
private readonly pdfService: ContractPdfService,
|
||||
private readonly minioService: MinioService,
|
||||
) {}
|
||||
|
||||
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||
async submit(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['DRAFT', 'CHANGES_REQUESTED']);
|
||||
|
||||
await this.pricingService.generatePrice(contractId);
|
||||
await this.pricingService.freezeRateSnapshots(contractId);
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SUBMITTED',
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/** Confirm a price change before submit (mirrors booking confirm-submit). */
|
||||
async confirmSubmit(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['PRICE_CHANGED_PENDING_CONFIRM']);
|
||||
|
||||
await this.pricingService.generatePrice(contractId);
|
||||
await this.pricingService.freezeRateSnapshots(contractId);
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SUBMITTED',
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Line staff accepts intake: set the validity window from validityDays and
|
||||
* instantiate the approval steps from approval_rules → PENDING_APPROVAL.
|
||||
*/
|
||||
async staffAccept(
|
||||
contractId: string,
|
||||
actorId: string,
|
||||
validityDays: number,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['SUBMITTED']);
|
||||
|
||||
if (!Number.isInteger(validityDays) || validityDays < 1) {
|
||||
throw new BadRequestException(
|
||||
'A contract validity (in days) is required to accept this contract.',
|
||||
);
|
||||
}
|
||||
|
||||
const validFrom = new Date();
|
||||
const validUntil = new Date(validFrom);
|
||||
validUntil.setDate(validUntil.getDate() + validityDays);
|
||||
|
||||
await this.instantiateApprovalSteps(contract);
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'PENDING_APPROVAL',
|
||||
approvedByStaffId: actorId,
|
||||
approvedByStaffAt: validFrom,
|
||||
contractValidityDays: validityDays,
|
||||
contractValidFrom: validFrom,
|
||||
contractValidUntil: validUntil,
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build contract approval steps from the system approval_rules chain (US-06:
|
||||
* container → line staff + director; bulk → directors + CEO). Mirrors the
|
||||
* booking transition's instantiateApprovalSteps but writes contract steps.
|
||||
*/
|
||||
private async instantiateApprovalSteps(contract: Contract): Promise<void> {
|
||||
if ((contract.approvalSteps?.length ?? 0) > 0) return;
|
||||
|
||||
const cargoTypeId =
|
||||
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId ?? null;
|
||||
|
||||
// US-06 routing: bulk always needs director approval; container needs it only
|
||||
// when its cargo type flags it. Resolve the chain via the same approval_rules
|
||||
// source of truth the booking flow uses (no booking row is created here).
|
||||
let requiresDirectorApproval = contract.freightType === 'BULK';
|
||||
if (cargoTypeId) {
|
||||
const cargoType = await this.cargoTypesService.findById(cargoTypeId);
|
||||
if (cargoType?.requiresDirectorApproval) {
|
||||
requiresDirectorApproval = true;
|
||||
}
|
||||
}
|
||||
|
||||
const chain = await this.approvalRulesService.findChain(requiresDirectorApproval);
|
||||
if (chain.length === 0) {
|
||||
throw new BadRequestException(
|
||||
`Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const rule of chain) {
|
||||
await this.contractsRepository.createApprovalStep({
|
||||
contractId: contract.id,
|
||||
stepOrder: rule.stepOrder,
|
||||
requiredRole: rule.requiredRole,
|
||||
blocksRole: rule.blocksRole ?? null,
|
||||
status: 'PENDING',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async requestChanges(
|
||||
contractId: string,
|
||||
note: string,
|
||||
actorId: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['SUBMITTED']);
|
||||
|
||||
await this.contractsRepository.createReviewNote(
|
||||
contractId,
|
||||
note,
|
||||
'CHANGES_REQUESTED',
|
||||
actorId,
|
||||
'STAFF',
|
||||
);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CHANGES_REQUESTED',
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
async reject(contractId: string, reason: string, actorId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']);
|
||||
|
||||
await this.contractsRepository.createReviewNote(
|
||||
contractId,
|
||||
reason,
|
||||
'REJECTION',
|
||||
actorId,
|
||||
'STAFF',
|
||||
);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'REJECTED',
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/** Approve one approval step in sequence; → APPROVED when all complete. */
|
||||
async approveStep(
|
||||
contractId: string,
|
||||
stepId: string,
|
||||
actorId: string,
|
||||
requiredRole: string,
|
||||
authUser?: TCurrentUser,
|
||||
): Promise<Contract> {
|
||||
if (authUser) {
|
||||
assertCanApproveBookingStep(authUser, requiredRole);
|
||||
}
|
||||
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
|
||||
|
||||
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
|
||||
if (!step || step.status !== 'PENDING') {
|
||||
throw new BadRequestException('Approval step not found or already actioned');
|
||||
}
|
||||
|
||||
const next = await this.contractsRepository.findNextPendingApprovalStep(contractId);
|
||||
if (!next || next.id !== step.id) {
|
||||
throw new BadRequestException('Approval steps must be completed in order');
|
||||
}
|
||||
if (step.requiredRole !== requiredRole) {
|
||||
throw new BadRequestException(
|
||||
`Step requires role ${step.requiredRole}, not ${requiredRole}`,
|
||||
);
|
||||
}
|
||||
if (step.blocksRole && step.blocksRole === requiredRole) {
|
||||
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
|
||||
}
|
||||
|
||||
await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
const now = new Date();
|
||||
if (requiredRole === 'LINE_STAFF') {
|
||||
updates.status = 'APPROVED_PENDING_SIGNATURE';
|
||||
updates.approvedByStaffId = actorId;
|
||||
updates.approvedByStaffAt = now;
|
||||
} else if (requiredRole === 'DIRECTOR') {
|
||||
updates.signedByDirectorId = actorId;
|
||||
updates.signedByDirectorAt = now;
|
||||
} else if (requiredRole === 'CEO') {
|
||||
updates.signedByCeoId = actorId;
|
||||
updates.signedByCeoAt = now;
|
||||
}
|
||||
|
||||
const allDone = await this.contractsRepository.allApprovalStepsComplete(contractId);
|
||||
if (allDone) {
|
||||
updates.status = 'APPROVED';
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await this.contractsRepository.update(contractId, updates as never);
|
||||
}
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the contract PDF from the Contract aggregate, store it via FilesService,
|
||||
* stamp the template key, and move to CONTRACT_READY. PDF rendering (Puppeteer/
|
||||
* Chromium) is best-effort and must NOT block the contract from becoming ready —
|
||||
* the document is (re)rendered lazily on view/download once Chromium is available.
|
||||
*/
|
||||
async generateContract(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']);
|
||||
|
||||
const { view } = await this.documentViewModelBuilder.build(contractId);
|
||||
|
||||
try {
|
||||
await this.upsertContractPdf(contractId, contract.reference, view);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CONTRACT_READY',
|
||||
contractTemplateKey: view.templateKey,
|
||||
contractGeneratedAt: new Date(),
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the contract PDF view-model and rendered HTML for portal/backoffice
|
||||
* signing. Sourced entirely from the Contract aggregate (unit-rate schedule, no
|
||||
* totals). Returns the view-model, the rendered HTML and the signature rows.
|
||||
*/
|
||||
async getContractDocumentView(contractId: string): Promise<{
|
||||
view: ContractViewModel;
|
||||
html: string;
|
||||
signatures: ContractViewModel['signatures'];
|
||||
}> {
|
||||
const { view } = await this.documentViewModelBuilder.build(contractId);
|
||||
await this.inlineSignatureImages(view.signatures);
|
||||
const html = this.renderer.render(view);
|
||||
return { view, html, signatures: view.signatures };
|
||||
}
|
||||
|
||||
/** Render the contract PDF and upsert it as the `contract` file on the contract. */
|
||||
private async upsertContractPdf(
|
||||
contractId: string,
|
||||
reference: string,
|
||||
view: ContractViewModel,
|
||||
): Promise<FileRecord> {
|
||||
await this.inlineSignatureImages(view.signatures);
|
||||
const html = this.renderer.render(view);
|
||||
const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
|
||||
const file: Express.Multer.File = {
|
||||
fieldname: 'contract',
|
||||
originalname: `contract-${reference}.pdf`,
|
||||
encoding: '7bit',
|
||||
mimetype: 'application/pdf',
|
||||
size: pdfBuffer.length,
|
||||
buffer: pdfBuffer,
|
||||
stream: Readable.from(pdfBuffer),
|
||||
destination: '',
|
||||
filename: '',
|
||||
path: '',
|
||||
};
|
||||
|
||||
return this.filesService.upsertByCode({
|
||||
resourceId: contractId,
|
||||
resource: 'contracts',
|
||||
code: 'contract',
|
||||
file,
|
||||
});
|
||||
}
|
||||
|
||||
/** Replace MinIO signature URLs with inline data URIs so they render in the PDF. */
|
||||
private async inlineSignatureImages(
|
||||
signatures: Array<{ signatureImageUrl?: string | null }>,
|
||||
): Promise<void> {
|
||||
for (const sig of signatures) {
|
||||
if (!sig.signatureImageUrl) continue;
|
||||
try {
|
||||
if (sig.signatureImageUrl.startsWith('data:')) continue;
|
||||
const objectName = this.minioService.getObjectNameFromUrl(
|
||||
sig.signatureImageUrl,
|
||||
);
|
||||
const stream = await this.minioService.getFileStream(objectName);
|
||||
const buffer = await this.streamToBuffer(stream);
|
||||
sig.signatureImageUrl = `data:image/png;base64,${buffer.toString('base64')}`;
|
||||
} catch {
|
||||
/* keep original url */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
stream.on('error', reject);
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply a digital signature row (mirrors booking-contract.service). */
|
||||
private async applySignature(
|
||||
contract: Contract,
|
||||
dto: SignContractDto,
|
||||
options: { signerUserId?: string },
|
||||
): Promise<void> {
|
||||
const role = dto.role as ContractSignerRole;
|
||||
const raw = dto.signatureImageBase64.includes(',')
|
||||
? dto.signatureImageBase64.split(',')[1]!
|
||||
: dto.signatureImageBase64;
|
||||
const buffer = Buffer.from(raw, 'base64');
|
||||
const sigFile: Express.Multer.File = {
|
||||
fieldname: `signature_${role.toLowerCase()}`,
|
||||
originalname: `signature-${role.toLowerCase()}-${contract.reference}.png`,
|
||||
encoding: '7bit',
|
||||
mimetype: 'image/png',
|
||||
size: buffer.length,
|
||||
buffer,
|
||||
stream: Readable.from(buffer),
|
||||
destination: '',
|
||||
filename: '',
|
||||
path: '',
|
||||
};
|
||||
|
||||
const fileRecord = await this.filesService.upsertByCode({
|
||||
resourceId: contract.id,
|
||||
resource: 'contracts',
|
||||
code: `signature_${role.toLowerCase()}`,
|
||||
file: sigFile,
|
||||
});
|
||||
|
||||
await this.contractsRepository.saveSignature({
|
||||
contractId: contract.id,
|
||||
role,
|
||||
signerDisplayName: dto.signerDisplayName,
|
||||
signedAt: new Date(),
|
||||
signatureFileId: fileRecord.id,
|
||||
consentText: dto.consentText ?? null,
|
||||
});
|
||||
|
||||
if (options.signerUserId) {
|
||||
try {
|
||||
await this.signaturesService.upsertForUser({
|
||||
userId: options.signerUserId,
|
||||
signerDisplayName: dto.signerDisplayName,
|
||||
signatureImageBase64: dto.signatureImageBase64,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Could not save reusable signature for user ${options.signerUserId}: ${err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
|
||||
async sign(
|
||||
contractId: string,
|
||||
dto: SignContractDto,
|
||||
options: { signerUserId?: string },
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
|
||||
if (dto.role === 'CUSTOMER') {
|
||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||
const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER');
|
||||
if (existing) {
|
||||
throw new BadRequestException('Customer has already signed this contract');
|
||||
}
|
||||
await this.applySignature(contract, dto, options);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SIGNED_CUSTOMER',
|
||||
customerSignedAt: new Date(),
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
return this.counterSign(contractId, dto, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff/Director/CEO counter-sign → branch on customs:
|
||||
* - customs: AWAITING_CLEARANCE_DOCUMENTS + clearance gate opened (Path B)
|
||||
* - transport: FULLY_EXECUTED (ONE_TIME) / CONTRACT_ACTIVE (GENERAL)
|
||||
*/
|
||||
async counterSign(
|
||||
contractId: string,
|
||||
dto: SignContractDto,
|
||||
options: { signerUserId?: string },
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['SIGNED_CUSTOMER']);
|
||||
|
||||
await this.applySignature(contract, dto, options);
|
||||
|
||||
const now = new Date();
|
||||
const updates: Record<string, unknown> = {
|
||||
fullyExecutedAt: now,
|
||||
lockedAt: now,
|
||||
};
|
||||
|
||||
if (contract.customsClearingEnabled) {
|
||||
// Path B — open a clearance cycle, seed the pre-booking milestones, and
|
||||
// route the customer to the document upload.
|
||||
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
|
||||
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
|
||||
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
|
||||
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||
updates.clearanceCycleNumber = cycleNumber;
|
||||
} else {
|
||||
// Path A — transport only; ready for the customer to book.
|
||||
updates.status =
|
||||
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
|
||||
updates.clearanceStatus = 'NOT_APPLICABLE';
|
||||
}
|
||||
|
||||
await this.contractsRepository.update(contractId, updates as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */
|
||||
async renew(contractId: string, userId?: string): Promise<Contract> {
|
||||
const source = await this.contractsService.findById(contractId);
|
||||
|
||||
const reference = await this.generateRenewalReference();
|
||||
const renewal = await this.contractsRepository.create({
|
||||
reference,
|
||||
companyId: source.companyId,
|
||||
companyProfileId: source.companyProfileId,
|
||||
isGovernment: source.isGovernment,
|
||||
governmentInstitution: source.governmentInstitution,
|
||||
contractKind: source.contractKind,
|
||||
renewalOfId: source.id,
|
||||
tradeDirection: source.tradeDirection,
|
||||
freightType: source.freightType,
|
||||
serviceTypeId: source.serviceTypeId,
|
||||
paymentCurrency: source.paymentCurrency,
|
||||
customsClearingEnabled: source.customsClearingEnabled,
|
||||
customsClearingAgent: source.customsClearingAgent,
|
||||
equipmentReturn: source.equipmentReturn,
|
||||
firstMilePickupAddress: source.firstMilePickupAddress,
|
||||
firstMilePickupLat: source.firstMilePickupLat,
|
||||
firstMilePickupLng: source.firstMilePickupLng,
|
||||
lastMileDeliveryAddress: source.lastMileDeliveryAddress,
|
||||
lastMileDeliveryLat: source.lastMileDeliveryLat,
|
||||
lastMileDeliveryLng: source.lastMileDeliveryLng,
|
||||
isHazardous: source.isHazardous,
|
||||
isReefer: source.isReefer,
|
||||
contractType: source.contractType,
|
||||
versionNumber: (source.versionNumber ?? 1) + 1,
|
||||
status: 'RENEWAL_DRAFT',
|
||||
clearanceStatus: 'NOT_APPLICABLE',
|
||||
clearanceCycleNumber: 0,
|
||||
} as never);
|
||||
|
||||
void userId;
|
||||
return this.contractsService.findById(renewal.id);
|
||||
}
|
||||
|
||||
private async generateRenewalReference(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const count = await this.contractsRepository.countByYear(year);
|
||||
return `CTR-${year}-${String(count + 1).padStart(5, '0')}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UnauthorizedException,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import {
|
||||
assertFreightPermission,
|
||||
hasFreightPermission,
|
||||
} from '../../common/freight-permission.util';
|
||||
import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from '../../common/resolve-auth-user-id';
|
||||
|
||||
import { ContractsService } from './contracts.service';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
import { ContractClearanceService } from './contract-clearance.service';
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
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 { AcceptContractDto } from './dto/accept-contract.dto';
|
||||
import {
|
||||
ApproveStepDto,
|
||||
RejectContractDto,
|
||||
RequestChangesDto,
|
||||
} from './dto/approve-step.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
||||
import { RenewContractDto } from './dto/renew-contract.dto';
|
||||
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||
|
||||
@ApiTags('contracts')
|
||||
@Controller('contracts')
|
||||
@ApiBearerAuth()
|
||||
export class ContractsController {
|
||||
constructor(
|
||||
private readonly contractsService: ContractsService,
|
||||
private readonly pricingService: ContractPricingService,
|
||||
private readonly transitionService: ContractTransitionService,
|
||||
private readonly clearanceService: ContractClearanceService,
|
||||
private readonly contractBookingService: ContractBookingService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Create a new contract (DRAFT) with routes + cargo scope' })
|
||||
@ApiBody({ type: CreateContractDto })
|
||||
async create(
|
||||
@Body() dto: CreateContractDto,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
if (dto.isGovernment) {
|
||||
assertFreightPermission(user, FREIGHT_PERMS.contracts.staffAccept);
|
||||
}
|
||||
return this.contractsService.create(dto, files ?? [], user?.id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List contracts (paginated)' })
|
||||
async findAll(
|
||||
@Query() filter: FilterContractDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// Staff see every contract; customers are force-scoped to their own company.
|
||||
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
return this.contractsService.findAll(filter);
|
||||
}
|
||||
const userId = user?.id;
|
||||
if (!userId) throw new UnauthorizedException('Authentication required');
|
||||
const companyId = await this.contractsService.resolveCustomerCompanyId(userId);
|
||||
if (!companyId) {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
return this.contractsService.findAll(filter, companyId);
|
||||
}
|
||||
|
||||
@Get('my')
|
||||
@ApiOperation({ summary: "List the current customer's contracts" })
|
||||
async findMy(
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Query() filter: FilterContractDto,
|
||||
) {
|
||||
const userId = resolveAuthUserId(user);
|
||||
const companyId = await this.contractsService.resolveCustomerCompanyId(userId);
|
||||
if (!companyId) {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
return this.contractsService.findAll(filter, companyId);
|
||||
}
|
||||
|
||||
@Get('list-summary')
|
||||
@ApiOperation({ summary: 'Contract list metrics and status counts (backoffice)' })
|
||||
@ApiOkResponse({ type: ContractListSummaryDto })
|
||||
findListSummary(@Query() filter: FilterContractDto) {
|
||||
return this.contractsService.getListSummary(filter);
|
||||
}
|
||||
|
||||
@Get('clearance/queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
|
||||
@ApiOperation({ summary: 'GL ET queue: contracts awaiting pre-booking document review' })
|
||||
clearanceQueue(
|
||||
@Query() filter: FilterContractDto,
|
||||
@Query('region') region?: string,
|
||||
) {
|
||||
return this.clearanceService.queue(filter, region);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get contract by ID (routes, cargo scope, unit rates)' })
|
||||
async findOne(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments)
|
||||
) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
return contract;
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary: 'Update contract',
|
||||
description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.',
|
||||
})
|
||||
@ApiBody({ type: UpdateContractDto })
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateContractDto,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
return this.contractsService.update(id, dto, files ?? []);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Soft-delete DRAFT contract' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.contractsService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/documents')
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload intake documents for a contract (DRAFT only)' })
|
||||
uploadDocuments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
return this.contractsService.uploadDocuments(id, files ?? []);
|
||||
}
|
||||
|
||||
@Post(':id/generate-price')
|
||||
@ApiOperation({ summary: 'Generate unit-rate breakdown (no totals at contract phase)' })
|
||||
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.pricingService.generatePrice(id);
|
||||
}
|
||||
|
||||
@Post(':id/submit')
|
||||
@ApiOperation({ summary: 'Customer submit contract (freezes contract_rate_snapshots)' })
|
||||
submit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.submit(id);
|
||||
}
|
||||
|
||||
@Post(':id/confirm-submit')
|
||||
@ApiOperation({ summary: 'Confirm submit after a price change' })
|
||||
confirmSubmit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.confirmSubmit(id);
|
||||
}
|
||||
|
||||
@Post(':id/staff/accept')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
||||
@ApiOperation({ summary: 'Staff accept → set validity window + start approval chain' })
|
||||
staffAccept(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AcceptContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.transitionService.staffAccept(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
dto.validityDays,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/staff/request-changes')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.requestChanges)
|
||||
@ApiOperation({ summary: 'Staff return contract for customer updates' })
|
||||
requestChanges(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RequestChangesDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.transitionService.requestChanges(
|
||||
id,
|
||||
dto.note,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/staff/reject')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.reject)
|
||||
@ApiOperation({ summary: 'Staff reject contract' })
|
||||
reject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.transitionService.reject(id, dto.reason, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/approve')
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.contracts.approveLineStaff,
|
||||
FREIGHT_PERMS.contracts.approveDirector,
|
||||
FREIGHT_PERMS.contracts.approveCeo,
|
||||
])
|
||||
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
||||
approveStep(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('stepId', ParseUUIDPipe) stepId: string,
|
||||
@Body() dto: ApproveStepDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.approveStep(
|
||||
id,
|
||||
stepId,
|
||||
resolveAuthUserId(user),
|
||||
dto.requiredRole,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/contract/generate')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.generateContract)
|
||||
@ApiOperation({ summary: 'Generate contract document → CONTRACT_READY' })
|
||||
generateContract(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.generateContract(id);
|
||||
}
|
||||
|
||||
@Get(':id/contract/view')
|
||||
@ApiOperation({ summary: 'Contract PDF view-model + rendered HTML for signing' })
|
||||
async getContractView(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
const { view, html, signatures } =
|
||||
await this.transitionService.getContractDocumentView(id);
|
||||
return {
|
||||
contractId: view.bookingId,
|
||||
reference: view.reference,
|
||||
status: view.status,
|
||||
templateKey: view.templateKey,
|
||||
title: view.template.title,
|
||||
html,
|
||||
view,
|
||||
canSignCustomer: view.canSignCustomer,
|
||||
canSignStaff: view.canSignStaff,
|
||||
hasContractDocument: view.hasContractDocument,
|
||||
signatures,
|
||||
};
|
||||
}
|
||||
|
||||
@Post(':id/contract/sign')
|
||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
||||
signContract(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: SignContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.transitionService.sign(id, dto, {
|
||||
signerUserId: user?.id ?? user?.sub,
|
||||
});
|
||||
}
|
||||
|
||||
@Post(':id/renew')
|
||||
@ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' })
|
||||
renew(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() _dto: RenewContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.transitionService.renew(id, user?.id ?? user?.sub);
|
||||
}
|
||||
|
||||
// ── Pre-booking clearance (Path B, doc §15.2.1) ────────────────────────────
|
||||
|
||||
@Get(':id/clearance')
|
||||
@ApiOperation({ summary: 'Pre-booking clearance document grid on the contract' })
|
||||
getClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.clearanceService.getClearanceView(id);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/documents')
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)' })
|
||||
uploadClearanceDocuments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
return this.clearanceService.uploadDocuments(id, files ?? []);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/review')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
|
||||
@ApiOperation({ summary: 'GL ET reviews a clearance document (Approve | Query)' })
|
||||
reviewClearanceDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ReviewClearanceDocumentDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.reviewDocument(
|
||||
id,
|
||||
dto.fileKey,
|
||||
dto.status,
|
||||
resolveAuthUserId(user),
|
||||
dto.note,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/output-documents')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…) pre-booking' })
|
||||
uploadOutputDocuments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
return this.clearanceService.uploadOutputDocuments(id, files ?? []);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/finalize')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.finalizeClearance)
|
||||
@ApiOperation({ summary: 'GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING' })
|
||||
finalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.clearanceService.finalize(id);
|
||||
}
|
||||
|
||||
// ── Booking under contract (Path A customer / Path B GL ET) ────────────────
|
||||
|
||||
@Post(':id/bookings')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).',
|
||||
})
|
||||
createBooking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CreateBookingUnderContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
// The service decides the execution path from the contract:
|
||||
// Path A (customs disabled) → customer/staff create; status checks apply.
|
||||
// Path B (customs enabled) → GL Ethiopia only, once clearance is ready.
|
||||
return this.contractBookingService.createUnderContract(
|
||||
id,
|
||||
dto,
|
||||
{ id: user?.id ?? user?.sub },
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Clearance milestones (doc §11.3, §12.2) ────────────────────────────────
|
||||
|
||||
@Get(':id/milestones')
|
||||
@ApiOperation({ summary: 'Pre-booking clearance milestones for a contract cycle' })
|
||||
listContractMilestones(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.milestoneService.listForContract(id);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/milestones')
|
||||
@ApiOperation({ summary: 'Post-booking clearance milestones for a shipment booking' })
|
||||
listBookingMilestones(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.milestoneService.listForBooking(bookingId);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/milestones/:code/complete')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'GL / Ops / Terminal marks a post-booking milestone complete' })
|
||||
completeBookingMilestone(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Param('code') code: string,
|
||||
@Body() body: { note?: string },
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.milestoneService.completeForBooking(
|
||||
bookingId,
|
||||
code,
|
||||
user?.id ?? user?.sub,
|
||||
body?.note,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||
|
||||
import { CompaniesModule } from '../companies/companies.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { MinioModule } from '../minio/minio.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
|
||||
import { ContractsController } from './contracts.controller';
|
||||
import { ContractsService } from './contracts.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
import { ContractClearanceService } from './contract-clearance.service';
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractRoute } from './entities/contract-route.entity';
|
||||
import { ContractCargoScope } from './entities/contract-cargo-scope.entity';
|
||||
import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
|
||||
import { ContractSignature } from './entities/contract-signature.entity';
|
||||
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
|
||||
import { ContractReviewNote } from './entities/contract-review-note.entity';
|
||||
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||
import { ContractDocumentReview } from './entities/contract-document-review.entity';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||||
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Contract,
|
||||
ContractRoute,
|
||||
ContractCargoScope,
|
||||
ContractRateSnapshot,
|
||||
ContractSignature,
|
||||
ContractApprovalStep,
|
||||
ContractReviewNote,
|
||||
ContractClearanceCycle,
|
||||
ContractDocumentReview,
|
||||
ClearanceMilestone,
|
||||
BookingContainerUnit,
|
||||
]),
|
||||
RuleEngineModule,
|
||||
FileUploadSettingsModule,
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
SignaturesModule,
|
||||
CompaniesModule,
|
||||
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
||||
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
||||
BookingsModule,
|
||||
ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||
}),
|
||||
],
|
||||
controllers: [ContractsController],
|
||||
providers: [
|
||||
ContractsService,
|
||||
ContractsRepository,
|
||||
ContractPricingService,
|
||||
ContractTransitionService,
|
||||
ContractClearanceService,
|
||||
ContractBookingService,
|
||||
ClearanceMilestoneService,
|
||||
// Contract PDF providers (template resolution + render + PDF) — stateless
|
||||
// helpers reused from src/contracts/.
|
||||
ContractTemplateResolver,
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
ContractDocumentViewModelBuilder,
|
||||
],
|
||||
exports: [
|
||||
ContractsService,
|
||||
ContractsRepository,
|
||||
ContractPricingService,
|
||||
ContractTransitionService,
|
||||
ContractClearanceService,
|
||||
ContractBookingService,
|
||||
ClearanceMilestoneService,
|
||||
],
|
||||
})
|
||||
export class ContractsModule {}
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
481
apps/edr-freight-api/src/modules/contracts/contracts.service.ts
Normal file
481
apps/edr-freight-api/src/modules/contracts/contracts.service.ts
Normal file
@@ -0,0 +1,481 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
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 { 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,
|
||||
estimatedShipmentDate: dto.estimatedShipmentDate
|
||||
? new Date(dto.estimatedShipmentDate)
|
||||
: null,
|
||||
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);
|
||||
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
return { contract: await this.findById(contract.id), warnings };
|
||||
}
|
||||
|
||||
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'],
|
||||
): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(ContractCargoScope);
|
||||
await repo.save(
|
||||
cargoScope.map((c) =>
|
||||
repo.create({
|
||||
contractId,
|
||||
containerSize: c.containerSize ?? null,
|
||||
cargoTypeId: c.cargoTypeId ?? null,
|
||||
cargoFreeText: c.cargoFreeText ?? 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.estimatedShipmentDate) {
|
||||
updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsInt, Max, Min } from 'class-validator';
|
||||
|
||||
export class AcceptContractDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'How many days the contract stays valid, counted from the accept date. ' +
|
||||
'The contract is valid from now through now + validityDays.',
|
||||
minimum: 1,
|
||||
maximum: 3650,
|
||||
example: 365,
|
||||
})
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(3650)
|
||||
validityDays!: number;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ApproveStepDto {
|
||||
@ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' })
|
||||
@IsString()
|
||||
requiredRole!: string;
|
||||
}
|
||||
|
||||
export class RequestChangesDto {
|
||||
@ApiProperty({ description: 'Note explaining what the customer must fix' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
note!: string;
|
||||
}
|
||||
|
||||
export class RejectContractDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
export class RejectStepDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
export class CancelContractDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ContractListSummaryMetricsDto {
|
||||
@ApiProperty({ example: 42 })
|
||||
inQueue!: number;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
onThisPage!: number;
|
||||
|
||||
@ApiProperty({ example: 8 })
|
||||
needsAction!: number;
|
||||
}
|
||||
|
||||
export class ContractListSummaryDto {
|
||||
@ApiProperty({ type: ContractListSummaryMetricsDto })
|
||||
metrics!: ContractListSummaryMetricsDto;
|
||||
|
||||
@ApiProperty({ description: 'Count per contract status', type: 'object', additionalProperties: { type: 'number' } })
|
||||
statusCounts!: Record<string, number>;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/** One physical container under a booking line — entered at booking time. */
|
||||
export class CreateContainerUnitDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
containerNumber!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sealNumber?: string;
|
||||
|
||||
@ApiProperty({ description: 'VGM in tons', minimum: 0 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
vgmTons!: number;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isHazardous?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isReefer?: boolean;
|
||||
}
|
||||
|
||||
export class CreateBookingContainerLineDto {
|
||||
@ApiProperty({ description: '"20ft" | "40ft" — must be in the contract scope' })
|
||||
@IsString()
|
||||
containerSize!: string;
|
||||
|
||||
@ApiProperty({ minimum: 1 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Transform(({ value }) => Number(value))
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
hazardousQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
reeferQuantity?: number;
|
||||
|
||||
@ApiProperty({ type: [CreateContainerUnitDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateContainerUnitDto)
|
||||
units!: CreateContainerUnitDto[];
|
||||
}
|
||||
|
||||
export class CreateBulkLineDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cargoTypeId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
cargoWeightTons?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
itemCount?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
hazardousQuantity?: number;
|
||||
}
|
||||
|
||||
/** Shipment booking created under a contract (Path A customer, Path B GL ET). */
|
||||
export class CreateBookingUnderContractDto {
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Required for GENERAL multi-route contracts; ONE_TIME auto-selected.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
contractRouteId?: string;
|
||||
|
||||
@ApiProperty({ description: 'Binding shipment day.', example: '2026-07-15' })
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateBookingContainerLineDto)
|
||||
containers?: CreateBookingContainerLineDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [CreateBulkLineDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateBulkLineDto)
|
||||
bulkLines?: CreateBulkLineDto[];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { CONTRACT_KINDS } from '../entities/contract.entity';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
|
||||
const EQUIPMENT_RETURNS = ['with_return', 'without_return'] as const;
|
||||
|
||||
export {
|
||||
CONTRACT_KINDS,
|
||||
TRADE_DIRECTIONS,
|
||||
FREIGHT_TYPES,
|
||||
PAYMENT_CURRENCIES,
|
||||
EQUIPMENT_RETURNS,
|
||||
};
|
||||
|
||||
/** One cargo-scope row — a container size OR a bulk commodity. NO quantities. */
|
||||
export class CreateContractCargoScopeDto {
|
||||
@ApiPropertyOptional({ description: '"20ft" | "40ft"; omit for bulk' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
containerSize?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'FK to cargo_types.id' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cargoTypeId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 200 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
cargoFreeText?: string | null;
|
||||
}
|
||||
|
||||
/** A contracted lane (origin → destination). Routes carry NO quantity. */
|
||||
export class CreateContractRouteInputDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' })
|
||||
@IsUUID()
|
||||
originYardId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (destination)' })
|
||||
@IsUUID()
|
||||
destinationYardId!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Road billing distance (km); null for rail-only.',
|
||||
minimum: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) =>
|
||||
value === undefined || value === null || value === '' ? undefined : Number(value),
|
||||
)
|
||||
km?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) =>
|
||||
value === undefined || value === null || value === '' ? undefined : Number(value),
|
||||
)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export class CreateContractDto {
|
||||
@ApiPropertyOptional({ description: 'Unique contract reference (auto-generated if omitted)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
reference?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Staff only: government contract flag' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isGovernment?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required when isGovernment is true' })
|
||||
@ValidateIf((o) => o.isGovernment === true)
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
governmentInstitution?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
|
||||
@ValidateIf((o) => o.isGovernment !== true)
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyId?: string;
|
||||
|
||||
@ApiProperty({ enum: CONTRACT_KINDS, description: 'ONE_TIME | GENERAL' })
|
||||
@IsIn([...CONTRACT_KINDS])
|
||||
contractKind!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'FK to contracts.id when renewing' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : value))
|
||||
renewalOfId?: string;
|
||||
|
||||
@ApiProperty({ enum: TRADE_DIRECTIONS })
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
tradeDirection!: string;
|
||||
|
||||
@ApiProperty({ enum: FREIGHT_TYPES })
|
||||
@IsIn([...FREIGHT_TYPES])
|
||||
freightType!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'FK to service_types.id' })
|
||||
@IsUUID()
|
||||
serviceTypeId!: string;
|
||||
|
||||
@ApiProperty({ enum: PAYMENT_CURRENCIES })
|
||||
@IsIn([...PAYMENT_CURRENCIES])
|
||||
paymentCurrency!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Whether EDR/GL handles customs clearance' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
customsClearingEnabled?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 200 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
customsClearingAgent?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: EQUIPMENT_RETURNS })
|
||||
@IsOptional()
|
||||
@IsIn([...EQUIPMENT_RETURNS])
|
||||
equipmentReturn?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
firstMilePickupAddress?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'First-mile pickup latitude (-90..90)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
|
||||
firstMilePickupLat?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'First-mile pickup longitude (-180..180)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
|
||||
firstMilePickupLng?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastMileDeliveryAddress?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Last-mile delivery latitude (-90..90)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
|
||||
lastMileDeliveryLat?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Last-mile delivery longitude (-180..180)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
|
||||
lastMileDeliveryLng?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: false, description: 'Sets contracts.is_hazardous' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isHazardous?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: false, description: 'Sets contracts.is_reefer' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isReefer?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Non-binding estimate from the wizard (NOT validated against departures)',
|
||||
example: '2026-07-15T00:00:00.000Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
estimatedShipmentDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Contract document type (SPOT, etc.)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
contractType?: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: [CreateContractCargoScopeDto],
|
||||
description:
|
||||
'Cargo scope rows. CONTAINER: ≥1 size row. BULK: exactly one cargo-type row.',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateContractCargoScopeDto)
|
||||
cargoScope!: CreateContractCargoScopeDto[];
|
||||
|
||||
@ApiProperty({
|
||||
type: [CreateContractRouteInputDto],
|
||||
description: 'Contracted lanes. ONE_TIME: exactly 1. GENERAL: 1..N.',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateContractRouteInputDto)
|
||||
routes!: CreateContractRouteInputDto[];
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
import { CONTRACT_STATUSES, CONTRACT_KINDS } from '../entities/contract.entity';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
|
||||
|
||||
export class FilterContractDto {
|
||||
@ApiPropertyOptional({ enum: CONTRACT_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...CONTRACT_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Filter by statuses: comma-separated or repeated query params. Overrides status when set.',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
if (Array.isArray(value)) return value.map(String).join(',');
|
||||
return String(value);
|
||||
})
|
||||
statuses?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyProfileId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: CONTRACT_KINDS })
|
||||
@IsOptional()
|
||||
@IsIn([...CONTRACT_KINDS])
|
||||
contractKind?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
serviceTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: FREIGHT_TYPES })
|
||||
@IsOptional()
|
||||
@IsIn([...FREIGHT_TYPES])
|
||||
freightType?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
|
||||
@IsOptional()
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
tradeDirection?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: PAYMENT_CURRENCIES })
|
||||
@IsOptional()
|
||||
@IsIn([...PAYMENT_CURRENCIES])
|
||||
paymentCurrency?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter contracts created on/after this date (ISO)' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
createdFrom?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter contracts created on/before this date (ISO)' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
createdTo?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value ? parseInt(value, 10) : 1))
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 20 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value ? parseInt(value, 10) : 20))
|
||||
pageSize?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 'createdAt' })
|
||||
@IsOptional()
|
||||
@IsIn(['createdAt', 'contractValidUntil'])
|
||||
sortBy?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
|
||||
@IsOptional()
|
||||
@IsIn(['ASC', 'DESC'])
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class RenewContractDto {
|
||||
@ApiPropertyOptional({ description: 'Reference of the prior contract being renewed.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
previousContractReference?: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ReviewClearanceDocumentDto {
|
||||
@ApiProperty({ description: 'The document fileKey being reviewed' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
fileKey!: string;
|
||||
|
||||
@ApiProperty({ enum: ['APPROVED', 'QUERIED'] })
|
||||
@IsIn(['APPROVED', 'QUERIED'])
|
||||
status!: 'APPROVED' | 'QUERIED';
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required when querying a document' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class SignContractDto {
|
||||
@ApiProperty({ enum: ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] })
|
||||
@IsIn(['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'])
|
||||
role!: 'CUSTOMER' | 'STAFF' | 'DIRECTOR' | 'CEO';
|
||||
|
||||
@ApiProperty({ description: 'PNG signature image as base64 (with or without data URL prefix)' })
|
||||
@IsString()
|
||||
@MinLength(20)
|
||||
signatureImageBase64!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
signerDisplayName!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
consentText?: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
|
||||
import { CreateContractDto } from './create-contract.dto';
|
||||
|
||||
/** Partial contract update — allowed only in DRAFT / CHANGES_REQUESTED. */
|
||||
export class UpdateContractDto extends PartialType(CreateContractDto) {}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
export const MILESTONE_STATUSES = ['PENDING', 'COMPLETED', 'SKIPPED'] as const;
|
||||
export type MilestoneStatus = (typeof MILESTONE_STATUSES)[number];
|
||||
|
||||
export const MILESTONE_OWNER_REGIONS = ['ET', 'DJ', 'OPS', 'CUST'] as const;
|
||||
export type MilestoneOwnerRegion = (typeof MILESTONE_OWNER_REGIONS)[number];
|
||||
|
||||
/**
|
||||
* A GL clearance milestone (18–23 per direction). Pre-booking milestones attach
|
||||
* to contract_id + clearance_cycle_id; post-booking milestones to booking_id.
|
||||
* See §5.12, §5.16, §11.3.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'clearance_milestones' })
|
||||
@Index(['bookingId'])
|
||||
@Index(['contractId'])
|
||||
@Index(['ownerRegion', 'status'])
|
||||
export class ClearanceMilestone extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId?: string | null;
|
||||
|
||||
@ManyToOne(() => Booking, { nullable: true, onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking | null;
|
||||
|
||||
@Column({ name: 'contract_id', type: 'uuid', nullable: true })
|
||||
contractId?: string | null;
|
||||
|
||||
@ManyToOne(() => Contract, { nullable: true, onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract?: Contract | null;
|
||||
|
||||
@Column({ name: 'clearance_cycle_id', type: 'uuid', nullable: true })
|
||||
clearanceCycleId?: string | null;
|
||||
|
||||
@Column({ name: 'milestone_code', type: 'varchar', length: 64 })
|
||||
milestoneCode!: string;
|
||||
|
||||
@Column({ name: 'milestone_label', type: 'varchar', length: 255 })
|
||||
milestoneLabel!: string;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
|
||||
status!: MilestoneStatus;
|
||||
|
||||
@Column({ name: 'owner_region', type: 'varchar', length: 5, nullable: true })
|
||||
ownerRegion?: MilestoneOwnerRegion | null;
|
||||
|
||||
@Column({ name: 'triggered_by_doc', type: 'boolean', default: false })
|
||||
triggeredByDoc!: boolean;
|
||||
|
||||
@Column({ name: 'triggered_at', type: 'timestamptz', nullable: true })
|
||||
triggeredAt?: Date | null;
|
||||
|
||||
@Column({ name: 'triggered_by_user_id', type: 'uuid', nullable: true })
|
||||
triggeredByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'note', type: 'text', nullable: true })
|
||||
note?: string | null;
|
||||
|
||||
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
|
||||
sortOrder!: number;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
export const CONTRACT_APPROVAL_STEP_STATUSES = [
|
||||
'PENDING',
|
||||
'APPROVED',
|
||||
'REJECTED',
|
||||
'SKIPPED',
|
||||
] as const;
|
||||
export type ContractApprovalStepStatus =
|
||||
(typeof CONTRACT_APPROVAL_STEP_STATUSES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'contract_approval_steps' })
|
||||
@Index(['contractId'])
|
||||
@Index(['status'])
|
||||
@Index(['contractId', 'stepOrder'])
|
||||
export class ContractApprovalStep extends BaseEntity {
|
||||
@Column({ name: 'contract_id', type: 'uuid' })
|
||||
contractId!: string;
|
||||
|
||||
@ManyToOne(() => Contract, (c) => c.approvalSteps, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract?: Contract;
|
||||
|
||||
@Column({ name: 'step_order', type: 'smallint', default: 0 })
|
||||
stepOrder!: number;
|
||||
|
||||
@Column({ name: 'required_role', type: 'varchar', length: 40 })
|
||||
requiredRole!: string;
|
||||
|
||||
@Column({ name: 'blocks_role', type: 'varchar', length: 40, nullable: true })
|
||||
blocksRole?: string | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
|
||||
status!: ContractApprovalStepStatus;
|
||||
|
||||
@Column({ name: 'acted_by_staff_id', type: 'uuid', nullable: true })
|
||||
actedByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'acted_at', type: 'timestamptz', nullable: true })
|
||||
actedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'note', type: 'text', nullable: true })
|
||||
note?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
/**
|
||||
* What cargo sizes/commodities are in scope for a contract — NO quantities.
|
||||
* Container: one row per enabled size (20ft/40ft). Bulk: one row with a cargo
|
||||
* type. See §5.4.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'contract_cargo_scope' })
|
||||
@Index(['contractId'])
|
||||
export class ContractCargoScope extends BaseEntity {
|
||||
@Column({ name: 'contract_id', type: 'uuid' })
|
||||
contractId!: string;
|
||||
|
||||
@ManyToOne(() => Contract, (c) => c.cargoScope, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract?: Contract;
|
||||
|
||||
/** '20ft' | '40ft'; null for bulk. */
|
||||
@Column({ name: 'container_size', type: 'varchar', length: 10, nullable: true })
|
||||
containerSize?: string | null;
|
||||
|
||||
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
|
||||
cargoTypeId?: string | null;
|
||||
|
||||
@ManyToOne(() => CargoType, { nullable: true })
|
||||
@JoinColumn({ name: 'cargo_type_id' })
|
||||
cargoType?: CargoType | null;
|
||||
|
||||
@Column({ name: 'cargo_free_text', type: 'varchar', length: 200, nullable: true })
|
||||
cargoFreeText?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
/**
|
||||
* One pre-booking clearance round on a contract (Path B). GENERAL contracts run
|
||||
* many cycles; ONE_TIME uses cycle_number = 1. The booking GL creates is linked
|
||||
* back via bookingId. See §5.16.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'contract_clearance_cycles' })
|
||||
@Index(['contractId'])
|
||||
export class ContractClearanceCycle extends BaseEntity {
|
||||
@Column({ name: 'contract_id', type: 'uuid' })
|
||||
contractId!: string;
|
||||
|
||||
@ManyToOne(() => Contract, (c) => c.clearanceCycles, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract?: Contract;
|
||||
|
||||
@Column({ name: 'cycle_number', type: 'int' })
|
||||
cycleNumber!: number;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 40, default: 'AWAITING_DOCUMENTS' })
|
||||
status!: string;
|
||||
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId?: string | null;
|
||||
|
||||
@Column({ name: 'started_at', type: 'timestamptz', default: () => 'now()' })
|
||||
startedAt!: Date;
|
||||
|
||||
@Column({ name: 'clearance_ready_at', type: 'timestamptz', nullable: true })
|
||||
clearanceReadyAt?: Date | null;
|
||||
|
||||
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||
completedAt?: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
export const CONTRACT_DOC_REVIEW_STATUSES = ['PENDING', 'APPROVED', 'QUERIED'] as const;
|
||||
export type ContractDocReviewStatus =
|
||||
(typeof CONTRACT_DOC_REVIEW_STATUSES)[number];
|
||||
|
||||
export const CONTRACT_DOC_UPLOADER_ROLES = ['CUSTOMER', 'GL_ET', 'GL_DJ'] as const;
|
||||
export type ContractDocUploaderRole =
|
||||
(typeof CONTRACT_DOC_UPLOADER_ROLES)[number];
|
||||
|
||||
/**
|
||||
* Per-document pre-booking clearance review on a contract (Path B). Mirrors
|
||||
* BookingDocumentReview but keyed on contract_id (+ optional clearance cycle).
|
||||
* See §5.16.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'contract_document_review' })
|
||||
@Index(['contractId'])
|
||||
@Index(['status'])
|
||||
export class ContractDocumentReview extends BaseEntity {
|
||||
@Column({ name: 'contract_id', type: 'uuid' })
|
||||
contractId!: string;
|
||||
|
||||
@ManyToOne(() => Contract, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract?: Contract;
|
||||
|
||||
@Column({ name: 'clearance_cycle_id', type: 'uuid', nullable: true })
|
||||
clearanceCycleId?: string | null;
|
||||
|
||||
@Column({ name: 'setting_code', type: 'varchar', length: 128 })
|
||||
settingCode!: string;
|
||||
|
||||
@Column({ name: 'file_key', type: 'varchar', length: 128 })
|
||||
fileKey!: string;
|
||||
|
||||
@Column({ name: 'file_record_id', type: 'uuid', nullable: true })
|
||||
fileRecordId?: string | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
|
||||
status!: ContractDocReviewStatus;
|
||||
|
||||
@Column({ name: 'note', type: 'text', nullable: true })
|
||||
note?: string | null;
|
||||
|
||||
@Column({ name: 'uploaded_by_role', type: 'varchar', length: 20, default: 'CUSTOMER' })
|
||||
uploadedByRole!: ContractDocUploaderRole;
|
||||
|
||||
@Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true })
|
||||
reviewedByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
|
||||
reviewedAt?: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
/**
|
||||
* Frozen UNIT rates at contract submit time — one row per rate line. The booking
|
||||
* computes totals from these × entered quantities. See §5.7.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'contract_rate_snapshots' })
|
||||
@Index(['contractId'])
|
||||
export class ContractRateSnapshot extends BaseEntity {
|
||||
@Column({ name: 'contract_id', type: 'uuid' })
|
||||
contractId!: string;
|
||||
|
||||
@ManyToOne(() => Contract, (c) => c.rateSnapshots, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract?: Contract;
|
||||
|
||||
@Column({ name: 'rate_id', type: 'uuid', nullable: true })
|
||||
rateId?: string | null;
|
||||
|
||||
@Column({ name: 'rate_code', type: 'varchar', length: 64 })
|
||||
rateCode!: string;
|
||||
|
||||
@Column({ name: 'description', type: 'varchar', length: 255, nullable: true })
|
||||
description?: string | null;
|
||||
|
||||
@Column({ name: 'unit_price', type: 'numeric', precision: 14, scale: 2 })
|
||||
unitPrice!: number;
|
||||
|
||||
/** per_container | per_ton | per_item | per_km | flat */
|
||||
@Column({ name: 'unit_of_measure', type: 'varchar', length: 32 })
|
||||
unitOfMeasure!: string;
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 5 })
|
||||
currency!: string;
|
||||
|
||||
@Column({ name: 'container_size', type: 'varchar', length: 10, nullable: true })
|
||||
containerSize?: string | null;
|
||||
|
||||
@Column({ name: 'is_surcharge', type: 'boolean', default: false })
|
||||
isSurcharge!: boolean;
|
||||
|
||||
/** is_hazardous | is_reefer when this is a conditional surcharge. */
|
||||
@Column({ name: 'conditional_on', type: 'varchar', length: 32, nullable: true })
|
||||
conditionalOn?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
export const CONTRACT_REVIEW_NOTE_TYPES = [
|
||||
'CHANGES_REQUESTED',
|
||||
'REJECTION',
|
||||
'STAFF_NOTE',
|
||||
'CUSTOMER_NOTE',
|
||||
'AMENDMENT',
|
||||
] as const;
|
||||
export type ContractReviewNoteType =
|
||||
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'contract_review_notes' })
|
||||
@Index(['contractId'])
|
||||
export class ContractReviewNote extends BaseEntity {
|
||||
@Column({ name: 'contract_id', type: 'uuid' })
|
||||
contractId!: string;
|
||||
|
||||
@ManyToOne(() => Contract, (c) => c.reviewNotes, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract?: Contract;
|
||||
|
||||
@Column({ name: 'note_type', type: 'varchar', length: 40 })
|
||||
noteType!: ContractReviewNoteType;
|
||||
|
||||
@Column({ name: 'body', type: 'text' })
|
||||
body!: string;
|
||||
|
||||
@Column({ name: 'author_role', type: 'varchar', length: 20, nullable: true })
|
||||
authorRole?: string | null;
|
||||
|
||||
@Column({ name: 'author_user_id', type: 'uuid', nullable: true })
|
||||
authorUserId?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
/**
|
||||
* An allowed origin→destination lane of a contract. Routes carry NO quantity —
|
||||
* the contract scope just lists which lanes shipments may use. See §5.3.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'contract_routes' })
|
||||
@Index(['contractId'])
|
||||
export class ContractRoute extends BaseEntity {
|
||||
@Column({ name: 'contract_id', type: 'uuid' })
|
||||
contractId!: string;
|
||||
|
||||
@ManyToOne(() => Contract, (c) => c.routes, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract?: Contract;
|
||||
|
||||
@Column({ name: 'origin_yard_id', type: 'uuid' })
|
||||
originYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'origin_yard_id' })
|
||||
originYard?: Yard;
|
||||
|
||||
@Column({ name: 'destination_yard_id', type: 'uuid' })
|
||||
destinationYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'destination_yard_id' })
|
||||
destinationYard?: Yard;
|
||||
|
||||
/** Road billing distance; null for rail-only. */
|
||||
@Column({ name: 'km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
km?: number | null;
|
||||
|
||||
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
|
||||
sortOrder!: number;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { FileRecord } from '../../files/entities/file.entity';
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
export const CONTRACT_SIGNER_ROLES = ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] as const;
|
||||
export type ContractSignerRole = (typeof CONTRACT_SIGNER_ROLES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'contract_signatures' })
|
||||
@Index(['contractId'])
|
||||
export class ContractSignature extends BaseEntity {
|
||||
@Column({ name: 'contract_id', type: 'uuid' })
|
||||
contractId!: string;
|
||||
|
||||
@ManyToOne(() => Contract, (c) => c.signatures, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract?: Contract;
|
||||
|
||||
@Column({ name: 'role', type: 'varchar', length: 20 })
|
||||
role!: ContractSignerRole;
|
||||
|
||||
@Column({ name: 'signer_display_name', type: 'varchar', length: 255 })
|
||||
signerDisplayName!: string;
|
||||
|
||||
@Column({ name: 'signature_file_id', type: 'uuid', nullable: true })
|
||||
signatureFileId?: string | null;
|
||||
|
||||
@ManyToOne(() => FileRecord, { nullable: true })
|
||||
@JoinColumn({ name: 'signature_file_id' })
|
||||
signatureFile?: FileRecord | null;
|
||||
|
||||
@Column({ name: 'consent_text', type: 'text', nullable: true })
|
||||
consentText?: string | null;
|
||||
|
||||
@Column({ name: 'signed_at', type: 'timestamptz', default: () => 'now()' })
|
||||
signedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
|
||||
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
|
||||
import { FileRecord } from '../../files/entities/file.entity';
|
||||
import { ContractRoute } from './contract-route.entity';
|
||||
import { ContractCargoScope } from './contract-cargo-scope.entity';
|
||||
import { ContractRateSnapshot } from './contract-rate-snapshot.entity';
|
||||
import { ContractSignature } from './contract-signature.entity';
|
||||
import { ContractApprovalStep } from './contract-approval-step.entity';
|
||||
import { ContractReviewNote } from './contract-review-note.entity';
|
||||
import { ContractClearanceCycle } from './contract-clearance-cycle.entity';
|
||||
|
||||
export const CONTRACT_STATUSES = [
|
||||
'DRAFT',
|
||||
'SUBMITTED',
|
||||
'PRICE_CHANGED_PENDING_CONFIRM',
|
||||
'CHANGES_REQUESTED',
|
||||
'PENDING_APPROVAL',
|
||||
'APPROVED',
|
||||
'APPROVED_PENDING_SIGNATURE',
|
||||
'CONTRACT_READY',
|
||||
'SIGNED_CUSTOMER',
|
||||
'FULLY_EXECUTED',
|
||||
'CONTRACT_ACTIVE',
|
||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
'CLEARANCE_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
'CONTRACT_CLOSED',
|
||||
'EXPIRED',
|
||||
'REJECTED',
|
||||
'CANCELLED',
|
||||
'RENEWAL_DRAFT',
|
||||
'RENEWAL_SUBMITTED',
|
||||
'RENEWAL_PENDING_APPROVAL',
|
||||
'AMENDMENTS_PROPOSED',
|
||||
'ARCHIVED',
|
||||
] as const;
|
||||
|
||||
export type ContractStatus = (typeof CONTRACT_STATUSES)[number];
|
||||
|
||||
export const CONTRACT_KINDS = ['ONE_TIME', 'GENERAL'] as const;
|
||||
export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
|
||||
|
||||
export const CONTRACT_CLEARANCE_STATUSES = [
|
||||
'NOT_APPLICABLE',
|
||||
'AWAITING_DOCUMENTS',
|
||||
'DOCUMENTS_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
] as const;
|
||||
export type ContractClearanceStatusValue =
|
||||
(typeof CONTRACT_CLEARANCE_STATUSES)[number];
|
||||
|
||||
/** Statuses where the customer may still edit contract fields. */
|
||||
export const CONTRACT_CUSTOMER_EDITABLE_STATUSES: ContractStatus[] = [
|
||||
'DRAFT',
|
||||
'CHANGES_REQUESTED',
|
||||
];
|
||||
|
||||
/**
|
||||
* The legal/commercial agreement. Defines what cargo sizes/commodities, routes,
|
||||
* and flags are in scope plus the frozen unit rates — but NO quantities. Spawns
|
||||
* shipment {@link Booking} rows via bookings.contract_id. See docs/new-doc.md §5.2.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'contracts' })
|
||||
@Index(['companyId'])
|
||||
@Index(['status'])
|
||||
@Index(['contractKind'])
|
||||
export class Contract extends BaseEntity {
|
||||
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
|
||||
reference!: string;
|
||||
|
||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
||||
companyId?: string | null;
|
||||
|
||||
@ManyToOne(() => Company, { nullable: true })
|
||||
@JoinColumn({ name: 'company_id' })
|
||||
company?: Company | null;
|
||||
|
||||
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
|
||||
companyProfileId?: string | null;
|
||||
|
||||
@ManyToOne(() => CompanyProfile, { nullable: true })
|
||||
@JoinColumn({ name: 'company_profile_id' })
|
||||
companyProfile?: CompanyProfile | null;
|
||||
|
||||
@Column({ name: 'is_government', type: 'boolean', default: false })
|
||||
isGovernment!: boolean;
|
||||
|
||||
@Column({ name: 'government_institution', type: 'varchar', length: 255, nullable: true })
|
||||
governmentInstitution?: string | null;
|
||||
|
||||
@Column({ name: 'contract_kind', type: 'varchar', length: 20 })
|
||||
contractKind!: ContractKindValue;
|
||||
|
||||
@Column({ name: 'renewal_of_id', type: 'uuid', nullable: true })
|
||||
renewalOfId?: string | null;
|
||||
|
||||
@ManyToOne(() => Contract, { nullable: true })
|
||||
@JoinColumn({ name: 'renewal_of_id' })
|
||||
renewalOf?: Contract | null;
|
||||
|
||||
@Column({ name: 'trade_direction', type: 'varchar', length: 10 })
|
||||
tradeDirection!: string;
|
||||
|
||||
@Column({ name: 'freight_type', type: 'varchar', length: 20 })
|
||||
freightType!: string;
|
||||
|
||||
@Column({ name: 'service_type_id', type: 'uuid' })
|
||||
serviceTypeId!: string;
|
||||
|
||||
@ManyToOne(() => ServiceType)
|
||||
@JoinColumn({ name: 'service_type_id' })
|
||||
serviceType?: ServiceType;
|
||||
|
||||
@Column({ name: 'payment_currency', type: 'varchar', length: 5 })
|
||||
paymentCurrency!: string;
|
||||
|
||||
@Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false })
|
||||
customsClearingEnabled!: boolean;
|
||||
|
||||
@Column({ name: 'customs_clearing_agent', type: 'varchar', length: 200, nullable: true })
|
||||
customsClearingAgent?: string | null;
|
||||
|
||||
@Column({ name: 'equipment_return', type: 'varchar', length: 20, nullable: true })
|
||||
equipmentReturn?: string | null;
|
||||
|
||||
@Column({ name: 'first_mile_pickup_address', type: 'text', nullable: true })
|
||||
firstMilePickupAddress?: string | null;
|
||||
|
||||
@Column({ name: 'first_mile_pickup_lat', type: 'numeric', precision: 10, scale: 7, nullable: true })
|
||||
firstMilePickupLat?: number | null;
|
||||
|
||||
@Column({ name: 'first_mile_pickup_lng', type: 'numeric', precision: 10, scale: 7, nullable: true })
|
||||
firstMilePickupLng?: number | null;
|
||||
|
||||
@Column({ name: 'last_mile_delivery_address', type: 'text', nullable: true })
|
||||
lastMileDeliveryAddress?: string | null;
|
||||
|
||||
@Column({ name: 'last_mile_delivery_lat', type: 'numeric', precision: 10, scale: 7, nullable: true })
|
||||
lastMileDeliveryLat?: number | null;
|
||||
|
||||
@Column({ name: 'last_mile_delivery_lng', type: 'numeric', precision: 10, scale: 7, nullable: true })
|
||||
lastMileDeliveryLng?: number | null;
|
||||
|
||||
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
||||
isHazardous!: boolean;
|
||||
|
||||
@Column({ name: 'is_reefer', type: 'boolean', default: false })
|
||||
isReefer!: boolean;
|
||||
|
||||
@Column({ name: 'estimated_shipment_date', type: 'timestamptz', nullable: true })
|
||||
estimatedShipmentDate?: Date | null;
|
||||
|
||||
@Column({ name: 'contract_validity_days', type: 'int', nullable: true })
|
||||
contractValidityDays?: number | null;
|
||||
|
||||
@Column({ name: 'contract_valid_from', type: 'timestamptz', nullable: true })
|
||||
contractValidFrom?: Date | null;
|
||||
|
||||
@Column({ name: 'contract_valid_until', type: 'timestamptz', nullable: true })
|
||||
contractValidUntil?: Date | null;
|
||||
|
||||
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
|
||||
expiresAt?: Date | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
|
||||
status!: string;
|
||||
|
||||
@Column({ name: 'clearance_status', type: 'varchar', length: 40, default: 'NOT_APPLICABLE' })
|
||||
clearanceStatus!: string;
|
||||
|
||||
@Column({ name: 'clearance_cycle_number', type: 'int', default: 0 })
|
||||
clearanceCycleNumber!: number;
|
||||
|
||||
@Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
|
||||
pricingBreakdown?: Record<string, unknown> | null;
|
||||
|
||||
@Column({ name: 'pricing_display_mode', type: 'varchar', length: 20, default: 'UNIT_RATES', nullable: true })
|
||||
pricingDisplayMode?: string | null;
|
||||
|
||||
@Column({ name: 'contract_type', type: 'varchar', length: 20, nullable: true })
|
||||
contractType?: string | null;
|
||||
|
||||
@Column({ name: 'contract_template_key', type: 'varchar', length: 128, nullable: true })
|
||||
contractTemplateKey?: string | null;
|
||||
|
||||
@Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
|
||||
contractGeneratedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'contract_summary', type: 'text', nullable: true })
|
||||
contractSummary?: string | null;
|
||||
|
||||
@Column({ name: 'version_number', type: 'int', default: 1 })
|
||||
versionNumber!: number;
|
||||
|
||||
@Column({ name: 'financial_terms', type: 'jsonb', nullable: true })
|
||||
financialTerms?: Record<string, unknown> | null;
|
||||
|
||||
@Column({ name: 'approved_by_staff_id', type: 'uuid', nullable: true })
|
||||
approvedByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'approved_by_staff_at', type: 'timestamptz', nullable: true })
|
||||
approvedByStaffAt?: Date | null;
|
||||
|
||||
@Column({ name: 'signed_by_director_id', type: 'uuid', nullable: true })
|
||||
signedByDirectorId?: string | null;
|
||||
|
||||
@Column({ name: 'signed_by_director_at', type: 'timestamptz', nullable: true })
|
||||
signedByDirectorAt?: Date | null;
|
||||
|
||||
@Column({ name: 'signed_by_ceo_id', type: 'uuid', nullable: true })
|
||||
signedByCeoId?: string | null;
|
||||
|
||||
@Column({ name: 'signed_by_ceo_at', type: 'timestamptz', nullable: true })
|
||||
signedByCeoAt?: Date | null;
|
||||
|
||||
@Column({ name: 'customer_signed_at', type: 'timestamptz', nullable: true })
|
||||
customerSignedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'fully_executed_at', type: 'timestamptz', nullable: true })
|
||||
fullyExecutedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'locked_at', type: 'timestamptz', nullable: true })
|
||||
lockedAt?: Date | null;
|
||||
|
||||
@OneToMany(() => ContractRoute, (r) => r.contract)
|
||||
routes?: ContractRoute[];
|
||||
|
||||
@OneToMany(() => ContractCargoScope, (c) => c.contract)
|
||||
cargoScope?: ContractCargoScope[];
|
||||
|
||||
@OneToMany(() => ContractRateSnapshot, (s) => s.contract)
|
||||
rateSnapshots?: ContractRateSnapshot[];
|
||||
|
||||
@OneToMany(() => ContractSignature, (s) => s.contract)
|
||||
signatures?: ContractSignature[];
|
||||
|
||||
@OneToMany(() => ContractApprovalStep, (s) => s.contract)
|
||||
approvalSteps?: ContractApprovalStep[];
|
||||
|
||||
@OneToMany(() => ContractReviewNote, (n) => n.contract)
|
||||
reviewNotes?: ContractReviewNote[];
|
||||
|
||||
@OneToMany(() => ContractClearanceCycle, (c) => c.contract)
|
||||
clearanceCycles?: ContractClearanceCycle[];
|
||||
|
||||
@OneToMany(() => FileRecord, (file) => file.resourceId, {
|
||||
createForeignKeyConstraints: false,
|
||||
})
|
||||
files?: FileRecord[];
|
||||
}
|
||||
@@ -34,13 +34,8 @@ import {
|
||||
RefundDto,
|
||||
} from "./payments.dto";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service";
|
||||
import { FirstMileService } from "../first-mile/first-mile.service";
|
||||
|
||||
/** Setting code holding the global ordering window (months) for general contracts. */
|
||||
const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period";
|
||||
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
|
||||
|
||||
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
|
||||
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
"processing": ProviderPaymentStatus.PROCESSING,
|
||||
@@ -60,24 +55,9 @@ export class PaymentService {
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly dropdownSettings: DropdownSettingsService,
|
||||
private readonly firstMileService: FirstMileService,
|
||||
) { }
|
||||
|
||||
/** Configured general-contract ordering window in months (defaults to 3). */
|
||||
private async contractPeriodMonths(): Promise<number> {
|
||||
try {
|
||||
const setting = await this.dropdownSettings.getByCode(
|
||||
CONTRACT_PERIOD_SETTING_CODE,
|
||||
);
|
||||
const months = Number(setting.children?.[0]?.value);
|
||||
if (Number.isFinite(months) && months > 0) return months;
|
||||
} catch {
|
||||
// Setting not seeded — fall back to the default.
|
||||
}
|
||||
return DEFAULT_CONTRACT_PERIOD_MONTHS;
|
||||
}
|
||||
|
||||
async getAll(filters: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
@@ -315,22 +295,8 @@ export class PaymentService {
|
||||
|
||||
const paidAt = input.paidAt ?? new Date();
|
||||
|
||||
// A general contract is paid once, up front; it does NOT enter the train
|
||||
// queue (nothing has been ordered yet). Instead it becomes ACTIVE and
|
||||
// opens its ordering window. Orders placed later spawn their own paid
|
||||
// child bookings that go through the normal pipeline.
|
||||
const booking = await this.datasource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: input.bookingId } });
|
||||
const isGeneralContract = booking?.bookingType === "GENERAL_CONTRACT";
|
||||
|
||||
let contractExpiresAt: Date | null = null;
|
||||
if (isGeneralContract) {
|
||||
const months = await this.contractPeriodMonths();
|
||||
contractExpiresAt = new Date(paidAt);
|
||||
contractExpiresAt.setMonth(contractExpiresAt.getMonth() + months);
|
||||
}
|
||||
|
||||
// Every booking is a real shipment now (contracts are a separate aggregate),
|
||||
// so payment always settles the booking to PAID and enters allocation.
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
await mg.update(
|
||||
PaymentEntity,
|
||||
@@ -340,21 +306,11 @@ export class PaymentService {
|
||||
await mg.update(
|
||||
Booking,
|
||||
{ id: input.bookingId },
|
||||
isGeneralContract
|
||||
? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt }
|
||||
: { paymentStatus: "PAID", status: "PAID" },
|
||||
{ paymentStatus: "PAID", status: "PAID" },
|
||||
);
|
||||
await this.firstMileService.acceptBooking(input.bookingId);
|
||||
|
||||
await this.firstMileService.acceptBooking(input.bookingId);
|
||||
});
|
||||
|
||||
if (isGeneralContract) {
|
||||
this.logger.log(
|
||||
`General contract ${booking?.reference ?? input.bookingId} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`,
|
||||
);
|
||||
return { alreadyFinalized: false };
|
||||
}
|
||||
|
||||
try {
|
||||
await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId);
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user