mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
403 lines
16 KiB
TypeScript
403 lines
16 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
ConflictException,
|
||
Injectable,
|
||
NotFoundException,
|
||
} from '@nestjs/common';
|
||
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
||
|
||
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 { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
|
||
|
||
interface BookingGuardRow {
|
||
tradeDirection: 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 {
|
||
constructor(
|
||
private readonly dataSource: DataSource,
|
||
private readonly assignments: CustomerTruckAssignmentsRepository,
|
||
) {}
|
||
|
||
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);
|
||
|
||
const isExport = booking.tradeDirection === 'EXPORT';
|
||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||
|
||
// EXPORT: the truck delivers 1–2 known containers. IMPORT: containers are
|
||
// not pre-specified — they are registered + weighed when the truck leaves.
|
||
if (isExport) {
|
||
if (requested.length < 1 || requested.length > 2) {
|
||
throw new BadRequestException('An export truck must carry 1 or 2 of the booking containers');
|
||
}
|
||
} else if (requested.length > 2) {
|
||
throw new BadRequestException('A truck carries at most 2 containers');
|
||
}
|
||
|
||
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 alreadyAssigned = await this.assignedContainerNumbers(bookingId);
|
||
for (const n of requested) {
|
||
if (alreadyAssigned.includes(n)) {
|
||
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||
}
|
||
}
|
||
}
|
||
|
||
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(),
|
||
}),
|
||
);
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* 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');
|
||
}
|
||
|
||
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');
|
||
}
|
||
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`);
|
||
}
|
||
}
|
||
|
||
const grossKg = await this.vgmKgForContainers(bookingId, requested);
|
||
await this.dataSource.transaction(async (manager) => {
|
||
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
||
await manager.getRepository(CustomerTruckContainer).save(
|
||
requested.map((containerNumber) =>
|
||
manager.getRepository(CustomerTruckContainer).create({
|
||
assignmentId,
|
||
bookingId,
|
||
containerNumber,
|
||
}),
|
||
),
|
||
);
|
||
// Provisional gross from the loaded containers' VGM — overridden by the
|
||
// weighed gross on departure.
|
||
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
||
grossWeightKg: grossKg,
|
||
});
|
||
});
|
||
return this.listTrucks(bookingId);
|
||
}
|
||
|
||
private async vgmKgForContainers(bookingId: string, numbers: string[]): Promise<number> {
|
||
const [row]: Array<{ kg: string }> = await this.dataSource.query(
|
||
`SELECT COALESCE(SUM(bcu.vgm_tons), 0) * 1000 AS kg
|
||
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?.kg ?? 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;
|
||
|
||
await m
|
||
.getRepository(CustomerTruckAssignment)
|
||
.update({ id: container.assignmentId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
|
||
|
||
await this.syncBookingArrival(bookingId, 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;
|
||
await m
|
||
.getRepository(CustomerTruckAssignment)
|
||
.update({ bookingId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
|
||
await this.syncBookingArrival(bookingId, m);
|
||
}
|
||
|
||
/**
|
||
* 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",
|
||
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 {
|
||
const hasFirstMile = Boolean(booking.firstMile?.trim());
|
||
const hasLastMile = Boolean(booking.lastMile?.trim());
|
||
const usesMileService =
|
||
booking.tradeDirection === 'IMPORT'
|
||
? hasLastMile
|
||
: booking.tradeDirection === 'EXPORT'
|
||
? hasFirstMile
|
||
: hasFirstMile || hasLastMile;
|
||
if (usesMileService) {
|
||
throw new BadRequestException(
|
||
'Customer truck assignment is only allowed when first/last mile delivery is not selected',
|
||
);
|
||
}
|
||
if (booking.paymentStatus !== 'PAID') {
|
||
throw new BadRequestException(
|
||
'Booking must be paid before assigning an external customer truck',
|
||
);
|
||
}
|
||
}
|
||
|
||
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());
|
||
}
|
||
}
|