Truck detention on lastmile

This commit is contained in:
Hagernesh
2026-07-07 08:45:51 +00:00
parent 24fa6ab83b
commit 392038852e
15 changed files with 543 additions and 11 deletions

View File

@@ -1,5 +1,18 @@
import { PartialType } from '@nestjs/mapped-types';
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsISO8601, IsOptional } from 'class-validator';
import { CreateLastMileDto } from './create-last-mile.dto';
export class UpdateLastMileDto extends PartialType(CreateLastMileDto) {}
export class UpdateLastMileDto extends PartialType(CreateLastMileDto) {
/** Truck-detention clock start (vehicle arrived at destination). Overrides the auto-stamp. */
@ApiPropertyOptional({ description: 'Vehicle arrival time (ISO 8601) — detention clock start.' })
@IsOptional()
@IsISO8601()
arrivedAt?: string;
/** Truck-detention clock end (cargo cleared / vehicle returned). Overrides the auto-stamp. */
@ApiPropertyOptional({ description: 'Delivery/return time (ISO 8601) — detention clock end.' })
@IsOptional()
@IsISO8601()
deliveredAt?: string;
}

View File

@@ -30,6 +30,15 @@ export class LastMile extends BaseEntity {
@Column({ name: 'status', type: 'varchar', length: 30, default: 'PAYMENT_PENDING' })
status!: LastMileStatus;
// Truck-detention window. arrivedAt = vehicle reached destination (IN_TRANSIT);
// deliveredAt = cargo cleared / vehicle returned (DELIVERED). Detention accrues
// between them beyond the rule's grace hours (default 3h), per truck per day.
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
arrivedAt?: Date | null;
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
deliveredAt?: Date | null;
@Column({ name: 'advanced_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
advancedPayment!: number;

View File

@@ -282,6 +282,17 @@ export class LastMileService {
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}),
// Truck-detention clock: stamp arrival when the vehicle goes IN_TRANSIT and
// delivery when it reaches DELIVERED (first time only). Explicit dto values
// below override the auto-stamp so staff can record the real times.
...(dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT' && !existing.arrivedAt
? { arrivedAt: new Date() }
: {}),
...(dto.status === 'DELIVERED' && existing.status !== 'DELIVERED' && !existing.deliveredAt
? { deliveredAt: new Date() }
: {}),
...(dtoAny.arrivedAt !== undefined ? { arrivedAt: dtoAny.arrivedAt ? new Date(dtoAny.arrivedAt) : null } : {}),
...(dtoAny.deliveredAt !== undefined ? { deliveredAt: dtoAny.deliveredAt ? new Date(dtoAny.deliveredAt) : null } : {}),
} as any);
if (!updated) {

View File

@@ -87,6 +87,12 @@ export class CreateFeeRuleDto {
@Min(0)
ratePerDay!: number;
@ApiPropertyOptional({ description: 'Truck detention only: grace window in hours (default 3).' })
@IsOptional()
@IsInt()
@Min(0)
freeHours?: number;
@ApiPropertyOptional({
enum: FEE_RULE_BASES,
description: 'Double-handling charge basis: PER_CONTAINER | PER_TON | PER_ITEM.',

View File

@@ -71,6 +71,11 @@ export class WarehouseFeeRule extends BaseEntity {
@Column({ name: 'free_days', type: 'int', default: 0 })
freeDays!: number;
// Truck detention only: grace window in HOURS before detention accrues
// (contract default 3h). Null/0 → the 3-hour default.
@Column({ name: 'free_hours', type: 'int', nullable: true })
freeHours?: number | null;
@Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 })
ratePerDay!: number;

View File

@@ -407,12 +407,9 @@ export class WarehouseFeeService {
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
const now = new Date();
const byType: FeeRuleType[] = [
'DEMURRAGE_FEE',
'STORAGE_FEE',
'DOUBLE_HANDLING_FEE',
'TRUCK_DETENTION_FEE',
];
// 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(
@@ -425,4 +422,111 @@ export class WarehouseFeeService {
),
);
}
/**
* 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 [row] = 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",
(SELECT count(*) FROM freight.last_mile_vehicle_assignments va
WHERE va.last_mile_id = lm.id AND va.deleted_at IS NULL) AS "truckCount"
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 (!row) throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
const item: ItemAttributes = {
arrivedAt: null,
gateClearedAt: null,
releaseDate: null,
freightType: row.freightType ?? null,
tradeDirection: row.tradeDirection ?? null,
cargoTypeCode: null,
containerTypeCode: null,
inventoryQuantity: 1,
bookingContainerCount: 1,
cargoQuantity: 0,
facilityId: null,
warehouseId: null,
yardId: null,
zoneId: null,
};
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
const rule = this.bestRule(
rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE'),
item,
);
return this.computeTruckDetention(rule, row, new Date(), billingCurrency);
}
private async computeTruckDetention(
rule: WarehouseFeeRule | null,
row: { arrivedAt: Date | string | null; deliveredAt: Date | string | null; truckCount: number | string },
now: Date,
billingCurrency: string,
): Promise<FeePreview> {
const graceHours = rule?.freeHours && Number(rule.freeHours) > 0 ? Number(rule.freeHours) : 3;
const truckCount = Math.max(1, Math.round(Number(row.truckCount) || 1));
const start = row.arrivedAt ? new Date(row.arrivedAt) : null;
const end = row.deliveredAt ? new Date(row.deliveredAt) : now;
const endIsOpen = !row.deliveredAt;
let chargeableDays = 0;
if (start) {
const detentionMs = end.getTime() - start.getTime() - graceHours * 60 * 60 * 1000;
chargeableDays = detentionMs > 0 ? Math.ceil(detentionMs / MS_PER_DAY) : 0;
}
const ratePerDay = Number(rule?.ratePerDay ?? 0);
const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null;
const targetCurrency = this.normalizeCurrency(billingCurrency);
const hasTiers = Boolean(rule?.tiers?.length);
const tiered = this.calculateTieredAmount(rule?.tiers, chargeableDays, truckCount);
const billableUnits = hasTiers ? tiered.billableUnits : chargeableDays * truckCount;
const sourceAmount = hasTiers ? tiered.sourceAmount : Math.round(billableUnits * ratePerDay * 100) / 100;
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
const sourceRatePerDay = hasTiers ? tiered.weightedRatePerDay : ratePerDay;
const convertedRatePerDay = ruleCurrency
? await this.convertAmount(sourceRatePerDay, ruleCurrency, targetCurrency)
: 0;
const convertedTiers = ruleCurrency
? await Promise.all(
tiered.tiers.map(async (tier) => ({
...tier,
ratePerDay: await this.convertAmount(tier.ratePerDay, ruleCurrency, targetCurrency),
amount: await this.convertAmount(tier.amount, ruleCurrency, targetCurrency),
})),
)
: [];
return {
ruleType: 'TRUCK_DETENTION_FEE',
basis: null,
ruleId: rule?.id ?? null,
ruleName: rule?.name ?? null,
freeDays: 0,
ratePerDay: convertedRatePerDay,
currency: targetCurrency,
ruleCurrency,
billingCurrency: targetCurrency,
startDate: start ? start.toISOString() : null,
endDate: end.toISOString(),
endIsOpen,
elapsedDays: chargeableDays,
chargeableDays,
containerCount: truckCount, // reused as the per-truck count
billableUnits,
amount,
tiers: hasTiers ? convertedTiers : [],
};
}
}

View File

@@ -18,6 +18,15 @@ export class WarehouseInvoiceController {
return this.invoiceService.generateForInventory(id, dto);
}
@Post('last-mile/:id/generate-truck-detention-invoice')
@ApiOperation({ summary: 'Generate a truck-detention invoice for a last-mile leg (per truck per day)' })
generateTruckDetention(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: GenerateInvoiceDto,
) {
return this.invoiceService.generateTruckDetentionInvoice(id, dto);
}
@Get('warehouse-inventory/:id/fee-invoices')
@ApiOperation({ summary: 'List fee invoices for an inventory item' })
listForInventory(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -269,6 +269,79 @@ export class WarehouseInvoiceService {
return detail;
}
/**
* Generate a truck-detention invoice for a last-mile leg. Unlike warehouse fees
* (per inventory item), detention is a per-truck charge on the last-mile leg, so
* it becomes a `last_mile` invoice with its own `TRUCK_DETENTION_FEE` type — kept
* separate from the delivery-fee invoice. Returns the global Invoice.
*/
async generateTruckDetentionInvoice(
lastMileId: string,
opts: { billingCurrency?: "ETB" | "USD"; confirmZero?: boolean } = {},
): Promise<Invoice> {
const [lm] = await this.dataSource.query(
`SELECT lm.id,
b.company_id AS "companyId",
b.company_profile_id AS "companyProfileId",
b.payment_currency AS "paymentCurrency"
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 (!lm) throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
if (!lm.companyId) {
throw new BadRequestException(
"Cannot invoice truck detention: the last-mile leg has no billable company (no associated booking).",
);
}
const existing = await this.billing.findPayable(
"last_mile" as Freight.InvoiceSource,
lastMileId,
"TRUCK_DETENTION_FEE",
);
if (existing) {
throw new ConflictException(
"An active truck detention invoice already exists for this last-mile leg. Cancel it before generating a new one.",
);
}
const billingCurrency: "ETB" | "USD" =
opts.billingCurrency ?? (lm.paymentCurrency === "ETB" ? "ETB" : "USD");
const preview = await this.feeService.previewTruckDetention(lastMileId, billingCurrency);
if (preview.amount <= 0 && !opts.confirmZero) {
throw new BadRequestException(
"No truck detention is currently payable for this last-mile leg.",
);
}
const truckCount = preview.containerCount; // reused as the per-truck count
const line: InvoiceLineInput = {
chargeType: "TRUCK_DETENTION",
description: `Truck detention - ${preview.chargeableDays} day(s) x ${truckCount} truck(s)${preview.tiers.length ? " using tiered tariff" : ""}`,
quantity: preview.billableUnits,
unitRate: preview.ratePerDay,
amount: preview.amount,
currency: preview.currency,
metadata: {
feeRuleId: preview.ruleId ?? null,
chargeableDays: preview.chargeableDays ?? null,
},
};
return this.billing.generateInvoice({
source: "last_mile" as Freight.InvoiceSource,
sourceId: lastMileId,
type: "TRUCK_DETENTION_FEE",
companyId: lm.companyId,
companyProfileId: lm.companyProfileId || "",
currency: billingCurrency,
lines: [line],
status: Freight.InvoiceStatus.Issued,
});
}
// ── Reads ────────────────────────────────────────────────────────────────
async findById(id: string): Promise<WarehouseFeeInvoiceDetail> {
const invoice = await this.loadWarehouseInvoice(id);

View File

@@ -85,4 +85,13 @@ export class WarehouseRulesController {
) {
return this.feeService.previewForInventory(id, billingCurrency);
}
@Get('last-mile/:id/truck-detention-preview')
@ApiOperation({ summary: 'Preview truck detention for a last-mile leg (per truck per day after grace)' })
truckDetentionPreview(
@Param('id', ParseUUIDPipe) id: string,
@Query('billingCurrency') billingCurrency?: string,
) {
return this.feeService.previewTruckDetention(id, billingCurrency);
}
}