mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
- 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>
121 lines
5.1 KiB
TypeScript
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(' → '),
|
|
};
|
|
}
|
|
}
|