mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
375 lines
13 KiB
TypeScript
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;
|
|
}
|