This commit is contained in:
natib21
2026-07-03 19:17:45 +00:00
parent 2550172480
commit a1dfb439ff
9 changed files with 289 additions and 131 deletions

View File

@@ -0,0 +1,8 @@
import { IsArray, IsUUID } from 'class-validator';
/** Replace the full set of vehicles assigned to a last-mile delivery. */
export class SetVehiclesDto {
@IsArray()
@IsUUID('4', { each: true })
vehicleIds!: string[];
}

View File

@@ -18,6 +18,7 @@ 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 { LastMileStatus } from './entities/last-mile.entity';
import { LastMileService } from './last-mile.service';
import { LastMileInvoiceService } from './last-mile-invoice.service';
@@ -136,4 +137,14 @@ export class LastMileController {
) {
return this.lastMileService.allocateContainers(id, dto.allocations);
}
@Post(':id/vehicles')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Set the vehicles assigned to a last-mile delivery (multi-truck)' })
async setVehicles(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SetVehiclesDto,
) {
return this.lastMileService.setVehicles(id, dto.vehicleIds);
}
}

View File

@@ -8,6 +8,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
import { VehiclesModule } from '../vehicles/vehicles.module';
import { LastMile } from './entities/last-mile.entity';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
import { LastMileController } from './last-mile.controller';
import { LastMileInvoiceService } from './last-mile-invoice.service';
import { LastMileRepository } from './last-mile.repository';
@@ -15,7 +16,7 @@ import { LastMileService } from './last-mile.service';
@Module({
imports: [
TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation]),
TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation, LastMileVehicleAssignment]),
BillingModule,
forwardRef(() => BookingsModule),
VehiclesModule,

View File

@@ -10,6 +10,7 @@ import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
import { LastMileRepository } from './last-mile.repository';
import { InvoiceEventPayload } from '../billing/billing.service';
import { OnEvent } from '@nestjs/event-emitter';
@@ -149,8 +150,9 @@ export class LastMileService {
const [data, total] = await this.lastMileRepository.findAndCount({
where,
relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: true },
vehicle: true,
vehicleAssignments: { vehicle: true },
},
order: { [sortBy]: sortOrder },
skip: (page - 1) * pageSize,
@@ -171,8 +173,9 @@ export class LastMileService {
async findById(id: string): Promise<LastMile> {
const record = await this.lastMileRepository.findById(id, {
relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: true },
vehicle: true,
vehicleAssignments: { vehicle: true },
},
});
@@ -353,6 +356,70 @@ export class LastMileService {
await this.vehiclesService.releaseIfUnused(vehicleIds);
}
/**
* Replace the full set of vehicles serving a last-mile delivery (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, vehicleIds: string[]): Promise<LastMile> {
const existing = await this.findById(id);
const desired = [...new Set(vehicleIds.filter(Boolean))];
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));
await this.dataSource.transaction(async (tx) => {
if (removed.length) {
await tx.delete(LastMileVehicleAssignment, {
lastMileId: id,
vehicleId: In(removed),
});
}
for (const vehicleId of added) {
await tx.insert(LastMileVehicleAssignment, { lastMileId: id, vehicleId });
}
});
// Legacy primary vehicle = first of the set (null when cleared).
await this.lastMileRepository.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,
lastMileId: id,
driverId: info.driverId,
label: existing.status,
metadata: { mile: 'LAST', 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,
lastMileId: id,
driverId: info.driverId,
metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
});
}
return this.findById(id);
}
private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise<void> {
try {
const vehicle = await this.vehiclesService.findById(vehicleId);

View File

@@ -8,6 +8,11 @@ import { EmailClientService } from "./email-client.service";
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
// Fall back to a sane broker URL so an unset RABBITMQ_URL can't produce
// `urls: [undefined]` (which crashes amqp-connection-manager on 'heartbeat').
const RABBITMQ_URL =
process.env.RABBITMQ_URL ?? process.env.PAYMENT_RABBITMQ_URL ?? "amqp://localhost:5672";
@Module({
imports: [
ConfigModule,
@@ -16,7 +21,7 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"
name: "SMS_SERVICE",
transport: Transport.RMQ,
options: {
urls: [process.env.RABBITMQ_URL as string],
urls: [RABBITMQ_URL],
queue: process.env.SMS_QUEUE ?? "sms_queue",
queueOptions: { durable: true },
},
@@ -25,7 +30,7 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"
name: "EMAIL_SERVICE",
transport: Transport.RMQ,
options: {
urls: [process.env.RABBITMQ_URL as string],
urls: [RABBITMQ_URL],
queue: process.env.EMAIL_QUEUE ?? "email_queue",
queueOptions: { durable: true },
},