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_PAYMENT', // Path B — clearance fee invoiced, unpaid '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]; /** One article on a per-contract document snapshot (mirrors the template shape). */ export interface ContractDocumentArticle { id: string; title: string; body: string; order: number; } /** * A per-contract copy of the resolved contract-document template, frozen when * staff accept the contract for approval. Staff may edit these articles for a * single contract in the accept/edit dialog — editing NEVER writes back to the * shared six {@link ContractTemplate} rows. The PDF is rendered from this * snapshot when present; a null snapshot renders from the live template. */ export interface ContractDocumentSnapshot { code?: string | null; name?: string | null; documentTitle?: string | null; whereasClauses: string[]; articles: ContractDocumentArticle[]; } /** Loose inbound shape (article ids/order optional) — normalized before store. */ export interface ContractDocumentSnapshotInput { code?: string | null; name?: string | null; documentTitle?: string | null; whereasClauses?: string[]; articles?: Array<{ id?: string; title?: string; body?: string; order?: 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_PAYMENT', // Path B — clearance service fee must be paid first 'AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking 'SELF_CLEARED', // Path A — Operations approved self-clearance; customer may book '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; /** When the prepaid customs clearance service fee settled (Path B ONE_TIME). */ @Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true }) clearanceFeePaidAt?: Date | null; @Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true }) pricingBreakdown?: Record | 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; /** * Per-contract frozen copy of the document template (articles + WHEREAS), * captured at staff accept. Editing it affects only this contract, never the * shared six templates. Null → the PDF renders from the live template. */ @Column({ name: 'document_snapshot', type: 'jsonb', nullable: true }) documentSnapshot?: ContractDocumentSnapshot | 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 | 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[]; /** * Latest clearance cycle's current_phase, attached by * ContractsRepository.attachClearancePhases for list responses. Not a column. */ clearancePhase?: string | null; /** * Latest clearance cycle's linked booking (id + status), attached alongside * clearancePhase. Lets the GL queue tell an expired (unpaid) booking apart * from a live one so it can offer a rebook. Not columns. */ latestCycleBookingId?: string | null; latestCycleBookingStatus?: string | null; /** * Body of the most recent CHANGES_REQUESTED review note, attached by * ContractsService.findById so the portal can show the customer what staff * asked them to fix. Lives in contract_review_notes, not a column here. */ latestChangeRequestNote?: string | null; }