mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 13:10:56 +00:00
Merge branch 'freight/develop' of github.com:Tria-plc/edr-platform into freight_feature/priority
This commit is contained in:
@@ -23,6 +23,9 @@ export const BOOKING_STATUSES = [
|
||||
'PENDING_APPROVAL',
|
||||
'APPROVED_PENDING_SIGNATURE',
|
||||
'APPROVED',
|
||||
'READY_FOR_ASSIGNMENT',
|
||||
'WAGON_ASSIGNED',
|
||||
'INVOICED',
|
||||
'CONTRACT_READY',
|
||||
'SIGNED_CUSTOMER',
|
||||
'FULLY_EXECUTED',
|
||||
|
||||
@@ -157,7 +157,9 @@ export class CargoesService {
|
||||
}
|
||||
|
||||
cargo.status = 'DELIVERED';
|
||||
if (dto?.deliveryRemarks) cargo.description = dto.deliveryRemarks;
|
||||
cargo.deliveredAt = dto?.pickupDate ? new Date(dto.pickupDate) : new Date();
|
||||
if (dto?.receiverName) cargo.receiverName = dto.receiverName;
|
||||
if (dto?.deliveryRemarks) cargo.deliveryRemarks = dto.deliveryRemarks;
|
||||
|
||||
const remaining =
|
||||
cargo.containerId != null
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
import { IsDateString, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class DeliverCargoDto {
|
||||
/** Name of the person who received / picked up the cargo (Proof of Delivery). */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
receiverName?: string;
|
||||
|
||||
/** When the cargo was picked up / delivered. Defaults to now. */
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
pickupDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
deliveryRemarks?: string;
|
||||
|
||||
@@ -40,6 +40,16 @@ export class Cargo extends BaseEntity {
|
||||
@Column({ name: 'unloaded_at', type: 'timestamp', nullable: true })
|
||||
unloadedAt!: Date | null;
|
||||
|
||||
// Proof of Delivery (customer pickup) capture.
|
||||
@Column({ name: 'receiver_name', type: 'varchar', nullable: true })
|
||||
receiverName!: string | null;
|
||||
|
||||
@Column({ name: 'delivered_at', type: 'timestamp', nullable: true })
|
||||
deliveredAt!: Date | null;
|
||||
|
||||
@Column({ name: 'delivery_remarks', type: 'text', nullable: true })
|
||||
deliveryRemarks!: string | null;
|
||||
|
||||
@Column({ name: 'wagon_booking_allocation_id', type: 'uuid', nullable: true })
|
||||
wagonBookingAllocationId!: string | null;
|
||||
|
||||
@@ -57,6 +67,7 @@ export class Cargo extends BaseEntity {
|
||||
@Column({ name: 'load_type', type: 'varchar', length: 20, nullable: true })
|
||||
loadType!: string | null;
|
||||
|
||||
// Relationship to Container
|
||||
@ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT', nullable: true })
|
||||
@JoinColumn({ name: 'container_id' })
|
||||
container!: Container | null;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { FacilityStatus, FacilityType } from '../entities/facility.entity';
|
||||
|
||||
export class CreateFacilityDto {
|
||||
code!: string;
|
||||
name!: string;
|
||||
description?: string;
|
||||
facilityType!: FacilityType;
|
||||
facilityStatus?: FacilityStatus;
|
||||
locationName?: string;
|
||||
country?: string;
|
||||
city?: string;
|
||||
address?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
capacity?: number;
|
||||
isActive?: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { FacilityStatus, FacilityType } from '../entities/facility.entity';
|
||||
|
||||
export class UpdateFacilityDto {
|
||||
code?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
facilityType?: FacilityType;
|
||||
facilityStatus?: FacilityStatus;
|
||||
locationName?: string;
|
||||
country?: string;
|
||||
city?: string;
|
||||
address?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
capacity?: number;
|
||||
isActive?: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, OneToMany } from 'typeorm';
|
||||
|
||||
import { Warehouse } from '../../warehouses/entities/warehouse.entity';
|
||||
|
||||
export const FACILITY_TYPES = ['PORT', 'DRY_PORT', 'TERMINAL', 'RAIL_YARD', 'WAREHOUSE_COMPLEX'] as const;
|
||||
export type FacilityType = (typeof FACILITY_TYPES)[number];
|
||||
|
||||
export const FACILITY_STATUSES = ['ACTIVE', 'INACTIVE', 'UNDER_MAINTENANCE'] as const;
|
||||
export type FacilityStatus = (typeof FACILITY_STATUSES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'facilities' })
|
||||
@Index(['code'], { unique: true })
|
||||
@Index(['facilityStatus'])
|
||||
export class Facility extends BaseEntity {
|
||||
@Column({ name: 'code', type: 'varchar', length: 40, unique: true })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 160 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'description', type: 'text', nullable: true })
|
||||
description?: string | null;
|
||||
|
||||
@Column({ name: 'facility_type', type: 'varchar', length: 32 })
|
||||
facilityType!: FacilityType;
|
||||
|
||||
@Column({ name: 'facility_status', type: 'varchar', length: 32, default: 'ACTIVE' })
|
||||
facilityStatus!: FacilityStatus;
|
||||
|
||||
@Column({ name: 'location_name', type: 'varchar', length: 200, nullable: true })
|
||||
locationName?: string | null;
|
||||
|
||||
@Column({ name: 'country', type: 'varchar', length: 100, nullable: true })
|
||||
country?: string | null;
|
||||
|
||||
@Column({ name: 'city', type: 'varchar', length: 100, nullable: true })
|
||||
city?: string | null;
|
||||
|
||||
@Column({ name: 'address', type: 'text', nullable: true })
|
||||
address?: string | null;
|
||||
|
||||
@Column({ name: 'latitude', type: 'numeric', precision: 10, scale: 8, nullable: true })
|
||||
latitude?: number | null;
|
||||
|
||||
@Column({ name: 'longitude', type: 'numeric', precision: 11, scale: 8, nullable: true })
|
||||
longitude?: number | null;
|
||||
|
||||
@Column({ name: 'capacity', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
capacity?: number | null;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
|
||||
@OneToMany(() => Warehouse, (warehouse) => warehouse.facility)
|
||||
warehouses?: Warehouse[];
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { CreateFacilityDto } from './dto/create-facility.dto';
|
||||
import { UpdateFacilityDto } from './dto/update-facility.dto';
|
||||
import { Facility } from './entities/facility.entity';
|
||||
import { FacilitiesService } from './facilities.service';
|
||||
|
||||
@ApiTags('Facilities')
|
||||
@Controller('facilities')
|
||||
export class FacilitiesController {
|
||||
constructor(private readonly facilitiesService: FacilitiesService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new facility' })
|
||||
async create(@Body() createFacilityDto: CreateFacilityDto): Promise<Facility> {
|
||||
return this.facilitiesService.create(createFacilityDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all facilities' })
|
||||
async findAll(): Promise<Facility[]> {
|
||||
return this.facilitiesService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a facility by ID' })
|
||||
async findOne(@Param('id') id: string): Promise<Facility | null> {
|
||||
return this.facilitiesService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a facility' })
|
||||
async update(@Param('id') id: string, @Body() updateFacilityDto: UpdateFacilityDto): Promise<Facility | null> {
|
||||
return this.facilitiesService.update(id, updateFacilityDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Delete a facility (soft delete)' })
|
||||
async remove(@Param('id') id: string): Promise<void> {
|
||||
return this.facilitiesService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { Facility } from './entities/facility.entity';
|
||||
import { FacilitiesController } from './facilities.controller';
|
||||
import { FacilitiesRepository } from './facilities.repository';
|
||||
import { FacilitiesService } from './facilities.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Facility])],
|
||||
controllers: [FacilitiesController],
|
||||
providers: [FacilitiesService, FacilitiesRepository],
|
||||
exports: [FacilitiesService, FacilitiesRepository],
|
||||
})
|
||||
export class FacilitiesModule {}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { Facility } from './entities/facility.entity';
|
||||
|
||||
@Injectable()
|
||||
export class FacilitiesRepository extends BaseRepository<Facility> {
|
||||
constructor(@InjectRepository(Facility) repository: Repository<Facility>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { CreateFacilityDto } from './dto/create-facility.dto';
|
||||
import { UpdateFacilityDto } from './dto/update-facility.dto';
|
||||
import { Facility } from './entities/facility.entity';
|
||||
import { FacilitiesRepository } from './facilities.repository';
|
||||
|
||||
@Injectable()
|
||||
export class FacilitiesService {
|
||||
constructor(private readonly facilitiesRepository: FacilitiesRepository) {}
|
||||
|
||||
async create(createFacilityDto: CreateFacilityDto): Promise<Facility> {
|
||||
return this.facilitiesRepository.create(createFacilityDto);
|
||||
}
|
||||
|
||||
async findAll(): Promise<Facility[]> {
|
||||
return this.facilitiesRepository.findAll({ relations: ['warehouses'] });
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<Facility | null> {
|
||||
return this.facilitiesRepository.findById(id);
|
||||
}
|
||||
|
||||
async update(id: string, updateFacilityDto: UpdateFacilityDto): Promise<Facility | null> {
|
||||
return this.facilitiesRepository.update(id, updateFacilityDto);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
return this.facilitiesRepository.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
|
||||
export const LOCOMOTIVE_STATUSES = [
|
||||
'AVAILABLE',
|
||||
'UNAVAILABLE',
|
||||
'IMPORT_READY',
|
||||
'EXPORT_READY',
|
||||
'ASSIGNED',
|
||||
'MAINTENANCE',
|
||||
'OUT_OF_SERVICE',
|
||||
|
||||
@@ -29,6 +29,13 @@ export class TrainSetWagon extends BaseEntity {
|
||||
@Column({ name: 'wagon_type_id', type: 'uuid' })
|
||||
wagonTypeId!: string;
|
||||
|
||||
@Column({ name: 'physical_wagon_id', type: 'uuid', nullable: true })
|
||||
physicalWagonId!: string | null;
|
||||
|
||||
@ManyToOne(() => Wagon, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'physical_wagon_id' })
|
||||
physicalWagon?: Wagon | null;
|
||||
|
||||
@ManyToOne(() => WagonType, (wagonType) => wagonType.trainSetWagons)
|
||||
@JoinColumn({ name: 'wagon_type_id' })
|
||||
wagonType?: WagonType;
|
||||
@@ -45,13 +52,6 @@ export class TrainSetWagon extends BaseEntity {
|
||||
@Column({ name: 'assigned_weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 })
|
||||
assignedWeightTons!: number;
|
||||
|
||||
@Column({ name: 'physical_wagon_id', type: 'uuid', nullable: true })
|
||||
physicalWagonId?: string | null;
|
||||
|
||||
@ManyToOne(() => Wagon, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'physical_wagon_id' })
|
||||
physicalWagon?: Wagon | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PLANNED' })
|
||||
status!: string;
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Wagon } from './entities/wagon.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { WagonsController, TrainWagonsReorderController } from './wagons.controller';
|
||||
import { WagonsService } from './wagons.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Wagon, Train])],
|
||||
imports: [TypeOrmModule.forFeature([Wagon, Train, Yard])],
|
||||
controllers: [WagonsController, TrainWagonsReorderController],
|
||||
providers: [WagonsService],
|
||||
exports: [WagonsService],
|
||||
})
|
||||
export class WagonsModule {}
|
||||
export class WagonsModule {}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateAllocationRuleDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 100 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
priority?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
freightType?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tradeDirection?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cargoTypeCode?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerStatus?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresInspection?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
targetFacilityCode?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
targetYardCode!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
targetWarehouseCode?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
targetZoneCode?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
storageType?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateAllocationRuleDto extends PartialType(CreateAllocationRuleDto) {}
|
||||
|
||||
export class AllocationPreviewDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
freightType?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tradeDirection?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cargoTypeCode?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerStatus?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresInspection?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
import {
|
||||
INSPECTION_REPORT_TYPES,
|
||||
INSPECTION_STATUSES,
|
||||
InspectionReportType,
|
||||
InspectionStatus,
|
||||
} from '../entities/warehouse-inspection-report.entity';
|
||||
|
||||
export class CreateInspectionReportDto {
|
||||
@ApiProperty({ enum: INSPECTION_REPORT_TYPES })
|
||||
@IsEnum(INSPECTION_REPORT_TYPES)
|
||||
reportType!: InspectionReportType;
|
||||
|
||||
@ApiProperty({ enum: INSPECTION_STATUSES })
|
||||
@IsEnum(INSPECTION_STATUSES)
|
||||
inspectionStatus!: InspectionStatus;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
hasDamage?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
damageDescription?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
hasWeightLoss?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
expectedWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
actualWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
hasMissingItems?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
missingItemsDescription?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
remarks?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
inspectedById?: string;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
|
||||
import { WAREHOUSE_YARD_TYPES, WarehouseYardType } from '../entities/warehouse-yard.entity';
|
||||
|
||||
export class CreateWarehouseYardDto {
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
warehouseId?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ enum: WAREHOUSE_YARD_TYPES })
|
||||
@IsEnum(WAREHOUSE_YARD_TYPES)
|
||||
type!: WarehouseYardType;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
capacityWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
capacityContainers?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxWeight?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Max volume capacity (m³).' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxVolume?: number;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
|
||||
import { WAREHOUSE_ZONE_TYPES, WarehouseZoneType } from '../entities/warehouse-zone.entity';
|
||||
|
||||
export class CreateWarehouseZoneDto {
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
yardId?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ enum: WAREHOUSE_ZONE_TYPES })
|
||||
@IsEnum(WAREHOUSE_ZONE_TYPES)
|
||||
type!: WarehouseZoneType;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
capacityWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
capacityContainers?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxWeight?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Max volume capacity (m³).' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxVolume?: number;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
|
||||
import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity';
|
||||
|
||||
export class CreateWarehouseDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ enum: WAREHOUSE_TYPES })
|
||||
@IsEnum(WAREHOUSE_TYPES)
|
||||
type!: WarehouseType;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
stationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Parent facility / port this warehouse belongs to.' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
facilityId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
locationName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
capacityWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
capacityContainers?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxWeight?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Max volume capacity (m³).' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxVolume?: number;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||
|
||||
import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity';
|
||||
|
||||
export class CreateFeeRuleDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ enum: FEE_RULE_TYPES })
|
||||
@IsEnum(FEE_RULE_TYPES)
|
||||
ruleType!: FeeRuleType;
|
||||
|
||||
@ApiPropertyOptional({ default: 100 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
priority?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
freightType?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tradeDirection?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cargoTypeCode?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerType?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
facilityId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
warehouseId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
yardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
zoneId?: string;
|
||||
|
||||
@ApiProperty({ description: 'Grace period in days before charging starts.' })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
freeDays!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
ratePerDay!: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 'USD' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export class UpdateFeeRuleDto extends PartialType(CreateFeeRuleDto) {}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
import {
|
||||
WAREHOUSE_INVENTORY_STATUSES,
|
||||
WarehouseInventoryStatus,
|
||||
} from '../entities/warehouse-inventory.entity';
|
||||
|
||||
export class FilterWarehouseInventoryDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
warehouseId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
yardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
zoneId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
bookingId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cargoId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
containerId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
goodsId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: WAREHOUSE_INVENTORY_STATUSES })
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_INVENTORY_STATUSES)
|
||||
status?: WarehouseInventoryStatus;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity';
|
||||
|
||||
export class FilterWarehouseDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: WAREHOUSE_TYPES })
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_TYPES)
|
||||
type?: WarehouseType;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
stationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: WAREHOUSE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_STATUSES)
|
||||
status?: WarehouseStatus;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
import {
|
||||
WAREHOUSE_INVENTORY_STATUSES,
|
||||
WarehouseInventoryStatus,
|
||||
} from '../entities/warehouse-inventory.entity';
|
||||
|
||||
export class InquiryWarehouseInventoryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bookingNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cargoType?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
goodsName?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
warehouseId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
yardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
zoneId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: WAREHOUSE_INVENTORY_STATUSES })
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_INVENTORY_STATUSES)
|
||||
status?: WarehouseInventoryStatus;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||
|
||||
export class GenerateInvoiceDto {
|
||||
@ApiPropertyOptional({ description: 'Create even when the calculated amount is zero.' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
confirmZero?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export class PayInvoiceBodyDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
amount!: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
method?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reference?: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class LoadInventoryDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'Physical wagon the item is loaded onto' })
|
||||
@IsUUID()
|
||||
wagonId!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Weight loaded onto the wagon (kg)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
loadedWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
loadedBy?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class MoveInventoryDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
warehouseId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
yardId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
zoneId!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
remarks?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
movedBy?: string;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||
|
||||
export class ReceiveWarehouseInventoryDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
warehouseId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
yardId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
zoneId!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
bookingId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cargoId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
containerId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
goodsId?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
quantity!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
weight!: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
volume?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class ReserveInventoryDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
bookingId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
inventoryId!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class UnloadBookingDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
facilityId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
warehouseId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
yardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
zoneId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
unloadedAt?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
|
||||
import { CreateInspectionReportDto } from './create-inspection-report.dto';
|
||||
|
||||
export class UpdateInspectionReportDto extends PartialType(CreateInspectionReportDto) {}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
|
||||
import { WAREHOUSE_YARD_STATUSES, WarehouseYardStatus } from '../entities/warehouse-yard.entity';
|
||||
import { CreateWarehouseYardDto } from './create-warehouse-yard.dto';
|
||||
|
||||
export class UpdateWarehouseYardDto extends PartialType(CreateWarehouseYardDto) {
|
||||
@ApiPropertyOptional({ enum: WAREHOUSE_YARD_STATUSES })
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_YARD_STATUSES)
|
||||
status?: WarehouseYardStatus;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
|
||||
import { WAREHOUSE_ZONE_STATUSES, WarehouseZoneStatus } from '../entities/warehouse-zone.entity';
|
||||
import { CreateWarehouseZoneDto } from './create-warehouse-zone.dto';
|
||||
|
||||
export class UpdateWarehouseZoneDto extends PartialType(CreateWarehouseZoneDto) {
|
||||
@ApiPropertyOptional({ enum: WAREHOUSE_ZONE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_ZONE_STATUSES)
|
||||
status?: WarehouseZoneStatus;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
|
||||
import { WAREHOUSE_STATUSES, WarehouseStatus } from '../entities/warehouse.entity';
|
||||
import { CreateWarehouseDto } from './create-warehouse.dto';
|
||||
|
||||
export class UpdateWarehouseDto extends PartialType(CreateWarehouseDto) {
|
||||
@ApiPropertyOptional({ enum: WAREHOUSE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_STATUSES)
|
||||
status?: WarehouseStatus;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
export const WAREHOUSE_ACTIVITY_TYPES = [
|
||||
'INVENTORY_RECEIVED',
|
||||
'INVENTORY_STORED',
|
||||
'INVENTORY_MOVED',
|
||||
'INVENTORY_RESERVED',
|
||||
'READY_FOR_LOADING',
|
||||
'INVENTORY_LOADED',
|
||||
'INVENTORY_DISPATCHED',
|
||||
] as const;
|
||||
export type WarehouseActivityType = (typeof WAREHOUSE_ACTIVITY_TYPES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_activity_log' })
|
||||
@Index(['inventoryId'])
|
||||
@Index(['warehouseId'])
|
||||
@Index(['activityType'])
|
||||
export class WarehouseActivityLog extends BaseEntity {
|
||||
@Column({ name: 'inventory_id', type: 'uuid', nullable: true })
|
||||
inventoryId?: string | null;
|
||||
|
||||
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
|
||||
warehouseId?: string | null;
|
||||
|
||||
@Column({ name: 'activity_type', type: 'varchar', length: 40 })
|
||||
activityType!: WarehouseActivityType;
|
||||
|
||||
@Column({ name: 'description', type: 'text', nullable: true })
|
||||
description?: string | null;
|
||||
|
||||
@Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true })
|
||||
performedBy?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Batch 5 — deterministic warehouse/yard allocation.
|
||||
* A booking's (freightType, tradeDirection, cargoType, containerStatus, inspection)
|
||||
* is matched against active rules in ascending `priority`; the first match wins and
|
||||
* resolves the target Yard (and optional Warehouse/Zone) by code.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'warehouse_allocation_rules' })
|
||||
@Index(['priority'])
|
||||
@Index(['isActive'])
|
||||
export class WarehouseAllocationRule extends BaseEntity {
|
||||
@Column({ name: 'name', type: 'varchar', length: 160 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'priority', type: 'int', default: 100 })
|
||||
priority!: number;
|
||||
|
||||
// ── Match criteria (null = wildcard) ──────────────────────────────────────
|
||||
@Column({ name: 'freight_type', type: 'varchar', length: 16, nullable: true })
|
||||
freightType?: string | null; // CONTAINER | BULK
|
||||
|
||||
@Column({ name: 'trade_direction', type: 'varchar', length: 16, nullable: true })
|
||||
tradeDirection?: string | null; // IMPORT | EXPORT | DOMESTIC | BOTH
|
||||
|
||||
@Column({ name: 'cargo_type_code', type: 'varchar', length: 50, nullable: true })
|
||||
cargoTypeCode?: string | null;
|
||||
|
||||
@Column({ name: 'container_status', type: 'varchar', length: 24, nullable: true })
|
||||
containerStatus?: string | null; // e.g. EMPTY | MAINTENANCE
|
||||
|
||||
@Column({ name: 'requires_inspection', type: 'boolean', nullable: true })
|
||||
requiresInspection?: boolean | null;
|
||||
|
||||
// ── Resolved target (by code) ─────────────────────────────────────────────
|
||||
@Column({ name: 'target_facility_code', type: 'varchar', length: 40, nullable: true })
|
||||
targetFacilityCode?: string | null;
|
||||
|
||||
@Column({ name: 'target_yard_code', type: 'varchar', length: 40 })
|
||||
targetYardCode!: string;
|
||||
|
||||
@Column({ name: 'target_warehouse_code', type: 'varchar', length: 40, nullable: true })
|
||||
targetWarehouseCode?: string | null;
|
||||
|
||||
@Column({ name: 'target_zone_code', type: 'varchar', length: 40, nullable: true })
|
||||
targetZoneCode?: string | null;
|
||||
|
||||
@Column({ name: 'storage_type', type: 'varchar', length: 80, nullable: true })
|
||||
storageType?: string | null; // descriptive: "Container terminal import / stack area"
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { WarehouseFeeInvoice } from './warehouse-fee-invoice.entity';
|
||||
|
||||
export const WAREHOUSE_FEE_TYPES = [
|
||||
'CONTAINER_DEMURRAGE',
|
||||
'BULK_DEMURRAGE',
|
||||
'STORAGE_FEE',
|
||||
'HANDLING_FEE',
|
||||
] as const;
|
||||
export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_fee_invoice_items' })
|
||||
@Index(['invoiceId'])
|
||||
export class WarehouseFeeInvoiceItem extends BaseEntity {
|
||||
@Column({ name: 'invoice_id', type: 'uuid' })
|
||||
invoiceId!: string;
|
||||
|
||||
@ManyToOne(() => WarehouseFeeInvoice, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'invoice_id' })
|
||||
invoice?: WarehouseFeeInvoice;
|
||||
|
||||
@Column({ name: 'fee_rule_id', type: 'uuid', nullable: true })
|
||||
feeRuleId?: string | null;
|
||||
|
||||
@Column({ name: 'fee_type', type: 'varchar', length: 32 })
|
||||
feeType!: WarehouseFeeType;
|
||||
|
||||
@Column({ name: 'description', type: 'varchar', length: 255 })
|
||||
description!: string;
|
||||
|
||||
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 2, default: 1 })
|
||||
quantity!: number;
|
||||
|
||||
@Column({ name: 'unit_rate', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
unitRate!: number;
|
||||
|
||||
@Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
amount!: number;
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' })
|
||||
currency!: string;
|
||||
|
||||
@Column({ name: 'chargeable_days', type: 'int', nullable: true })
|
||||
chargeableDays?: number | null;
|
||||
|
||||
@Column({ name: 'free_days', type: 'int', nullable: true })
|
||||
freeDays?: number | null;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const;
|
||||
export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number];
|
||||
|
||||
export const WAREHOUSE_INVOICE_STATUSES = [
|
||||
'DRAFT',
|
||||
'ISSUED',
|
||||
'PARTIALLY_PAID',
|
||||
'PAID',
|
||||
'CANCELLED',
|
||||
] as const;
|
||||
export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number];
|
||||
|
||||
/** A single recorded payment against a warehouse fee invoice (history). */
|
||||
export interface WarehouseInvoicePayment {
|
||||
amount: number;
|
||||
method?: string | null;
|
||||
reference?: string | null;
|
||||
paidAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch 6 — invoice generated from Batch 5 demurrage/storage fee calculation.
|
||||
* Owns warehouse fees; links to booking/customer/inventory/location so it can
|
||||
* connect to the existing payment module without duplicating it.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'warehouse_fee_invoices' })
|
||||
@Index(['invoiceNumber'], { unique: true })
|
||||
@Index(['bookingId'])
|
||||
@Index(['inventoryId'])
|
||||
@Index(['status'])
|
||||
export class WarehouseFeeInvoice extends BaseEntity {
|
||||
@Column({ name: 'invoice_number', type: 'varchar', length: 40, unique: true })
|
||||
invoiceNumber!: string;
|
||||
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId?: string | null;
|
||||
|
||||
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
|
||||
customerId?: string | null;
|
||||
|
||||
@Column({ name: 'inventory_id', type: 'uuid' })
|
||||
inventoryId!: string;
|
||||
|
||||
@Column({ name: 'facility_id', type: 'uuid', nullable: true })
|
||||
facilityId?: string | null;
|
||||
|
||||
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
|
||||
warehouseId?: string | null;
|
||||
|
||||
@Column({ name: 'yard_id', type: 'uuid', nullable: true })
|
||||
yardId?: string | null;
|
||||
|
||||
@Column({ name: 'zone_id', type: 'uuid', nullable: true })
|
||||
zoneId?: string | null;
|
||||
|
||||
@Column({ name: 'invoice_type', type: 'varchar', length: 32, default: 'MIXED_WAREHOUSE_FEES' })
|
||||
invoiceType!: WarehouseInvoiceType;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
|
||||
status!: WarehouseInvoiceStatus;
|
||||
|
||||
@Column({ name: 'subtotal_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
subtotalAmount!: number;
|
||||
|
||||
@Column({ name: 'tax_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
taxAmount!: number;
|
||||
|
||||
@Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
totalAmount!: number;
|
||||
|
||||
@Column({ name: 'paid_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
paidAmount!: number;
|
||||
|
||||
@Column({ name: 'balance_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
balanceAmount!: number;
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' })
|
||||
currency!: string;
|
||||
|
||||
/** Charge window covered by this invoice — used to allow a later invoice for a new period. */
|
||||
@Column({ name: 'period_start', type: 'timestamptz', nullable: true })
|
||||
periodStart?: Date | null;
|
||||
|
||||
@Column({ name: 'period_end', type: 'timestamptz', nullable: true })
|
||||
periodEnd?: Date | null;
|
||||
|
||||
@Column({ name: 'issued_at', type: 'timestamptz', nullable: true })
|
||||
issuedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'due_date', type: 'timestamptz', nullable: true })
|
||||
dueDate?: Date | null;
|
||||
|
||||
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
|
||||
paidAt?: Date | null;
|
||||
|
||||
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
|
||||
cancelledAt?: Date | null;
|
||||
|
||||
@Column({ name: 'payments', type: 'jsonb', default: () => "'[]'" })
|
||||
payments!: WarehouseInvoicePayment[];
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const;
|
||||
export type FeeRuleType = (typeof FEE_RULE_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Batch 5 — configurable storage / demurrage fee rules (no invoice/payment here — that is Batch 6).
|
||||
* The most specific active rule (highest `specificity` then lowest `priority`) applies to an item.
|
||||
* `freeDays` is the grace period; charging starts the day after it expires.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'warehouse_fee_rules' })
|
||||
@Index(['ruleType'])
|
||||
@Index(['isActive'])
|
||||
export class WarehouseFeeRule extends BaseEntity {
|
||||
@Column({ name: 'name', type: 'varchar', length: 160 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'rule_type', type: 'varchar', length: 20 })
|
||||
ruleType!: FeeRuleType;
|
||||
|
||||
@Column({ name: 'priority', type: 'int', default: 100 })
|
||||
priority!: number;
|
||||
|
||||
// ── Scope (null = applies to all) ─────────────────────────────────────────
|
||||
@Column({ name: 'freight_type', type: 'varchar', length: 16, nullable: true })
|
||||
freightType?: string | null; // CONTAINER | BULK
|
||||
|
||||
@Column({ name: 'trade_direction', type: 'varchar', length: 16, nullable: true })
|
||||
tradeDirection?: string | null; // IMPORT | EXPORT | DOMESTIC | BOTH
|
||||
|
||||
@Column({ name: 'cargo_type_code', type: 'varchar', length: 50, nullable: true })
|
||||
cargoTypeCode?: string | null;
|
||||
|
||||
@Column({ name: 'container_type', type: 'varchar', length: 40, nullable: true })
|
||||
containerType?: string | null;
|
||||
|
||||
@Column({ name: 'facility_id', type: 'uuid', nullable: true })
|
||||
facilityId?: string | null;
|
||||
|
||||
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
|
||||
warehouseId?: string | null;
|
||||
|
||||
@Column({ name: 'yard_id', type: 'uuid', nullable: true })
|
||||
yardId?: string | null;
|
||||
|
||||
@Column({ name: 'zone_id', type: 'uuid', nullable: true })
|
||||
zoneId?: string | null;
|
||||
|
||||
// ── Fee definition ────────────────────────────────────────────────────────
|
||||
@Column({ name: 'free_days', type: 'int', default: 0 })
|
||||
freeDays!: number;
|
||||
|
||||
@Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
ratePerDay!: number;
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' })
|
||||
currency!: string;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { WarehouseInventory } from './warehouse-inventory.entity';
|
||||
|
||||
export const INSPECTION_REPORT_TYPES = [
|
||||
'INSPECTION',
|
||||
'DAMAGE',
|
||||
'WEIGHT_LOSS',
|
||||
'MISSING_ITEM',
|
||||
'GENERAL',
|
||||
] as const;
|
||||
export type InspectionReportType = (typeof INSPECTION_REPORT_TYPES)[number];
|
||||
|
||||
export const INSPECTION_STATUSES = ['PASSED', 'FAILED', 'NEEDS_REVIEW'] as const;
|
||||
export type InspectionStatus = (typeof INSPECTION_STATUSES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_inspection_reports' })
|
||||
@Index(['inventoryId'])
|
||||
@Index(['bookingId'])
|
||||
@Index(['inspectionStatus'])
|
||||
export class WarehouseInspectionReport extends BaseEntity {
|
||||
@Column({ name: 'inventory_id', type: 'uuid' })
|
||||
inventoryId!: string;
|
||||
|
||||
@ManyToOne(() => WarehouseInventory)
|
||||
@JoinColumn({ name: 'inventory_id' })
|
||||
inventory?: WarehouseInventory;
|
||||
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId?: string | null;
|
||||
|
||||
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
|
||||
customerId?: string | null;
|
||||
|
||||
@Column({ name: 'report_type', type: 'varchar', length: 32, default: 'INSPECTION' })
|
||||
reportType!: InspectionReportType;
|
||||
|
||||
@Column({ name: 'inspection_status', type: 'varchar', length: 20, default: 'NEEDS_REVIEW' })
|
||||
inspectionStatus!: InspectionStatus;
|
||||
|
||||
@Column({ name: 'has_damage', type: 'boolean', default: false })
|
||||
hasDamage!: boolean;
|
||||
|
||||
@Column({ name: 'damage_description', type: 'text', nullable: true })
|
||||
damageDescription?: string | null;
|
||||
|
||||
@Column({ name: 'has_weight_loss', type: 'boolean', default: false })
|
||||
hasWeightLoss!: boolean;
|
||||
|
||||
@Column({ name: 'expected_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
expectedWeight?: number | null;
|
||||
|
||||
@Column({ name: 'actual_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
actualWeight?: number | null;
|
||||
|
||||
@Column({ name: 'weight_loss', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
weightLoss?: number | null;
|
||||
|
||||
@Column({ name: 'weight_loss_unit', type: 'varchar', length: 12, nullable: true })
|
||||
weightLossUnit?: string | null;
|
||||
|
||||
@Column({ name: 'has_missing_items', type: 'boolean', default: false })
|
||||
hasMissingItems!: boolean;
|
||||
|
||||
@Column({ name: 'missing_items_description', type: 'text', nullable: true })
|
||||
missingItemsDescription?: string | null;
|
||||
|
||||
@Column({ name: 'remarks', type: 'text', nullable: true })
|
||||
remarks?: string | null;
|
||||
|
||||
@Column({ name: 'inspected_by_id', type: 'uuid', nullable: true })
|
||||
inspectedById?: string | null;
|
||||
|
||||
@Column({ name: 'inspected_at', type: 'timestamptz', nullable: true })
|
||||
inspectedAt?: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { WarehouseInventory } from './warehouse-inventory.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_inventory_movement' })
|
||||
@Index(['inventoryId'])
|
||||
export class WarehouseInventoryMovement extends BaseEntity {
|
||||
@Column({ name: 'inventory_id', type: 'uuid' })
|
||||
inventoryId!: string;
|
||||
|
||||
@ManyToOne(() => WarehouseInventory, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'inventory_id' })
|
||||
inventory?: WarehouseInventory;
|
||||
|
||||
@Column({ name: 'from_warehouse_id', type: 'uuid' })
|
||||
fromWarehouseId!: string;
|
||||
|
||||
@Column({ name: 'from_yard_id', type: 'uuid' })
|
||||
fromYardId!: string;
|
||||
|
||||
@Column({ name: 'from_zone_id', type: 'uuid' })
|
||||
fromZoneId!: string;
|
||||
|
||||
@Column({ name: 'to_warehouse_id', type: 'uuid' })
|
||||
toWarehouseId!: string;
|
||||
|
||||
@Column({ name: 'to_yard_id', type: 'uuid' })
|
||||
toYardId!: string;
|
||||
|
||||
@Column({ name: 'to_zone_id', type: 'uuid' })
|
||||
toZoneId!: string;
|
||||
|
||||
@Column({ name: 'remarks', type: 'text', nullable: true })
|
||||
remarks?: string | null;
|
||||
|
||||
@Column({ name: 'moved_by', type: 'varchar', length: 120, nullable: true })
|
||||
movedBy?: string | null;
|
||||
|
||||
@Column({ name: 'moved_at', type: 'timestamptz' })
|
||||
movedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Cargo } from '../../cargoes/entities/cargoes.entity';
|
||||
import { Container } from '../../container-management/entities/container.entity';
|
||||
import { Warehouse } from './warehouse.entity';
|
||||
import { WarehouseYard } from './warehouse-yard.entity';
|
||||
import { WarehouseZone } from './warehouse-zone.entity';
|
||||
|
||||
// Batch 2 lifecycle. Supersedes the Batch 1 set
|
||||
// (ARRIVED_AT_WAREHOUSE / UNDER_INSPECTION / READY_FOR_LOADING) — migrated in place.
|
||||
export const WAREHOUSE_INVENTORY_STATUSES = [
|
||||
'RECEIVED',
|
||||
'STORED',
|
||||
'RESERVED',
|
||||
'READY_FOR_LOADING',
|
||||
'LOADED',
|
||||
'DISPATCHED',
|
||||
] as const;
|
||||
export type WarehouseInventoryStatus = (typeof WAREHOUSE_INVENTORY_STATUSES)[number];
|
||||
|
||||
/** Allowed forward transitions for the inventory lifecycle. */
|
||||
export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, WarehouseInventoryStatus[]> = {
|
||||
RECEIVED: ['STORED'],
|
||||
STORED: ['RESERVED'],
|
||||
RESERVED: ['READY_FOR_LOADING'],
|
||||
READY_FOR_LOADING: ['LOADED'],
|
||||
LOADED: ['DISPATCHED'],
|
||||
DISPATCHED: [],
|
||||
};
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_inventory' })
|
||||
@Index(['warehouseId'])
|
||||
@Index(['yardId'])
|
||||
@Index(['zoneId'])
|
||||
@Index(['bookingId'])
|
||||
@Index(['cargoId'])
|
||||
@Index(['containerId'])
|
||||
@Index(['goodsId'])
|
||||
@Index(['status'])
|
||||
export class WarehouseInventory extends BaseEntity {
|
||||
@Column({ name: 'warehouse_id', type: 'uuid' })
|
||||
warehouseId!: string;
|
||||
|
||||
@ManyToOne(() => Warehouse)
|
||||
@JoinColumn({ name: 'warehouse_id' })
|
||||
warehouse?: Warehouse;
|
||||
|
||||
@Column({ name: 'yard_id', type: 'uuid' })
|
||||
yardId!: string;
|
||||
|
||||
@ManyToOne(() => WarehouseYard)
|
||||
@JoinColumn({ name: 'yard_id' })
|
||||
yard?: WarehouseYard;
|
||||
|
||||
@Column({ name: 'zone_id', type: 'uuid' })
|
||||
zoneId!: string;
|
||||
|
||||
@ManyToOne(() => WarehouseZone)
|
||||
@JoinColumn({ name: 'zone_id' })
|
||||
zone?: WarehouseZone;
|
||||
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId?: string | null;
|
||||
|
||||
@ManyToOne(() => Booking, { nullable: true })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking | null;
|
||||
|
||||
@Column({ name: 'cargo_id', type: 'uuid', nullable: true })
|
||||
cargoId?: string | null;
|
||||
|
||||
@ManyToOne(() => Cargo, { nullable: true })
|
||||
@JoinColumn({ name: 'cargo_id' })
|
||||
cargo?: Cargo | null;
|
||||
|
||||
@Column({ name: 'container_id', type: 'uuid', nullable: true })
|
||||
containerId?: string | null;
|
||||
|
||||
@ManyToOne(() => Container, { nullable: true })
|
||||
@JoinColumn({ name: 'container_id' })
|
||||
container?: Container | null;
|
||||
|
||||
@Column({ name: 'goods_id', type: 'uuid', nullable: true })
|
||||
goodsId?: string | null;
|
||||
|
||||
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
quantity!: number;
|
||||
|
||||
@Column({ name: 'weight', type: 'numeric', precision: 14, scale: 3, default: 0 })
|
||||
weight!: number;
|
||||
|
||||
@Column({ name: 'volume', type: 'numeric', precision: 12, scale: 3, nullable: true })
|
||||
volume?: number | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 32, default: 'RECEIVED' })
|
||||
status!: WarehouseInventoryStatus;
|
||||
|
||||
// Batch 4.5: latest inspection outcome (PASSED | FAILED | NEEDS_REVIEW). Null = not yet inspected.
|
||||
@Column({ name: 'inspection_status', type: 'varchar', length: 20, nullable: true })
|
||||
inspectionStatus?: string | null;
|
||||
|
||||
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
|
||||
arrivedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'stored_at', type: 'timestamptz', nullable: true })
|
||||
storedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'reserved_at', type: 'timestamptz', nullable: true })
|
||||
reservedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'inspected_at', type: 'timestamptz', nullable: true })
|
||||
inspectedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'ready_for_loading_at', type: 'timestamptz', nullable: true })
|
||||
readyForLoadingAt?: Date | null;
|
||||
|
||||
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
|
||||
loadedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'dispatched_at', type: 'timestamptz', nullable: true })
|
||||
dispatchedAt?: Date | null;
|
||||
|
||||
// Batch 5 — demurrage / storage lifecycle timestamps.
|
||||
@Column({ name: 'inspection_started_at', type: 'timestamptz', nullable: true })
|
||||
inspectionStartedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'inspection_completed_at', type: 'timestamptz', nullable: true })
|
||||
inspectionCompletedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'ready_for_pickup_at', type: 'timestamptz', nullable: true })
|
||||
readyForPickupAt?: Date | null;
|
||||
|
||||
@Column({ name: 'release_date', type: 'timestamptz', nullable: true })
|
||||
releaseDate?: Date | null;
|
||||
|
||||
@Column({ name: 'gate_cleared_at', type: 'timestamptz', nullable: true })
|
||||
gateClearedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { WarehouseInventory } from './warehouse-inventory.entity';
|
||||
|
||||
/**
|
||||
* Batch 3 — a record that a warehouse inventory item was physically loaded onto a wagon.
|
||||
* The warehouse OWNS this record. It only READS wagon/schedule data from the scheduling
|
||||
* domain (via SchedulingReadFacade); it never writes to wagons or train schedules.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'warehouse_loadings' })
|
||||
@Index(['warehouseInventoryId'])
|
||||
@Index(['bookingId'])
|
||||
@Index(['wagonId'])
|
||||
export class WarehouseLoading extends BaseEntity {
|
||||
@Column({ name: 'warehouse_inventory_id', type: 'uuid' })
|
||||
warehouseInventoryId!: string;
|
||||
|
||||
@ManyToOne(() => WarehouseInventory)
|
||||
@JoinColumn({ name: 'warehouse_inventory_id' })
|
||||
inventory?: WarehouseInventory;
|
||||
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId?: string | null;
|
||||
|
||||
@ManyToOne(() => Booking, { nullable: true })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking | null;
|
||||
|
||||
/** Physical wagon the item was loaded onto. References freight.wagons (read-only link). */
|
||||
@Column({ name: 'wagon_id', type: 'uuid' })
|
||||
wagonId!: string;
|
||||
|
||||
@Column({ name: 'loaded_at', type: 'timestamptz' })
|
||||
loadedAt!: Date;
|
||||
|
||||
@Column({ name: 'loaded_by', type: 'varchar', length: 120, nullable: true })
|
||||
loadedBy?: string | null;
|
||||
|
||||
@Column({ name: 'loaded_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
loadedWeight?: number | null;
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
|
||||
import { Warehouse } from './warehouse.entity';
|
||||
import { WarehouseZone } from './warehouse-zone.entity';
|
||||
|
||||
export const WAREHOUSE_YARD_TYPES = [
|
||||
'CONTAINER_YARD',
|
||||
'BULK_YARD',
|
||||
'GENERAL_CARGO_YARD',
|
||||
'HAZARDOUS_YARD',
|
||||
'COLD_STORAGE_YARD',
|
||||
] as const;
|
||||
export type WarehouseYardType = (typeof WAREHOUSE_YARD_TYPES)[number];
|
||||
|
||||
export const WAREHOUSE_YARD_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
|
||||
export type WarehouseYardStatus = (typeof WAREHOUSE_YARD_STATUSES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_yards' })
|
||||
@Index(['warehouseId'])
|
||||
@Index(['type'])
|
||||
@Index(['status'])
|
||||
export class WarehouseYard extends BaseEntity {
|
||||
@Column({ name: 'warehouse_id', type: 'uuid' })
|
||||
warehouseId!: string;
|
||||
|
||||
@ManyToOne(() => Warehouse, (warehouse) => warehouse.yards, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'warehouse_id' })
|
||||
warehouse?: Warehouse;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 160 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'code', type: 'varchar', length: 40 })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: 'type', type: 'varchar', length: 32 })
|
||||
type!: WarehouseYardType;
|
||||
|
||||
@Column({ name: 'capacity_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
capacityWeight?: number | null;
|
||||
|
||||
@Column({ name: 'capacity_containers', type: 'int', nullable: true })
|
||||
capacityContainers?: number | null;
|
||||
|
||||
@Column({ name: 'current_weight', type: 'numeric', precision: 14, scale: 3, default: 0 })
|
||||
currentWeight!: number;
|
||||
|
||||
@Column({ name: 'current_containers', type: 'int', default: 0 })
|
||||
currentContainers!: number;
|
||||
|
||||
@Column({ name: 'max_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
maxWeight?: number | null;
|
||||
|
||||
@Column({ name: 'max_volume', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
maxVolume?: number | null;
|
||||
|
||||
@Column({ name: 'current_volume', type: 'numeric', precision: 14, scale: 3, default: 0 })
|
||||
currentVolume!: number;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' })
|
||||
status!: WarehouseYardStatus;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@OneToMany(() => WarehouseZone, (zone) => zone.yard)
|
||||
zones?: WarehouseZone[];
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { WarehouseYard } from './warehouse-yard.entity';
|
||||
|
||||
export const WAREHOUSE_ZONE_TYPES = [
|
||||
'CONTAINER_ZONE',
|
||||
'BULK_ZONE',
|
||||
'GENERAL_CARGO_ZONE',
|
||||
'HAZARDOUS_ZONE',
|
||||
'COLD_STORAGE_ZONE',
|
||||
] as const;
|
||||
export type WarehouseZoneType = (typeof WAREHOUSE_ZONE_TYPES)[number];
|
||||
|
||||
export const WAREHOUSE_ZONE_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
|
||||
export type WarehouseZoneStatus = (typeof WAREHOUSE_ZONE_STATUSES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_zones' })
|
||||
@Index(['yardId'])
|
||||
@Index(['type'])
|
||||
@Index(['status'])
|
||||
export class WarehouseZone extends BaseEntity {
|
||||
@Column({ name: 'yard_id', type: 'uuid' })
|
||||
yardId!: string;
|
||||
|
||||
@ManyToOne(() => WarehouseYard, (yard) => yard.zones, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'yard_id' })
|
||||
yard?: WarehouseYard;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 160 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'code', type: 'varchar', length: 40 })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: 'type', type: 'varchar', length: 32 })
|
||||
type!: WarehouseZoneType;
|
||||
|
||||
@Column({ name: 'capacity_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
capacityWeight?: number | null;
|
||||
|
||||
@Column({ name: 'capacity_containers', type: 'int', nullable: true })
|
||||
capacityContainers?: number | null;
|
||||
|
||||
@Column({ name: 'current_weight', type: 'numeric', precision: 14, scale: 3, default: 0 })
|
||||
currentWeight!: number;
|
||||
|
||||
@Column({ name: 'current_containers', type: 'int', default: 0 })
|
||||
currentContainers!: number;
|
||||
|
||||
@Column({ name: 'max_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
maxWeight?: number | null;
|
||||
|
||||
@Column({ name: 'max_volume', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
maxVolume?: number | null;
|
||||
|
||||
@Column({ name: 'current_volume', type: 'numeric', precision: 14, scale: 3, default: 0 })
|
||||
currentVolume!: number;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' })
|
||||
status!: WarehouseZoneStatus;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, ManyToOne, OneToMany } from 'typeorm';
|
||||
|
||||
import { Facility } from '../../facilities/entities/facility.entity';
|
||||
import { WarehouseYard } from './warehouse-yard.entity';
|
||||
|
||||
export const WAREHOUSE_TYPES = ['OPEN_WAREHOUSE', 'CLOSED_WAREHOUSE'] as const;
|
||||
export type WarehouseType = (typeof WAREHOUSE_TYPES)[number];
|
||||
|
||||
export const WAREHOUSE_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
|
||||
export type WarehouseStatus = (typeof WAREHOUSE_STATUSES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouses' })
|
||||
@Index(['code'], { unique: true })
|
||||
@Index(['type'])
|
||||
@Index(['status'])
|
||||
@Index(['stationId'])
|
||||
export class Warehouse extends BaseEntity {
|
||||
@Column({ name: 'name', type: 'varchar', length: 160 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'code', type: 'varchar', length: 40, unique: true })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: 'type', type: 'varchar', length: 32 })
|
||||
type!: WarehouseType;
|
||||
|
||||
@Column({ name: 'station_id', type: 'uuid', nullable: true })
|
||||
stationId?: string | null;
|
||||
|
||||
@Column({ name: 'location_name', type: 'varchar', length: 200, nullable: true })
|
||||
locationName?: string | null;
|
||||
|
||||
@Column({ name: 'capacity_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
capacityWeight?: number | null;
|
||||
|
||||
@Column({ name: 'capacity_containers', type: 'int', nullable: true })
|
||||
capacityContainers?: number | null;
|
||||
|
||||
@Column({ name: 'current_weight', type: 'numeric', precision: 14, scale: 3, default: 0 })
|
||||
currentWeight!: number;
|
||||
|
||||
@Column({ name: 'current_containers', type: 'int', default: 0 })
|
||||
currentContainers!: number;
|
||||
|
||||
// Batch 2 capacity (weight + volume). maxWeight backfilled from capacityWeight.
|
||||
@Column({ name: 'max_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
maxWeight?: number | null;
|
||||
|
||||
@Column({ name: 'max_volume', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
maxVolume?: number | null;
|
||||
|
||||
@Column({ name: 'current_volume', type: 'numeric', precision: 14, scale: 3, default: 0 })
|
||||
currentVolume!: number;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' })
|
||||
status!: WarehouseStatus;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@Column({ name: 'facility_id', type: 'uuid', nullable: true })
|
||||
facilityId?: string | null;
|
||||
|
||||
@ManyToOne(() => Facility, (facility) => facility.warehouses, { nullable: true })
|
||||
facility?: Facility | null;
|
||||
|
||||
@OneToMany(() => WarehouseYard, (yard) => yard.warehouse)
|
||||
yards?: WarehouseYard[];
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
/**
|
||||
* READ-ONLY view into the train-scheduling / wagons domain for the warehouse module.
|
||||
*
|
||||
* IMPORTANT: this facade only ever runs SELECTs. The warehouse must never modify
|
||||
* wagon assignment, rescheduling, import_ready/export_ready, or locomotive flow.
|
||||
* It is intentionally decoupled (raw SQL) so it does not import the scheduling
|
||||
* services/entities and cannot accidentally write to them.
|
||||
*/
|
||||
export interface WagonView {
|
||||
id: string;
|
||||
wagonNumber: string;
|
||||
status: string;
|
||||
trainId: string | null;
|
||||
}
|
||||
|
||||
export interface BookingScheduleView {
|
||||
schedule: {
|
||||
id: string;
|
||||
status: string;
|
||||
scheduledDepartureDate: string | null;
|
||||
scheduledArrivalDate: string | null;
|
||||
originStationId: string | null;
|
||||
destinationStationId: string | null;
|
||||
} | null;
|
||||
wagon: {
|
||||
wagonId: string | null;
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
allocatedWeightTons: number | null;
|
||||
} | null;
|
||||
/** Mirror of schedule.status — the headline "where is the train" indicator. */
|
||||
departureStatus: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SchedulingReadFacade {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
/** Look up a single physical wagon. Returns null if it does not exist. */
|
||||
async findWagon(wagonId: string): Promise<WagonView | null> {
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId"
|
||||
FROM freight.wagons
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[wagonId],
|
||||
);
|
||||
return rows?.[0] ?? null;
|
||||
}
|
||||
|
||||
/** True when the wagon is already part of a train set (selected by an existing schedule). */
|
||||
async isWagonScheduled(wagonId: string): Promise<boolean> {
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT 1 FROM freight.train_set_wagons
|
||||
WHERE physical_wagon_id = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[wagonId],
|
||||
);
|
||||
return (rows?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** List wagons usable for loading (available, or already assigned to a schedule). */
|
||||
listLoadableWagons(): Promise<WagonView[]> {
|
||||
return this.dataSource.query(
|
||||
`SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId"
|
||||
FROM freight.wagons
|
||||
WHERE deleted_at IS NULL
|
||||
AND status NOT IN ('RETIRED', 'MAINTENANCE')
|
||||
ORDER BY wagon_number ASC`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a booking, return its related schedule, wagon assignment and departure status.
|
||||
* All fields are read straight from the scheduling tables — nothing is written.
|
||||
*/
|
||||
async getBookingSchedule(bookingId: string): Promise<BookingScheduleView> {
|
||||
const scheduleRows = await this.dataSource.query(
|
||||
`SELECT ts.id,
|
||||
ts.status,
|
||||
ts.scheduled_departure_date AS "scheduledDepartureDate",
|
||||
ts.scheduled_arrival_date AS "scheduledArrivalDate",
|
||||
ts.origin_station_id AS "originStationId",
|
||||
ts.destination_station_id AS "destinationStationId"
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
INNER JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
|
||||
WHERE tsb.booking_id = $1 AND ts.deleted_at IS NULL
|
||||
ORDER BY ts.scheduled_departure_date DESC NULLS LAST
|
||||
LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
const schedule = scheduleRows?.[0] ?? null;
|
||||
|
||||
const wagonRows = await this.dataSource.query(
|
||||
`SELECT w.id AS "wagonId",
|
||||
w.wagon_number AS "wagonNumber",
|
||||
tsw.sequence_no AS "sequenceNo",
|
||||
wba.allocated_weight_tons AS "allocatedWeightTons"
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
INNER JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||||
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||
WHERE wba.booking_id = $1
|
||||
ORDER BY tsw.sequence_no ASC NULLS LAST
|
||||
LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
const wagon = wagonRows?.[0] ?? null;
|
||||
|
||||
return {
|
||||
schedule,
|
||||
wagon,
|
||||
departureStatus: schedule?.status ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseActivityLogRepository extends BaseRepository<WarehouseActivityLog> {
|
||||
constructor(@InjectRepository(WarehouseActivityLog) repository: Repository<WarehouseActivityLog>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EntityManager } from 'typeorm';
|
||||
|
||||
import {
|
||||
WarehouseActivityLog,
|
||||
WarehouseActivityType,
|
||||
} from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository';
|
||||
|
||||
interface LogInput {
|
||||
activityType: WarehouseActivityType;
|
||||
description?: string;
|
||||
inventoryId?: string | null;
|
||||
warehouseId?: string | null;
|
||||
performedBy?: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseActivityLogService {
|
||||
constructor(private readonly logRepository: WarehouseActivityLogRepository) {}
|
||||
|
||||
/** Persist an activity record. Pass a transaction manager to enrol in the caller's transaction. */
|
||||
async record(input: LogInput, manager?: EntityManager): Promise<void> {
|
||||
const data = {
|
||||
activityType: input.activityType,
|
||||
description: input.description ?? null,
|
||||
inventoryId: input.inventoryId ?? null,
|
||||
warehouseId: input.warehouseId ?? null,
|
||||
performedBy: input.performedBy ?? 'system',
|
||||
};
|
||||
|
||||
if (manager) {
|
||||
await manager.getRepository(WarehouseActivityLog).save(
|
||||
manager.getRepository(WarehouseActivityLog).create(data),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.logRepository.create(data);
|
||||
}
|
||||
|
||||
findByInventory(inventoryId: string): Promise<WarehouseActivityLog[]> {
|
||||
return this.logRepository.findAll({
|
||||
where: { inventoryId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseAllocationRuleRepository extends BaseRepository<WarehouseAllocationRule> {
|
||||
constructor(
|
||||
@InjectRepository(WarehouseAllocationRule) repository: Repository<WarehouseAllocationRule>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { CreateAllocationRuleDto, UpdateAllocationRuleDto } from './dto/allocation-rule.dto';
|
||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||
import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.repository';
|
||||
|
||||
export interface AllocationCriteria {
|
||||
freightType?: string | null; // CONTAINER | BULK
|
||||
tradeDirection?: string | null; // IMPORT | EXPORT | DOMESTIC | BOTH
|
||||
cargoTypeCode?: string | null;
|
||||
containerStatus?: string | null; // EMPTY | MAINTENANCE | ...
|
||||
requiresInspection?: boolean | null;
|
||||
}
|
||||
|
||||
export interface AllocationResult {
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
facilityId: string | null;
|
||||
rule: { id: string; name: string; storageType: string | null } | null;
|
||||
/** Human-readable path: Facility → Warehouse → Yard → Zone. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch 5 — deterministic warehouse/yard allocation driven by configurable rules.
|
||||
* Never assigns randomly: matches criteria against active rules by priority and
|
||||
* resolves the target Yard/Warehouse/Zone by code.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WarehouseAllocationService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly ruleRepository: WarehouseAllocationRuleRepository,
|
||||
) {}
|
||||
|
||||
// ── Rule CRUD ──────────────────────────────────────────────────────────────
|
||||
listRules(): Promise<WarehouseAllocationRule[]> {
|
||||
return this.ruleRepository.findAll({ order: { priority: 'ASC' } });
|
||||
}
|
||||
|
||||
createRule(dto: CreateAllocationRuleDto): Promise<WarehouseAllocationRule> {
|
||||
return this.ruleRepository.create({ isActive: true, priority: 100, ...dto });
|
||||
}
|
||||
|
||||
async updateRule(id: string, dto: UpdateAllocationRuleDto): Promise<WarehouseAllocationRule> {
|
||||
const updated = await this.ruleRepository.update(id, dto);
|
||||
if (!updated) throw new NotFoundException(`Allocation rule ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
deleteRule(id: string): Promise<void> {
|
||||
return this.ruleRepository.softDelete(id);
|
||||
}
|
||||
|
||||
private matches(rule: WarehouseAllocationRule, c: AllocationCriteria): boolean {
|
||||
const eq = (ruleVal?: string | null, inVal?: string | null) =>
|
||||
ruleVal == null || (inVal != null && ruleVal.toUpperCase() === inVal.toUpperCase());
|
||||
return (
|
||||
eq(rule.freightType, c.freightType) &&
|
||||
eq(rule.tradeDirection, c.tradeDirection) &&
|
||||
eq(rule.cargoTypeCode, c.cargoTypeCode) &&
|
||||
eq(rule.containerStatus, c.containerStatus) &&
|
||||
(rule.requiresInspection == null || rule.requiresInspection === Boolean(c.requiresInspection))
|
||||
);
|
||||
}
|
||||
|
||||
/** First active rule (by priority) whose criteria match. */
|
||||
async findMatchingRule(criteria: AllocationCriteria): Promise<WarehouseAllocationRule | null> {
|
||||
const rules = await this.ruleRepository.findAll({
|
||||
where: { isActive: true },
|
||||
order: { priority: 'ASC' },
|
||||
});
|
||||
return rules.find((r) => this.matches(r, criteria)) ?? null;
|
||||
}
|
||||
|
||||
/** Resolve a concrete warehouse/yard/zone for the given criteria, or null if none configured. */
|
||||
async resolveLocation(criteria: AllocationCriteria): Promise<AllocationResult | null> {
|
||||
const rule = await this.findMatchingRule(criteria);
|
||||
const yardCode = rule?.targetYardCode;
|
||||
|
||||
// Resolve yard (by rule code, else first available yard with a zone).
|
||||
const [yard] = await this.dataSource.query(
|
||||
yardCode
|
||||
? `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y
|
||||
WHERE y.code = $1 AND y.deleted_at IS NULL LIMIT 1`
|
||||
: `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y
|
||||
JOIN freight.warehouse_zones z ON z.yard_id = y.id AND z.deleted_at IS NULL
|
||||
WHERE y.deleted_at IS NULL ORDER BY y.created_at ASC LIMIT 1`,
|
||||
yardCode ? [yardCode] : [],
|
||||
);
|
||||
if (!yard) return null;
|
||||
|
||||
// Zone: rule code if given, else first zone in the yard.
|
||||
const [zone] = await this.dataSource.query(
|
||||
rule?.targetZoneCode
|
||||
? `SELECT z.id, z.name FROM freight.warehouse_zones z WHERE z.code = $1 AND z.deleted_at IS NULL LIMIT 1`
|
||||
: `SELECT z.id, z.name FROM freight.warehouse_zones z WHERE z.yard_id = $1 AND z.deleted_at IS NULL ORDER BY z.created_at ASC LIMIT 1`,
|
||||
rule?.targetZoneCode ? [rule.targetZoneCode] : [yard.id],
|
||||
);
|
||||
if (!zone) return null;
|
||||
|
||||
const [wh] = await this.dataSource.query(
|
||||
`SELECT w.id, w.name, w.facility_id AS "facilityId",
|
||||
(SELECT name FROM freight.facilities f WHERE f.id = w.facility_id) AS "facilityName"
|
||||
FROM freight.warehouses w WHERE w.id = $1 AND w.deleted_at IS NULL LIMIT 1`,
|
||||
[yard.warehouseId],
|
||||
);
|
||||
|
||||
return {
|
||||
warehouseId: yard.warehouseId,
|
||||
yardId: yard.id,
|
||||
zoneId: zone.id,
|
||||
facilityId: wh?.facilityId ?? null,
|
||||
rule: rule ? { id: rule.id, name: rule.name, storageType: rule.storageType ?? null } : null,
|
||||
path: [wh?.facilityName, wh?.name, yard.name, zone.name].filter(Boolean).join(' → '),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Warehouse } from './entities/warehouse.entity';
|
||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||
|
||||
export interface WarehouseDashboard {
|
||||
totalWarehouses: number;
|
||||
totalInventory: number;
|
||||
receivedToday: number;
|
||||
stored: number;
|
||||
reserved: number;
|
||||
readyForLoading: number;
|
||||
loaded: number;
|
||||
dispatched: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseDashboardService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async getDashboard(): Promise<WarehouseDashboard> {
|
||||
const warehouseRepo = this.dataSource.getRepository(Warehouse);
|
||||
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
|
||||
|
||||
const startOfToday = new Date();
|
||||
startOfToday.setHours(0, 0, 0, 0);
|
||||
|
||||
const [totalWarehouses, totalInventory, stored, reserved, readyForLoading, loaded, dispatched, receivedToday] =
|
||||
await Promise.all([
|
||||
warehouseRepo.count(),
|
||||
inventoryRepo.count(),
|
||||
inventoryRepo.count({ where: { status: 'STORED' } }),
|
||||
inventoryRepo.count({ where: { status: 'RESERVED' } }),
|
||||
inventoryRepo.count({ where: { status: 'READY_FOR_LOADING' } }),
|
||||
inventoryRepo.count({ where: { status: 'LOADED' } }),
|
||||
inventoryRepo.count({ where: { status: 'DISPATCHED' } }),
|
||||
inventoryRepo
|
||||
.createQueryBuilder('inv')
|
||||
.where('inv.arrived_at >= :start', { start: startOfToday })
|
||||
.getCount(),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalWarehouses,
|
||||
totalInventory,
|
||||
receivedToday,
|
||||
stored,
|
||||
reserved,
|
||||
readyForLoading,
|
||||
loaded,
|
||||
dispatched,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseFeeInvoiceItemRepository extends BaseRepository<WarehouseFeeInvoiceItem> {
|
||||
constructor(@InjectRepository(WarehouseFeeInvoiceItem) repository: Repository<WarehouseFeeInvoiceItem>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseFeeInvoiceRepository extends BaseRepository<WarehouseFeeInvoice> {
|
||||
constructor(@InjectRepository(WarehouseFeeInvoice) repository: Repository<WarehouseFeeInvoice>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseFeeRuleRepository extends BaseRepository<WarehouseFeeRule> {
|
||||
constructor(@InjectRepository(WarehouseFeeRule) repository: Repository<WarehouseFeeRule>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
||||
import { FeeRuleType, WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
|
||||
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
|
||||
|
||||
interface ItemAttributes {
|
||||
arrivedAt: Date | null;
|
||||
gateClearedAt: Date | null;
|
||||
releaseDate: Date | null;
|
||||
freightType: string | null;
|
||||
tradeDirection: string | null;
|
||||
cargoTypeCode: string | null;
|
||||
containerTypeCode: string | null;
|
||||
facilityId: string | null;
|
||||
warehouseId: string | null;
|
||||
yardId: string | null;
|
||||
zoneId: string | null;
|
||||
}
|
||||
|
||||
export interface FeePreview {
|
||||
ruleType: FeeRuleType;
|
||||
ruleId: string | null;
|
||||
ruleName: string | null;
|
||||
freeDays: number;
|
||||
ratePerDay: number;
|
||||
currency: string;
|
||||
startDate: string | null;
|
||||
endDate: string;
|
||||
endIsOpen: boolean; // true when still accruing (no release/gate-clear yet)
|
||||
elapsedDays: number;
|
||||
chargeableDays: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseFeeService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly feeRuleRepository: WarehouseFeeRuleRepository,
|
||||
) {}
|
||||
|
||||
// ── Rule CRUD ──────────────────────────────────────────────────────────────
|
||||
listRules(): Promise<WarehouseFeeRule[]> {
|
||||
return this.feeRuleRepository.findAll({ order: { ruleType: 'ASC', priority: 'ASC' } });
|
||||
}
|
||||
|
||||
createRule(dto: CreateFeeRuleDto): Promise<WarehouseFeeRule> {
|
||||
return this.feeRuleRepository.create({ isActive: true, priority: 100, currency: 'USD', ...dto });
|
||||
}
|
||||
|
||||
async updateRule(id: string, dto: UpdateFeeRuleDto): Promise<WarehouseFeeRule> {
|
||||
const updated = await this.feeRuleRepository.update(id, dto);
|
||||
if (!updated) throw new NotFoundException(`Fee rule ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
deleteRule(id: string): Promise<void> {
|
||||
return this.feeRuleRepository.softDelete(id);
|
||||
}
|
||||
|
||||
private async loadItem(inventoryId: string): Promise<ItemAttributes> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.arrived_at AS "arrivedAt",
|
||||
inv.gate_cleared_at AS "gateClearedAt",
|
||||
inv.release_date AS "releaseDate",
|
||||
inv.warehouse_id AS "warehouseId",
|
||||
inv.yard_id AS "yardId",
|
||||
inv.zone_id AS "zoneId",
|
||||
w.facility_id AS "facilityId",
|
||||
b.freight_type AS "freightType",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
cgt.code AS "cargoTypeCode",
|
||||
ctt.code AS "containerTypeCode"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
|
||||
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
||||
LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
|
||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL`,
|
||||
[inventoryId],
|
||||
);
|
||||
if (!row) throw new NotFoundException(`Inventory item ${inventoryId} not found`);
|
||||
return row;
|
||||
}
|
||||
|
||||
private matchScore(rule: WarehouseFeeRule, item: ItemAttributes): number | null {
|
||||
// Returns specificity score (#matched non-null scope fields), or null if any constraint fails.
|
||||
let score = 0;
|
||||
const check = (ruleVal: string | null | undefined, itemVal: string | null) => {
|
||||
if (ruleVal == null) return true;
|
||||
if (itemVal != null && ruleVal.toUpperCase() === itemVal.toUpperCase()) {
|
||||
score += 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (!check(rule.freightType, item.freightType)) return null;
|
||||
if (!check(rule.tradeDirection, item.tradeDirection)) return null;
|
||||
if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null;
|
||||
if (!check(rule.containerType, item.containerTypeCode)) return null;
|
||||
if (!check(rule.facilityId, item.facilityId)) return null;
|
||||
if (!check(rule.warehouseId, item.warehouseId)) return null;
|
||||
if (!check(rule.yardId, item.yardId)) return null;
|
||||
if (!check(rule.zoneId, item.zoneId)) return null;
|
||||
return score;
|
||||
}
|
||||
|
||||
private bestRule(rules: WarehouseFeeRule[], item: ItemAttributes): WarehouseFeeRule | null {
|
||||
let best: WarehouseFeeRule | null = null;
|
||||
let bestScore = -1;
|
||||
for (const rule of rules) {
|
||||
const score = this.matchScore(rule, item);
|
||||
if (score == null) continue;
|
||||
if (score > bestScore || (score === bestScore && best && rule.priority < best.priority)) {
|
||||
best = rule;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private compute(ruleType: FeeRuleType, rule: WarehouseFeeRule | null, item: ItemAttributes, now: Date): FeePreview {
|
||||
const start = item.arrivedAt ? new Date(item.arrivedAt) : null;
|
||||
const endDate = item.gateClearedAt ?? item.releaseDate ?? now;
|
||||
const endIsOpen = !item.gateClearedAt && !item.releaseDate;
|
||||
const freeDays = rule?.freeDays ?? 0;
|
||||
const ratePerDay = Number(rule?.ratePerDay ?? 0);
|
||||
|
||||
const elapsedDays = start
|
||||
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
|
||||
: 0;
|
||||
const chargeableDays = Math.max(0, elapsedDays - freeDays);
|
||||
const amount = Math.round(chargeableDays * ratePerDay * 100) / 100;
|
||||
|
||||
return {
|
||||
ruleType,
|
||||
ruleId: rule?.id ?? null,
|
||||
ruleName: rule?.name ?? null,
|
||||
freeDays,
|
||||
ratePerDay,
|
||||
currency: rule?.currency ?? 'USD',
|
||||
startDate: start ? start.toISOString() : null,
|
||||
endDate: new Date(endDate).toISOString(),
|
||||
endIsOpen,
|
||||
elapsedDays,
|
||||
chargeableDays,
|
||||
amount,
|
||||
};
|
||||
}
|
||||
|
||||
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
|
||||
async previewForInventory(inventoryId: string): Promise<FeePreview[]> {
|
||||
const item = await this.loadItem(inventoryId);
|
||||
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
||||
const now = new Date();
|
||||
|
||||
const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE'];
|
||||
return byType.map((type) =>
|
||||
this.compute(type, this.bestRule(rules.filter((r) => r.ruleType === type), item), item, now),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
|
||||
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||
import { WarehouseInspectionService } from './warehouse-inspection.service';
|
||||
|
||||
@ApiTags('warehouse-inspection')
|
||||
@ApiBearerAuth()
|
||||
@Controller()
|
||||
export class WarehouseInspectionController {
|
||||
constructor(private readonly inspectionService: WarehouseInspectionService) {}
|
||||
|
||||
@Post('warehouse-inventory/:inventoryId/inspection-reports')
|
||||
@ApiOperation({ summary: 'Create an inspection / damage report for an inventory item' })
|
||||
create(
|
||||
@Param('inventoryId', ParseUUIDPipe) inventoryId: string,
|
||||
@Body() dto: CreateInspectionReportDto,
|
||||
) {
|
||||
return this.inspectionService.create(inventoryId, dto);
|
||||
}
|
||||
|
||||
@Get('warehouse-inventory/:inventoryId/inspection-reports')
|
||||
@ApiOperation({ summary: 'List inspection reports for an inventory item' })
|
||||
listByInventory(@Param('inventoryId', ParseUUIDPipe) inventoryId: string) {
|
||||
return this.inspectionService.findByInventory(inventoryId);
|
||||
}
|
||||
|
||||
@Get('warehouse-inspection-reports/:id')
|
||||
@ApiOperation({ summary: 'Get an inspection report (with attachments)' })
|
||||
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const report = await this.inspectionService.findById(id);
|
||||
const attachments = await this.inspectionService.listAttachments(id);
|
||||
return { ...report, attachments };
|
||||
}
|
||||
|
||||
@Patch('warehouse-inspection-reports/:id')
|
||||
@ApiOperation({ summary: 'Update an inspection report' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateInspectionReportDto) {
|
||||
return this.inspectionService.update(id, dto);
|
||||
}
|
||||
|
||||
@Post('warehouse-inspection-reports/:id/attachments')
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload inspection images / documents' })
|
||||
addAttachments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
return this.inspectionService.addAttachments(id, files);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseInspectionRepository extends BaseRepository<WarehouseInspectionReport> {
|
||||
constructor(
|
||||
@InjectRepository(WarehouseInspectionReport) repository: Repository<WarehouseInspectionReport>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
|
||||
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||
import { WarehouseInspectionRepository } from './warehouse-inspection.repository';
|
||||
|
||||
const INSPECTION_RESOURCE = 'warehouse-inspection-report';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseInspectionService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly inspectionRepository: WarehouseInspectionRepository,
|
||||
private readonly filesService: FilesService,
|
||||
) {}
|
||||
|
||||
/** Create an inspection report for an inventory item and sync its inspectionStatus. */
|
||||
async create(inventoryId: string, dto: CreateInspectionReportDto): Promise<WarehouseInspectionReport> {
|
||||
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
|
||||
const inventory = await inventoryRepo.findOne({ where: { id: inventoryId } });
|
||||
if (!inventory) {
|
||||
throw new NotFoundException(`Inventory item ${inventoryId} not found`);
|
||||
}
|
||||
|
||||
const expected = dto.expectedWeight ?? null;
|
||||
const actual = dto.actualWeight ?? null;
|
||||
const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : null;
|
||||
|
||||
const report = await this.inspectionRepository.create({
|
||||
inventoryId,
|
||||
bookingId: inventory.bookingId ?? null,
|
||||
reportType: dto.reportType,
|
||||
inspectionStatus: dto.inspectionStatus,
|
||||
hasDamage: dto.hasDamage ?? false,
|
||||
damageDescription: dto.damageDescription ?? null,
|
||||
hasWeightLoss: dto.hasWeightLoss ?? false,
|
||||
expectedWeight: expected,
|
||||
actualWeight: actual,
|
||||
weightLoss,
|
||||
weightLossUnit: weightLoss !== null ? 'kg' : null,
|
||||
hasMissingItems: dto.hasMissingItems ?? false,
|
||||
missingItemsDescription: dto.missingItemsDescription ?? null,
|
||||
remarks: dto.remarks ?? null,
|
||||
inspectedById: dto.inspectedById ?? null,
|
||||
inspectedAt: new Date(),
|
||||
});
|
||||
|
||||
// Mirror the latest outcome onto the inventory item so loading rules can read it.
|
||||
await inventoryRepo.update(inventoryId, {
|
||||
inspectionStatus: dto.inspectionStatus,
|
||||
inspectedAt: new Date(),
|
||||
});
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
async findByInventory(inventoryId: string): Promise<WarehouseInspectionReport[]> {
|
||||
return this.inspectionRepository.findAll({
|
||||
where: { inventoryId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WarehouseInspectionReport> {
|
||||
const report = await this.inspectionRepository.findById(id);
|
||||
if (!report) {
|
||||
throw new NotFoundException(`Inspection report ${id} not found`);
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateInspectionReportDto): Promise<WarehouseInspectionReport> {
|
||||
const report = await this.findById(id);
|
||||
|
||||
const expected = dto.expectedWeight ?? report.expectedWeight ?? null;
|
||||
const actual = dto.actualWeight ?? report.actualWeight ?? null;
|
||||
const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : report.weightLoss ?? null;
|
||||
|
||||
await this.inspectionRepository.update(id, {
|
||||
...(dto.reportType ? { reportType: dto.reportType } : {}),
|
||||
...(dto.inspectionStatus ? { inspectionStatus: dto.inspectionStatus } : {}),
|
||||
...(dto.hasDamage !== undefined ? { hasDamage: dto.hasDamage } : {}),
|
||||
...(dto.damageDescription !== undefined ? { damageDescription: dto.damageDescription } : {}),
|
||||
...(dto.hasWeightLoss !== undefined ? { hasWeightLoss: dto.hasWeightLoss } : {}),
|
||||
expectedWeight: expected,
|
||||
actualWeight: actual,
|
||||
weightLoss,
|
||||
...(dto.hasMissingItems !== undefined ? { hasMissingItems: dto.hasMissingItems } : {}),
|
||||
...(dto.missingItemsDescription !== undefined ? { missingItemsDescription: dto.missingItemsDescription } : {}),
|
||||
...(dto.remarks !== undefined ? { remarks: dto.remarks } : {}),
|
||||
});
|
||||
|
||||
if (dto.inspectionStatus) {
|
||||
await this.dataSource
|
||||
.getRepository(WarehouseInventory)
|
||||
.update(report.inventoryId, { inspectionStatus: dto.inspectionStatus });
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/** Attach uploaded images/documents to a report, reusing the shared Files (MinIO) module. */
|
||||
async addAttachments(reportId: string, files: Express.Multer.File[]) {
|
||||
await this.findById(reportId);
|
||||
if (!files?.length) return [];
|
||||
return this.filesService.uploadMany(reportId, INSPECTION_RESOURCE, files);
|
||||
}
|
||||
|
||||
listAttachments(reportId: string) {
|
||||
return this.filesService.findByResource(reportId, INSPECTION_RESOURCE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseInventoryMovementRepository extends BaseRepository<WarehouseInventoryMovement> {
|
||||
constructor(
|
||||
@InjectRepository(WarehouseInventoryMovement) repository: Repository<WarehouseInventoryMovement>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
|
||||
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
||||
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||
import { SchedulingReadFacade } from './scheduling-read.facade';
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
|
||||
@ApiTags('warehouse-inventory')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouse-inventory')
|
||||
export class WarehouseInventoryController {
|
||||
constructor(
|
||||
private readonly inventoryService: WarehouseInventoryService,
|
||||
private readonly scheduling: SchedulingReadFacade,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List warehouse inventory' })
|
||||
findAll(@Query() filter: FilterWarehouseInventoryDto) {
|
||||
return this.inventoryService.findAll(filter);
|
||||
}
|
||||
|
||||
@Get('ready-for-loading')
|
||||
@ApiOperation({ summary: 'List inventory ready for loading' })
|
||||
findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) {
|
||||
return this.inventoryService.findReadyForLoading(filter);
|
||||
}
|
||||
|
||||
@Get('inquiry')
|
||||
@ApiOperation({ summary: 'Locate any item inside the warehouse' })
|
||||
inquiry(@Query() filter: InquiryWarehouseInventoryDto) {
|
||||
return this.inventoryService.inquiry(filter);
|
||||
}
|
||||
|
||||
@Get('arrival-queue')
|
||||
@ApiOperation({ summary: 'Arrived bookings awaiting unload / inspection' })
|
||||
arrivalQueue() {
|
||||
return this.inventoryService.arrivalQueue();
|
||||
}
|
||||
|
||||
@Post('auto-unload-arrived')
|
||||
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
|
||||
autoUnloadArrived() {
|
||||
return this.inventoryService.autoUnloadArrived();
|
||||
}
|
||||
|
||||
@Post('auto-load-ready')
|
||||
@ApiOperation({ summary: 'Auto-load READY_FOR_LOADING inventory with PAID bookings' })
|
||||
autoLoadReady() {
|
||||
return this.inventoryService.autoLoadReady();
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/unload')
|
||||
@ApiOperation({ summary: 'Unload a single arrived booking into a location' })
|
||||
unloadBooking(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: UnloadBookingDto,
|
||||
) {
|
||||
return this.inventoryService.unloadBooking(bookingId, dto);
|
||||
}
|
||||
|
||||
@Post(':id/gate-clearance')
|
||||
@ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' })
|
||||
gateClearance(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.gateClearance(id, performedBy);
|
||||
}
|
||||
|
||||
@Get('loadable-wagons')
|
||||
@ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' })
|
||||
loadableWagons() {
|
||||
return this.scheduling.listLoadableWagons();
|
||||
}
|
||||
|
||||
@Get('booking/:bookingId/schedule')
|
||||
@ApiOperation({ summary: 'Read-only schedule + wagon + departure status for a booking' })
|
||||
bookingSchedule(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.scheduling.getBookingSchedule(bookingId);
|
||||
}
|
||||
|
||||
@Post('receive')
|
||||
@ApiOperation({ summary: 'Receive inventory at a warehouse location' })
|
||||
receive(@Body() dto: ReceiveWarehouseInventoryDto) {
|
||||
return this.inventoryService.receive(dto);
|
||||
}
|
||||
|
||||
@Post('reserve')
|
||||
@ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' })
|
||||
reserve(@Body() dto: ReserveInventoryDto) {
|
||||
return this.inventoryService.reserve(dto);
|
||||
}
|
||||
|
||||
@Get(':id/movements')
|
||||
@ApiOperation({ summary: 'Inventory movement history' })
|
||||
movements(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.inventoryService.findMovements(id);
|
||||
}
|
||||
|
||||
@Get(':id/activity')
|
||||
@ApiOperation({ summary: 'Inventory activity log' })
|
||||
activity(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.inventoryService.findActivity(id);
|
||||
}
|
||||
|
||||
@Get(':id/loadings')
|
||||
@ApiOperation({ summary: 'Loading records for an inventory item' })
|
||||
loadings(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.inventoryService.findLoadingsByInventory(id);
|
||||
}
|
||||
|
||||
@Post(':id/move')
|
||||
@ApiOperation({ summary: 'Move inventory to another warehouse/yard/zone' })
|
||||
move(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveInventoryDto) {
|
||||
return this.inventoryService.move(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/store')
|
||||
@ApiOperation({ summary: 'Mark received inventory as STORED' })
|
||||
store(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.store(id, performedBy);
|
||||
}
|
||||
|
||||
@Post(':id/ready-for-loading')
|
||||
@ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' })
|
||||
readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.readyForLoading(id, performedBy);
|
||||
}
|
||||
|
||||
@Post(':id/load')
|
||||
@ApiOperation({ summary: 'Load READY_FOR_LOADING inventory onto a wagon' })
|
||||
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadInventoryDto) {
|
||||
return this.inventoryService.load(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/dispatch')
|
||||
@ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' })
|
||||
dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.dispatch(id, performedBy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseInventoryRepository extends BaseRepository<WarehouseInventory> {
|
||||
constructor(@InjectRepository(WarehouseInventory) repository: Repository<WarehouseInventory>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,940 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm';
|
||||
|
||||
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
|
||||
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
||||
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||
import { WarehouseAllocationService } from './warehouse-allocation.service';
|
||||
import { WarehouseInvoiceService } from './warehouse-invoice.service';
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
|
||||
import {
|
||||
WAREHOUSE_INVENTORY_TRANSITIONS,
|
||||
WarehouseInventory,
|
||||
WarehouseInventoryStatus,
|
||||
} from './entities/warehouse-inventory.entity';
|
||||
import { WarehouseLoading } from './entities/warehouse-loading.entity';
|
||||
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
||||
import { Warehouse } from './entities/warehouse.entity';
|
||||
import { SchedulingReadFacade } from './scheduling-read.facade';
|
||||
import { WarehouseActivityLogService } from './warehouse-activity-log.service';
|
||||
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
||||
|
||||
/** Wagon states that may receive a load (besides being part of an existing schedule). */
|
||||
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
|
||||
|
||||
export interface InventoryInquiryResult {
|
||||
id: string;
|
||||
bookingId: string | null;
|
||||
bookingNumber: string | null;
|
||||
customerName: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
cargoDescription: string | null;
|
||||
goodsId: string | null;
|
||||
warehouse: { id: string; name: string; code: string } | null;
|
||||
yard: { id: string; name: string; code: string } | null;
|
||||
zone: { id: string; name: string; code: string } | null;
|
||||
status: string;
|
||||
quantity: number;
|
||||
weight: number;
|
||||
arrivedAt: Date | null;
|
||||
readyForLoadingAt: Date | null;
|
||||
}
|
||||
|
||||
interface LocationNode {
|
||||
maxWeight?: number | null;
|
||||
capacityWeight?: number | null;
|
||||
maxVolume?: number | null;
|
||||
capacityContainers?: number | null;
|
||||
currentWeight: number;
|
||||
currentVolume: number;
|
||||
currentContainers: number;
|
||||
}
|
||||
|
||||
// ── Batch 4.5 result/queue shapes ────────────────────────────────────────────
|
||||
interface ArrivalQueueRow {
|
||||
bookingId: string;
|
||||
bookingReference: string;
|
||||
customer: string | null;
|
||||
cargo: string | null;
|
||||
container: string | null;
|
||||
arrivalDate: Date | null;
|
||||
bookingStatus: string;
|
||||
inventoryId: string | null;
|
||||
currentStatus: string | null;
|
||||
inspectionStatus: string | null;
|
||||
facility: string | null;
|
||||
warehouse: string | null;
|
||||
yard: string | null;
|
||||
zone: string | null;
|
||||
}
|
||||
|
||||
export interface ArrivalQueueItem {
|
||||
bookingId: string;
|
||||
bookingReference: string;
|
||||
customer: string | null;
|
||||
cargo: string | null;
|
||||
container: string | null;
|
||||
facility: string | null;
|
||||
warehouse: string | null;
|
||||
yard: string | null;
|
||||
zone: string | null;
|
||||
inventoryId: string | null;
|
||||
currentStatus: string | null;
|
||||
arrivalDate: Date | null;
|
||||
inspectionStatus: string | null;
|
||||
unloaded: boolean;
|
||||
}
|
||||
|
||||
interface DefaultLocation {
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
facilityId: string | null;
|
||||
}
|
||||
|
||||
export interface AutoUnloadResult {
|
||||
processedCount: number;
|
||||
skippedCount: number;
|
||||
failedCount: number;
|
||||
results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[];
|
||||
}
|
||||
|
||||
export interface AutoLoadResult {
|
||||
loadedCount: number;
|
||||
skippedCount: number;
|
||||
results: { inventoryId: string; status: string; reason?: string }[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseInventoryService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly inventoryRepository: WarehouseInventoryRepository,
|
||||
private readonly loadingRepository: WarehouseLoadingRepository,
|
||||
private readonly activityLog: WarehouseActivityLogService,
|
||||
private readonly scheduling: SchedulingReadFacade,
|
||||
private readonly allocation: WarehouseAllocationService,
|
||||
private readonly invoices: WarehouseInvoiceService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Batch 6 — final terminal release / gate clearance.
|
||||
* Blocked while an unpaid demurrage/storage invoice exists. Does NOT touch
|
||||
* inspection / storage / loading steps — only the final release.
|
||||
*/
|
||||
async gateClearance(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
const blocking = await this.invoices.findBlockingInvoice(id);
|
||||
if (blocking) {
|
||||
throw new BadRequestException(
|
||||
'Warehouse demurrage/storage fee must be paid before terminal release.',
|
||||
);
|
||||
}
|
||||
const now = new Date();
|
||||
await this.inventoryRepository.update(id, {
|
||||
gateClearedAt: now,
|
||||
releaseDate: item.releaseDate ?? now,
|
||||
});
|
||||
await this.activityLog.record({
|
||||
activityType: 'INVENTORY_DISPATCHED',
|
||||
inventoryId: id,
|
||||
warehouseId: item.warehouseId,
|
||||
description: 'Gate clearance / terminal release',
|
||||
performedBy,
|
||||
});
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
// ── Listing ────────────────────────────────────────────────────────────
|
||||
|
||||
findAll(filter: FilterWarehouseInventoryDto): Promise<WarehouseInventory[]> {
|
||||
const base = {
|
||||
...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}),
|
||||
...(filter.yardId ? { yardId: filter.yardId } : {}),
|
||||
...(filter.zoneId ? { zoneId: filter.zoneId } : {}),
|
||||
...(filter.bookingId ? { bookingId: filter.bookingId } : {}),
|
||||
...(filter.cargoId ? { cargoId: filter.cargoId } : {}),
|
||||
...(filter.containerId ? { containerId: filter.containerId } : {}),
|
||||
...(filter.goodsId ? { goodsId: filter.goodsId } : {}),
|
||||
...(filter.status ? { status: filter.status } : {}),
|
||||
};
|
||||
|
||||
const search = filter.search?.trim();
|
||||
const where: FindManyOptions<WarehouseInventory>['where'] = search
|
||||
? { ...base, notes: ILike(`%${search}%`) }
|
||||
: base;
|
||||
|
||||
return this.inventoryRepository.findAll({
|
||||
where,
|
||||
relations: { warehouse: { facility: true }, yard: true, zone: true, booking: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
findReadyForLoading(filter: FilterWarehouseInventoryDto): Promise<WarehouseInventory[]> {
|
||||
return this.findAll({ ...filter, status: 'READY_FOR_LOADING' });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WarehouseInventory> {
|
||||
const item = await this.inventoryRepository.findById(id, {
|
||||
relations: { warehouse: true, yard: true, zone: true },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
throw new NotFoundException(`Inventory item ${id} not found`);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
// ── Batch 4.5: Arrival / Unload / Load automation ──────────────────────────
|
||||
|
||||
/** Bookings whose goods have arrived and may be unloaded into the warehouse. */
|
||||
private readonly ARRIVED_BOOKING_STATUSES = ['IN_TRANSIT'];
|
||||
|
||||
/** Arrived bookings + their current inventory/inspection state (queue view). */
|
||||
async arrivalQueue(): Promise<ArrivalQueueItem[]> {
|
||||
const rows: ArrivalQueueRow[] = await this.dataSource.query(
|
||||
`SELECT b.id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
company.name AS "customer",
|
||||
b.cargo_free_text AS "cargo",
|
||||
ct.container_number AS "container",
|
||||
b.scheduled_date AS "arrivalDate",
|
||||
b.status AS "bookingStatus",
|
||||
inv.id AS "inventoryId",
|
||||
inv.status AS "currentStatus",
|
||||
inv.inspection_status AS "inspectionStatus",
|
||||
fac.name AS "facility",
|
||||
wh.name AS "warehouse",
|
||||
yard.name AS "yard",
|
||||
zone.name AS "zone"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
|
||||
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
|
||||
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
|
||||
LEFT JOIN freight.facilities fac ON fac.id = wh.facility_id
|
||||
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
||||
WHERE b.status = ANY($1) AND b.deleted_at IS NULL
|
||||
ORDER BY b.scheduled_date DESC NULLS LAST`,
|
||||
[this.ARRIVED_BOOKING_STATUSES],
|
||||
);
|
||||
|
||||
return rows.map((r) => ({
|
||||
bookingId: r.bookingId,
|
||||
bookingReference: r.bookingReference,
|
||||
customer: r.customer ?? null,
|
||||
cargo: r.cargo ?? null,
|
||||
container: r.container ?? null,
|
||||
facility: r.facility ?? null,
|
||||
warehouse: r.warehouse ?? null,
|
||||
yard: r.yard ?? null,
|
||||
zone: r.zone ?? null,
|
||||
inventoryId: r.inventoryId ?? null,
|
||||
currentStatus: r.currentStatus ?? null,
|
||||
arrivalDate: r.arrivalDate ?? null,
|
||||
inspectionStatus: r.inspectionStatus ?? null,
|
||||
unloaded: Boolean(r.inventoryId),
|
||||
}));
|
||||
}
|
||||
|
||||
/** First warehouse that has at least one yard + zone (fallback location for auto-unload). */
|
||||
private async pickDefaultLocation(): Promise<DefaultLocation | null> {
|
||||
const [row]: DefaultLocation[] = await this.dataSource.query(
|
||||
`SELECT wh.id AS "warehouseId", wh.facility_id AS "facilityId",
|
||||
yard.id AS "yardId", zone.id AS "zoneId"
|
||||
FROM freight.warehouses wh
|
||||
JOIN freight.warehouse_yards yard ON yard.warehouse_id = wh.id AND yard.deleted_at IS NULL
|
||||
JOIN freight.warehouse_zones zone ON zone.yard_id = yard.id AND zone.deleted_at IS NULL
|
||||
WHERE wh.deleted_at IS NULL
|
||||
ORDER BY wh.created_at ASC
|
||||
LIMIT 1`,
|
||||
);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** Bulk-create inventory (RECEIVED) for arrived bookings that are not yet unloaded. */
|
||||
async autoUnloadArrived(): Promise<AutoUnloadResult> {
|
||||
const arrived: {
|
||||
id: string;
|
||||
weight: string | null;
|
||||
freightType: string | null;
|
||||
tradeDirection: string | null;
|
||||
cargoTypeCode: string | null;
|
||||
}[] = await this.dataSource.query(
|
||||
`SELECT b.id, b.cargo_total_weight_vgm AS weight,
|
||||
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
|
||||
cgt.code AS "cargoTypeCode"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
WHERE b.status = ANY($1) AND b.deleted_at IS NULL AND inv.id IS NULL`,
|
||||
[this.ARRIVED_BOOKING_STATUSES],
|
||||
);
|
||||
|
||||
const result: AutoUnloadResult = { processedCount: 0, skippedCount: 0, failedCount: 0, results: [] };
|
||||
|
||||
if (arrived.length === 0) return result;
|
||||
|
||||
const fallback = await this.pickDefaultLocation();
|
||||
|
||||
for (const booking of arrived) {
|
||||
try {
|
||||
// Deterministic allocation by rules; fall back to default location if no rule resolves.
|
||||
const allocated = await this.allocation.resolveLocation({
|
||||
freightType: booking.freightType,
|
||||
tradeDirection: booking.tradeDirection,
|
||||
cargoTypeCode: booking.cargoTypeCode,
|
||||
});
|
||||
const location = allocated ?? fallback;
|
||||
if (!location) {
|
||||
result.failedCount += 1;
|
||||
result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' });
|
||||
continue;
|
||||
}
|
||||
const saved = await this.inventoryRepository.create({
|
||||
warehouseId: location.warehouseId,
|
||||
yardId: location.yardId,
|
||||
zoneId: location.zoneId,
|
||||
bookingId: booking.id,
|
||||
quantity: 1,
|
||||
weight: Number(booking.weight) || 0,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt: new Date(),
|
||||
notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue',
|
||||
});
|
||||
result.processedCount += 1;
|
||||
result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'PROCESSED' });
|
||||
} catch (error) {
|
||||
result.failedCount += 1;
|
||||
result.results.push({
|
||||
bookingId: booking.id,
|
||||
status: 'FAILED',
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Unload a single arrived booking into a chosen (or default) location. */
|
||||
async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise<WarehouseInventory> {
|
||||
const existing = await this.inventoryRepository.findAll({ where: { bookingId } });
|
||||
|
||||
let location: DefaultLocation | null =
|
||||
dto.warehouseId && dto.yardId && dto.zoneId
|
||||
? { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, facilityId: dto.facilityId ?? null }
|
||||
: null;
|
||||
if (!location) location = await this.pickDefaultLocation();
|
||||
if (!location) {
|
||||
throw new BadRequestException('No warehouse/yard/zone provided or configured for unloading');
|
||||
}
|
||||
|
||||
const arrivedAt = dto.unloadedAt ? new Date(dto.unloadedAt) : new Date();
|
||||
|
||||
if (existing[0]) {
|
||||
await this.inventoryRepository.update(existing[0].id, {
|
||||
warehouseId: location.warehouseId,
|
||||
yardId: location.yardId,
|
||||
zoneId: location.zoneId,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt,
|
||||
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
|
||||
});
|
||||
return this.findById(existing[0].id);
|
||||
}
|
||||
|
||||
const saved = await this.inventoryRepository.create({
|
||||
warehouseId: location.warehouseId,
|
||||
yardId: location.yardId,
|
||||
zoneId: location.zoneId,
|
||||
bookingId,
|
||||
quantity: 1,
|
||||
weight: 0,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt,
|
||||
notes: dto.notes ?? 'Unloaded',
|
||||
});
|
||||
return this.findById(saved.id);
|
||||
}
|
||||
|
||||
/** Auto-load all READY_FOR_LOADING inventory whose booking is PAID. Unpaid stay pending. */
|
||||
async autoLoadReady(): Promise<AutoLoadResult> {
|
||||
const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } });
|
||||
const result: AutoLoadResult = { loadedCount: 0, skippedCount: 0, results: [] };
|
||||
|
||||
for (const item of ready) {
|
||||
const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null;
|
||||
if (bookingStatus !== 'PAID') {
|
||||
result.skippedCount += 1;
|
||||
result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason: 'Booking not PAID' });
|
||||
continue;
|
||||
}
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(item.id, {
|
||||
status: 'LOADED',
|
||||
loadedAt: new Date(),
|
||||
});
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_LOADED',
|
||||
inventoryId: item.id,
|
||||
warehouseId: item.warehouseId,
|
||||
description: 'Auto-loaded (PAID booking)',
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
result.loadedCount += 1;
|
||||
result.results.push({ inventoryId: item.id, status: 'LOADED' });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Receive ──────────────────────────────────────────────────────────────
|
||||
|
||||
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
||||
const weight = Number(dto.weight) || 0;
|
||||
const volume = Number(dto.volume) || 0;
|
||||
const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0;
|
||||
|
||||
const id = await this.dataSource.transaction(async (manager) => {
|
||||
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
|
||||
|
||||
if (dto.bookingId) {
|
||||
await this.assertBookingExists(manager, dto.bookingId);
|
||||
}
|
||||
|
||||
this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount);
|
||||
this.assertCapacity('Yard', yard, weight, volume, containerCount);
|
||||
this.assertCapacity('Zone', zone, weight, volume, containerCount);
|
||||
|
||||
const now = new Date();
|
||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||
manager.getRepository(WarehouseInventory).create({
|
||||
warehouseId: dto.warehouseId,
|
||||
yardId: dto.yardId,
|
||||
zoneId: dto.zoneId,
|
||||
bookingId: dto.bookingId ?? null,
|
||||
cargoId: dto.cargoId ?? null,
|
||||
containerId: dto.containerId ?? null,
|
||||
goodsId: dto.goodsId ?? null,
|
||||
quantity: Number(dto.quantity) || 0,
|
||||
weight,
|
||||
volume: dto.volume ?? null,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt: now,
|
||||
notes: dto.notes?.trim() ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.applyCapacityDelta(manager, dto.warehouseId, dto.yardId, dto.zoneId, weight, volume, containerCount, +1);
|
||||
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_RECEIVED',
|
||||
inventoryId: saved.id,
|
||||
warehouseId: dto.warehouseId,
|
||||
description: `Received ${weight}kg at warehouse location`,
|
||||
performedBy: dto.performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
return saved.id;
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
// ── Lifecycle transitions ────────────────────────────────────────────────
|
||||
|
||||
store(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||||
return this.transition(id, 'STORED', {
|
||||
timestampField: 'storedAt',
|
||||
activityType: 'INVENTORY_STORED',
|
||||
description: 'Inventory stored',
|
||||
performedBy,
|
||||
});
|
||||
}
|
||||
|
||||
async reserve(dto: ReserveInventoryDto): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(dto.inventoryId);
|
||||
|
||||
if (item.status !== 'STORED') {
|
||||
throw new BadRequestException(`Inventory must be STORED to reserve (current: ${item.status})`);
|
||||
}
|
||||
|
||||
const status = await this.getBookingStatus(dto.bookingId);
|
||||
if (!status) {
|
||||
throw new NotFoundException(`Booking ${dto.bookingId} not found`);
|
||||
}
|
||||
if (status !== 'PAID') {
|
||||
throw new BadRequestException(`Booking must be PAID to reserve inventory (current: ${status})`);
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(dto.inventoryId, {
|
||||
status: 'RESERVED',
|
||||
bookingId: dto.bookingId,
|
||||
reservedAt: new Date(),
|
||||
});
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_RESERVED',
|
||||
inventoryId: dto.inventoryId,
|
||||
warehouseId: item.warehouseId,
|
||||
description: `Reserved for booking ${dto.bookingId}`,
|
||||
performedBy: dto.performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
return this.findById(dto.inventoryId);
|
||||
}
|
||||
|
||||
async readyForLoading(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
if (!item.bookingId || !item.warehouseId || !item.yardId || !item.zoneId) {
|
||||
throw new BadRequestException('Inventory must have booking, warehouse, yard and zone before loading prep');
|
||||
}
|
||||
return this.transition(id, 'READY_FOR_LOADING', {
|
||||
timestampField: 'readyForLoadingAt',
|
||||
activityType: 'READY_FOR_LOADING',
|
||||
description: 'Inventory ready for loading',
|
||||
performedBy,
|
||||
preloaded: item,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load READY_FOR_LOADING inventory onto a wagon. Creates a WarehouseLoading record.
|
||||
* Reads wagon/schedule data read-only — never modifies scheduling.
|
||||
*/
|
||||
async load(id: string, dto: LoadInventoryDto): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
|
||||
// 1. inventory status must be READY_FOR_LOADING (and not already LOADED).
|
||||
this.assertTransition(item.status, 'LOADED');
|
||||
|
||||
// 2. inventory is at a valid warehouse/yard/zone location.
|
||||
if (!item.warehouseId || !item.yardId || !item.zoneId) {
|
||||
throw new BadRequestException('Inventory must be at a warehouse/yard/zone before loading');
|
||||
}
|
||||
|
||||
// 3. wagon must exist.
|
||||
const wagon = await this.scheduling.findWagon(dto.wagonId);
|
||||
if (!wagon) {
|
||||
throw new NotFoundException(`Wagon ${dto.wagonId} not found`);
|
||||
}
|
||||
|
||||
// 4. wagon must be available, or already selected by an existing train schedule.
|
||||
const scheduled = await this.scheduling.isWagonScheduled(dto.wagonId);
|
||||
if (!LOADABLE_WAGON_STATUSES.includes(wagon.status) && !scheduled) {
|
||||
throw new BadRequestException(
|
||||
`Wagon ${wagon.wagonNumber} is not available for loading (status: ${wagon.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. inventory must not already have a loading record.
|
||||
const existing = await this.loadingRepository.findAll({ where: { warehouseInventoryId: id } });
|
||||
if (existing.length > 0) {
|
||||
throw new BadRequestException('Inventory has already been loaded');
|
||||
}
|
||||
|
||||
const loadedWeight = dto.loadedWeight ?? (Number(item.weight) || 0);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const now = new Date();
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
status: 'LOADED',
|
||||
loadedAt: now,
|
||||
});
|
||||
|
||||
await manager.getRepository(WarehouseLoading).save(
|
||||
manager.getRepository(WarehouseLoading).create({
|
||||
warehouseInventoryId: id,
|
||||
bookingId: item.bookingId ?? null,
|
||||
wagonId: dto.wagonId,
|
||||
loadedAt: now,
|
||||
loadedBy: dto.loadedBy ?? null,
|
||||
loadedWeight,
|
||||
notes: dto.notes?.trim() ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_LOADED',
|
||||
inventoryId: id,
|
||||
warehouseId: item.warehouseId,
|
||||
description: `Loaded onto wagon ${wagon.wagonNumber}`,
|
||||
performedBy: dto.loadedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
// ── Loading records (Batch 3) ─────────────────────────────────────────────
|
||||
|
||||
async findLoadings(
|
||||
filter: { bookingId?: string; wagonId?: string },
|
||||
): Promise<Array<WarehouseLoading & { wagonNumber: string | null }>> {
|
||||
const where = {
|
||||
...(filter.bookingId ? { bookingId: filter.bookingId } : {}),
|
||||
...(filter.wagonId ? { wagonId: filter.wagonId } : {}),
|
||||
};
|
||||
const loadings = await this.loadingRepository.findAll({
|
||||
where,
|
||||
relations: { inventory: { warehouse: true, yard: true, zone: true } },
|
||||
order: { loadedAt: 'DESC' },
|
||||
});
|
||||
|
||||
// Enrich with wagon numbers (read-only lookup into the scheduling domain).
|
||||
const wagonIds = [...new Set(loadings.map((l) => l.wagonId))];
|
||||
const wagonNumbers = new Map<string, string>();
|
||||
if (wagonIds.length > 0) {
|
||||
const rows: Array<{ id: string; wagon_number: string }> = await this.dataSource.query(
|
||||
'SELECT id, wagon_number FROM freight.wagons WHERE id = ANY($1)',
|
||||
[wagonIds],
|
||||
);
|
||||
rows.forEach((r) => wagonNumbers.set(r.id, r.wagon_number));
|
||||
}
|
||||
|
||||
return loadings.map((loading) =>
|
||||
Object.assign(loading, { wagonNumber: wagonNumbers.get(loading.wagonId) ?? null }),
|
||||
);
|
||||
}
|
||||
|
||||
findLoadingsByInventory(inventoryId: string): Promise<WarehouseLoading[]> {
|
||||
return this.loadingRepository.findAll({
|
||||
where: { warehouseInventoryId: inventoryId },
|
||||
order: { loadedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async dispatch(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
this.assertTransition(item.status, 'DISPATCHED');
|
||||
|
||||
const weight = Number(item.weight) || 0;
|
||||
const volume = Number(item.volume) || 0;
|
||||
const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0;
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
status: 'DISPATCHED',
|
||||
dispatchedAt: new Date(),
|
||||
});
|
||||
// Item physically leaves the warehouse — free up capacity.
|
||||
await this.applyCapacityDelta(manager, item.warehouseId, item.yardId, item.zoneId, weight, volume, containerCount, -1);
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_DISPATCHED',
|
||||
inventoryId: id,
|
||||
warehouseId: item.warehouseId,
|
||||
description: 'Inventory dispatched',
|
||||
performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
// ── Movement ──────────────────────────────────────────────────────────────
|
||||
|
||||
async move(id: string, dto: MoveInventoryDto): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
if (item.status === 'DISPATCHED') {
|
||||
throw new BadRequestException('Dispatched inventory cannot be moved');
|
||||
}
|
||||
|
||||
const weight = Number(item.weight) || 0;
|
||||
const volume = Number(item.volume) || 0;
|
||||
const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0;
|
||||
|
||||
const from = { warehouseId: item.warehouseId, yardId: item.yardId, zoneId: item.zoneId };
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const { warehouse } = await this.validateLocation(manager, dto);
|
||||
|
||||
// Capacity check at the destination (item is added there).
|
||||
const dest = await this.loadLocation(manager, dto);
|
||||
this.assertCapacity('Warehouse', dest.warehouse, weight, volume, containerCount);
|
||||
this.assertCapacity('Yard', dest.yard, weight, volume, containerCount);
|
||||
this.assertCapacity('Zone', dest.zone, weight, volume, containerCount);
|
||||
|
||||
// Free the old location, occupy the new one.
|
||||
await this.applyCapacityDelta(manager, from.warehouseId, from.yardId, from.zoneId, weight, volume, containerCount, -1);
|
||||
await this.applyCapacityDelta(manager, dto.warehouseId, dto.yardId, dto.zoneId, weight, volume, containerCount, +1);
|
||||
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
warehouseId: dto.warehouseId,
|
||||
yardId: dto.yardId,
|
||||
zoneId: dto.zoneId,
|
||||
});
|
||||
|
||||
await manager.getRepository(WarehouseInventoryMovement).save(
|
||||
manager.getRepository(WarehouseInventoryMovement).create({
|
||||
inventoryId: id,
|
||||
fromWarehouseId: from.warehouseId,
|
||||
fromYardId: from.yardId,
|
||||
fromZoneId: from.zoneId,
|
||||
toWarehouseId: dto.warehouseId,
|
||||
toYardId: dto.yardId,
|
||||
toZoneId: dto.zoneId,
|
||||
remarks: dto.remarks?.trim() ?? null,
|
||||
movedBy: dto.movedBy ?? 'system',
|
||||
movedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_MOVED',
|
||||
inventoryId: id,
|
||||
warehouseId: warehouse.id,
|
||||
description: dto.remarks?.trim() || 'Inventory moved',
|
||||
performedBy: dto.movedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
findMovements(id: string): Promise<WarehouseInventoryMovement[]> {
|
||||
return this.dataSource.getRepository(WarehouseInventoryMovement).find({
|
||||
where: { inventoryId: id },
|
||||
order: { movedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
findActivity(id: string): Promise<WarehouseActivityLog[]> {
|
||||
return this.activityLog.findByInventory(id);
|
||||
}
|
||||
|
||||
// ── Inquiry (Batch 1) ──────────────────────────────────────────────────
|
||||
|
||||
async inquiry(filter: InquiryWarehouseInventoryDto): Promise<InventoryInquiryResult[]> {
|
||||
const qb = this.dataSource
|
||||
.getRepository(WarehouseInventory)
|
||||
.createQueryBuilder('inv')
|
||||
.leftJoinAndSelect('inv.warehouse', 'warehouse')
|
||||
.leftJoinAndSelect('inv.yard', 'yard')
|
||||
.leftJoinAndSelect('inv.zone', 'zone')
|
||||
.leftJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id')
|
||||
.leftJoin('freight.companies', 'company', 'company.id = booking.company_id')
|
||||
.leftJoin('freight.containers', 'container', 'container.id = inv.container_id')
|
||||
.leftJoin('freight.cargoes', 'cargo', 'cargo.id = inv.cargo_id')
|
||||
.leftJoin('freight.cargo_types', 'cargo_type', 'cargo_type.id = cargo.cargo_type_id')
|
||||
.addSelect('booking.reference', 'b_reference')
|
||||
.addSelect('company.name', 'c_name')
|
||||
.addSelect('container.container_number', 'ct_number')
|
||||
.addSelect('cargo.description', 'cg_description')
|
||||
.addSelect('cargo_type.cargo_type_name', 'cgt_name')
|
||||
.orderBy('inv.created_at', 'DESC');
|
||||
|
||||
if (filter.bookingNumber?.trim()) {
|
||||
qb.andWhere('booking.reference ILIKE :bn', { bn: `%${filter.bookingNumber.trim()}%` });
|
||||
}
|
||||
if (filter.containerNumber?.trim()) {
|
||||
qb.andWhere('container.container_number ILIKE :cn', { cn: `%${filter.containerNumber.trim()}%` });
|
||||
}
|
||||
if (filter.cargoType?.trim()) {
|
||||
qb.andWhere('cargo_type.cargo_type_name ILIKE :ctype', { ctype: `%${filter.cargoType.trim()}%` });
|
||||
}
|
||||
if (filter.goodsName?.trim()) {
|
||||
qb.andWhere('(inv.notes ILIKE :gn OR cargo.description ILIKE :gn)', { gn: `%${filter.goodsName.trim()}%` });
|
||||
}
|
||||
if (filter.warehouseId) qb.andWhere('inv.warehouse_id = :wid', { wid: filter.warehouseId });
|
||||
if (filter.yardId) qb.andWhere('inv.yard_id = :yid', { yid: filter.yardId });
|
||||
if (filter.zoneId) qb.andWhere('inv.zone_id = :zid', { zid: filter.zoneId });
|
||||
if (filter.status) qb.andWhere('inv.status = :status', { status: filter.status });
|
||||
|
||||
const { entities, raw } = await qb.getRawAndEntities();
|
||||
|
||||
return entities.map((inv, index) => {
|
||||
const row = raw[index] ?? {};
|
||||
return {
|
||||
id: inv.id,
|
||||
bookingId: inv.bookingId ?? null,
|
||||
bookingNumber: row.b_reference ?? null,
|
||||
customerName: row.c_name ?? null,
|
||||
containerNumber: row.ct_number ?? null,
|
||||
cargoType: row.cgt_name ?? null,
|
||||
cargoDescription: row.cg_description ?? null,
|
||||
goodsId: inv.goodsId ?? null,
|
||||
warehouse: inv.warehouse
|
||||
? { id: inv.warehouse.id, name: inv.warehouse.name, code: inv.warehouse.code }
|
||||
: null,
|
||||
yard: inv.yard ? { id: inv.yard.id, name: inv.yard.name, code: inv.yard.code } : null,
|
||||
zone: inv.zone ? { id: inv.zone.id, name: inv.zone.name, code: inv.zone.code } : null,
|
||||
status: inv.status,
|
||||
quantity: Number(inv.quantity),
|
||||
weight: Number(inv.weight),
|
||||
arrivedAt: inv.arrivedAt ?? null,
|
||||
readyForLoadingAt: inv.readyForLoadingAt ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private async transition(
|
||||
id: string,
|
||||
to: WarehouseInventoryStatus,
|
||||
opts: {
|
||||
timestampField: keyof WarehouseInventory;
|
||||
activityType: Parameters<WarehouseActivityLogService['record']>[0]['activityType'];
|
||||
description: string;
|
||||
performedBy?: string;
|
||||
preloaded?: WarehouseInventory;
|
||||
},
|
||||
): Promise<WarehouseInventory> {
|
||||
const item = opts.preloaded ?? (await this.findById(id));
|
||||
this.assertTransition(item.status, to);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
status: to,
|
||||
[opts.timestampField]: new Date(),
|
||||
});
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: opts.activityType,
|
||||
inventoryId: id,
|
||||
warehouseId: item.warehouseId,
|
||||
description: opts.description,
|
||||
performedBy: opts.performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
private assertTransition(from: WarehouseInventoryStatus, to: WarehouseInventoryStatus): void {
|
||||
if (!WAREHOUSE_INVENTORY_TRANSITIONS[from]?.includes(to)) {
|
||||
throw new BadRequestException(`Invalid transition ${from} → ${to}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateLocation(
|
||||
manager: EntityManager,
|
||||
dto: { warehouseId: string; yardId: string; zoneId: string },
|
||||
): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> {
|
||||
const { warehouse, yard, zone } = await this.loadLocation(manager, dto);
|
||||
|
||||
if (warehouse.status !== 'ACTIVE') throw new BadRequestException('Warehouse is not ACTIVE');
|
||||
if (yard.warehouseId !== warehouse.id) throw new BadRequestException('Yard does not belong to the selected warehouse');
|
||||
if (yard.status !== 'ACTIVE') throw new BadRequestException('Yard is not ACTIVE');
|
||||
if (zone.yardId !== yard.id) throw new BadRequestException('Zone does not belong to the selected yard');
|
||||
if (zone.status !== 'ACTIVE') throw new BadRequestException('Zone is not ACTIVE');
|
||||
|
||||
return { warehouse, yard, zone };
|
||||
}
|
||||
|
||||
private async loadLocation(
|
||||
manager: EntityManager,
|
||||
dto: { warehouseId: string; yardId: string; zoneId: string },
|
||||
): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> {
|
||||
const warehouse = await manager.getRepository(Warehouse).findOne({ where: { id: dto.warehouseId } });
|
||||
if (!warehouse) throw new NotFoundException(`Warehouse ${dto.warehouseId} not found`);
|
||||
const yard = await manager.getRepository(WarehouseYard).findOne({ where: { id: dto.yardId } });
|
||||
if (!yard) throw new NotFoundException(`Yard ${dto.yardId} not found`);
|
||||
const zone = await manager.getRepository(WarehouseZone).findOne({ where: { id: dto.zoneId } });
|
||||
if (!zone) throw new NotFoundException(`Zone ${dto.zoneId} not found`);
|
||||
return { warehouse, yard, zone };
|
||||
}
|
||||
|
||||
private async assertBookingExists(manager: EntityManager, bookingId: string): Promise<void> {
|
||||
const rows = await manager.query(
|
||||
'SELECT id FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1',
|
||||
[bookingId],
|
||||
);
|
||||
if (!rows || rows.length === 0) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
private async getBookingStatus(bookingId: string): Promise<string | null> {
|
||||
const rows = await this.dataSource.query(
|
||||
'SELECT status FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1',
|
||||
[bookingId],
|
||||
);
|
||||
return rows?.[0]?.status ?? null;
|
||||
}
|
||||
|
||||
private assertCapacity(
|
||||
label: string,
|
||||
node: LocationNode,
|
||||
weightAdd: number,
|
||||
volumeAdd: number,
|
||||
containerAdd: number,
|
||||
): void {
|
||||
const maxWeight = node.maxWeight ?? node.capacityWeight;
|
||||
if (maxWeight != null) {
|
||||
const projected = Number(node.currentWeight) + weightAdd;
|
||||
if (projected > Number(maxWeight)) {
|
||||
throw new BadRequestException(`${label} weight capacity exceeded (${projected} / ${maxWeight})`);
|
||||
}
|
||||
}
|
||||
if (node.maxVolume != null && volumeAdd > 0) {
|
||||
const projected = Number(node.currentVolume) + volumeAdd;
|
||||
if (projected > Number(node.maxVolume)) {
|
||||
throw new BadRequestException(`${label} volume capacity exceeded (${projected} / ${node.maxVolume})`);
|
||||
}
|
||||
}
|
||||
if (node.capacityContainers != null && containerAdd > 0) {
|
||||
const projected = Number(node.currentContainers) + containerAdd;
|
||||
if (projected > Number(node.capacityContainers)) {
|
||||
throw new BadRequestException(`${label} container capacity exceeded (${projected} / ${node.capacityContainers})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async applyCapacityDelta(
|
||||
manager: EntityManager,
|
||||
warehouseId: string,
|
||||
yardId: string,
|
||||
zoneId: string,
|
||||
weight: number,
|
||||
volume: number,
|
||||
containers: number,
|
||||
sign: 1 | -1,
|
||||
): Promise<void> {
|
||||
const apply = sign === 1 ? manager.increment.bind(manager) : manager.decrement.bind(manager);
|
||||
const targets: Array<[typeof Warehouse | typeof WarehouseYard | typeof WarehouseZone, string]> = [
|
||||
[Warehouse, warehouseId],
|
||||
[WarehouseYard, yardId],
|
||||
[WarehouseZone, zoneId],
|
||||
];
|
||||
|
||||
for (const [entity, id] of targets) {
|
||||
if (weight) await apply(entity, { id }, 'currentWeight', weight);
|
||||
if (volume) await apply(entity, { id }, 'currentVolume', volume);
|
||||
if (containers) await apply(entity, { id }, 'currentContainers', containers);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
|
||||
import { WarehouseInvoiceService } from './warehouse-invoice.service';
|
||||
|
||||
@ApiTags('warehouse-fee-invoices')
|
||||
@ApiBearerAuth()
|
||||
@Controller()
|
||||
export class WarehouseInvoiceController {
|
||||
constructor(private readonly invoiceService: WarehouseInvoiceService) {}
|
||||
|
||||
@Post('warehouse-inventory/:id/generate-fee-invoice')
|
||||
@ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' })
|
||||
generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto) {
|
||||
return this.invoiceService.generateForInventory(id, dto);
|
||||
}
|
||||
|
||||
@Get('warehouse-inventory/:id/fee-invoices')
|
||||
@ApiOperation({ summary: 'List fee invoices for an inventory item' })
|
||||
listForInventory(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.invoiceService.listForInventory(id);
|
||||
}
|
||||
|
||||
@Get('bookings/:id/warehouse-fee-invoices')
|
||||
@ApiOperation({ summary: 'List warehouse fee invoices for a booking' })
|
||||
listForBooking(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.invoiceService.listForBooking(id);
|
||||
}
|
||||
|
||||
@Get('warehouse-fee-invoices')
|
||||
@ApiOperation({ summary: 'List / filter warehouse fee invoices' })
|
||||
findAll(
|
||||
@Query('status') status?: string,
|
||||
@Query('invoiceType') invoiceType?: string,
|
||||
@Query('warehouseId') warehouseId?: string,
|
||||
@Query('facilityId') facilityId?: string,
|
||||
@Query('customerId') customerId?: string,
|
||||
@Query('bookingId') bookingId?: string,
|
||||
) {
|
||||
return this.invoiceService.findAll({
|
||||
status: status as never,
|
||||
invoiceType: invoiceType as never,
|
||||
warehouseId,
|
||||
facilityId,
|
||||
customerId,
|
||||
bookingId,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('warehouse-fee-invoices/:id')
|
||||
@ApiOperation({ summary: 'Get a warehouse fee invoice with items + payment history' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.invoiceService.findById(id);
|
||||
}
|
||||
|
||||
@Patch('warehouse-fee-invoices/:id/cancel')
|
||||
@ApiOperation({ summary: 'Cancel a warehouse fee invoice' })
|
||||
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.invoiceService.cancel(id);
|
||||
}
|
||||
|
||||
@Post('warehouse-fee-invoices/:id/pay')
|
||||
@ApiOperation({ summary: 'Record a payment against a warehouse fee invoice' })
|
||||
pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) {
|
||||
return this.invoiceService.pay(id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import {
|
||||
WarehouseFeeInvoice,
|
||||
WarehouseInvoiceStatus,
|
||||
WarehouseInvoiceType,
|
||||
} from './entities/warehouse-fee-invoice.entity';
|
||||
import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity';
|
||||
import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository';
|
||||
import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository';
|
||||
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||
|
||||
interface GenerateOptions {
|
||||
confirmZero?: boolean;
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export interface PayInvoiceDto {
|
||||
amount: number;
|
||||
method?: string;
|
||||
reference?: string;
|
||||
}
|
||||
|
||||
/** Invoices that still owe money and therefore block terminal release. */
|
||||
const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID'];
|
||||
const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', 'PAID'];
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseInvoiceService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly invoiceRepository: WarehouseFeeInvoiceRepository,
|
||||
private readonly itemRepository: WarehouseFeeInvoiceItemRepository,
|
||||
private readonly feeService: WarehouseFeeService,
|
||||
) {}
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────
|
||||
async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise<WarehouseFeeInvoice> {
|
||||
const [item] = await this.dataSource.query(
|
||||
`SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
|
||||
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt",
|
||||
w.facility_id AS "facilityId",
|
||||
b.company_id AS "customerId", b.freight_type AS "freightType"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL`,
|
||||
[inventoryId],
|
||||
);
|
||||
if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`);
|
||||
|
||||
// Dedup: only one active (non-cancelled) invoice per inventory item.
|
||||
const active = await this.invoiceRepository.findAll({ where: { inventoryId } });
|
||||
if (active.some((inv) => ACTIVE_STATUSES.includes(inv.status))) {
|
||||
throw new ConflictException(
|
||||
'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.',
|
||||
);
|
||||
}
|
||||
|
||||
const previews = await this.feeService.previewForInventory(inventoryId);
|
||||
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
|
||||
const items = previews
|
||||
.filter((p) => p.amount > 0)
|
||||
.map((p) => {
|
||||
const feeType: WarehouseFeeType =
|
||||
p.ruleType === 'STORAGE_FEE'
|
||||
? 'STORAGE_FEE'
|
||||
: isContainer
|
||||
? 'CONTAINER_DEMURRAGE'
|
||||
: 'BULK_DEMURRAGE';
|
||||
return {
|
||||
feeRuleId: p.ruleId,
|
||||
feeType,
|
||||
description:
|
||||
p.ruleType === 'STORAGE_FEE'
|
||||
? `Storage fee — ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`
|
||||
: `${isContainer ? 'Container' : 'Bulk'} demurrage — ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`,
|
||||
quantity: p.chargeableDays,
|
||||
unitRate: p.ratePerDay,
|
||||
amount: p.amount,
|
||||
currency: p.currency,
|
||||
chargeableDays: p.chargeableDays,
|
||||
freeDays: p.freeDays,
|
||||
};
|
||||
});
|
||||
|
||||
const subtotal = items.reduce((s, i) => s + i.amount, 0);
|
||||
const total = subtotal; // tax model can be layered on later
|
||||
|
||||
if (total <= 0 && !opts.confirmZero) {
|
||||
throw new BadRequestException('No payable warehouse fee found for this item.');
|
||||
}
|
||||
|
||||
const hasDemurrage = items.some((i) => i.feeType !== 'STORAGE_FEE');
|
||||
const hasStorage = items.some((i) => i.feeType === 'STORAGE_FEE');
|
||||
const invoiceType: WarehouseInvoiceType =
|
||||
hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE';
|
||||
|
||||
const currency = items[0]?.currency ?? 'USD';
|
||||
const now = new Date();
|
||||
const periodEnd = previews[0] ? new Date(previews[0].endDate) : now;
|
||||
|
||||
const invoice = await this.invoiceRepository.create({
|
||||
invoiceNumber: await this.nextInvoiceNumber(),
|
||||
bookingId: item.bookingId ?? null,
|
||||
customerId: item.customerId ?? null,
|
||||
inventoryId,
|
||||
facilityId: item.facilityId ?? null,
|
||||
warehouseId: item.warehouseId ?? null,
|
||||
yardId: item.yardId ?? null,
|
||||
zoneId: item.zoneId ?? null,
|
||||
invoiceType,
|
||||
status: 'ISSUED',
|
||||
subtotalAmount: subtotal,
|
||||
taxAmount: 0,
|
||||
totalAmount: total,
|
||||
paidAmount: 0,
|
||||
balanceAmount: total,
|
||||
currency,
|
||||
periodStart: item.arrivedAt ?? null,
|
||||
periodEnd,
|
||||
issuedAt: now,
|
||||
payments: [],
|
||||
notes: opts.performedBy ? `Generated by ${opts.performedBy}` : null,
|
||||
});
|
||||
|
||||
for (const it of items) {
|
||||
await this.itemRepository.create({ invoiceId: invoice.id, ...it });
|
||||
}
|
||||
|
||||
return this.findById(invoice.id);
|
||||
}
|
||||
|
||||
/** WHF-YYYYMMDD-00001 — sequential per day. */
|
||||
private async nextInvoiceNumber(): Promise<string> {
|
||||
const now = new Date();
|
||||
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
|
||||
const prefix = `WHF-${ymd}-`;
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
|
||||
FROM freight.warehouse_fee_invoices WHERE invoice_number LIKE $1`,
|
||||
[`${prefix}%`],
|
||||
);
|
||||
const next = Number(row?.seq ?? 0) + 1;
|
||||
return `${prefix}${String(next).padStart(5, '0')}`;
|
||||
}
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────
|
||||
async findById(id: string): Promise<WarehouseFeeInvoice & { items: unknown[] }> {
|
||||
const invoice = await this.invoiceRepository.findById(id);
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||
const items = await this.itemRepository.findAll({ where: { invoiceId: id } });
|
||||
return { ...invoice, items } as WarehouseFeeInvoice & { items: unknown[] };
|
||||
}
|
||||
|
||||
listForInventory(inventoryId: string): Promise<WarehouseFeeInvoice[]> {
|
||||
return this.invoiceRepository.findAll({ where: { inventoryId }, order: { createdAt: 'DESC' } });
|
||||
}
|
||||
|
||||
listForBooking(bookingId: string): Promise<WarehouseFeeInvoice[]> {
|
||||
return this.invoiceRepository.findAll({ where: { bookingId }, order: { createdAt: 'DESC' } });
|
||||
}
|
||||
|
||||
findAll(filter: Partial<Pick<WarehouseFeeInvoice, 'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId'>>): Promise<WarehouseFeeInvoice[]> {
|
||||
const where = Object.fromEntries(Object.entries(filter).filter(([, v]) => v != null));
|
||||
return this.invoiceRepository.findAll({ where, order: { createdAt: 'DESC' } });
|
||||
}
|
||||
|
||||
// ── State changes ────────────────────────────────────────────────────────
|
||||
async cancel(id: string): Promise<WarehouseFeeInvoice> {
|
||||
const invoice = await this.invoiceRepository.findById(id);
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||
if (invoice.status === 'PAID') throw new BadRequestException('A paid invoice cannot be cancelled.');
|
||||
const updated = await this.invoiceRepository.update(id, { status: 'CANCELLED', cancelledAt: new Date() });
|
||||
return updated as WarehouseFeeInvoice;
|
||||
}
|
||||
|
||||
/** Record a payment against the invoice and sync status (links to existing payment flow). */
|
||||
async pay(id: string, dto: PayInvoiceDto): Promise<WarehouseFeeInvoice> {
|
||||
const invoice = await this.invoiceRepository.findById(id);
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||
if (invoice.status === 'CANCELLED') throw new BadRequestException('Cannot pay a cancelled invoice.');
|
||||
if (invoice.status === 'PAID') throw new BadRequestException('Invoice is already fully paid.');
|
||||
if (!(dto.amount > 0)) throw new BadRequestException('Payment amount must be greater than zero.');
|
||||
|
||||
const paidAmount = Number(invoice.paidAmount) + dto.amount;
|
||||
const total = Number(invoice.totalAmount);
|
||||
const balance = Math.max(0, Math.round((total - paidAmount) * 100) / 100);
|
||||
const fullyPaid = paidAmount >= total;
|
||||
|
||||
const payments = [
|
||||
...(invoice.payments ?? []),
|
||||
{ amount: dto.amount, method: dto.method ?? null, reference: dto.reference ?? null, paidAt: new Date().toISOString() },
|
||||
];
|
||||
|
||||
const updated = await this.invoiceRepository.update(id, {
|
||||
paidAmount: Math.round(paidAmount * 100) / 100,
|
||||
balanceAmount: balance,
|
||||
status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID',
|
||||
paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null,
|
||||
payments,
|
||||
});
|
||||
return updated as WarehouseFeeInvoice;
|
||||
}
|
||||
|
||||
// ── Release blocking ──────────────────────────────────────────────────────
|
||||
/** Returns the first unpaid invoice that blocks terminal release, or null. */
|
||||
async findBlockingInvoice(inventoryId: string): Promise<WarehouseFeeInvoice | null> {
|
||||
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
|
||||
return invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)) ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseLoading } from './entities/warehouse-loading.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseLoadingRepository extends BaseRepository<WarehouseLoading> {
|
||||
constructor(@InjectRepository(WarehouseLoading) repository: Repository<WarehouseLoading>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
|
||||
@ApiTags('warehouse-loadings')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouse-loadings')
|
||||
export class WarehouseLoadingsController {
|
||||
constructor(private readonly inventoryService: WarehouseInventoryService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List wagon loading records' })
|
||||
findAll(@Query('bookingId') bookingId?: string, @Query('wagonId') wagonId?: string) {
|
||||
return this.inventoryService.findLoadings({ bookingId, wagonId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
AllocationPreviewDto,
|
||||
CreateAllocationRuleDto,
|
||||
UpdateAllocationRuleDto,
|
||||
} from './dto/allocation-rule.dto';
|
||||
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
||||
import { WarehouseAllocationService } from './warehouse-allocation.service';
|
||||
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||
|
||||
@ApiTags('warehouse-rules')
|
||||
@ApiBearerAuth()
|
||||
@Controller()
|
||||
export class WarehouseRulesController {
|
||||
constructor(
|
||||
private readonly allocationService: WarehouseAllocationService,
|
||||
private readonly feeService: WarehouseFeeService,
|
||||
) {}
|
||||
|
||||
// ── Allocation rules ───────────────────────────────────────────────────────
|
||||
@Get('warehouse-allocation-rules')
|
||||
@ApiOperation({ summary: 'List warehouse allocation rules' })
|
||||
listAllocationRules() {
|
||||
return this.allocationService.listRules();
|
||||
}
|
||||
|
||||
@Post('warehouse-allocation-rules')
|
||||
@ApiOperation({ summary: 'Create a warehouse allocation rule' })
|
||||
createAllocationRule(@Body() dto: CreateAllocationRuleDto) {
|
||||
return this.allocationService.createRule(dto);
|
||||
}
|
||||
|
||||
@Patch('warehouse-allocation-rules/:id')
|
||||
@ApiOperation({ summary: 'Update a warehouse allocation rule' })
|
||||
updateAllocationRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateAllocationRuleDto) {
|
||||
return this.allocationService.updateRule(id, dto);
|
||||
}
|
||||
|
||||
@Delete('warehouse-allocation-rules/:id')
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Delete a warehouse allocation rule' })
|
||||
deleteAllocationRule(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.allocationService.deleteRule(id);
|
||||
}
|
||||
|
||||
@Post('warehouse-allocation/preview')
|
||||
@ApiOperation({ summary: 'Preview the yard/warehouse/zone a booking would be allocated to' })
|
||||
previewAllocation(@Body() dto: AllocationPreviewDto) {
|
||||
return this.allocationService.resolveLocation(dto);
|
||||
}
|
||||
|
||||
// ── Fee rules ────────────────────────────────────────────────────────────────
|
||||
@Get('warehouse-fee-rules')
|
||||
@ApiOperation({ summary: 'List storage / demurrage fee rules' })
|
||||
listFeeRules() {
|
||||
return this.feeService.listRules();
|
||||
}
|
||||
|
||||
@Post('warehouse-fee-rules')
|
||||
@ApiOperation({ summary: 'Create a storage / demurrage fee rule' })
|
||||
createFeeRule(@Body() dto: CreateFeeRuleDto) {
|
||||
return this.feeService.createRule(dto);
|
||||
}
|
||||
|
||||
@Patch('warehouse-fee-rules/:id')
|
||||
@ApiOperation({ summary: 'Update a fee rule' })
|
||||
updateFeeRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFeeRuleDto) {
|
||||
return this.feeService.updateRule(id, dto);
|
||||
}
|
||||
|
||||
@Delete('warehouse-fee-rules/:id')
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Delete a fee rule' })
|
||||
deleteFeeRule(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.feeService.deleteRule(id);
|
||||
}
|
||||
|
||||
@Get('warehouse-inventory/:id/fee-preview')
|
||||
@ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' })
|
||||
feePreview(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.feeService.previewForInventory(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||
|
||||
/**
|
||||
* READ-ONLY bridge that exposes warehouse inventory to the Train Scheduling
|
||||
* domain. It only reads warehouse data — it never assigns wagons, creates or
|
||||
* mutates schedules, and is intentionally NOT imported by the scheduling module.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WarehouseSchedulingAdapterService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
private get repo() {
|
||||
return this.dataSource.getRepository(WarehouseInventory);
|
||||
}
|
||||
|
||||
getReadyForLoadingInventory(): Promise<WarehouseInventory[]> {
|
||||
return this.repo.find({
|
||||
where: { status: 'READY_FOR_LOADING' },
|
||||
relations: { warehouse: true, yard: true, zone: true },
|
||||
order: { readyForLoadingAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
getReservedInventory(): Promise<WarehouseInventory[]> {
|
||||
return this.repo.find({
|
||||
where: { status: 'RESERVED' },
|
||||
relations: { warehouse: true, yard: true, zone: true },
|
||||
order: { reservedAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
getInventoryByBooking(bookingId: string): Promise<WarehouseInventory[]> {
|
||||
return this.repo.find({
|
||||
where: { bookingId },
|
||||
relations: { warehouse: true, yard: true, zone: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory whose origin booking runs on the given route. Best-effort, read-only:
|
||||
* matches the route's origin/destination yards against the booking's yards.
|
||||
*/
|
||||
async getInventoryByRoute(routeId: string): Promise<WarehouseInventory[]> {
|
||||
return this.repo
|
||||
.createQueryBuilder('inv')
|
||||
.leftJoinAndSelect('inv.warehouse', 'warehouse')
|
||||
.leftJoinAndSelect('inv.yard', 'yard')
|
||||
.leftJoinAndSelect('inv.zone', 'zone')
|
||||
.innerJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id')
|
||||
.innerJoin(
|
||||
'freight.routes',
|
||||
'route',
|
||||
'route.id = :routeId AND (route.origin_yard_id = booking.origin_yard_id OR route.destination_yard_id = booking.destination_yard_id)',
|
||||
{ routeId },
|
||||
)
|
||||
.orderBy('inv.created_at', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
||||
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
||||
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||
import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
|
||||
@ApiTags('warehouse-yards')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouse-yards')
|
||||
export class WarehouseYardsController {
|
||||
constructor(
|
||||
private readonly yardsService: WarehouseYardsService,
|
||||
private readonly zonesService: WarehouseZonesService,
|
||||
) {}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get warehouse yard by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.yardsService.findById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update warehouse yard' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseYardDto) {
|
||||
return this.yardsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Get(':yardId/zones')
|
||||
@ApiOperation({ summary: 'List zones within a yard' })
|
||||
listZones(@Param('yardId', ParseUUIDPipe) yardId: string) {
|
||||
return this.zonesService.findByYard(yardId);
|
||||
}
|
||||
|
||||
@Post(':yardId/zones')
|
||||
@ApiOperation({ summary: 'Create a zone within a yard' })
|
||||
createZone(
|
||||
@Param('yardId', ParseUUIDPipe) yardId: string,
|
||||
@Body() dto: CreateWarehouseZoneDto,
|
||||
) {
|
||||
return this.zonesService.create(yardId, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseYardsRepository extends BaseRepository<WarehouseYard> {
|
||||
constructor(@InjectRepository(WarehouseYard) repository: Repository<WarehouseYard>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
|
||||
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
||||
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||
import { WarehouseYardsRepository } from './warehouse-yards.repository';
|
||||
import { WarehousesService } from './warehouses.service';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseYardsService {
|
||||
constructor(
|
||||
private readonly yardsRepository: WarehouseYardsRepository,
|
||||
private readonly warehousesService: WarehousesService,
|
||||
) {}
|
||||
|
||||
findByWarehouse(warehouseId: string): Promise<WarehouseYard[]> {
|
||||
return this.yardsRepository.findAll({
|
||||
where: { warehouseId },
|
||||
relations: { zones: true },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WarehouseYard> {
|
||||
const yard = await this.yardsRepository.findById(id, {
|
||||
relations: { warehouse: true, zones: true },
|
||||
});
|
||||
|
||||
if (!yard) {
|
||||
throw new NotFoundException(`Warehouse yard ${id} not found`);
|
||||
}
|
||||
|
||||
return yard;
|
||||
}
|
||||
|
||||
async create(warehouseId: string, dto: CreateWarehouseYardDto): Promise<WarehouseYard> {
|
||||
// Ensure the parent warehouse exists.
|
||||
await this.warehousesService.findById(warehouseId);
|
||||
await this.assertCodeUnique(warehouseId, dto.code.trim());
|
||||
|
||||
return this.yardsRepository.create({
|
||||
warehouseId,
|
||||
name: dto.name.trim(),
|
||||
code: dto.code.trim(),
|
||||
type: dto.type,
|
||||
capacityWeight: dto.capacityWeight ?? null,
|
||||
capacityContainers: dto.capacityContainers ?? null,
|
||||
maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null,
|
||||
maxVolume: dto.maxVolume ?? null,
|
||||
currentWeight: 0,
|
||||
currentContainers: 0,
|
||||
currentVolume: 0,
|
||||
status: 'ACTIVE',
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateWarehouseYardDto): Promise<WarehouseYard> {
|
||||
const existing = await this.findById(id);
|
||||
|
||||
if (dto.code && dto.code.trim() !== existing.code) {
|
||||
await this.assertCodeUnique(existing.warehouseId, dto.code.trim(), id);
|
||||
}
|
||||
|
||||
const status = dto.status ?? existing.status;
|
||||
|
||||
const updated = await this.yardsRepository.update(id, {
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
code: dto.code?.trim() ?? existing.code,
|
||||
type: dto.type ?? existing.type,
|
||||
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
||||
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
||||
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||
status,
|
||||
isActive: status === 'ACTIVE',
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Warehouse yard ${id} not found`);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
private async assertCodeUnique(warehouseId: string, code: string, ignoreId?: string): Promise<void> {
|
||||
const [existing] = await this.yardsRepository.findAll({ where: { warehouseId, code } });
|
||||
|
||||
if (existing && existing.id !== ignoreId) {
|
||||
throw new ConflictException(`Yard code ${code} already exists in this warehouse`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
|
||||
import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
|
||||
@ApiTags('warehouse-zones')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouse-zones')
|
||||
export class WarehouseZonesController {
|
||||
constructor(private readonly zonesService: WarehouseZonesService) {}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get warehouse zone by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.zonesService.findById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update warehouse zone' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) {
|
||||
return this.zonesService.update(id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseZonesRepository extends BaseRepository<WarehouseZone> {
|
||||
constructor(@InjectRepository(WarehouseZone) repository: Repository<WarehouseZone>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
||||
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
|
||||
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
||||
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||
import { WarehouseZonesRepository } from './warehouse-zones.repository';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseZonesService {
|
||||
constructor(
|
||||
private readonly zonesRepository: WarehouseZonesRepository,
|
||||
private readonly yardsService: WarehouseYardsService,
|
||||
) {}
|
||||
|
||||
findByYard(yardId: string): Promise<WarehouseZone[]> {
|
||||
return this.zonesRepository.findAll({
|
||||
where: { yardId },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WarehouseZone> {
|
||||
const zone = await this.zonesRepository.findById(id, {
|
||||
relations: { yard: { warehouse: true } },
|
||||
});
|
||||
|
||||
if (!zone) {
|
||||
throw new NotFoundException(`Warehouse zone ${id} not found`);
|
||||
}
|
||||
|
||||
return zone;
|
||||
}
|
||||
|
||||
async create(yardId: string, dto: CreateWarehouseZoneDto): Promise<WarehouseZone> {
|
||||
// Ensure the parent yard exists.
|
||||
await this.yardsService.findById(yardId);
|
||||
await this.assertCodeUnique(yardId, dto.code.trim());
|
||||
|
||||
return this.zonesRepository.create({
|
||||
yardId,
|
||||
name: dto.name.trim(),
|
||||
code: dto.code.trim(),
|
||||
type: dto.type,
|
||||
capacityWeight: dto.capacityWeight ?? null,
|
||||
capacityContainers: dto.capacityContainers ?? null,
|
||||
maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null,
|
||||
maxVolume: dto.maxVolume ?? null,
|
||||
currentWeight: 0,
|
||||
currentContainers: 0,
|
||||
currentVolume: 0,
|
||||
status: 'ACTIVE',
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateWarehouseZoneDto): Promise<WarehouseZone> {
|
||||
const existing = await this.findById(id);
|
||||
|
||||
if (dto.code && dto.code.trim() !== existing.code) {
|
||||
await this.assertCodeUnique(existing.yardId, dto.code.trim(), id);
|
||||
}
|
||||
|
||||
const status = dto.status ?? existing.status;
|
||||
|
||||
const updated = await this.zonesRepository.update(id, {
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
code: dto.code?.trim() ?? existing.code,
|
||||
type: dto.type ?? existing.type,
|
||||
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
||||
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
||||
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||
status,
|
||||
isActive: status === 'ACTIVE',
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Warehouse zone ${id} not found`);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
private async assertCodeUnique(yardId: string, code: string, ignoreId?: string): Promise<void> {
|
||||
const [existing] = await this.zonesRepository.findAll({ where: { yardId, code } });
|
||||
|
||||
if (existing && existing.id !== ignoreId) {
|
||||
throw new ConflictException(`Zone code ${code} already exists in this yard`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { CreateWarehouseDto } from './dto/create-warehouse.dto';
|
||||
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
|
||||
import { FilterWarehouseDto } from './dto/filter-warehouse.dto';
|
||||
import { UpdateWarehouseDto } from './dto/update-warehouse.dto';
|
||||
import { WarehouseDashboardService } from './warehouse-dashboard.service';
|
||||
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||
import { WarehousesService } from './warehouses.service';
|
||||
|
||||
@ApiTags('warehouses')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouses')
|
||||
export class WarehousesController {
|
||||
constructor(
|
||||
private readonly warehousesService: WarehousesService,
|
||||
private readonly yardsService: WarehouseYardsService,
|
||||
private readonly dashboardService: WarehouseDashboardService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List warehouses' })
|
||||
findAll(@Query() filter: FilterWarehouseDto) {
|
||||
return this.warehousesService.findAll(filter);
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
@ApiOperation({ summary: 'Warehouse dashboard metrics' })
|
||||
dashboard() {
|
||||
return this.dashboardService.getDashboard();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create warehouse' })
|
||||
create(@Body() dto: CreateWarehouseDto) {
|
||||
return this.warehousesService.create(dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get warehouse by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.warehousesService.findById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update warehouse' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseDto) {
|
||||
return this.warehousesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Get(':warehouseId/yards')
|
||||
@ApiOperation({ summary: 'List yards within a warehouse' })
|
||||
listYards(@Param('warehouseId', ParseUUIDPipe) warehouseId: string) {
|
||||
return this.yardsService.findByWarehouse(warehouseId);
|
||||
}
|
||||
|
||||
@Post(':warehouseId/yards')
|
||||
@ApiOperation({ summary: 'Create a yard within a warehouse' })
|
||||
createYard(
|
||||
@Param('warehouseId', ParseUUIDPipe) warehouseId: string,
|
||||
@Body() dto: CreateWarehouseYardDto,
|
||||
) {
|
||||
return this.yardsService.create(warehouseId, dto);
|
||||
}
|
||||
}
|
||||
114
apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts
Normal file
114
apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
|
||||
import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity';
|
||||
import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
|
||||
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
|
||||
import { WarehouseLoading } from './entities/warehouse-loading.entity';
|
||||
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
||||
import { Warehouse } from './entities/warehouse.entity';
|
||||
import { SchedulingReadFacade } from './scheduling-read.facade';
|
||||
import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository';
|
||||
import { WarehouseActivityLogService } from './warehouse-activity-log.service';
|
||||
import { WarehouseDashboardService } from './warehouse-dashboard.service';
|
||||
import { WarehouseInspectionController } from './warehouse-inspection.controller';
|
||||
import { WarehouseInspectionRepository } from './warehouse-inspection.repository';
|
||||
import { WarehouseInspectionService } from './warehouse-inspection.service';
|
||||
import { WarehouseInventoryController } from './warehouse-inventory.controller';
|
||||
import { WarehouseInventoryMovementRepository } from './warehouse-inventory-movement.repository';
|
||||
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
||||
import { WarehouseLoadingsController } from './warehouse-loadings.controller';
|
||||
import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.repository';
|
||||
import { WarehouseAllocationService } from './warehouse-allocation.service';
|
||||
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
|
||||
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||
import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository';
|
||||
import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository';
|
||||
import { WarehouseInvoiceController } from './warehouse-invoice.controller';
|
||||
import { WarehouseInvoiceService } from './warehouse-invoice.service';
|
||||
import { WarehouseRulesController } from './warehouse-rules.controller';
|
||||
import { WarehouseSchedulingAdapterService } from './warehouse-scheduling-adapter.service';
|
||||
import { WarehouseYardsController } from './warehouse-yards.controller';
|
||||
import { WarehouseYardsRepository } from './warehouse-yards.repository';
|
||||
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||
import { WarehouseZonesController } from './warehouse-zones.controller';
|
||||
import { WarehouseZonesRepository } from './warehouse-zones.repository';
|
||||
import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
import { WarehousesController } from './warehouses.controller';
|
||||
import { WarehousesRepository } from './warehouses.repository';
|
||||
import { WarehousesService } from './warehouses.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Warehouse,
|
||||
WarehouseYard,
|
||||
WarehouseZone,
|
||||
WarehouseInventory,
|
||||
WarehouseInventoryMovement,
|
||||
WarehouseActivityLog,
|
||||
WarehouseLoading,
|
||||
WarehouseInspectionReport,
|
||||
WarehouseAllocationRule,
|
||||
WarehouseFeeRule,
|
||||
WarehouseFeeInvoice,
|
||||
WarehouseFeeInvoiceItem,
|
||||
]),
|
||||
FilesModule,
|
||||
],
|
||||
controllers: [
|
||||
WarehousesController,
|
||||
WarehouseYardsController,
|
||||
WarehouseZonesController,
|
||||
WarehouseInventoryController,
|
||||
WarehouseLoadingsController,
|
||||
WarehouseInspectionController,
|
||||
WarehouseRulesController,
|
||||
WarehouseInvoiceController,
|
||||
],
|
||||
providers: [
|
||||
WarehousesRepository,
|
||||
WarehouseYardsRepository,
|
||||
WarehouseZonesRepository,
|
||||
WarehouseInventoryRepository,
|
||||
WarehouseInventoryMovementRepository,
|
||||
WarehouseActivityLogRepository,
|
||||
WarehouseLoadingRepository,
|
||||
WarehouseInspectionRepository,
|
||||
WarehouseAllocationRuleRepository,
|
||||
WarehouseFeeRuleRepository,
|
||||
WarehouseFeeInvoiceRepository,
|
||||
WarehouseFeeInvoiceItemRepository,
|
||||
WarehousesService,
|
||||
WarehouseYardsService,
|
||||
WarehouseZonesService,
|
||||
WarehouseInventoryService,
|
||||
WarehouseActivityLogService,
|
||||
WarehouseDashboardService,
|
||||
WarehouseInspectionService,
|
||||
WarehouseAllocationService,
|
||||
WarehouseFeeService,
|
||||
WarehouseInvoiceService,
|
||||
WarehouseSchedulingAdapterService,
|
||||
SchedulingReadFacade,
|
||||
],
|
||||
exports: [
|
||||
WarehousesService,
|
||||
WarehouseYardsService,
|
||||
WarehouseZonesService,
|
||||
WarehouseInventoryService,
|
||||
WarehouseAllocationService,
|
||||
WarehouseFeeService,
|
||||
WarehouseSchedulingAdapterService,
|
||||
],
|
||||
})
|
||||
export class WarehousesModule {}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { Warehouse } from './entities/warehouse.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehousesRepository extends BaseRepository<Warehouse> {
|
||||
constructor(@InjectRepository(Warehouse) repository: Repository<Warehouse>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { FindManyOptions, ILike } from 'typeorm';
|
||||
|
||||
import { CreateWarehouseDto } from './dto/create-warehouse.dto';
|
||||
import { FilterWarehouseDto } from './dto/filter-warehouse.dto';
|
||||
import { UpdateWarehouseDto } from './dto/update-warehouse.dto';
|
||||
import { Warehouse } from './entities/warehouse.entity';
|
||||
import { WarehousesRepository } from './warehouses.repository';
|
||||
|
||||
@Injectable()
|
||||
export class WarehousesService {
|
||||
constructor(private readonly warehousesRepository: WarehousesRepository) {}
|
||||
|
||||
async findAll(filter: FilterWarehouseDto): Promise<Warehouse[]> {
|
||||
const where: FindManyOptions<Warehouse>['where'] = {
|
||||
...(filter.type ? { type: filter.type } : {}),
|
||||
...(filter.stationId ? { stationId: filter.stationId } : {}),
|
||||
...(filter.status ? { status: filter.status } : {}),
|
||||
};
|
||||
|
||||
const search = filter.search?.trim();
|
||||
const whereClauses = search
|
||||
? [
|
||||
{ ...where, name: ILike(`%${search}%`) },
|
||||
{ ...where, code: ILike(`%${search}%`) },
|
||||
{ ...where, locationName: ILike(`%${search}%`) },
|
||||
]
|
||||
: where;
|
||||
|
||||
return this.warehousesRepository.findAll({
|
||||
where: whereClauses,
|
||||
relations: { facility: true },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Warehouse> {
|
||||
const warehouse = await this.warehousesRepository.findById(id, {
|
||||
relations: { facility: true, yards: { zones: true } },
|
||||
});
|
||||
|
||||
if (!warehouse) {
|
||||
throw new NotFoundException(`Warehouse ${id} not found`);
|
||||
}
|
||||
|
||||
return warehouse;
|
||||
}
|
||||
|
||||
async create(dto: CreateWarehouseDto): Promise<Warehouse> {
|
||||
await this.assertCodeUnique(dto.code.trim());
|
||||
|
||||
return this.warehousesRepository.create({
|
||||
name: dto.name.trim(),
|
||||
code: dto.code.trim(),
|
||||
type: dto.type,
|
||||
stationId: dto.stationId ?? null,
|
||||
facilityId: dto.facilityId ?? null,
|
||||
locationName: dto.locationName?.trim() ?? null,
|
||||
capacityWeight: dto.capacityWeight ?? null,
|
||||
capacityContainers: dto.capacityContainers ?? null,
|
||||
maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null,
|
||||
maxVolume: dto.maxVolume ?? null,
|
||||
currentWeight: 0,
|
||||
currentContainers: 0,
|
||||
currentVolume: 0,
|
||||
status: 'ACTIVE',
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateWarehouseDto): Promise<Warehouse> {
|
||||
const existing = await this.findById(id);
|
||||
|
||||
if (dto.code && dto.code.trim() !== existing.code) {
|
||||
await this.assertCodeUnique(dto.code.trim(), id);
|
||||
}
|
||||
|
||||
const status = dto.status ?? existing.status;
|
||||
|
||||
const updated = await this.warehousesRepository.update(id, {
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
code: dto.code?.trim() ?? existing.code,
|
||||
type: dto.type ?? existing.type,
|
||||
stationId: dto.stationId ?? existing.stationId,
|
||||
facilityId: dto.facilityId ?? existing.facilityId,
|
||||
locationName: dto.locationName?.trim() ?? existing.locationName,
|
||||
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
||||
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
||||
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||
status,
|
||||
isActive: status === 'ACTIVE',
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Warehouse ${id} not found`);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
private async assertCodeUnique(code: string, ignoreId?: string): Promise<void> {
|
||||
const [existing] = await this.warehousesRepository.findAll({ where: { code } });
|
||||
|
||||
if (existing && existing.id !== ignoreId) {
|
||||
throw new ConflictException(`Warehouse code ${code} already exists`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user