From ae30441f0732a506c1a418bdd544a364d2bd7e3b Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 13 Jul 2026 09:53:33 +0000 Subject: [PATCH] Truck assignment for Export before loading and import after arrival --- .../bookings/customer-truck.service.ts | 107 +++++++++++++++++- .../warehouses/warehouse-inventory.service.ts | 49 ++++++++ .../bookings/booking-status.config.ts | 14 ++- .../BookingDetailPage/ReadonlyBookingView.tsx | 11 +- .../CustomerTruckAssignmentCard.tsx | 39 ++++--- .../bookings/BookingDetailPage/constants.ts | 7 ++ 6 files changed, 200 insertions(+), 27 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index 4e364a03a..b61d0078d 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -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 { @@ -41,14 +51,20 @@ export class CustomerTruckService { async addTruck(bookingId: string, dto: AddCustomerTruckDto): Promise { 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 { 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 { + 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 { 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 { const rows: Array<{ containerNumber: string }> = await this.dataSource.query( `SELECT bcu.container_number AS "containerNumber" diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index fd4f2057f..c4fc7316f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -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 { + 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; diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts index 25f4b51bc..f87f96433 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts @@ -62,6 +62,10 @@ export const BOOKING_STATUS_STYLES: Record = { 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 = { 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; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index f135078c1..267bd160d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -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"; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx index a8cee69eb..a73057fcd 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx @@ -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({ )} - {/* 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 ? ( <> @@ -256,18 +261,20 @@ export function CustomerTruckAssignmentCard({ value={truckType || null} onChange={(value) => setTruckType(value ?? "")} /> - + {!isBulk && ( + + )} {editingId && ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts index ae3d20e4e..e2547e7c3 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts @@ -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.",