mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 13:10:56 +00:00
feat(warehouse): Batch 5 — config-driven allocation + storage/demurrage fee rules
- Allocation rules engine: cargo/container/trade criteria -> deterministic yard/warehouse/zone by code; wired into auto-unload (fallback to default) - Storage/demurrage fee rules: configurable freeDays + ratePerDay; most-specific match; fee preview per inventory item - Inventory demurrage timestamps: inspectionStartedAt, inspectionCompletedAt, readyForPickupAt, releaseDate, gateClearedAt - Migration 1790000000000 (allocation_rules + fee_rules tables + inventory date columns) - Frontend: Allocation & Fees config page, automatic Fee Preview modal, plumbing/hooks - No invoice/payment (Batch 6) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateAllocationRuleDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 100 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
priority?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
freightType?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tradeDirection?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cargoTypeCode?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerStatus?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresInspection?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
targetFacilityCode?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
targetYardCode!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
targetWarehouseCode?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
targetZoneCode?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
storageType?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateAllocationRuleDto extends PartialType(CreateAllocationRuleDto) {}
|
||||
|
||||
export class AllocationPreviewDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
freightType?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tradeDirection?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cargoTypeCode?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerStatus?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresInspection?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||
|
||||
import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity';
|
||||
|
||||
export class CreateFeeRuleDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ enum: FEE_RULE_TYPES })
|
||||
@IsEnum(FEE_RULE_TYPES)
|
||||
ruleType!: FeeRuleType;
|
||||
|
||||
@ApiPropertyOptional({ default: 100 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
priority?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
freightType?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tradeDirection?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cargoTypeCode?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerType?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
facilityId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
warehouseId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
yardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
zoneId?: string;
|
||||
|
||||
@ApiProperty({ description: 'Grace period in days before charging starts.' })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
freeDays!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
ratePerDay!: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 'USD' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export class UpdateFeeRuleDto extends PartialType(CreateFeeRuleDto) {}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Batch 5 — deterministic warehouse/yard allocation.
|
||||
* A booking's (freightType, tradeDirection, cargoType, containerStatus, inspection)
|
||||
* is matched against active rules in ascending `priority`; the first match wins and
|
||||
* resolves the target Yard (and optional Warehouse/Zone) by code.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'warehouse_allocation_rules' })
|
||||
@Index(['priority'])
|
||||
@Index(['isActive'])
|
||||
export class WarehouseAllocationRule extends BaseEntity {
|
||||
@Column({ name: 'name', type: 'varchar', length: 160 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'priority', type: 'int', default: 100 })
|
||||
priority!: number;
|
||||
|
||||
// ── Match criteria (null = wildcard) ──────────────────────────────────────
|
||||
@Column({ name: 'freight_type', type: 'varchar', length: 16, nullable: true })
|
||||
freightType?: string | null; // CONTAINER | BULK
|
||||
|
||||
@Column({ name: 'trade_direction', type: 'varchar', length: 16, nullable: true })
|
||||
tradeDirection?: string | null; // IMPORT | EXPORT | DOMESTIC | BOTH
|
||||
|
||||
@Column({ name: 'cargo_type_code', type: 'varchar', length: 50, nullable: true })
|
||||
cargoTypeCode?: string | null;
|
||||
|
||||
@Column({ name: 'container_status', type: 'varchar', length: 24, nullable: true })
|
||||
containerStatus?: string | null; // e.g. EMPTY | MAINTENANCE
|
||||
|
||||
@Column({ name: 'requires_inspection', type: 'boolean', nullable: true })
|
||||
requiresInspection?: boolean | null;
|
||||
|
||||
// ── Resolved target (by code) ─────────────────────────────────────────────
|
||||
@Column({ name: 'target_facility_code', type: 'varchar', length: 40, nullable: true })
|
||||
targetFacilityCode?: string | null;
|
||||
|
||||
@Column({ name: 'target_yard_code', type: 'varchar', length: 40 })
|
||||
targetYardCode!: string;
|
||||
|
||||
@Column({ name: 'target_warehouse_code', type: 'varchar', length: 40, nullable: true })
|
||||
targetWarehouseCode?: string | null;
|
||||
|
||||
@Column({ name: 'target_zone_code', type: 'varchar', length: 40, nullable: true })
|
||||
targetZoneCode?: string | null;
|
||||
|
||||
@Column({ name: 'storage_type', type: 'varchar', length: 80, nullable: true })
|
||||
storageType?: string | null; // descriptive: "Container terminal import / stack area"
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -122,6 +122,22 @@ export class WarehouseInventory extends BaseEntity {
|
||||
@Column({ name: 'dispatched_at', type: 'timestamptz', nullable: true })
|
||||
dispatchedAt?: Date | null;
|
||||
|
||||
// Batch 5 — demurrage / storage lifecycle timestamps.
|
||||
@Column({ name: 'inspection_started_at', type: 'timestamptz', nullable: true })
|
||||
inspectionStartedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'inspection_completed_at', type: 'timestamptz', nullable: true })
|
||||
inspectionCompletedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'ready_for_pickup_at', type: 'timestamptz', nullable: true })
|
||||
readyForPickupAt?: Date | null;
|
||||
|
||||
@Column({ name: 'release_date', type: 'timestamptz', nullable: true })
|
||||
releaseDate?: Date | null;
|
||||
|
||||
@Column({ name: 'gate_cleared_at', type: 'timestamptz', nullable: true })
|
||||
gateClearedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseAllocationRuleRepository extends BaseRepository<WarehouseAllocationRule> {
|
||||
constructor(
|
||||
@InjectRepository(WarehouseAllocationRule) repository: Repository<WarehouseAllocationRule>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { CreateAllocationRuleDto, UpdateAllocationRuleDto } from './dto/allocation-rule.dto';
|
||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||
import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.repository';
|
||||
|
||||
export interface AllocationCriteria {
|
||||
freightType?: string | null; // CONTAINER | BULK
|
||||
tradeDirection?: string | null; // IMPORT | EXPORT | DOMESTIC | BOTH
|
||||
cargoTypeCode?: string | null;
|
||||
containerStatus?: string | null; // EMPTY | MAINTENANCE | ...
|
||||
requiresInspection?: boolean | null;
|
||||
}
|
||||
|
||||
export interface AllocationResult {
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
facilityId: string | null;
|
||||
rule: { id: string; name: string; storageType: string | null } | null;
|
||||
/** Human-readable path: Facility → Warehouse → Yard → Zone. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch 5 — deterministic warehouse/yard allocation driven by configurable rules.
|
||||
* Never assigns randomly: matches criteria against active rules by priority and
|
||||
* resolves the target Yard/Warehouse/Zone by code.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WarehouseAllocationService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly ruleRepository: WarehouseAllocationRuleRepository,
|
||||
) {}
|
||||
|
||||
// ── Rule CRUD ──────────────────────────────────────────────────────────────
|
||||
listRules(): Promise<WarehouseAllocationRule[]> {
|
||||
return this.ruleRepository.findAll({ order: { priority: 'ASC' } });
|
||||
}
|
||||
|
||||
createRule(dto: CreateAllocationRuleDto): Promise<WarehouseAllocationRule> {
|
||||
return this.ruleRepository.create({ isActive: true, priority: 100, ...dto });
|
||||
}
|
||||
|
||||
async updateRule(id: string, dto: UpdateAllocationRuleDto): Promise<WarehouseAllocationRule> {
|
||||
const updated = await this.ruleRepository.update(id, dto);
|
||||
if (!updated) throw new NotFoundException(`Allocation rule ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
deleteRule(id: string): Promise<void> {
|
||||
return this.ruleRepository.softDelete(id);
|
||||
}
|
||||
|
||||
private matches(rule: WarehouseAllocationRule, c: AllocationCriteria): boolean {
|
||||
const eq = (ruleVal?: string | null, inVal?: string | null) =>
|
||||
ruleVal == null || (inVal != null && ruleVal.toUpperCase() === inVal.toUpperCase());
|
||||
return (
|
||||
eq(rule.freightType, c.freightType) &&
|
||||
eq(rule.tradeDirection, c.tradeDirection) &&
|
||||
eq(rule.cargoTypeCode, c.cargoTypeCode) &&
|
||||
eq(rule.containerStatus, c.containerStatus) &&
|
||||
(rule.requiresInspection == null || rule.requiresInspection === Boolean(c.requiresInspection))
|
||||
);
|
||||
}
|
||||
|
||||
/** First active rule (by priority) whose criteria match. */
|
||||
async findMatchingRule(criteria: AllocationCriteria): Promise<WarehouseAllocationRule | null> {
|
||||
const rules = await this.ruleRepository.findAll({
|
||||
where: { isActive: true },
|
||||
order: { priority: 'ASC' },
|
||||
});
|
||||
return rules.find((r) => this.matches(r, criteria)) ?? null;
|
||||
}
|
||||
|
||||
/** Resolve a concrete warehouse/yard/zone for the given criteria, or null if none configured. */
|
||||
async resolveLocation(criteria: AllocationCriteria): Promise<AllocationResult | null> {
|
||||
const rule = await this.findMatchingRule(criteria);
|
||||
const yardCode = rule?.targetYardCode;
|
||||
|
||||
// Resolve yard (by rule code, else first available yard with a zone).
|
||||
const [yard] = await this.dataSource.query(
|
||||
yardCode
|
||||
? `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y
|
||||
WHERE y.code = $1 AND y.deleted_at IS NULL LIMIT 1`
|
||||
: `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y
|
||||
JOIN freight.warehouse_zones z ON z.yard_id = y.id AND z.deleted_at IS NULL
|
||||
WHERE y.deleted_at IS NULL ORDER BY y.created_at ASC LIMIT 1`,
|
||||
yardCode ? [yardCode] : [],
|
||||
);
|
||||
if (!yard) return null;
|
||||
|
||||
// Zone: rule code if given, else first zone in the yard.
|
||||
const [zone] = await this.dataSource.query(
|
||||
rule?.targetZoneCode
|
||||
? `SELECT z.id, z.name FROM freight.warehouse_zones z WHERE z.code = $1 AND z.deleted_at IS NULL LIMIT 1`
|
||||
: `SELECT z.id, z.name FROM freight.warehouse_zones z WHERE z.yard_id = $1 AND z.deleted_at IS NULL ORDER BY z.created_at ASC LIMIT 1`,
|
||||
rule?.targetZoneCode ? [rule.targetZoneCode] : [yard.id],
|
||||
);
|
||||
if (!zone) return null;
|
||||
|
||||
const [wh] = await this.dataSource.query(
|
||||
`SELECT w.id, w.name, w.facility_id AS "facilityId",
|
||||
(SELECT name FROM freight.facilities f WHERE f.id = w.facility_id) AS "facilityName"
|
||||
FROM freight.warehouses w WHERE w.id = $1 AND w.deleted_at IS NULL LIMIT 1`,
|
||||
[yard.warehouseId],
|
||||
);
|
||||
|
||||
return {
|
||||
warehouseId: yard.warehouseId,
|
||||
yardId: yard.id,
|
||||
zoneId: zone.id,
|
||||
facilityId: wh?.facilityId ?? null,
|
||||
rule: rule ? { id: rule.id, name: rule.name, storageType: rule.storageType ?? null } : null,
|
||||
path: [wh?.facilityName, wh?.name, yard.name, zone.name].filter(Boolean).join(' → '),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseFeeRuleRepository extends BaseRepository<WarehouseFeeRule> {
|
||||
constructor(@InjectRepository(WarehouseFeeRule) repository: Repository<WarehouseFeeRule>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
||||
import { FeeRuleType, WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
|
||||
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
|
||||
|
||||
interface ItemAttributes {
|
||||
arrivedAt: Date | null;
|
||||
gateClearedAt: Date | null;
|
||||
releaseDate: Date | null;
|
||||
freightType: string | null;
|
||||
tradeDirection: string | null;
|
||||
cargoTypeCode: string | null;
|
||||
containerTypeCode: string | null;
|
||||
facilityId: string | null;
|
||||
warehouseId: string | null;
|
||||
yardId: string | null;
|
||||
zoneId: string | null;
|
||||
}
|
||||
|
||||
export interface FeePreview {
|
||||
ruleType: FeeRuleType;
|
||||
ruleId: string | null;
|
||||
ruleName: string | null;
|
||||
freeDays: number;
|
||||
ratePerDay: number;
|
||||
currency: string;
|
||||
startDate: string | null;
|
||||
endDate: string;
|
||||
endIsOpen: boolean; // true when still accruing (no release/gate-clear yet)
|
||||
elapsedDays: number;
|
||||
chargeableDays: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseFeeService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly feeRuleRepository: WarehouseFeeRuleRepository,
|
||||
) {}
|
||||
|
||||
// ── Rule CRUD ──────────────────────────────────────────────────────────────
|
||||
listRules(): Promise<WarehouseFeeRule[]> {
|
||||
return this.feeRuleRepository.findAll({ order: { ruleType: 'ASC', priority: 'ASC' } });
|
||||
}
|
||||
|
||||
createRule(dto: CreateFeeRuleDto): Promise<WarehouseFeeRule> {
|
||||
return this.feeRuleRepository.create({ isActive: true, priority: 100, currency: 'USD', ...dto });
|
||||
}
|
||||
|
||||
async updateRule(id: string, dto: UpdateFeeRuleDto): Promise<WarehouseFeeRule> {
|
||||
const updated = await this.feeRuleRepository.update(id, dto);
|
||||
if (!updated) throw new NotFoundException(`Fee rule ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
deleteRule(id: string): Promise<void> {
|
||||
return this.feeRuleRepository.softDelete(id);
|
||||
}
|
||||
|
||||
private async loadItem(inventoryId: string): Promise<ItemAttributes> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.arrived_at AS "arrivedAt",
|
||||
inv.gate_cleared_at AS "gateClearedAt",
|
||||
inv.release_date AS "releaseDate",
|
||||
inv.warehouse_id AS "warehouseId",
|
||||
inv.yard_id AS "yardId",
|
||||
inv.zone_id AS "zoneId",
|
||||
w.facility_id AS "facilityId",
|
||||
b.freight_type AS "freightType",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
cgt.code AS "cargoTypeCode",
|
||||
ctt.code AS "containerTypeCode"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
|
||||
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
||||
LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
|
||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL`,
|
||||
[inventoryId],
|
||||
);
|
||||
if (!row) throw new NotFoundException(`Inventory item ${inventoryId} not found`);
|
||||
return row;
|
||||
}
|
||||
|
||||
private matchScore(rule: WarehouseFeeRule, item: ItemAttributes): number | null {
|
||||
// Returns specificity score (#matched non-null scope fields), or null if any constraint fails.
|
||||
let score = 0;
|
||||
const check = (ruleVal: string | null | undefined, itemVal: string | null) => {
|
||||
if (ruleVal == null) return true;
|
||||
if (itemVal != null && ruleVal.toUpperCase() === itemVal.toUpperCase()) {
|
||||
score += 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (!check(rule.freightType, item.freightType)) return null;
|
||||
if (!check(rule.tradeDirection, item.tradeDirection)) return null;
|
||||
if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null;
|
||||
if (!check(rule.containerType, item.containerTypeCode)) return null;
|
||||
if (!check(rule.facilityId, item.facilityId)) return null;
|
||||
if (!check(rule.warehouseId, item.warehouseId)) return null;
|
||||
if (!check(rule.yardId, item.yardId)) return null;
|
||||
if (!check(rule.zoneId, item.zoneId)) return null;
|
||||
return score;
|
||||
}
|
||||
|
||||
private bestRule(rules: WarehouseFeeRule[], item: ItemAttributes): WarehouseFeeRule | null {
|
||||
let best: WarehouseFeeRule | null = null;
|
||||
let bestScore = -1;
|
||||
for (const rule of rules) {
|
||||
const score = this.matchScore(rule, item);
|
||||
if (score == null) continue;
|
||||
if (score > bestScore || (score === bestScore && best && rule.priority < best.priority)) {
|
||||
best = rule;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private compute(ruleType: FeeRuleType, rule: WarehouseFeeRule | null, item: ItemAttributes, now: Date): FeePreview {
|
||||
const start = item.arrivedAt ? new Date(item.arrivedAt) : null;
|
||||
const endDate = item.gateClearedAt ?? item.releaseDate ?? now;
|
||||
const endIsOpen = !item.gateClearedAt && !item.releaseDate;
|
||||
const freeDays = rule?.freeDays ?? 0;
|
||||
const ratePerDay = Number(rule?.ratePerDay ?? 0);
|
||||
|
||||
const elapsedDays = start
|
||||
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
|
||||
: 0;
|
||||
const chargeableDays = Math.max(0, elapsedDays - freeDays);
|
||||
const amount = Math.round(chargeableDays * ratePerDay * 100) / 100;
|
||||
|
||||
return {
|
||||
ruleType,
|
||||
ruleId: rule?.id ?? null,
|
||||
ruleName: rule?.name ?? null,
|
||||
freeDays,
|
||||
ratePerDay,
|
||||
currency: rule?.currency ?? 'USD',
|
||||
startDate: start ? start.toISOString() : null,
|
||||
endDate: new Date(endDate).toISOString(),
|
||||
endIsOpen,
|
||||
elapsedDays,
|
||||
chargeableDays,
|
||||
amount,
|
||||
};
|
||||
}
|
||||
|
||||
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
|
||||
async previewForInventory(inventoryId: string): Promise<FeePreview[]> {
|
||||
const item = await this.loadItem(inventoryId);
|
||||
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
||||
const now = new Date();
|
||||
|
||||
const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE'];
|
||||
return byType.map((type) =>
|
||||
this.compute(type, this.bestRule(rules.filter((r) => r.ruleType === type), item), item, now),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||
import { WarehouseAllocationService } from './warehouse-allocation.service';
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
|
||||
import {
|
||||
@@ -119,6 +120,7 @@ export class WarehouseInventoryService {
|
||||
private readonly loadingRepository: WarehouseLoadingRepository,
|
||||
private readonly activityLog: WarehouseActivityLogService,
|
||||
private readonly scheduling: SchedulingReadFacade,
|
||||
private readonly allocation: WarehouseAllocationService,
|
||||
) {}
|
||||
|
||||
// ── Listing ────────────────────────────────────────────────────────────
|
||||
@@ -233,10 +235,19 @@ export class WarehouseInventoryService {
|
||||
|
||||
/** Bulk-create inventory (RECEIVED) for arrived bookings that are not yet unloaded. */
|
||||
async autoUnloadArrived(): Promise<AutoUnloadResult> {
|
||||
const arrived: { id: string; weight: string | null }[] = await this.dataSource.query(
|
||||
`SELECT b.id, b.cargo_total_weight_vgm AS weight
|
||||
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],
|
||||
);
|
||||
@@ -245,17 +256,22 @@ export class WarehouseInventoryService {
|
||||
|
||||
if (arrived.length === 0) return result;
|
||||
|
||||
const location = await this.pickDefaultLocation();
|
||||
if (!location) {
|
||||
return {
|
||||
...result,
|
||||
failedCount: arrived.length,
|
||||
results: arrived.map((b) => ({ bookingId: b.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' })),
|
||||
};
|
||||
}
|
||||
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,
|
||||
@@ -265,7 +281,7 @@ export class WarehouseInventoryService {
|
||||
weight: Number(booking.weight) || 0,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt: new Date(),
|
||||
notes: 'Auto-unloaded from arrival queue',
|
||||
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' });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||
import { 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';
|
||||
@@ -23,6 +25,11 @@ 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 { WarehouseRulesController } from './warehouse-rules.controller';
|
||||
import { WarehouseSchedulingAdapterService } from './warehouse-scheduling-adapter.service';
|
||||
import { WarehouseYardsController } from './warehouse-yards.controller';
|
||||
import { WarehouseYardsRepository } from './warehouse-yards.repository';
|
||||
@@ -45,6 +52,8 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseActivityLog,
|
||||
WarehouseLoading,
|
||||
WarehouseInspectionReport,
|
||||
WarehouseAllocationRule,
|
||||
WarehouseFeeRule,
|
||||
]),
|
||||
FilesModule,
|
||||
],
|
||||
@@ -55,6 +64,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseInventoryController,
|
||||
WarehouseLoadingsController,
|
||||
WarehouseInspectionController,
|
||||
WarehouseRulesController,
|
||||
],
|
||||
providers: [
|
||||
WarehousesRepository,
|
||||
@@ -65,6 +75,8 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseActivityLogRepository,
|
||||
WarehouseLoadingRepository,
|
||||
WarehouseInspectionRepository,
|
||||
WarehouseAllocationRuleRepository,
|
||||
WarehouseFeeRuleRepository,
|
||||
WarehousesService,
|
||||
WarehouseYardsService,
|
||||
WarehouseZonesService,
|
||||
@@ -72,6 +84,8 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseActivityLogService,
|
||||
WarehouseDashboardService,
|
||||
WarehouseInspectionService,
|
||||
WarehouseAllocationService,
|
||||
WarehouseFeeService,
|
||||
WarehouseSchedulingAdapterService,
|
||||
SchedulingReadFacade,
|
||||
],
|
||||
@@ -80,6 +94,8 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseYardsService,
|
||||
WarehouseZonesService,
|
||||
WarehouseInventoryService,
|
||||
WarehouseAllocationService,
|
||||
WarehouseFeeService,
|
||||
WarehouseSchedulingAdapterService,
|
||||
],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user