fix: first-mile error

This commit is contained in:
ghost2023
2026-07-02 16:25:45 +03:00
parent 79c3293a72
commit b75a3ab54b

View File

@@ -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,
});
}