mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 17:23:38 +00:00
634 lines
25 KiB
TypeScript
634 lines
25 KiB
TypeScript
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||
import { ExchangeService } from '@edr/api-common';
|
||
import { DataSource } from 'typeorm';
|
||
|
||
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
||
import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } 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;
|
||
/** 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). */
|
||
cargoQuantity: number;
|
||
facilityId: string | null;
|
||
warehouseId: string | null;
|
||
yardId: string | null;
|
||
zoneId: string | null;
|
||
}
|
||
|
||
export interface FeePreview {
|
||
ruleType: FeeRuleType;
|
||
/** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */
|
||
basis: FeeRuleBasis | null;
|
||
ruleId: string | null;
|
||
ruleName: string | null;
|
||
freeDays: number;
|
||
ratePerDay: number;
|
||
currency: string;
|
||
ruleCurrency: string | null;
|
||
billingCurrency: string;
|
||
startDate: string | null;
|
||
endDate: string;
|
||
endIsOpen: boolean; // true when still accruing (no release/gate-clear yet)
|
||
elapsedDays: number;
|
||
chargeableDays: number;
|
||
containerCount: number;
|
||
billableUnits: number;
|
||
amount: number;
|
||
tiers: Array<{
|
||
fromDay: number;
|
||
toDay: number | null;
|
||
appliedFromDay: number;
|
||
appliedToDay: number;
|
||
days: number;
|
||
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;
|
||
|
||
@Injectable()
|
||
export class WarehouseFeeService {
|
||
constructor(
|
||
private readonly dataSource: DataSource,
|
||
private readonly feeRuleRepository: WarehouseFeeRuleRepository,
|
||
private readonly exchangeService: ExchangeService,
|
||
) {}
|
||
|
||
// ── 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',
|
||
...this.normalizeRuleInput(dto),
|
||
});
|
||
}
|
||
|
||
async updateRule(id: string, dto: UpdateFeeRuleDto): Promise<WarehouseFeeRule> {
|
||
const updated = await this.feeRuleRepository.update(id, this.normalizeRuleInput(dto));
|
||
if (!updated) throw new NotFoundException(`Fee rule ${id} not found`);
|
||
return updated;
|
||
}
|
||
|
||
deleteRule(id: string): Promise<void> {
|
||
return this.feeRuleRepository.softDelete(id);
|
||
}
|
||
|
||
private normalizeRuleInput<T extends CreateFeeRuleDto | UpdateFeeRuleDto>(dto: T): T {
|
||
if (dto.tiers === undefined) return dto;
|
||
const tiers = (dto.tiers ?? [])
|
||
.map((tier) => ({
|
||
fromDay: Number(tier.fromDay),
|
||
toDay: tier.toDay == null ? null : Number(tier.toDay),
|
||
ratePerDay: Number(tier.ratePerDay),
|
||
}))
|
||
.filter((tier) => tier.fromDay > 0 || tier.toDay != null || tier.ratePerDay > 0);
|
||
|
||
for (const tier of tiers) {
|
||
if (!Number.isInteger(tier.fromDay) || tier.fromDay < 1) {
|
||
throw new BadRequestException('Fee tier from day must be a positive whole number.');
|
||
}
|
||
if (tier.toDay != null && (!Number.isInteger(tier.toDay) || tier.toDay < tier.fromDay)) {
|
||
throw new BadRequestException('Fee tier to day must be empty or greater than/equal to from day.');
|
||
}
|
||
if (!Number.isFinite(tier.ratePerDay) || tier.ratePerDay < 0) {
|
||
throw new BadRequestException('Fee tier rate per day must be zero or greater.');
|
||
}
|
||
}
|
||
|
||
const sorted = [...tiers].sort((a, b) => a.fromDay - b.fromDay || (a.toDay ?? Infinity) - (b.toDay ?? Infinity));
|
||
for (let i = 1; i < sorted.length; i += 1) {
|
||
const prev = sorted[i - 1];
|
||
const current = sorted[i];
|
||
if (prev.toDay == null || current.fromDay <= prev.toDay) {
|
||
throw new BadRequestException('Fee tiers cannot overlap. Use separate from/to day ranges.');
|
||
}
|
||
}
|
||
|
||
return { ...dto, tiers: sorted } as T;
|
||
}
|
||
|
||
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.quantity AS "inventoryQuantity",
|
||
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",
|
||
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
|
||
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
|
||
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount",
|
||
COALESCE(b.cargo_total_weight_vgm, 0) AS "cargoQuantity"
|
||
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.cargo_types booking_cgt ON booking_cgt.id = b.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
|
||
LEFT JOIN LATERAL (
|
||
SELECT bc.container_type_id
|
||
FROM freight.booking_container bc
|
||
WHERE bc.booking_id = inv.booking_id
|
||
AND bc.deleted_at IS NULL
|
||
AND bc.container_type_id IS NOT NULL
|
||
ORDER BY bc.created_at ASC
|
||
LIMIT 1
|
||
) booking_container_type ON true
|
||
LEFT JOIN freight.container_types booking_ctt ON booking_ctt.id = booking_container_type.container_type_id
|
||
LEFT JOIN LATERAL (
|
||
SELECT COALESCE(SUM(bc.quantity), 0)::int AS container_count
|
||
FROM freight.booking_container bc
|
||
WHERE bc.booking_id = inv.booking_id
|
||
AND bc.deleted_at IS NULL
|
||
) container_lines ON true
|
||
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 normalized = (value: string | null | undefined) => value?.trim().toUpperCase() || null;
|
||
const check = (
|
||
ruleVal: string | null | undefined,
|
||
itemVal: string | null,
|
||
opts: { allowBoth?: boolean } = {},
|
||
) => {
|
||
const ruleCode = normalized(ruleVal);
|
||
if (ruleCode == null || ruleCode === 'ANY' || ruleCode === 'ALL') return true;
|
||
if (opts.allowBoth && ruleCode === 'BOTH') {
|
||
score += 1;
|
||
return true;
|
||
}
|
||
if (ruleCode === normalized(itemVal)) {
|
||
score += 1;
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
if (!check(rule.freightType, item.freightType)) return null;
|
||
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;
|
||
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 normalizeCurrency(currency?: string | null): 'ETB' | 'USD' {
|
||
return currency === 'ETB' ? 'ETB' : 'USD';
|
||
}
|
||
|
||
private async convertAmount(amount: number, fromCurrency: string, toCurrency: string): Promise<number> {
|
||
const from = this.normalizeCurrency(fromCurrency);
|
||
const to = this.normalizeCurrency(toCurrency);
|
||
if (from === to) return Math.round(amount * 100) / 100;
|
||
const rate = await this.exchangeService.getRate(from, to);
|
||
return Math.round(amount * rate * 100) / 100;
|
||
}
|
||
|
||
private calculateTieredAmount(
|
||
tiers: WarehouseFeeTier[] | null | undefined,
|
||
elapsedDays: number,
|
||
containerCount: number,
|
||
): {
|
||
sourceAmount: number;
|
||
billableUnits: number;
|
||
chargeableDays: number;
|
||
weightedRatePerDay: number;
|
||
tiers: FeePreview['tiers'];
|
||
} {
|
||
const sourceTiers = (tiers ?? [])
|
||
.map((tier) => ({
|
||
fromDay: Number(tier.fromDay),
|
||
toDay: tier.toDay == null ? null : Number(tier.toDay),
|
||
ratePerDay: Number(tier.ratePerDay),
|
||
}))
|
||
.filter((tier) => Number.isFinite(tier.fromDay) && tier.fromDay > 0 && Number.isFinite(tier.ratePerDay))
|
||
.sort((a, b) => a.fromDay - b.fromDay);
|
||
|
||
let sourceAmount = 0;
|
||
let tierDays = 0;
|
||
const appliedTiers: FeePreview['tiers'] = [];
|
||
|
||
for (const tier of sourceTiers) {
|
||
if (elapsedDays < tier.fromDay) continue;
|
||
const appliedFromDay = tier.fromDay;
|
||
const appliedToDay = Math.min(elapsedDays, tier.toDay ?? elapsedDays);
|
||
const days = Math.max(0, appliedToDay - appliedFromDay + 1);
|
||
if (days <= 0) continue;
|
||
|
||
const amount = Math.round(days * containerCount * tier.ratePerDay * 100) / 100;
|
||
sourceAmount += amount;
|
||
tierDays += days;
|
||
appliedTiers.push({
|
||
fromDay: tier.fromDay,
|
||
toDay: tier.toDay,
|
||
appliedFromDay,
|
||
appliedToDay,
|
||
days,
|
||
ratePerDay: tier.ratePerDay,
|
||
amount,
|
||
});
|
||
}
|
||
|
||
return {
|
||
sourceAmount: Math.round(sourceAmount * 100) / 100,
|
||
billableUnits: tierDays * containerCount,
|
||
chargeableDays: tierDays,
|
||
weightedRatePerDay: tierDays > 0 ? Math.round((sourceAmount / tierDays / containerCount) * 100) / 100 : 0,
|
||
tiers: appliedTiers,
|
||
};
|
||
}
|
||
|
||
private async compute(
|
||
ruleType: FeeRuleType,
|
||
rule: WarehouseFeeRule | null,
|
||
item: ItemAttributes,
|
||
now: Date,
|
||
billingCurrency: string,
|
||
): Promise<FeePreview> {
|
||
// Double handling is a flat charge (rate × basis quantity), not day-based.
|
||
if (ruleType === 'DOUBLE_HANDLING_FEE') {
|
||
return this.computeDoubleHandling(rule, item, now, billingCurrency);
|
||
}
|
||
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 ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null;
|
||
const targetCurrency = this.normalizeCurrency(billingCurrency);
|
||
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
|
||
const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1));
|
||
const containerCount = isContainer
|
||
? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity))
|
||
: 1;
|
||
|
||
const elapsedDays = start
|
||
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
|
||
: 0;
|
||
const tiered = this.calculateTieredAmount(rule?.tiers, elapsedDays, containerCount);
|
||
const hasTiers = Boolean(rule?.tiers?.length);
|
||
const chargeableDays = hasTiers ? tiered.chargeableDays : Math.max(0, elapsedDays - freeDays);
|
||
const billableUnits = hasTiers ? tiered.billableUnits : chargeableDays * containerCount;
|
||
const sourceAmount = hasTiers ? tiered.sourceAmount : Math.round(billableUnits * ratePerDay * 100) / 100;
|
||
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
|
||
const sourceRatePerDay = hasTiers ? tiered.weightedRatePerDay : ratePerDay;
|
||
const convertedRatePerDay = ruleCurrency
|
||
? await this.convertAmount(sourceRatePerDay, ruleCurrency, targetCurrency)
|
||
: 0;
|
||
const convertedTiers = ruleCurrency
|
||
? await Promise.all(
|
||
tiered.tiers.map(async (tier) => ({
|
||
...tier,
|
||
ratePerDay: await this.convertAmount(tier.ratePerDay, ruleCurrency, targetCurrency),
|
||
amount: await this.convertAmount(tier.amount, ruleCurrency, targetCurrency),
|
||
})),
|
||
)
|
||
: [];
|
||
|
||
return {
|
||
ruleType,
|
||
basis: null,
|
||
ruleId: rule?.id ?? null,
|
||
ruleName: rule?.name ?? null,
|
||
freeDays,
|
||
ratePerDay: convertedRatePerDay,
|
||
currency: targetCurrency,
|
||
ruleCurrency,
|
||
billingCurrency: targetCurrency,
|
||
startDate: start ? start.toISOString() : null,
|
||
endDate: new Date(endDate).toISOString(),
|
||
endIsOpen,
|
||
elapsedDays,
|
||
chargeableDays,
|
||
containerCount,
|
||
billableUnits,
|
||
amount,
|
||
tiers: hasTiers ? convertedTiers : [],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Double handling — a flat one-time charge, not time-based. Amount = rate ×
|
||
* the basis quantity: PER_CONTAINER (booking container count), or PER_TON /
|
||
* PER_ITEM (the booking cargo total in the cargo's unit of measure — tonnes
|
||
* for bulk, item count for break-bulk). No free days, no elapsed days, no tiers.
|
||
*/
|
||
private async computeDoubleHandling(
|
||
rule: WarehouseFeeRule | null,
|
||
item: ItemAttributes,
|
||
now: Date,
|
||
billingCurrency: string,
|
||
): Promise<FeePreview> {
|
||
const basis: FeeRuleBasis = rule?.basis ?? 'PER_CONTAINER';
|
||
const rate = Number(rule?.ratePerDay ?? 0);
|
||
const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null;
|
||
const targetCurrency = this.normalizeCurrency(billingCurrency);
|
||
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
|
||
const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1));
|
||
const containerCount = isContainer
|
||
? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity))
|
||
: 1;
|
||
// PER_TON (tonnes) and PER_ITEM (piece count) both read the cargo total,
|
||
// which is stored in the cargo's own unit of measure.
|
||
const cargoQuantity = Math.max(0, Number(item.cargoQuantity) || 0);
|
||
// Double handling applies to IMPORT only — no charge for export/domestic.
|
||
const isImport = (item.tradeDirection ?? '').toUpperCase() === 'IMPORT';
|
||
const quantity = !isImport ? 0 : basis === 'PER_CONTAINER' ? containerCount : cargoQuantity;
|
||
const sourceAmount = Math.round(rate * quantity * 100) / 100;
|
||
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
|
||
const convertedRate = ruleCurrency ? await this.convertAmount(rate, ruleCurrency, targetCurrency) : 0;
|
||
|
||
return {
|
||
ruleType: 'DOUBLE_HANDLING_FEE',
|
||
basis,
|
||
ruleId: rule?.id ?? null,
|
||
ruleName: rule?.name ?? null,
|
||
freeDays: 0,
|
||
ratePerDay: convertedRate,
|
||
currency: targetCurrency,
|
||
ruleCurrency,
|
||
billingCurrency: targetCurrency,
|
||
startDate: null,
|
||
endDate: now.toISOString(),
|
||
endIsOpen: false,
|
||
elapsedDays: 0,
|
||
chargeableDays: 0,
|
||
containerCount,
|
||
billableUnits: quantity,
|
||
amount,
|
||
tiers: [],
|
||
};
|
||
}
|
||
|
||
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
|
||
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
|
||
const item = await this.loadItem(inventoryId);
|
||
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
||
const now = new Date();
|
||
|
||
// Truck detention is a per-truck last-mile charge, not a per-inventory fee —
|
||
// it is computed separately via previewTruckDetention(), not here.
|
||
const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE', 'DOUBLE_HANDLING_FEE'];
|
||
return Promise.all(
|
||
byType.map((type) =>
|
||
this.compute(
|
||
type,
|
||
this.bestRule(rules.filter((r) => r.ruleType === type), item),
|
||
item,
|
||
now,
|
||
billingCurrency,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Truck detention preview for an EDR last-mile leg. The vehicle should be
|
||
* returned within the rule's grace window (default 3h) of arriving; beyond
|
||
* that, detention accrues per truck per day (flat rate/day or progressive
|
||
* tiers by detention day) until it is delivered/returned (or now, if open).
|
||
*/
|
||
async previewTruckDetention(lastMileId: string, billingCurrency = 'USD'): Promise<FeePreview> {
|
||
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"
|
||
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 (!leg) throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
|
||
|
||
// Truck detention applies to IMPORT only — no charge for export/domestic.
|
||
if ((leg.tradeDirection ?? '').toUpperCase() !== 'IMPORT') {
|
||
const cur = this.normalizeCurrency(billingCurrency);
|
||
return {
|
||
ruleType: 'TRUCK_DETENTION_FEE',
|
||
basis: null,
|
||
ruleId: null,
|
||
ruleName: null,
|
||
freeDays: 0,
|
||
ratePerDay: 0,
|
||
currency: cur,
|
||
ruleCurrency: null,
|
||
billingCurrency: cur,
|
||
startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null,
|
||
endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : new Date()).toISOString(),
|
||
endIsOpen: !leg.deliveredAt,
|
||
elapsedDays: 0,
|
||
chargeableDays: 0,
|
||
containerCount: 0,
|
||
billableUnits: 0,
|
||
amount: 0,
|
||
tiers: [],
|
||
groups: [],
|
||
};
|
||
}
|
||
|
||
// 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 rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
||
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 };
|
||
}),
|
||
);
|
||
|
||
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(
|
||
rule: WarehouseFeeRule | null,
|
||
row: { arrivedAt: Date | string | null; deliveredAt: Date | string | null; truckCount: number | string },
|
||
now: Date,
|
||
billingCurrency: string,
|
||
): Promise<FeePreview> {
|
||
const graceHours = rule?.freeHours && Number(rule.freeHours) > 0 ? Number(rule.freeHours) : 3;
|
||
const truckCount = Math.max(1, Math.round(Number(row.truckCount) || 1));
|
||
const start = row.arrivedAt ? new Date(row.arrivedAt) : null;
|
||
const end = row.deliveredAt ? new Date(row.deliveredAt) : now;
|
||
const endIsOpen = !row.deliveredAt;
|
||
|
||
let chargeableDays = 0;
|
||
if (start) {
|
||
const detentionMs = end.getTime() - start.getTime() - graceHours * 60 * 60 * 1000;
|
||
chargeableDays = detentionMs > 0 ? Math.ceil(detentionMs / MS_PER_DAY) : 0;
|
||
}
|
||
|
||
const ratePerDay = Number(rule?.ratePerDay ?? 0);
|
||
const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null;
|
||
const targetCurrency = this.normalizeCurrency(billingCurrency);
|
||
const hasTiers = Boolean(rule?.tiers?.length);
|
||
const tiered = this.calculateTieredAmount(rule?.tiers, chargeableDays, truckCount);
|
||
const billableUnits = hasTiers ? tiered.billableUnits : chargeableDays * truckCount;
|
||
const sourceAmount = hasTiers ? tiered.sourceAmount : Math.round(billableUnits * ratePerDay * 100) / 100;
|
||
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
|
||
const sourceRatePerDay = hasTiers ? tiered.weightedRatePerDay : ratePerDay;
|
||
const convertedRatePerDay = ruleCurrency
|
||
? await this.convertAmount(sourceRatePerDay, ruleCurrency, targetCurrency)
|
||
: 0;
|
||
const convertedTiers = ruleCurrency
|
||
? await Promise.all(
|
||
tiered.tiers.map(async (tier) => ({
|
||
...tier,
|
||
ratePerDay: await this.convertAmount(tier.ratePerDay, ruleCurrency, targetCurrency),
|
||
amount: await this.convertAmount(tier.amount, ruleCurrency, targetCurrency),
|
||
})),
|
||
)
|
||
: [];
|
||
|
||
return {
|
||
ruleType: 'TRUCK_DETENTION_FEE',
|
||
basis: null,
|
||
ruleId: rule?.id ?? null,
|
||
ruleName: rule?.name ?? null,
|
||
freeDays: 0,
|
||
ratePerDay: convertedRatePerDay,
|
||
currency: targetCurrency,
|
||
ruleCurrency,
|
||
billingCurrency: targetCurrency,
|
||
startDate: start ? start.toISOString() : null,
|
||
endDate: end.toISOString(),
|
||
endIsOpen,
|
||
elapsedDays: chargeableDays,
|
||
chargeableDays,
|
||
containerCount: truckCount, // reused as the per-truck count
|
||
billableUnits,
|
||
amount,
|
||
tiers: hasTiers ? convertedTiers : [],
|
||
};
|
||
}
|
||
}
|