mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 15:25:45 +00:00
- rates: min_km/max_km columns, PER_TON_KM unit, ETB|USD currency for last mile - extend UQ_rates_pattern with band start; overlap + shape validation - shared last-mile charge resolver; approve-dialog price estimate endpoint - delivery-fee invoice prices via rules, falls back to vehicle price/km - backoffice: last-mile rate form (mode, container type, band, currency)
171 lines
6.3 KiB
TypeScript
171 lines
6.3 KiB
TypeScript
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
|
import { OnEvent } from '@nestjs/event-emitter';
|
|
import { DataSource } from 'typeorm';
|
|
import { Freight } from '@edr/types';
|
|
|
|
import { ruleBasedLastMileCharge } from '../../common/last-mile-charge.util';
|
|
import {
|
|
BillingService,
|
|
GenerateInvoiceInput,
|
|
InvoiceEventPayload,
|
|
} from '../billing/billing.service';
|
|
import { Invoice } from '../billing/entities/invoice.entity';
|
|
import { RatesService } from '../rule-engine/services/rates.service';
|
|
import { LastMileRepository } from './last-mile.repository';
|
|
import { LastMile } from './entities/last-mile.entity';
|
|
|
|
/**
|
|
* Owns the last-mile ⇄ invoice mapping — the one place that knows how a last-mile
|
|
* record turns into invoices, which type to use, and how it advances when paid.
|
|
* Last-mile records are billable business entities for delivery fees, so they
|
|
* generate their own invoices directly via {@link BillingService}. All last-mile-specific
|
|
* type branching lives here, at the two points it belongs: invoice creation and
|
|
* settlement (the paid handler).
|
|
*/
|
|
@Injectable()
|
|
export class LastMileInvoiceService {
|
|
private readonly logger = new Logger(LastMileInvoiceService.name);
|
|
|
|
constructor(
|
|
private readonly billing: BillingService,
|
|
private readonly lastMileRepo: LastMileRepository,
|
|
private readonly ratesService: RatesService,
|
|
private readonly dataSource: DataSource,
|
|
) {}
|
|
|
|
/**
|
|
* Ensure the last-mile record has its invoice, generating one from the
|
|
* remainingPayment if absent. Called when a last-mile record reaches a
|
|
* billable state. Idempotent — returns the existing open invoice instead
|
|
* of a duplicate. Returns `null` (and logs) when the record is not billable:
|
|
* no company to bill (invoices FK requires a companyId).
|
|
*/
|
|
async ensureInvoiceFor(record: LastMile): Promise<Invoice | null> {
|
|
// Check if invoice already exists
|
|
const existing = await this.billing.findPayable(
|
|
'last_mile' as Freight.InvoiceSource,
|
|
record.id,
|
|
'DELIVERY_FEE',
|
|
);
|
|
if (existing) return existing;
|
|
|
|
// Can't bill without company
|
|
const lm = record.booking ? record : (await this.lastMileRepo.findById(record.id, { relations: { booking: true } }));
|
|
if (!lm) return null;
|
|
if (!lm.booking?.companyId) {
|
|
this.logger.warn(
|
|
`Skipping invoice for last-mile record ${record.id}: no company to bill.`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// Rule-based pricing first (bulk per-ton-km / container distance bands
|
|
// against the exact km): when a LIVE last-mile rate covers the job, it —
|
|
// not the per-vehicle price/km — is the delivery fee, with its own
|
|
// currency and per-size breakdown. Same resolver setDistances used to
|
|
// write remainingPayment, recomputed here so a rate change between the
|
|
// two moments settles on the invoice's side.
|
|
const exactKm = Number(record.exactKm) || 0;
|
|
const rule =
|
|
exactKm > 0
|
|
? await ruleBasedLastMileCharge(
|
|
this.dataSource,
|
|
await this.ratesService.findLiveRatesDetailed(),
|
|
record.id,
|
|
exactKm,
|
|
)
|
|
: null;
|
|
if (rule && rule.total > 0) {
|
|
return this.billing.generateInvoice({
|
|
source: 'last_mile' as Freight.InvoiceSource,
|
|
sourceId: record.id,
|
|
type: 'DELIVERY_FEE',
|
|
companyId: lm.booking!.companyId,
|
|
companyProfileId: lm.booking!.companyProfileId || '',
|
|
currency: rule.currency,
|
|
lines: rule.lines.map((line) => ({
|
|
chargeType: 'DELIVERY',
|
|
description: line.description,
|
|
quantity: line.quantity,
|
|
unitRate: line.unitRate,
|
|
amount: line.amount,
|
|
})),
|
|
totalAmount: rule.total,
|
|
});
|
|
}
|
|
|
|
// numeric columns come back as strings — coerce before billing.
|
|
const totalAmount = Number(record.remainingPayment) || 0;
|
|
if (!Number.isFinite(totalAmount) || totalAmount <= 0) {
|
|
this.logger.warn(
|
|
`Skipping invoice for last-mile record ${record.id}: no remaining payment.`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// Reject mixed-currency truck sets — a single invoice can only be one
|
|
// currency, and amounts across currencies can't be summed.
|
|
const billableTrucks = (record.vehicleAssignments ?? []).filter(
|
|
(a) => Number(a.distanceKm) > 0,
|
|
);
|
|
const currencies = [
|
|
...new Set(
|
|
billableTrucks
|
|
.map((a) => (a.vehicle as { currency?: string } | undefined)?.currency)
|
|
.filter((c): c is string => Boolean(c)),
|
|
),
|
|
];
|
|
if (currencies.length > 1) {
|
|
throw new BadRequestException(
|
|
`Cannot generate invoice: assigned trucks use mixed currencies (${currencies.join(', ')}). Assign trucks that share one currency.`,
|
|
);
|
|
}
|
|
|
|
// Currency follows the truck (price/km is quoted per vehicle), falling back
|
|
// to the booking's currency, then ETB.
|
|
const truckCurrency =
|
|
(record.vehicle as { currency?: string } | undefined)?.currency ||
|
|
(record.vehicleAssignments?.[0]?.vehicle as { currency?: string } | undefined)?.currency;
|
|
|
|
// Generate invoice with remainingPayment as totalAmount
|
|
const input: GenerateInvoiceInput = {
|
|
source: 'last_mile' as Freight.InvoiceSource,
|
|
sourceId: record.id,
|
|
type: 'DELIVERY_FEE',
|
|
companyId: lm.booking!.companyId,
|
|
companyProfileId: lm.booking!.companyProfileId || '',
|
|
currency: truckCurrency || lm.booking!.paymentCurrency || 'ETB',
|
|
lines: [
|
|
{
|
|
chargeType: 'DELIVERY',
|
|
description: 'Last-mile delivery',
|
|
quantity: 1,
|
|
unitRate: totalAmount,
|
|
amount: totalAmount,
|
|
},
|
|
],
|
|
totalAmount,
|
|
};
|
|
|
|
return this.billing.generateInvoice(input);
|
|
}
|
|
|
|
/**
|
|
* React to a last-mile invoice being paid — the settlement branch point.
|
|
* Advances the last-mile record to mark post-payment as completed.
|
|
*/
|
|
@OnEvent('last_mile.invoice.paid')
|
|
async onPaid(payload: InvoiceEventPayload): Promise<void> {
|
|
if (payload.type === 'DELIVERY_FEE') {
|
|
const record = await this.lastMileRepo.findById(payload.sourceId);
|
|
if (record) {
|
|
this.logger.log(`Last-mile invoice paid for record ${payload.sourceId}.`);
|
|
} else {
|
|
this.logger.warn(
|
|
`Cannot mark last-mile record ${payload.sourceId} as paid: not found.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|