Files
edr-platform/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts
Hagernesh 01aec12ee9 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>
2026-06-17 23:53:04 +00:00

121 lines
5.1 KiB
TypeScript

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