mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 20:05:41 +00:00
517 lines
21 KiB
TypeScript
517 lines
21 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 requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||
|
||
// Both import and export specify the containers each truck carries. 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 (requested.length < 1) {
|
||
throw new BadRequestException('Select at least one container for this truck');
|
||
}
|
||
if (requested.length > 2) {
|
||
throw new BadRequestException('A truck carries at most 2 containers');
|
||
}
|
||
|
||
if (requested.length) {
|
||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||
// Never assign more trucks than the booking has containers.
|
||
const existingTrucks = await this.dataSource
|
||
.getRepository(CustomerTruckAssignment)
|
||
.count({ where: { bookingId } });
|
||
if (existingTrucks + 1 > bookingNumbers.length) {
|
||
throw new BadRequestException(
|
||
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`,
|
||
);
|
||
}
|
||
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`);
|
||
}
|
||
}
|
||
// Size cap: a 40ft container fills the truck.
|
||
const sizes = await this.containerSizes(bookingId, requested);
|
||
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
|
||
throw new BadRequestException(
|
||
'A 40ft container fills the truck — assign only 1 container to this 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);
|
||
}
|
||
|
||
/**
|
||
* 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');
|
||
}
|
||
|
||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||
if (requested.length < 1) {
|
||
throw new BadRequestException('Select at least one container for this truck');
|
||
}
|
||
if (requested.length > 2) {
|
||
throw new BadRequestException('A truck carries at most 2 containers');
|
||
}
|
||
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`);
|
||
}
|
||
}
|
||
// Exclude THIS truck's own containers so re-saving the same set is allowed.
|
||
const assignedElsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
|
||
for (const n of requested) {
|
||
if (assignedElsewhere.includes(n)) {
|
||
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||
}
|
||
}
|
||
const sizes = await this.containerSizes(bookingId, requested);
|
||
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
|
||
throw new BadRequestException(
|
||
'A 40ft container fills the truck — assign only 1 container to 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(),
|
||
});
|
||
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');
|
||
}
|
||
// Containers can only be loaded after the truck has physically arrived at the
|
||
// warehouse (arrival weighing recorded). Assignment alone is just planning.
|
||
if (!assignment.arrivedAt) {
|
||
throw new BadRequestException(
|
||
'Record the truck arrival before loading — containers can only be loaded onto an arrived truck',
|
||
);
|
||
}
|
||
|
||
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 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.)
|
||
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
||
grossWeightKg: grossTons,
|
||
});
|
||
});
|
||
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;
|
||
|
||
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());
|
||
}
|
||
|
||
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
|
||
private async containerSizes(bookingId: string, numbers: string[]): Promise<string[]> {
|
||
if (!numbers.length) return [];
|
||
const rows: Array<{ size: string | null }> = await this.dataSource.query(
|
||
`SELECT bc.container_size AS "size"
|
||
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 UPPER(bcu.container_number) = ANY($2)
|
||
AND bcu.deleted_at IS NULL`,
|
||
[bookingId, numbers],
|
||
);
|
||
return rows.map((r) => (r.size ?? '').trim());
|
||
}
|
||
}
|