mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Truck assignment for Export before loading and import after arrival
This commit is contained in:
@@ -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
|
||||
// 1–2 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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 1–2 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 && (
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user