mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
mile
This commit is contained in:
@@ -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<void> {
|
||||
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<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||
DROP COLUMN IF EXISTS distance_km
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<LastMile> {
|
||||
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<void> {
|
||||
try {
|
||||
const vehicle = await this.vehiclesService.findById(vehicleId);
|
||||
|
||||
@@ -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<Record<string, string>>({});
|
||||
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
||||
const [invoiceRecord, setInvoiceRecord] = useState<LastMileRecord | null>(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<string, string> = {};
|
||||
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 = () => {
|
||||
<Menu.Item
|
||||
leftSection={<Receipt size={15} />}
|
||||
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
|
||||
</Menu.Item>
|
||||
@@ -1693,34 +1722,51 @@ const LastMilePage = () => {
|
||||
<Stack gap="md">
|
||||
{activeRecord && (
|
||||
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} size="sm">{bookingRef(activeRecord)}</Text>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">Customer</Text>
|
||||
<Text size="sm">{customerName(activeRecord)}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">Est. Distance (KM)</Text>
|
||||
<Text size="sm">{activeRecord.estimatedKm ?? "—"}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
<Text size="xs" c="dimmed">Est. {activeRecord.estimatedKm ?? "—"} km</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
<NumberInput
|
||||
label="Actual Distance (KM)"
|
||||
placeholder="Enter distance"
|
||||
value={distanceValue}
|
||||
onChange={(v) => setDistanceValue(String(v ?? ""))}
|
||||
min={0}
|
||||
step={0.1}
|
||||
decimalScale={2}
|
||||
/>
|
||||
{(activeRecord?.vehicleAssignments?.length ?? 0) === 0 ? (
|
||||
<Text size="sm" c="dimmed">Assign a vehicle before entering distance.</Text>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{activeRecord!.vehicleAssignments!.map((a) => {
|
||||
const v = a.vehicle;
|
||||
const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId;
|
||||
return (
|
||||
<NumberInput
|
||||
key={a.id}
|
||||
label={`${label}${a.containerNumber ? ` · ${a.containerNumber}` : ""}`}
|
||||
placeholder="Distance (km)"
|
||||
value={distanceRows[a.vehicleId] ?? ""}
|
||||
onChange={(val) =>
|
||||
setDistanceRows((prev) => ({ ...prev, [a.vehicleId]: String(val ?? "") }))
|
||||
}
|
||||
min={0}
|
||||
step={0.1}
|
||||
decimalScale={2}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">Total</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{Object.values(distanceRows)
|
||||
.reduce((s, val) => s + (parseFloat(val) || 0), 0)
|
||||
.toFixed(2)}{" "}
|
||||
km
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={closeDistance}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleSaveDistance}
|
||||
loading={updateDistanceMutation.isPending}
|
||||
disabled={!distanceValue}
|
||||
loading={distanceMutation.isPending}
|
||||
disabled={Object.values(distanceRows).every((v) => !v)}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
@@ -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<LastMileRecord>(`${LM.BASE}/${id}/vehicles`, { vehicles }),
|
||||
setDistances: (
|
||||
id: string,
|
||||
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
||||
remainingPayment?: number,
|
||||
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/distances`, { distances, remainingPayment }),
|
||||
generateInvoice: (id: string) =>
|
||||
api.post<unknown>(`${LM.BASE}/${id}/invoice`),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user