Files
edr-platform/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts

375 lines
13 KiB
TypeScript

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',
// Reversible backoffice freeze — see statusBeforeSuspension.
'SUSPENDED',
'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_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;
/** UN/ADR dangerous-goods class (CLASS_1..CLASS_9); null unless hazardous. */
@Column({ name: 'hazard_class', type: 'varchar', length: 16, nullable: true })
hazardClass?: string | null;
/** UN number of the dangerous good; null unless hazardous. */
@Column({ name: 'un_number', type: 'varchar', length: 16, nullable: true })
unNumber?: string | null;
@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;
/** When the customer last submitted this contract (DRAFT/CHANGES_REQUESTED → SUBMITTED). */
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
submittedAt?: Date | null;
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
status!: string;
/**
* Status the contract held when the backoffice suspended it, restored when
* the suspension is lifted. Null unless the contract is (or once was)
* SUSPENDED. A suspension without this would just be a cancellation.
*/
@Column({ name: 'status_before_suspension', type: 'varchar', length: 40, nullable: true })
statusBeforeSuspension?: string | null;
@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;
/**
* 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<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[];
/**
* 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;
/**
* Body of the most recent REJECTION review note, attached by
* ContractsService.findById when status is REJECTED so both backoffice and
* portal can show why. Lives in contract_review_notes, not a column here.
*/
latestRejectionNote?: string | null;
/**
* Body of the most recent send-back STAFF_NOTE, attached by
* ContractsService.findById while the contract is PENDING_APPROVAL and no
* approval step has acted since the send-back. Lives in
* contract_review_notes, not a column here.
*/
latestSendBackNote?: string | null;
/**
* Body of the most recent SUSPENSION review note, attached by
* ContractsService.findById while the contract is SUSPENDED so both sides see
* why it was frozen. Lives in contract_review_notes, not a column here.
*/
latestSuspensionNote?: string | null;
/**
* Count of this contract's non-terminal bookings, attached by
* ContractsService.findById. The portal disables customer cancellation while
* it is > 0 (the API enforces the same). Not a column.
*/
activeBookingCount?: number;
}