From 77dc14f6a597380a01205700ee7e24c56f325768 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 20:00:41 +0000 Subject: [PATCH] mile --- ...09-AddLastMileAssignmentContainerNumber.ts | 26 ++++ .../modules/last-mile/dto/set-vehicles.dto.ts | 19 ++- .../last-mile-vehicle-assignment.entity.ts | 5 + .../modules/last-mile/last-mile.controller.ts | 2 +- .../modules/last-mile/last-mile.service.ts | 45 +++++-- .../src/pages/operations/LastMilePage.tsx | 113 ++++++++++++------ .../src/services/last-mile.service.ts | 13 +- 7 files changed, 171 insertions(+), 52 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1890000000009-AddLastMileAssignmentContainerNumber.ts diff --git a/apps/edr-freight-api/src/migrations/1890000000009-AddLastMileAssignmentContainerNumber.ts b/apps/edr-freight-api/src/migrations/1890000000009-AddLastMileAssignmentContainerNumber.ts new file mode 100644 index 000000000..d8bc1c5c0 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000009-AddLastMileAssignmentContainerNumber.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Container number carried by each vehicle on a last-mile delivery. Auto-filled + * from the booking's container number when present, else entered by the operator + * at assignment time. + */ +export class AddLastMileAssignmentContainerNumber1890000000009 + implements MigrationInterface +{ + name = "AddLastMileAssignmentContainerNumber1890000000009"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + ADD COLUMN IF NOT EXISTS container_number varchar + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + DROP COLUMN IF EXISTS container_number + `); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts index 40426b663..e07eec0b4 100644 --- a/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts @@ -1,8 +1,19 @@ -import { IsArray, IsUUID } from 'class-validator'; +import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; -/** Replace the full set of vehicles assigned to a last-mile delivery. */ +export class LastMileVehicleInput { + @IsUUID() + vehicleId!: string; + + @IsOptional() + @IsString() + containerNumber?: string; +} + +/** Replace the full set of vehicles (with their container numbers) on a delivery. */ export class SetVehiclesDto { @IsArray() - @IsUUID('4', { each: true }) - vehicleIds!: string[]; + @ValidateNested({ each: true }) + @Type(() => LastMileVehicleInput) + vehicles!: LastMileVehicleInput[]; } 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 0ba0f7d06..8abbb0805 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 @@ -27,4 +27,9 @@ export class LastMileVehicleAssignment extends BaseEntity { @ManyToOne(() => Vehicle, { nullable: false, eager: false }) @JoinColumn({ name: 'vehicle_id' }) vehicle?: Vehicle; + + /** Container this truck carries — auto-filled from the booking's container + * number when known, else entered manually at assignment time. */ + @Column({ name: 'container_number', type: 'varchar', nullable: true }) + containerNumber?: string | 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 047d6b3e9..7dcc29c1b 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 @@ -145,6 +145,6 @@ export class LastMileController { @Param('id', ParseUUIDPipe) id: string, @Body() dto: SetVehiclesDto, ) { - return this.lastMileService.setVehicles(id, dto.vehicleIds); + return this.lastMileService.setVehicles(id, dto.vehicles); } } 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 201692fdb..3eb5ca26e 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 @@ -362,19 +362,37 @@ export class LastMileService { * for each added/removed vehicle. The first vehicle is mirrored onto the legacy * `vehicleId` column for back-compat with single-vehicle readers. */ - async setVehicles(id: string, vehicleIds: string[]): Promise { + async setVehicles( + id: string, + inputs: Array<{ vehicleId: string; containerNumber?: string | null }>, + ): Promise { const existing = await this.findById(id); - const desired = [...new Set(vehicleIds.filter(Boolean))]; + // Dedupe by vehicleId, keeping the container number; preserve order. + const desiredMap = new Map(); + for (const inp of inputs) { + if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null); + } + const desired = [...desiredMap.keys()]; + const desiredSet = new Set(desired); const manager = this.dataSource.manager; const current = await manager.find(LastMileVehicleAssignment, { where: { lastMileId: id }, }); - const currentIds = current.map((a) => a.vehicleId); - const currentSet = new Set(currentIds); - const desiredSet = new Set(desired); - const added = desired.filter((v) => !currentSet.has(v)); - const removed = currentIds.filter((v) => !desiredSet.has(v)); + const junctionSet = new Set(current.map((a) => a.vehicleId)); + // Fold the legacy vehicleId into the release set — a vehicle assigned via the + // old single-vehicle path has no junction row but must still be freed. + const releaseIds = [...new Set( + current.map((a) => a.vehicleId).concat(existing.vehicleId ? [existing.vehicleId] : []), + )]; + const added = desired.filter((v) => !junctionSet.has(v)); + const removed = releaseIds.filter((v) => !desiredSet.has(v)); + // Vehicles that stay but whose container number changed. + const changed = current.filter( + (a) => + desiredMap.has(a.vehicleId) && + (a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null), + ); await this.dataSource.transaction(async (tx) => { if (removed.length) { @@ -384,7 +402,18 @@ export class LastMileService { }); } for (const vehicleId of added) { - await tx.insert(LastMileVehicleAssignment, { lastMileId: id, vehicleId }); + await tx.insert(LastMileVehicleAssignment, { + lastMileId: id, + vehicleId, + containerNumber: desiredMap.get(vehicleId) ?? null, + }); + } + for (const row of changed) { + await tx.update( + LastMileVehicleAssignment, + { lastMileId: id, vehicleId: row.vehicleId }, + { containerNumber: desiredMap.get(row.vehicleId) ?? null }, + ); } }); 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 6fa102ff1..a66dda5de 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -110,6 +110,12 @@ const requiredVehicles = (record: LastMileRecord) => { return n > 0 ? Math.ceil(n / CONTAINERS_PER_VEHICLE) : 0; }; +/** Container numbers on a booking, in line order (skips lines without one). */ +const bookingContainerNumbers = (record: LastMileRecord): string[] => + (record.booking?.bookingContainers ?? []) + .map((c) => c.containerNumber) + .filter((n): n is string => Boolean(n)); + /** Container rows for the per-container→vehicle allocation table. */ const allocationRowsFor = (record: LastMileRecord): LastMileContainerRow[] => (record.booking?.bookingContainers ?? []).map((c) => ({ @@ -473,8 +479,10 @@ const LastMilePage = () => { const [tripSlipOpen, setTripSlipOpen] = useState(false); const [tripSlipRecord, setTripSlipRecord] = useState(null); const [activeId, setActiveId] = useState(null); - // Multi-vehicle assign: one entry per selected vehicle (null = empty picker). - const [vehicleValues, setVehicleValues] = useState<(string | null)[]>([null]); + // Multi-vehicle assign: one row per truck — vehicle + the container it carries. + const [vehicleRows, setVehicleRows] = useState< + Array<{ vehicleId: string | null; containerNumber: string }> + >([{ vehicleId: null, containerNumber: "" }]); // 2-step "Assign Mile" accept modal (arrival queue → vehicle) const [acceptOpen, setAcceptOpen] = useState(false); @@ -569,8 +577,13 @@ const LastMilePage = () => { }); const setVehiclesMutation = useMutation({ - mutationFn: ({ id, vehicleIds }: { id: string; vehicleIds: string[] }) => - lastMileService.setVehicles(id, vehicleIds), + mutationFn: ({ + id, + vehicles, + }: { + id: string; + vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>; + }) => lastMileService.setVehicles(id, vehicles), onSuccess: () => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); void qc.invalidateQueries({ queryKey: ["vehicles"] }); @@ -645,7 +658,8 @@ const LastMilePage = () => { items.map((item) => lastMileService.accept(item.bookingReference).then((r) => r.data)), ); if (vehicleIds.length) { - await Promise.all(created.map((record) => lastMileService.setVehicles(record.id, vehicleIds))); + const vehicles = vehicleIds.map((v) => ({ vehicleId: v })); + await Promise.all(created.map((record) => lastMileService.setVehicles(record.id, vehicles))); } return created; }, @@ -837,21 +851,28 @@ const LastMilePage = () => { const openAssign = (id: string | null) => { const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null; const rec = records.find((r) => r.id === resolved); - const existing = rec?.vehicleAssignments?.length - ? rec.vehicleAssignments.map((a) => a.vehicleId) - : rec?.vehicleId - ? [rec.vehicleId] - : []; + // Prefill each row's container number from the booking's container numbers + // (by order) when the assignment doesn't already carry one. + const nums = rec ? bookingContainerNumbers(rec) : []; + const rows = + rec?.vehicleAssignments?.length + ? rec.vehicleAssignments.map((a, i) => ({ + vehicleId: a.vehicleId, + containerNumber: a.containerNumber ?? nums[i] ?? "", + })) + : rec?.vehicleId + ? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }] + : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]; setBulkMode(false); setActiveId(resolved); - setVehicleValues(existing.length ? existing : [null]); + setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]); setAssignOpen(true); }; const openBulkAssign = () => { setBulkMode(true); setActiveId(null); - setVehicleValues([null]); + setVehicleRows([{ vehicleId: null, containerNumber: "" }]); setAssignOpen(true); }; @@ -859,11 +880,16 @@ const LastMilePage = () => { setAssignOpen(false); setBulkMode(false); setActiveId(null); - setVehicleValues([null]); + setVehicleRows([{ vehicleId: null, containerNumber: "" }]); }; const handleAssign = () => { - const ids = [...new Set(vehicleValues.filter((v): v is string => Boolean(v)))]; + const seen = new Set(); + const vehicles = vehicleRows + .filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId)) + .filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId))) + .map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null })); + const count = vehicles.length; const targetIds = bulkMode ? selectedIds : [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.id].filter((id): id is string => Boolean(id)); @@ -871,14 +897,14 @@ const LastMilePage = () => { if (!targetIds.length) return; // Empty set = unassign all (setVehicles releases the removed vehicles). - Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicleIds: ids }))) + Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles }))) .then(() => { toast({ - title: ids.length === 0 ? "Vehicles unassigned" : ids.length > 1 ? "Vehicles assigned" : "Vehicle assigned", + title: count === 0 ? "Vehicles unassigned" : count > 1 ? "Vehicles assigned" : "Vehicle assigned", description: - ids.length === 0 + count === 0 ? bulkMode ? `${targetIds.length} deliveries` : undefined - : `${bulkMode ? `${targetIds.length} deliveries · ` : ""}${ids.length} vehicle${ids.length > 1 ? "s" : ""}`, + : `${bulkMode ? `${targetIds.length} deliveries · ` : ""}${count} vehicle${count > 1 ? "s" : ""}`, }); if (bulkMode) setRowSelection({}); closeAssign(); @@ -1132,7 +1158,7 @@ const LastMilePage = () => { disabled={!assigned || delivered} onClick={() => setVehiclesMutation.mutate( - { id: row.original.id, vehicleIds: [] }, + { id: row.original.id, vehicles: [] }, { onSuccess: () => toast({ title: "Vehicles unassigned", description: bookingRef(row.original) }), @@ -1467,7 +1493,7 @@ const LastMilePage = () => { {!bulkMode && activeRecord && (() => { const containers = containerCount(activeRecord); const needed = requiredVehicles(activeRecord); - const picked = vehicleValues.filter(Boolean).length; + const picked = vehicleRows.filter((r) => r.vehicleId).length; if (needed === 0) { return ( @@ -1504,34 +1530,40 @@ const LastMilePage = () => { )} - {vehicleValues.map((val, i) => ( + {vehicleRows.map((row, i) => (