mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user