From 16bd7fabccab0483d86257aaaa73201a184f5da7 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 20:21:16 +0000 Subject: [PATCH] mile --- ...000000010-AddLastMileAssignmentDistance.ts | 26 ++++ .../last-mile/dto/set-distances.dto.ts | 24 ++++ .../last-mile-vehicle-assignment.entity.ts | 4 + .../modules/last-mile/last-mile.controller.ts | 19 +++ .../modules/last-mile/last-mile.service.ts | 26 ++++ .../src/pages/operations/LastMilePage.tsx | 122 ++++++++++++------ .../src/services/last-mile.service.ts | 8 ++ 7 files changed, 191 insertions(+), 38 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1890000000010-AddLastMileAssignmentDistance.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile/dto/set-distances.dto.ts diff --git a/apps/edr-freight-api/src/migrations/1890000000010-AddLastMileAssignmentDistance.ts b/apps/edr-freight-api/src/migrations/1890000000010-AddLastMileAssignmentDistance.ts new file mode 100644 index 000000000..fc2d30552 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000010-AddLastMileAssignmentDistance.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Per-vehicle actual distance on a last-mile delivery. A booking served by + * several trucks records each truck's km; the record's total (last_mile.exact_km) + * is their sum and drives the invoice. + */ +export class AddLastMileAssignmentDistance1890000000010 + implements MigrationInterface +{ + name = "AddLastMileAssignmentDistance1890000000010"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + ADD COLUMN IF NOT EXISTS distance_km numeric(10,2) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + DROP COLUMN IF EXISTS distance_km + `); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/set-distances.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/set-distances.dto.ts new file mode 100644 index 000000000..3b8b26bfe --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/set-distances.dto.ts @@ -0,0 +1,24 @@ +import { IsArray, IsNumber, IsOptional, IsUUID, Min, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class VehicleDistanceInput { + @IsUUID() + vehicleId!: string; + + @IsNumber() + @Min(0) + distanceKm!: number; +} + +/** Per-vehicle actual distances for a last-mile delivery (multi-truck). */ +export class SetDistancesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => VehicleDistanceInput) + distances!: VehicleDistanceInput[]; + + /** Recomputed remaining payment (total km × rate), from the client. */ + @IsOptional() + @IsNumber() + remainingPayment?: number; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts index 8abbb0805..eee414275 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts @@ -32,4 +32,8 @@ export class LastMileVehicleAssignment extends BaseEntity { * number when known, else entered manually at assignment time. */ @Column({ name: 'container_number', type: 'varchar', nullable: true }) containerNumber?: string | null; + + /** Actual distance driven by this truck (km), entered per vehicle. */ + @Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) + distanceKm?: number | null; } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 7dcc29c1b..9d2a82d0b 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -19,6 +19,7 @@ import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto'; import { SetVehiclesDto } from './dto/set-vehicles.dto'; +import { SetDistancesDto } from './dto/set-distances.dto'; import { LastMileStatus } from './entities/last-mile.entity'; import { LastMileService } from './last-mile.service'; import { LastMileInvoiceService } from './last-mile-invoice.service'; @@ -147,4 +148,22 @@ export class LastMileController { ) { return this.lastMileService.setVehicles(id, dto.vehicles); } + + @Post(':id/distances') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' }) + async setDistances( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetDistancesDto, + ) { + return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment); + } + + @Post(':id/invoice') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' }) + async generateInvoice(@Param('id', ParseUUIDPipe) id: string) { + const record = await this.lastMileService.findById(id); + return this.lastMileInvoiceService.ensureInvoiceFor(record); + } } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 3eb5ca26e..fdc8a4c48 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -449,6 +449,32 @@ export class LastMileService { return this.findById(id); } + /** + * Record each truck's actual distance. The delivery total (exact_km) is their + * sum and drives billing; `remainingPayment` (total km × rate) is recomputed + * client-side. Does NOT generate an invoice — that's a separate explicit step. + */ + async setDistances( + id: string, + distances: Array<{ vehicleId: string; distanceKm: number }>, + remainingPayment?: number, + ): Promise { + await this.findById(id); + for (const d of distances) { + await this.dataSource.manager.update( + LastMileVehicleAssignment, + { lastMileId: id, vehicleId: d.vehicleId }, + { distanceKm: d.distanceKm }, + ); + } + const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0); + await this.lastMileRepository.update(id, { + exactKm: total, + ...(remainingPayment != null ? { remainingPayment } : {}), + } as any); + return this.findById(id); + } + private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index fa3f71e9c..06ac6d60a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -492,7 +492,8 @@ const LastMilePage = () => { const [arrivalSearch, setArrivalSearch] = useState(""); const [distanceOpen, setDistanceOpen] = useState(false); - const [distanceValue, setDistanceValue] = useState(""); + // Per-vehicle actual distance, keyed by vehicleId. + const [distanceRows, setDistanceRows] = useState>({}); const [invoiceOpen, setInvoiceOpen] = useState(false); const [invoiceRecord, setInvoiceRecord] = useState(null); @@ -593,14 +594,19 @@ const LastMilePage = () => { }, }); - const updateDistanceMutation = useMutation({ - mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) => - lastMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }), + const distanceMutation = useMutation({ + mutationFn: ({ + id, + distances, + remainingPayment, + }: { + id: string; + distances: Array<{ vehicleId: string; distanceKm: number }>; + remainingPayment?: number; + }) => lastMileService.setDistances(id, distances, remainingPayment), onSuccess: () => { - void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.list() }); - if (activeRecord) { - toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` }); - } + void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); + toast({ title: "Distances saved", description: activeRecord ? bookingRef(activeRecord) : undefined }); closeDistance(); }, onError: () => { @@ -608,6 +614,17 @@ const LastMilePage = () => { }, }); + const generateInvoiceMutation = useMutation({ + mutationFn: (id: string) => lastMileService.generateInvoice(id), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); + toast({ title: "Invoice generated" }); + }, + onError: () => { + toast({ title: "Invoice generation failed", variant: "destructive" }); + }, + }); + const deleteMutation = useMutation({ mutationFn: (id: string) => lastMileService.remove(id), onSuccess: () => { @@ -707,15 +724,20 @@ const LastMilePage = () => { }; const openDistance = (id: string) => { + const rec = records.find((r) => r.id === id); + const rows: Record = {}; + for (const a of rec?.vehicleAssignments ?? []) { + rows[a.vehicleId] = a.distanceKm != null ? String(a.distanceKm) : ""; + } setActiveId(id); - setDistanceValue(""); + setDistanceRows(rows); setDistanceOpen(true); }; const closeDistance = () => { setDistanceOpen(false); setActiveId(null); - setDistanceValue(""); + setDistanceRows({}); }; const openInvoice = (record: LastMileRecord) => { @@ -741,24 +763,27 @@ const LastMilePage = () => { }; const handleSaveDistance = () => { - const distance = parseFloat(distanceValue); - if (!activeId || isNaN(distance) || distance < 0) { - toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" }); + const distances = Object.entries(distanceRows) + .map(([vehicleId, val]) => ({ vehicleId, distanceKm: parseFloat(val) })) + .filter((d) => !Number.isNaN(d.distanceKm) && d.distanceKm >= 0); + + if (!activeId || !distances.length) { + toast({ title: "Invalid distance", description: "Enter a distance for at least one vehicle.", variant: "destructive" }); return; } + const total = distances.reduce((s, d) => s + d.distanceKm, 0); let remainingPayment: number | undefined; if (ratesData?.data) { const lastMileRate = ratesData.data.find( (r) => r.rateType === "LAST_MILE" && (r.status === "LIVE" || r.status === "DRAFT") ); if (lastMileRate) { - const rateValue = parseFloat(lastMileRate.rateValue); - remainingPayment = distance * rateValue; + remainingPayment = total * parseFloat(lastMileRate.rateValue); } } - updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment }); + distanceMutation.mutate({ id: activeId, distances, remainingPayment }); }; const activeRecord = useMemo( @@ -1206,7 +1231,11 @@ const LastMilePage = () => { } disabled={!(row.original.exactKm != null && row.original.exactKm > 0)} - onClick={() => openInvoice(row.original)} + onClick={() => + generateInvoiceMutation.mutate(row.original.id, { + onSuccess: () => openInvoice(row.original), + }) + } > Generate Invoice @@ -1693,34 +1722,51 @@ const LastMilePage = () => { {activeRecord && ( - + {bookingRef(activeRecord)} - - Customer - {customerName(activeRecord)} - - - Est. Distance (KM) - {activeRecord.estimatedKm ?? "—"} - - + Est. {activeRecord.estimatedKm ?? "—"} km + )} - setDistanceValue(String(v ?? ""))} - min={0} - step={0.1} - decimalScale={2} - /> + {(activeRecord?.vehicleAssignments?.length ?? 0) === 0 ? ( + Assign a vehicle before entering distance. + ) : ( + + {activeRecord!.vehicleAssignments!.map((a) => { + const v = a.vehicle; + const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; + return ( + + setDistanceRows((prev) => ({ ...prev, [a.vehicleId]: String(val ?? "") })) + } + min={0} + step={0.1} + decimalScale={2} + /> + ); + })} + + Total + + {Object.values(distanceRows) + .reduce((s, val) => s + (parseFloat(val) || 0), 0) + .toFixed(2)}{" "} + km + + + + )} diff --git a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts index a50778143..9eb6d03a5 100644 --- a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts @@ -61,6 +61,7 @@ export interface LastMileRecord { id: string; vehicleId: string; containerNumber?: string | null; + distanceKm?: number | null; vehicle?: LastMileVehicle | null; }>; createdAt: string; @@ -88,4 +89,11 @@ export const lastMileService = { id: string, vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>, ) => api.post(`${LM.BASE}/${id}/vehicles`, { vehicles }), + setDistances: ( + id: string, + distances: Array<{ vehicleId: string; distanceKm: number }>, + remainingPayment?: number, + ) => api.post(`${LM.BASE}/${id}/distances`, { distances, remainingPayment }), + generateInvoice: (id: string) => + api.post(`${LM.BASE}/${id}/invoice`), };