feat(last-mile): default the approval advance to the live rate

The chief's typed advance was mandatory, so the rule-based estimate shown in
the approve dialog had to be retyped and could silently diverge from it.

advanceAmount is now optional: the advance defaults to the live last-mile
rate estimate (km x rate) and the typed value is only an override. When no
rate covers the job the request is rejected with a message telling the chief
to enter the amount manually, rather than approving a zero advance.

The advance invoice now bills in the rate's currency from the snapshotted
contract summary, falling back to the booking payment currency only when the
amount came from a manual override.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-08-07 11:43:18 +00:00
parent 95fad6f095
commit c77f5200a3
3 changed files with 35 additions and 14 deletions

View File

@@ -1,13 +1,19 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsNumber, Min } from 'class-validator';
import { IsNumber, IsOptional, Min } from 'class-validator';
export class ApproveLastMileRequestDto {
// The approve dialog prefills this from GET :id/price-estimate (rule-based),
// but the chief can still override — the typed value is what's invoiced.
@ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 })
@Transform(({ value }) => Number(value))
// Omitted = the rule-based last-mile rate estimate is the advance. The chief
// can still override with an explicit amount (required when no rate covers
// the job).
@ApiPropertyOptional({
description:
'Advance override. Omitted = the amount comes from the live last-mile rates (km × rate).',
example: 3000,
})
@IsOptional()
@Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value)))
@IsNumber()
@Min(0.01)
advanceAmount!: number;
advanceAmount?: number;
}

View File

@@ -110,7 +110,7 @@ export class LastMileRequestsController {
@Post(':id/approve')
@BookingStaff(FREIGHT_PERMS.lastMile.requestApprove)
@ApiOperation({ summary: 'Truck & Machinery chief approves the request — LM contract becomes signable; the advance invoice follows the customer signature' })
@ApiOperation({ summary: 'Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature' })
approve(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ApproveLastMileRequestDto,

View File

@@ -299,7 +299,11 @@ export class LastMileRequestsService {
return this.findById(id);
}
async approve(id: string, staffId: string | null, advanceAmount: number): Promise<LastMileRequest> {
async approve(
id: string,
staffId: string | null,
advanceOverride?: number | null,
): Promise<LastMileRequest> {
const request = await this.findById(id);
if (request.status !== LastMileRequestStatus.Submitted) {
throw new BadRequestException(`Only a submitted request can be approved (current status: ${request.status})`);
@@ -307,6 +311,18 @@ export class LastMileRequestsService {
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`);
// The live last-mile rates are the authority on the advance (km × rate);
// the chief's typed amount is only an override — and the only path when no
// rate covers the job. Snapshotted so the contract and invoice stay immune
// to later rate edits.
const estimate = await this.priceEstimate(id);
const advanceAmount = advanceOverride ?? estimate.total;
if (!advanceAmount || advanceAmount <= 0) {
throw new BadRequestException(
'No live last-mile rate covers this job — enter the advance amount manually.',
);
}
// Idempotent per booking — reuses the record if one already exists.
const lastMile = await this.lastMileService.create({
bookingId: request.bookingId,
@@ -316,10 +332,6 @@ export class LastMileRequestsService {
// No invoice yet: the advance is invoiced by LastMileContractService.sign()
// once the customer has signed the LM contract — doc first, then payment.
// Snapshot the rate estimate now so the contract shows the numbers the
// chief actually approved against, immune to later rate edits.
const estimate = await this.priceEstimate(id);
await this.requestsRepository.update(id, {
status: LastMileRequestStatus.Approved,
reviewedByStaffId: staffId,
@@ -364,7 +376,10 @@ export class LastMileRequestsService {
type: 'LAST_MILE_ADVANCE',
companyId: booking.companyId,
companyProfileId: booking.companyProfileId || '',
currency: booking.paymentCurrency || 'ETB',
// The advance is priced by the last-mile rate, so it bills in that
// rate's currency (birr for domestic trucking) — the booking's payment
// currency is only the fallback when the amount was a manual override.
currency: request.contractSummary?.currency || booking.paymentCurrency || 'ETB',
lines: [
{
chargeType: 'LAST_MILE_ADVANCE',