mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +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,93 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Batch 5 — warehouse allocation rules, storage/demurrage fee rules,
|
||||
* and demurrage lifecycle timestamps on inventory.
|
||||
*/
|
||||
export class AddWarehouseAllocationAndFeeRules1790000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'warehouse_allocation_rules',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
|
||||
{ name: 'name', type: 'varchar', length: '160' },
|
||||
{ name: 'priority', type: 'int', default: 100 },
|
||||
{ name: 'freight_type', type: 'varchar', length: '16', isNullable: true },
|
||||
{ name: 'trade_direction', type: 'varchar', length: '16', isNullable: true },
|
||||
{ name: 'cargo_type_code', type: 'varchar', length: '50', isNullable: true },
|
||||
{ name: 'container_status', type: 'varchar', length: '24', isNullable: true },
|
||||
{ name: 'requires_inspection', type: 'boolean', isNullable: true },
|
||||
{ name: 'target_facility_code', type: 'varchar', length: '40', isNullable: true },
|
||||
{ name: 'target_yard_code', type: 'varchar', length: '40' },
|
||||
{ name: 'target_warehouse_code', type: 'varchar', length: '40', isNullable: true },
|
||||
{ name: 'target_zone_code', type: 'varchar', length: '40', isNullable: true },
|
||||
{ name: 'storage_type', type: 'varchar', length: '80', isNullable: true },
|
||||
{ name: 'is_active', type: 'boolean', default: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
indices: [
|
||||
{ name: 'idx_war_priority', columnNames: ['priority'] },
|
||||
{ name: 'idx_war_active', columnNames: ['is_active'] },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'warehouse_fee_rules',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
|
||||
{ name: 'name', type: 'varchar', length: '160' },
|
||||
{ name: 'rule_type', type: 'varchar', length: '20' },
|
||||
{ name: 'priority', type: 'int', default: 100 },
|
||||
{ name: 'freight_type', type: 'varchar', length: '16', isNullable: true },
|
||||
{ name: 'trade_direction', type: 'varchar', length: '16', isNullable: true },
|
||||
{ name: 'cargo_type_code', type: 'varchar', length: '50', isNullable: true },
|
||||
{ name: 'container_type', type: 'varchar', length: '40', isNullable: true },
|
||||
{ name: 'facility_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'warehouse_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'yard_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'zone_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'free_days', type: 'int', default: 0 },
|
||||
{ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 },
|
||||
{ name: 'currency', type: 'varchar', length: '8', default: "'USD'" },
|
||||
{ name: 'is_active', type: 'boolean', default: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
indices: [
|
||||
{ name: 'idx_wfr_type', columnNames: ['rule_type'] },
|
||||
{ name: 'idx_wfr_active', columnNames: ['is_active'] },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.addColumns('freight.warehouse_inventory', [
|
||||
new TableColumn({ name: 'inspection_started_at', type: 'timestamptz', isNullable: true }),
|
||||
new TableColumn({ name: 'inspection_completed_at', type: 'timestamptz', isNullable: true }),
|
||||
new TableColumn({ name: 'ready_for_pickup_at', type: 'timestamptz', isNullable: true }),
|
||||
new TableColumn({ name: 'release_date', type: 'timestamptz', isNullable: true }),
|
||||
new TableColumn({ name: 'gate_cleared_at', type: 'timestamptz', isNullable: true }),
|
||||
]);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropColumns('freight.warehouse_inventory', [
|
||||
'inspection_started_at',
|
||||
'inspection_completed_at',
|
||||
'ready_for_pickup_at',
|
||||
'release_date',
|
||||
'gate_cleared_at',
|
||||
]);
|
||||
await queryRunner.dropTable('freight.warehouse_fee_rules', true);
|
||||
await queryRunner.dropTable('freight.warehouse_allocation_rules', true);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -65,6 +65,7 @@ import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
|
||||
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
|
||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
|
||||
|
||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
{
|
||||
@@ -198,6 +199,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
href: "/dashboard/inventory-inquiry",
|
||||
icon: <Boxes />,
|
||||
},
|
||||
{
|
||||
label: "Allocation & Fees",
|
||||
href: "/dashboard/warehouse-rules",
|
||||
icon: <SlidersHorizontal />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -368,6 +374,7 @@ const App = () => {
|
||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
||||
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
|
||||
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
|
||||
|
||||
<Route
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Badge, Card, Group, Loader, Modal, Stack, Text } from '@mantine/core';
|
||||
import { CalendarClock, Coins } from 'lucide-react';
|
||||
|
||||
import { useFeePreview } from '@/hooks/useWarehouses';
|
||||
import type { FeePreview } from '@/types/warehouse';
|
||||
|
||||
interface FeePreviewModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
inventoryId: string | null;
|
||||
}
|
||||
|
||||
const LABELS: Record<string, { label: string; color: string }> = {
|
||||
DEMURRAGE_FEE: { label: 'Demurrage', color: 'orange' },
|
||||
STORAGE_FEE: { label: 'Storage', color: 'teal' },
|
||||
};
|
||||
|
||||
function fmtDate(iso: string | null) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
function FeeCard({ fee }: { fee: FeePreview }) {
|
||||
const meta = LABELS[fee.ruleType] ?? { label: fee.ruleType, color: 'gray' };
|
||||
const configured = Boolean(fee.ruleId);
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md" style={{ borderColor: `var(--mantine-color-${meta.color}-3)` }}>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Group gap="xs">
|
||||
<Coins size={16} />
|
||||
<Text fw={700}>{meta.label}</Text>
|
||||
{fee.endIsOpen && (
|
||||
<Badge size="xs" color={meta.color} variant="light">
|
||||
accruing
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text fw={800} size="lg" c={`${meta.color}.7`}>
|
||||
{fee.amount.toLocaleString()} {fee.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{!configured ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
No active {meta.label.toLowerCase()} rule configured — amount shown as 0.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={4}>
|
||||
<Row label="Rule" value={fee.ruleName ?? '—'} />
|
||||
<Row label="Free days" value={String(fee.freeDays)} />
|
||||
<Row label="Rate / day" value={`${fee.ratePerDay.toLocaleString()} ${fee.currency}`} />
|
||||
<Row label="Period" value={`${fmtDate(fee.startDate)} → ${fmtDate(fee.endDate)}${fee.endIsOpen ? ' (today)' : ''}`} />
|
||||
<Row label="Elapsed days" value={String(fee.elapsedDays)} />
|
||||
<Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} />
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm">{value}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Batch 5 — automatic storage/demurrage fee preview for an inventory item (no invoice/payment). */
|
||||
export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModalProps) {
|
||||
const { data, isLoading } = useFeePreview(opened ? inventoryId ?? undefined : undefined);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<CalendarClock size={18} />
|
||||
<Text fw={700}>Storage & Demurrage Preview</Text>
|
||||
</Group>
|
||||
}
|
||||
centered
|
||||
size="md"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{(data ?? []).map((fee) => (
|
||||
<FeeCard key={fee.ruleType} fee={fee} />
|
||||
))}
|
||||
<Text size="xs" c="dimmed">
|
||||
Preview only — invoicing & payment are handled in Batch 6. Charges accrue from arrival until
|
||||
gate clearance / release (or today if still in terminal).
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useStoreInventory,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { FeePreviewModal } from './FeePreviewModal';
|
||||
import { InspectionReportModal } from './InspectionReportModal';
|
||||
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
import { LoadInventoryModal } from './LoadInventoryModal';
|
||||
@@ -30,6 +31,7 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
|
||||
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [inspectItem, setInspectItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
|
||||
const storeMutation = useStoreInventory();
|
||||
const readyMutation = useMarkReadyForLoading();
|
||||
@@ -83,6 +85,7 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
|
||||
onMove={setMoveItem}
|
||||
onHistory={setHistoryItem}
|
||||
onInspect={setInspectItem}
|
||||
onFeePreview={setFeeItem}
|
||||
/>
|
||||
|
||||
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
||||
@@ -102,6 +105,11 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
|
||||
onClose={() => setInspectItem(null)}
|
||||
inventoryId={inspectItem?.id ?? null}
|
||||
/>
|
||||
<FeePreviewModal
|
||||
opened={Boolean(feeItem)}
|
||||
onClose={() => setFeeItem(null)}
|
||||
inventoryId={feeItem?.id ?? null}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ActionIcon, Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
|
||||
import { ArrowRightLeft, ClipboardList, History } from 'lucide-react';
|
||||
import { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react';
|
||||
|
||||
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { INVENTORY_NEXT_ACTION } from '@/types/warehouse';
|
||||
@@ -13,6 +13,7 @@ interface WarehouseInventoryTableProps {
|
||||
onMove: (item: WarehouseInventoryItem) => void;
|
||||
onHistory: (item: WarehouseInventoryItem) => void;
|
||||
onInspect?: (item: WarehouseInventoryItem) => void;
|
||||
onFeePreview?: (item: WarehouseInventoryItem) => void;
|
||||
}
|
||||
|
||||
const itemKind = (item: WarehouseInventoryItem) => {
|
||||
@@ -37,6 +38,7 @@ export function WarehouseInventoryTable({
|
||||
onMove,
|
||||
onHistory,
|
||||
onInspect,
|
||||
onFeePreview,
|
||||
}: WarehouseInventoryTableProps) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
@@ -128,6 +130,13 @@ export function WarehouseInventoryTable({
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onFeePreview && (
|
||||
<Tooltip label="Storage / Demurrage preview" withArrow>
|
||||
<ActionIcon variant="subtle" color="teal" onClick={() => onFeePreview(item)}>
|
||||
<Coins size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="History" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
|
||||
<History size={16} />
|
||||
|
||||
@@ -26,3 +26,4 @@ export { WarehouseHero } from './WarehouseHero';
|
||||
export { VisualEmptyState } from './VisualEmptyState';
|
||||
export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
|
||||
export { InspectionReportModal } from './InspectionReportModal';
|
||||
export { FeePreviewModal } from './FeePreviewModal';
|
||||
|
||||
@@ -312,4 +312,13 @@ export const URL_CONSTANTS = {
|
||||
BY_ID: (id: string) => `/warehouse-inspection-reports/${id}`,
|
||||
ATTACHMENTS: (id: string) => `/warehouse-inspection-reports/${id}/attachments`,
|
||||
},
|
||||
|
||||
WAREHOUSE_RULES: {
|
||||
ALLOCATION: '/warehouse-allocation-rules',
|
||||
ALLOCATION_BY_ID: (id: string) => `/warehouse-allocation-rules/${id}`,
|
||||
ALLOCATION_PREVIEW: '/warehouse-allocation/preview',
|
||||
FEES: '/warehouse-fee-rules',
|
||||
FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`,
|
||||
FEE_PREVIEW: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-preview`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import type {
|
||||
InspectionReportPayload,
|
||||
SaveAllocationRulePayload,
|
||||
SaveFeeRulePayload,
|
||||
InventoryFilter,
|
||||
InventoryInquiryFilter,
|
||||
LoadInventoryPayload,
|
||||
@@ -286,3 +288,60 @@ export function useUploadInspectionAttachments() {
|
||||
warehouseService.uploadInspectionAttachments(reportId, files),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Batch 5: Allocation + Fee rules / preview ───────────────────────────────
|
||||
|
||||
export function useAllocationRules() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-allocation-rules'],
|
||||
queryFn: () => warehouseService.listAllocationRules().then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFeeRules() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-fee-rules'],
|
||||
queryFn: () => warehouseService.listFeeRules().then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
function useRuleMutation<TArgs>(fn: (args: TArgs) => Promise<unknown>, keys: string[]) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: fn,
|
||||
onSuccess: () => keys.forEach((k) => qc.invalidateQueries({ queryKey: [k] })),
|
||||
});
|
||||
}
|
||||
|
||||
export const useCreateAllocationRule = () =>
|
||||
useRuleMutation(
|
||||
(payload: SaveAllocationRulePayload) => warehouseService.createAllocationRule(payload),
|
||||
['warehouse-allocation-rules'],
|
||||
);
|
||||
export const useUpdateAllocationRule = () =>
|
||||
useRuleMutation(
|
||||
(args: { id: string; payload: Partial<SaveAllocationRulePayload> }) =>
|
||||
warehouseService.updateAllocationRule(args.id, args.payload),
|
||||
['warehouse-allocation-rules'],
|
||||
);
|
||||
export const useDeleteAllocationRule = () =>
|
||||
useRuleMutation((id: string) => warehouseService.deleteAllocationRule(id), ['warehouse-allocation-rules']);
|
||||
|
||||
export const useCreateFeeRule = () =>
|
||||
useRuleMutation((payload: SaveFeeRulePayload) => warehouseService.createFeeRule(payload), ['warehouse-fee-rules']);
|
||||
export const useUpdateFeeRule = () =>
|
||||
useRuleMutation(
|
||||
(args: { id: string; payload: Partial<SaveFeeRulePayload> }) =>
|
||||
warehouseService.updateFeeRule(args.id, args.payload),
|
||||
['warehouse-fee-rules'],
|
||||
);
|
||||
export const useDeleteFeeRule = () =>
|
||||
useRuleMutation((id: string) => warehouseService.deleteFeeRule(id), ['warehouse-fee-rules']);
|
||||
|
||||
export function useFeePreview(inventoryId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', inventoryId, 'fee-preview'],
|
||||
queryFn: () => warehouseService.feePreview(inventoryId as string).then((r) => r.data),
|
||||
enabled: Boolean(inventoryId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { WarehouseHero } from '@/components/warehouses';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
useAllocationRules,
|
||||
useCreateAllocationRule,
|
||||
useCreateFeeRule,
|
||||
useDeleteAllocationRule,
|
||||
useDeleteFeeRule,
|
||||
useFeeRules,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
|
||||
|
||||
const FREIGHT = [
|
||||
{ value: 'CONTAINER', label: 'Container' },
|
||||
{ value: 'BULK', label: 'Bulk' },
|
||||
];
|
||||
const TRADE = [
|
||||
{ value: 'IMPORT', label: 'Import' },
|
||||
{ value: 'EXPORT', label: 'Export' },
|
||||
{ value: 'DOMESTIC', label: 'Domestic' },
|
||||
];
|
||||
|
||||
const clean = (s: string) => s.trim() || undefined;
|
||||
|
||||
export default function WarehouseRulesPage() {
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Warehouse rules' }]} />
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="warehouse"
|
||||
secondaryVariant="container"
|
||||
title="Allocation & Fee Rules"
|
||||
subtitle="Configure deterministic yard allocation and storage / demurrage free time and rates."
|
||||
/>
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Tabs defaultValue="allocation">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="allocation">Allocation Rules</Tabs.Tab>
|
||||
<Tabs.Tab value="fees">Storage / Demurrage Fees</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="allocation" pt="md">
|
||||
<AllocationRules />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="fees" pt="md">
|
||||
<FeeRules />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function AllocationRules() {
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading } = useAllocationRules();
|
||||
const create = useCreateAllocationRule();
|
||||
const remove = useDeleteAllocationRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
priority: 100,
|
||||
freightType: '',
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
containerStatus: '',
|
||||
targetYardCode: '',
|
||||
storageType: '',
|
||||
});
|
||||
const rules = data ?? [];
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim() || !form.targetYardCode.trim()) {
|
||||
toast({ variant: 'destructive', title: 'Name and target yard code are required' });
|
||||
return;
|
||||
}
|
||||
await create.mutateAsync({
|
||||
name: form.name.trim(),
|
||||
priority: form.priority,
|
||||
freightType: clean(form.freightType) ?? null,
|
||||
tradeDirection: clean(form.tradeDirection) ?? null,
|
||||
cargoTypeCode: clean(form.cargoTypeCode) ?? null,
|
||||
containerStatus: clean(form.containerStatus) ?? null,
|
||||
targetYardCode: form.targetYardCode.trim(),
|
||||
storageType: clean(form.storageType) ?? null,
|
||||
isActive: true,
|
||||
} as never);
|
||||
toast({ title: 'Allocation rule created' });
|
||||
setOpen(false);
|
||||
setForm({ name: '', priority: 100, freightType: '', tradeDirection: '', cargoTypeCode: '', containerStatus: '', targetYardCode: '', storageType: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text c="dimmed" size="sm">{rules.length} rule(s) — matched by ascending priority</Text>
|
||||
<Button color="orange" leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New allocation rule</Button>
|
||||
</Group>
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl"><Loader /></Group>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Priority</Table.Th><Table.Th>Name</Table.Th><Table.Th>Freight</Table.Th>
|
||||
<Table.Th>Trade</Table.Th><Table.Th>Cargo code</Table.Th><Table.Th>Target yard</Table.Th>
|
||||
<Table.Th>Active</Table.Th><Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rules.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>{r.priority}</Table.Td>
|
||||
<Table.Td>{r.name}</Table.Td>
|
||||
<Table.Td>{r.freightType ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.tradeDirection ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.cargoTypeCode ?? '—'}</Table.Td>
|
||||
<Table.Td><Badge variant="light">{r.targetYardCode}</Badge></Table.Td>
|
||||
<Table.Td><Badge color={r.isActive ? 'green' : 'gray'} variant="light">{r.isActive ? 'Yes' : 'No'}</Badge></Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(r.id)} title="Delete">
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg">
|
||||
<Stack gap="sm">
|
||||
<Group grow>
|
||||
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} />
|
||||
<NumberInput label="Priority" value={form.priority} onChange={(v) => setForm((f) => ({ ...f, priority: Number(v) || 100 }))} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable />
|
||||
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} />
|
||||
<TextInput label="Container status" placeholder="e.g. MAINTENANCE" value={form.containerStatus} onChange={(e) => setForm((f) => ({ ...f, containerStatus: e.currentTarget.value }))} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Target yard code" required value={form.targetYardCode} onChange={(e) => setForm((f) => ({ ...f, targetYardCode: e.currentTarget.value }))} />
|
||||
<TextInput label="Storage type" value={form.storageType} onChange={(e) => setForm((f) => ({ ...f, storageType: e.currentTarget.value }))} />
|
||||
</Group>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button color="orange" loading={create.isPending} onClick={submit}>Create</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FeeRules() {
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading } = useFeeRules();
|
||||
const create = useCreateFeeRule();
|
||||
const remove = useDeleteFeeRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
|
||||
freightType: '',
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
freeDays: 3,
|
||||
ratePerDay: 0,
|
||||
currency: 'USD',
|
||||
});
|
||||
const rules = data ?? [];
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast({ variant: 'destructive', title: 'Name is required' });
|
||||
return;
|
||||
}
|
||||
await create.mutateAsync({
|
||||
name: form.name.trim(),
|
||||
ruleType: form.ruleType,
|
||||
freightType: clean(form.freightType) ?? null,
|
||||
tradeDirection: clean(form.tradeDirection) ?? null,
|
||||
cargoTypeCode: clean(form.cargoTypeCode) ?? null,
|
||||
freeDays: form.freeDays,
|
||||
ratePerDay: form.ratePerDay,
|
||||
currency: form.currency || 'USD',
|
||||
isActive: true,
|
||||
} as never);
|
||||
toast({ title: 'Fee rule created' });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text c="dimmed" size="sm">{rules.length} rule(s) — most specific match applies</Text>
|
||||
<Button color="teal" leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New fee rule</Button>
|
||||
</Group>
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl"><Loader /></Group>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Type</Table.Th><Table.Th>Name</Table.Th><Table.Th>Freight</Table.Th>
|
||||
<Table.Th>Trade</Table.Th><Table.Th>Free days</Table.Th><Table.Th>Rate / day</Table.Th>
|
||||
<Table.Th>Active</Table.Th><Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rules.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td><Badge color={r.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light">{r.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'}</Badge></Table.Td>
|
||||
<Table.Td>{r.name}</Table.Td>
|
||||
<Table.Td>{r.freightType ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.tradeDirection ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.freeDays}</Table.Td>
|
||||
<Table.Td>{Number(r.ratePerDay).toLocaleString()} {r.currency}</Table.Td>
|
||||
<Table.Td><Badge color={r.isActive ? 'green' : 'gray'} variant="light">{r.isActive ? 'Yes' : 'No'}</Badge></Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(r.id)} title="Delete">
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg">
|
||||
<Stack gap="sm">
|
||||
<Group grow>
|
||||
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} />
|
||||
<Select label="Rule type" data={FEE_RULE_TYPES.map((t) => ({ value: t, label: t === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage' }))} value={form.ruleType} onChange={(v) => setForm((f) => ({ ...f, ruleType: (v as FeeRuleType) ?? 'DEMURRAGE_FEE' }))} allowDeselect={false} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable />
|
||||
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable />
|
||||
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput label="Free days" min={0} value={form.freeDays} onChange={(v) => setForm((f) => ({ ...f, freeDays: Number(v) || 0 }))} />
|
||||
<NumberInput label="Rate / day" min={0} value={form.ratePerDay} onChange={(v) => setForm((f) => ({ ...f, ratePerDay: Number(v) || 0 }))} />
|
||||
<TextInput label="Currency" value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.currentTarget.value }))} />
|
||||
</Group>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button color="teal" loading={create.isPending} onClick={submit}>Create</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,19 @@ import { api as apiClient } from '../auth/http';
|
||||
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
import type {
|
||||
AllocationCriteria,
|
||||
AllocationPreviewResult,
|
||||
AllocationRule,
|
||||
ArrivalQueueItem,
|
||||
AutoLoadResult,
|
||||
AutoUnloadResult,
|
||||
FeePreview,
|
||||
FeeRule,
|
||||
InspectionAttachment,
|
||||
InspectionReport,
|
||||
InspectionReportPayload,
|
||||
SaveAllocationRulePayload,
|
||||
SaveFeeRulePayload,
|
||||
BookingScheduleView,
|
||||
InventoryFilter,
|
||||
InventoryInquiryFilter,
|
||||
@@ -145,4 +152,25 @@ export const warehouseService = {
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } },
|
||||
);
|
||||
},
|
||||
|
||||
// ── Batch 5: Allocation + Fee rules / previews ─────────────────────────────
|
||||
listAllocationRules: () =>
|
||||
apiClient.get<AllocationRule[]>(URL_CONSTANTS.WAREHOUSE_RULES.ALLOCATION),
|
||||
createAllocationRule: (payload: SaveAllocationRulePayload) =>
|
||||
apiClient.post<AllocationRule>(URL_CONSTANTS.WAREHOUSE_RULES.ALLOCATION, payload),
|
||||
updateAllocationRule: (id: string, payload: Partial<SaveAllocationRulePayload>) =>
|
||||
apiClient.patch<AllocationRule>(URL_CONSTANTS.WAREHOUSE_RULES.ALLOCATION_BY_ID(id), payload),
|
||||
deleteAllocationRule: (id: string) =>
|
||||
apiClient.delete(URL_CONSTANTS.WAREHOUSE_RULES.ALLOCATION_BY_ID(id)),
|
||||
previewAllocation: (criteria: AllocationCriteria) =>
|
||||
apiClient.post<AllocationPreviewResult | null>(URL_CONSTANTS.WAREHOUSE_RULES.ALLOCATION_PREVIEW, criteria),
|
||||
|
||||
listFeeRules: () => apiClient.get<FeeRule[]>(URL_CONSTANTS.WAREHOUSE_RULES.FEES),
|
||||
createFeeRule: (payload: SaveFeeRulePayload) =>
|
||||
apiClient.post<FeeRule>(URL_CONSTANTS.WAREHOUSE_RULES.FEES, payload),
|
||||
updateFeeRule: (id: string, payload: Partial<SaveFeeRulePayload>) =>
|
||||
apiClient.patch<FeeRule>(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id), payload),
|
||||
deleteFeeRule: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id)),
|
||||
feePreview: (inventoryId: string) =>
|
||||
apiClient.get<FeePreview[]>(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId)),
|
||||
};
|
||||
|
||||
@@ -359,6 +359,81 @@ export interface InspectionReport extends InspectionReportPayload {
|
||||
attachments?: InspectionAttachment[];
|
||||
}
|
||||
|
||||
// ── Batch 5: Allocation + Fee rules / preview ───────────────────────────────
|
||||
|
||||
export interface AllocationRule {
|
||||
id: string;
|
||||
name: string;
|
||||
priority: number;
|
||||
freightType?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
cargoTypeCode?: string | null;
|
||||
containerStatus?: string | null;
|
||||
requiresInspection?: boolean | null;
|
||||
targetFacilityCode?: string | null;
|
||||
targetYardCode: string;
|
||||
targetWarehouseCode?: string | null;
|
||||
targetZoneCode?: string | null;
|
||||
storageType?: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
export type SaveAllocationRulePayload = Omit<AllocationRule, 'id'>;
|
||||
|
||||
export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const;
|
||||
export type FeeRuleType = (typeof FEE_RULE_TYPES)[number];
|
||||
|
||||
export interface FeeRule {
|
||||
id: string;
|
||||
name: string;
|
||||
ruleType: FeeRuleType;
|
||||
priority: number;
|
||||
freightType?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
cargoTypeCode?: string | null;
|
||||
containerType?: string | null;
|
||||
facilityId?: string | null;
|
||||
warehouseId?: string | null;
|
||||
yardId?: string | null;
|
||||
zoneId?: string | null;
|
||||
freeDays: number;
|
||||
ratePerDay: number;
|
||||
currency: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
export type SaveFeeRulePayload = Omit<FeeRule, 'id'>;
|
||||
|
||||
export interface FeePreview {
|
||||
ruleType: FeeRuleType;
|
||||
ruleId: string | null;
|
||||
ruleName: string | null;
|
||||
freeDays: number;
|
||||
ratePerDay: number;
|
||||
currency: string;
|
||||
startDate: string | null;
|
||||
endDate: string;
|
||||
endIsOpen: boolean;
|
||||
elapsedDays: number;
|
||||
chargeableDays: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface AllocationPreviewResult {
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
facilityId: string | null;
|
||||
rule: { id: string; name: string; storageType: string | null } | null;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface AllocationCriteria {
|
||||
freightType?: string;
|
||||
tradeDirection?: string;
|
||||
cargoTypeCode?: string;
|
||||
containerStatus?: string;
|
||||
requiresInspection?: boolean;
|
||||
}
|
||||
|
||||
// ── Payloads ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SaveWarehousePayload {
|
||||
|
||||
Reference in New Issue
Block a user