This commit is contained in:
natib21
2026-07-04 01:47:28 +00:00
parent 58c49d34b2
commit bd88424167
10 changed files with 637 additions and 87 deletions

View File

@@ -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 first-mile pickup (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;
}

View File

@@ -0,0 +1,19 @@
import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class FirstMileVehicleInput {
@IsUUID()
vehicleId!: string;
@IsOptional()
@IsString()
containerNumber?: string;
}
/** Replace the full set of vehicles (with their container numbers) on a pickup. */
export class SetVehiclesDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => FirstMileVehicleInput)
vehicles!: FirstMileVehicleInput[];
}

View File

@@ -0,0 +1,39 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { FirstMile } from './first-mile.entity';
/**
* One row per vehicle assigned to a first-mile pickup. A pickup can be served
* by several vehicles at once (multi-truck bookings); the legacy
* `first_mile.vehicle_id` column keeps pointing at the first assignment for
* backward compatibility.
*/
@Entity({ name: 'first_mile_vehicle_assignments', schema: 'freight' })
@Unique(['firstMileId', 'vehicleId'])
@Index(['vehicleId'])
export class FirstMileVehicleAssignment extends BaseEntity {
@Column({ name: 'first_mile_id', type: 'uuid' })
firstMileId!: string;
@ManyToOne(() => FirstMile, (fm) => fm.vehicleAssignments, { nullable: false, onDelete: 'CASCADE' })
@JoinColumn({ name: 'first_mile_id' })
firstMile?: FirstMile;
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@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;
/** 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;
}

View File

@@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm
import { Booking } from '../../bookings/entities/booking.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { FirstMileContainerAllocation } from './first-mile-container-allocation.entity';
import { FirstMileVehicleAssignment } from './first-mile-vehicle-assignment.entity';
export const FIRST_MILE_STATUSES = [
'PAYMENT_PENDING',
@@ -61,4 +62,7 @@ export class FirstMile extends BaseEntity {
{ eager: false },
)
containerAllocations!: FirstMileContainerAllocation[];
@OneToMany(() => FirstMileVehicleAssignment, (va) => va.firstMile)
vehicleAssignments?: FirstMileVehicleAssignment[];
}

View File

@@ -18,6 +18,8 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
import { SetVehiclesDto } from './dto/set-vehicles.dto';
import { SetDistancesDto } from './dto/set-distances.dto';
import { FirstMileStatus } from './entities/first-mile.entity';
import { FirstMileService } from './first-mile.service';
import { FirstMileInvoiceService } from './first-mile-invoice.service';
@@ -103,6 +105,26 @@ export class FirstMileController {
return invoice;
}
@Post(':id/vehicles')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Set the vehicles assigned to a first-mile pickup (multi-truck)' })
async setVehicles(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SetVehiclesDto,
) {
return this.firstMileService.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.firstMileService.setDistances(id, dto.distances, dto.remainingPayment);
}
@Delete(':id')
@TrainSchedulingManage()
@HttpCode(HttpStatus.NO_CONTENT)

View File

@@ -8,6 +8,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
import { VehiclesModule } from '../vehicles/vehicles.module';
import { FirstMile } from './entities/first-mile.entity';
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
import { FirstMileVehicleAssignment } from './entities/first-mile-vehicle-assignment.entity';
import { FirstMileController } from './first-mile.controller';
import { FirstMileInvoiceService } from './first-mile-invoice.service';
import { FirstMileRepository } from './first-mile.repository';
@@ -15,7 +16,7 @@ import { FirstMileService } from './first-mile.service';
@Module({
imports: [
TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]),
TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation, FirstMileVehicleAssignment]),
forwardRef(() => BillingModule),
forwardRef(() => BookingsModule),
VehiclesModule,

View File

@@ -1,5 +1,5 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere, IsNull, Not } from 'typeorm';
import { FindOptionsWhere, In, IsNull, Not } from 'typeorm';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
@@ -11,6 +11,7 @@ import { CreateFirstMileDto } from "./dto/create-first-mile.dto";
import { UpdateFirstMileDto } from "./dto/update-first-mile.dto";
import { FirstMile, FirstMileStatus } from "./entities/first-mile.entity";
import { FirstMileContainerAllocation } from "./entities/first-mile-container-allocation.entity";
import { FirstMileVehicleAssignment } from "./entities/first-mile-vehicle-assignment.entity";
import { FirstMileRepository } from "./first-mile.repository";
import { OnEvent } from "@nestjs/event-emitter";
import { BillingService, InvoiceEventPayload } from "../billing/billing.service";
@@ -92,10 +93,15 @@ export class FirstMileService {
directVehicleId?: string | null,
): Promise<boolean> {
if (directVehicleId) return true;
const count = await this.dataSource.manager.count(FirstMileContainerAllocation, {
where: { firstMileId: recordId, vehicleId: Not(IsNull()) },
});
return count > 0;
const [junction, allocations] = await Promise.all([
this.dataSource.manager.count(FirstMileVehicleAssignment, {
where: { firstMileId: recordId },
}),
this.dataSource.manager.count(FirstMileContainerAllocation, {
where: { firstMileId: recordId, vehicleId: Not(IsNull()) },
}),
]);
return junction > 0 || allocations > 0;
}
/** Human booking reference for a first-mile record, for the history timeline. */
@@ -204,6 +210,7 @@ export class FirstMileService {
cargoType: true,
},
vehicle: true,
vehicleAssignments: { vehicle: true },
},
order: { [sortBy]: sortOrder },
skip: (page - 1) * pageSize,
@@ -250,6 +257,7 @@ export class FirstMileService {
cargoType: true,
},
vehicle: true,
vehicleAssignments: { vehicle: true },
},
});
@@ -490,18 +498,154 @@ export class FirstMileService {
* allocations), unless still in use by another active trip.
*/
private async releaseVehicles(record: FirstMile): Promise<void> {
const recordAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, {
where: { firstMileId: record.id },
});
const vehicleIds = recordAllocations
.map((a) => a.vehicleId)
.filter((id): id is string => Boolean(id));
if (record.vehicleId) {
vehicleIds.push(record.vehicleId);
}
const [assignments, recordAllocations] = await Promise.all([
this.dataSource.manager.find(FirstMileVehicleAssignment, {
where: { firstMileId: record.id },
}),
this.dataSource.manager.find(FirstMileContainerAllocation, {
where: { firstMileId: record.id },
}),
]);
const vehicleIds = [
...new Set(
[
...assignments.map((a) => a.vehicleId),
...recordAllocations.map((a) => a.vehicleId),
record.vehicleId ?? null,
].filter((id): id is string => Boolean(id)),
),
];
await this.vehiclesService.releaseIfUnused(vehicleIds);
}
/**
* Replace the full set of vehicles serving a first-mile pickup (multi-truck).
* Diffs against the current junction rows, syncing availability + audit history
* 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,
inputs: Array<{ vehicleId: string; containerNumber?: string | null }>,
): Promise<FirstMile> {
const existing = await this.findById(id);
// Dedupe by vehicleId, keeping the container number; preserve order.
const desiredMap = new Map<string, string | null>();
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(FirstMileVehicleAssignment, {
where: { firstMileId: id },
});
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) {
await tx.delete(FirstMileVehicleAssignment, {
firstMileId: id,
vehicleId: In(removed),
});
}
for (const vehicleId of added) {
await tx.insert(FirstMileVehicleAssignment, {
firstMileId: id,
vehicleId,
containerNumber: desiredMap.get(vehicleId) ?? null,
});
}
for (const row of changed) {
await tx.update(
FirstMileVehicleAssignment,
{ firstMileId: id, vehicleId: row.vehicleId },
{ containerNumber: desiredMap.get(row.vehicleId) ?? null },
);
}
});
// Legacy primary vehicle = first of the set (null when cleared).
await this.firstMileRepository.update(id, { vehicleId: desired[0] ?? null } as any);
const bookingRef = await this.resolveBookingRef(existing);
for (const vehicleId of added) {
await this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY);
void this.notifyDriverAssignment(vehicleId, existing);
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
vehicleId,
firstMileId: id,
driverId: info.driverId,
label: existing.status,
metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
});
}
for (const vehicleId of removed) {
await this.vehiclesService.releaseIfUnused([vehicleId]);
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
vehicleId,
firstMileId: id,
driverId: info.driverId,
metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
});
}
return this.findById(id);
}
/**
* Record each truck's actual distance. The pickup 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<FirstMile> {
await this.findById(id);
// Distances are locked once the invoice exists.
const invoices = await this.billing.findBySourceIds('first_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(
FirstMileVehicleAssignment,
{ firstMileId: id, vehicleId: d.vehicleId },
{ distanceKm: d.distanceKm },
);
}
const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0);
await this.firstMileRepository.update(id, {
exactKm: total,
...(remainingPayment != null ? { remainingPayment } : {}),
} as any);
return this.findById(id);
}
private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise<void> {
try {
const vehicle = await this.vehiclesService.findById(vehicleId);
@@ -570,8 +714,44 @@ export class FirstMileService {
);
}
// Every vehicle this pickup holds — junction + legacy + container rows.
const [assignments, allocations] = await Promise.all([
this.dataSource.manager.find(FirstMileVehicleAssignment, {
where: { firstMileId: id },
}),
this.dataSource.manager.find(FirstMileContainerAllocation, {
where: { firstMileId: id },
}),
]);
const vehicleIds = [
...new Set(
[
...assignments.map((a) => a.vehicleId),
...allocations.map((a) => a.vehicleId),
existing.vehicleId ?? null,
].filter((v): v is string => Boolean(v)),
),
];
await this.firstMileRepository.softDelete(id);
// Free the trucks it was holding (direct + container), unless still in use.
await this.releaseVehicles(existing);
if (assignments.length) {
await this.dataSource.manager.softDelete(FirstMileVehicleAssignment, { firstMileId: id });
}
// Free every vehicle no longer held by another active trip and audit release.
if (vehicleIds.length) {
await this.vehiclesService.releaseIfUnused(vehicleIds);
const bookingRef = await this.resolveBookingRef(existing);
for (const vehicleId of vehicleIds) {
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
vehicleId,
firstMileId: id,
driverId: info.driverId,
metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
});
}
}
}
}