This commit is contained in:
natib21
2026-07-03 21:21:42 +00:00
parent 7155ec7e01
commit 6bf8da2934
5 changed files with 51 additions and 431 deletions

View File

@@ -1,17 +0,0 @@
import { IsArray, IsUUID, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class LastMileContainerAllocationDto {
@IsUUID()
containerId!: string;
@IsUUID()
vehicleId!: string;
}
export class AllocateLastMileContainersDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => LastMileContainerAllocationDto)
allocations!: LastMileContainerAllocationDto[];
}

View File

@@ -17,7 +17,6 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
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';
@@ -93,15 +92,6 @@ export class LastMileController {
return this.lastMileService.remove(id);
}
@Post(':id/allocate-containers')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Allocate containers to vehicles' })
async allocateContainers(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AllocateLastMileContainersDto,
) {
return this.lastMileService.allocateContainers(id, dto.allocations);
}
@Post(':id/vehicles')
@TrainSchedulingManage()

View File

@@ -243,14 +243,16 @@ export class LastMileService {
return record;
}
@OnEvent("lastmile.invoice.paid")
@OnEvent("last_mile.invoice.paid")
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
try {
await this.lastMileRepository.update(payload.sourceId, { paid: true } as any);
this.logger.log(`Marked last-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`);
// Invoice paid → the delivery is complete. Route through update() so it
// also frees the trucks + records history (same as "Mark Delivered").
await this.update(payload.sourceId, { status: 'DELIVERED', paid: true } as unknown as UpdateLastMileDto);
this.logger.log(`Last-mile ${payload.sourceId} marked DELIVERED on invoice ${payload.invoiceId} payment`);
} catch (err) {
this.logger.error(
`Failed to update last-mile payment status for record ${payload.sourceId}: ${String(err)}`,
`Failed to deliver last-mile ${payload.sourceId} on payment: ${String(err)}`,
);
}
}
@@ -484,6 +486,15 @@ export class LastMileService {
remainingPayment?: number,
): Promise<LastMile> {
await this.findById(id);
// Distances are locked once the invoice exists.
const invoices = await this.billing.findBySourceIds('last_mile', [id]);
if (invoices.length) {
throw new BadRequestException(
'Distances cannot be changed after the invoice is generated',
);
}
for (const d of distances) {
await this.dataSource.manager.update(
LastMileVehicleAssignment,
@@ -541,6 +552,14 @@ export class LastMileService {
async remove(id: string): Promise<void> {
const existing = await this.findById(id);
// Can't delete once billed.
const invoices = await this.billing.findBySourceIds('last_mile', [id]);
if (invoices.length) {
throw new BadRequestException(
'Cannot delete a last-mile delivery after its invoice is generated',
);
}
// Every vehicle this delivery holds — junction + legacy + container rows.
const assignments = await this.dataSource.manager.find(LastMileVehicleAssignment, {
where: { lastMileId: id },
@@ -581,89 +600,4 @@ export class LastMileService {
}
}
async allocateContainers(
lastMileId: string,
allocations: Array<{ containerId: string; vehicleId: string }>,
) {
const lastMile = await this.findById(lastMileId);
if (!lastMile) {
throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
}
// Capture the vehicles currently on these containers so a reallocation can
// be diffed into assigned/released history events below.
const previousAllocations = await this.dataSource.manager.find(
LastMileContainerAllocation,
{
where: {
lastMileId,
containerId: In(allocations.map((a) => a.containerId)),
},
},
);
const previousVehicleIds = previousAllocations
.map((a) => a.vehicleId)
.filter((id): id is string => Boolean(id));
await this.dataSource.transaction(async (manager) => {
for (const allocation of allocations) {
await manager.delete(LastMileContainerAllocation, {
lastMileId,
containerId: allocation.containerId,
});
await manager.insert(LastMileContainerAllocation, {
lastMileId,
containerId: allocation.containerId,
vehicleId: allocation.vehicleId,
containerType: 'CONTAINER',
quantity: 1,
});
}
});
// Keep vehicle availability in sync: newly-allocated cars go BUSY, cars no
// longer on any of these containers are freed if unused elsewhere.
const vehicleIds = new Set(allocations.map((a) => a.vehicleId));
await Promise.all(
[...vehicleIds].map((id) =>
this.vehiclesService.setAvailability(id, VehicleAvailability.BUSY),
),
);
await this.vehiclesService.releaseIfUnused(
previousVehicleIds.filter((id) => !vehicleIds.has(id)),
);
// History: one event per vehicle actually added or removed by this
// multi-car (re)allocation, so reassignments show on every timeline.
const prevSet = new Set(previousVehicleIds);
const bookingRef = await this.resolveBookingRef(lastMile);
for (const vehicleId of vehicleIds) {
if (prevSet.has(vehicleId)) continue;
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
vehicleId,
lastMileId,
driverId: info.driverId,
label: lastMile.status,
metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
});
}
for (const vehicleId of previousVehicleIds) {
if (vehicleIds.has(vehicleId)) continue;
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
vehicleId,
lastMileId,
driverId: info.driverId,
metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
});
}
return {
success: true,
allocated: allocations.length,
};
}
}

View File

@@ -1,186 +0,0 @@
import { useState, useMemo } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Box,
Button,
Group,
Loader,
Select,
Stack,
Table,
Text,
Alert,
} from "@mantine/core";
import { AlertCircle } from "lucide-react";
import toast from "react-hot-toast";
import { vehiclesService } from "@/services/vehicles.service";
export interface LastMileContainerRow {
id: string;
type: string;
qty: number;
}
/** One vehicle (with trailer) carries at most this many containers. */
const CONTAINERS_PER_VEHICLE = 2;
export interface LastMileContainerAllocationTableProps {
containers: LastMileContainerRow[];
onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise<void>;
}
/**
* Manual container-to-vehicle allocation table for last-mile deliveries.
* Displays containers with type/qty, vehicle dropdown per row, and save action.
*/
export function LastMileContainerAllocationTable({
containers,
onSave,
}: LastMileContainerAllocationTableProps) {
const [allocations, setAllocations] = useState<Record<string, string | null>>(
() => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
queryKey: ["vehicles", "free"],
queryFn: () =>
vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }).then((r) => r.data),
});
const vehicleOptions = useMemo(
() =>
vehicles.map((v) => ({
value: v.id,
label: `${v.plateNumber} (${v.vehicleType})`,
description: `${v.model} · ${v.manufacturer}`,
})),
[vehicles],
);
const saveAllocation = useMutation({
mutationFn: async () => {
const mappings = containers
.filter((c) => allocations[c.id])
.map((c) => ({
containerId: c.id,
vehicleId: allocations[c.id]!,
}));
if (mappings.length === 0) {
throw new Error("No containers allocated to vehicles");
}
await onSave(mappings);
},
onSuccess: () => {
toast.success("Container allocations saved");
setAllocations(
containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to save allocations",
);
},
});
const allocatedCount = Object.values(allocations).filter(Boolean).length;
// Containers already loaded onto each vehicle, to enforce the 2-per-vehicle cap.
const loadByVehicle = useMemo(() => {
const map: Record<string, number> = {};
for (const c of containers) {
const v = allocations[c.id];
if (v) map[v] = (map[v] ?? 0) + (c.qty || 1);
}
return map;
}, [allocations, containers]);
/** Options for a given row: a vehicle is disabled if assigning this container
* to it would exceed its 2-container capacity. */
const optionsForRow = (row: LastMileContainerRow) =>
vehicleOptions.map((o) => {
const already = loadByVehicle[o.value] ?? 0;
const selfHere = allocations[row.id] === o.value ? row.qty || 1 : 0;
const over = already - selfHere + (row.qty || 1) > CONTAINERS_PER_VEHICLE;
return { ...o, disabled: over };
});
if (vehiclesLoading) {
return (
<Group justify="center" p="xl">
<Loader size="sm" />
</Group>
);
}
return (
<Stack gap="md">
{vehicles.length === 0 && (
<Alert icon={<AlertCircle size={16} />} color="yellow">
No free vehicles available. Free up or add vehicles before allocating containers.
</Alert>
)}
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Container ID</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Assigned Vehicle</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((container) => (
<Table.Tr key={container.id}>
<Table.Td>
<Text fw={600} size="sm">
{container.id}
</Text>
</Table.Td>
<Table.Td>{container.type}</Table.Td>
<Table.Td>{container.qty}</Table.Td>
<Table.Td>
<Select
placeholder="Select vehicle"
data={optionsForRow(container)}
value={allocations[container.id] ?? null}
onChange={(value) =>
setAllocations((prev) => ({
...prev,
[container.id]: value,
}))
}
searchable
clearable
disabled={vehicles.length === 0}
style={{ minWidth: 200 }}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<Group justify="space-between">
<Text size="sm" c="dimmed">
{allocatedCount} of {containers.length} containers allocated · max{" "}
{CONTAINERS_PER_VEHICLE} per vehicle
</Text>
<Button
color="edr-green"
loading={saveAllocation.isPending}
disabled={allocatedCount === 0 || vehicles.length === 0}
onClick={() => saveAllocation.mutate()}
>
Save Allocations
</Button>
</Group>
</Stack>
);
}

View File

@@ -1,7 +1,6 @@
import { type ReactNode, useMemo, useState } from "react";
import {
ArrowRight,
Boxes,
Eye,
MoreHorizontal,
Plus,
@@ -55,10 +54,8 @@ import {
import { vehiclesService } from "@/services/vehicles.service";
import { driversService, type Driver } from "@/services/drivers.service";
import { ratesService } from "@/services/rates.service";
import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable";
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
import { api } from "@/auth/http";
const formatPrice = (amount: number) =>
`ETB ${amount.toLocaleString("en-US", {
@@ -118,19 +115,6 @@ const bookingContainerNumbers = (record: LastMileRecord): string[] =>
.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) => ({
id: c.id,
type:
c.containerNumber ??
c.containerType?.code ??
c.containerType?.label ??
c.containerType?.name ??
(c.containerSize || "Container"),
qty: c.quantity || 1,
}));
/** Container badges for a booking: the container number when known, else the
* type × quantity. */
const containerLabels = (record: LastMileRecord): string[] => {
@@ -500,8 +484,6 @@ const LastMilePage = () => {
// Record pending invoice-generation confirmation (shows a summary first).
const [invoiceConfirm, setInvoiceConfirm] = useState<LastMileRecord | null>(null);
const [allocationOpen, setAllocationOpen] = useState(false);
const [allocationContainers, setAllocationContainers] = useState<LastMileContainerRow[]>([]);
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
const [releaseTruckPrefill, setReleaseTruckPrefill] = useState<ReleaseOrderTruckPrefill | null>(null);
@@ -639,19 +621,6 @@ const LastMilePage = () => {
},
});
const allocateMutation = useMutation({
mutationFn: (data: Array<{ containerId: string; vehicleId: string }>) =>
api.post(`/last-mile/${activeId}/allocate-containers`, { allocations: data }),
onSuccess: () => {
toast({ title: "Containers allocated", variant: "default" });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
void qc.invalidateQueries({ queryKey: ["vehicles"] });
closeAllocation();
},
onError: () => {
toast({ title: "Allocation failed", variant: "destructive" });
},
});
const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({
queryKey: ["warehouse-inventory", "arrival-queue"],
@@ -744,17 +713,6 @@ const LastMilePage = () => {
};
const openAllocation = (id: string, containers?: LastMileContainerRow[]) => {
setActiveId(id);
setAllocationContainers(containers ?? []);
setAllocationOpen(true);
};
const closeAllocation = () => {
setAllocationOpen(false);
setActiveId(null);
setAllocationContainers([]);
};
const handleSaveDistance = () => {
const distances = Object.entries(distanceRows)
@@ -984,6 +942,15 @@ const LastMilePage = () => {
setReleaseItem(toReleaseInventoryItem(row));
};
// Truck leaving the warehouse = the leg is now in transit. Advance the status
// (same as "Mark In Transit") alongside the warehouse exit-weighing flow.
const handleTruckLeaving = (record: LastMileRecord) => {
openTruckArrival(record);
if (record.status === "READY_TO_TRANSIT") {
updateMutation.mutate({ id: record.id, data: { status: "IN_TRANSIT" } });
}
};
const closeTruckArrival = () => {
setReleaseItem(null);
setReleaseTruckPrefill(null);
@@ -1089,7 +1056,11 @@ const LastMilePage = () => {
return (
<Group gap="xs" wrap="nowrap">
<UnstyledButton
onClick={() => navigate(`/dashboard/invoices/${invoice.id}`)}
onClick={() =>
invoice.id
? navigate(`/dashboard/invoices/${invoice.id}`)
: toast({ title: "Invoice link unavailable", description: "Refresh after the API restart.", variant: "destructive" })
}
c="blue"
fw={500}
style={{ textDecoration: "underline", cursor: "pointer" }}
@@ -1135,11 +1106,12 @@ const LastMilePage = () => {
(status === "IN_TRANSIT" && hasDistance);
const canAssignStep = !assigned && status !== "DELIVERED";
const canDistance = status === "IN_TRANSIT";
// Truck arrival/leaving are independent — each driven only by its own
// warehouse state: arrive once assigned & not arrived, leave once
// arrived & not departed.
const canArrive = assigned && !releaseRow?.releaseOrderReference;
const canLeave = Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate;
// Truck arrival/leaving are independent — each driven by its own
// warehouse state — but both are done once the leg is IN_TRANSIT/DELIVERED.
const pastTransit = status === "IN_TRANSIT" || status === "DELIVERED";
const canArrive = assigned && !releaseRow?.releaseOrderReference && !pastTransit;
const canLeave =
Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate && !pastTransit;
return (
<Group gap={4} justify="flex-end" wrap="nowrap">
<Menu position="bottom-end" width={200} withinPortal>
@@ -1189,13 +1161,6 @@ const LastMilePage = () => {
>
Unassign
</Menu.Item>
<Menu.Item
leftSection={<Boxes size={15} />}
disabled={allocationRowsFor(row.original).length === 0 || delivered}
onClick={() => openAllocation(row.original.id, allocationRowsFor(row.original))}
>
Allocate to trucks
</Menu.Item>
<Menu.Item
leftSection={<Truck size={15} />}
disabled={!canArrive}
@@ -1206,7 +1171,7 @@ const LastMilePage = () => {
<Menu.Item
leftSection={<Truck size={15} />}
disabled={!canLeave}
onClick={() => openTruckArrival(row.original)}
onClick={() => handleTruckLeaving(row.original)}
>
Truck Leaving
</Menu.Item>
@@ -1219,17 +1184,20 @@ const LastMilePage = () => {
</Menu.Item>
<Menu.Item
leftSection={<Ruler size={15} />}
disabled={!canDistance}
disabled={!canDistance || Boolean(row.original.invoice)}
onClick={() => openDistance(row.original.id)}
>
Add distance
</Menu.Item>
<Menu.Item
leftSection={<Receipt size={15} />}
disabled={!(row.original.exactKm != null && row.original.exactKm > 0)}
disabled={
!(row.original.exactKm != null && row.original.exactKm > 0) ||
Boolean(row.original.invoice)
}
onClick={() => setInvoiceConfirm(row.original)}
>
Generate Invoice
{row.original.invoice ? "Invoice generated" : "Generate Invoice"}
</Menu.Item>
{canPrint && (
<Menu.Item
@@ -1245,7 +1213,7 @@ const LastMilePage = () => {
<Menu.Item
leftSection={<Trash size={15} />}
color="red"
disabled={delivered}
disabled={delivered || Boolean(row.original.invoice)}
onClick={() => {
if (confirm(`Delete last-mile record ${bookingRef(row.original)}?`)) {
deleteMutation.mutate(row.original.id);
@@ -1829,75 +1797,6 @@ const LastMilePage = () => {
)}
</Modal>
{/* Container Allocation modal */}
<Modal
opened={allocationOpen}
onClose={closeAllocation}
title={<Text fw={600}>Allocate Containers to Vehicles</Text>}
size="xl"
radius="lg"
centered
>
<Stack gap="md">
{activeRecord && (
<>
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Group justify="space-between">
<Stack gap={0}>
<Text fw={600} size="sm">{bookingRef(activeRecord)}</Text>
<Text size="xs" c="dimmed">{customerName(activeRecord)}</Text>
</Stack>
<Stack gap={0} align="flex-end">
<Text size="xs" c="dimmed" tt="uppercase">Cargo Type</Text>
<Text size="sm" fw={600}>{activeRecord.booking?.cargoType?.label ?? activeRecord.booking?.cargoType?.name ?? "—"}</Text>
</Stack>
</Group>
</Stack>
</Card>
{/* Capacity logic based on cargo type */}
{activeRecord.booking?.cargoType?.name === "BULK" ? (
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-blue-0)" style={{ borderColor: "var(--mantine-color-blue-3)" }}>
<Stack gap="sm">
<Group gap="xs">
<Text fw={600} size="sm">Smart Capacity Allocation</Text>
</Group>
<Stack gap={2}>
<Text size="sm">Capacity: TBD</Text>
<Text size="xs" c="dimmed">
TODO: add vehicle capacity_tons to vehicle API if missing
</Text>
<Text size="xs" c="dimmed">
TODO: add container weight to booking if missing
</Text>
</Stack>
<Text size="sm" fw={500} mt="xs">
Select multiple containers per vehicle based on capacity
</Text>
</Stack>
</Card>
) : (
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Text size="sm" fw={500}>Up to 2 containers per vehicle (trailer)</Text>
</Card>
)}
</>
)}
<LastMileContainerAllocationTable
containers={allocationContainers}
onSave={async (mappings) => {
await allocateMutation.mutateAsync(mappings);
}}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeAllocation}>Close</Button>
</Group>
</Stack>
</Modal>
<ReleaseOrderModal
opened={Boolean(releaseItem)}
onClose={closeTruckArrival}