mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
@@ -1,13 +1,19 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { IsNumber, Min } from 'class-validator';
|
import { IsNumber, IsOptional, Min } from 'class-validator';
|
||||||
|
|
||||||
export class ApproveLastMileRequestDto {
|
export class ApproveLastMileRequestDto {
|
||||||
// The approve dialog prefills this from GET :id/price-estimate (rule-based),
|
// Omitted = the rule-based last-mile rate estimate is the advance. The chief
|
||||||
// but the chief can still override — the typed value is what's invoiced.
|
// can still override with an explicit amount (required when no rate covers
|
||||||
@ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 })
|
// the job).
|
||||||
@Transform(({ value }) => Number(value))
|
@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()
|
@IsNumber()
|
||||||
@Min(0.01)
|
@Min(0.01)
|
||||||
advanceAmount!: number;
|
advanceAmount?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export class LastMileRequestsController {
|
|||||||
|
|
||||||
@Post(':id/approve')
|
@Post(':id/approve')
|
||||||
@BookingStaff(FREIGHT_PERMS.lastMile.requestApprove)
|
@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(
|
approve(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: ApproveLastMileRequestDto,
|
@Body() dto: ApproveLastMileRequestDto,
|
||||||
|
|||||||
@@ -299,7 +299,11 @@ export class LastMileRequestsService {
|
|||||||
return this.findById(id);
|
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);
|
const request = await this.findById(id);
|
||||||
if (request.status !== LastMileRequestStatus.Submitted) {
|
if (request.status !== LastMileRequestStatus.Submitted) {
|
||||||
throw new BadRequestException(`Only a submitted request can be approved (current status: ${request.status})`);
|
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));
|
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
|
||||||
if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`);
|
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.
|
// Idempotent per booking — reuses the record if one already exists.
|
||||||
const lastMile = await this.lastMileService.create({
|
const lastMile = await this.lastMileService.create({
|
||||||
bookingId: request.bookingId,
|
bookingId: request.bookingId,
|
||||||
@@ -316,10 +332,6 @@ export class LastMileRequestsService {
|
|||||||
|
|
||||||
// No invoice yet: the advance is invoiced by LastMileContractService.sign()
|
// No invoice yet: the advance is invoiced by LastMileContractService.sign()
|
||||||
// once the customer has signed the LM contract — doc first, then payment.
|
// 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, {
|
await this.requestsRepository.update(id, {
|
||||||
status: LastMileRequestStatus.Approved,
|
status: LastMileRequestStatus.Approved,
|
||||||
reviewedByStaffId: staffId,
|
reviewedByStaffId: staffId,
|
||||||
@@ -364,7 +376,10 @@ export class LastMileRequestsService {
|
|||||||
type: 'LAST_MILE_ADVANCE',
|
type: 'LAST_MILE_ADVANCE',
|
||||||
companyId: booking.companyId,
|
companyId: booking.companyId,
|
||||||
companyProfileId: booking.companyProfileId || '',
|
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: [
|
lines: [
|
||||||
{
|
{
|
||||||
chargeType: 'LAST_MILE_ADVANCE',
|
chargeType: 'LAST_MILE_ADVANCE',
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ export function Step2ServiceType({
|
|||||||
serviceType ?? {};
|
serviceType ?? {};
|
||||||
const firstMileEnabled = form.watch("firstMile.enabled");
|
const firstMileEnabled = form.watch("firstMile.enabled");
|
||||||
const lastMileEnabled = form.watch("lastMile.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);
|
const prevServiceType = useRef(serviceType);
|
||||||
// Only clear a mile when the current service doesn't include it — this ran
|
// Only clear a mile when the current service doesn't include it — this ran
|
||||||
@@ -205,7 +210,11 @@ export function Step2ServiceType({
|
|||||||
<ServiceToggle
|
<ServiceToggle
|
||||||
icon={<Truck size={18} />}
|
icon={<Truck size={18} />}
|
||||||
title="Last Mile — Delivery"
|
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}
|
checked={field.value ?? false}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
@@ -224,6 +233,15 @@ export function Step2ServiceType({
|
|||||||
>
|
>
|
||||||
{lastMileEnabled && (
|
{lastMileEnabled && (
|
||||||
<Box mt="md">
|
<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
|
<Controller
|
||||||
name="lastMile"
|
name="lastMile"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -254,6 +254,11 @@ export function Step2ServiceType({
|
|||||||
(serviceType?.includesLastMile ?? false) && tradeDirection !== "EXPORT";
|
(serviceType?.includesLastMile ?? false) && tradeDirection !== "EXPORT";
|
||||||
const firstMileEnabled = form.watch("firstMile.enabled");
|
const firstMileEnabled = form.watch("firstMile.enabled");
|
||||||
const lastMileEnabled = form.watch("lastMile.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);
|
const prevServiceType = useRef(serviceType);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -471,7 +476,11 @@ export function Step2ServiceType({
|
|||||||
<ServiceToggle
|
<ServiceToggle
|
||||||
icon={<Truck size={18} />}
|
icon={<Truck size={18} />}
|
||||||
title="Last Mile — Delivery"
|
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}
|
checked={field.value ?? false}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
@@ -492,6 +501,15 @@ export function Step2ServiceType({
|
|||||||
>
|
>
|
||||||
{lastMileEnabled && (
|
{lastMileEnabled && (
|
||||||
<Stack mt="md" gap={12}>
|
<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
|
<Controller
|
||||||
name="lastMile"
|
name="lastMile"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
Reference in New Issue
Block a user