mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 22:25:42 +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,35 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-truck detention clocks. Detention was timed once per last-mile leg
|
||||||
|
* (last_mile.arrived_at / delivered_at), so every truck on a multi-truck
|
||||||
|
* delivery shared one window and was billed identical days — wrong the moment
|
||||||
|
* two trucks arrive or return at different times.
|
||||||
|
*
|
||||||
|
* Deliberately NEW columns rather than reusing the existing per-truck
|
||||||
|
* arrived_at / departed_at on this table: those are WAREHOUSE gate-in/gate-out
|
||||||
|
* events stamped by release(), whereas detention runs from arrival at the
|
||||||
|
* DESTINATION until the truck is released/returned.
|
||||||
|
*
|
||||||
|
* Both nullable — a truck without its own window falls back to the leg-level
|
||||||
|
* timestamps, so legacy legs keep billing exactly as before.
|
||||||
|
*/
|
||||||
|
export class AddPerTruckDetentionWindow2860000000000 implements MigrationInterface {
|
||||||
|
name = 'AddPerTruckDetentionWindow2860000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||||
|
ADD COLUMN IF NOT EXISTS destination_arrived_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS returned_at timestamptz;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||||
|
DROP COLUMN IF EXISTS returned_at,
|
||||||
|
DROP COLUMN IF EXISTS destination_arrived_at;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
||||||
departedAt?: Date | null;
|
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). */
|
/** 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 })
|
@Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||||
grossWeightTons?: number | null;
|
grossWeightTons?: number | null;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
|||||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||||
import { SetVehiclesDto } from './dto/set-vehicles.dto';
|
import { SetVehiclesDto } from './dto/set-vehicles.dto';
|
||||||
|
import { SetDetentionTimesDto } from './dto/set-detention-times.dto';
|
||||||
import { SetDistancesDto } from './dto/set-distances.dto';
|
import { SetDistancesDto } from './dto/set-distances.dto';
|
||||||
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
|
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
|
||||||
import { LastMileStatus } from './entities/last-mile.entity';
|
import { LastMileStatus } from './entities/last-mile.entity';
|
||||||
@@ -131,6 +132,18 @@ export class LastMileController {
|
|||||||
return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment);
|
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')
|
@Post(':id/proof-of-delivery')
|
||||||
@BookingStaff(FREIGHT_PERMS.lastMile.update)
|
@BookingStaff(FREIGHT_PERMS.lastMile.update)
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
|||||||
@@ -869,6 +869,46 @@ export class LastMileService {
|
|||||||
* sum and drives billing; `remainingPayment` (total km × rate) is recomputed
|
* sum and drives billing; `remainingPayment` (total km × rate) is recomputed
|
||||||
* client-side. Does NOT generate an invoice — that's a separate explicit step.
|
* 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(
|
async setDistances(
|
||||||
id: string,
|
id: string,
|
||||||
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
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,
|
inventoryWeight: 25,
|
||||||
bookingContainerCount: 0,
|
bookingContainerCount: 0,
|
||||||
cargoUnitOfMeasure: null,
|
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,
|
facilityId: null,
|
||||||
warehouseId: null,
|
warehouseId: null,
|
||||||
yardId: null,
|
yardId: null,
|
||||||
|
|||||||
@@ -92,10 +92,20 @@ export interface FeePreview {
|
|||||||
ratePerDay: number;
|
ratePerDay: number;
|
||||||
amount: 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<{
|
groups?: Array<{
|
||||||
|
assignmentId: string | null;
|
||||||
|
vehicleId: string | null;
|
||||||
|
plateNumber: string | null;
|
||||||
vehicleType: string | null;
|
vehicleType: string | null;
|
||||||
truckCount: number;
|
truckCount: number;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
endIsOpen: boolean;
|
||||||
chargeableDays: number;
|
chargeableDays: number;
|
||||||
ratePerDay: number;
|
ratePerDay: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -827,25 +837,48 @@ export class WarehouseFeeService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group the leg's vehicles by CANONICAL truck type so each type is billed
|
// One row PER TRUCK: each truck has its own detention window (it reaches the
|
||||||
// by its own matching rule (rates differ by truck type). The FK to
|
// destination and is released at its own time) and resolves its own rule by
|
||||||
// truck_types is the source of truth — renaming a type's label no longer
|
// CANONICAL truck type — the truck_types FK is the source of truth, with the
|
||||||
// silently unmatches its rule; the normalized legacy vehicle_type code is
|
// normalized legacy vehicle_type code as fallback so FK-less vehicles keep
|
||||||
// only a fallback for vehicles without the FK (LEFT JOIN keeps them billed
|
// billing. Per-truck timestamps fall back to the leg-level pair for legacy
|
||||||
// instead of dropping them). Falls back to one untyped group.
|
// legs recorded before per-truck tracking.
|
||||||
const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> =
|
const truckRows: Array<{
|
||||||
await this.dataSource.query(
|
assignmentId: string;
|
||||||
`SELECT COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType",
|
vehicleId: string;
|
||||||
count(*)::int AS "truckCount"
|
plateNumber: string | null;
|
||||||
FROM freight.last_mile_vehicle_assignments va
|
vehicleType: string | null;
|
||||||
JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL
|
startAt: Date | string | null;
|
||||||
LEFT JOIN freight.truck_types t
|
endAt: Date | string | null;
|
||||||
ON t.id = v.truck_type_id AND t.deleted_at IS NULL
|
}> = await this.dataSource.query(
|
||||||
WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL
|
`SELECT va.id AS "assignmentId",
|
||||||
GROUP BY 1`,
|
va.vehicle_id AS "vehicleId",
|
||||||
[lastMileId],
|
COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber",
|
||||||
);
|
COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType",
|
||||||
const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }];
|
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 rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
||||||
const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE');
|
const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE');
|
||||||
@@ -853,7 +886,7 @@ export class WarehouseFeeService {
|
|||||||
const targetCurrency = this.normalizeCurrency(billingCurrency);
|
const targetCurrency = this.normalizeCurrency(billingCurrency);
|
||||||
|
|
||||||
const computed = await Promise.all(
|
const computed = await Promise.all(
|
||||||
groups.map(async (g) => {
|
trucks.map(async (t) => {
|
||||||
const item: ItemAttributes = {
|
const item: ItemAttributes = {
|
||||||
arrivedAt: null,
|
arrivedAt: null,
|
||||||
gateClearedAt: null,
|
gateClearedAt: null,
|
||||||
@@ -862,7 +895,7 @@ export class WarehouseFeeService {
|
|||||||
tradeDirection: leg.tradeDirection ?? null,
|
tradeDirection: leg.tradeDirection ?? null,
|
||||||
cargoTypeCode: null,
|
cargoTypeCode: null,
|
||||||
containerTypeCode: null,
|
containerTypeCode: null,
|
||||||
vehicleType: g.vehicleType ?? null,
|
vehicleType: t.vehicleType ?? null,
|
||||||
inventoryQuantity: 1,
|
inventoryQuantity: 1,
|
||||||
inventoryWeight: 0,
|
inventoryWeight: 0,
|
||||||
bookingContainerCount: 1,
|
bookingContainerCount: 1,
|
||||||
@@ -875,37 +908,47 @@ export class WarehouseFeeService {
|
|||||||
zoneId: null,
|
zoneId: null,
|
||||||
};
|
};
|
||||||
const rule = this.bestRule(detentionRules, item);
|
const rule = this.bestRule(detentionRules, item);
|
||||||
|
// truckCount 1 — this row IS one truck.
|
||||||
const c = await this.computeTruckDetention(
|
const c = await this.computeTruckDetention(
|
||||||
rule,
|
rule,
|
||||||
{ arrivedAt: leg.arrivedAt, deliveredAt: leg.deliveredAt, truckCount: g.truckCount },
|
{ arrivedAt: t.startAt, deliveredAt: t.endAt, truckCount: 1 },
|
||||||
now,
|
now,
|
||||||
billingCurrency,
|
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 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 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 single = computed.length === 1 ? computed[0].c : null;
|
||||||
const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? 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 {
|
return {
|
||||||
ruleType: 'TRUCK_DETENTION_FEE',
|
ruleType: 'TRUCK_DETENTION_FEE',
|
||||||
basis: null,
|
basis: null,
|
||||||
unitLabel: 'truck',
|
unitLabel: 'truck',
|
||||||
ruleId: single?.ruleId ?? null,
|
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,
|
freeDays: 0,
|
||||||
ratePerDay: single?.ratePerDay ?? 0,
|
ratePerDay: single?.ratePerDay ?? 0,
|
||||||
currency: targetCurrency,
|
currency: targetCurrency,
|
||||||
ruleCurrency: single?.ruleCurrency ?? null,
|
ruleCurrency: single?.ruleCurrency ?? null,
|
||||||
billingCurrency: targetCurrency,
|
billingCurrency: targetCurrency,
|
||||||
startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null,
|
startDate: earliestStart != null ? new Date(earliestStart).toISOString() : null,
|
||||||
endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : now).toISOString(),
|
endDate: (anyOpen ? now : new Date(Math.max(
|
||||||
endIsOpen: !leg.deliveredAt,
|
...computed.map((x) => (x.endAt ? new Date(x.endAt).getTime() : now.getTime())),
|
||||||
|
))).toISOString(),
|
||||||
|
endIsOpen: anyOpen,
|
||||||
elapsedDays: chargeableDays,
|
elapsedDays: chargeableDays,
|
||||||
chargeableDays,
|
chargeableDays,
|
||||||
containerCount: totalTrucks,
|
containerCount: totalTrucks,
|
||||||
@@ -913,8 +956,14 @@ export class WarehouseFeeService {
|
|||||||
amount: totalAmount,
|
amount: totalAmount,
|
||||||
tiers: single ? single.tiers : [],
|
tiers: single ? single.tiers : [],
|
||||||
groups: computed.map((x) => ({
|
groups: computed.map((x) => ({
|
||||||
|
assignmentId: x.assignmentId,
|
||||||
|
vehicleId: x.vehicleId,
|
||||||
|
plateNumber: x.plateNumber,
|
||||||
vehicleType: x.vehicleType,
|
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,
|
chargeableDays: x.c.chargeableDays,
|
||||||
ratePerDay: x.c.ratePerDay,
|
ratePerDay: x.c.ratePerDay,
|
||||||
amount: x.c.amount,
|
amount: x.c.amount,
|
||||||
|
|||||||
@@ -43,21 +43,56 @@ function Stat({ label, value, strong }: { label: string; value: React.ReactNode;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TruckRow = {
|
||||||
|
vehicleId: string;
|
||||||
|
label: string;
|
||||||
|
arrived: Date | null;
|
||||||
|
returned: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const plateOf = (a: NonNullable<LastMileRecord['vehicleAssignments']>[number]) =>
|
||||||
|
[a.vehicle?.code, a.vehicle?.plateNumber].filter(Boolean).join(' · ') || a.vehicleId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* View/override the detention clock (arrival + delivery/return) for a last-mile
|
* Detention is PER TRUCK: every truck reaches the destination and is released at
|
||||||
* leg, preview the per-truck-per-day charge, and generate the detention invoice.
|
* its own time, so each row carries its own clock, days and amount. Legs with no
|
||||||
|
* trucks assigned fall back to the single leg-level window.
|
||||||
*/
|
*/
|
||||||
export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionModalProps) {
|
export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionModalProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const id = record?.id ?? null;
|
const id = record?.id ?? null;
|
||||||
|
const assignments = record?.vehicleAssignments ?? [];
|
||||||
|
const perTruck = assignments.length > 0;
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<TruckRow[]>([]);
|
||||||
|
// Leg-level fallback (no trucks assigned yet).
|
||||||
const [arrived, setArrived] = useState<Date | null>(null);
|
const [arrived, setArrived] = useState<Date | null>(null);
|
||||||
const [delivered, setDelivered] = useState<Date | null>(null);
|
const [delivered, setDelivered] = useState<Date | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setRows(
|
||||||
|
assignments.map((a) => ({
|
||||||
|
vehicleId: a.vehicleId,
|
||||||
|
label: plateOf(a),
|
||||||
|
// Fall back to the leg-level pair so a truck without its own window
|
||||||
|
// shows what it is actually being billed on today.
|
||||||
|
arrived: a.destinationArrivedAt
|
||||||
|
? new Date(a.destinationArrivedAt)
|
||||||
|
: record?.arrivedAt
|
||||||
|
? new Date(record.arrivedAt)
|
||||||
|
: null,
|
||||||
|
returned: a.returnedAt
|
||||||
|
? new Date(a.returnedAt)
|
||||||
|
: record?.deliveredAt
|
||||||
|
? new Date(record.deliveredAt)
|
||||||
|
: null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
setArrived(record?.arrivedAt ? new Date(record.arrivedAt) : null);
|
setArrived(record?.arrivedAt ? new Date(record.arrivedAt) : null);
|
||||||
setDelivered(record?.deliveredAt ? new Date(record.deliveredAt) : null);
|
setDelivered(record?.deliveredAt ? new Date(record.deliveredAt) : null);
|
||||||
}, [record?.id, record?.arrivedAt, record?.deliveredAt, opened]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [record?.id, record?.arrivedAt, record?.deliveredAt, assignments.length, opened]);
|
||||||
|
|
||||||
const previewQuery = useQuery({
|
const previewQuery = useQuery({
|
||||||
queryKey: ['truck-detention-preview', id],
|
queryKey: ['truck-detention-preview', id],
|
||||||
@@ -65,19 +100,35 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
|||||||
enabled: opened && Boolean(id),
|
enabled: opened && Boolean(id),
|
||||||
});
|
});
|
||||||
const preview = previewQuery.data;
|
const preview = previewQuery.data;
|
||||||
|
// With several trucks the header rule is null by design (each truck resolves
|
||||||
|
// its own) — only warn when NO truck matched a rule.
|
||||||
|
const hasAnyRule = Boolean(preview?.ruleId) || (preview?.groups ?? []).some((g) => g.ruleId);
|
||||||
|
const byVehicle = new Map((preview?.groups ?? []).map((g) => [g.vehicleId ?? '', g]));
|
||||||
|
|
||||||
const saveTimes = useMutation({
|
const saveTimes = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
lastMileService.update(id as string, {
|
perTruck
|
||||||
arrivedAt: arrived ? arrived.toISOString() : null,
|
? lastMileService.setDetentionTimes(
|
||||||
deliveredAt: delivered ? delivered.toISOString() : null,
|
id as string,
|
||||||
}),
|
rows.map((r) => ({
|
||||||
|
vehicleId: r.vehicleId,
|
||||||
|
destinationArrivedAt: r.arrived ? r.arrived.toISOString() : null,
|
||||||
|
returnedAt: r.returned ? r.returned.toISOString() : null,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
: lastMileService.update(id as string, {
|
||||||
|
arrivedAt: arrived ? arrived.toISOString() : null,
|
||||||
|
deliveredAt: delivered ? delivered.toISOString() : null,
|
||||||
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
|
||||||
void previewQuery.refetch();
|
void previewQuery.refetch();
|
||||||
toast({ title: 'Detention times saved' });
|
toast({ title: 'Detention times saved' });
|
||||||
},
|
},
|
||||||
onError: () => toast({ title: 'Save failed', variant: 'destructive' }),
|
onError: (e: unknown) => {
|
||||||
|
const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast({ title: 'Save failed', description, variant: 'destructive' });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const generate = useMutation({
|
const generate = useMutation({
|
||||||
@@ -93,12 +144,40 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const values = perTruck
|
||||||
|
? rows.flatMap((r) => [r.arrived, r.returned])
|
||||||
|
: [arrived, delivered];
|
||||||
|
// No backdating: detention times are recorded as they happen.
|
||||||
|
if (values.some((v) => isBackdated(v))) {
|
||||||
|
toast({ variant: 'destructive', title: 'Detention times cannot be in the past' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reversed = perTruck
|
||||||
|
? rows.find((r) => r.arrived && r.returned && r.returned < r.arrived)
|
||||||
|
: arrived && delivered && delivered < arrived
|
||||||
|
? { label: 'this delivery' }
|
||||||
|
: undefined;
|
||||||
|
if (reversed) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: 'Return time is before arrival',
|
||||||
|
description: `Check the times for ${reversed.label}.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveTimes.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchRow = (vehicleId: string, patch: Partial<TruckRow>) =>
|
||||||
|
setRows((prev) => prev.map((r) => (r.vehicleId === vehicleId ? { ...r, ...patch } : r)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
opened={opened}
|
opened={opened}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
centered
|
centered
|
||||||
size="lg"
|
size="xl"
|
||||||
title={
|
title={
|
||||||
<Text fw={700}>
|
<Text fw={700}>
|
||||||
Truck detention{record?.booking?.reference ? ` · ${record.booking.reference}` : ''}
|
Truck detention{record?.booking?.reference ? ` · ${record.booking.reference}` : ''}
|
||||||
@@ -106,40 +185,94 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Group grow align="flex-start">
|
{perTruck ? (
|
||||||
<DateTimePicker
|
<Stack gap="xs">
|
||||||
label="Arrived at"
|
<Text size="sm" c="dimmed">
|
||||||
description="Detention clock start"
|
Each truck has its own detention clock — record when it reached the destination and
|
||||||
value={arrived}
|
when it was released. Days and charges are calculated per truck.
|
||||||
onChange={(v) => setArrived(v ? new Date(v) : null)}
|
</Text>
|
||||||
minDate={new Date()}
|
{rows.map((r) => {
|
||||||
clearable
|
const g = byVehicle.get(r.vehicleId);
|
||||||
/>
|
return (
|
||||||
<DateTimePicker
|
<Paper key={r.vehicleId} withBorder p="sm" radius="md">
|
||||||
label="Delivered / returned at"
|
<Group justify="space-between" mb={6} wrap="nowrap">
|
||||||
description="Clock end (blank = still out)"
|
<Group gap={8}>
|
||||||
value={delivered}
|
<Text size="sm" fw={600}>
|
||||||
onChange={(v) => setDelivered(v ? new Date(v) : null)}
|
{r.label}
|
||||||
minDate={new Date()}
|
</Text>
|
||||||
clearable
|
{g?.vehicleType && (
|
||||||
/>
|
<Badge size="xs" variant="light" color="gray">
|
||||||
</Group>
|
{g.vehicleType}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
{g && (
|
||||||
|
<Group gap={10} wrap="nowrap">
|
||||||
|
<Text size="xs" c={g.endIsOpen ? 'orange' : 'dimmed'}>
|
||||||
|
{g.chargeableDays} day{g.chargeableDays === 1 ? '' : 's'}
|
||||||
|
{g.endIsOpen ? ' · still out' : ''}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={700}>
|
||||||
|
{money(g.amount, preview?.currency ?? 'USD')}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Group grow align="flex-start">
|
||||||
|
<DateTimePicker
|
||||||
|
label="Arrived at destination"
|
||||||
|
description="Detention clock start"
|
||||||
|
value={r.arrived}
|
||||||
|
onChange={(v) => patchRow(r.vehicleId, { arrived: v ? new Date(v) : null })}
|
||||||
|
minDate={new Date()}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<DateTimePicker
|
||||||
|
label="Released / returned at"
|
||||||
|
description="Clock end (blank = still out)"
|
||||||
|
value={r.returned}
|
||||||
|
onChange={(v) => patchRow(r.vehicleId, { returned: v ? new Date(v) : null })}
|
||||||
|
minDate={new Date()}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
{g && !g.ruleId && (
|
||||||
|
<Text size="xs" c="red" mt={4}>
|
||||||
|
No detention rule matches this truck type — it will not be billed.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No trucks assigned yet — this records the delivery-level detention window. Assign
|
||||||
|
trucks to track each one separately.
|
||||||
|
</Text>
|
||||||
|
<Group grow align="flex-start">
|
||||||
|
<DateTimePicker
|
||||||
|
label="Arrived at"
|
||||||
|
description="Detention clock start"
|
||||||
|
value={arrived}
|
||||||
|
onChange={(v) => setArrived(v ? new Date(v) : null)}
|
||||||
|
minDate={new Date()}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<DateTimePicker
|
||||||
|
label="Delivered / returned at"
|
||||||
|
description="Clock end (blank = still out)"
|
||||||
|
value={delivered}
|
||||||
|
onChange={(v) => setDelivered(v ? new Date(v) : null)}
|
||||||
|
minDate={new Date()}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button
|
<Button variant="light" loading={saveTimes.isPending} onClick={handleSave}>
|
||||||
variant="light"
|
|
||||||
loading={saveTimes.isPending}
|
|
||||||
onClick={() => {
|
|
||||||
// No backdating: detention times are recorded as they happen.
|
|
||||||
if (isBackdated(arrived) || isBackdated(delivered)) {
|
|
||||||
toast({
|
|
||||||
variant: 'destructive',
|
|
||||||
title: 'Detention times cannot be in the past',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
saveTimes.mutate();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save times
|
Save times
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -154,7 +287,7 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
|||||||
<Alert color="gray" variant="light">
|
<Alert color="gray" variant="light">
|
||||||
No preview available.
|
No preview available.
|
||||||
</Alert>
|
</Alert>
|
||||||
) : !preview.ruleId ? (
|
) : !hasAnyRule ? (
|
||||||
<Alert color="orange" variant="light">
|
<Alert color="orange" variant="light">
|
||||||
No active Truck Detention rule matches this booking. Create one under Warehouse → Fee rules
|
No active Truck Detention rule matches this booking. Create one under Warehouse → Fee rules
|
||||||
(rule type "Truck Detention Cost").
|
(rule type "Truck Detention Cost").
|
||||||
@@ -162,39 +295,47 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
|||||||
) : (
|
) : (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<Stat label="Chargeable days" value={preview.chargeableDays} />
|
<Stat label="Longest detention" value={`${preview.chargeableDays} day(s)`} />
|
||||||
<Stat label="Trucks" value={preview.containerCount} />
|
<Stat label="Trucks" value={preview.containerCount} />
|
||||||
<Stat label="Amount" value={money(preview.amount, preview.currency)} strong />
|
<Stat label="Total amount" value={money(preview.amount, preview.currency)} strong />
|
||||||
</Group>
|
</Group>
|
||||||
{preview.endIsOpen && (
|
{preview.endIsOpen && (
|
||||||
<Text size="xs" c="orange">
|
<Text size="xs" c="orange">
|
||||||
Still accruing — no delivery/return time yet. The amount grows until the vehicle is returned.
|
Still accruing — at least one truck has no release time yet. The amount grows until
|
||||||
|
every truck is returned.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{preview.groups && preview.groups.length > 1 ? (
|
{preview.groups && preview.groups.length > 0 ? (
|
||||||
<Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs">
|
<Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs">
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Truck type</Table.Th>
|
<Table.Th>Truck</Table.Th>
|
||||||
<Table.Th>Trucks</Table.Th>
|
<Table.Th>Type</Table.Th>
|
||||||
<Table.Th>Days</Table.Th>
|
<Table.Th>Days</Table.Th>
|
||||||
<Table.Th ta="right">Rate / truck / day</Table.Th>
|
<Table.Th ta="right">Rate / day</Table.Th>
|
||||||
<Table.Th ta="right">Amount</Table.Th>
|
<Table.Th ta="right">Amount</Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{preview.groups.map((g, i) => (
|
{preview.groups.map((g, i) => (
|
||||||
<Table.Tr key={i}>
|
<Table.Tr key={g.assignmentId ?? i}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{g.vehicleType ?? 'Unknown'}
|
{g.plateNumber ?? 'Unassigned'}
|
||||||
{!g.ruleId && (
|
{!g.ruleId && (
|
||||||
<Text span size="xs" c="red">
|
<Text span size="xs" c="red">
|
||||||
{' '}· no rule
|
{' '}· no rule
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>{g.truckCount}</Table.Td>
|
<Table.Td>{g.vehicleType ?? 'Unknown'}</Table.Td>
|
||||||
<Table.Td>{g.chargeableDays}</Table.Td>
|
<Table.Td>
|
||||||
|
{g.chargeableDays}
|
||||||
|
{g.endIsOpen && (
|
||||||
|
<Text span size="xs" c="orange">
|
||||||
|
{' '}· open
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
<Table.Td ta="right">{money(g.ratePerDay, preview.currency)}</Table.Td>
|
<Table.Td ta="right">{money(g.ratePerDay, preview.currency)}</Table.Td>
|
||||||
<Table.Td ta="right">{money(g.amount, preview.currency)}</Table.Td>
|
<Table.Td ta="right">{money(g.amount, preview.currency)}</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ export interface LastMileRecord {
|
|||||||
/** Per-truck arrival / exit, stamped by the warehouse weighing steps. */
|
/** Per-truck arrival / exit, stamped by the warehouse weighing steps. */
|
||||||
arrivedAt?: string | null;
|
arrivedAt?: string | null;
|
||||||
departedAt?: string | null;
|
departedAt?: string | null;
|
||||||
|
/** This truck's own detention window (destination arrival → released). */
|
||||||
|
destinationArrivedAt?: string | null;
|
||||||
|
returnedAt?: string | null;
|
||||||
grossWeightTons?: number | null;
|
grossWeightTons?: number | null;
|
||||||
netWeightTons?: number | null;
|
netWeightTons?: number | null;
|
||||||
vehicle?: LastMileVehicle | null;
|
vehicle?: LastMileVehicle | null;
|
||||||
@@ -143,4 +146,13 @@ export const lastMileService = {
|
|||||||
/** Preview the truck-detention charge for a last-mile leg. */
|
/** Preview the truck-detention charge for a last-mile leg. */
|
||||||
truckDetentionPreview: (id: string) =>
|
truckDetentionPreview: (id: string) =>
|
||||||
api.get<FeePreview>(`${LM.BASE}/${id}/truck-detention-preview`),
|
api.get<FeePreview>(`${LM.BASE}/${id}/truck-detention-preview`),
|
||||||
|
/** Per-truck detention windows — each truck has its own clock. */
|
||||||
|
setDetentionTimes: (
|
||||||
|
id: string,
|
||||||
|
trucks: Array<{
|
||||||
|
vehicleId: string;
|
||||||
|
destinationArrivedAt?: string | null;
|
||||||
|
returnedAt?: string | null;
|
||||||
|
}>,
|
||||||
|
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/detention-times`, { trucks }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -856,10 +856,16 @@ export interface FeePreview {
|
|||||||
billableUnits: number;
|
billableUnits: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
tiers?: FeePreviewTier[];
|
tiers?: FeePreviewTier[];
|
||||||
/** Truck detention: per-vehicle-type breakdown. */
|
/** Truck detention: one row per truck — each has its own window and rule. */
|
||||||
groups?: Array<{
|
groups?: Array<{
|
||||||
|
assignmentId?: string | null;
|
||||||
|
vehicleId?: string | null;
|
||||||
|
plateNumber?: string | null;
|
||||||
vehicleType: string | null;
|
vehicleType: string | null;
|
||||||
truckCount: number;
|
truckCount: number;
|
||||||
|
startDate?: string | null;
|
||||||
|
endDate?: string | null;
|
||||||
|
endIsOpen?: boolean;
|
||||||
chargeableDays: number;
|
chargeableDays: number;
|
||||||
ratePerDay: number;
|
ratePerDay: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user