Truck assignment for Export before loading and import after arrival

This commit is contained in:
Hagernesh
2026-07-13 09:53:33 +00:00
parent da44752c70
commit ae30441f07
6 changed files with 200 additions and 27 deletions

View File

@@ -2,18 +2,24 @@ 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 { 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;
@@ -29,9 +35,13 @@ interface BookingGuardRow {
*/
@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[]> {
@@ -41,14 +51,20 @@ export class CustomerTruckService {
async addTruck(bookingId: string, dto: AddCustomerTruckDto): Promise<CustomerTruckAssignment[]> {
const booking = await this.loadBookingGuard(bookingId);
this.assertSelfHaulPaid(booking);
this.assertAssignmentWindow(booking);
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
// 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());
// 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) {
// 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');
}
if (requested.length > 2) {
@@ -395,20 +411,74 @@ export class CustomerTruckService {
});
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}`,
);
}
}
/**
@@ -430,6 +500,7 @@ export class CustomerTruckService {
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",
@@ -463,6 +534,30 @@ export class CustomerTruckService {
}
}
/**
* 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"

View File

@@ -1,4 +1,5 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
@@ -396,6 +397,54 @@ export class WarehouseInventoryService {
* but has no customer truck assigned yet, nudge the customer to assign one — with
* a deep-link to the booking's truck-assignment card. Fire-and-forget.
*/
/**
* Recurring nudge: keep reminding self-haul IMPORT customers to assign a
* collection truck while their goods are still in the warehouse
* (READY_FOR_PICKUP) and no truck has been assigned yet. Stops once a truck is
* assigned (customer_truck_assigned_at set) or the goods leave (DELIVERED).
*/
@Cron(CronExpression.EVERY_30_MINUTES, { name: 'import-truck-assignment-reminder' })
async remindImportTruckAssignment(): Promise<void> {
try {
const rows: Array<{
bookingId: string;
companyId: string | null;
reference: string | null;
}> = await this.dataSource.query(
`SELECT DISTINCT b.id AS "bookingId",
b.company_id AS "companyId",
b.reference
FROM freight.warehouse_inventory inv
JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
WHERE inv.deleted_at IS NULL
AND inv.status = 'READY_FOR_PICKUP'
AND b.trade_direction = 'IMPORT'
AND b.customer_truck_assigned_at IS NULL
AND COALESCE(NULLIF(TRIM(b.last_mile_delivery_address), ''), '') = ''`,
);
if (!rows.length) return;
this.logger.log(
`Import truck-assignment reminder: ${rows.length} booking(s) awaiting a collection truck`,
);
for (const row of rows) {
await this.notifyTruckAssignmentNeeded(
{
companyId: row.companyId,
reference: row.reference,
hasFirstMile: false,
hasLastMile: false,
customerTruckAssignedAt: null,
},
row.bookingId,
);
}
} catch (err) {
this.logger.warn(
`Import truck-assignment reminder tick failed: ${(err as Error).message}`,
);
}
}
private async notifyTruckAssignmentNeeded(booking: {
companyId?: string | null;
reference?: string | null;

View File

@@ -62,6 +62,10 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
label: "Paid",
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
},
TRUCK_ASSIGNED: {
label: "Truck Assigned",
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
},
IN_TRANSIT: {
label: "In Transit",
color: "bg-sky-50 text-sky-700 border-sky-200",
@@ -206,6 +210,12 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
color: "text-[color:var(--freight-brand)]",
stage: 4,
},
TRUCK_ASSIGNED: {
title: "Truck Assigned",
description: "Customer truck assigned for self-haul; ready for operations.",
color: "text-[color:var(--freight-brand)]",
stage: 4,
},
IN_TRANSIT: {
title: "In Transit",
description: "Shipment is on the railway network.",
@@ -300,7 +310,7 @@ export const BOOKING_LIST_TABS = [
{
key: "operations",
label: "Operations",
statuses: ["PAID", "IN_TRANSIT", "ARRIVED", "ROAD_DISPATCH_PENDING"],
statuses: ["PAID", "TRUCK_ASSIGNED", "IN_TRANSIT", "ARRIVED", "ROAD_DISPATCH_PENDING"],
},
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
@@ -338,7 +348,7 @@ export const WORKFLOW_STAGES = [
},
{
label: "Operations",
statuses: ["PAID", "IN_TRANSIT", "ARRIVED"],
statuses: ["PAID", "TRUCK_ASSIGNED", "IN_TRANSIT", "ARRIVED"],
},
{ label: "Done", statuses: ["COMPLETED"] },
] as const;

View File

@@ -122,9 +122,14 @@ export function ReadonlyBookingView({
const canAssignCustomerTruck =
booking.paymentStatus === "PAID" &&
usesCustomerTruck &&
["PAID", "IN_TRANSIT", "ARRIVED", "COMPLETED", "TRUCK_ASSIGNED"].includes(
status,
);
(booking.tradeDirection === "IMPORT"
? // Import self-haul: pickup trucks are assigned only after the train has
// arrived at the destination.
status === "ARRIVED"
: // Export / domestic self-haul: delivery trucks are assigned only before
// the cargo is loaded onto the train (PAID / TRUCK_ASSIGNED). Once loaded
// (IN_TRANSIT and beyond) assignment is closed.
["PAID", "TRUCK_ASSIGNED"].includes(status));
const showCountdown = canPay && !!booking.paymentDeadline;
const isExpired = status === "EXPIRED";
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";

View File

@@ -97,13 +97,17 @@ export function CustomerTruckAssignmentCard({
setError(null);
};
// Bulk bookings have no containers — the truck hauls loose tonnage (weighed on
// departure), so the container picker and its validation are skipped.
const isBulk = String(booking.freightType) === "BULK";
const addMutation = useMutation({
mutationFn: () => {
const payload = {
truckPlateNumber: plateNumber.trim().toUpperCase(),
driverName: driverName.trim(),
truckType: truckType.trim(),
containerNumbers: containers,
containerNumbers: isBulk ? [] : containers,
};
return editingId
? customerTrucksService.update(booking.id, editingId, payload)
@@ -138,7 +142,7 @@ export function CustomerTruckAssignmentCard({
setError("Plate number, driver name and truck type are required.");
return;
}
if (containers.length < 1 || containers.length > 2) {
if (!isBulk && (containers.length < 1 || containers.length > 2)) {
setError("Select 1 or 2 container numbers for this truck.");
return;
}
@@ -232,8 +236,9 @@ export function CustomerTruckAssignmentCard({
</Alert>
)}
{/* Add-truck form — both directions assign the containers each truck carries. */}
{availableContainers.length > 0 ? (
{/* Add-truck form. Container bookings assign 12 containers per truck;
bulk bookings just register the truck (no container picker). */}
{isBulk || availableContainers.length > 0 ? (
<>
<Divider label={editingId ? "Edit truck" : "Add a truck"} labelPosition="center" />
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
@@ -256,18 +261,20 @@ export function CustomerTruckAssignmentCard({
value={truckType || null}
onChange={(value) => setTruckType(value ?? "")}
/>
<MultiSelect
label="Containers to load"
description="20ft: up to 2 per truck · 40ft: 1 per truck"
required
placeholder="Select container numbers"
data={availableContainers}
value={containers}
onChange={setContainers}
maxValues={2}
searchable
nothingFoundMessage="No unassigned containers"
/>
{!isBulk && (
<MultiSelect
label="Containers to load"
description="20ft: up to 2 per truck · 40ft: 1 per truck"
required
placeholder="Select container numbers"
data={availableContainers}
value={containers}
onChange={setContainers}
maxValues={2}
searchable
nothingFoundMessage="No unassigned containers"
/>
)}
</SimpleGrid>
<Group justify="flex-end">
{editingId && (

View File

@@ -54,6 +54,7 @@ export const PROGRESS_STAGES = [
icon: Ship,
statuses: [
"PAID",
"TRUCK_ASSIGNED",
"PNR_GENERATED",
"PENDING_CONSOLIDATION",
"CONSOLIDATED",
@@ -152,6 +153,7 @@ export const CONTRACT_PROGRESS_STAGES = [
icon: Ship,
statuses: [
"PAID",
"TRUCK_ASSIGNED",
"PNR_GENERATED",
"PENDING_CONSOLIDATION",
"CONSOLIDATED",
@@ -315,6 +317,11 @@ export const STATUS_MAP: Record<
description: "Payment has been confirmed for this booking.",
stage: 5,
},
TRUCK_ASSIGNED: {
title: "Truck assigned",
description: "Customer truck assigned for self-haul; ready for loading.",
stage: 5,
},
IN_TRANSIT: {
title: "Cargo moving",
description: "Your shipment is currently moving through the rail network.",