diff --git a/apps/edr-freight-api/src/migrations/2010000000000-AddFeeRuleVehicleType.ts b/apps/edr-freight-api/src/migrations/2010000000000-AddFeeRuleVehicleType.ts new file mode 100644 index 000000000..a71f3cb5b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2010000000000-AddFeeRuleVehicleType.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Truck-detention rules can be scoped by vehicle type (TRUCK / VAN / TRAILER / + * TANKER / FLATBED / …), so different truck types carry different detention + * rates. Null = applies to any truck type. + */ +export class AddFeeRuleVehicleType2010000000000 implements MigrationInterface { + name = 'AddFeeRuleVehicleType2010000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS vehicle_type varchar(20)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS vehicle_type`); + } +} 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 index 680eb2591..a688f3e1a 100644 --- 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 @@ -93,6 +93,11 @@ export class CreateFeeRuleDto { @Min(0) freeHours?: number; + @ApiPropertyOptional({ description: 'Truck detention only: scope by vehicle type (TRUCK | VAN | TRAILER | …). Null = any.' }) + @IsOptional() + @IsString() + vehicleType?: string; + @ApiPropertyOptional({ enum: FEE_RULE_BASES, description: 'Double-handling charge basis: PER_CONTAINER | PER_TON | PER_ITEM.', 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 index f6ad7ec8f..a233143cf 100644 --- 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 @@ -55,6 +55,11 @@ export class WarehouseFeeRule extends BaseEntity { @Column({ name: 'container_type', type: 'varchar', length: 40, nullable: true }) containerType?: string | null; + // Truck detention only: scope by vehicle type (TRUCK | VAN | TRAILER | TANKER + // | FLATBED | …). Null = any truck type. + @Column({ name: 'vehicle_type', type: 'varchar', length: 20, nullable: true }) + vehicleType?: string | null; + @Column({ name: 'facility_id', type: 'uuid', nullable: true }) facilityId?: string | null; 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 index c322482b2..947a99f58 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -14,6 +14,8 @@ interface ItemAttributes { tradeDirection: string | null; cargoTypeCode: string | null; containerTypeCode: string | null; + /** Vehicle type of the truck (truck detention scoping); null otherwise. */ + vehicleType: string | null; inventoryQuantity: number; bookingContainerCount: number; /** Booking cargo total in the cargo's unit of measure: tonnes (PER_TON) or item count (PER_ITEM). */ @@ -52,6 +54,16 @@ export interface FeePreview { ratePerDay: number; amount: number; }>; + /** Truck detention: per-vehicle-type breakdown — each truck-type group billed by its own matching rule. */ + groups?: Array<{ + vehicleType: string | null; + truckCount: number; + chargeableDays: number; + ratePerDay: number; + amount: number; + ruleId: string | null; + ruleName: string | null; + }>; } const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -194,6 +206,7 @@ export class WarehouseFeeService { if (!check(rule.tradeDirection, item.tradeDirection, { allowBoth: true })) return null; if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null; if (!check(rule.containerType, item.containerTypeCode)) return null; + if (!check(rule.vehicleType, item.vehicleType)) 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; @@ -430,42 +443,102 @@ export class WarehouseFeeService { * tiers by detention day) until it is delivered/returned (or now, if open). */ async previewTruckDetention(lastMileId: string, billingCurrency = 'USD'): Promise { - const [row] = await this.dataSource.query( + const [leg] = await this.dataSource.query( `SELECT lm.arrived_at AS "arrivedAt", lm.delivered_at AS "deliveredAt", b.freight_type AS "freightType", - b.trade_direction AS "tradeDirection", - (SELECT count(*) FROM freight.last_mile_vehicle_assignments va - WHERE va.last_mile_id = lm.id AND va.deleted_at IS NULL) AS "truckCount" + b.trade_direction AS "tradeDirection" FROM freight.last_mile lm LEFT JOIN freight.bookings b ON b.id = lm.booking_id WHERE lm.id = $1 AND lm.deleted_at IS NULL`, [lastMileId], ); - if (!row) throw new NotFoundException(`Last-mile record ${lastMileId} not found`); + if (!leg) throw new NotFoundException(`Last-mile record ${lastMileId} not found`); + + // Group the leg's vehicles by type so each truck type is billed by its own + // matching rule (rates differ by truck type). Falls back to one untyped group. + const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> = + await this.dataSource.query( + `SELECT v.vehicle_type AS "vehicleType", count(*)::int AS "truckCount" + FROM freight.last_mile_vehicle_assignments va + JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL + WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL + GROUP BY v.vehicle_type`, + [lastMileId], + ); + const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }]; - const item: ItemAttributes = { - arrivedAt: null, - gateClearedAt: null, - releaseDate: null, - freightType: row.freightType ?? null, - tradeDirection: row.tradeDirection ?? null, - cargoTypeCode: null, - containerTypeCode: null, - inventoryQuantity: 1, - bookingContainerCount: 1, - cargoQuantity: 0, - facilityId: null, - warehouseId: null, - yardId: null, - zoneId: null, - }; const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); - const rule = this.bestRule( - rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE'), - item, + const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE'); + const now = new Date(); + const targetCurrency = this.normalizeCurrency(billingCurrency); + + const computed = await Promise.all( + groups.map(async (g) => { + const item: ItemAttributes = { + arrivedAt: null, + gateClearedAt: null, + releaseDate: null, + freightType: leg.freightType ?? null, + tradeDirection: leg.tradeDirection ?? null, + cargoTypeCode: null, + containerTypeCode: null, + vehicleType: g.vehicleType ?? null, + inventoryQuantity: 1, + bookingContainerCount: 1, + cargoQuantity: 0, + facilityId: null, + warehouseId: null, + yardId: null, + zoneId: null, + }; + const rule = this.bestRule(detentionRules, item); + const c = await this.computeTruckDetention( + rule, + { arrivedAt: leg.arrivedAt, deliveredAt: leg.deliveredAt, truckCount: g.truckCount }, + now, + billingCurrency, + ); + return { vehicleType: g.vehicleType ?? null, truckCount: Math.max(1, Math.round(Number(g.truckCount) || 1)), c }; + }), ); - return this.computeTruckDetention(rule, row, new Date(), billingCurrency); + + const totalAmount = Math.round(computed.reduce((s, x) => s + x.c.amount, 0) * 100) / 100; + const totalTrucks = computed.reduce((s, x) => s + x.truckCount, 0); + const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0); + const chargeableDays = computed[0]?.c.chargeableDays ?? 0; + const single = computed.length === 1 ? computed[0].c : null; + const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? null; + + return { + ruleType: 'TRUCK_DETENTION_FEE', + basis: null, + ruleId: single?.ruleId ?? null, + ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck-type rules' : anyRuleName, + freeDays: 0, + ratePerDay: single?.ratePerDay ?? 0, + currency: targetCurrency, + ruleCurrency: single?.ruleCurrency ?? null, + billingCurrency: targetCurrency, + startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null, + endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : now).toISOString(), + endIsOpen: !leg.deliveredAt, + elapsedDays: chargeableDays, + chargeableDays, + containerCount: totalTrucks, + billableUnits: totalBillable, + amount: totalAmount, + tiers: single ? single.tiers : [], + groups: computed.map((x) => ({ + vehicleType: x.vehicleType, + truckCount: x.truckCount, + chargeableDays: x.c.chargeableDays, + ratePerDay: x.c.ratePerDay, + amount: x.c.amount, + ruleId: x.c.ruleId, + ruleName: x.c.ruleName, + })), + }; } private async computeTruckDetention( diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 4683b3bda..9ee1dc398 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -316,19 +316,44 @@ export class WarehouseInvoiceService { ); } - const truckCount = preview.containerCount; // reused as the per-truck count - const line: InvoiceLineInput = { - chargeType: "TRUCK_DETENTION", - description: `Truck detention - ${preview.chargeableDays} day(s) x ${truckCount} truck(s)${preview.tiers.length ? " using tiered tariff" : ""}`, - quantity: preview.billableUnits, - unitRate: preview.ratePerDay, - amount: preview.amount, - currency: preview.currency, - metadata: { - feeRuleId: preview.ruleId ?? null, - chargeableDays: preview.chargeableDays ?? null, - }, - }; + // One line per truck-type group (each billed by its own matching rule). Groups + // with no matching rule bill 0 and are dropped. Falls back to a single line. + const groups = preview.groups && preview.groups.length ? preview.groups : null; + const lines: InvoiceLineInput[] = groups + ? groups + .filter((g) => g.amount > 0) + .map((g) => ({ + chargeType: "TRUCK_DETENTION", + description: `Truck detention${g.vehicleType ? ` (${g.vehicleType})` : ""} - ${g.chargeableDays} day(s) x ${g.truckCount} truck(s)`, + quantity: g.truckCount * g.chargeableDays, + unitRate: g.ratePerDay, + amount: g.amount, + currency: preview.currency, + metadata: { + feeRuleId: g.ruleId ?? null, + chargeableDays: g.chargeableDays, + vehicleType: g.vehicleType ?? null, + }, + })) + : [ + { + chargeType: "TRUCK_DETENTION", + description: `Truck detention - ${preview.chargeableDays} day(s) x ${preview.containerCount} truck(s)`, + quantity: preview.billableUnits, + unitRate: preview.ratePerDay, + amount: preview.amount, + currency: preview.currency, + metadata: { + feeRuleId: preview.ruleId ?? null, + chargeableDays: preview.chargeableDays ?? null, + }, + }, + ]; + if (lines.length === 0) { + throw new BadRequestException( + "No truck detention is currently payable for this last-mile leg.", + ); + } return this.billing.generateInvoice({ source: "last_mile" as Freight.InvoiceSource, @@ -337,7 +362,7 @@ export class WarehouseInvoiceService { companyId: lm.companyId, companyProfileId: lm.companyProfileId || "", currency: billingCurrency, - lines: [line], + lines, status: Freight.InvoiceStatus.Issued, }); } diff --git a/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx b/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx index 1a0d96f96..2088db32d 100644 --- a/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx @@ -154,7 +154,37 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM Still accruing — no delivery/return time yet. The amount grows until the vehicle is returned. )} - {preview.tiers && preview.tiers.length > 0 ? ( + {preview.groups && preview.groups.length > 1 ? ( + + + + Truck type + Trucks + Days + Rate / truck / day + Amount + + + + {preview.groups.map((g, i) => ( + + + {g.vehicleType ?? 'Unknown'} + {!g.ruleId && ( + + {' '}· no rule + + )} + + {g.truckCount} + {g.chargeableDays} + {money(g.ratePerDay, preview.currency)} + {money(g.amount, preview.currency)} + + ))} + +
+ ) : preview.tiers && preview.tiers.length > 0 ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx index 0e91407dc..49e79252e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx @@ -37,6 +37,7 @@ import { FEE_RULE_BASIS_LABELS, FEE_RULE_TYPES, FEE_RULE_TYPE_LABELS, + VEHICLE_TYPES, type FeeRuleBasis, type FeeRuleType, } from '@/types/warehouse'; @@ -372,6 +373,7 @@ function FeeRules() { tradeDirection: '', cargoTypeCode: '', containerType: '', + vehicleType: '', freeDays: 3, freeHours: 3, ratePerDay: 0, @@ -399,6 +401,7 @@ function FeeRules() { tradeDirection: '', cargoTypeCode: '', containerType: '', + vehicleType: '', freeDays: 3, freeHours: 3, ratePerDay: 0, @@ -469,7 +472,7 @@ function FeeRules() { ratePerDay: form.ratePerDay, currency: form.currency || 'USD', ...(isDoubleHandling ? { basis: form.basis } : {}), - ...(isTruckDetention ? { freeHours: form.freeHours } : {}), + ...(isTruckDetention ? { freeHours: form.freeHours, vehicleType: clean(form.vehicleType) ?? null } : {}), ...(!isDoubleHandling && tiers.length ? { tiers } : {}), }; @@ -638,6 +641,16 @@ function FeeRules() { onChange={(value) => setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))} clearable /> + {isTruckDetention && ( + = { PER_ITEM: 'Per Item', }; +/** Vehicle types a Truck Detention rule can be scoped to (rates differ by truck type). */ +export const VEHICLE_TYPES = ['TRUCK', 'VAN', 'CAR', 'BUS', 'TRAILER', 'TANKER', 'FLATBED'] as const; +export type VehicleTypeCode = (typeof VEHICLE_TYPES)[number]; + export interface FeeRule { id: string; name: string; @@ -774,6 +778,8 @@ export interface FeeRule { tradeDirection?: string | null; cargoTypeCode?: string | null; containerType?: string | null; + /** Truck detention only: scope by vehicle type (null = any). */ + vehicleType?: string | null; facilityId?: string | null; warehouseId?: string | null; yardId?: string | null; @@ -820,6 +826,16 @@ export interface FeePreview { billableUnits: number; amount: number; tiers?: FeePreviewTier[]; + /** Truck detention: per-vehicle-type breakdown. */ + groups?: Array<{ + vehicleType: string | null; + truckCount: number; + chargeableDays: number; + ratePerDay: number; + amount: number; + ruleId: string | null; + ruleName: string | null; + }>; } export interface AllocationPreviewResult {