210 lines
4.9 KiB
TypeScript
210 lines
4.9 KiB
TypeScript
/**
|
|
* Port Entity
|
|
*
|
|
* Represents a maritime port (based on UN/LOCODE standard)
|
|
*
|
|
* Business Rules:
|
|
* - Port code must follow UN/LOCODE format (2-letter country + 3-letter location)
|
|
* - Coordinates must be valid latitude/longitude
|
|
*/
|
|
|
|
export interface PortCoordinates {
|
|
latitude: number;
|
|
longitude: number;
|
|
}
|
|
|
|
export interface PortProps {
|
|
id: string;
|
|
code: string; // UN/LOCODE (e.g., 'NLRTM' for Rotterdam)
|
|
name: string; // Port name
|
|
city: string;
|
|
country: string; // ISO 3166-1 alpha-2 country code
|
|
countryName: string; // Full country name
|
|
coordinates: PortCoordinates;
|
|
timezone?: string; // IANA timezone (e.g., 'Europe/Amsterdam')
|
|
isActive: boolean;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
}
|
|
|
|
export class Port {
|
|
private readonly props: PortProps;
|
|
|
|
private constructor(props: PortProps) {
|
|
this.props = props;
|
|
}
|
|
|
|
/**
|
|
* Factory method to create a new Port
|
|
*/
|
|
static create(props: Omit<PortProps, 'createdAt' | 'updatedAt'>): Port {
|
|
const now = new Date();
|
|
|
|
// Validate UN/LOCODE format
|
|
if (!Port.isValidUNLOCODE(props.code)) {
|
|
throw new Error('Invalid port code format. Must follow UN/LOCODE format (e.g., NLRTM).');
|
|
}
|
|
|
|
// Validate country code
|
|
if (!Port.isValidCountryCode(props.country)) {
|
|
throw new Error('Invalid country code. Must be ISO 3166-1 alpha-2 format (e.g., NL).');
|
|
}
|
|
|
|
// Validate coordinates
|
|
if (!Port.isValidCoordinates(props.coordinates)) {
|
|
throw new Error('Invalid coordinates.');
|
|
}
|
|
|
|
return new Port({
|
|
...props,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Factory method to reconstitute from persistence
|
|
*/
|
|
static fromPersistence(props: PortProps): Port {
|
|
return new Port(props);
|
|
}
|
|
|
|
/**
|
|
* Validate UN/LOCODE format (5 characters: 2-letter country code + 3-letter location code)
|
|
*/
|
|
private static isValidUNLOCODE(code: string): boolean {
|
|
const unlocodePattern = /^[A-Z]{2}[A-Z0-9]{3}$/;
|
|
return unlocodePattern.test(code);
|
|
}
|
|
|
|
/**
|
|
* Validate ISO 3166-1 alpha-2 country code
|
|
*/
|
|
private static isValidCountryCode(code: string): boolean {
|
|
const countryCodePattern = /^[A-Z]{2}$/;
|
|
return countryCodePattern.test(code);
|
|
}
|
|
|
|
/**
|
|
* Validate coordinates
|
|
*/
|
|
private static isValidCoordinates(coords: PortCoordinates): boolean {
|
|
const { latitude, longitude } = coords;
|
|
return latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180;
|
|
}
|
|
|
|
// Getters
|
|
get id(): string {
|
|
return this.props.id;
|
|
}
|
|
|
|
get code(): string {
|
|
return this.props.code;
|
|
}
|
|
|
|
get name(): string {
|
|
return this.props.name;
|
|
}
|
|
|
|
get city(): string {
|
|
return this.props.city;
|
|
}
|
|
|
|
get country(): string {
|
|
return this.props.country;
|
|
}
|
|
|
|
get countryName(): string {
|
|
return this.props.countryName;
|
|
}
|
|
|
|
get coordinates(): PortCoordinates {
|
|
return { ...this.props.coordinates };
|
|
}
|
|
|
|
get timezone(): string | undefined {
|
|
return this.props.timezone;
|
|
}
|
|
|
|
get isActive(): boolean {
|
|
return this.props.isActive;
|
|
}
|
|
|
|
get createdAt(): Date {
|
|
return this.props.createdAt;
|
|
}
|
|
|
|
get updatedAt(): Date {
|
|
return this.props.updatedAt;
|
|
}
|
|
|
|
// Business methods
|
|
/**
|
|
* Get display name (e.g., "Rotterdam, Netherlands (NLRTM)")
|
|
*/
|
|
getDisplayName(): string {
|
|
return `${this.props.name}, ${this.props.countryName} (${this.props.code})`;
|
|
}
|
|
|
|
/**
|
|
* Calculate distance to another port (Haversine formula)
|
|
* Returns distance in kilometers
|
|
*/
|
|
distanceTo(otherPort: Port): number {
|
|
const R = 6371; // Earth's radius in kilometers
|
|
const lat1 = this.toRadians(this.props.coordinates.latitude);
|
|
const lat2 = this.toRadians(otherPort.coordinates.latitude);
|
|
const deltaLat = this.toRadians(
|
|
otherPort.coordinates.latitude - this.props.coordinates.latitude
|
|
);
|
|
const deltaLon = this.toRadians(
|
|
otherPort.coordinates.longitude - this.props.coordinates.longitude
|
|
);
|
|
|
|
const a =
|
|
Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
|
|
Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2);
|
|
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
|
|
return R * c;
|
|
}
|
|
|
|
private toRadians(degrees: number): number {
|
|
return degrees * (Math.PI / 180);
|
|
}
|
|
|
|
updateCoordinates(coordinates: PortCoordinates): void {
|
|
if (!Port.isValidCoordinates(coordinates)) {
|
|
throw new Error('Invalid coordinates.');
|
|
}
|
|
this.props.coordinates = { ...coordinates };
|
|
this.props.updatedAt = new Date();
|
|
}
|
|
|
|
updateTimezone(timezone: string): void {
|
|
this.props.timezone = timezone;
|
|
this.props.updatedAt = new Date();
|
|
}
|
|
|
|
deactivate(): void {
|
|
this.props.isActive = false;
|
|
this.props.updatedAt = new Date();
|
|
}
|
|
|
|
activate(): void {
|
|
this.props.isActive = true;
|
|
this.props.updatedAt = new Date();
|
|
}
|
|
|
|
/**
|
|
* Convert to plain object for persistence
|
|
*/
|
|
toObject(): PortProps {
|
|
return {
|
|
...this.props,
|
|
coordinates: { ...this.props.coordinates },
|
|
};
|
|
}
|
|
}
|