mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 20:40:55 +00:00
Truck detention on lastmile
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 : [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user