mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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.
660 lines
26 KiB
TypeScript
660 lines
26 KiB
TypeScript
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 1–2 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
|
||
// 1–2 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}: 1–2 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,
|
||
};
|
||
}
|
||
}
|