From 01aec12ee9ca0a9cc77f8bcfcd89627ee53b3aef Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 17 Jun 2026 23:53:04 +0000 Subject: [PATCH] =?UTF-8?q?feat(warehouse):=20Batch=205=20=E2=80=94=20conf?= =?UTF-8?q?ig-driven=20allocation=20+=20storage/demurrage=20fee=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- ...00000-AddWarehouseAllocationAndFeeRules.ts | 93 ++++++ .../warehouses/dto/allocation-rule.dto.ts | 96 ++++++ .../modules/warehouses/dto/fee-rule.dto.ts | 76 +++++ .../warehouse-allocation-rule.entity.ts | 54 ++++ .../entities/warehouse-fee-rule.entity.ts | 62 ++++ .../entities/warehouse-inventory.entity.ts | 16 + .../warehouse-allocation-rule.repository.ts | 15 + .../warehouse-allocation.service.ts | 120 ++++++++ .../warehouse-fee-rule.repository.ts | 13 + .../warehouses/warehouse-fee.service.ts | 168 +++++++++++ .../warehouses/warehouse-inventory.service.ts | 38 ++- .../warehouses/warehouse-rules.controller.ts | 85 ++++++ .../modules/warehouses/warehouses.module.ts | 16 + apps/edr-freight-web/backoffice/src/App.tsx | 7 + .../components/warehouses/FeePreviewModal.tsx | 106 +++++++ .../warehouses/InventoryWorkbench.tsx | 8 + .../warehouses/WarehouseInventoryTable.tsx | 11 +- .../src/components/warehouses/index.ts | 1 + .../backoffice/src/constants/URLS.ts | 9 + .../backoffice/src/hooks/useWarehouses.ts | 59 ++++ .../pages/warehouses/WarehouseRulesPage.tsx | 285 ++++++++++++++++++ .../src/services/warehouse.service.ts | 28 ++ .../backoffice/src/types/warehouse.ts | 75 +++++ 23 files changed, 1429 insertions(+), 12 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1790000000000-AddWarehouseAllocationAndFeeRules.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/entities/warehouse-allocation-rule.entity.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-allocation-rule.repository.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-fee-rule.repository.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx diff --git a/apps/edr-freight-api/src/migrations/1790000000000-AddWarehouseAllocationAndFeeRules.ts b/apps/edr-freight-api/src/migrations/1790000000000-AddWarehouseAllocationAndFeeRules.ts new file mode 100644 index 000000000..125725400 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1790000000000-AddWarehouseAllocationAndFeeRules.ts @@ -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 { + 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 { + 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); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts new file mode 100644 index 000000000..43cd6f61a --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts new file mode 100644 index 000000000..873f97a6b --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-allocation-rule.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-allocation-rule.entity.ts new file mode 100644 index 000000000..a597ecf0e --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-allocation-rule.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts new file mode 100644 index 000000000..f346be282 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index d43f499d9..6c9270987 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation-rule.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation-rule.repository.ts new file mode 100644 index 000000000..a066c93f1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation-rule.repository.ts @@ -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 { + constructor( + @InjectRepository(WarehouseAllocationRule) repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts new file mode 100644 index 000000000..f110dfcf7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts @@ -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 { + return this.ruleRepository.findAll({ order: { priority: 'ASC' } }); + } + + createRule(dto: CreateAllocationRuleDto): Promise { + return this.ruleRepository.create({ isActive: true, priority: 100, ...dto }); + } + + async updateRule(id: string, dto: UpdateAllocationRuleDto): Promise { + const updated = await this.ruleRepository.update(id, dto); + if (!updated) throw new NotFoundException(`Allocation rule ${id} not found`); + return updated; + } + + deleteRule(id: string): Promise { + 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 { + 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 { + 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(' → '), + }; + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-rule.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-rule.repository.ts new file mode 100644 index 000000000..5b5d3b2ce --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-rule.repository.ts @@ -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 { + constructor(@InjectRepository(WarehouseFeeRule) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts new file mode 100644 index 000000000..ccf66deb4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -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 { + return this.feeRuleRepository.findAll({ order: { ruleType: 'ASC', priority: 'ASC' } }); + } + + createRule(dto: CreateFeeRuleDto): Promise { + return this.feeRuleRepository.create({ isActive: true, priority: 100, currency: 'USD', ...dto }); + } + + async updateRule(id: string, dto: UpdateFeeRuleDto): Promise { + const updated = await this.feeRuleRepository.update(id, dto); + if (!updated) throw new NotFoundException(`Fee rule ${id} not found`); + return updated; + } + + deleteRule(id: string): Promise { + return this.feeRuleRepository.softDelete(id); + } + + private async loadItem(inventoryId: string): Promise { + 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 { + 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), + ); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 47b3e47a4..a4e9c9040 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -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 { - 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' }); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts new file mode 100644 index 000000000..333597618 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index fe07ccb57..ba62772c1 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -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, ], }) diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 940863274..f60fbba63 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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: , }, + { + label: "Allocation & Fees", + href: "/dashboard/warehouse-rules", + icon: , + }, ], }, { @@ -368,6 +374,7 @@ const App = () => { } /> } /> } /> + } /> } /> void; + inventoryId: string | null; +} + +const LABELS: Record = { + 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 ( + + + + + {meta.label} + {fee.endIsOpen && ( + + accruing + + )} + + + {fee.amount.toLocaleString()} {fee.currency} + + + + {!configured ? ( + + No active {meta.label.toLowerCase()} rule configured — amount shown as 0. + + ) : ( + + + + + + + + + )} + + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + {value} + + ); +} + +/** 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 ( + + + Storage & Demurrage Preview + + } + centered + size="md" + > + {isLoading ? ( + + + + ) : ( + + {(data ?? []).map((fee) => ( + + ))} + + Preview only — invoicing & payment are handled in Batch 6. Charges accrue from arrival until + gate clearance / release (or today if still in terminal). + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx index ad0f525bf..aeeffbdbe 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx @@ -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(null); const [historyItem, setHistoryItem] = useState(null); const [inspectItem, setInspectItem] = useState(null); + const [feeItem, setFeeItem] = useState(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} /> setMoveItem(null)} item={moveItem} /> @@ -102,6 +105,11 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps onClose={() => setInspectItem(null)} inventoryId={inspectItem?.id ?? null} /> + setFeeItem(null)} + inventoryId={feeItem?.id ?? null} + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx index d13cef75f..11d9d323f 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx @@ -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({ )} + {onFeePreview && ( + + onFeePreview(item)}> + + + + )} onHistory(item)}> diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts index 9d0823616..c0111fbef 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts @@ -26,3 +26,4 @@ export { WarehouseHero } from './WarehouseHero'; export { VisualEmptyState } from './VisualEmptyState'; export { WarehouseDashboardCharts } from './WarehouseDashboardCharts'; export { InspectionReportModal } from './InspectionReportModal'; +export { FeePreviewModal } from './FeePreviewModal'; diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index a269e895d..7c3c600e5 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -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`, + }, }; diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts index bc9b54888..639e0dc16 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -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(fn: (args: TArgs) => Promise, 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 }) => + 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 }) => + 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), + }); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx new file mode 100644 index 000000000..1bfce1de1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx @@ -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 ( + + + + + + + + Allocation Rules + Storage / Demurrage Fees + + + + + + + + + + + + ); +} + +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 ( + <> + + {rules.length} rule(s) — matched by ascending priority + + + {isLoading ? ( + + ) : ( + + + + + PriorityNameFreight + TradeCargo codeTarget yard + ActiveActions + + + + {rules.map((r) => ( + + {r.priority} + {r.name} + {r.freightType ?? '—'} + {r.tradeDirection ?? '—'} + {r.cargoTypeCode ?? '—'} + {r.targetYardCode} + {r.isActive ? 'Yes' : 'No'} + + remove.mutate(r.id)} title="Delete"> + + + + + ))} + +
+
+ )} + + setOpen(false)} title="New allocation rule" centered size="lg"> + + + setForm((f) => ({ ...f, name: e.currentTarget.value }))} /> + setForm((f) => ({ ...f, priority: Number(v) || 100 }))} /> + + + setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable /> + + + setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} /> + setForm((f) => ({ ...f, containerStatus: e.currentTarget.value }))} /> + + + setForm((f) => ({ ...f, targetYardCode: e.currentTarget.value }))} /> + setForm((f) => ({ ...f, storageType: e.currentTarget.value }))} /> + + + + + + + + + ); +} + +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 ( + <> + + {rules.length} rule(s) — most specific match applies + + + {isLoading ? ( + + ) : ( + + + + + TypeNameFreight + TradeFree daysRate / day + ActiveActions + + + + {rules.map((r) => ( + + {r.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'} + {r.name} + {r.freightType ?? '—'} + {r.tradeDirection ?? '—'} + {r.freeDays} + {Number(r.ratePerDay).toLocaleString()} {r.currency} + {r.isActive ? 'Yes' : 'No'} + + remove.mutate(r.id)} title="Delete"> + + + + + ))} + +
+
+ )} + + setOpen(false)} title="New fee rule" centered size="lg"> + + + setForm((f) => ({ ...f, name: e.currentTarget.value }))} /> + setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable /> +