Warehouse Enhancemendt

This commit is contained in:
hagiye
2026-06-20 11:49:22 +03:00
1393 changed files with 334619 additions and 20282 deletions

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -34,4 +34,16 @@ export class CreateWarehouseYardDto {
@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;
}

View File

@@ -34,4 +34,16 @@ export class CreateWarehouseZoneDto {
@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;
}

View File

@@ -23,6 +23,11 @@ export class CreateWarehouseDto {
@IsUUID()
stationId?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Parent facility / port this warehouse belongs to.' })
@IsOptional()
@IsUUID()
facilityId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@@ -40,4 +45,16 @@ export class CreateWarehouseDto {
@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;
}

View File

@@ -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) {}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -1,7 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsUUID } from 'class-validator';
export class MoveWarehouseInventoryDto {
export class MoveInventoryDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
warehouseId!: string;
@@ -18,4 +18,9 @@ export class MoveWarehouseInventoryDto {
@IsOptional()
@IsString()
remarks?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
movedBy?: string;
}

View File

@@ -14,9 +14,10 @@ export class ReceiveWarehouseInventoryDto {
@IsUUID()
zoneId!: string;
@ApiProperty({ format: 'uuid' })
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
bookingId!: string;
bookingId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@@ -53,4 +54,9 @@ export class ReceiveWarehouseInventoryDto {
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/swagger';
import { CreateInspectionReportDto } from './create-inspection-report.dto';
export class UpdateInspectionReportDto extends PartialType(CreateInspectionReportDto) {}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -1,22 +1,35 @@
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',
'ARRIVED_AT_WAREHOUSE',
'UNDER_INSPECTION',
'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'])
@@ -48,15 +61,27 @@ export class WarehouseInventory extends BaseEntity {
@JoinColumn({ name: 'zone_id' })
zone?: WarehouseZone;
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@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;
@@ -69,18 +94,50 @@ export class WarehouseInventory extends BaseEntity {
@Column({ name: 'volume', type: 'numeric', precision: 12, scale: 3, nullable: true })
volume?: number | null;
@Column({ name: 'status', type: 'varchar', length: 32, default: 'ARRIVED_AT_WAREHOUSE' })
@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;
}

View File

@@ -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;
}

View File

@@ -49,6 +49,15 @@ export class WarehouseYard extends BaseEntity {
@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;

View File

@@ -48,6 +48,15 @@ export class WarehouseZone extends BaseEntity {
@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;

View File

@@ -1,7 +1,7 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Column, Entity, Index, ManyToOne, OneToMany } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Facility } from '../../facilities/entities/facility.entity';
import { WarehouseYard } from './warehouse-yard.entity';
export const WAREHOUSE_TYPES = ['OPEN_WAREHOUSE', 'CLOSED_WAREHOUSE'] as const;
@@ -47,12 +47,28 @@ export class Warehouse extends BaseEntity {
@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[];
}

View File

@@ -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,
};
}
}

View File

@@ -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);
}
}

View File

@@ -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' },
});
}
}

View File

@@ -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);
}
}

View File

@@ -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(' → '),
};
}
}

View File

