import { BaseEntity } from '@edr/api-common'; import { SchedulingStatus } from '@edr/types'; import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; // import { Customer } from '../../customers/entities/customer.entity'; import { Company } from '../../companies/entities/company.entity'; import { CompanyProfile } from '../../companies/entities/company-profile.entity'; import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; import { ServiceType } from '../../rule-engine/entities/service-type.entity'; import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity'; import { Yard } from '../../rule-engine/entities/yard.entity'; import { Train } from '../../trains/entities/train.entity'; import { FileRecord } from '../../files/entities/file.entity'; import { BookingCargoModifier } from './booking-cargo-modifier.entity'; import { BookingContainer } from './booking-container.entity'; import { BookingContainerAllocation } from './booking-container-allocation.entity'; import { BookingRateSnapshot } from './booking-rate-snapshot.entity'; import { BookingReviewNote } from './booking-review-note.entity'; export const BOOKING_STATUSES = [ 'DRAFT', 'SUBMITTED', 'PRICE_CHANGED_PENDING_CONFIRM', 'CHANGES_REQUESTED', 'PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE', 'APPROVED', 'READY_FOR_ASSIGNMENT', 'WAGON_ASSIGNED', 'INVOICED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'EXPIRED', 'PNR_GENERATED', 'PAYMENT_VERIFICATION_IN_PROGRESS', 'PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'REJECTED', 'CANCELLED', 'PENDING_CONSOLIDATION', 'CONSOLIDATED', 'CONTRACT_ACTIVE', 'CONTRACT_CLOSED', // Post counter-sign document-clearance gate (GL workflow). 'AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW', 'CLEARANCE_READY', // Road (truck) drawdown orders skip the train batch pool and wait here for // truck dispatch after Marketing accepts; billed by KM, not wagons. 'ROAD_DISPATCH_PENDING', 'TRUCK_ASSIGNED', 'OPERATION_REQUESTED', // Operations review gate: customer picks a schedule day and submits the // operation request; the operations team reviews capacity/docs/route before // the booking enters the batch holding pool. 'OPERATION_REQUEST_PENDING', 'OPERATION_CHANGES_REQUESTED', 'OPERATION_PRICE_PENDING_CONFIRM', ] as const; export type BookingStatus = (typeof BOOKING_STATUSES)[number]; export const BOOKING_TYPES = ['ONE_TIME', 'GENERAL_CONTRACT'] as const; export type BookingTypeValue = (typeof BOOKING_TYPES)[number]; export const PAYMENT_STATUSES = [ 'PENDING', 'PNR_GENERATED', 'VERIFICATION_IN_PROGRESS', 'PAID', 'FAILED', ] as const; export type PaymentStatus = (typeof PAYMENT_STATUSES)[number]; export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const; export type FreightType = (typeof FREIGHT_TYPES)[number]; export const SCHEDULING_STATUSES = [ SchedulingStatus.NotScheduled, SchedulingStatus.Holding, SchedulingStatus.Eligible, SchedulingStatus.Scheduled, SchedulingStatus.Dispatched, SchedulingStatus.WaitingForWagon, ] as const; export type BookingSchedulingStatus = (typeof SCHEDULING_STATUSES)[number]; /** Statuses where the customer may edit booking fields. */ export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [ 'DRAFT', 'CHANGES_REQUESTED', ]; @Entity({ schema: 'freight', name: 'bookings' }) export class Booking extends BaseEntity { @Column({ name: 'reference', type: 'varchar', length: 64, unique: true }) reference!: string; // Legacy — superseded by companyId (column kept in DB) // @Column({ name: 'customer_id', type: 'uuid' }) // customerId!: string; // @ManyToOne(() => Customer) // @JoinColumn({ name: 'customer_id' }) // customer?: Customer; // Every booking is billed to a company — government bookings bill to a seeded // government company (companies.kind = 'government'). Enforced NOT NULL. @Column({ name: 'company_id', type: 'uuid' }) companyId!: string; @ManyToOne(() => Company, { nullable: true }) @JoinColumn({ name: 'company_id' }) company?: Company | null; /** * The operational profile (importer/exporter/forwarder) this booking belongs * to. Stamped at creation from the booking's trade direction (IMPORT→importer, * EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder. * Customer portal lists and dashboard KPIs are scoped by this. Required: * commercial bookings resolve it from trade direction / active mode; * government bookings carry the explicitly-picked government profile. */ @Column({ name: 'company_profile_id', type: 'uuid' }) companyProfileId!: string; @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; /** @deprecated Fleet master data link — scheduling uses train_schedule_bookings instead. */ @Column({ name: 'train_id', type: 'uuid', nullable: true }) trainId?: string | null; /** @deprecated Use train_schedule_bookings for operational scheduling. */ @ManyToOne(() => Train, { nullable: true }) @JoinColumn({ name: 'train_id' }) train?: Train | null; @Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' }) status!: 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; /** Booking origin: ONE_TIME (single-shipment) or GENERAL_CONTRACT (drawdown). */ @Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' }) bookingType!: string; /** 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; /** * The customer paid a partial batch offer and this booking was reduced to the * offered part (see BookingSplitService.applySplit). On a ONE_TIME contract a * split booking releases the single-active-booking slot for the remainder — * the contract kind itself is never changed. */ @Column({ name: 'is_split', type: 'boolean', default: false }) isSplit!: boolean; /** * Quantities this booking carried BEFORE it was reduced by a split — the * split chain's source of truth for the outstanding remainder (ONE_TIME * contracts have no quantity cap to derive it from). Bulk: total tons; * container: units per size. Null until the booking is split. */ @Column({ name: 'pre_split_quantities', type: 'jsonb', nullable: true }) preSplitQuantities?: { bulkTons?: number; bySize?: Record } | 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 * chosen per drawdown order. One-time bookings always set this (the pool day key). * * NOTE: this is the BINDING shipment day, validated against actual open train * departures. It is set later, when the customer requests the operation — NOT * at booking creation. See estimatedShipmentDate for the non-binding estimate * captured in the booking wizard. */ @Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true }) scheduledDate?: Date | null; /** * Non-binding shipment-date estimate captured in the booking wizard. Purely * informational — NOT validated against train departures. The binding * scheduledDate is chosen later at the operation-request step. */ @Column({ name: 'estimated_shipment_date', type: 'timestamptz', nullable: true }) estimatedShipmentDate?: Date | null; /** * General contracts only: when the ordering window closes, computed from the * global CONTRACT_PERIOD_MONTHS setting at activation. Null for one-time * bookings and for contracts that are not yet active. */ @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) expiresAt?: Date | null; @Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) totalAmount!: number; /** * Staff-adjusted total price. When set, it overrides the computed totalAmount * for the customer, who is shown an "Adjusted by EDR" badge. */ @Column({ name: 'adjusted_total_amount', type: 'numeric', precision: 14, scale: 2, nullable: true }) adjustedTotalAmount?: number | null; @Column({ name: 'adjusted_by_staff_id', type: 'uuid', nullable: true }) adjustedByStaffId?: string | null; @Column({ name: 'adjusted_at', type: 'timestamptz', nullable: true }) adjustedAt?: Date | null; @Column({ name: 'adjustment_reason', type: 'text', nullable: true }) adjustmentReason?: string | null; /** * Contract validity window, set by the backoffice at the accept step. The * staff enter a number of days; the contract is valid from contractValidFrom * (the accept moment) through contractValidUntil (validFrom + N days). Outside * this window the contract is expired and the booking cannot proceed. */ @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: 'payment_status', type: 'varchar', length: 20, default: 'PENDING' }) paymentStatus!: string; @Column({ name: 'contract_type', type: 'varchar', length: 20 }) contractType!: string; @Column({ name: 'service_type_id', type: 'uuid' }) serviceTypeId!: string; @ManyToOne(() => ServiceType) @JoinColumn({ name: 'service_type_id' }) serviceType?: ServiceType; @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: 'customer_truck_plate_number', type: 'varchar', length: 32, nullable: true }) customerTruckPlateNumber?: string | null; @Column({ name: 'customer_truck_driver_name', type: 'varchar', length: 120, nullable: true }) customerTruckDriverName?: string | null; @Column({ name: 'customer_truck_type', type: 'varchar', length: 60, nullable: true }) customerTruckType?: string | null; @Column({ name: 'customer_truck_container_number', type: 'varchar', length: 16, nullable: true }) customerTruckContainerNumber?: string | null; @Column({ name: 'customer_truck_assigned_at', type: 'timestamptz', nullable: true }) customerTruckAssignedAt?: Date | null; @Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true }) customerTruckArrivedAt?: Date | null; @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 }) equipmentReturn!: string; @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; @Column({ name: 'trade_direction', type: 'varchar', length: 10 }) tradeDirection!: string; @Column({ name: 'freight_type', type: 'varchar', length: 20 }) freightType!: string; @Column({ name: 'cargo_type_id', type: 'uuid', nullable: true }) cargoTypeId?: string | null; @ManyToOne(() => CargoType) @JoinColumn({ name: 'cargo_type_id' }) cargoType?: CargoType; @Column({ name: 'cargo_free_text', type: 'varchar', length: 200, nullable: true }) cargoFreeText?: string | null; @Column({ name: 'shipping_line_id', type: 'uuid', nullable: true }) shippingLineId?: string | null; @ManyToOne(() => ShippingLine, { nullable: true }) @JoinColumn({ name: 'shipping_line_id' }) shippingLine?: ShippingLine | null; @Column({ name: 'cargo_total_weight_vgm', type: 'numeric', precision: 12, scale: 3 }) cargoTotalWeightVgm!: number; @Column({ name: 'is_hazardous', type: 'boolean', default: false }) isHazardous!: boolean; /** * Refrigerated cargo flag. For one-time bookings reefer is derived from the * container type; for general-contract drawdown orders the customer enters a * reefer quantity per order, which sets this flag on the spawned child so the * REEFER_SURCHARGE rate applies even when the container type is not a reefer. */ @Column({ name: 'is_reefer', type: 'boolean', default: false }) isReefer!: boolean; /** * Bulk-only hazardous / reefer amount, in the cargo's own unit of measure * (tons for PER_TON commodities, item count for PER_ITEM) — i.e. how much of * `cargoTotalWeightVgm` is hazardous / refrigerated. 0 when none. Container * freight carries this per line on `booking_container` instead, so these stay * 0 for CONTAINER bookings. The booleans above remain the surcharge trigger. */ @Column({ name: 'bulk_hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) bulkHazardousQuantity!: number; @Column({ name: 'bulk_reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) bulkReeferQuantity!: number; @Column({ name: 'payment_currency', type: 'varchar', length: 5 }) paymentCurrency!: string; @Column({ name: 'pnr_code', type: 'varchar', length: 50, nullable: true }) pnrCode?: string | null; @Column({ name: 'start_date', type: 'date', nullable: true }) startDate?: Date | null; @Column({ name: 'end_date', type: 'date', nullable: true }) endDate?: Date | null; @Column({ name: 'financial_terms', type: 'text', nullable: true }) financialTerms?: string | null; @Column({ name: 'version_number', type: 'int', default: 1 }) versionNumber!: number; @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: 'marketing_approved_by_id', type: 'uuid', nullable: true }) marketingApprovedById?: string | null; @Column({ name: 'marketing_approved_at', type: 'timestamptz', nullable: true }) marketingApprovedAt?: Date | null; @Column({ name: 'contract_summary', type: 'text', nullable: true }) contractSummary?: string | null; @Column({ name: 'contract_template_key', type: 'varchar', length: 80, nullable: true }) contractTemplateKey?: string | null; @Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true }) contractGeneratedAt?: Date | null; @Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true }) pricingBreakdown?: Record | null; @Column({ name: 'locked_at', type: 'timestamptz', nullable: true }) lockedAt?: Date | null; @Column({ name: 'priority_score', type: 'int', default: 0 }) priorityScore!: number; @Column({ name: 'consolidation_partner_id', type: 'uuid', nullable: true }) consolidationPartnerId?: string | null; @ManyToOne(() => Booking, { nullable: true }) @JoinColumn({ name: 'consolidation_partner_id' }) consolidationPartner?: Booking | null; // Status a booking parked in PENDING_CONSOLIDATION returns to once it pairs. // Null for direct customer bookings (they resume to SUBMITTED, the historical // default); contract-drawdown bookings set it to the status createUnderContract // would otherwise have used (OPERATION_REQUEST_PENDING / AWAITING_DOCUMENTS), so // pairing resumes them into the right flow instead of the direct-booking one. @Column({ name: 'consolidation_resume_status', type: 'varchar', length: 40, nullable: true }) consolidationResumeStatus?: string | null; @Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true }) wagonsRequired?: number | null; @Column({ name: 'scheduling_status', type: 'varchar', length: 30, default: 'NOT_SCHEDULED' }) schedulingStatus!: string; @Column({ name: 'hold_started_at', type: 'timestamptz', nullable: true }) holdStartedAt?: Date | null; @Column({ name: 'hold_expires_at', type: 'timestamptz', nullable: true }) holdExpiresAt?: Date | null; @Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true }) scheduledAt?: Date | null; /** * The train this booking is assigned to. FK to train_schedules. * * Day-level pooling: customers no longer pick a train — they pick a DAY, and * this stays null at creation. The batch engine sets it when it assigns the * booking to a specific train within its (route, day) pool; staff may also * pin it manually. The day-level pool is keyed on * (origin_yard_id, destination_yard_id, day of scheduled_date), not this column. */ @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) trainScheduleId?: string | null; // ── Per-booking journey (segment corridor bookings) ──────────────────────── // A booking rides only its own origin→destination leg of the train's route, // so dispatch/arrival are per-booking facts, not train facts. Clearance gates // read arrivedAt (booking arrival), never the schedule's actualArrivalAt. /** Operator confirmed cargo loaded at the booking's origin yard (per-booking dispatch). */ @Column({ name: 'loaded_at', type: 'timestamptz', nullable: true }) loadedAt?: Date | null; @Column({ name: 'loaded_by_user_id', type: 'uuid', nullable: true }) loadedByUserId?: string | null; /** Operator confirmed cargo unloaded at the booking's destination yard (per-booking arrival). */ @Column({ name: 'arrived_at', type: 'timestamptz', nullable: true }) arrivedAt?: Date | null; @Column({ name: 'arrived_by_user_id', type: 'uuid', nullable: true }) arrivedByUserId?: string | null; /** End of the pay window once the booking is SELECTED_FOR_BATCH. */ @Column({ name: 'payment_deadline', type: 'timestamptz', nullable: true }) paymentDeadline?: Date | null; /** When the batch engine picked this booking and opened the pay window. */ @Column({ name: 'selected_for_batch_at', type: 'timestamptz', nullable: true }) selectedForBatchAt?: Date | null; // ── Global Logistics station routing (GL Import/Export US-02) ────────────── /** Origin-station yard the shipment is routed to for GL handling. */ @Column({ name: 'gl_station_yard_id', type: 'uuid', nullable: true }) glStationYardId?: string | null; /** Per-booking phased clearance (GENERAL + customs). */ @Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true }) clearanceCurrentPhase?: string | null; @Column({ name: 'duty_required', type: 'boolean', nullable: true }) dutyRequired?: boolean | null; @Column({ name: 'vessel_departure_date', type: 'date', nullable: true }) vesselDepartureDate?: string | null; @Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true }) roAmendmentRequestedAt?: Date | null; @Column({ name: 'ro_hold_reason', type: 'text', nullable: true }) roHoldReason?: string | null; @Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true }) preClearanceFinalizedAt?: Date | null; /** GL staff user bound to this shipment by the station manager. */ @Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true }) glAssignedStaffId?: string | null; @Column({ name: 'gl_assigned_at', type: 'timestamptz', nullable: true }) glAssignedAt?: Date | null; @OneToMany(() => BookingContainer, (bc) => bc.booking) bookingContainers?: BookingContainer[]; @OneToMany(() => BookingContainerAllocation, (ca) => ca.booking) containerAllocations?: BookingContainerAllocation[]; @OneToMany(() => BookingCargoModifier, (m) => m.booking) cargoModifiers?: BookingCargoModifier[]; @OneToMany(() => BookingRateSnapshot, (s) => s.booking) rateSnapshots?: BookingRateSnapshot[]; @OneToMany(() => BookingReviewNote, (n) => n.booking) reviewNotes?: BookingReviewNote[]; @OneToMany(() => FileRecord, (file) => file.resourceId, { createForeignKeyConstraints: false, }) files?: FileRecord[]; }