mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 04:20:55 +00:00
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:
@@ -0,0 +1,29 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsDateString, IsOptional, IsUUID, ValidateNested } from 'class-validator';
|
||||
|
||||
/**
|
||||
* One truck's detention window. Each truck reaches the destination and is
|
||||
* released at its own time, so detention days differ between trucks on the
|
||||
* same delivery. Null clears the value (falls back to the leg-level pair).
|
||||
*/
|
||||
export class TruckDetentionTimeInput {
|
||||
@IsUUID()
|
||||
vehicleId!: string;
|
||||
|
||||
/** Detention clock start — this truck reached the destination. */
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
destinationArrivedAt?: string | null;
|
||||
|
||||
/** Detention clock end — this truck was released/returned. Omit = still out. */
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
returnedAt?: string | null;
|
||||
}
|
||||
|
||||
export class SetDetentionTimesDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TruckDetentionTimeInput)
|
||||
trucks!: TruckDetentionTimeInput[];
|
||||
}
|
||||
@@ -51,6 +51,21 @@ export class LastMileVehicleAssignment extends BaseEntity {
|
||||
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
||||
departedAt?: Date | null;
|
||||
|
||||
/**
|
||||
* Detention clock START for THIS truck: reached the delivery destination.
|
||||
* Distinct from `arrivedAt` (warehouse gate-in). Null falls back to the
|
||||
* leg-level `last_mile.arrived_at`.
|
||||
*/
|
||||
@Column({ name: 'destination_arrived_at', type: 'timestamptz', nullable: true })
|
||||
destinationArrivedAt?: Date | null;
|
||||
|
||||
/**
|
||||
* Detention clock END for THIS truck: released / returned by the customer.
|
||||
* Null (with no leg-level `delivered_at`) means still out — detention accrues.
|
||||
*/
|
||||
@Column({ name: 'returned_at', type: 'timestamptz', nullable: true })
|
||||
returnedAt?: Date | null;
|
||||
|
||||
/** Weighed gross on exit, in TONNES (not kg — see the migration note). */
|
||||
@Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
grossWeightTons?: number | null;
|
||||
|
||||
@@ -23,6 +23,7 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||
import { SetVehiclesDto } from './dto/set-vehicles.dto';
|
||||
import { SetDetentionTimesDto } from './dto/set-detention-times.dto';
|
||||
import { SetDistancesDto } from './dto/set-distances.dto';
|
||||
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
|
||||
import { LastMileStatus } from './entities/last-mile.entity';
|
||||
@@ -131,6 +132,18 @@ export class LastMileController {
|
||||
return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment);
|
||||
}
|
||||
|
||||
@Post(':id/detention-times')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.update)
|
||||
@ApiOperation({
|
||||
summary: 'Set each truck\'s own detention window (arrived at destination / returned)',
|
||||
})
|
||||
async setDetentionTimes(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: SetDetentionTimesDto,
|
||||
) {
|
||||
return this.lastMileService.setDetentionTimes(id, dto.trucks);
|
||||
}
|
||||
|
||||
@Post(':id/proof-of-delivery')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.update)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
|
||||
@@ -869,6 +869,46 @@ export class LastMileService {
|
||||
* sum and drives billing; `remainingPayment` (total km × rate) is recomputed
|
||||
* client-side. Does NOT generate an invoice — that's a separate explicit step.
|
||||
*/
|
||||
/**
|
||||
* Per-truck detention windows. Each truck reaches the destination and is
|
||||
* released at its own time, so every truck gets its own clock (and therefore
|
||||
* its own chargeable days). Locked once the detention invoice exists.
|
||||
*/
|
||||
async setDetentionTimes(
|
||||
id: string,
|
||||
trucks: Array<{
|
||||
vehicleId: string;
|
||||
destinationArrivedAt?: string | null;
|
||||
returnedAt?: string | null;
|
||||
}>,
|
||||
): Promise<LastMile> {
|
||||
await this.findById(id);
|
||||
|
||||
const invoices = await this.billing.findBySourceIds('last_mile', [id]);
|
||||
if (invoices.length) {
|
||||
throw new BadRequestException(
|
||||
'Detention times cannot be changed after the invoice is generated',
|
||||
);
|
||||
}
|
||||
|
||||
for (const t of trucks) {
|
||||
const start = t.destinationArrivedAt ? new Date(t.destinationArrivedAt) : null;
|
||||
const end = t.returnedAt ? new Date(t.returnedAt) : null;
|
||||
if (start && end && end.getTime() < start.getTime()) {
|
||||
throw new BadRequestException(
|
||||
'A truck cannot be returned before it arrived — check the detention times',
|
||||
);
|
||||
}
|
||||
await this.dataSource.manager.update(
|
||||
LastMileVehicleAssignment,
|
||||
{ lastMileId: id, vehicleId: t.vehicleId },
|
||||
{ destinationArrivedAt: start, returnedAt: end },
|
||||
);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async setDistances(
|
||||
id: string,
|
||||
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user