@@ -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,
};
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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),
);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -3,15 +3,22 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
import { MoveWarehouseInventoryDto } from './dto/move-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) {}
constructor(
private readonly inventoryService: WarehouseInventoryService,
private readonly scheduling: SchedulingReadFacade,
) {}
@Get()
@ApiOperation({ summary: 'List warehouse inventory' })
@@ -31,46 +38,109 @@ export class WarehouseInventoryController {
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 location' })
move(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveWarehouseInventoryDto) {
@ApiOperation({ summary: 'Move inventory to another warehouse/yard/zone' })
move(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveInventoryDto) {
return this.inventoryService.move(id, dto);
}
@Get('dashboard/summary')
@ApiOperation({ summary: 'Warehouse dashboard summary' })
dashboardSummary(@Query() filter: FilterWarehouseInventoryDto) {
return this.inventoryService.dashboardSummary(filter);
}
@Post(':id/store')
@ApiOperation({ summary: 'Store received inventory' })
store(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.store(id);
@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/reserve')
@ApiOperation({ summary: 'Reserve stored inventory against a paid booking' })
reserve(@Param('id', ParseUUIDPipe) id: string, @Body() dto: { bookingId?: string }) {
return this.inventoryService.reserve(id, dto);
@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);
}
@Patch(':id/inspect')
@ApiOperation({ summary: 'Move inventory to UNDER_INSPECTION' })
inspect(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.inspect(id);
@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/ready-for-loading')
@ApiOperation({ summary: 'Move inventory to READY_FOR_LOADING' })
readyForLoading(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.readyForLoading(id);
@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);
}
@Post(':id/load')

View File

@@ -3,17 +3,33 @@ import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrE
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
import { MoveWarehouseInventoryDto } from './dto/move-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
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;
bookingId: string | null;
bookingNumber: string | null;
customerName: string | null;
containerNumber: string | null;
@@ -30,24 +46,46 @@ export interface InventoryInquiryResult {
readyForLoadingAt: Date | null;
}
export interface WarehouseDashboardSummary {
totalWarehouses: number;
totalInventory: number;
receivedToday: number;
stored: number;
reserved: number;
readyForLoading: number;
loaded: number;
dispatched: number;
}
@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 ────────────────────────────────────────────────────────────
async findAll(filter: FilterWarehouseInventoryDto): Promise<WarehouseInventory[]> {
@@ -80,7 +118,7 @@ export class WarehouseInventoryService {
const items = await this.inventoryRepository.findAll({
where,
relations: { warehouse: { facility: true }, yard: true, zone: true },
relations: { warehouse: true, yard: true, zone: true },
order: { createdAt: 'DESC' },
});
await this.attachBookingSummaries(items);
@@ -103,20 +141,231 @@ export class WarehouseInventoryService {
return item;
}
// ── Receive (with location + capacity validation) ──────────────────────
// ── 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);
await this.assertBookingExists(manager, dto.bookingId);
if (dto.bookingId) {
await this.assertBookingExists(manager, dto.bookingId);
}
this.assertCapacity('Warehouse', warehouse, weight, containerCount);
this.assertCapacity('Yard', yard, weight, containerCount);
this.assertCapacity('Zone', zone, weight, containerCount);
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(
@@ -124,7 +373,7 @@ export class WarehouseInventoryService {
warehouseId: dto.warehouseId,
yardId: dto.yardId,
zoneId: dto.zoneId,
bookingId: dto.bookingId,
bookingId: dto.bookingId ?? null,
cargoId: dto.cargoId ?? null,
containerId: dto.containerId ?? null,
goodsId: dto.goodsId ?? null,
@@ -137,7 +386,18 @@ export class WarehouseInventoryService {
}),
);
await this.applyCapacityDelta(manager, dto, weight, containerCount);
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;
});
@@ -205,20 +465,133 @@ export class WarehouseInventoryService {
return this.findById(movedId);
}
// ── Status transitions ────────────────────────────────────────────────
// ── Lifecycle transitions ────────────────────────────────────────────────
async inspect(id: string): Promise<WarehouseInventory> {
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);
if (item.status !== 'ARRIVED_AT_WAREHOUSE') {
// 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(
`Only items in ARRIVED_AT_WAREHOUSE can be inspected (current: ${item.status})`,
`Wagon ${wagon.wagonNumber} is not available for loading (status: ${wagon.status})`,
);
}
await this.inventoryRepository.update(id, {
status: 'UNDER_INSPECTION',
inspectedAt: new Date(),
// 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);
@@ -252,12 +625,50 @@ export class WarehouseInventoryService {
return this.findById(id);
}
async readyForLoading(id: string): Promise<WarehouseInventory> {
// ── 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);
if (item.status !== 'RESERVED' && item.status !== 'UNDER_INSPECTION') {
if (item.status !== 'UNDER_INSPECTION') {
throw new BadRequestException(
`Only RESERVED inventory can be marked READY_FOR_LOADING (current: ${item.status})`,
`Only items in UNDER_INSPECTION can be marked READY_FOR_LOADING (current: ${item.status})`,
);
}
@@ -327,7 +738,18 @@ export class WarehouseInventoryService {
};
}
// ── Inquiry ────────────────────────────────────────────────────────────
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
@@ -358,21 +780,12 @@ export class WarehouseInventoryService {
qb.andWhere('cargo_type.cargo_type_name ILIKE :ctype', { ctype: `%${filter.cargoType.trim()}%` });
}
if (filter.goodsName?.trim()) {
// No dedicated goods entity in Batch 1 — best-effort match against notes / cargo description.
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 });
}
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();
@@ -380,7 +793,7 @@ export class WarehouseInventoryService {
const row = raw[index] ?? {};
return {
id: inv.id,
bookingId: inv.bookingId,
bookingId: inv.bookingId ?? null,
bookingNumber: row.b_reference ?? null,
customerName: row.c_name ?? null,
containerNumber: row.ct_number ?? null,
@@ -403,40 +816,56 @@ export class WarehouseInventoryService {
// ── 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: Pick<ReceiveWarehouseInventoryDto, 'warehouseId' | 'yardId' | 'zoneId'>,
dto: ReceiveWarehouseInventoryDto,
): 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`);
}
if (warehouse.status !== 'ACTIVE') {
throw new BadRequestException('Warehouse is not ACTIVE');
}
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`);
}
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 (!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`);
}
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');
}
if (!zone) throw new NotFoundException(`Zone ${dto.zoneId} not found`);
return { warehouse, yard, zone };
}
@@ -450,84 +879,51 @@ export class WarehouseInventoryService {
}
}
private async assertPaidBooking(manager: EntityManager, bookingId: string): Promise<void> {
const rows = await manager.query(
`SELECT id FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL AND status = 'PAID' AND payment_status = 'PAID'
LIMIT 1`,
[bookingId],
);
if (!rows || rows.length === 0) {
throw new BadRequestException('Only PAID bookings can reserve stored inventory.');
}
}
private async attachBookingSummaries(items: WarehouseInventory[]): Promise<void> {
const bookingIds = Array.from(new Set(items.map((item) => item.bookingId).filter(Boolean)));
if (!bookingIds.length) return;
const rows = await this.dataSource.manager.query(
`SELECT id, reference, status, payment_status
FROM freight.bookings
WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`,
[bookingIds],
);
const byId = new Map<string, { id: string; reference: string; status: string; paymentStatus: string }>(
rows.map((row: { id: string; reference: string; status: string; payment_status: string }) => [
row.id,
{
id: row.id,
reference: row.reference,
status: row.status,
paymentStatus: row.payment_status,
},
]),
);
for (const item of items) {
Object.assign(item, { booking: byId.get(item.bookingId) ?? null });
}
}
private assertCapacity(
label: string,
node: { capacityWeight?: number | null; capacityContainers?: number | null; currentWeight: number; currentContainers: number },
node: LocationNode,
weightAdd: number,
volumeAdd: number,
containerAdd: number,
): void {
if (node.capacityWeight != null) {
const maxWeight = node.maxWeight ?? node.capacityWeight;
if (maxWeight != null) {
const projected = Number(node.currentWeight) + weightAdd;
if (projected > Number(node.capacityWeight)) {
throw new BadRequestException(
`${label} weight capacity exceeded (${projected} / ${node.capacityWeight})`,
);
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})`,
);
throw new BadRequestException(`${label} container capacity exceeded (${projected} / ${node.capacityContainers})`);
}
}
}
private async applyCapacityDelta(
manager: EntityManager,
dto: Pick<ReceiveWarehouseInventoryDto, 'warehouseId' | 'yardId' | 'zoneId'>,
dto: ReceiveWarehouseInventoryDto,
weightAdd: number,
containerAdd: number,
): Promise<void> {
await manager.increment(Warehouse, { id: dto.warehouseId }, 'currentWeight', weightAdd);
await manager.increment(WarehouseYard, { id: dto.yardId }, 'currentWeight', weightAdd);
await manager.increment(WarehouseZone, { id: dto.zoneId }, 'currentWeight', weightAdd);
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],
];
if (containerAdd > 0) {
await manager.increment(Warehouse, { id: dto.warehouseId }, 'currentContainers', containerAdd);
await manager.increment(WarehouseYard, { id: dto.yardId }, 'currentContainers', containerAdd);
await manager.increment(WarehouseZone, { id: dto.zoneId }, 'currentContainers', containerAdd);
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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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 });
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -45,8 +45,11 @@ export class WarehouseYardsService {
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,
});
@@ -67,6 +70,8 @@ export class WarehouseYardsService {
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',
});

View File

@@ -44,8 +44,11 @@ export class WarehouseZonesService {
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,
});
@@ -66,6 +69,8 @@ export class WarehouseZonesService {
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',
});

View File

@@ -5,6 +5,7 @@ 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';
@@ -15,6 +16,7 @@ export class WarehousesController {
constructor(
private readonly warehousesService: WarehousesService,
private readonly yardsService: WarehouseYardsService,
private readonly dashboardService: WarehouseDashboardService,
) {}
@Get()
@@ -23,6 +25,12 @@ export class WarehousesController {
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) {

View File

@@ -1,14 +1,42 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Yard } from '../rule-engine/entities/yard.entity';
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';
@@ -20,23 +48,67 @@ import { WarehousesRepository } from './warehouses.repository';
import { WarehousesService } from './warehouses.service';
@Module({
imports: [TypeOrmModule.forFeature([Warehouse, WarehouseYard, WarehouseZone, WarehouseInventory, Yard])],
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,
],
exports: [WarehousesService, WarehouseYardsService, WarehouseZonesService, WarehouseInventoryService],
})
export class WarehousesModule {}

View File

@@ -54,11 +54,15 @@ export class WarehousesService {
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,
});
@@ -78,9 +82,12 @@ export class WarehousesService {
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',
});