301 lines
7.5 KiB
TypeScript
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 };
|
|
}
|
|
}
|