Truck detention was timed once per delivery, so every truck on a multi-truck last mile was billed the same number of days regardless of when it actually arrived or was released. Each truck now carries its own detention clock (destination arrival → release) with its own rule match, chargeable days and amount; the modal records and displays the window per truck, and the summary shows the longest detention plus the combined total. Also corrected a false no detention rule matches warning that appeared whenever more than one truck was assigned.

This commit is contained in:
Hagernesh
2026-07-27 08:05:12 +00:00
parent 3e2ae80e5a
commit 500668a415
11 changed files with 512 additions and 86 deletions

View File

@@ -0,0 +1,82 @@
import { WarehouseFeeService } from './warehouse-fee.service';
/**
* Detention is per truck: two trucks on the same delivery with different
* windows must produce different chargeable days and amounts (the old
* leg-level clock billed them identically).
*/
const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
const svc = Object.create(WarehouseFeeService.prototype) as {
computeTruckDetention: (
rule: Record<string, unknown> | null,
row: { arrivedAt: Date | string | null; deliveredAt: Date | string | null; truckCount: number },
now: Date,
billingCurrency: string,
) => Promise<{ chargeableDays: number; billableUnits: number; amount: number; endIsOpen: boolean }>;
normalizeCurrency: (c?: string | null) => string;
convertAmount: (a: number, from: string, to: string) => Promise<number>;
calculateTieredAmount: unknown;
};
svc.normalizeCurrency = (c) => (c ? String(c).toUpperCase() : 'USD');
svc.convertAmount = async (a) => a;
// 3h grace, 50/truck/day, no tiers.
const rule = { freeHours: 3, ratePerDay: 50, currency: 'USD', id: 'r1', name: 'Detention', tiers: [] };
const now = new Date('2026-07-25T12:00:00Z');
describe('per-truck detention', () => {
it('bills each truck on its own window', async () => {
// Truck A: out ~1 day past grace. Truck B: out ~3 days past grace.
const a = await svc.computeTruckDetention(
rule,
{
arrivedAt: new Date(now.getTime() - DAY - 4 * HOUR),
deliveredAt: now,
truckCount: 1,
},
now,
'USD',
);
const b = await svc.computeTruckDetention(
rule,
{
arrivedAt: new Date(now.getTime() - 3 * DAY - 4 * HOUR),
deliveredAt: now,
truckCount: 1,
},
now,
'USD',
);
expect(a.chargeableDays).toBe(2);
expect(b.chargeableDays).toBe(4);
expect(a.amount).toBe(100);
expect(b.amount).toBe(200);
// The whole point: same delivery, different bills.
expect(a.amount).not.toBe(b.amount);
});
it('charges nothing inside the grace window', async () => {
const out = await svc.computeTruckDetention(
rule,
{ arrivedAt: new Date(now.getTime() - 2 * HOUR), deliveredAt: now, truckCount: 1 },
now,
'USD',
);
expect(out.chargeableDays).toBe(0);
expect(out.amount).toBe(0);
});
it('keeps accruing against now when a truck has not returned', async () => {
const out = await svc.computeTruckDetention(
rule,
{ arrivedAt: new Date(now.getTime() - 2 * DAY), deliveredAt: null, truckCount: 1 },
now,
'USD',
);
expect(out.endIsOpen).toBe(true);
expect(out.chargeableDays).toBe(2);
});
});

View File

@@ -37,6 +37,10 @@ describe('WarehouseFeeService bulk quantity billing', () => {
inventoryWeight: 25,
bookingContainerCount: 0,
cargoUnitOfMeasure: null,
// Double handling now bills only when staff answered Yes after unloading;
// these quantity-basis cases assume that answer (the gate itself is covered
// in double-handling-gate.spec.ts).
doubleHandling: true,
facilityId: null,
warehouseId: null,
yardId: null,

View File

@@ -92,10 +92,20 @@ export interface FeePreview {
ratePerDay: number;
amount: number;
}>;
/** Truck detention: per-vehicle-type breakdown — each truck-type group billed by its own matching rule. */
/**
* 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;
@@ -827,25 +837,48 @@ export class WarehouseFeeService {
};
}
// Group the leg's vehicles by CANONICAL truck type so each type is billed
// by its own matching rule (rates differ by truck type). The FK to
// truck_types is the source of truth — renaming a type's label no longer
// silently unmatches its rule; the normalized legacy vehicle_type code is
// only a fallback for vehicles without the FK (LEFT JOIN keeps them billed
// instead of dropping them). Falls back to one untyped group.
const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> =
await this.dataSource.query(
`SELECT COALESCE(t.code, NULLIF(UPPER(TRIM(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
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
GROUP BY 1`,
[lastMileId],
);
const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }];
// 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');
@@ -853,7 +886,7 @@ export class WarehouseFeeService {
const targetCurrency = this.normalizeCurrency(billingCurrency);
const computed = await Promise.all(
groups.map(async (g) => {
trucks.map(async (t) => {
const item: ItemAttributes = {
arrivedAt: null,
gateClearedAt: null,
@@ -862,7 +895,7 @@ export class WarehouseFeeService {
tradeDirection: leg.tradeDirection ?? null,
cargoTypeCode: null,
containerTypeCode: null,
vehicleType: g.vehicleType ?? null,
vehicleType: t.vehicleType ?? null,
inventoryQuantity: 1,
inventoryWeight: 0,
bookingContainerCount: 1,
@@ -875,37 +908,47 @@ export class WarehouseFeeService {
zoneId: null,
};
const rule = this.bestRule(detentionRules, item);
// truckCount 1 — this row IS one truck.
const c = await this.computeTruckDetention(
rule,
{ arrivedAt: leg.arrivedAt, deliveredAt: leg.deliveredAt, truckCount: g.truckCount },
{ arrivedAt: t.startAt, deliveredAt: t.endAt, truckCount: 1 },
now,
billingCurrency,
);
return { vehicleType: g.vehicleType ?? null, truckCount: Math.max(1, Math.round(Number(g.truckCount) || 1)), c };
return { ...t, 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 totalTrucks = computed.length;
const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0);
const chargeableDays = computed[0]?.c.chargeableDays ?? 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-type rules' : anyRuleName,
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: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null,
endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : now).toISOString(),
endIsOpen: !leg.deliveredAt,
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,
@@ -913,8 +956,14 @@ export class WarehouseFeeService {
amount: totalAmount,
tiers: single ? single.tiers : [],
groups: computed.map((x) => ({
assignmentId: x.assignmentId,
vehicleId: x.vehicleId,
plateNumber: x.plateNumber,
vehicleType: x.vehicleType,
truckCount: x.truckCount,
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,