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:
Hagernesh
2026-06-17 23:53:04 +00:00
parent 270e39edd6
commit 01aec12ee9
23 changed files with 1429 additions and 12 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,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,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,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

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

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,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

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

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

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