From ba821b06ca6cc9ec62c4e73dc0d35c0560c41889 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 8 Jul 2026 05:19:25 +0000 Subject: [PATCH 1/7] Warehouse and customer truck edit and KG to Tons --- .../modules/bookings/bookings.controller.ts | 15 ++ .../bookings/customer-truck.service.ts | 70 +++++++++ .../pages/warehouses/WarehouseRulesPage.tsx | 148 +++++++++++++----- .../CustomerTruckAssignmentCard.tsx | 72 ++++++--- .../src/services/customer-trucks.service.ts | 9 ++ 5 files changed, 257 insertions(+), 57 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 444ac578a..79d94f3de 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -350,6 +350,21 @@ export class BookingsController { return this.customerTruckService.addTruck(id, dto); } + @Patch(':id/customer-trucks/:assignmentId') + @ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' }) + async updateCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Body() dto: AddCustomerTruckDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.updateTruck(id, assignmentId, dto); + } + @Delete(':id/customer-trucks/:assignmentId') @ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' }) async removeCustomerTruck( 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 43c14c7f6..693b60e09 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 @@ -140,6 +140,76 @@ export class CustomerTruckService { 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 { + 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. diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx index 41a97110f..81633f9f8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx @@ -16,7 +16,7 @@ import { Text, TextInput, } from '@mantine/core'; -import { Info, Plus, Trash2 } from 'lucide-react'; +import { Info, Pencil, Plus, Trash2 } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; @@ -30,6 +30,8 @@ import { useDeleteAllocationRule, useDeleteFeeRule, useFeeRules, + useUpdateAllocationRule, + useUpdateFeeRule, } from '@/hooks/useWarehouses'; import { api } from '@/services/api'; import { @@ -38,6 +40,8 @@ import { FEE_RULE_TYPES, FEE_RULE_TYPE_LABELS, VEHICLE_TYPES, + type AllocationRule, + type FeeRule, type FeeRuleBasis, type FeeRuleType, } from '@/types/warehouse'; @@ -121,8 +125,10 @@ function AllocationRules() { const { data, isLoading } = useAllocationRules(); const { data: yards = [], isLoading: yardsLoading } = useAllWarehouseYards(); const create = useCreateAllocationRule(); + const update = useUpdateAllocationRule(); const remove = useDeleteAllocationRule(); const [open, setOpen] = useState(false); + const [editingId, setEditingId] = useState(null); const [form, setForm] = useState({ name: '', priority: 100, @@ -142,7 +148,8 @@ function AllocationRules() { label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`, })); - const resetForm = () => + const resetForm = () => { + setEditingId(null); setForm({ name: '', priority: 100, @@ -153,6 +160,22 @@ function AllocationRules() { targetYardCode: '', storageType: '', }); + }; + + const startEdit = (rule: AllocationRule) => { + setForm({ + name: rule.name, + priority: rule.priority ?? 100, + freightType: rule.freightType ?? '', + tradeDirection: rule.tradeDirection ?? '', + cargoTypeCode: rule.cargoTypeCode ?? '', + containerStatus: rule.containerStatus ?? '', + targetYardCode: rule.targetYardCode ?? '', + storageType: rule.storageType ?? '', + }); + setEditingId(rule.id); + setOpen(true); + }; const submit = async () => { if (!form.name.trim() || !form.targetYardCode.trim()) { @@ -160,7 +183,7 @@ function AllocationRules() { return; } - await create.mutateAsync({ + const payload = { name: form.name.trim(), priority: form.priority, freightType: clean(form.freightType) ?? null, @@ -170,10 +193,20 @@ function AllocationRules() { targetYardCode: form.targetYardCode.trim(), storageType: clean(form.storageType) ?? null, isActive: true, - } as never); - toast({ title: 'Allocation rule created' }); - setOpen(false); - resetForm(); + }; + try { + if (editingId) { + await update.mutateAsync({ id: editingId, payload: payload as never }); + toast({ title: 'Allocation rule updated' }); + } else { + await create.mutateAsync(payload as never); + toast({ title: 'Allocation rule created' }); + } + setOpen(false); + resetForm(); + } catch (error) { + toast({ variant: 'destructive', title: editingId ? 'Update failed' : 'Create failed', description: extractErrorMessage(error) }); + } }; return ( @@ -182,7 +215,7 @@ function AllocationRules() { {rules.length} rule(s) matched by ascending priority - @@ -229,14 +262,19 @@ function AllocationRules() { - remove.mutate(rule.id)} - title="Delete" - > - - + + startEdit(rule)} title="Edit"> + + + remove.mutate(rule.id)} + title="Delete" + > + + + ))} @@ -245,7 +283,7 @@ function AllocationRules() { )} - setOpen(false)} title="New allocation rule" centered size="lg"> + { setOpen(false); resetForm(); }} title={editingId ? 'Edit allocation rule' : 'New allocation rule'} centered size="lg"> @@ -340,11 +378,11 @@ function AllocationRules() { /> - - @@ -363,8 +401,10 @@ function FeeRules() { api.containerTypes.list.queryOptions({ staleTime: Infinity }), ); const create = useCreateFeeRule(); + const update = useUpdateFeeRule(); const remove = useDeleteFeeRule(); const [open, setOpen] = useState(false); + const [editingId, setEditingId] = useState(null); const [form, setForm] = useState({ name: '', ruleType: 'DEMURRAGE_FEE' as FeeRuleType, @@ -394,7 +434,8 @@ function FeeRules() { // Double handling + truck detention apply to IMPORT only — trade direction is locked. const isImportOnly = isDoubleHandling || isTruckDetention; - const resetForm = () => + const resetForm = () => { + setEditingId(null); setForm({ name: '', ruleType: 'DEMURRAGE_FEE', @@ -410,6 +451,27 @@ function FeeRules() { tiers: [], currency: 'USD', }); + }; + + const startEdit = (rule: FeeRule) => { + setForm({ + name: rule.name, + ruleType: rule.ruleType, + basis: (rule.basis as FeeRuleBasis) ?? 'PER_CONTAINER', + freightType: rule.freightType ?? '', + tradeDirection: rule.tradeDirection ?? '', + cargoTypeCode: rule.cargoTypeCode ?? '', + containerType: rule.containerType ?? '', + vehicleType: rule.vehicleType ?? '', + freeDays: rule.freeDays ?? 3, + freeHours: rule.freeHours ?? 3, + ratePerDay: rule.ratePerDay ?? 0, + tiers: (rule.tiers ?? []).map((t) => ({ fromDay: t.fromDay, toDay: t.toDay, ratePerDay: t.ratePerDay })), + currency: rule.currency ?? 'USD', + }); + setEditingId(rule.id); + setOpen(true); + }; const addTier = () => setForm((f) => { @@ -479,12 +541,17 @@ function FeeRules() { }; try { - await create.mutateAsync(payload as never); - toast({ title: 'Fee rule created' }); + if (editingId) { + await update.mutateAsync({ id: editingId, payload: payload as never }); + toast({ title: 'Fee rule updated' }); + } else { + await create.mutateAsync(payload as never); + toast({ title: 'Fee rule created' }); + } setOpen(false); resetForm(); } catch (error) { - if (tiers.length && isUnknownTiersError(error)) { + if (!editingId && tiers.length && isUnknownTiersError(error)) { const legacyPayload: Omit = { name: payload.name, ruleType: payload.ruleType, @@ -505,7 +572,7 @@ function FeeRules() { resetForm(); return; } - toast({ variant: 'destructive', title: 'Create failed', description: extractErrorMessage(error) }); + toast({ variant: 'destructive', title: editingId ? 'Update failed' : 'Create failed', description: extractErrorMessage(error) }); } }; @@ -515,7 +582,7 @@ function FeeRules() { {rules.length} rule(s) - most specific match applies - @@ -577,14 +644,19 @@ function FeeRules() { - remove.mutate(rule.id)} - title="Delete" - > - - + + startEdit(rule)} title="Edit"> + + + remove.mutate(rule.id)} + title="Delete" + > + + + ))} @@ -593,7 +665,7 @@ function FeeRules() { )} - setOpen(false)} title="New fee rule" centered size="lg"> + { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg"> )} - - 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 67840c133..a8cee69eb 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 @@ -15,7 +15,7 @@ import { } from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Freight } from "@edr/types"; -import { CheckCircle2, Clock, Download, Plus, Trash2, Truck } from "lucide-react"; +import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck } from "lucide-react"; import { useState } from "react"; import toast from "react-hot-toast"; @@ -63,14 +63,19 @@ export function CustomerTruckAssignmentCard({ const [driverName, setDriverName] = useState(""); const [truckType, setTruckType] = useState(""); const [containers, setContainers] = useState([]); + const [editingId, setEditingId] = useState(null); const [error, setError] = useState(null); // Container numbers on the booking that aren't already loaded onto a truck. const assignedNumbers = new Set( trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)), ); + // When editing a truck, its own containers stay selectable. + const editingOwn = new Set( + (trucks.find((t) => t.id === editingId)?.containers ?? []).map((c) => c.containerNumber), + ); const availableContainers = (booking.containerNumbers ?? []).filter( - (n) => !assignedNumbers.has(n), + (n) => !assignedNumbers.has(n) || editingOwn.has(n), ); // Both import and export specify the containers each truck carries. @@ -79,24 +84,38 @@ export function CustomerTruckAssignmentCard({ setDriverName(""); setTruckType(""); setContainers([]); + setEditingId(null); + setError(null); + }; + + const startEdit = (t: Freight.ICustomerTruck) => { + setPlateNumber(t.plateNumber ?? ""); + setDriverName(t.driverName ?? ""); + setTruckType(t.truckType ?? ""); + setContainers((t.containers ?? []).map((c) => c.containerNumber)); + setEditingId(t.id); setError(null); }; const addMutation = useMutation({ - mutationFn: () => - customerTrucksService.add(booking.id, { + mutationFn: () => { + const payload = { truckPlateNumber: plateNumber.trim().toUpperCase(), driverName: driverName.trim(), truckType: truckType.trim(), containerNumbers: containers, - }), + }; + return editingId + ? customerTrucksService.update(booking.id, editingId, payload) + : customerTrucksService.add(booking.id, payload); + }, onSuccess: (list) => { queryClient.setQueryData(trucksKey, list); + toast.success(editingId ? "Truck updated" : "Truck added"); resetForm(); onAssigned(); - toast.success("Truck added"); }, - onError: (e) => setError(errorMessage(e, "Could not add truck")), + onError: (e) => setError(errorMessage(e, editingId ? "Could not update truck" : "Could not add truck")), }); const removeMutation = useMutation({ @@ -183,15 +202,25 @@ export function CustomerTruckAssignmentCard({ {!t.arrivedAt && ( - removeMutation.mutate(t.id)} - loading={removeMutation.isPending} - > - - + + startEdit(t)} + > + + + removeMutation.mutate(t.id)} + loading={removeMutation.isPending} + > + + + )} )) @@ -206,7 +235,7 @@ export function CustomerTruckAssignmentCard({ {/* Add-truck form — both directions assign the containers each truck carries. */} {availableContainers.length > 0 ? ( <> - + + {editingId && ( + + )} diff --git a/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts b/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts index ee317e204..2663d43ec 100644 --- a/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts +++ b/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts @@ -23,6 +23,15 @@ export const customerTrucksService = { return data.data ?? data; }, + update: async ( + bookingId: string, + assignmentId: string, + payload: Freight.AddCustomerTruckPayload, + ): Promise => { + const { data } = await client.patch(B.CUSTOMER_TRUCK(bookingId, assignmentId), payload); + return data.data ?? data; + }, + remove: async ( bookingId: string, assignmentId: string, From 9ffc8bfb0bf09931c6b9839dc2379cecce71daaa Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 8 Jul 2026 05:46:33 +0000 Subject: [PATCH 2/7] Notify customer on Sign handover, truck assignment, arrival and dispatch of train --- .../booking-notifier.service.ts | 18 +++++++++ .../train-scheduling.service.spec.ts | 1 + .../train-scheduling.service.ts | 31 +++++++++++++++ .../modules/warehouses/handover.service.ts | 30 +++++++++++++- .../warehouses/warehouse-inventory.service.ts | 39 ++++++++++++++++++- .../warehouses/warehouse-invoice.service.ts | 24 +++++++++++- .../modules/warehouses/warehouses.module.ts | 2 + 7 files changed, 142 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index f1f63802e..48985bdf0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -71,6 +71,24 @@ export class BookingNotifierService { }); } + /** Train carrying the booking departed — dispatched origin → destination. */ + dispatched(b: Booking, origin: string | null, destination: string | null): void { + const msg = + `Your booking ${b.reference ?? b.id} has been dispatched` + + `${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}.`; + void this.notifyContact(b, msg, 'DISPATCHED'); + this.inApp(b, 'Shipment dispatched', msg); + } + + /** Train carrying the booking arrived at destination. */ + arrived(b: Booking, origin: string | null, destination: string | null): void { + const msg = + `Your booking ${b.reference ?? b.id} has arrived` + + `${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`; + void this.notifyContact(b, msg, 'ARRIVED'); + this.inApp(b, 'Shipment arrived', msg); + } + async payNow(b: Booking, deadline: Date): Promise { const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000)); const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 0dba6b3de..04a972ed7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -158,6 +158,7 @@ describe('TrainSchedulingService', () => { { autoArriveAtFinalYard: jest.fn().mockResolvedValue([]), } as never, // bookingJourneyService + { dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier ); const defaultFleetWagons = [ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 2c901b1ff..754a302d3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -72,6 +72,7 @@ import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.d import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto'; import { type BookingWindowConfig } from './booking-window.config'; import { BookingWindowGateway } from './booking-window.gateway'; +import { BookingNotifierService } from './booking-notifier.service'; import { buildCappedWagonPlan, computeFleetAvailability, @@ -281,10 +282,38 @@ export class TrainSchedulingService { private readonly pdfDocuments: WarehouseReleaseDocumentService, private readonly bookingWindowGateway: BookingWindowGateway, private readonly bookingJourneyService: BookingJourneyService, + private readonly bookingNotifier: BookingNotifierService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, private readonly configService?: ConfigService, ) {} + /** + * Notify each booking's customer that their shipment was dispatched / arrived, + * with a deep-link to the booking. Fire-and-forget — never blocks the action. + */ + private async notifyScheduleBookings( + schedule: TrainSchedule, + event: 'dispatched' | 'arrived', + ): Promise { + try { + const ids = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId).filter(Boolean); + if (!ids.length) return; + const origin = schedule.originStation?.label ?? schedule.originStation?.code ?? null; + const destination = + schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null; + const bookings = await this.dataSource.getRepository(Booking).find({ + where: { id: In(ids) }, + relations: { company: true }, + }); + for (const b of bookings) { + if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination); + else this.bookingNotifier.arrived(b, origin, destination); + } + } catch (err) { + this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`); + } + } + /** * Complete customer-tracking clearance milestones for every booking on a * schedule when a physical lifecycle event fires (dispatch, arrive, load, @@ -1545,6 +1574,7 @@ export class TrainSchedulingService { { originYardId: schedule.originStationId }, ); } + void this.notifyScheduleBookings(schedule, 'dispatched'); return this.getTrainScheduleById(scheduleId); } @@ -2550,6 +2580,7 @@ export class TrainSchedulingService { { destinationYardId: schedule.destinationStationId }, ); } + void this.notifyScheduleBookings(schedule, 'arrived'); const detail = await this.getTrainScheduleById(scheduleId); const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId); diff --git a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts index 52bd318e1..d91ba4cbe 100644 --- a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts @@ -1,7 +1,9 @@ import { Injectable, Logger } from '@nestjs/common'; +import { NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, EntityManager, IsNull } from 'typeorm'; import { BookingHandover } from './entities/booking-handover.entity'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; /** * Import handover records. A booking has one handover per truck (single truck ⇒ @@ -13,7 +15,32 @@ import { BookingHandover } from './entities/booking-handover.entity'; export class HandoverService { private readonly logger = new Logger(HandoverService.name); - constructor(private readonly dataSource: DataSource) {} + constructor( + private readonly dataSource: DataSource, + private readonly inbox: NotificationInboxService, + ) {} + + /** Tell the customer a handover is ready and needs their signature. */ + private async notifySignNeeded(bookingId: string, reference: string): Promise { + try { + const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query( + `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!b?.companyId) return; + await this.inbox.notify({ + recipients: { companyId: b.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.DOCUMENT_ACTION, + title: 'Handover — signature needed', + body: `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`, + link: `/bookings/${bookingId}`, + data: { bookingId, reference }, + }); + } catch (err) { + this.logger.warn(`Failed to notify handover sign for ${bookingId}: ${(err as Error).message}`); + } + } list(bookingId: string): Promise { return this.dataSource.getRepository(BookingHandover).find({ @@ -54,6 +81,7 @@ export class HandoverService { }), ); this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`); + void this.notifySignNeeded(bookingId, reference); return saved; } 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 c31fd471e..004704bea 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 @@ -39,6 +39,8 @@ import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; import { WarehouseLoadingRepository } from './warehouse-loading.repository'; import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; import { HandoverService } from './handover.service'; +import { NotificationAudience, NotificationType } from '@edr/types'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; /** Wagon states that may receive a load (besides being part of an existing schedule). */ const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED']; @@ -383,8 +385,39 @@ export class WarehouseInventoryService { private readonly notifications: NotificationsService, private readonly signatures: SignaturesService, private readonly handover: HandoverService, + private readonly inbox: NotificationInboxService, ) {} + /** + * When a self-haul booking (no EDR first/last mile) is received to the warehouse + * 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. + */ + private async notifyTruckAssignmentNeeded(booking: { + companyId?: string | null; + reference?: string | null; + hasFirstMile?: boolean; + hasLastMile?: boolean; + customerTruckAssignedAt?: string | null; + }, bookingId: string): Promise { + if (!booking.companyId) return; + if (booking.hasFirstMile || booking.hasLastMile) return; // EDR mile — no customer truck + if (booking.customerTruckAssignedAt) return; // already assigned + try { + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Assign a truck for pickup', + body: `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`, + link: `/bookings/${bookingId}`, + data: { bookingId, action: 'ASSIGN_TRUCK' }, + }); + } catch (err) { + this.logger.warn(`Truck-assignment notify failed for ${bookingId}: ${(err as Error).message}`); + } + } + /** * Batch 6 — final terminal release / gate clearance. * Blocked while an unpaid demurrage/storage invoice exists. Does NOT touch @@ -866,7 +899,10 @@ export class WarehouseInventoryService { b.customer_truck_driver_name AS "customerTruckDriverName", b.customer_truck_type AS "customerTruckType", b.customer_truck_container_number AS "customerTruckContainerNumber", - b.customer_truck_assigned_at AS "customerTruckAssignedAt" + b.customer_truck_assigned_at AS "customerTruckAssignedAt", + b.company_id AS "companyId", + (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id @@ -1002,6 +1038,7 @@ export class WarehouseInventoryService { result.receivedCount += 1; result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber }); + void this.notifyTruckAssignmentNeeded(booking, bookingId); } }); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 9ee1dc398..2508e373a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -6,9 +6,11 @@ import { NotFoundException, } from "@nestjs/common"; import { OnEvent } from "@nestjs/event-emitter"; -import { Freight } from "@edr/types"; +import { Freight, NotificationAudience, NotificationType } from "@edr/types"; import { DataSource } from "typeorm"; +import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; + import { BillingService, InvoiceEventPayload, @@ -135,6 +137,7 @@ export class WarehouseInvoiceService { private readonly invoiceDocuments: InvoiceDocumentService, private readonly feeService: WarehouseFeeService, private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, ) { } // ── Generation ─────────────────────────────────────────────────────────── @@ -968,6 +971,25 @@ export class WarehouseInvoiceService { message, `warehouse fee invoice ${invoice.invoiceNumber}`, ); + + // In-app deep-link to pay the fee from the booking. + if (invoice.customerId && invoice.bookingId) { + try { + await this.inbox.notify({ + recipients: { companyId: invoice.customerId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.INVOICE_ISSUED, + title: "Warehouse fee due", + body: + `Warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ${invoice.invoiceNumber} is due — ` + + `${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Pay from the portal before cargo pickup.`, + link: `/bookings/${invoice.bookingId}`, + data: { bookingId: invoice.bookingId, invoiceNumber: invoice.invoiceNumber }, + }); + } catch (err) { + this.logger.warn(`In-app warehouse fee notify failed: ${(err as Error).message}`); + } + } } private async notifyWarehouseFeePayment( diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index 5f0ce5749..4011bc14f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -9,6 +9,7 @@ import { FilesModule } from '../files/files.module'; import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module'; import { LastMileModule } from '../last-mile/last-mile.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { SignaturesModule } from '../signatures/signatures.module'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; @@ -75,6 +76,7 @@ import { WarehousesService } from './warehouses.service'; InterchangeDocumentsModule, forwardRef(() => LastMileModule), NotificationsModule, + NotificationInboxModule, SignaturesModule, ExchangeModule.forRootAsync({ inject: [ConfigService], From 32aaa77d5045fbf00c59bb15e625d73ce4cf5218 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 8 Jul 2026 07:25:34 +0000 Subject: [PATCH 3/7] fix: backoffice socket --- .../src/modules/support/support.gateway.ts | 26 ++++++++----------- .../src/features/support/useSupportSocket.ts | 14 ++++++++++ 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/support/support.gateway.ts b/apps/edr-passenger-api/src/modules/support/support.gateway.ts index 203c39035..c5b65497b 100644 --- a/apps/edr-passenger-api/src/modules/support/support.gateway.ts +++ b/apps/edr-passenger-api/src/modules/support/support.gateway.ts @@ -7,7 +7,6 @@ import { import { Server, Socket } from 'socket.io'; import { Passenger as PassengerTypes } from '@edr/types'; -import { PrismaService } from '../../common/prisma.service'; import { WsAuthService } from './ws-auth.service'; /** @@ -34,27 +33,24 @@ export class SupportGateway implements OnGatewayConnection { @WebSocketServer() private readonly server!: Server; - constructor( - private readonly wsAuth: WsAuthService, - private readonly prisma: PrismaService, - ) {} + constructor(private readonly wsAuth: WsAuthService) {} async handleConnection(socket: Socket): Promise { const userId = await this.wsAuth.resolveUserId(this.extractToken(socket)); - // Authenticated: passenger (own room) or backoffice staff (shared room). + // A valid token means a backoffice agent: the portal connects only with a + // device/guest id (never a token), so every token-authed socket is staff. + // Join the shared backoffice room — no passenger-row heuristic needed. if (userId) { socket.data.userId = userId; - const passenger = await this.prisma.passenger.findUnique({ - where: { iamUserId: userId }, + socket.data.side = 'AGENT'; + await socket.join(SupportGateway.BACKOFFICE_ROOM); + socket.emit('support:hello', { + side: 'AGENT', + room: SupportGateway.BACKOFFICE_ROOM, + userId, }); - if (passenger) { - await socket.join(`user:${userId}`); - socket.data.side = 'USER'; - } else { - await socket.join(SupportGateway.BACKOFFICE_ROOM); - socket.data.side = 'AGENT'; - } + this.logger.debug(`support socket ${socket.id} → AGENT (backoffice)`); return; } diff --git a/apps/edr-passenger-web/backoffice/src/features/support/useSupportSocket.ts b/apps/edr-passenger-web/backoffice/src/features/support/useSupportSocket.ts index 503793d2f..aabeb0ce4 100644 --- a/apps/edr-passenger-web/backoffice/src/features/support/useSupportSocket.ts +++ b/apps/edr-passenger-web/backoffice/src/features/support/useSupportSocket.ts @@ -44,6 +44,20 @@ export function useSupportSocket( }, ); + // Temporary diagnostics — remove once live delivery is confirmed. + socket.on('connect', () => + console.warn('[support] agent socket connected', socket.id), + ); + socket.on('connect_error', (err) => + console.warn('[support] agent socket connect_error:', err.message), + ); + socket.on('disconnect', (reason) => + console.warn('[support] agent socket disconnected:', reason), + ); + socket.on('support:hello', (info) => + console.warn('[support] server assigned:', info), + ); + socket.on( Passenger.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW, (event: Passenger.PassengerSupportMessageEvent) => { From 7bedfde6e7aac39fa249b1d5b6ce499796333a90 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 8 Jul 2026 08:19:35 +0000 Subject: [PATCH 4/7] add production DB drift check and fix for invoice payments --- .../py/prod-check-invoices-payments.sql | 114 ++++++++++++++++++ .../src/pages/contracts/NewShipmentPage.tsx | 13 +- 2 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 apps/edr-freight-api/py/prod-check-invoices-payments.sql diff --git a/apps/edr-freight-api/py/prod-check-invoices-payments.sql b/apps/edr-freight-api/py/prod-check-invoices-payments.sql new file mode 100644 index 000000000..fa94c481d --- /dev/null +++ b/apps/edr-freight-api/py/prod-check-invoices-payments.sql @@ -0,0 +1,114 @@ +-- ============================================================================ +-- Production DB drift check + fix for the batch/window flow. +-- +-- WHY: BookingBatchService.reserve() calls billing.syncPayableDueDate, which +-- queries freight.invoices.payments (a jsonb ledger added by migration +-- 1828000000000-ExtendInvoicesForPartialPayment). If that column is MISSING on +-- production (snapshot/restore drift — the migration can read as "applied" in +-- freight.migrations while the DDL never took effect), every reserve() throws +-- `column Invoice.payments does not exist`, the batch fill loop aborts mid-pass, +-- and you see exactly: +-- * only ONE booking gets a pay window (the loop dies after the first reserve +-- whose invoice sync throws), and +-- * reservations never expire cleanly (the settle path hits the same query). +-- +-- Run STEP 1 first (read-only). If it shows the columns are MISSING, run STEP 2 +-- (idempotent, additive — safe to run even if partially applied). +-- ============================================================================ + +-- --------------------------------------------------------------------------- +-- STEP 1 — CHECK (read-only). Expect all 6 rows present; if any are missing, +-- production has the drift and STEP 2 is required. +-- --------------------------------------------------------------------------- +SELECT column_name +FROM information_schema.columns +WHERE table_schema = 'freight' + AND table_name = 'invoices' + AND column_name IN ( + 'payments', 'subtotal_amount', 'tax_amount', + 'paid_amount', 'balance_amount', 'paid_at' + ) +ORDER BY column_name; +-- Also confirm the enum has the partial-payment statuses: +SELECT unnest(enum_range(NULL::freight.invoices_status_enum))::text AS status; +-- Expect ISSUED and PARTIALLY_PAID to be present. + + +-- --------------------------------------------------------------------------- +-- STEP 2 — FIX (idempotent). Only run if STEP 1 showed missing columns. +-- Mirrors migration 1828000000000 up(); all ADD COLUMN IF NOT EXISTS, so +-- re-running is safe. Wrapped so the enum additions (which cannot run inside a +-- transaction block with immediate use) are applied first, then the columns. +-- --------------------------------------------------------------------------- + +-- Enum values (no-op if they already exist). +ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING'; +ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID'; + +-- Money-tracking + payments ledger columns. +ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_at timestamptz, + ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]'; + +-- Backfill derived money fields for existing rows (only rows not already set). +UPDATE freight.invoices + SET subtotal_amount = total_amount, + balance_amount = total_amount + WHERE subtotal_amount = 0 AND balance_amount = 0; + +UPDATE freight.invoices + SET paid_amount = total_amount, + balance_amount = 0, + paid_at = COALESCE(paid_at, updated_at) + WHERE status = 'PAID' AND paid_amount = 0; + +-- --------------------------------------------------------------------------- +-- STEP 3 — RE-CHECK. Re-run STEP 1; all 6 columns + both enum values should +-- now be present. After this, deploy the freight_feature/usermanagement branch +-- and the batch will reserve ALL fitting bookings + expire non-payers + top up. +-- --------------------------------------------------------------------------- + + +-- ============================================================================ +-- STEP 4 — BROADER DRIFT AUDIT (read-only). The same snapshot drift that hid +-- invoices.payments can hide OTHER columns the batch flow selects. reserve() +-- and settleReserved() load the FULL Booking entity, so ANY missing booking +-- column throws mid-loop (e.g. we already hit +-- `column Booking.consolidation_resume_status does not exist`). This lists every +-- booking column the entity expects that is MISSING from production — expect +-- ZERO rows. Any row = a drifted migration whose DDL must be re-applied. +-- ============================================================================ +WITH expected(col) AS ( + SELECT unnest(ARRAY[ + 'reference','customer_id','company_id','company_profile_id','is_government', + 'government_institution','train_id','status','contract_id','contract_route_id', + 'booking_type','contract_kind','created_by_role','created_by_user_id', + 'scheduled_date','estimated_shipment_date','expires_at','total_amount', + 'adjusted_total_amount','adjusted_by_staff_id','adjusted_at','adjustment_reason', + 'contract_validity_days','contract_valid_from','contract_valid_until', + 'payment_status','contract_type','service_type_id','customs_clearing_enabled', + 'customs_clearing_agent','equipment_return','origin_yard_id','destination_yard_id', + 'trade_direction','freight_type','cargo_type_id','cargo_free_text','shipping_line_id', + 'cargo_total_weight_vgm','is_hazardous','is_reefer','bulk_hazardous_quantity', + 'bulk_reefer_quantity','payment_currency','pnr_code','fully_executed_at', + 'pricing_breakdown','locked_at','priority_score','consolidation_partner_id', + 'consolidation_resume_status','wagons_required','scheduling_status', + 'hold_started_at','hold_expires_at','scheduled_at','train_schedule_id', + 'loaded_at','arrived_at','payment_deadline','selected_for_batch_at', + 'gl_station_yard_id','clearance_current_phase','duty_required', + 'vessel_departure_date','ro_amendment_requested_at','ro_hold_reason', + 'pre_clearance_finalized_at','gl_assigned_staff_id','gl_assigned_at' + ]) +) +SELECT e.col AS missing_booking_column +FROM expected e +LEFT JOIN information_schema.columns c + ON c.table_schema = 'freight' AND c.table_name = 'bookings' AND c.column_name = e.col +WHERE c.column_name IS NULL +ORDER BY e.col; +-- If any rows come back, tell me which columns — I'll give you the exact +-- migration(s) to re-apply (each is ADD COLUMN IF NOT EXISTS, idempotent). diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index d064197d5..e2c7bffcc 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -455,8 +455,6 @@ function PriceConfirmModal({ const hasPairingBlock = pairingErrors.length > 0; const capacityErrors = validation?.capacityErrors ?? []; const hasCapacityBlock = capacityErrors.length > 0; - const confirmDisabled = - loading || validationLoading || hasPairingBlock || hasCapacityBlock; // Authoritative server breakdown — the SAME BookingPricingService pass that // prices the booking on create, so it carries every line the booking will be @@ -479,6 +477,17 @@ function PriceConfirmModal({ }; }, [validation, baseTotal]); + // Block confirm until the authoritative server price is in hand. The client + // baseTotal fallback is display-only; booking on it (e.g. after a validation + // error clears validationLoading with no data) would let the customer confirm + // an un-validated, possibly wrong price. + const confirmDisabled = + loading || + validationLoading || + hasPairingBlock || + hasCapacityBlock || + !serverTotal; + // Fallback while the server preview loads: the contract's frozen unit rates // (container/bulk + hazard/reefer only) with the overweight surcharge folded // in. Replaced by the full server breakdown the moment it arrives. From 44023efb125d0fecb5ed193d3c4ace91f8a7f4b8 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 8 Jul 2026 09:08:53 +0000 Subject: [PATCH 5/7] solve priorty issue --- .../train-scheduling/booking-batch.service.ts | 111 ++++- .../booking-window.service.ts | 33 +- .../trainScheduling/ForecastPanel.tsx | 404 ++++++++++++++++++ .../trainScheduling/PriorityTrackingTab.tsx | 59 ++- .../trainScheduling/batchForecast.ts | 189 ++++++++ .../backoffice/src/types/trainScheduling.ts | 2 + 6 files changed, 770 insertions(+), 28 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/ForecastPanel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/batchForecast.ts diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 2a9169ab7..26f8749be 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -177,6 +177,8 @@ export interface BatchBoardSchedule { /** Weight committed on the train (allocated + selected-for-batch). */ usedWeightTons: number; maxWeightTons: number | null; + /** Wagon-slot cap for the train (locomotive/wagon-type derived). */ + maxWagons: number | null; }; counts: { allocated: number; @@ -290,6 +292,9 @@ export class BookingBatchService implements OnModuleInit { * schedule-scoped — only the fill is day-level). */ async processRouteDay(group: RouteDayGroup): Promise { + this.logger.log( + `[BATCH] processRouteDay START ${group.originYardId}->${group.destinationYardId} ${group.day}`, + ); const scheduleIds = await this.fillRouteDay( group.originYardId, group.destinationYardId, @@ -863,7 +868,7 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") @@ -902,6 +907,7 @@ export class BookingBatchService implements OnModuleInit { lengthMeters: number; }>, loco: Locomotive | null, + maxWagons: number | null, ): BatchBoardSchedule["capacity"] { const allocated = items.filter((i) => i.state === "ALLOCATED"); const committed = items.filter( @@ -918,6 +924,7 @@ export class BookingBatchService implements OnModuleInit { Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / 100, maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null, + maxWagons: maxWagons ?? null, }; } @@ -956,7 +963,7 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") @@ -1033,6 +1040,7 @@ export class BookingBatchService implements OnModuleInit { const pool = await this.bookingsRepository.findBatchPool(scheduleId); const units = this.groupConsolidatedPool(pool); let armed = false; + let reservedThisPass = 0; // Batch fill trace: caps + pool at entry. Kept on debug level — invaluable when // reservations trickle instead of landing in one pass (a reserve() throwing @@ -1087,18 +1095,33 @@ export class BookingBatchService implements OnModuleInit { } } - if (isGov) { - await this.allocate(scheduleId, booking, "gov"); - if (partner) await this.allocate(scheduleId, partner, "gov"); - } else { - await this.reserve(booking, scheduleId); - if (partner) await this.reserve(partner, scheduleId); - armed = true; + // Isolate each unit so a throw in reserve/allocate (e.g. billing hiccup) + // can't abort the whole top-up pass and leave the rest to trickle in one + // per tick. Log + skip the failing unit, keep going. + try { + if (isGov) { + await this.allocate(scheduleId, booking, "gov"); + if (partner) await this.allocate(scheduleId, partner, "gov"); + } else { + await this.reserve(booking, scheduleId); + if (partner) await this.reserve(partner, scheduleId); + armed = true; + } + budget.subtract(need, leg); + reservedThisPass += 1; + } catch (err) { + this.logger.error( + `[fillSchedule ${scheduleId}] reserve/allocate FAILED for ${booking.reference} ` + + `— skipping this unit, continuing: ${(err as Error).message}`, + ); + continue; } - budget.subtract(need, leg); if (budget.maxRemaining().wagons <= 0) break; // every leg exhausted — nothing more can board } + this.logger.log( + `[fillSchedule ${scheduleId}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`, + ); if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL"); if (armed) this.armSettle(scheduleId); void this.triggerWagonAllocation(scheduleId); @@ -1190,6 +1213,7 @@ export class BookingBatchService implements OnModuleInit { `trains=${trains.map((t) => `${t.id}:${JSON.stringify(t.budget.maxRemaining())}`).join(",")} ` + `poolSize=${pool.length} units=${units.length}`, ); + let reservedThisPass = 0; for (const unit of units) { const { primary: booking, partner } = unit; @@ -1256,17 +1280,35 @@ export class BookingBatchService implements OnModuleInit { continue; } - if (isGov) { - await this.allocate(target.id, booking, "gov"); - if (partner) await this.allocate(target.id, partner, "gov"); - } else { - await this.reserve(booking, target.id); - if (partner) await this.reserve(partner, target.id); - target.armed = true; + // A throw here (e.g. a billing/invoice hiccup inside reserve) must NOT abort + // the whole pass — otherwise only the bookings before the failure get a pay + // window and the rest trickle in one-per-tick on later retries (the + // "selected one at a time / staggered" symptom). Isolate each unit: log + + // skip a failing one, keep reserving the others. The skipped unit stays in + // the pool and is retried next cycle. + try { + if (isGov) { + await this.allocate(target.id, booking, "gov"); + if (partner) await this.allocate(target.id, partner, "gov"); + } else { + await this.reserve(booking, target.id); + if (partner) await this.reserve(partner, target.id); + target.armed = true; + } + target.budget.subtract(need, legOn(target)!); + reservedThisPass += 1; + } catch (err) { + this.logger.error( + `[fillRouteDay] reserve/allocate FAILED for ${booking.reference} on ${target.id} ` + + `— skipping this unit, continuing the batch: ${(err as Error).message}`, + ); } - target.budget.subtract(need, legOn(target)!); } + this.logger.log( + `[fillRouteDay ${originYardId}->${destinationYardId} ${day}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`, + ); + for (const t of trains) { if (t.budget.maxRemaining().wagons <= 0) await this.setWindow(t.id, "FULL"); if (t.armed) this.armSettle(t.id); @@ -1402,6 +1444,9 @@ export class BookingBatchService implements OnModuleInit { const byId = new Map(reserved.map((b) => [b.id, b])); const done = new Set(); let anySettled = false; + this.logger.debug( + `[settleReserved ${scheduleId}] ${reserved.length} reserved booking(s) to settle`, + ); const isPaid = (b: Booking) => b.paymentStatus === "PAID" || b.status === "PAID"; @@ -1448,7 +1493,14 @@ export class BookingBatchService implements OnModuleInit { /** Durable settle: allocate paid / expire overdue reservations, then top up. */ async settleDueReservations(scheduleId: string): Promise { const anySettled = await this.settleReserved(scheduleId, false); - if (anySettled) await this.fillSchedule(scheduleId); + // A settle that allocated/expired anything frees or fills capacity → re-run the + // fill so the next waiting-list bookings get a fresh pay window (top-up). + if (anySettled) { + this.logger.log( + `[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`, + ); + await this.fillSchedule(scheduleId); + } } // ---- settle (1h after a batch) ------------------------------------------- @@ -1637,6 +1689,11 @@ export class BookingBatchService implements OnModuleInit { "PREPAID", ); await this.notifier.payNow(booking, deadline); + this.logger.log( + `[BATCH] RESERVED ${booking.reference} (${this.wagonsFor(booking)}w, ` + + `priority ${booking.priorityScore ?? 0}) on schedule ${scheduleId} — ` + + `pay by ${deadline.toISOString()}`, + ); // Customer tracking: a wagon slot is reserved and the freight pay window is // open. Doc-trigger path — silent no-op for bookings without milestone rows. void this.completeTrackingMilestones(booking.id, [ @@ -1671,6 +1728,9 @@ export class BookingBatchService implements OnModuleInit { selectedForBatchAt: null, } as never); }); + this.logger.log( + `[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`, + ); this.notifier.secured(booking, reason); void this.triggerWagonAllocation(scheduleId); void this.markWagonAllocatedMilestone(booking.id); @@ -1738,6 +1798,10 @@ export class BookingBatchService implements OnModuleInit { // source-agnostic. await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID"); this.notifier.expired(booking); + this.logger.log( + `[BATCH] EXPIRED ${booking.reference} — payment window passed; freed its ` + + `wagons back to the pool for top-up`, + ); } /** @@ -1793,6 +1857,12 @@ export class BookingBatchService implements OnModuleInit { corridorYards, group.day, ); + if (unaccepted.length > 0) { + this.logger.log( + `[BATCH] doc-review end: expiring ${unaccepted.length} un-accepted booking(s) ` + + `on ${group.originYardId}->${group.destinationYardId} ${group.day}`, + ); + } for (const booking of unaccepted) { await this.bookingsRepository.update(booking.id, { status: "EXPIRED", @@ -1807,8 +1877,7 @@ export class BookingBatchService implements OnModuleInit { .catch(() => undefined); this.notifier.expired(booking); this.logger.log( - `Expired unaccepted booking ${booking.reference}:${booking.id} at doc-review end ` + - `(${group.originYardId}->${group.destinationYardId} ${group.day})`, + `[BATCH] EXPIRED (unaccepted) ${booking.reference}:${booking.id} at doc-review end`, ); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 0f600d549..0d9087419 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -92,8 +92,17 @@ export class BookingWindowService implements OnModuleInit { now, ); } catch (err) { + // This is THE line to watch when a window freezes mid-phase: the tick + // catches a throw here per-schedule and moves on, so a schedule whose + // transition keeps throwing stays stuck in its phase forever. Log the + // phase + stack so the failing step is obvious. this.logger.error( - `Window transition failed for schedule ${schedule.id}: ${(err as Error).message}`, + `[WINDOW] transition FAILED for schedule ${schedule.id} ` + + `(phase=${schedule.windowPhase}, cycle=${schedule.bookingCycleNo}): ` + + `${(err as Error).message}`, + ); + this.logger.error( + `[WINDOW] stack: ${((err as Error).stack ?? "").split("\n").slice(0, 5).join(" | ")}`, ); } } @@ -236,7 +245,8 @@ export class BookingWindowService implements OnModuleInit { // Fire-and-forget so a slow SMS/email gateway never stalls the tick loop. if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule); this.logger.log( - `Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`, + `[WINDOW] ${schedule.id} PRE_WINDOW→OPEN — booking window opened ` + + `(cycle ${schedule.bookingCycleNo})`, ); return true; } @@ -251,7 +261,8 @@ export class BookingWindowService implements OnModuleInit { schedule.bookingWindowStatus = 'CLOSED'; } this.logger.log( - `Booking stopped for schedule ${schedule.id}; staff document review until ${docReviewEndsAt.toISOString()}`, + `[WINDOW] ${schedule.id} OPEN→DOC_REVIEW — booking closed; staff document ` + + `review until ${docReviewEndsAt.toISOString()}`, ); return true; } @@ -277,7 +288,8 @@ export class BookingWindowService implements OnModuleInit { // is handled inside the fill (all fit → all reserved → all notified). await this.bookingBatchService.processRouteDay(routeDay); this.logger.log( - `Batch ran for schedule ${schedule.id}; payment phase until ${paymentPhaseEndsAt.toISOString()}`, + `[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch ran; payment phase ` + + `until ${paymentPhaseEndsAt.toISOString()}`, ); return true; } @@ -287,6 +299,10 @@ export class BookingWindowService implements OnModuleInit { schedule.paymentPhaseEndsAt != null && now >= schedule.paymentPhaseEndsAt ) { + this.logger.log( + `[WINDOW] ${schedule.id} PAYMENT window ended — settling reservations ` + + `(allocate paid / expire unpaid) then concluding the cycle`, + ); await this.bookingBatchService.settleDueReservations(schedule.id); await this.concludeCycle(schedule, cfg, now); return true; @@ -306,6 +322,9 @@ export class BookingWindowService implements OnModuleInit { await this.bookingBatchService.setWindow(schedule.id, 'FULL'); await this.setPhase(schedule, { windowPhase: 'DONE' }); await this.tryAutoFinalize(schedule.id); + this.logger.log( + `[WINDOW] ${schedule.id} conclude → train FULL — window DONE, finalizing`, + ); return; } @@ -324,7 +343,8 @@ export class BookingWindowService implements OnModuleInit { if (nextOpensAt == null) { await this.setPhase(schedule, { windowPhase: 'DONE' }); this.logger.log( - `Schedule ${schedule.id} not full but no cycle fits before departure — window done`, + `[WINDOW] ${schedule.id} conclude → not full but no cycle fits before ` + + `departure — window DONE`, ); return; } @@ -347,7 +367,8 @@ export class BookingWindowService implements OnModuleInit { }); const sameDay = eatDay(nextOpensAt) === eatDay(now); this.logger.log( - `Schedule ${schedule.id} not full — window reopens ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`, + `[WINDOW] ${schedule.id} conclude → NOT full, waiting list may remain — ` + + `REOPENS ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`, ); } diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ForecastPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ForecastPanel.tsx new file mode 100644 index 000000000..ecf27c07d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ForecastPanel.tsx @@ -0,0 +1,404 @@ +import { useMemo } from "react"; +import { + Alert, + Badge, + Box, + Group, + Paper, + Progress, + Stack, + Text, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { + Crown, + Container, + Boxes, + FlaskConical, + Layers, + Ruler, + Scale, + Sparkles, + TrainFront, + Trophy, + XCircle, +} from "lucide-react"; + +import type { BatchBoardScheduleDetail } from "@/types/trainScheduling"; +import { + simulateBatch, + limitsFromDetail, + type BlockingAxis, + type ForecastRow, +} from "./batchForecast"; + +type Props = { + data: BatchBoardScheduleDetail; + bookings: BatchBoardScheduleDetail["pendingContract"]["bookings"]; +}; + +const cardVar = (color: string, shade: number) => + `var(--mantine-color-${color}-${shade})`; + +const fmtTons = (n: number) => + `${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`; +const fmtMeters = (n: number) => + `${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`; + +const AXIS_LABEL: Record = { + wagons: "wagon slots full", + weight: "over max pull weight", + length: "over train length", +}; + +/** One capacity axis as a labelled meter (used vs cap). */ +function AxisMeter({ + icon: Icon, + label, + used, + cap, + fmt, + color, +}: { + icon: typeof Scale; + label: string; + used: number; + cap: number | null; + fmt: (n: number) => string; + color: string; +}) { + const pct = cap && cap > 0 ? Math.min(100, (used / cap) * 100) : 0; + const near = pct >= 90; + return ( + + + + + + {label} + + + + {fmt(used)} + {cap != null ? ` / ${fmt(cap)}` : ""} + + + + + ); +} + +function FreightIcon({ type }: { type: string | null }) { + const Icon = type === "BULK" ? Boxes : Container; + return ( + + + + + + ); +} + +/** A single forecast row: rank, booking, capacity contribution, projected verdict. */ +function ForecastCard({ row }: { row: ForecastRow }) { + const { booking, rank, selected, blockedBy } = row; + const gov = booking.isGovernment; + + return ( + + + + + {gov ? ( + + ) : ( + + {rank} + + )} + + + + + {booking.reference} + + + {gov ? ( + + + + + + ) : null} + + + {booking.company} + + + + + + {/* score */} + + + + {booking.priorityScore} + + + {/* wagons + weight this booking adds */} + + + + {booking.wagons}w + + + + {fmtTons(booking.weightTons)} + + {/* verdict */} + + {selected ? ( + } + > + Would board + + ) : ( + + + Waiting list + + + )} + + + + + ); +} + +/** Cut line between the simulated batch and the simulated waiting list. */ +function CutLine({ full }: { full: boolean }) { + return ( + + + + + + + + Forecast capacity line{full ? " · TRAIN FULL" : ""} + + + + + ); +} + +/** + * Forecast / "what-if" panel. Simulates the batch engine's greedy fill on the + * current pool and shows the projected winners + waiting list BEFORE document + * review closes. Not the real selection — the engine commits that when staff run + * the batch after the review window ends. + */ +export function ForecastPanel({ data, bookings }: Props) { + const limits = useMemo(() => limitsFromDetail(data), [data]); + const sim = useMemo( + () => simulateBatch(bookings, limits), + [bookings, limits], + ); + + const noCaps = + limits.maxWagons == null && + limits.maxWeightTons == null && + limits.maxLengthMeters == null; + + return ( + + {/* Header + explainer */} + + + + + + + + + Forecast batch (simulated) + + Preview + + + + What the batch engine would pick if it ran now — greedy fill by + priority until the train is full. The real selection happens when + document review ends and staff run the batch. + + + + + + + {sim.selected.length} + + + would board + + + + + {sim.waiting.length} + + + waiting list + + + + + + {/* Three capacity axes */} + + `${n}`} + color="edr-green" + /> + + + + + {noCaps ? ( + } + > + No locomotive / capacity limits on this schedule yet — forecast can't + draw the capacity line. Assign a locomotive to simulate the fill. + + ) : null} + + + {sim.rows.length === 0 ? ( + + + No eligible bookings to forecast yet. + + + ) : ( + + {/* WOULD BOARD */} + {sim.selected.length > 0 ? ( + + + + + + + Projected batch{" "} + + ({sim.selected.length}) — top priority, fits capacity + + + + {sim.selected.map((r) => ( + + ))} + + ) : null} + + + + {/* WAITING LIST */} + {sim.waiting.length > 0 ? ( + + + + + + + Projected waiting list{" "} + + ({sim.waiting.length}) — boards only if a slot frees up + + + + {sim.waiting.map((r) => ( + + ))} + + ) : null} + + {/* INELIGIBLE (expired / pending contract) */} + {sim.ineligible.length > 0 ? ( + + {sim.ineligible.length} booking + {sim.ineligible.length === 1 ? "" : "s"} not in the forecast + (expired or contract not signed). + + ) : null} + + )} + + ); +} + +export default ForecastPanel; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx index 0271d199c..84367fad7 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx @@ -1,9 +1,10 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Box, Group, Paper, Progress, + SegmentedControl, Stack, Text, ThemeIcon, @@ -15,9 +16,11 @@ import { Clock, Container, Crown, + FlaskConical, Hourglass, Layers, ListOrdered, + Radio, TrainFront, Trophy, XCircle, @@ -30,6 +33,8 @@ import type { BatchBoardScheduleDetail, } from "@/types/trainScheduling"; import { WindowPhasePill } from "./batchVisuals"; +import { ForecastPanel } from "./ForecastPanel"; +import { forecastIsLive } from "./batchForecast"; /** * Priority Tracking tab — live, glanceable ranking of every booking on this @@ -266,6 +271,15 @@ export function PriorityTrackingTab({ data, bookings }: Props) { const phase = data.windowPhase; const isPayPhase = phase === "PAYMENT"; + // Before the batch is committed (pre-window / open / doc-review) the real + // selection doesn't exist yet — offer a simulated forecast of who WOULD board. + // Default to it while it's live; let staff flip to the current live state. + const forecastAvailable = forecastIsLive(phase); + const [view, setView] = useState<"forecast" | "live">( + forecastAvailable ? "forecast" : "live", + ); + const showForecast = forecastAvailable && view === "forecast"; + // Rank exactly as the batch engine does: government first, then priority score // desc, then oldest (fullyExecutedAt / selectedForBatchAt as the tiebreak the // backend uses). The board already returns them in this order, but re-sort @@ -320,8 +334,51 @@ export function PriorityTrackingTab({ data, bookings }: Props) { let rankNo = 0; + const viewToggle = forecastAvailable ? ( + setView(v as "forecast" | "live")} + size="sm" + radius="md" + data={[ + { + value: "forecast", + label: ( + + + + Forecast + + + ), + }, + { + value: "live", + label: ( + + + + Live state + + + ), + }, + ]} + /> + ) : null; + + if (showForecast) { + return ( + + {viewToggle ? {viewToggle} : null} + + + ); + } + return ( + {viewToggle ? {viewToggle} : null} {/* Header: phase + capacity meter */} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/batchForecast.ts b/apps/edr-freight-web/backoffice/src/components/trainScheduling/batchForecast.ts new file mode 100644 index 000000000..f7365feda --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/batchForecast.ts @@ -0,0 +1,189 @@ +import type { + BatchBoardBookingDetail, + BatchBoardScheduleDetail, +} from "@/types/trainScheduling"; + +/** + * Client-side forecast of what the batch engine WOULD select if it ran right now. + * + * The real selection only happens once the document-review window closes and staff + * hit "run batch". Before that, operations can only see the *current* per-booking + * state (READY / SELECTED / …). This module simulates the engine's greedy fill so + * the board can show the likely winners + waiting list live, during OPEN and + * DOC_REVIEW, before anything is committed. + * + * It mirrors the engine (booking-batch.service): rank government-first, then + * priority score desc, then oldest booked; greedily board each booking while it + * fits ALL THREE capacity axes at once — wagon slots, max pull weight (tons), and + * train length (metres). The first booking that busts any axis, and everyone after + * it, drops to the waiting list. Purely a projection; the server stays the source + * of truth for the real run. + */ + +export interface ForecastLimits { + /** Wagon-slot cap (schedule.maxWagons), or null if unknown. */ + maxWagons: number | null; + /** Locomotive max pull weight in tons, or null. */ + maxWeightTons: number | null; + /** Max train length in metres, or null. */ + maxLengthMeters: number | null; +} + +/** Which capacity axis stopped a booking from boarding (for the "why not" hint). */ +export type BlockingAxis = "wagons" | "weight" | "length"; + +export interface ForecastRow { + booking: BatchBoardBookingDetail; + /** 1-based rank across the whole eligible pool. */ + rank: number; + /** True → boards in the simulated batch; false → simulated waiting list. */ + selected: boolean; + /** Cumulative wagons/weight/length AFTER this booking (only when selected). */ + cumulativeWagons: number; + cumulativeWeightTons: number; + cumulativeLengthMeters: number; + /** If not selected, the first axis that would have overflowed. */ + blockedBy: BlockingAxis | null; +} + +export interface ForecastResult { + rows: ForecastRow[]; + selected: ForecastRow[]; + waiting: ForecastRow[]; + /** Bookings excluded from the sim entirely (expired / no signed contract). */ + ineligible: BatchBoardBookingDetail[]; + limits: ForecastLimits; + /** Totals of the simulated batch. */ + usedWagons: number; + usedWeightTons: number; + usedLengthMeters: number; + /** True once any axis is at/over its cap — train is "full" in the sim. */ + full: boolean; +} + +/** Engine rank order: government first, then priority desc, then oldest booked. */ +export function rankBookings( + bookings: BatchBoardBookingDetail[], +): BatchBoardBookingDetail[] { + const time = (b: BatchBoardBookingDetail) => + b.fullyExecutedAt + ? new Date(b.fullyExecutedAt).getTime() + : Number.MAX_SAFE_INTEGER; + return [...bookings].sort((a, b) => { + if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1; + if (b.priorityScore !== a.priorityScore) + return b.priorityScore - a.priorityScore; + return time(a) - time(b); + }); +} + +/** + * A booking can compete in the batch only once its contract is signed. Expired + * bookings and pending-contract bookings never board, so they're pulled out of the + * sim (surfaced separately so they don't vanish from the board). + */ +function isEligible(b: BatchBoardBookingDetail): boolean { + return b.state !== "EXPIRED" && b.state !== "PENDING_CONTRACT"; +} + +const round2 = (n: number) => Math.round(n * 100) / 100; + +/** Would adding `add` to `used` exceed `cap`? (cap null ⇒ axis unconstrained.) */ +function overflows(used: number, add: number, cap: number | null): boolean { + return cap != null && used + add > cap; +} + +export function simulateBatch( + bookings: BatchBoardBookingDetail[], + limits: ForecastLimits, +): ForecastResult { + const ranked = rankBookings(bookings); + const eligible = ranked.filter(isEligible); + const ineligible = ranked.filter((b) => !isEligible(b)); + + const rows: ForecastRow[] = []; + let wagons = 0; + let weight = 0; + let length = 0; + // Once the train is full we stop boarding, but keep ranking the rest as waiting. + let full = false; + + eligible.forEach((booking, i) => { + let blockedBy: BlockingAxis | null = null; + if (!full) { + if (overflows(wagons, booking.wagons, limits.maxWagons)) + blockedBy = "wagons"; + else if (overflows(weight, booking.weightTons, limits.maxWeightTons)) + blockedBy = "weight"; + else if (overflows(length, booking.lengthMeters, limits.maxLengthMeters)) + blockedBy = "length"; + } + // Strict fill: the first booking that doesn't fit closes the train, so lower- + // priority bookings can't leapfrog it even if they'd individually fit. Matches + // the engine's greedy pass. + const selected = !full && blockedBy === null; + if (selected) { + wagons += booking.wagons; + weight = round2(weight + booking.weightTons); + length = round2(length + booking.lengthMeters); + } else { + full = true; + } + rows.push({ + booking, + rank: i + 1, + selected, + cumulativeWagons: selected ? wagons : 0, + cumulativeWeightTons: selected ? weight : 0, + cumulativeLengthMeters: selected ? length : 0, + blockedBy: selected ? null : (blockedBy ?? firstBindingAxis(limits)), + }); + }); + + return { + rows, + selected: rows.filter((r) => r.selected), + waiting: rows.filter((r) => !r.selected), + ineligible, + limits, + usedWagons: wagons, + usedWeightTons: weight, + usedLengthMeters: length, + full, + }; +} + +/** When the train closed on an earlier booking, name the tightest axis for the hint. */ +function firstBindingAxis(limits: ForecastLimits): BlockingAxis { + if (limits.maxWagons != null) return "wagons"; + if (limits.maxWeightTons != null) return "weight"; + return "length"; +} + +/** Pull the three capacity caps off the board detail response. */ +export function limitsFromDetail( + data: BatchBoardScheduleDetail, +): ForecastLimits { + return { + maxWagons: data.capacity.maxWagons ?? null, + maxWeightTons: + data.capacity.maxWeightTons ?? + data.locomotive?.maxPullWeightTons ?? + null, + maxLengthMeters: + data.capacity.maxLengthMeters ?? + data.locomotive?.maxTrainLengthMeters ?? + null, + }; +} + +/** + * The forecast is meaningful before the batch is committed — i.e. while bookings + * are still being taken or reviewed. Once the engine has run (PAYMENT onward) the + * real per-booking state is the truth, so we stop showing the projection. + */ +export function forecastIsLive( + phase: BatchBoardScheduleDetail["windowPhase"], +): boolean { + return phase === "PRE_WINDOW" || phase === "OPEN" || phase === "DOC_REVIEW"; +} diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 2f0422c6f..ab14a18da 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -283,6 +283,8 @@ export interface BatchBoardSchedule { maxLengthMeters: number | null; usedWeightTons: number; maxWeightTons: number | null; + /** Wagon-slot cap for the train (locomotive/wagon-type derived). */ + maxWagons: number | null; }; counts: { allocated: number; From 80904eeeb2ac2fef959286d100c9adc9bef82b95 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 8 Jul 2026 09:32:24 +0000 Subject: [PATCH 6/7] Handover SIGN notification, resending until signed no exit before sign --- .../bookings/customer-truck.service.ts | 9 + .../notifications/notify-company.util.ts | 37 ++++ .../warehouses/dto/create-warehouse.dto.ts | 7 +- .../warehouses/dto/store-inventory.dto.ts | 29 +++ .../entities/warehouse-inventory.entity.ts | 4 +- .../modules/warehouses/handover.service.ts | 33 +++- .../warehouse-inspection.service.ts | 43 ++++- .../warehouse-inventory.controller.ts | 19 +- .../warehouses/warehouse-inventory.service.ts | 104 +++++++++-- .../modules/warehouses/warehouses.service.ts | 4 +- .../warehouses/ContainerItemsModal.tsx | 50 ++++- .../warehouses/CreateWarehouseModal.tsx | 18 +- .../warehouses/InventoryWorkbench.tsx | 14 +- .../warehouses/ReceiveInventoryModal.tsx | 156 ++++++++-------- .../warehouses/ReleaseOrderModal.tsx | 172 ++++++++++-------- .../warehouses/StoreInventoryModal.tsx | 143 +++++++++++++++ .../warehouses/WarehouseInventoryTable.tsx | 1 - .../src/components/warehouses/options.ts | 21 +++ .../src/pages/operations/LastMilePage.tsx | 3 + .../backoffice/src/services/api.ts | 8 +- .../src/services/warehouse.service.ts | 26 ++- .../backoffice/src/types/warehouse.ts | 17 +- 22 files changed, 703 insertions(+), 215 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/notifications/notify-company.util.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/StoreInventoryModal.tsx 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 693b60e09..14402f830 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 @@ -57,6 +57,15 @@ export class CustomerTruckService { 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`); diff --git a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts new file mode 100644 index 000000000..9d7f32c3d --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts @@ -0,0 +1,37 @@ +import { DataSource } from 'typeorm'; + +import { NotificationsService } from './notifications.service'; + +/** + * Best-effort SMS + email fan-out to a company's contacts. Looks up the + * company's phone/email and sends the message over both channels, swallowing + * per-channel failures so a missing provider never breaks the caller's flow. + */ +export async function sendCompanyChannels( + dataSource: DataSource, + notifications: NotificationsService, + companyId: string, + message: string, +): Promise { + const [contact]: Array<{ phone: string | null; email: string | null }> = + await dataSource.query( + `SELECT COALESCE(phone, etrade_phone) AS phone, email + FROM freight.companies + WHERE id = $1 AND deleted_at IS NULL`, + [companyId], + ); + if (contact?.phone) { + try { + await notifications.directSend('sms', contact.phone, message); + } catch { + /* best-effort: SMS provider unavailable */ + } + } + if (contact?.email) { + try { + await notifications.directSend('email', contact.email, message); + } catch { + /* best-effort: email provider unavailable */ + } + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts index 900a9d8ac..a99ca4f46 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator'; -import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity'; +import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity'; export class CreateWarehouseDto { @ApiProperty() @@ -58,4 +58,9 @@ export class CreateWarehouseDto { @IsNumber() @Min(0) maxVolume?: number; + + @ApiPropertyOptional({ enum: WAREHOUSE_STATUSES, default: 'ACTIVE' }) + @IsOptional() + @IsEnum(WAREHOUSE_STATUSES) + status?: WarehouseStatus; } diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts new file mode 100644 index 000000000..08b15d536 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsUUID } from 'class-validator'; + +/** + * Optional explicit storage location. When warehouse/yard/zone are all provided, + * the item is stored there directly; otherwise store() falls back to the + * allocation-rule / capacity-balanced auto pick. + */ +export class StoreInventoryDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + warehouseId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + zoneId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index 290b6f0c2..a54f40973 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -34,7 +34,9 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record { + const repo = this.dataSource.getRepository(BookingHandover); + const existing = await repo.find({ where: { bookingId }, order: { generatedAt: 'ASC' } }); + + if (existing.length === 0) { + // No handover yet (truck not arrived): create a booking-level one so the + // customer has something to sign. ensureForArrivedTruck notifies on create. + const created = await this.ensureForArrivedTruck(bookingId, {}); + return { notified: true, reference: created.reference, alreadySigned: false }; + } + + const unsigned = existing.find((h) => !h.signedAt); + if (!unsigned) { + return { notified: false, reference: existing[0].reference, alreadySigned: true }; + } + await this.notifySignNeeded(bookingId, unsigned.reference); + return { notified: true, reference: unsigned.reference, alreadySigned: false }; + } + /** * Self-haul: ensure a handover exists for a customer truck that just arrived. * Idempotent — one per (booking, truck). Runs inside the caller's transaction diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts index 0a0c56821..b5894c21b 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts @@ -1,8 +1,13 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { NotificationAudience, NotificationType } from '@edr/types'; + import { FilesService } from '../files/files.service'; import { LastMileService } from '../last-mile/last-mile.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { sendCompanyChannels } from '../notifications/notify-company.util'; import { CreateInspectionReportDto } from './dto/create-inspection-report.dto'; import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; @@ -13,11 +18,15 @@ const INSPECTION_RESOURCE = 'warehouse-inspection-report'; @Injectable() export class WarehouseInspectionService { + private readonly logger = new Logger(WarehouseInspectionService.name); + constructor( private readonly dataSource: DataSource, private readonly inspectionRepository: WarehouseInspectionRepository, private readonly filesService: FilesService, private readonly lastMileService: LastMileService, + private readonly inbox: NotificationInboxService, + private readonly notifications: NotificationsService, ) {} /** Create or update the inspection report for an inventory item and sync its inspectionStatus. */ @@ -83,8 +92,10 @@ export class WarehouseInspectionService { const [row] = await this.dataSource.query( `SELECT inv.booking_id AS "bookingId", b.reference AS "bookingReference", + b.company_id AS "companyId", b.trade_direction AS "tradeDirection", b.last_mile_delivery_address AS "lastMileDeliveryAddress", + b.customer_truck_assigned_at AS "customerTruckAssignedAt", COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile" FROM freight.warehouse_inventory inv LEFT JOIN freight.bookings b ON b.id = inv.booking_id @@ -105,6 +116,36 @@ export class WarehouseInspectionService { if (row.bookingReference && hasLastMile) { await this.lastMileService.acceptBooking(row.bookingReference); + } else if (!hasLastMile && !row.customerTruckAssignedAt) { + // Self-haul import: goods are pickup-ready but no collection truck is + // assigned yet — nudge the customer to assign one from the portal. + void this.notifyTruckAssignmentNeeded(row); + } + } + + /** Portal nudge: import goods are ready for pickup but no customer truck is assigned. */ + private async notifyTruckAssignmentNeeded(row: { + bookingId?: string | null; + bookingReference?: string | null; + companyId?: string | null; + }): Promise { + if (!row.companyId || !row.bookingId) return; + const body = `Booking ${row.bookingReference ?? row.bookingId} has passed inspection and is ready for pickup. Please assign your collection truck(s) from the portal to proceed.`; + try { + await this.inbox.notify({ + recipients: { companyId: row.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Assign a truck for pickup', + body, + link: `/bookings/${row.bookingId}`, + data: { bookingId: row.bookingId, action: 'ASSIGN_TRUCK' }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, row.companyId, body); + } catch (err) { + this.logger.warn( + `Truck-assignment notify failed for ${row.bookingId}: ${(err as Error).message}`, + ); } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 84baaf4da..9eb4ea502 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -9,6 +9,7 @@ import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; import { LoadInventoryDto } from './dto/load-inventory.dto'; import { MoveInventoryDto } from './dto/move-inventory.dto'; +import { StoreInventoryDto } from './dto/store-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; import { ReleaseOrderDto } from './dto/release-order.dto'; import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; @@ -267,9 +268,9 @@ export class WarehouseInventoryController { } @Post(':id/store') - @ApiOperation({ summary: 'Mark received inventory as STORED' }) - store(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { - return this.inventoryService.store(id, performedBy); + @ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' }) + store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) { + return this.inventoryService.store(id, dto.performedBy, dto); } @Post(':id/ready-for-loading') @@ -354,12 +355,24 @@ export class WarehouseInventoryController { return this.handoverService.list(bookingId); } + @Post('bookings/:bookingId/request-handover-signature') + @ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' }) + requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.handoverService.requestSignature(bookingId); + } + @Get('bookings/:bookingId/container-items') @ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' }) containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.inventoryService.containerItems(bookingId); } + @Get('bookings/:bookingId/container-weights') + @ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" }) + containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.inventoryService.bookingContainerWeights(bookingId); + } + @Post(':id/deliver') @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { 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 004704bea..14f4e27f3 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 @@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity'; import { LastMileService } from '../last-mile/last-mile.service'; import { NotificationsService } from '../notifications/notifications.service'; +import { sendCompanyChannels } from '../notifications/notify-company.util'; import { SignaturesService } from '../signatures/signatures.service'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto'; @@ -358,12 +359,14 @@ export interface ImportUnloadedRow { customerTruckType: string | null; customerTruckContainerNumber: string | null; customerTruckAssignedAt: string | null; + hasAssignedTruck: boolean; currentStatus: string; releaseDate: string | null; releaseOrderReference: string | null; handoverDocumentReference: string | null; handoverDocumentDate: string | null; deliveredAt: string | null; + notes: string | null; } @Injectable() @@ -403,16 +406,18 @@ export class WarehouseInventoryService { if (!booking.companyId) return; if (booking.hasFirstMile || booking.hasLastMile) return; // EDR mile — no customer truck if (booking.customerTruckAssignedAt) return; // already assigned + const body = `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`; try { await this.inbox.notify({ recipients: { companyId: booking.companyId }, audience: NotificationAudience.PORTAL, type: NotificationType.BOOKING_STATUS, title: 'Assign a truck for pickup', - body: `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`, + body, link: `/bookings/${bookingId}`, data: { bookingId, action: 'ASSIGN_TRUCK' }, }); + await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body); } catch (err) { this.logger.warn(`Truck-assignment notify failed for ${bookingId}: ${(err as Error).message}`); } @@ -1338,12 +1343,18 @@ export class WarehouseInventoryService { b.customer_truck_type AS "customerTruckType", b.customer_truck_container_number AS "customerTruckContainerNumber", b.customer_truck_assigned_at AS "customerTruckAssignedAt", + (b.customer_truck_assigned_at IS NOT NULL + OR EXISTS (SELECT 1 FROM freight.last_mile lm + WHERE lm.booking_id = b.id + AND lm.vehicle_id IS NOT NULL + AND lm.deleted_at IS NULL)) AS "hasAssignedTruck", inv.status AS "currentStatus", inv.release_date AS "releaseDate", inv.release_order_reference AS "releaseOrderReference", substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference", substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate", inv.delivered_at AS "deliveredAt", + inv.notes AS "notes", oy.country AS "originCountry", dy.country AS "destinationCountry" FROM freight.warehouse_inventory inv @@ -2141,13 +2152,30 @@ export class WarehouseInventoryService { // ── Lifecycle transitions ──────────────────────────────────────────────── - async store(id: string, performedBy?: string): Promise { + async store( + id: string, + performedBy?: string, + chosen?: { warehouseId?: string; yardId?: string; zoneId?: string }, + ): Promise { const item = await this.findById(id); this.assertTransition(item.status, 'STORED'); + // Explicit location wins when the operator picked warehouse + yard + zone; + // otherwise fall back to the allocation-rule / capacity-balanced auto pick. + const manualLocation = + chosen?.warehouseId && chosen?.yardId && chosen?.zoneId + ? { + warehouseId: chosen.warehouseId, + yardId: chosen.yardId, + zoneId: chosen.zoneId, + path: undefined as string | undefined, + } + : null; + const criteria = await this.getInventoryAllocationCriteria(item); - const ruleLocation = await this.allocation.resolveLocation(criteria); - const location = ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria)); + const ruleLocation = manualLocation ? null : await this.allocation.resolveLocation(criteria); + const location = + manualLocation ?? ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria)); if (!location) { throw new BadRequestException('No active warehouse yard/zone is available for this inventory item'); @@ -2191,18 +2219,19 @@ export class WarehouseInventoryService { await this.applyCapacityDelta(manager, location, weight, volume, containerCount); } + const storedReason = manualLocation + ? `Stored at operator-selected location -> ${location.path ?? 'chosen yard/zone'}` + : ruleLocation?.rule + ? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}` + : `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`; + await manager.getRepository(WarehouseInventory).update(id, { status: 'STORED', storedAt: new Date(), warehouseId: location.warehouseId, yardId: location.yardId, zoneId: location.zoneId, - notes: this.appendNote( - locked.notes, - ruleLocation?.rule - ? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}` - : `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`, - ), + notes: this.appendNote(locked.notes, storedReason), }); await this.activityLog.record( @@ -2210,9 +2239,7 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_STORED', inventoryId: id, warehouseId: location.warehouseId, - description: ruleLocation?.rule - ? `Inventory stored by rule "${ruleLocation.rule.name}" at ${ruleLocation.path}` - : `Inventory stored at ${location.path ?? 'assigned yard/zone'}`, + description: storedReason.replace(/^Stored/, 'Inventory stored'), performedBy, }, manager, @@ -2331,6 +2358,26 @@ export class WarehouseInventoryService { 'Customer must sign the handover before the exit paper can be generated', ); } + + // Authoritative weight match: the truck's net (gross − tare) must equal the + // total VGM cargo weight of the containers selected as loaded on it. + if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) { + const selected = dto.containerNumber + .split(/[,;\n]+/) + .map((n) => n.trim()) + .filter(Boolean); + if (selected.length) { + const weights = await this.bookingContainerWeights(item.bookingId); + const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons])); + const expected = selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0); + const computedNet = Number((dto.grossWeight - dto.tareWeight).toFixed(3)); + if (expected > 0 && Math.abs(computedNet - expected) > 0.001) { + throw new BadRequestException( + `Weight mismatch: gross − tare (${computedNet} t) must equal the selected containers' cargo weight (${expected} t).`, + ); + } + } + } } } const releaseDate = isTruckLeaving @@ -2565,6 +2612,7 @@ export class WarehouseInventoryService { bookingReference: string | null; contractId: string | null; hasLastMile: boolean; + handoverSigned: boolean; }> > { const rows: Array<{ @@ -2611,6 +2659,10 @@ export class WarehouseInventoryService { [bookingId], ); + // Booking-level gate: the per-truck exit paper is blocked until the handover + // is fully signed, so the UI can disable "Exit Paper" with a clear reason. + const handoverSigned = await this.handover.isFullySigned(bookingId); + return rows.map((r) => ({ containerNumber: r.containerNumber, goods: r.goods, @@ -2633,6 +2685,32 @@ export class WarehouseInventoryService { bookingReference: r.bookingReference, contractId: r.contractId, hasLastMile: r.hasLastMile, + handoverSigned, + })); + } + + /** + * The booking's containers with their VGM cargo weight (tonnes), keyed by + * container number. Drives the truck-leaving exit weighing: the selected + * containers' total cargo weight must match (gross − tare). + */ + async bookingContainerWeights( + bookingId: string, + ): Promise> { + const rows: Array<{ containerNumber: string; weightTons: string }> = + await this.dataSource.query( + `SELECT bcu.container_number AS "containerNumber", + COALESCE(bcu.vgm_tons, 0) AS "weightTons" + 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 + ORDER BY bcu.container_number`, + [bookingId], + ); + return rows.map((r) => ({ + containerNumber: r.containerNumber, + weightTons: Number(r.weightTons) || 0, })); } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts index f92401dfc..140d9f6b4 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts @@ -64,8 +64,8 @@ export class WarehousesService { currentWeight: 0, currentContainers: 0, currentVolume: 0, - status: 'ACTIVE', - isActive: true, + status: dto.status ?? 'ACTIVE', + isActive: (dto.status ?? 'ACTIVE') === 'ACTIVE', }); } catch (error) { this.mapDbError(error); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx index 168bfd744..bb769df46 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx @@ -11,6 +11,7 @@ import { Table, Tabs, Text, + Tooltip, } from '@mantine/core'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { FileText } from 'lucide-react'; @@ -22,7 +23,7 @@ import { type ContainerItem, type ContainerItemStage, } from '@/services/warehouse.service'; -import { extractErrorMessage } from './options'; +import { extractDownloadErrorMessage, extractErrorMessage } from './options'; import { openPdfBlob } from './pdf'; interface ContainerItemsModalProps { @@ -90,12 +91,29 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }), }); + const requestSign = async () => { + try { + const res = await warehouseService.requestHandoverSignature(bookingId as string); + queryClient.invalidateQueries({ queryKey: itemsKey }); + if (res.alreadySigned) { + toast({ title: 'Handover already signed', description: 'You can generate the exit paper now.' }); + } else { + toast({ + title: 'Handover not signed', + description: `Signature request sent to the customer${res.reference ? ` (${res.reference})` : ''}.`, + }); + } + } catch (e) { + toast({ variant: 'destructive', title: 'Could not request signature', description: extractErrorMessage(e) }); + } + }; + const openExitPaper = async (assignmentId: string, plate: string) => { try { const res = await warehouseService.downloadTruckExitPaper(assignmentId); openPdfBlob(res.data, `exit-${plate}.pdf`); } catch (e) { - toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) }); + toast({ variant: 'destructive', title: 'Exit paper not ready', description: await extractDownloadErrorMessage(e) }); } }; @@ -164,15 +182,27 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen {i.hasLastMile ? EDR : Self-haul} {i.truckAssignmentId && ( - + + )} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx index b1afff27b..8c8560442 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx @@ -102,7 +102,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh await updateMutation.mutateAsync({ id: warehouse.id, payload: { ...payload, status: form.status } }); toast({ title: 'Warehouse updated' }); } else { - await createMutation.mutateAsync(payload); + await createMutation.mutateAsync({ ...payload, status: form.status }); toast({ title: 'Warehouse created' }); } onClose(); @@ -149,15 +149,13 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseType) ?? 'OPEN_WAREHOUSE' }))} allowDeselect={false} /> - {isEdit && ( - setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))} + allowDeselect={false} + /> void; } -/** Inventory table + all lifecycle actions (advance / move / reserve / history). */ +/** Inventory table + all lifecycle actions (advance / move / history). */ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWorkbenchProps) { const { toast } = useToast(); const [busyId, setBusyId] = useState(null); const [moveItem, setMoveItem] = useState(null); - const [reserveItem, setReserveItem] = useState(null); const [loadItem, setLoadItem] = useState(null); const [historyItem, setHistoryItem] = useState(null); const [viewItem, setViewItem] = useState(null); @@ -171,7 +169,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo const storeInventory = async (item: WarehouseInventoryItem) => { setBusyId(item.id); try { - const stored = await storeMutation.mutateAsync(item.id); + const stored = await storeMutation.mutateAsync({ id: item.id }); toast({ title: 'Inventory stored', description: [stored.warehouse?.code, stored.yard?.code, stored.zone?.code].filter(Boolean).join(' / '), @@ -187,9 +185,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo switch (action) { case 'store': return storeInventory(item); - case 'reserve': - setReserveItem(item); - return; case 'ready-for-loading': return runDirect(item, () => readyMutation.mutateAsync(item.id), 'Ready for loading'); case 'load': @@ -258,11 +253,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo setMoveItem(null)} item={moveItem} /> - setReserveItem(null)} - item={reserveItem} - /> setLoadItem(null)} item={loadItem} /> >(new Set()); const [inspectId, setInspectId] = useState(null); @@ -2192,8 +2196,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { const [feeItem, setFeeItem] = useState(null); const [releaseItem, setReleaseItem] = useState(null); const [deliverItem, setDeliverItem] = useState(null); - const [loadTruckItem, setLoadTruckItem] = useState(null); const [containerItemsItem, setContainerItemsItem] = useState(null); + const [storeItem, setStoreItem] = useState(null); + const [moveItem, setMoveItem] = useState(null); const allSelected = rows.length > 0 && selected.size === rows.length; const someSelected = selected.size > 0 && !allSelected; @@ -2413,49 +2418,16 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { - {r.currentStatus === 'UNLOADED' && ( + {/* Primary stage action stays visible; the rest live under the kebab. */} + {r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && r.hasAssignedTruck && ( - )} - {['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && ( - - )} - {r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && ( - <> - - - )} - {r.currentStatus === 'READY_FOR_PICKUP' && ( - )} {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && ( @@ -2470,40 +2442,64 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { Exit Paper )} - {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && ( - - )} - {r.inspectionStatus === 'PASSED' && ( - - )} - - - setFeeItem(toInventoryItem(r))}> - - - - - setHistoryItem(toInventoryItem(r))}> - - - + + + + + + + + {r.currentStatus === 'UNLOADED' && ( + } onClick={() => setStoreItem(toInventoryItem(r))}> + Store… + + )} + {r.currentStatus !== 'UNLOADED' && ( + } onClick={() => setMoveItem(toInventoryItem(r))}> + Move… + + )} + {['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && ( + runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}> + Ready for pickup + + )} + {r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && ( + } + disabled={!r.hasAssignedTruck} + onClick={() => setReleaseItem(toInventoryItem(r))} + > + {r.hasAssignedTruck + ? r.releaseOrderReference + ? 'Truck leaving' + : 'Truck arrival' + : 'Truck arrival — assign a truck first'} + + )} + {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && ( + } onClick={() => openReleaseDocument(r)}> + Exit paper + + )} + {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && ( + setDeliverItem(toInventoryItem(r))}>Deliver + )} + {r.inspectionStatus === 'PASSED' && ( + } onClick={() => openHandoverDocument(r)}> + {r.handoverDocumentReference ? 'View handover' : 'Handover'} + + )} + setInspectId(r.id)}>Inspect / report + + } onClick={() => setFeeItem(toInventoryItem(r))}> + Storage / fee preview + + } onClick={() => setHistoryItem(toInventoryItem(r))}> + History + + + @@ -2526,13 +2522,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { inventoryId={feeItem?.id ?? null} /> setReleaseItem(null)} item={releaseItem} /> + setStoreItem(null)} item={storeItem} /> + setMoveItem(null)} item={moveItem} /> setDeliverItem(null)} item={deliverItem} /> - setLoadTruckItem(null)} - bookingId={loadTruckItem?.booking?.id ?? null} - bookingReference={loadTruckItem?.booking?.reference ?? null} - /> setContainerItemsItem(null)} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index 1bf371e5d..4268b8732 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; +import { Alert, Button, Group, Modal, MultiSelect, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; import { Info, Scale } from 'lucide-react'; import { useMutation, useQuery } from '@tanstack/react-query'; @@ -28,29 +28,6 @@ export interface ReleaseOrderTruckPrefill { containerNumber?: string | null; } -const REGISTERED_FIRST_LAST_MILE_TRUCKS = [ - ['03-ET A45843', '43495'], ['03-ET A45866', '43470'], ['03-ET A45853', '43508'], ['03-ET A45849', '43414'], - ['03-ET A45845', '43492'], ['03-ET A45820', '43487'], ['03-ET A45842', '43478'], ['03-ET A45841', '43515'], - ['03-ET A45832', '43504'], ['03-ET A45856', '43510'], ['03-ET A45865', '43490'], ['03-ET A45855', '43485'], - ['03-ET A45840', '43499'], ['03-ET A45867', '43493'], ['03-ET A45833', '43466'], ['03-ET A45858', '43496'], - ['03-ET A45819', '43474'], ['03-ET A45834', '43502'], ['03-ET A45868', '43469'], ['03-ET A45831', '43488'], - ['03-ET A45828', '43479'], ['03-ET A45850', '43505'], ['03-ET A45823', '43480'], ['03-ET A45838', '43472'], - ['03-ET A45854', '43500'], ['03-ET A45839', '43486'], ['03-ET A45861', '43513'], ['03-ET A45830', '43501'], - ['03-ET A45826', '43498'], ['03-ET A45836', '43467'], ['03-ET A45822', '43512'], ['03-ET A45821', '43210'], - ['03-ET A45837', '43475'], ['03-ET A45860', '43497'], ['03-ET A45863', '43477'], ['03-ET A45825', '43483'], - ['03-ET A45829', '43473'], ['03-ET A45824', '43491'], ['03-ET A45857', '43481'], ['03-ET A45851', '43509'], - ['03-ET A45827', '43468'], ['03-ET A45859', '43887'], ['03-ET A45846', '43471'], ['03-ET A45847', '43511'], - ['03-ET A45852', '43484'], ['03-ET A45844', '43476'], ['03-ET A45835', '43482'], ['03-ET A45864', '43503'], - ['03-ET A45848', '43494'], ['03-ET A45862', '43465'], ['03-ET A39105', '41218'], ['03-ET A39098', '41220'], - ['03-ET A29900', '41865'], ['03-ET A39097', '41226'], ['03-ET A39104', '41225'], ['03-ET A39103', '41223'], - ['03-ET A39106', '41221'], ['03-ET A39107', '41215'], ['03-ET A39094', '41222'], ['03-ET A39099', '41216'], - ['03-ET A39092', '41224'], ['03-ET A31801', '41214'], -].map(([powerPlate, trailerPlate], index) => ({ - value: powerPlate, - label: `${index + 1}. ${powerPlate} / ${trailerPlate}`, - trailerPlate, -})); - const toIsoDateTime = (value: string) => { if (!value) return undefined; const date = new Date(value); @@ -141,6 +118,13 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea queryFn: () => warehouseService.getCustomerTrucks(bookingId as string), enabled: opened && Boolean(bookingId), }); + // Per-container cargo weights — the truck's net (gross − tare) must equal the + // total cargo weight of the containers selected as loaded on it. + const { data: containerWeights = [] } = useQuery({ + queryKey: ['release-container-weights', bookingId], + queryFn: () => warehouseService.getContainerWeights(bookingId as string), + enabled: opened && Boolean(bookingId), + }); const [reference, setReference] = useState(''); const [truckPlateNumber, setTruckPlateNumber] = useState(''); const [trailerPlateNumber, setTrailerPlateNumber] = useState(''); @@ -210,22 +194,39 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea truckType: t.truckType, })), ]; - const truckSelectOptions = [ - ...assignedTruckOptions, - ...REGISTERED_FIRST_LAST_MILE_TRUCKS.map((t) => ({ - value: t.value, - label: t.label, - trailerPlate: t.trailerPlate, - driverName: '', - driverPhone: '', - truckType: '', - })), - ]; + // Only trucks actually assigned to THIS booking (last-mile prefill or customer + // portal) are selectable. No global fleet list — if nothing is assigned, the + // operator types the plate manually in the field below. + const truckSelectOptions = assignedTruckOptions; // Neither a last-mile truck nor a customer truck has been assigned yet. const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck; const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill; const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName); - const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight); + + // Which containers ride this truck, and their combined cargo weight. When the + // booking has container weights, that sum is the authoritative net; the + // operator selects the containers loaded on the truck at exit. + const hasContainerWeights = containerWeights.length > 0; + const containerWeightByNumber = new Map( + containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]), + ); + const containerSelectData = containerWeights.map((c) => ({ + value: c.containerNumber, + label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`, + })); + const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean); + const selectedCargoWeight = Number( + selectedContainerNumbers + .reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0) + .toFixed(3), + ); + const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0; + + const systemNetWeight = useContainerNet + ? selectedCargoWeight + : item?.weight == null + ? netWeight + : Number(item.weight); const computedNetWeight = tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null; const weightMismatch = @@ -246,6 +247,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' }); return; } + if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) { + toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' }); + return; + } if (isExitStep && systemNetWeight === '') { toast({ variant: 'destructive', title: 'System recorded net weight is missing' }); return; @@ -336,23 +341,25 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea Truck is not assigned yet — assign a last-mile or customer truck, or enter the plate manually below. )} - truck.value === truckPlateNumber) ? truckPlateNumber : null} + onChange={(value) => { + const truck = truckSelectOptions.find((row) => row.value === value); + setTruckPlateNumber(truck?.value ?? ''); + setTrailerPlateNumber(truck?.trailerPlate ?? ''); + if (truck?.driverName) setDriverName(truck.driverName); + if (truck?.driverPhone) setDriverPhone(truck.driverPhone); + if (truck?.truckType) setTruckType(truck.truckType); + }} + /> + )} setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} /> setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} /> - - - 1 ? 2 : 1} spacing="sm"> - {containerNumbers.map((containerNumber, index) => ( - 1 ? `Container number ${index + 1}` : 'Container number'} - value={containerNumber} - onChange={(e) => - setContainerNumbers((numbers) => - numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)), - ) - } - readOnly={isTruckIdentityLocked} - /> - ))} - - + + {hasContainerWeights ? ( + setContainerNumbers(values.length ? values : [''])} + /> + ) : ( + + 1 ? 2 : 1} spacing="sm"> + {containerNumbers.map((containerNumber, index) => ( + 1 ? `Container number ${index + 1}` : 'Container number'} + value={containerNumber} + onChange={(e) => + setContainerNumbers((numbers) => + numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)), + ) + } + readOnly={isTruckIdentityLocked} + /> + ))} + + + )} setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} /> setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} /> setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} /> - + diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/StoreInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/StoreInventoryModal.tsx new file mode 100644 index 000000000..f1a5aa4ad --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/StoreInventoryModal.tsx @@ -0,0 +1,143 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Alert, Button, Group, Modal, Select, Stack, Text } from '@mantine/core'; +import { Info } from 'lucide-react'; + +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; +import { useToast } from '@/hooks/use-toast'; +import type { WarehouseInventoryItem } from '@/types/warehouse'; +import { extractErrorMessage } from './options'; + +interface StoreInventoryModalProps { + opened: boolean; + onClose: () => void; + item: WarehouseInventoryItem | null; +} + +/** + * Store an unloaded import item. The operator may pick warehouse → yard → zone + * explicitly; leaving them blank falls back to the backend auto allocation. + */ +export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryModalProps) { + const { toast } = useToast(); + const storeMutation = useMutation(api.warehouses.store.mutationOptions()); + const [warehouseId, setWarehouseId] = useState(''); + const [yardId, setYardId] = useState(''); + const [zoneId, setZoneId] = useState(''); + + useEffect(() => { + if (opened) { + setWarehouseId(''); + setYardId(''); + setZoneId(''); + } + }, [opened]); + + const warehousesQuery = useQuery( + api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }), + ); + const yardsQuery = useQuery( + api.warehouses.listYards.queryOptions({ + input: { warehouseId }, + enabled: Boolean(warehouseId), + }), + ); + const zonesQuery = useQuery( + api.warehouses.listZones.queryOptions({ + input: { yardId }, + enabled: Boolean(yardId), + }), + ); + + const warehouseOptions = useMemo( + () => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })), + [warehousesQuery.data], + ); + const yardOptions = useMemo( + () => (yardsQuery.data ?? []).filter((y) => y.status === 'ACTIVE').map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })), + [yardsQuery.data], + ); + const zoneOptions = useMemo( + () => (zonesQuery.data ?? []).filter((z) => z.status === 'ACTIVE').map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })), + [zonesQuery.data], + ); + + const isManual = Boolean(warehouseId || yardId || zoneId); + const manualComplete = Boolean(warehouseId && yardId && zoneId); + + const handleSubmit = async () => { + if (!item) return; + if (isManual && !manualComplete) { + toast({ variant: 'destructive', title: 'Pick warehouse, yard and zone — or clear all to auto-allocate' }); + return; + } + try { + await storeMutation.mutateAsync({ + id: item.id, + payload: manualComplete ? { warehouseId, yardId, zoneId } : undefined, + }); + toast({ title: manualComplete ? 'Inventory stored at selected location' : 'Inventory stored (auto-allocated)' }); + onClose(); + } catch (error) { + toast({ variant: 'destructive', title: 'Store failed', description: extractErrorMessage(error) }); + } + }; + + return ( + + + } color="blue" variant="light"> + + Choose a warehouse, yard and zone to store this item at a specific location, or leave them + blank to let the system auto-allocate by rule / available capacity. + + + { + setYardId(v ?? ''); + setZoneId(''); + }} + /> +