Merge pull request #645 from Tria-plc/uienhancement

Export Truck assignment
Added truck assignment between payment and Loading status
guarded assignment to maxmum number of truck to the number of containers
This commit is contained in:
Hagernesh Tadesse
2026-07-13 13:10:11 +03:00
committed by GitHub
6 changed files with 200 additions and 27 deletions

View File

@@ -2,18 +2,24 @@ import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
Injectable, Injectable,
Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { DataSource, EntityManager, IsNull } from 'typeorm'; import { DataSource, EntityManager, IsNull } from 'typeorm';
import { NotificationAudience, NotificationType } from '@edr/types';
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; 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 { interface BookingGuardRow {
tradeDirection: string | null; tradeDirection: string | null;
freightType: string | null;
firstMile: string | null; firstMile: string | null;
lastMile: string | null; lastMile: string | null;
paymentStatus: string | null; paymentStatus: string | null;
@@ -29,9 +35,13 @@ interface BookingGuardRow {
*/ */
@Injectable() @Injectable()
export class CustomerTruckService { export class CustomerTruckService {
private readonly logger = new Logger(CustomerTruckService.name);
constructor( constructor(
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly assignments: CustomerTruckAssignmentsRepository, private readonly assignments: CustomerTruckAssignmentsRepository,
private readonly inbox: NotificationInboxService,
private readonly notifications: NotificationsService,
) {} ) {}
listTrucks(bookingId: string): Promise<CustomerTruckAssignment[]> { listTrucks(bookingId: string): Promise<CustomerTruckAssignment[]> {
@@ -41,14 +51,20 @@ export class CustomerTruckService {
async addTruck(bookingId: string, dto: AddCustomerTruckDto): Promise<CustomerTruckAssignment[]> { async addTruck(bookingId: string, dto: AddCustomerTruckDto): Promise<CustomerTruckAssignment[]> {
const booking = await this.loadBookingGuard(bookingId); const booking = await this.loadBookingGuard(bookingId);
this.assertSelfHaulPaid(booking); 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 // Container capacity is size-based: a 40ft container fills the truck (max 1);
// is size-based: a 40ft container fills the truck (max 1); two 20ft containers // two 20ft containers fit (max 2), no size mixing. #trucks <= #containers
// fit (max 2), no size mixing. #trucks <= #containers follows naturally since // follows naturally since each container is assigned to exactly one truck.
// each container is assigned to exactly one truck. if (!isBulk && requested.length < 1) {
if (requested.length < 1) {
throw new BadRequestException('Select at least one container for this truck'); throw new BadRequestException('Select at least one container for this truck');
} }
if (requested.length > 2) { if (requested.length > 2) {
@@ -395,20 +411,74 @@ export class CustomerTruckService {
}); });
if (!container) return; if (!container) return;
const assignment = await m
.getRepository(CustomerTruckAssignment)
.findOne({ where: { id: container.assignmentId } });
const justArrived = Boolean(assignment) && !assignment?.arrivedAt;
await m await m
.getRepository(CustomerTruckAssignment) .getRepository(CustomerTruckAssignment)
.update({ id: container.assignmentId, arrivedAt: IsNull() }, { arrivedAt: new Date() }); .update({ id: container.assignmentId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
await this.syncBookingArrival(bookingId, m); 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). */ /** Mark every truck on the booking arrived (fallback when no container is known). */
async markAllArrived(bookingId: string, manager?: EntityManager): Promise<void> { async markAllArrived(bookingId: string, manager?: EntityManager): Promise<void> {
const m = manager ?? this.dataSource.manager; const m = manager ?? this.dataSource.manager;
const justArrived = await m
.getRepository(CustomerTruckAssignment)
.find({ where: { bookingId, arrivedAt: IsNull() } });
await m await m
.getRepository(CustomerTruckAssignment) .getRepository(CustomerTruckAssignment)
.update({ bookingId, arrivedAt: IsNull() }, { arrivedAt: new Date() }); .update({ bookingId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
await this.syncBookingArrival(bookingId, m); 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> { private async loadBookingGuard(bookingId: string): Promise<BookingGuardRow> {
const [row]: BookingGuardRow[] = await this.dataSource.query( const [row]: BookingGuardRow[] = await this.dataSource.query(
`SELECT trade_direction AS "tradeDirection", `SELECT trade_direction AS "tradeDirection",
freight_type AS "freightType",
first_mile_pickup_address AS "firstMile", first_mile_pickup_address AS "firstMile",
last_mile_delivery_address AS "lastMile", last_mile_delivery_address AS "lastMile",
payment_status AS "paymentStatus", 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[]> { private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
const rows: Array<{ containerNumber: string }> = await this.dataSource.query( const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber" `SELECT bcu.container_number AS "containerNumber"

View File

@@ -1,4 +1,5 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; 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 { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; 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 * 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. * 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: { private async notifyTruckAssignmentNeeded(booking: {
companyId?: string | null; companyId?: string | null;
reference?: string | null; reference?: string | null;

View File

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

View File

@@ -123,9 +123,14 @@ export function ReadonlyBookingView({
const canAssignCustomerTruck = const canAssignCustomerTruck =
booking.paymentStatus === "PAID" && booking.paymentStatus === "PAID" &&
usesCustomerTruck && usesCustomerTruck &&
["PAID", "IN_TRANSIT", "ARRIVED", "COMPLETED", "TRUCK_ASSIGNED"].includes( (booking.tradeDirection === "IMPORT"
status, ? // 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 showCountdown = canPay && !!booking.paymentDeadline;
const isExpired = status === "EXPIRED"; const isExpired = status === "EXPIRED";
const isPendingConsolidation = status === "PENDING_CONSOLIDATION"; const isPendingConsolidation = status === "PENDING_CONSOLIDATION";

View File

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

View File

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