xpeditis2.0/apps/backend/src/domain/entities/container.entity.ts
2025-10-27 20:54:01 +01:00

301 lines
7.5 KiB
TypeScript

/**
* Container Entity
*
* Represents a shipping container in a booking
*
* Business Rules:
* - Container number must follow ISO 6346 format (when provided)
* - VGM (Verified Gross Mass) is required for export shipments
* - Temperature must be within valid range for reefer containers
*/
export enum ContainerCategory {
DRY = 'DRY',
REEFER = 'REEFER',
OPEN_TOP = 'OPEN_TOP',
FLAT_RACK = 'FLAT_RACK',
TANK = 'TANK',
}
export enum ContainerSize {
TWENTY = '20',
FORTY = '40',
FORTY_FIVE = '45',
}
export enum ContainerHeight {
STANDARD = 'STANDARD',
HIGH_CUBE = 'HIGH_CUBE',
}
export interface ContainerProps {
id: string;
bookingId?: string; // Optional until container is assigned to a booking
type: string; // e.g., '20DRY', '40HC', '40REEFER'
category: ContainerCategory;
size: ContainerSize;
height: ContainerHeight;
containerNumber?: string; // ISO 6346 format (assigned by carrier)
sealNumber?: string;
vgm?: number; // Verified Gross Mass in kg
tareWeight?: number; // Empty container weight in kg
maxGrossWeight?: number; // Maximum gross weight in kg
temperature?: number; // For reefer containers (°C)
humidity?: number; // For reefer containers (%)
ventilation?: string; // For reefer containers
isHazmat: boolean;
imoClass?: string; // IMO hazmat class (if hazmat)
cargoDescription?: string;
createdAt: Date;
updatedAt: Date;
}
export class Container {
private readonly props: ContainerProps;
private constructor(props: ContainerProps) {
this.props = props;
}
/**
* Factory method to create a new Container
*/
static create(props: Omit<ContainerProps, 'createdAt' | 'updatedAt'>): Container {
const now = new Date();
// Validate container number format if provided
if (props.containerNumber && !Container.isValidContainerNumber(props.containerNumber)) {
throw new Error('Invalid container number format. Must follow ISO 6346 standard.');
}
// Validate VGM if provided
if (props.vgm !== undefined && props.vgm <= 0) {
throw new Error('VGM must be positive.');
}
// Validate temperature for reefer containers
if (props.category === ContainerCategory.REEFER) {
if (props.temperature === undefined) {
throw new Error('Temperature is required for reefer containers.');
}
if (props.temperature < -40 || props.temperature > 40) {
throw new Error('Temperature must be between -40°C and +40°C.');
}
}
// Validate hazmat
if (props.isHazmat && !props.imoClass) {
throw new Error('IMO class is required for hazmat containers.');
}
return new Container({
...props,
createdAt: now,
updatedAt: now,
});
}
/**
* Factory method to reconstitute from persistence
*/
static fromPersistence(props: ContainerProps): Container {
return new Container(props);
}
/**
* Validate ISO 6346 container number format
* Format: 4 letters (owner code) + 6 digits + 1 check digit
* Example: MSCU1234567
*/
private static isValidContainerNumber(containerNumber: string): boolean {
const pattern = /^[A-Z]{4}\d{7}$/;
if (!pattern.test(containerNumber)) {
return false;
}
// Validate check digit (ISO 6346 algorithm)
const ownerCode = containerNumber.substring(0, 4);
const serialNumber = containerNumber.substring(4, 10);
const checkDigit = parseInt(containerNumber.substring(10, 11), 10);
// Convert letters to numbers (A=10, B=12, C=13, ..., Z=38)
const letterValues: { [key: string]: number } = {};
'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').forEach((letter, index) => {
letterValues[letter] = 10 + index + Math.floor(index / 2);
});
// Calculate sum
let sum = 0;
for (let i = 0; i < ownerCode.length; i++) {
sum += letterValues[ownerCode[i]] * Math.pow(2, i);
}
for (let i = 0; i < serialNumber.length; i++) {
sum += parseInt(serialNumber[i], 10) * Math.pow(2, i + 4);
}
// Check digit = sum % 11 (if 10, use 0)
const calculatedCheckDigit = sum % 11 === 10 ? 0 : sum % 11;
return calculatedCheckDigit === checkDigit;
}
// Getters
get id(): string {
return this.props.id;
}
get bookingId(): string | undefined {
return this.props.bookingId;
}
get type(): string {
return this.props.type;
}
get category(): ContainerCategory {
return this.props.category;
}
get size(): ContainerSize {
return this.props.size;
}
get height(): ContainerHeight {
return this.props.height;
}
get containerNumber(): string | undefined {
return this.props.containerNumber;
}
get sealNumber(): string | undefined {
return this.props.sealNumber;
}
get vgm(): number | undefined {
return this.props.vgm;
}
get tareWeight(): number | undefined {
return this.props.tareWeight;
}
get maxGrossWeight(): number | undefined {
return this.props.maxGrossWeight;
}
get temperature(): number | undefined {
return this.props.temperature;
}
get humidity(): number | undefined {
return this.props.humidity;
}
get ventilation(): string | undefined {
return this.props.ventilation;
}
get isHazmat(): boolean {
return this.props.isHazmat;
}
get imoClass(): string | undefined {
return this.props.imoClass;
}
get cargoDescription(): string | undefined {
return this.props.cargoDescription;
}
get createdAt(): Date {
return this.props.createdAt;
}
get updatedAt(): Date {
return this.props.updatedAt;
}
// Business methods
isReefer(): boolean {
return this.props.category === ContainerCategory.REEFER;
}
isDry(): boolean {
return this.props.category === ContainerCategory.DRY;
}
isHighCube(): boolean {
return this.props.height === ContainerHeight.HIGH_CUBE;
}
getTEU(): number {
// Twenty-foot Equivalent Unit
if (this.props.size === ContainerSize.TWENTY) {
return 1;
} else if (
this.props.size === ContainerSize.FORTY ||
this.props.size === ContainerSize.FORTY_FIVE
) {
return 2;
}
return 0;
}
getPayload(): number | undefined {
if (this.props.vgm !== undefined && this.props.tareWeight !== undefined) {
return this.props.vgm - this.props.tareWeight;
}
return undefined;
}
assignContainerNumber(containerNumber: string): void {
if (!Container.isValidContainerNumber(containerNumber)) {
throw new Error('Invalid container number format.');
}
this.props.containerNumber = containerNumber;
this.props.updatedAt = new Date();
}
assignSealNumber(sealNumber: string): void {
this.props.sealNumber = sealNumber;
this.props.updatedAt = new Date();
}
setVGM(vgm: number): void {
if (vgm <= 0) {
throw new Error('VGM must be positive.');
}
this.props.vgm = vgm;
this.props.updatedAt = new Date();
}
setTemperature(temperature: number): void {
if (!this.isReefer()) {
throw new Error('Cannot set temperature for non-reefer container.');
}
if (temperature < -40 || temperature > 40) {
throw new Error('Temperature must be between -40°C and +40°C.');
}
this.props.temperature = temperature;
this.props.updatedAt = new Date();
}
setCargoDescription(description: string): void {
this.props.cargoDescription = description;
this.props.updatedAt = new Date();
}
assignToBooking(bookingId: string): void {
this.props.bookingId = bookingId;
this.props.updatedAt = new Date();
}
/**
* Convert to plain object for persistence
*/
toObject(): ContainerProps {
return { ...this.props };
}
}