Merge pull request #1180 from Tria-plc/lastmilerequest

Lastmilerequest
This commit is contained in:
Hagernesh Tadesse
2026-08-08 12:30:43 +03:00
committed by GitHub
5 changed files with 73 additions and 16 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

@@ -114,7 +114,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',

View File

@@ -40,6 +40,11 @@ export function Step2ServiceType({
serviceType ?? {};
const firstMileEnabled = form.watch("firstMile.enabled");
const lastMileEnabled = form.watch("lastMile.enabled");
// The paid-mile service prices the road legs into the booking itself; every
// other service defers last-mile billing to after the Djibouti departure
// (confirm containers → sign supplementary LM contract → pay advance).
const deferredLastMileBilling =
serviceType?.code !== "RAIL_CONTAINER_PAID_MILE";
const prevServiceType = useRef(serviceType);
// Only clear a mile when the current service doesn't include it — this ran
@@ -205,7 +210,11 @@ export function Step2ServiceType({
<ServiceToggle
icon={<Truck size={18} />}
title="Last Mile — Delivery"
description="Truck delivery from the destination rail yard to the final address (Port to Door)."
description={
deferredLastMileBilling
? "Truck delivery from the destination rail yard to the final address (Port to Door). Nothing is paid now — last-mile billing starts after your train departs Djibouti."
: "Truck delivery from the destination rail yard to the final address (Port to Door)."
}
checked={field.value ?? false}
onChange={(value) => {
field.onChange(value);
@@ -224,6 +233,15 @@ export function Step2ServiceType({
>
{lastMileEnabled && (
<Box mt="md">
{deferredLastMileBilling && (
<Text size="xs" c="dimmed" mb={10}>
How it works: when your train departs Djibouti, you
will be asked to confirm which containers EDR should
deliver. Once truck availability is approved, you will
sign a short supplementary last-mile contract and pay
the delivery advance nothing is charged at booking.
</Text>
)}
<Controller
name="lastMile"
control={form.control}

View File

@@ -254,6 +254,11 @@ export function Step2ServiceType({
(serviceType?.includesLastMile ?? false) && tradeDirection !== "EXPORT";
const firstMileEnabled = form.watch("firstMile.enabled");
const lastMileEnabled = form.watch("lastMile.enabled");
// The paid-mile service prices the road legs into the booking itself; every
// other service defers last-mile billing to after the Djibouti departure
// (confirm containers → sign supplementary LM contract → pay advance).
const deferredLastMileBilling =
serviceType?.code !== "RAIL_CONTAINER_PAID_MILE";
const prevServiceType = useRef(serviceType);
useEffect(() => {
@@ -471,7 +476,11 @@ export function Step2ServiceType({
<ServiceToggle
icon={<Truck size={18} />}
title="Last Mile — Delivery"
description="Truck delivery from the destination rail yard to the final address (Port to Door)."
description={
deferredLastMileBilling
? "Truck delivery from the destination rail yard to the final address (Port to Door). Nothing is paid now — last-mile billing starts after your train departs Djibouti."
: "Truck delivery from the destination rail yard to the final address (Port to Door)."
}
checked={field.value ?? false}
onChange={(value) => {
field.onChange(value);
@@ -492,6 +501,15 @@ export function Step2ServiceType({
>
{lastMileEnabled && (
<Stack mt="md" gap={12}>
{deferredLastMileBilling && (
<Text size="xs" c="dimmed">
How it works: when your train departs Djibouti, you
will be asked to confirm which containers EDR should
deliver. Once truck availability is approved, you will
sign a short supplementary last-mile contract and pay
the delivery advance nothing is charged at booking.
</Text>
)}
<Controller
name="lastMile"
control={form.control}