mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 21:20:57 +00:00
fix: first-mile error
This commit is contained in:
@@ -1,19 +1,25 @@
|
||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { FindOptionsWhere } from 'typeorm';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { FindOptionsWhere } from "typeorm";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { DriversService } from '../drivers/drivers.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { VehiclesService } from '../vehicles/vehicles.service';
|
||||
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 { FirstMileRepository } from './first-mile.repository';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { InvoiceEventPayload } from '../billing/billing.service';
|
||||
import { BookingsRepository } from "../bookings/bookings.repository";
|
||||
import { DriversService } from "../drivers/drivers.service";
|
||||
import { SmsClientService } from "../notifications/sms-client.service";
|
||||
import { VehiclesService } from "../vehicles/vehicles.service";
|
||||
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 { FirstMileRepository } from "./first-mile.repository";
|
||||
import { OnEvent } from "@nestjs/event-emitter";
|
||||
import { InvoiceEventPayload } from "../billing/billing.service";
|
||||
|
||||
type FirstMileListFilter = {
|
||||
status?: FirstMileStatus;
|
||||
@@ -26,10 +32,10 @@ type FirstMileListFilter = {
|
||||
};
|
||||
|
||||
const SORTABLE_FIELDS: (keyof FirstMile)[] = [
|
||||
'status',
|
||||
'advancedPayment',
|
||||
'remainingPayment',
|
||||
'createdAt',
|
||||
"status",
|
||||
"advancedPayment",
|
||||
"remainingPayment",
|
||||
"createdAt",
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
@@ -43,26 +49,28 @@ export class FirstMileService {
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
private readonly driversService: DriversService,
|
||||
private readonly smsClient: SmsClientService,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Look up a booking by its human-readable reference and confirm it has been
|
||||
* paid before any first-mile work proceeds. Throws if the reference is
|
||||
* unknown or the booking has not reached PAID status.
|
||||
*/
|
||||
async acceptBooking(bookingId: string): Promise<FirstMile> {
|
||||
async acceptBooking(bookingId: string): Promise<FirstMile | null> {
|
||||
const booking = await this.bookingsRepository.findById(bookingId, {
|
||||
relations: { serviceType: true },
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.acceptEligibleBooking(booking);
|
||||
}
|
||||
|
||||
async acceptBookingByReference(bookingReference: string): Promise<FirstMile> {
|
||||
async acceptBookingByReference(
|
||||
bookingReference: string,
|
||||
): Promise<FirstMile | null> {
|
||||
const [booking] = await this.bookingsRepository.findAll({
|
||||
where: { reference: bookingReference },
|
||||
relations: { serviceType: true },
|
||||
@@ -89,20 +97,20 @@ export class FirstMileService {
|
||||
tradeDirection?: string | null;
|
||||
firstMilePickupAddress?: string | null;
|
||||
serviceType?: { includesFirstMile?: boolean | null } | null;
|
||||
}): Promise<FirstMile> {
|
||||
}): Promise<FirstMile | null> {
|
||||
const label = booking.reference ?? booking.id;
|
||||
|
||||
if (booking.paymentStatus !== 'PAID') {
|
||||
throw new BadRequestException(`Booking ${label} is not paid`);
|
||||
if (booking.paymentStatus !== "PAID") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.bookingRequestsFirstMile(booking)) {
|
||||
throw new BadRequestException(`Booking ${label} does not require a first mile`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = await this.findByBookingId(booking.id);
|
||||
if (existing) {
|
||||
throw new ConflictException(`Booking ${label} already has a first-mile assignment`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.create({
|
||||
@@ -118,8 +126,9 @@ export class FirstMileService {
|
||||
const pageSize = filter.pageSize ?? 50;
|
||||
const sortBy = SORTABLE_FIELDS.includes(filter.sortBy as keyof FirstMile)
|
||||
? (filter.sortBy as keyof FirstMile)
|
||||
: 'createdAt';
|
||||
const sortOrder = filter.sortOrder?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
|
||||
: "createdAt";
|
||||
const sortOrder =
|
||||
filter.sortOrder?.toUpperCase() === "ASC" ? "ASC" : "DESC";
|
||||
|
||||
const where: FindOptionsWhere<FirstMile> = {};
|
||||
if (filter.status) where.status = filter.status;
|
||||
@@ -129,7 +138,13 @@ export class FirstMileService {
|
||||
const [data, total] = await this.firstMileRepository.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,
|
||||
},
|
||||
vehicle: true,
|
||||
},
|
||||
order: { [sortBy]: sortOrder },
|
||||
@@ -151,8 +166,12 @@ export class FirstMileService {
|
||||
@OnEvent("firstmile.invoice.paid")
|
||||
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
try {
|
||||
await this.firstMileRepository.update(payload.sourceId, { paid: true } as any);
|
||||
this.logger.log(`Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`);
|
||||
await this.firstMileRepository.update(payload.sourceId, {
|
||||
paid: true,
|
||||
} as any);
|
||||
this.logger.log(
|
||||
`Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to update first-mile payment status for record ${payload.sourceId}: ${String(err)}`,
|
||||
@@ -163,7 +182,13 @@ export class FirstMileService {
|
||||
async findById(id: string): Promise<FirstMile> {
|
||||
const record = await this.firstMileRepository.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,
|
||||
},
|
||||
vehicle: true,
|
||||
},
|
||||
});
|
||||
@@ -183,7 +208,7 @@ export class FirstMileService {
|
||||
|
||||
return this.firstMileRepository.create({
|
||||
bookingId: dto.bookingId,
|
||||
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||
status: dto.status ?? "READY_TO_TRANSIT",
|
||||
advancedPayment: dto.advancedPayment ?? 0,
|
||||
remainingPayment: dto.remainingPayment ?? 0,
|
||||
estimatedKm: dto.estimatedKm ?? null,
|
||||
@@ -197,7 +222,13 @@ export class FirstMileService {
|
||||
const [records] = await this.firstMileRepository.findAndCount({
|
||||
where: { bookingId },
|
||||
relations: {
|
||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
|
||||
booking: {
|
||||
company: true,
|
||||
serviceType: true,
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
cargoType: true,
|
||||
},
|
||||
vehicle: true,
|
||||
},
|
||||
take: 1,
|
||||
@@ -213,9 +244,9 @@ export class FirstMileService {
|
||||
// Export bookings always need a first mile (pickup → origin yard); the
|
||||
// pickup address is captured at assignment time, not required upfront.
|
||||
return Boolean(
|
||||
booking.tradeDirection === 'EXPORT' ||
|
||||
booking.firstMilePickupAddress?.trim() ||
|
||||
booking.serviceType?.includesFirstMile,
|
||||
booking.tradeDirection === "EXPORT" ||
|
||||
booking.firstMilePickupAddress?.trim() ||
|
||||
booking.serviceType?.includesFirstMile,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -226,9 +257,15 @@ export class FirstMileService {
|
||||
const updated = await this.firstMileRepository.update(id, {
|
||||
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||
...(dto.advancedPayment !== undefined ? { advancedPayment: dto.advancedPayment } : {}),
|
||||
...(dto.remainingPayment !== undefined ? { remainingPayment: dto.remainingPayment } : {}),
|
||||
...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}),
|
||||
...(dto.advancedPayment !== undefined
|
||||
? { advancedPayment: dto.advancedPayment }
|
||||
: {}),
|
||||
...(dto.remainingPayment !== undefined
|
||||
? { remainingPayment: dto.remainingPayment }
|
||||
: {}),
|
||||
...(dto.estimatedKm !== undefined
|
||||
? { estimatedKm: dto.estimatedKm }
|
||||
: {}),
|
||||
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
|
||||
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
|
||||
...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}),
|
||||
@@ -256,37 +293,63 @@ export class FirstMileService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise<void> {
|
||||
private async notifyDriverAssignment(
|
||||
vehicleId: string,
|
||||
record: FirstMile,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const vehicle = await this.vehiclesService.findById(vehicleId);
|
||||
if (!vehicle.assignedDriverId) {
|
||||
this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`);
|
||||
this.logger.warn(
|
||||
`Vehicle ${vehicleId} has no assigned driver — skipping SMS`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const driver = await this.driversService.findById(vehicle.assignedDriverId);
|
||||
const driver = await this.driversService.findById(
|
||||
vehicle.assignedDriverId,
|
||||
);
|
||||
if (!driver.phoneNumber) {
|
||||
this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`);
|
||||
this.logger.warn(
|
||||
`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking;
|
||||
const booking = (
|
||||
record as FirstMile & {
|
||||
booking?: {
|
||||
reference?: string;
|
||||
firstMilePickupAddress?: string | null;
|
||||
originYard?: { label?: string } | null;
|
||||
};
|
||||
}
|
||||
).booking;
|
||||
|
||||
const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim();
|
||||
const driverName =
|
||||
`${driver.firstName ?? ""} ${driver.lastName ?? ""}`.trim();
|
||||
const message =
|
||||
`Dear ${driverName}, you have been assigned to a first-mile pickup. ` +
|
||||
`Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` +
|
||||
(booking?.firstMilePickupAddress ? `Pickup: ${booking.firstMilePickupAddress}. ` : '') +
|
||||
(booking?.originYard?.label ? `Destination: ${booking.originYard.label}.` : '');
|
||||
(booking?.firstMilePickupAddress
|
||||
? `Pickup: ${booking.firstMilePickupAddress}. `
|
||||
: "") +
|
||||
(booking?.originYard?.label
|
||||
? `Destination: ${booking.originYard.label}.`
|
||||
: "");
|
||||
|
||||
void this.smsClient.sendSms({
|
||||
to: driver.phoneNumber,
|
||||
message,
|
||||
});
|
||||
|
||||
this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
|
||||
this.logger.log(
|
||||
`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
|
||||
this.logger.error(
|
||||
`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +377,7 @@ export class FirstMileService {
|
||||
firstMileId,
|
||||
containerId: allocation.containerId,
|
||||
vehicleId: allocation.vehicleId,
|
||||
containerType: 'CONTAINER',
|
||||
containerType: "CONTAINER",
|
||||
quantity: 1,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user