From 129448e437edb3be34e7a10a2a551c6fd432148c Mon Sep 17 00:00:00 2001 From: hagiye Date: Thu, 2 Jul 2026 01:08:37 +0300 Subject: [PATCH] frist mile receive to warehouse and last mile truck arrival integration --- .../src/config/database.config.ts | 1 + .../1821000000002-CreateInvoices.ts | 2 +- ...29000000000-CentralizeWarehouseInvoices.ts | 25 ++++ .../modules/last-mile/last-mile.service.ts | 6 + .../warehouse-inspection.service.ts | 9 +- .../warehouses/warehouse-inventory.service.ts | 19 ++- .../warehouses/ReceiveInventoryModal.tsx | 77 ++++++++++-- .../warehouses/ReleaseOrderModal.tsx | 36 ++++-- .../src/pages/operations/FirstMilePage.tsx | 35 ++++++ .../src/pages/operations/LastMilePage.tsx | 116 +++++++++++++++++- .../src/services/last-mile.service.ts | 3 + 11 files changed, 299 insertions(+), 30 deletions(-) diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index 0e7375b19..9e206f529 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -116,6 +116,7 @@ export default registerAs("database", (): TypeOrmModuleOptions => { freightMigrationsGlob, ], migrationsRun: true, + migrationsTransactionMode: "each", // Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows). synchronize: false, logging: process.env.NODE_ENV === "development", diff --git a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts index 87a7f39bb..4c2fb5d95 100644 --- a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts +++ b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts @@ -94,7 +94,7 @@ export class CreateInvoices1821000000002 implements MigrationInterface { BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint - WHERE conname = 'pk_invoices' + WHERE contype = 'p' AND conrelid = 'freight.invoices'::regclass ) THEN ALTER TABLE freight.invoices ADD CONSTRAINT pk_invoices PRIMARY KEY (id); diff --git a/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts b/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts index dd246cb7d..75082c5c4 100644 --- a/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts +++ b/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts @@ -19,6 +19,31 @@ export class CentralizeWarehouseInvoices1829000000000 implements MigrationInterf name = 'CentralizeWarehouseInvoices1829000000000'; public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'freight' + AND table_name = 'invoices' + AND column_name = 'booking_id' + ) THEN + ALTER TABLE freight.invoices ALTER COLUMN booking_id DROP NOT NULL; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'freight' + AND table_name = 'invoices' + AND column_name = 'amount' + ) THEN + ALTER TABLE freight.invoices ALTER COLUMN amount DROP NOT NULL; + END IF; + END $$; + `); + // 1. Invoice headers. Keep the same id so items still link, and so any // external reference to the invoice id stays valid. await queryRunner.query(` diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 5faad49b9..f1d103344 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -129,6 +129,12 @@ export class LastMileService { } async create(dto: CreateLastMileDto): Promise { + const [existing] = await this.lastMileRepository.findAll({ + where: { bookingId: dto.bookingId }, + take: 1, + }); + if (existing) return existing; + return this.lastMileRepository.create({ bookingId: dto.bookingId, status: dto.status ?? 'READY_TO_TRANSIT', 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 1de6daf81..ff25258d3 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 @@ -84,9 +84,11 @@ export class WarehouseInspectionService { `SELECT inv.booking_id AS "bookingId", b.reference AS "bookingReference", b.trade_direction AS "tradeDirection", - b.last_mile_delivery_address AS "lastMileDeliveryAddress" + b.last_mile_delivery_address AS "lastMileDeliveryAddress", + COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile" FROM freight.warehouse_inventory inv LEFT JOIN freight.bookings b ON b.id = inv.booking_id + LEFT JOIN freight.service_types st ON st.id = b.service_type_id WHERE inv.id = $1 AND inv.deleted_at IS NULL LIMIT 1`, [inventoryId], @@ -98,7 +100,10 @@ export class WarehouseInspectionService { readyForPickupAt: new Date(), }); - if (row.bookingReference && row.lastMileDeliveryAddress) { + const hasLastMile = + Boolean(row.lastMileDeliveryAddress?.trim?.()) || Boolean(row.serviceIncludesLastMile); + + if (row.bookingReference && hasLastMile) { await this.lastMileService.acceptBooking(row.bookingReference); } } 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 5b6c35afd..3f55a8d61 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 @@ -1061,9 +1061,11 @@ export class WarehouseInventoryService { COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber", ts.train_number AS "trainSchedule", inv.inspection_status AS "inspectionStatus", - CASE WHEN b.last_mile_delivery_address IS NOT NULL + CASE WHEN NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_last_mile, false) THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption", - (b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested", + (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested", b.customer_truck_plate_number AS "customerTruckPlateNumber", b.customer_truck_driver_name AS "customerTruckDriverName", b.customer_truck_type AS "customerTruckType", @@ -1081,6 +1083,7 @@ export class WarehouseInventoryService { LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.service_types st ON st.id = b.service_type_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL @@ -1666,13 +1669,17 @@ export class WarehouseInventoryService { if (!bookingId) return; const [booking] = await this.dataSource.query( `SELECT reference, - last_mile_delivery_address AS "lastMileDeliveryAddress" - FROM freight.bookings - WHERE id = $1 AND deleted_at IS NULL + last_mile_delivery_address AS "lastMileDeliveryAddress", + COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile" + FROM freight.bookings b + LEFT JOIN freight.service_types st ON st.id = b.service_type_id + WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, [bookingId], ); - if (!booking?.reference || !booking.lastMileDeliveryAddress) return; + const hasLastMile = + Boolean(booking?.lastMileDeliveryAddress?.trim?.()) || Boolean(booking?.serviceIncludesLastMile); + if (!booking?.reference || !hasLastMile) return; await this.lastMileService.acceptBooking(booking.reference); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 6e5245f87..c2cd106e6 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -76,6 +76,8 @@ interface ReceiveInventoryModalProps { /** When supplied the modal locks to a single booking (legacy single-receive). */ bookingId?: string; bookingLabel?: string; + mode?: 'single' | 'bulk'; + direction?: WarehouseFlowDirection; onReceived?: () => void; } @@ -716,11 +718,15 @@ function EligibleTab({ location, enabled, onChanged, + focusedBookingId, + focusedBookingLabel, }: { direction: 'IMPORT' | 'EXPORT'; location: Location; enabled: boolean; onChanged?: () => void; + focusedBookingId?: string; + focusedBookingLabel?: string; }) { const { toast } = useToast(); const qc = useQueryClient(); @@ -730,7 +736,15 @@ function EligibleTab({ enabled, }), ); - const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]); + const rows = useMemo( + () => + allRows.filter( + (r) => + r.direction === direction && + (!focusedBookingId || r.id === focusedBookingId), + ), + [allRows, direction, focusedBookingId], + ); const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions()); const requestFirstMile = useMutation({ mutationFn: (reference: string) => firstMileService.accept(reference), @@ -856,6 +870,18 @@ function EligibleTab({ title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`, description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined), }); + const firstGrn = r.results.find((item) => item.inventoryId && item.grnNumber); + if (focusedBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) { + const pdfWindow = window.open('', '_blank'); + try { + const response = await warehouseService.downloadGrnDocument(firstGrn.inventoryId); + const opened = openPdfBlob(response.data, `grn-${firstGrn.grnNumber}.pdf`, pdfWindow); + toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' }); + } catch (error) { + pdfWindow?.close(); + toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) }); + } + } setSelected(new Set()); setTruckOpen(false); setPendingReceiveIds([]); @@ -1007,7 +1033,9 @@ function EligibleTab({ ) : statusFilteredRows.length === 0 ? ( - No eligible PAID {direction.toLowerCase()} bookings to receive. + {focusedBookingLabel + ? `${focusedBookingLabel} is not eligible for warehouse receiving yet.` + : `No eligible PAID ${direction.toLowerCase()} bookings to receive.`} ) : ( @@ -2369,6 +2397,8 @@ interface WarehouseFlowWorkbenchProps { direction?: WarehouseFlowDirection; enabled?: boolean; onChanged?: () => void; + focusedBookingId?: string; + focusedBookingLabel?: string; } function WarehouseQueueTabs({ @@ -2588,10 +2618,14 @@ function ExportWarehouseTabs({ enabled, location, onChanged, + focusedBookingId, + focusedBookingLabel, }: { enabled: boolean; location: Location; onChanged?: () => void; + focusedBookingId?: string; + focusedBookingLabel?: string; }) { const [activeTab, setActiveTab] = useState('receive-queue'); const { data: eligibleRows = [] } = useQuery( @@ -2650,7 +2684,14 @@ function ExportWarehouseTabs({ {activeTab === 'receive-queue' && ( - + )} {activeTab === 'received' && ( @@ -2675,6 +2716,8 @@ export function WarehouseFlowWorkbench({ direction = 'BOTH', enabled = true, onChanged, + focusedBookingId, + focusedBookingLabel, }: WarehouseFlowWorkbenchProps) { const [location, setLocation] = useState({ warehouseId: '', yardId: '', zoneId: '' }); const [tab, setTab] = useState>( @@ -2707,24 +2750,42 @@ export function WarehouseFlowWorkbench({ - + ) : activeDirection === 'IMPORT' ? ( ) : ( - + )} ); } /** New bulk Receive: Import / Export tabs with eligible PAID bookings. */ -function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) { +function BulkReceiveModal({ opened, onClose, onReceived, bookingId, bookingLabel, direction = 'BOTH' }: ReceiveInventoryModalProps) { return ( - +