mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
1044 lines
42 KiB
TypeScript
1044 lines
42 KiB
TypeScript
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||
import { ExchangeService } from '@edr/api-common';
|
||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||
import { DataSource } from 'typeorm';
|
||
|
||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||
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';
|
||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||
|
||
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;
|
||
/** This inventory row's own net weight (tonnes) — bulk STORAGE/DEMURRAGE for PER_TON cargo bills against this, not the booking-wide total. */
|
||
inventoryWeight: number;
|
||
bookingContainerCount: number;
|
||
/** This item's cargo type unit of measure (PER_TON | PER_ITEM); null defaults to PER_TON. Decides whether bulk day-based fees bill by weight or item count. */
|
||
cargoUnitOfMeasure: string | null;
|
||
/** Booking-level Yes/No recorded after unloading; only true bills double handling (null = undecided). */
|
||
doubleHandling: boolean | null;
|
||
facilityId: string | null;
|
||
warehouseId: string | null;
|
||
yardId: string | null;
|
||
zoneId: string | null;
|
||
}
|
||
|
||
export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
|
||
|
||
export interface AccrualDashboardRow {
|
||
inventoryId: string;
|
||
status: string;
|
||
bookingId: string | null;
|
||
companyId: string | null;
|
||
bookingReference: string | null;
|
||
customerName: string | null;
|
||
warehouseCode: string | null;
|
||
zoneCode: string | null;
|
||
receivedAt: string | null;
|
||
currency: string;
|
||
accruedAmount: number;
|
||
freeDaysLeft: number | null;
|
||
charging: boolean;
|
||
alert: AccrualAlert;
|
||
/** Reviewed by ops — suppressed from alerts (snoozed until snoozeUntil, or indefinitely). */
|
||
acknowledged: boolean;
|
||
snoozeUntil: string | null;
|
||
breakdown: Array<{
|
||
type: FeeRuleType;
|
||
amount: number;
|
||
freeDays: number;
|
||
elapsedDays: number;
|
||
chargeableDays: number;
|
||
}>;
|
||
}
|
||
|
||
export interface FeePreview {
|
||
ruleType: FeeRuleType;
|
||
/** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_ITEM); 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;
|
||
/** What `containerCount`/`billableUnits` are counted in — 'container' | 'truck' | 'ton' | 'item'. Bulk cargo bills by weight (ton) or item count depending on the cargo type's unit of measure. */
|
||
unitLabel: string;
|
||
billableUnits: number;
|
||
amount: number;
|
||
tiers: Array<{
|
||
fromDay: number;
|
||
toDay: number | null;
|
||
appliedFromDay: number;
|
||
appliedToDay: number;
|
||
days: number;
|
||
ratePerDay: number;
|
||
amount: number;
|
||
}>;
|
||
/**
|
||
* Truck detention: one row PER TRUCK — each truck has its own detention
|
||
* window (it arrives and is released at its own time) and its own matching
|
||
* rule by truck type, so days and amount differ between trucks.
|
||
*/
|
||
groups?: Array<{
|
||
assignmentId: string | null;
|
||
vehicleId: string | null;
|
||
plateNumber: string | null;
|
||
vehicleType: string | null;
|
||
truckCount: number;
|
||
startDate: string | null;
|
||
endDate: string | null;
|
||
endIsOpen: boolean;
|
||
chargeableDays: number;
|
||
ratePerDay: number;
|
||
amount: number;
|
||
ruleId: string | null;
|
||
ruleName: string | null;
|
||
}>;
|
||
}
|
||
|
||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||
|
||
@Injectable()
|
||
export class WarehouseFeeService {
|
||
private readonly logger = new Logger(WarehouseFeeService.name);
|
||
|
||
constructor(
|
||
private readonly dataSource: DataSource,
|
||
private readonly feeRuleRepository: WarehouseFeeRuleRepository,
|
||
private readonly exchangeService: ExchangeService,
|
||
private readonly inbox: NotificationInboxService,
|
||
) {}
|
||
|
||
/**
|
||
* Daily accrual alerts: for every in-warehouse item that is charging or within
|
||
* its last free days, send the customer an in-app notification with the
|
||
* outstanding accrued amount so they can collect before (more) charges hit.
|
||
*/
|
||
@Cron(CronExpression.EVERY_DAY_AT_6AM, { name: 'warehouse-accrual-alert' })
|
||
async sendAccrualAlerts(): Promise<void> {
|
||
try {
|
||
const alerts = (await this.accrualDashboard()).filter(
|
||
(r) => r.alert !== 'OK' && !r.acknowledged,
|
||
);
|
||
if (!alerts.length) return;
|
||
this.logger.log(`Accrual alerts: ${alerts.length} item(s) charging or nearing charges`);
|
||
|
||
// Per-customer: notify each company about its own items.
|
||
for (const row of alerts.filter((r) => r.companyId)) {
|
||
const ref = row.bookingReference ?? row.inventoryId.slice(0, 8);
|
||
const amount = `${row.accruedAmount.toFixed(2)} ${row.currency}`;
|
||
const body = row.charging
|
||
? `Storage/demurrage is now charging on booking ${ref} — ${amount} accrued. Collect the cargo to stop further charges.`
|
||
: `Booking ${ref} has ${row.freeDaysLeft ?? 0} free day(s) left before storage/demurrage charges start (${amount} accrued so far).`;
|
||
try {
|
||
await this.inbox.notify({
|
||
recipients: { companyId: row.companyId! },
|
||
audience: NotificationAudience.PORTAL,
|
||
type: NotificationType.BOOKING_STATUS,
|
||
title: row.charging ? 'Storage charges accruing' : 'Free days ending soon',
|
||
body,
|
||
link: row.bookingId ? `/bookings/${row.bookingId}` : undefined,
|
||
data: {
|
||
inventoryId: row.inventoryId,
|
||
bookingId: row.bookingId,
|
||
alert: row.alert,
|
||
accruedAmount: row.accruedAmount,
|
||
action: 'ACCRUAL_ALERT',
|
||
},
|
||
});
|
||
} catch (err) {
|
||
this.logger.warn(
|
||
`Accrual alert failed for ${row.inventoryId}: ${(err as Error).message}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// Ops staff: one digest covering every alerting item.
|
||
const charging = alerts.filter((r) => r.charging).length;
|
||
const nearing = alerts.length - charging;
|
||
const currency = alerts[0]?.currency ?? 'USD';
|
||
const total = alerts.reduce((sum, r) => sum + r.accruedAmount, 0);
|
||
try {
|
||
await this.inbox.notify({
|
||
recipients: {
|
||
permissionKeys: [
|
||
FREIGHT_PERMS.warehouseFeeInvoices.getNotification,
|
||
],
|
||
},
|
||
audience: NotificationAudience.BACKOFFICE,
|
||
type: NotificationType.BOOKING_STATUS,
|
||
title: 'Warehouse fee accruals need attention',
|
||
body: `${charging} item(s) charging, ${nearing} nearing the free-day limit — ${total.toFixed(2)} ${currency} accruing. Review the accrual dashboard.`,
|
||
link: '/dashboard/warehouse-fee-invoices',
|
||
data: { charging, nearing, totalAccrued: Math.round(total * 100) / 100, action: 'ACCRUAL_ALERT_DIGEST' },
|
||
});
|
||
} catch (err) {
|
||
this.logger.warn(`Accrual staff digest failed: ${(err as Error).message}`);
|
||
}
|
||
} catch (err) {
|
||
this.logger.warn(`Accrual alert tick failed: ${(err as Error).message}`);
|
||
}
|
||
}
|
||
|
||
// ── 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.weight AS "inventoryWeight",
|
||
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",
|
||
b.double_handling AS "doubleHandling",
|
||
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(cgt.unit_of_measure, booking_cgt.unit_of_measure) AS "cargoUnitOfMeasure"
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* On-time dispatch rate: the share of items dispatched in the last N days that
|
||
* LEFT before their storage free-days expired — CEIL((dispatched−arrived)/day)
|
||
* <= freeDays, with freeDays resolved by the same rule matching the fee engine
|
||
* uses (bestRule over active STORAGE_FEE rules). onTimePct is null when there
|
||
* is nothing to measure (e.g. no dispatched items / no storage rules).
|
||
*/
|
||
async onTimeDispatchStats(
|
||
windowDays = 90,
|
||
): Promise<{ sampleSize: number; onTimeCount: number; onTimePct: number | null }> {
|
||
const storageRules = (
|
||
await this.feeRuleRepository.findAll({ where: { isActive: true } })
|
||
).filter((r) => r.ruleType === 'STORAGE_FEE');
|
||
|
||
// Batched attribute pull mirroring loadItem's scope joins (multi-row) — only
|
||
// the fields bestRule/matchScore reads, plus the two clock timestamps.
|
||
const rows: Array<
|
||
ItemAttributes & { arrivedAt: string; dispatchedAt: string }
|
||
> = await this.dataSource.query(
|
||
`SELECT inv.arrived_at AS "arrivedAt",
|
||
inv.dispatched_at AS "dispatchedAt",
|
||
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",
|
||
NULL AS "vehicleType"
|
||
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
|
||
WHERE inv.deleted_at IS NULL
|
||
AND inv.arrived_at IS NOT NULL
|
||
AND inv.dispatched_at IS NOT NULL
|
||
AND inv.dispatched_at > now() - ($1 || ' days')::interval`,
|
||
[windowDays],
|
||
);
|
||
|
||
let onTimeCount = 0;
|
||
for (const row of rows) {
|
||
const freeDays = this.bestRule(storageRules, row)?.freeDays ?? 0;
|
||
const elapsed = Math.max(
|
||
0,
|
||
Math.ceil(
|
||
(new Date(row.dispatchedAt).getTime() - new Date(row.arrivedAt).getTime()) / MS_PER_DAY,
|
||
),
|
||
);
|
||
if (elapsed <= freeDays) onTimeCount += 1;
|
||
}
|
||
const sampleSize = rows.length;
|
||
return {
|
||
sampleSize,
|
||
onTimeCount,
|
||
onTimePct: sampleSize ? Math.round((onTimeCount / sampleSize) * 100) : null,
|
||
};
|
||
}
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Bulk's own billing quantity for THIS inventory row — weight (tons) for
|
||
* PER_TON cargo, unit count for PER_ITEM cargo (Machinery, Truck, Automobile,
|
||
* Livestock…). Shared by every cargo-scoped fee type (storage, demurrage,
|
||
* double handling) so a booking split across several rows is never billed
|
||
* more than once against its full total. 0 is a legitimate charge (nothing
|
||
* weighed/counted yet), so no forced floor.
|
||
*/
|
||
private resolveBulkQuantity(item: ItemAttributes): { quantity: number; unitLabel: string } {
|
||
const cargoUnit = (item.cargoUnitOfMeasure ?? 'PER_TON').toUpperCase();
|
||
if (cargoUnit === 'PER_ITEM') {
|
||
return { quantity: Math.max(0, Number(item.inventoryQuantity) || 0), unitLabel: 'item' };
|
||
}
|
||
return { quantity: Math.max(0, Number(item.inventoryWeight) || 0), unitLabel: 'ton' };
|
||
}
|
||
|
||
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 bulk = this.resolveBulkQuantity(item);
|
||
const containerCount = isContainer
|
||
? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity))
|
||
: bulk.quantity;
|
||
const unitLabel = isContainer ? 'container' : bulk.unitLabel;
|
||
|
||
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,
|
||
unitLabel,
|
||
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 THIS row's own
|
||
// weight/count — never the whole booking's total. previewForInventory()
|
||
// computes double handling once per inventory row, so a booking-wide total
|
||
// would double- (or triple-) bill a booking split across several rows.
|
||
const bulk = this.resolveBulkQuantity(item);
|
||
// Double handling applies to IMPORT only — no charge for export/domestic —
|
||
// AND only when warehouse staff recorded that the goods were actually
|
||
// re-handled (booking flag = Yes after unloading). Undecided (null) or No
|
||
// means no charge, so the rule can exist without billing every import.
|
||
const isImport = (item.tradeDirection ?? '').toUpperCase() === 'IMPORT';
|
||
const applies = isImport && item.doubleHandling === true;
|
||
const quantity = !applies ? 0 : basis === 'PER_CONTAINER' ? containerCount : bulk.quantity;
|
||
const unitLabel = basis === 'PER_CONTAINER' ? 'container' : bulk.unitLabel;
|
||
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,
|
||
unitLabel,
|
||
billableUnits: quantity,
|
||
amount,
|
||
tiers: [],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Live accrual dashboard: for every item still in the warehouse, the fees
|
||
* accruing right now (storage + demurrage + double-handling), how many free
|
||
* days remain, and an alert level so staff can act before charges land.
|
||
*/
|
||
async accrualDashboard(billingCurrency = 'USD'): Promise<AccrualDashboardRow[]> {
|
||
const items: Array<{
|
||
id: string;
|
||
status: string;
|
||
bookingId: string | null;
|
||
companyId: string | null;
|
||
bookingReference: string | null;
|
||
customerName: string | null;
|
||
warehouseCode: string | null;
|
||
zoneCode: string | null;
|
||
receivedAt: string | null;
|
||
}> = await this.dataSource.query(
|
||
`SELECT inv.id,
|
||
inv.status,
|
||
b.id AS "bookingId",
|
||
b.company_id AS "companyId",
|
||
b.reference AS "bookingReference",
|
||
c.name AS "customerName",
|
||
w.code AS "warehouseCode",
|
||
z.code AS "zoneCode",
|
||
inv.created_at AS "receivedAt"
|
||
FROM freight.warehouse_inventory inv
|
||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||
LEFT JOIN freight.companies c ON c.id = b.company_id
|
||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||
LEFT JOIN freight.warehouse_zones z ON z.id = inv.zone_id
|
||
WHERE inv.deleted_at IS NULL
|
||
AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED')
|
||
ORDER BY inv.created_at ASC`,
|
||
);
|
||
|
||
const ackRows: Array<{ inventoryId: string; snoozeUntil: string | null }> =
|
||
await this.dataSource.query(
|
||
`SELECT inventory_id AS "inventoryId", snooze_until AS "snoozeUntil"
|
||
FROM freight.warehouse_accrual_acks`,
|
||
);
|
||
const now = new Date();
|
||
const acks = new Map(ackRows.map((a) => [a.inventoryId, a.snoozeUntil]));
|
||
|
||
const rows = await Promise.all(
|
||
items.map(async (it): Promise<AccrualDashboardRow> => {
|
||
const previews = (await this.previewForInventory(it.id, billingCurrency)).filter(
|
||
(p) => p.ruleId,
|
||
);
|
||
const accruedAmount =
|
||
Math.round(previews.reduce((sum, p) => sum + (p.amount ?? 0), 0) * 100) / 100;
|
||
const charging = previews.some((p) => p.chargeableDays > 0);
|
||
const freeDaysLeftVals = previews
|
||
.filter((p) => p.endIsOpen)
|
||
.map((p) => Math.max(0, p.freeDays - p.elapsedDays));
|
||
const freeDaysLeft = freeDaysLeftVals.length ? Math.min(...freeDaysLeftVals) : null;
|
||
const alert: AccrualAlert = charging
|
||
? 'CHARGING'
|
||
: freeDaysLeft != null && freeDaysLeft <= 2
|
||
? 'WARNING'
|
||
: 'OK';
|
||
return {
|
||
inventoryId: it.id,
|
||
status: it.status,
|
||
bookingId: it.bookingId,
|
||
companyId: it.companyId,
|
||
bookingReference: it.bookingReference,
|
||
customerName: it.customerName,
|
||
warehouseCode: it.warehouseCode,
|
||
zoneCode: it.zoneCode,
|
||
receivedAt: it.receivedAt,
|
||
currency: billingCurrency,
|
||
accruedAmount,
|
||
freeDaysLeft,
|
||
charging,
|
||
alert,
|
||
acknowledged:
|
||
acks.has(it.id) &&
|
||
(acks.get(it.id) == null || new Date(acks.get(it.id) as string) > now),
|
||
snoozeUntil: acks.get(it.id) ?? null,
|
||
breakdown: previews.map((p) => ({
|
||
type: p.ruleType,
|
||
amount: p.amount,
|
||
freeDays: p.freeDays,
|
||
elapsedDays: p.elapsedDays,
|
||
chargeableDays: p.chargeableDays,
|
||
})),
|
||
};
|
||
}),
|
||
);
|
||
|
||
const rank = (a: AccrualAlert) => (a === 'CHARGING' ? 0 : a === 'WARNING' ? 1 : 2);
|
||
// Acknowledged items sink to the bottom; among the rest, worst alert first.
|
||
return rows.sort(
|
||
(a, b) =>
|
||
Number(a.acknowledged) - Number(b.acknowledged) ||
|
||
rank(a.alert) - rank(b.alert) ||
|
||
b.accruedAmount - a.accruedAmount,
|
||
);
|
||
}
|
||
|
||
/** Mark an item's accrual reviewed. `snoozeDays` > 0 suppresses alerts until then; omitted = indefinitely. */
|
||
async acknowledgeAccrual(
|
||
inventoryId: string,
|
||
opts: { snoozeDays?: number; note?: string; userId?: string } = {},
|
||
): Promise<void> {
|
||
const snoozeUntil =
|
||
opts.snoozeDays && opts.snoozeDays > 0
|
||
? new Date(Date.now() + opts.snoozeDays * 24 * 60 * 60 * 1000)
|
||
: null;
|
||
await this.dataSource.query(
|
||
`INSERT INTO freight.warehouse_accrual_acks
|
||
(inventory_id, acknowledged_by, acknowledged_at, snooze_until, note, updated_at)
|
||
VALUES ($1, $2, now(), $3, $4, now())
|
||
ON CONFLICT (inventory_id) DO UPDATE
|
||
SET acknowledged_by = EXCLUDED.acknowledged_by,
|
||
acknowledged_at = now(),
|
||
snooze_until = EXCLUDED.snooze_until,
|
||
note = EXCLUDED.note,
|
||
updated_at = now()`,
|
||
[inventoryId, opts.userId ?? null, snoozeUntil, opts.note?.trim() || null],
|
||
);
|
||
}
|
||
|
||
/** Remove an acknowledgement so the item re-surfaces for alerts. */
|
||
async unacknowledgeAccrual(inventoryId: string): Promise<void> {
|
||
await this.dataSource.query(
|
||
`DELETE FROM freight.warehouse_accrual_acks WHERE inventory_id = $1`,
|
||
[inventoryId],
|
||
);
|
||
}
|
||
|
||
/** 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,
|
||
unitLabel: 'truck',
|
||
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: [],
|
||
};
|
||
}
|
||
|
||
// One row PER TRUCK: each truck has its own detention window (it reaches the
|
||
// destination and is released at its own time) and resolves its own rule by
|
||
// CANONICAL truck type — the truck_types FK is the source of truth, with the
|
||
// normalized legacy vehicle_type code as fallback so FK-less vehicles keep
|
||
// billing. Per-truck timestamps fall back to the leg-level pair for legacy
|
||
// legs recorded before per-truck tracking.
|
||
const truckRows: Array<{
|
||
assignmentId: string;
|
||
vehicleId: string;
|
||
plateNumber: string | null;
|
||
vehicleType: string | null;
|
||
startAt: Date | string | null;
|
||
endAt: Date | string | null;
|
||
}> = await this.dataSource.query(
|
||
`SELECT va.id AS "assignmentId",
|
||
va.vehicle_id AS "vehicleId",
|
||
COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber",
|
||
COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType",
|
||
COALESCE(va.destination_arrived_at, $2::timestamptz) AS "startAt",
|
||
COALESCE(va.returned_at, $3::timestamptz) AS "endAt"
|
||
FROM freight.last_mile_vehicle_assignments va
|
||
JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL
|
||
LEFT JOIN freight.truck_types t
|
||
ON t.id = v.truck_type_id AND t.deleted_at IS NULL
|
||
WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL
|
||
ORDER BY va.created_at ASC`,
|
||
[lastMileId, leg.arrivedAt ?? null, leg.deliveredAt ?? null],
|
||
);
|
||
// No trucks assigned yet: keep the leg-level single-truck estimate so the
|
||
// preview still tells the operator what detention would cost.
|
||
const trucks = truckRows.length
|
||
? truckRows
|
||
: [
|
||
{
|
||
assignmentId: null as string | null,
|
||
vehicleId: null as string | null,
|
||
plateNumber: null as string | null,
|
||
vehicleType: null as string | null,
|
||
startAt: leg.arrivedAt ?? null,
|
||
endAt: leg.deliveredAt ?? null,
|
||
},
|
||
];
|
||
|
||
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(
|
||
trucks.map(async (t) => {
|
||
const item: ItemAttributes = {
|
||
arrivedAt: null,
|
||
gateClearedAt: null,
|
||
releaseDate: null,
|
||
freightType: leg.freightType ?? null,
|
||
tradeDirection: leg.tradeDirection ?? null,
|
||
cargoTypeCode: null,
|
||
containerTypeCode: null,
|
||
vehicleType: t.vehicleType ?? null,
|
||
inventoryQuantity: 1,
|
||
inventoryWeight: 0,
|
||
bookingContainerCount: 1,
|
||
cargoUnitOfMeasure: null,
|
||
// Irrelevant to detention (truck-time based, never double handling).
|
||
doubleHandling: null,
|
||
facilityId: null,
|
||
warehouseId: null,
|
||
yardId: null,
|
||
zoneId: null,
|
||
};
|
||
const rule = this.bestRule(detentionRules, item);
|
||
// truckCount 1 — this row IS one truck.
|
||
const c = await this.computeTruckDetention(
|
||
rule,
|
||
{ arrivedAt: t.startAt, deliveredAt: t.endAt, truckCount: 1 },
|
||
now,
|
||
billingCurrency,
|
||
);
|
||
return { ...t, c };
|
||
}),
|
||
);
|
||
|
||
const totalAmount = Math.round(computed.reduce((s, x) => s + x.c.amount, 0) * 100) / 100;
|
||
const totalTrucks = computed.length;
|
||
const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0);
|
||
// Header days: the worst truck — a single number can't represent per-truck
|
||
// windows, and the longest detention is the one operations must act on.
|
||
const chargeableDays = computed.reduce((m, x) => Math.max(m, x.c.chargeableDays), 0);
|
||
const single = computed.length === 1 ? computed[0].c : null;
|
||
const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? null;
|
||
const earliestStart = computed
|
||
.map((x) => (x.startAt ? new Date(x.startAt).getTime() : null))
|
||
.filter((n): n is number => n != null)
|
||
.sort((a, b) => a - b)[0];
|
||
const anyOpen = computed.some((x) => x.c.endIsOpen);
|
||
|
||
return {
|
||
ruleType: 'TRUCK_DETENTION_FEE',
|
||
basis: null,
|
||
unitLabel: 'truck',
|
||
ruleId: single?.ruleId ?? null,
|
||
ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck rules' : anyRuleName,
|
||
freeDays: 0,
|
||
ratePerDay: single?.ratePerDay ?? 0,
|
||
currency: targetCurrency,
|
||
ruleCurrency: single?.ruleCurrency ?? null,
|
||
billingCurrency: targetCurrency,
|
||
startDate: earliestStart != null ? new Date(earliestStart).toISOString() : null,
|
||
endDate: (anyOpen ? now : new Date(Math.max(
|
||
...computed.map((x) => (x.endAt ? new Date(x.endAt).getTime() : now.getTime())),
|
||
))).toISOString(),
|
||
endIsOpen: anyOpen,
|
||
elapsedDays: chargeableDays,
|
||
chargeableDays,
|
||
containerCount: totalTrucks,
|
||
billableUnits: totalBillable,
|
||
amount: totalAmount,
|
||
tiers: single ? single.tiers : [],
|
||
groups: computed.map((x) => ({
|
||
assignmentId: x.assignmentId,
|
||
vehicleId: x.vehicleId,
|
||
plateNumber: x.plateNumber,
|
||
vehicleType: x.vehicleType,
|
||
truckCount: 1,
|
||
startDate: x.startAt ? new Date(x.startAt).toISOString() : null,
|
||
endDate: x.c.endDate,
|
||
endIsOpen: x.c.endIsOpen,
|
||
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,
|
||
unitLabel: 'truck',
|
||
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 : [],
|
||
};
|
||
}
|
||
}
|