396 lines
10 KiB
TypeScript
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;
|
|
}
|
|
}
|