Files
edr-platform/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts
Hagernesh cf8a2e928d fix(warehouses): detention groups by canonical truck type
Join truck_types via vehicles.truck_type_id (normalized legacy
vehicle_type only as fallback) so type renames can't unmatch detention
rules and FK-less vehicles keep billing.
2026-07-24 11:55:05 +00:00

660 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager, IsNull } from 'typeorm';
import { NotificationAudience, NotificationType } from '@edr/types';
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
import {
EDR_HAULAGE_CONFLICT_MESSAGE,
usesEdrMileService,
} from '../../common/mile-haulage.util';
import {
assertBulkTonnageRemains,
assertTruckCountWithinContainers,
assertTruckLoad,
bookingContainerSizes,
remainingBulkTons,
} from '../../common/truck-load.util';
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
interface BookingGuardRow {
tradeDirection: string | null;
freightType: string | null;
firstMile: string | null;
lastMile: string | null;
paymentStatus: string | null;
status: string | null;
}
/**
* Multi-truck self-haul assignment. A booking with no EDR first/last-mile leg
* can have several customer trucks, each carrying 12 of its containers and
* tracking its own arrival. The legacy booking.customer_truck_* columns are kept
* as a booking-level flag (any truck assigned / all arrived) so the warehouse
* exit-gate + delivery-approval logic keep working unchanged.
*/
@Injectable()
export class CustomerTruckService {
private readonly logger = new Logger(CustomerTruckService.name);
constructor(
private readonly dataSource: DataSource,
private readonly assignments: CustomerTruckAssignmentsRepository,
private readonly inbox: NotificationInboxService,
private readonly notifications: NotificationsService,
) {}
listTrucks(bookingId: string): Promise<CustomerTruckAssignment[]> {
return this.assignments.findByBookingId(bookingId);
}
async addTruck(bookingId: string, dto: AddCustomerTruckDto): Promise<CustomerTruckAssignment[]> {
const booking = await this.loadBookingGuard(bookingId);
this.assertSelfHaulPaid(booking);
this.assertAssignmentWindow(booking);
// Bulk bookings have no containers — the truck hauls loose tonnage and is
// weighed out on departure (gross_weight_kg). Container bookings assign the
// 12 specific containers each truck carries.
const isBulk = booking.freightType === 'BULK';
const requested = isBulk
? []
: (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
// Container capacity is size-based: a 40ft container fills the truck (max 1);
// two 20ft containers fit (max 2), no size mixing. #trucks <= #containers
// follows naturally since each container is assigned to exactly one truck.
if (!isBulk && requested.length < 1) {
throw new BadRequestException('Select at least one container for this truck');
}
// Bulk is capped by tonnage, not container count: trucks may be added until
// the booking's declared weight has been hauled away. Container bookings are
// capped below by #trucks <= #containers.
if (isBulk) {
const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId);
assertBulkTonnageRemains(totalTons, remainingTons);
// Assignment-time drawdown: planned tonnage across live trucks (weighed
// net once departed, planned before) may not exceed the declared total.
if (totalTons > 0) {
const [p]: Array<{ planned: string | null }> = await this.dataSource.query(
`SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned
FROM freight.customer_truck_assignments a
WHERE a.booking_id = $1 AND a.deleted_at IS NULL`,
[bookingId],
);
const alreadyPlanned = Number(p?.planned ?? 0);
const requestedTons = Number(dto.plannedTons ?? 0);
if (requestedTons > 0 && alreadyPlanned + requestedTons > totalTons + 0.001) {
throw new BadRequestException(
`Planned tonnage exceeds the booking: ${alreadyPlanned} t already assigned of ${totalTons} t — at most ${Math.max(0, totalTons - alreadyPlanned)} t left for this truck`,
);
}
}
}
if (requested.length) {
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
const existingTrucks = await this.dataSource
.getRepository(CustomerTruckAssignment)
.count({ where: { bookingId } });
assertTruckCountWithinContainers(existingTrucks + 1, bookingNumbers.length);
assertTruckLoad({
containers: requested,
bookingContainers: bookingNumbers,
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
assignedElsewhere: await this.assignedContainerNumbers(bookingId),
});
}
await this.dataSource.transaction(async (manager) => {
const assignment = await manager.getRepository(CustomerTruckAssignment).save(
manager.getRepository(CustomerTruckAssignment).create({
bookingId,
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
driverName: dto.driverName.trim(),
truckType: dto.truckType.trim(),
plannedTons: isBulk ? (dto.plannedTons ?? null) : null,
plannedQuantity: isBulk ? (dto.plannedQuantity ?? null) : null,
}),
);
await manager.getRepository(CustomerTruckContainer).save(
requested.map((containerNumber) =>
manager.getRepository(CustomerTruckContainer).create({
assignmentId: assignment.id,
bookingId,
containerNumber,
}),
),
);
// Booking-level flag: first truck marks the booking as truck-assigned.
await manager.query(
`UPDATE freight.bookings
SET customer_truck_assigned_at = COALESCE(customer_truck_assigned_at, NOW()),
status = CASE WHEN status = 'PAID' THEN 'TRUCK_ASSIGNED' ELSE status END,
updated_at = NOW()
WHERE id = $1`,
[bookingId],
);
});
return this.listTrucks(bookingId);
}
async removeTruck(bookingId: string, assignmentId: string): Promise<CustomerTruckAssignment[]> {
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
if (!assignment || assignment.bookingId !== bookingId) {
throw new NotFoundException('Truck assignment not found for this booking');
}
if (assignment.arrivedAt) {
throw new ConflictException('Cannot remove a truck that has already arrived');
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
await manager.getRepository(CustomerTruckAssignment).softDelete(assignmentId);
const remaining = await manager
.getRepository(CustomerTruckAssignment)
.count({ where: { bookingId } });
if (remaining === 0) {
// No trucks left — clear the booking-level flag and revert the status.
await manager.query(
`UPDATE freight.bookings
SET customer_truck_assigned_at = NULL,
status = CASE WHEN status = 'TRUCK_ASSIGNED' THEN 'PAID' ELSE status END,
updated_at = NOW()
WHERE id = $1`,
[bookingId],
);
}
});
return this.listTrucks(bookingId);
}
/**
* Edit a truck assignment — plate/driver/type and the containers it carries.
* Allowed only until the truck has arrived (same guard as removal). Container
* rules mirror {@link addTruck}: 12 of the booking's containers, none already
* on another truck, and a 40ft container fills the truck (max 1).
*/
async updateTruck(
bookingId: string,
assignmentId: string,
dto: AddCustomerTruckDto,
): Promise<CustomerTruckAssignment[]> {
const booking = await this.loadBookingGuard(bookingId);
this.assertSelfHaulPaid(booking);
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
if (!assignment || assignment.bookingId !== bookingId) {
throw new NotFoundException('Truck assignment not found for this booking');
}
if (assignment.arrivedAt) {
throw new ConflictException('Cannot edit a truck that has already arrived');
}
// Bulk trucks carry loose tonnage, not containers — planned tonnage is
// editable instead, capped by what the other trucks haven't claimed.
const isBulk = booking.freightType === 'BULK';
const requested = isBulk
? []
: (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (!isBulk && requested.length < 1) {
throw new BadRequestException('Select at least one container for this truck');
}
if (!isBulk) {
assertTruckLoad({
containers: requested,
bookingContainers: await this.bookingContainerNumbers(bookingId),
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
// Exclude THIS truck's own containers so re-saving the same set is allowed.
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
});
} else if (dto.plannedTons != null) {
const { totalTons } = await remainingBulkTons(this.dataSource, bookingId);
if (totalTons > 0) {
const [p]: Array<{ planned: string | null }> = await this.dataSource.query(
`SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned
FROM freight.customer_truck_assignments a
WHERE a.booking_id = $1 AND a.deleted_at IS NULL AND a.id <> $2`,
[bookingId, assignmentId],
);
const others = Number(p?.planned ?? 0);
if (others + Number(dto.plannedTons) > totalTons + 0.001) {
throw new BadRequestException(
`Planned tonnage exceeds the booking: ${others} t on other trucks of ${totalTons} t — at most ${Math.max(0, totalTons - others)} t left for this truck`,
);
}
}
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
driverName: dto.driverName.trim(),
truckType: dto.truckType.trim(),
...(isBulk
? {
plannedTons: dto.plannedTons ?? null,
plannedQuantity: dto.plannedQuantity ?? null,
}
: {}),
});
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
await manager.getRepository(CustomerTruckContainer).save(
requested.map((containerNumber) =>
manager.getRepository(CustomerTruckContainer).create({
assignmentId,
bookingId,
containerNumber,
}),
),
);
});
return this.listTrucks(bookingId);
}
/**
* Register an IMPORT self-haul truck leaving the port: the containers it
* actually loaded (replacing any provisional list) and its weighed gross.
* Export bookings have no truck departure — trucks only deliver (receive).
*/
async departTruck(
bookingId: string,
assignmentId: string,
dto: DepartCustomerTruckDto,
): Promise<CustomerTruckAssignment[]> {
const booking = await this.loadBookingGuard(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException(
'Truck departure/weighing applies to import self-haul only (export trucks only deliver)',
);
}
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
if (!assignment || assignment.bookingId !== bookingId) {
throw new NotFoundException('Truck assignment not found for this booking');
}
// Once filled, the departure record is uneditable.
if (assignment.departedAt) {
throw new ConflictException('This truck has already departed — its exit record is locked');
}
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (requested.length) {
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
for (const n of requested) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
}
}
const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
for (const n of requested) {
if (elsewhere.includes(n)) {
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
}
}
}
await this.dataSource.transaction(async (manager) => {
if (requested.length) {
// Replace the truck's containers with what was actually loaded.
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
await manager.getRepository(CustomerTruckContainer).save(
requested.map((containerNumber) =>
manager.getRepository(CustomerTruckContainer).create({
assignmentId,
bookingId,
containerNumber,
}),
),
);
}
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
grossWeightKg: dto.grossWeightKg,
departedAt: dto.gateOutTime ? new Date(dto.gateOutTime) : new Date(),
arrivedAt: assignment.arrivedAt ?? new Date(),
});
});
return this.listTrucks(bookingId);
}
/** Booking container numbers not yet loaded onto any truck. */
async getLoadableContainers(bookingId: string): Promise<string[]> {
const [all, assigned] = await Promise.all([
this.bookingContainerNumbers(bookingId),
this.assignedContainerNumbers(bookingId),
]);
const taken = new Set(assigned);
return all.filter((n) => !taken.has(n));
}
/**
* Truck_dispatch (load): assign the selected containers to a truck after it has
* arrived, and set a provisional gross weight from their VGM. The truck is
* weighed for real on departure. Locked once the truck has left.
*/
async loadTruck(
bookingId: string,
assignmentId: string,
dto: { containerNumbers: string[] },
): Promise<CustomerTruckAssignment[]> {
await this.loadBookingGuard(bookingId);
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
if (!assignment || assignment.bookingId !== bookingId) {
throw new NotFoundException('Truck assignment not found for this booking');
}
if (assignment.departedAt) {
throw new ConflictException('This truck has already left — its load is locked');
}
// Loading a truck at the warehouse implies it is physically present, so a
// truck that is still only assigned (not yet marked arrived) is auto-arrived
// here rather than blocking the operator — the real gross is weighed on
// departure anyway.
const needsArrival = !assignment.arrivedAt;
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (!requested.length) {
throw new BadRequestException('Select at least one container to load onto the truck');
}
// Capacity is size-based: a truck carries at most 2 containers, and a 40ft
// container fills the truck (max 1) — mirror the addTruck/updateTruck rule.
assertTruckLoad({
containers: requested,
bookingContainers: await this.bookingContainerNumbers(bookingId),
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
});
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
// Operator loading the truck: stamp loaded_at so these containers move to
// the LOADED stage (customer assignment alone leaves loaded_at null).
const loadedAt = new Date();
await manager.getRepository(CustomerTruckContainer).save(
requested.map((containerNumber) =>
manager.getRepository(CustomerTruckContainer).create({
assignmentId,
bookingId,
containerNumber,
loadedAt,
}),
),
);
// Provisional gross (tonnes) from the loaded containers' VGM — overridden
// by the weighed gross on departure. (Column is *_kg but holds tonnes.)
// Auto-stamp arrival if the truck was still only assigned.
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
grossWeightKg: grossTons,
...(needsArrival ? { arrivedAt: new Date() } : {}),
});
if (needsArrival) {
await manager.query(
`UPDATE freight.bookings
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
updated_at = NOW()
WHERE id = $1`,
[bookingId],
);
}
});
return this.listTrucks(bookingId);
}
/** Summed VGM (tonnes) of the given containers — provisional truck gross. */
private async vgmTonsForContainers(bookingId: string, numbers: string[]): Promise<number> {
const [row]: Array<{ tons: string }> = await this.dataSource.query(
`SELECT COALESCE(SUM(bcu.vgm_tons), 0) AS tons
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1
AND bcu.container_number = ANY($2::varchar[])
AND bcu.deleted_at IS NULL`,
[bookingId, numbers],
);
return Number(row?.tons ?? 0);
}
/**
* Mark the truck carrying `containerNumber` as arrived. Called by the warehouse
* receive flow. When every truck on the booking has arrived, the booking-level
* customer_truck_arrived_at flag is stamped (used by the delivery-approval
* gate). No-op when the container is not on any customer truck.
*/
async markArrivedByContainer(
bookingId: string,
containerNumber: string,
manager?: EntityManager,
): Promise<void> {
const m = manager ?? this.dataSource.manager;
const cn = containerNumber.trim().toUpperCase();
const container = await m.getRepository(CustomerTruckContainer).findOne({
where: { bookingId, containerNumber: cn },
});
if (!container) return;
const assignment = await m
.getRepository(CustomerTruckAssignment)
.findOne({ where: { id: container.assignmentId } });
const justArrived = Boolean(assignment) && !assignment?.arrivedAt;
await m
.getRepository(CustomerTruckAssignment)
.update({ id: container.assignmentId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
await this.syncBookingArrival(bookingId, m);
if (justArrived && assignment) {
await this.notifyTruckArrival(bookingId, assignment.plateNumber, m);
}
}
/** Mark every truck on the booking arrived (fallback when no container is known). */
async markAllArrived(bookingId: string, manager?: EntityManager): Promise<void> {
const m = manager ?? this.dataSource.manager;
const justArrived = await m
.getRepository(CustomerTruckAssignment)
.find({ where: { bookingId, arrivedAt: IsNull() } });
await m
.getRepository(CustomerTruckAssignment)
.update({ bookingId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
await this.syncBookingArrival(bookingId, m);
for (const truck of justArrived) {
await this.notifyTruckArrival(bookingId, truck.plateNumber, m);
}
}
/**
* Best-effort truck-arrival notification to the booking's company across every
* channel: in-app (portal inbox) + SMS + email. Never throws — a missing
* provider or contact must not break the arrival flow.
*/
private async notifyTruckArrival(
bookingId: string,
plateNumber: string | null,
m: EntityManager,
): Promise<void> {
try {
const [booking]: Array<{ companyId: string | null; reference: string | null }> =
await m.query(
`SELECT company_id AS "companyId", reference
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!booking?.companyId) return;
const ref = booking.reference ?? bookingId;
const truck = plateNumber ? `Truck ${plateNumber}` : 'A customer truck';
const body = `${truck} has arrived at the terminal for booking ${ref}.`;
await this.inbox.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title: 'Truck arrived',
body,
link: `/bookings/${bookingId}`,
data: { bookingId, plateNumber, action: 'TRUCK_ARRIVED' },
});
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
} catch (err) {
this.logger.warn(
`Truck-arrival notify failed for ${bookingId}: ${(err as Error).message}`,
);
}
}
/**
* Stamp the booking-level arrival flag on the FIRST truck arrival. The import
* handover is signed once, before the first truck leaves, even though trucks
* pick up per-container — so the flag fires on the first arrival (COALESCE
* keeps it), not once all trucks have arrived.
*/
private async syncBookingArrival(bookingId: string, m: EntityManager): Promise<void> {
await m.query(
`UPDATE freight.bookings
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
updated_at = NOW()
WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL`,
[bookingId],
);
}
private async loadBookingGuard(bookingId: string): Promise<BookingGuardRow> {
const [row]: BookingGuardRow[] = await this.dataSource.query(
`SELECT trade_direction AS "tradeDirection",
freight_type AS "freightType",
first_mile_pickup_address AS "firstMile",
last_mile_delivery_address AS "lastMile",
payment_status AS "paymentStatus",
status
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!row) throw new NotFoundException(`Booking ${bookingId} not found`);
return row;
}
private assertSelfHaulPaid(booking: BookingGuardRow): void {
// Shared with the EDR side (LastMileService.assertNoCustomerTruck) so the two
// halves of this rule cannot drift apart — they did, and a booking ended up
// with a customer truck and an EDR leg at once.
if (usesEdrMileService(booking)) {
throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE);
}
if (booking.paymentStatus !== 'PAID') {
throw new BadRequestException(
'Booking must be paid before assigning an external customer truck',
);
}
}
/**
* Assignment window by direction:
* - IMPORT: pickup trucks are assigned only AFTER the train has arrived.
* - EXPORT / DOMESTIC: delivery trucks are assigned only BEFORE the cargo is
* loaded onto the train (booking still PAID / TRUCK_ASSIGNED). Once loaded
* (IN_TRANSIT and beyond) assignment is closed.
*/
private assertAssignmentWindow(booking: BookingGuardRow): void {
const status = booking.status ?? '';
if (booking.tradeDirection === 'IMPORT') {
if (status !== 'ARRIVED') {
throw new BadRequestException(
'Import pickup trucks can only be assigned after the train has arrived',
);
}
return;
}
if (!['PAID', 'TRUCK_ASSIGNED'].includes(status)) {
throw new BadRequestException(
'Export delivery trucks can only be assigned before the cargo is loaded onto the train',
);
}
}
private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber"
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
[bookingId],
);
return rows.map((r) => r.containerNumber.trim().toUpperCase());
}
private async assignedContainerNumbers(bookingId: string): Promise<string[]> {
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
`SELECT container_number AS "containerNumber"
FROM freight.customer_truck_containers
WHERE booking_id = $1 AND deleted_at IS NULL`,
[bookingId],
);
return rows.map((r) => r.containerNumber.trim().toUpperCase());
}
private async assignedContainerNumbersExcept(
bookingId: string,
exceptAssignmentId: string,
): Promise<string[]> {
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
`SELECT container_number AS "containerNumber"
FROM freight.customer_truck_containers
WHERE booking_id = $1 AND assignment_id <> $2 AND deleted_at IS NULL`,
[bookingId, exceptAssignmentId],
);
return rows.map((r) => r.containerNumber.trim().toUpperCase());
}
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
async addBulkTrucks(
bookingId: string,
dtos: AddCustomerTruckDto[],
): Promise<{
success: number;
failed: number;
errors: Array<{ row: number; truck: string; reason: string }>;
}> {
const errors: Array<{ row: number; truck: string; reason: string }> = [];
let successCount = 0;
for (let i = 0; i < dtos.length; i++) {
try {
await this.addTruck(bookingId, dtos[i]);
successCount++;
} catch (err: any) {
errors.push({
row: i + 2, // Row 1 is header
truck: dtos[i].truckPlateNumber,
reason: err.message || 'Unknown error',
});
}
}
return {
success: successCount,
failed: errors.length,
errors,
};
}
}