mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 08:20:58 +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 })
|
@Column({ name: 'dispatched_at', type: 'timestamptz', nullable: true })
|
||||||
dispatchedAt?: Date | null;
|
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 })
|
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||||
notes?: string | null;
|
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 { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||||
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||||
|
import { WarehouseAllocationService } from './warehouse-allocation.service';
|
||||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||||
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
|
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
|
||||||
import {
|
import {
|
||||||
@@ -119,6 +120,7 @@ export class WarehouseInventoryService {
|
|||||||
private readonly loadingRepository: WarehouseLoadingRepository,
|
private readonly loadingRepository: WarehouseLoadingRepository,
|
||||||
private readonly activityLog: WarehouseActivityLogService,
|
private readonly activityLog: WarehouseActivityLogService,
|
||||||
private readonly scheduling: SchedulingReadFacade,
|
private readonly scheduling: SchedulingReadFacade,
|
||||||
|
private readonly allocation: WarehouseAllocationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ── Listing ────────────────────────────────────────────────────────────
|
// ── Listing ────────────────────────────────────────────────────────────
|
||||||
@@ -233,10 +235,19 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
/** Bulk-create inventory (RECEIVED) for arrived bookings that are not yet unloaded. */
|
/** Bulk-create inventory (RECEIVED) for arrived bookings that are not yet unloaded. */
|
||||||
async autoUnloadArrived(): Promise<AutoUnloadResult> {
|
async autoUnloadArrived(): Promise<AutoUnloadResult> {
|
||||||
const arrived: { id: string; weight: string | null }[] = await this.dataSource.query(
|
const arrived: {
|
||||||
`SELECT b.id, b.cargo_total_weight_vgm AS weight
|
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
|
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.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`,
|
WHERE b.status = ANY($1) AND b.deleted_at IS NULL AND inv.id IS NULL`,
|
||||||
[this.ARRIVED_BOOKING_STATUSES],
|
[this.ARRIVED_BOOKING_STATUSES],
|
||||||
);
|
);
|
||||||
@@ -245,17 +256,22 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
if (arrived.length === 0) return result;
|
if (arrived.length === 0) return result;
|
||||||
|
|
||||||
const location = await this.pickDefaultLocation();
|
const fallback = 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' })),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const booking of arrived) {
|
for (const booking of arrived) {
|
||||||
try {
|
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({
|
const saved = await this.inventoryRepository.create({
|
||||||
warehouseId: location.warehouseId,
|
warehouseId: location.warehouseId,
|
||||||
yardId: location.yardId,
|
yardId: location.yardId,
|
||||||
@@ -265,7 +281,7 @@ export class WarehouseInventoryService {
|
|||||||
weight: Number(booking.weight) || 0,
|
weight: Number(booking.weight) || 0,
|
||||||
status: 'RECEIVED',
|
status: 'RECEIVED',
|
||||||
arrivedAt: new Date(),
|
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.processedCount += 1;
|
||||||
result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'PROCESSED' });
|
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 { FilesModule } from '../files/files.module';
|
||||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
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 { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
||||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||||
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.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 { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||||
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
||||||
import { WarehouseLoadingsController } from './warehouse-loadings.controller';
|
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 { WarehouseSchedulingAdapterService } from './warehouse-scheduling-adapter.service';
|
||||||
import { WarehouseYardsController } from './warehouse-yards.controller';
|
import { WarehouseYardsController } from './warehouse-yards.controller';
|
||||||
import { WarehouseYardsRepository } from './warehouse-yards.repository';
|
import { WarehouseYardsRepository } from './warehouse-yards.repository';
|
||||||
@@ -45,6 +52,8 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
WarehouseActivityLog,
|
WarehouseActivityLog,
|
||||||
WarehouseLoading,
|
WarehouseLoading,
|
||||||
WarehouseInspectionReport,
|
WarehouseInspectionReport,
|
||||||
|
WarehouseAllocationRule,
|
||||||
|
WarehouseFeeRule,
|
||||||
]),
|
]),
|
||||||
FilesModule,
|
FilesModule,
|
||||||
],
|
],
|
||||||
@@ -55,6 +64,7 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
WarehouseInventoryController,
|
WarehouseInventoryController,
|
||||||
WarehouseLoadingsController,
|
WarehouseLoadingsController,
|
||||||
WarehouseInspectionController,
|
WarehouseInspectionController,
|
||||||
|
WarehouseRulesController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
WarehousesRepository,
|
WarehousesRepository,
|
||||||
@@ -65,6 +75,8 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
WarehouseActivityLogRepository,
|
WarehouseActivityLogRepository,
|
||||||
WarehouseLoadingRepository,
|
WarehouseLoadingRepository,
|
||||||
WarehouseInspectionRepository,
|
WarehouseInspectionRepository,
|
||||||
|
WarehouseAllocationRuleRepository,
|
||||||
|
WarehouseFeeRuleRepository,
|
||||||
WarehousesService,
|
WarehousesService,
|
||||||
WarehouseYardsService,
|
WarehouseYardsService,
|
||||||
WarehouseZonesService,
|
WarehouseZonesService,
|
||||||
@@ -72,6 +84,8 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
WarehouseActivityLogService,
|
WarehouseActivityLogService,
|
||||||
WarehouseDashboardService,
|
WarehouseDashboardService,
|
||||||
WarehouseInspectionService,
|
WarehouseInspectionService,
|
||||||
|
WarehouseAllocationService,
|
||||||
|
WarehouseFeeService,
|
||||||
WarehouseSchedulingAdapterService,
|
WarehouseSchedulingAdapterService,
|
||||||
SchedulingReadFacade,
|
SchedulingReadFacade,
|
||||||
],
|
],
|
||||||
@@ -80,6 +94,8 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
WarehouseYardsService,
|
WarehouseYardsService,
|
||||||
WarehouseZonesService,
|
WarehouseZonesService,
|
||||||
WarehouseInventoryService,
|
WarehouseInventoryService,
|
||||||
|
WarehouseAllocationService,
|
||||||
|
WarehouseFeeService,
|
||||||
WarehouseSchedulingAdapterService,
|
WarehouseSchedulingAdapterService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
|
|||||||
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
|
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
|
||||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||||
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||||
|
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
|
||||||
|
|
||||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||||
{
|
{
|
||||||
@@ -198,6 +199,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
href: "/dashboard/inventory-inquiry",
|
href: "/dashboard/inventory-inquiry",
|
||||||
icon: <Boxes />,
|
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="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||||
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
||||||
|
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
|
||||||
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
|
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
|
||||||
|
|
||||||
<Route
|
<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,
|
useStoreInventory,
|
||||||
} from '@/hooks/useWarehouses';
|
} from '@/hooks/useWarehouses';
|
||||||
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||||
|
import { FeePreviewModal } from './FeePreviewModal';
|
||||||
import { InspectionReportModal } from './InspectionReportModal';
|
import { InspectionReportModal } from './InspectionReportModal';
|
||||||
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||||
import { LoadInventoryModal } from './LoadInventoryModal';
|
import { LoadInventoryModal } from './LoadInventoryModal';
|
||||||
@@ -30,6 +31,7 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
|
|||||||
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
|
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
|
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [inspectItem, setInspectItem] = useState<WarehouseInventoryItem | null>(null);
|
const [inspectItem, setInspectItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
|
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
|
|
||||||
const storeMutation = useStoreInventory();
|
const storeMutation = useStoreInventory();
|
||||||
const readyMutation = useMarkReadyForLoading();
|
const readyMutation = useMarkReadyForLoading();
|
||||||
@@ -83,6 +85,7 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
|
|||||||
onMove={setMoveItem}
|
onMove={setMoveItem}
|
||||||
onHistory={setHistoryItem}
|
onHistory={setHistoryItem}
|
||||||
onInspect={setInspectItem}
|
onInspect={setInspectItem}
|
||||||
|
onFeePreview={setFeeItem}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
||||||
@@ -102,6 +105,11 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
|
|||||||
onClose={() => setInspectItem(null)}
|
onClose={() => setInspectItem(null)}
|
||||||
inventoryId={inspectItem?.id ?? 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 { 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 type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||||
import { INVENTORY_NEXT_ACTION } from '@/types/warehouse';
|
import { INVENTORY_NEXT_ACTION } from '@/types/warehouse';
|
||||||
@@ -13,6 +13,7 @@ interface WarehouseInventoryTableProps {
|
|||||||
onMove: (item: WarehouseInventoryItem) => void;
|
onMove: (item: WarehouseInventoryItem) => void;
|
||||||
onHistory: (item: WarehouseInventoryItem) => void;
|
onHistory: (item: WarehouseInventoryItem) => void;
|
||||||
onInspect?: (item: WarehouseInventoryItem) => void;
|
onInspect?: (item: WarehouseInventoryItem) => void;
|
||||||
|
onFeePreview?: (item: WarehouseInventoryItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemKind = (item: WarehouseInventoryItem) => {
|
const itemKind = (item: WarehouseInventoryItem) => {
|
||||||
@@ -37,6 +38,7 @@ export function WarehouseInventoryTable({
|
|||||||
onMove,
|
onMove,
|
||||||
onHistory,
|
onHistory,
|
||||||
onInspect,
|
onInspect,
|
||||||
|
onFeePreview,
|
||||||
}: WarehouseInventoryTableProps) {
|
}: WarehouseInventoryTableProps) {
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -128,6 +130,13 @@ export function WarehouseInventoryTable({
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</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>
|
<Tooltip label="History" withArrow>
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
|
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
|
||||||
<History size={16} />
|
<History size={16} />
|
||||||
|
|||||||
@@ -26,3 +26,4 @@ export { WarehouseHero } from './WarehouseHero';
|
|||||||
export { VisualEmptyState } from './VisualEmptyState';
|
export { VisualEmptyState } from './VisualEmptyState';
|
||||||
export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
|
export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
|
||||||
export { InspectionReportModal } from './InspectionReportModal';
|
export { InspectionReportModal } from './InspectionReportModal';
|
||||||
|
export { FeePreviewModal } from './FeePreviewModal';
|
||||||
|
|||||||
@@ -312,4 +312,13 @@ export const URL_CONSTANTS = {
|
|||||||
BY_ID: (id: string) => `/warehouse-inspection-reports/${id}`,
|
BY_ID: (id: string) => `/warehouse-inspection-reports/${id}`,
|
||||||
ATTACHMENTS: (id: string) => `/warehouse-inspection-reports/${id}/attachments`,
|
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 { warehouseService } from '@/services/warehouse.service';
|
||||||
import type {
|
import type {
|
||||||
InspectionReportPayload,
|
InspectionReportPayload,
|
||||||
|
SaveAllocationRulePayload,
|
||||||
|
SaveFeeRulePayload,
|
||||||
InventoryFilter,
|
InventoryFilter,
|
||||||
InventoryInquiryFilter,
|
InventoryInquiryFilter,
|
||||||
LoadInventoryPayload,
|
LoadInventoryPayload,
|
||||||
@@ -286,3 +288,60 @@ export function useUploadInspectionAttachments() {
|
|||||||
warehouseService.uploadInspectionAttachments(reportId, files),
|
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 { URL_CONSTANTS } from '@/constants/URLS';
|
||||||
import type {
|
import type {
|
||||||
|
AllocationCriteria,
|
||||||
|
AllocationPreviewResult,
|
||||||
|
AllocationRule,
|
||||||
ArrivalQueueItem,
|
ArrivalQueueItem,
|
||||||
AutoLoadResult,
|
AutoLoadResult,
|
||||||
AutoUnloadResult,
|
AutoUnloadResult,
|
||||||
|
FeePreview,
|
||||||
|
FeeRule,
|
||||||
InspectionAttachment,
|
InspectionAttachment,
|
||||||
InspectionReport,
|
InspectionReport,
|
||||||
InspectionReportPayload,
|
InspectionReportPayload,
|
||||||
|
SaveAllocationRulePayload,
|
||||||
|
SaveFeeRulePayload,
|
||||||
BookingScheduleView,
|
BookingScheduleView,
|
||||||
InventoryFilter,
|
InventoryFilter,
|
||||||
InventoryInquiryFilter,
|
InventoryInquiryFilter,
|
||||||
@@ -145,4 +152,25 @@ export const warehouseService = {
|
|||||||
{ headers: { 'Content-Type': 'multipart/form-data' } },
|
{ 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[];
|
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 ───────────────────────────────────────────────────────────────
|
// ── Payloads ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface SaveWarehousePayload {
|
export interface SaveWarehousePayload {
|
||||||
|
|||||||
Reference in New Issue
Block a user