Files
edr-platform/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts
yaschalew 49036abf7f fix
2026-07-02 18:07:21 +03:00

439 lines
14 KiB
TypeScript

import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere, In } from 'typeorm';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
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;
bookingId?: string;
vehicleId?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: string;
};
const SORTABLE_FIELDS: (keyof FirstMile)[] = [
"status",
"advancedPayment",
"remainingPayment",
"createdAt",
];
@Injectable()
export class FirstMileService {
private readonly logger = new Logger(FirstMileService.name);
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly firstMileRepository: FirstMileRepository,
private readonly bookingsRepository: BookingsRepository,
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 | null> {
const booking = await this.bookingsRepository.findById(bookingId, {
relations: { serviceType: true },
});
if (!booking) {
return null;
}
return this.acceptEligibleBooking(booking);
}
async acceptBookingByReference(
bookingReference: string,
): Promise<FirstMile | null> {
const [booking] = await this.bookingsRepository.findAll({
where: { reference: bookingReference },
relations: { serviceType: true },
take: 1,
});
if (!booking) {
throw new NotFoundException(`Booking ${bookingReference} not found`);
}
return this.acceptEligibleBooking(booking);
}
/**
* Shared accept path: validates payment + first-mile eligibility, rejects an
* already-assigned booking, then creates the first-mile record. Throws a
* meaningful HTTP error instead of returning null so the client can surface
* why an accept was refused.
*/
private async acceptEligibleBooking(booking: {
id: string;
reference?: string;
paymentStatus?: string | null;
tradeDirection?: string | null;
firstMilePickupAddress?: string | null;
serviceType?: { includesFirstMile?: boolean | null } | null;
}): Promise<FirstMile | null> {
if (booking.paymentStatus !== "PAID") {
return null;
}
if (!this.bookingRequestsFirstMile(booking)) {
return null;
}
const existing = await this.findByBookingId(booking.id);
if (existing) {
return null;
}
return this.create({
bookingId: booking.id,
advancedPayment: 0,
});
}
async findAll(filter: FirstMileListFilter = {}): Promise<{
data: FirstMile[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
}> {
const page = filter.page ?? 1;
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";
const where: FindOptionsWhere<FirstMile> = {};
if (filter.status) where.status = filter.status;
if (filter.bookingId) where.bookingId = filter.bookingId;
if (filter.vehicleId) where.vehicleId = filter.vehicleId;
const [data, total] = await this.firstMileRepository.findAndCount({
where,
relations: {
booking: {
company: true,
serviceType: true,
originYard: true,
destinationYard: true,
cargoType: true,
},
vehicle: true,
},
order: { [sortBy]: sortOrder },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
data,
meta: {
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
},
};
}
@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})`,
);
} catch (err) {
this.logger.error(
`Failed to update first-mile payment status for record ${payload.sourceId}: ${String(err)}`,
);
}
}
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,
},
vehicle: true,
},
});
if (!record) {
throw new NotFoundException(`First-mile record ${id} not found`);
}
return record;
}
async create(dto: CreateFirstMileDto): Promise<FirstMile> {
const existing = await this.findByBookingId(dto.bookingId);
if (existing) {
return existing;
}
const record = await this.firstMileRepository.create({
bookingId: dto.bookingId,
status: dto.status ?? "READY_TO_TRANSIT",
advancedPayment: dto.advancedPayment ?? 0,
remainingPayment: dto.remainingPayment ?? 0,
estimatedKm: dto.estimatedKm ?? null,
exactKm: dto.exactKm ?? null,
vehicleId: dto.vehicleId ?? null,
paid: (dto as any).paid ?? false,
});
if (dto.vehicleId) {
await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
}
return record;
}
private async findByBookingId(bookingId: string): Promise<FirstMile | null> {
const [records] = await this.firstMileRepository.findAndCount({
where: { bookingId },
relations: {
booking: {
company: true,
serviceType: true,
originYard: true,
destinationYard: true,
cargoType: true,
},
vehicle: true,
},
take: 1,
});
return records[0] ?? null;
}
private bookingRequestsFirstMile(booking: {
tradeDirection?: string | null;
firstMilePickupAddress?: string | null;
serviceType?: { includesFirstMile?: boolean | null } | null;
}): boolean {
return Boolean(
booking.tradeDirection === 'EXPORT' &&
(booking.firstMilePickupAddress?.trim() ||
booking.serviceType?.includesFirstMile),
);
}
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
const existing = await this.findById(id);
const dtoAny = dto as any;
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.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}),
} as any);
if (!updated) {
throw new NotFoundException(`First-mile record ${id} not found`);
}
// Keep vehicle statuses in sync: new vehicle goes BUSY, replaced one goes back to FREE
if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) {
if (dto.vehicleId) {
await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
}
if (existing.vehicleId) {
await this.vehiclesService.releaseIfUnused([existing.vehicleId]);
}
}
// Notify assigned driver on every explicit vehicle assignment or reassignment
if (dto.vehicleId) {
void this.notifyDriverAssignment(dto.vehicleId, existing);
}
// Trip finished — release the vehicles it was holding
if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
await this.releaseVehicles(updated);
}
return updated;
}
async updateStatus(id: string, status: FirstMileStatus): Promise<FirstMile> {
const existing = await this.findById(id);
const updated = await this.firstMileRepository.update(id, { status });
if (!updated) {
throw new NotFoundException(`First-mile record ${id} not found`);
}
if (status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
await this.releaseVehicles(updated);
}
return updated;
}
/**
* Free every vehicle held by this record (direct assignment + container
* 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);
}
await this.vehiclesService.releaseIfUnused(vehicleIds);
}
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`,
);
return;
}
const driver = await this.driversService.findById(
vehicle.assignedDriverId,
);
if (!driver.phoneNumber) {
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 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}.`
: "");
void this.smsClient.sendSms({
to: driver.phoneNumber,
message,
});
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)}`,
);
}
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.firstMileRepository.softDelete(id);
}
async allocateContainers(
firstMileId: string,
allocations: Array<{ containerId: string; vehicleId: string }>,
) {
const firstMile = await this.findById(firstMileId);
if (!firstMile) {
throw new NotFoundException(`First-mile record ${firstMileId} not found`);
}
const previousAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, {
where: {
firstMileId,
containerId: In(allocations.map((a) => a.containerId)),
},
});
const previousVehicleIds = previousAllocations
.map((a) => a.vehicleId)
.filter((id): id is string => Boolean(id));
await this.dataSource.transaction(async (manager) => {
for (const allocation of allocations) {
await manager.delete(FirstMileContainerAllocation, {
firstMileId,
containerId: allocation.containerId,
});
await manager.insert(FirstMileContainerAllocation, {
firstMileId,
containerId: allocation.containerId,
vehicleId: allocation.vehicleId,
containerType: "CONTAINER",
quantity: 1,
});
}
});
const vehicleIds = new Set(allocations.map((a) => a.vehicleId));
await Promise.all(
[...vehicleIds].map((vehicleId) => this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY)),
);
await this.vehiclesService.releaseIfUnused(
previousVehicleIds.filter((id) => !vehicleIds.has(id)),
);
return {
success: true,
allocated: allocations.length,
};
}
}