xpeditis2.0/apps/backend/src/domain/entities/csv-booking.entity.ts
2026-01-17 15:47:03 +01:00

396 lines
10 KiB
TypeScript

import { PortCode } from '../value-objects/port-code.vo';
/**
* CSV Booking Status Enum
*
* Represents the lifecycle of a CSV-based booking request
*/
export enum CsvBookingStatus {
PENDING = 'PENDING', // Awaiting carrier response
ACCEPTED = 'ACCEPTED', // Carrier accepted the booking
REJECTED = 'REJECTED', // Carrier rejected the booking
CANCELLED = 'CANCELLED', // User cancelled the booking
}
/**
* Document Interface
*
* Represents a document attached to a booking
*/
export interface CsvBookingDocument {
id: string;
type: DocumentType;
fileName: string;
filePath: string;
mimeType: string;
size: number;
uploadedAt: Date;
}
/**
* Document Type Enum
*
* Types of documents that can be attached to a booking
*/
export enum DocumentType {
BILL_OF_LADING = 'BILL_OF_LADING',
PACKING_LIST = 'PACKING_LIST',
COMMERCIAL_INVOICE = 'COMMERCIAL_INVOICE',
CERTIFICATE_OF_ORIGIN = 'CERTIFICATE_OF_ORIGIN',
OTHER = 'OTHER',
}
/**
* CSV Booking Entity
*
* Domain entity representing a shipping booking request from CSV rate search.
* This is a simplified booking workflow for CSV-based rates where the user
* selects a rate and sends a booking request to the carrier with documents.
*
* Business Rules:
* - Booking can only be accepted/rejected when status is PENDING
* - Once accepted/rejected, status cannot be changed
* - Booking expires after 7 days if not responded to
* - At least one document is required for booking creation
* - Confirmation token is used for email accept/reject links
* - Only carrier can accept/reject via email link
* - User can cancel pending bookings
*/
export class CsvBooking {
constructor(
public readonly id: string,
public readonly userId: string,
public readonly organizationId: string,
public readonly carrierName: string,
public readonly carrierEmail: string,
public readonly origin: PortCode,
public readonly destination: PortCode,
public readonly volumeCBM: number,
public readonly weightKG: number,
public readonly palletCount: number,
public readonly priceUSD: number,
public readonly priceEUR: number,
public readonly primaryCurrency: string,
public readonly transitDays: number,
public readonly containerType: string,
public status: CsvBookingStatus,
public readonly documents: CsvBookingDocument[],
public readonly confirmationToken: string,
public readonly requestedAt: Date,
public respondedAt?: Date,
public notes?: string,
public rejectionReason?: string
) {
this.validate();
}
/**
* Validate booking data
*/
private validate(): void {
if (!this.id || this.id.trim().length === 0) {
throw new Error('Booking ID is required');
}
if (!this.userId || this.userId.trim().length === 0) {
throw new Error('User ID is required');
}
if (!this.organizationId || this.organizationId.trim().length === 0) {
throw new Error('Organization ID is required');
}
if (!this.carrierName || this.carrierName.trim().length === 0) {
throw new Error('Carrier name is required');
}
if (!this.carrierEmail || this.carrierEmail.trim().length === 0) {
throw new Error('Carrier email is required');
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(this.carrierEmail)) {
throw new Error('Invalid carrier email format');
}
if (this.volumeCBM <= 0) {
throw new Error('Volume must be positive');
}
if (this.weightKG <= 0) {
throw new Error('Weight must be positive');
}
if (this.palletCount < 0) {
throw new Error('Pallet count cannot be negative');
}
if (this.priceUSD < 0 || this.priceEUR < 0) {
throw new Error('Price cannot be negative');
}
if (this.transitDays <= 0) {
throw new Error('Transit days must be positive');
}
if (!this.confirmationToken || this.confirmationToken.trim().length === 0) {
throw new Error('Confirmation token is required');
}
if (!this.documents || this.documents.length === 0) {
throw new Error('At least one document is required for booking');
}
}
/**
* Accept the booking
*
* @throws Error if booking is not in PENDING status
*/
accept(): void {
if (this.status !== CsvBookingStatus.PENDING) {
throw new Error(
`Cannot accept booking with status ${this.status}. Only PENDING bookings can be accepted.`
);
}
if (this.isExpired()) {
throw new Error('Cannot accept expired booking');
}
this.status = CsvBookingStatus.ACCEPTED;
this.respondedAt = new Date();
}
/**
* Reject the booking
*
* @param reason Optional reason for rejection
* @throws Error if booking is not in PENDING status
*/
reject(reason?: string): void {
if (this.status !== CsvBookingStatus.PENDING) {
throw new Error(
`Cannot reject booking with status ${this.status}. Only PENDING bookings can be rejected.`
);
}
if (this.isExpired()) {
throw new Error('Cannot reject expired booking (already expired)');
}
this.status = CsvBookingStatus.REJECTED;
this.respondedAt = new Date();
if (reason) {
this.rejectionReason = reason;
}
}
/**
* Cancel the booking (by user)
*
* @throws Error if booking is already accepted/rejected
*/
cancel(): void {
if (this.status === CsvBookingStatus.ACCEPTED) {
throw new Error('Cannot cancel accepted booking. Contact carrier to cancel.');
}
if (this.status === CsvBookingStatus.REJECTED) {
throw new Error('Cannot cancel rejected booking');
}
this.status = CsvBookingStatus.CANCELLED;
this.respondedAt = new Date();
}
/**
* Check if booking has expired (7 days without response)
*
* @returns true if booking is older than 7 days and still pending
*/
isExpired(): boolean {
if (this.status !== CsvBookingStatus.PENDING) {
return false;
}
const expirationDate = new Date(this.requestedAt);
expirationDate.setDate(expirationDate.getDate() + 7);
return new Date() > expirationDate;
}
/**
* Check if booking is still pending (awaiting response)
*/
isPending(): boolean {
return this.status === CsvBookingStatus.PENDING && !this.isExpired();
}
/**
* Check if booking was accepted
*/
isAccepted(): boolean {
return this.status === CsvBookingStatus.ACCEPTED;
}
/**
* Check if booking was rejected
*/
isRejected(): boolean {
return this.status === CsvBookingStatus.REJECTED;
}
/**
* Check if booking was cancelled
*/
isCancelled(): boolean {
return this.status === CsvBookingStatus.CANCELLED;
}
/**
* Get route description (origin → destination)
*/
getRouteDescription(): string {
return `${this.origin.getValue()}${this.destination.getValue()}`;
}
/**
* Get booking summary
*/
getSummary(): string {
return `CSV Booking ${this.id}: ${this.carrierName} - ${this.getRouteDescription()} (${this.status})`;
}
/**
* Get price in specified currency
*/
getPriceInCurrency(currency: 'USD' | 'EUR'): number {
return currency === 'USD' ? this.priceUSD : this.priceEUR;
}
/**
* Get days until expiration (negative if expired)
*/
getDaysUntilExpiration(): number {
if (this.status !== CsvBookingStatus.PENDING) {
return 0;
}
const expirationDate = new Date(this.requestedAt);
expirationDate.setDate(expirationDate.getDate() + 7);
const now = new Date();
const diffTime = expirationDate.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
}
/**
* Check if booking has a specific document type
*/
hasDocumentType(type: DocumentType): boolean {
return this.documents.some(doc => doc.type === type);
}
/**
* Get documents by type
*/
getDocumentsByType(type: DocumentType): CsvBookingDocument[] {
return this.documents.filter(doc => doc.type === type);
}
/**
* Check if all required documents are present
*/
hasAllRequiredDocuments(): boolean {
const requiredTypes = [
DocumentType.BILL_OF_LADING,
DocumentType.PACKING_LIST,
DocumentType.COMMERCIAL_INVOICE,
];
return requiredTypes.every(type => this.hasDocumentType(type));
}
/**
* Get response time in hours (if responded)
*/
getResponseTimeHours(): number | null {
if (!this.respondedAt) {
return null;
}
const diffTime = this.respondedAt.getTime() - this.requestedAt.getTime();
const diffHours = diffTime / (1000 * 60 * 60);
return Math.round(diffHours * 100) / 100; // Round to 2 decimals
}
toString(): string {
return this.getSummary();
}
/**
* Create a CsvBooking from persisted data (skips document validation)
*
* Use this when loading from database where bookings might have been created
* before document requirement was enforced, or documents were lost.
*/
static fromPersistence(
id: string,
userId: string,
organizationId: string,
carrierName: string,
carrierEmail: string,
origin: PortCode,
destination: PortCode,
volumeCBM: number,
weightKG: number,
palletCount: number,
priceUSD: number,
priceEUR: number,
primaryCurrency: string,
transitDays: number,
containerType: string,
status: CsvBookingStatus,
documents: CsvBookingDocument[],
confirmationToken: string,
requestedAt: Date,
respondedAt?: Date,
notes?: string,
rejectionReason?: string
): CsvBooking {
// Create instance without calling constructor validation
const booking = Object.create(CsvBooking.prototype);
// Assign all properties directly
booking.id = id;
booking.userId = userId;
booking.organizationId = organizationId;
booking.carrierName = carrierName;
booking.carrierEmail = carrierEmail;
booking.origin = origin;
booking.destination = destination;
booking.volumeCBM = volumeCBM;
booking.weightKG = weightKG;
booking.palletCount = palletCount;
booking.priceUSD = priceUSD;
booking.priceEUR = priceEUR;
booking.primaryCurrency = primaryCurrency;
booking.transitDays = transitDays;
booking.containerType = containerType;
booking.status = status;
booking.documents = documents || [];
booking.confirmationToken = confirmationToken;
booking.requestedAt = requestedAt;
booking.respondedAt = respondedAt;
booking.notes = notes;
booking.rejectionReason = rejectionReason;
return booking;
}
}