diff --git a/apps/edr-freight-api/src/modules/warehouses/mark-customer-truck-arrived.spec.ts b/apps/edr-freight-api/src/modules/warehouses/mark-customer-truck-arrived.spec.ts new file mode 100644 index 000000000..51a1f4c1c --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/mark-customer-truck-arrived.spec.ts @@ -0,0 +1,56 @@ +import { WarehouseInventoryService } from './warehouse-inventory.service'; + +/** + * Export self-haul has no separate "truck arrived" gate action the way import + * does (release()'s arrival branch, fired later when a truck shows up to + * COLLECT already-warehoused goods) — the truck delivering cargo TO the + * warehouse arrives and is received in the same act, so receive()/ + * bulkReceive() must stamp customer_truck_assignments.arrived_at themselves. + */ +type Marker = ( + manager: { query: jest.Mock }, + bookingId: string, + plateNumber: string | null | undefined, +) => Promise; + +function makeMarker() { + const service = Object.create(WarehouseInventoryService.prototype) as Record; + const marker = ( + service as unknown as { markCustomerTruckArrived: Marker } + ).markCustomerTruckArrived.bind(service); + return marker; +} + +describe('markCustomerTruckArrived', () => { + it('stamps arrival matched by booking + plate', async () => { + const marker = makeMarker(); + const manager = { query: jest.fn().mockResolvedValue(undefined) }; + + await marker(manager, 'b-1', 'AAA-2323'); + + expect(manager.query).toHaveBeenCalledTimes(1); + const [sql, params] = manager.query.mock.calls[0]; + expect(sql).toMatch(/UPDATE freight\.customer_truck_assignments/); + expect(sql).toMatch(/UPPER\(a\.plate_number\) = UPPER\(\$2\)/); + expect(params).toEqual(['b-1', 'AAA-2323']); + }); + + it('trims the plate before matching', async () => { + const marker = makeMarker(); + const manager = { query: jest.fn().mockResolvedValue(undefined) }; + + await marker(manager, 'b-1', ' AAA-2323 '); + + expect(manager.query.mock.calls[0][1]).toEqual(['b-1', 'AAA-2323']); + }); + + it('no-ops on a missing/blank plate — no query, nothing to match on', async () => { + const marker = makeMarker(); + const manager = { query: jest.fn() }; + + await marker(manager, 'b-1', undefined); + await marker(manager, 'b-1', ' '); + + expect(manager.query).not.toHaveBeenCalled(); + }); +}); 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 1159c1151..3e11380c1 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 @@ -1643,6 +1643,12 @@ export class WarehouseInventoryService { [bookingId], ); + // Export self-haul: this receive IS the truck's arrival — see + // markCustomerTruckArrived / receive()'s single-booking mirror. + if (dto.direction === 'EXPORT') { + await this.markCustomerTruckArrived(manager, bookingId, truckEntrance?.truckPlateNumber); + } + await this.activityLog.record( { activityType: 'INVENTORY_RECEIVED', @@ -2886,6 +2892,13 @@ export class WarehouseInventoryService { ); } + // Export self-haul: this receive IS the truck's arrival — stamp it on + // its own customer_truck_assignments row (mirror of import's arrival, + // see markCustomerTruckArrived). + if (dto.bookingId && bookingDirection === 'EXPORT') { + await this.markCustomerTruckArrived(manager, dto.bookingId, truckEntrance.truckPlateNumber); + } + await this.activityLog.record( { activityType: 'INVENTORY_RECEIVED', @@ -6008,6 +6021,33 @@ export class WarehouseInventoryService { }; } + /** + * EXPORT self-haul mirror of the customer truck lifecycle IMPORT already has: + * import stamps a truck's arrival on the SEPARATE gate action that comes + * later (release()'s arrival branch, when the customer's truck shows up to + * COLLECT already-warehoused goods). Export has no such separate step — the + * truck delivering cargo TO the warehouse arrives and is received in the + * same act, so receive()/bulkReceive() themselves are the arrival event. + * Matched by plate (not assignmentId — neither receive endpoint carries + * one), same as release()'s departure-branch self-haul UPDATE. + */ + private async markCustomerTruckArrived( + manager: EntityManager, + bookingId: string, + plateNumber: string | null | undefined, + ): Promise { + const plate = plateNumber?.trim(); + if (!plate) return; + await manager.query( + `UPDATE freight.customer_truck_assignments a + SET arrived_at = COALESCE(a.arrived_at, NOW()), updated_at = NOW() + WHERE a.booking_id = $1 + AND UPPER(a.plate_number) = UPPER($2) + AND a.deleted_at IS NULL`, + [bookingId, plate], + ); + } + private async getBookingTruckEntranceSource( manager: EntityManager, bookingId: string, @@ -6027,6 +6067,10 @@ export class WarehouseInventoryService { firstMileDriverPhone?: string | null; firstMileDriverLicenseNumber?: string | null; firstMileTruckType?: string | null; + customerTruckPlateNumber?: string | null; + customerTruckDriverName?: string | null; + customerTruckType?: string | null; + customerTruckContainerNumber?: string | null; }> { const [booking] = await manager.query( `SELECT b.reference AS "reference", @@ -6046,7 +6090,24 @@ export class WarehouseInventoryService { ) AS "firstMileDriverName", driver.phone_number AS "firstMileDriverPhone", driver.license_number AS "firstMileDriverLicenseNumber", - v.vehicle_type AS "firstMileTruckType" + v.vehicle_type AS "firstMileTruckType", + -- Self-haul truck assigned via the portal — export delivering to + -- the warehouse or import collecting from it. Same pattern as + -- eligibleBookings/importQueueByStatuses: multi-truck self-haul + -- writes plates/drivers to customer_truck_assignments and leaves + -- the booking columns null, so read the assignments first and + -- keep the legacy column as the fallback for single-truck + -- bookings written before that table existed. + COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ') + FROM freight.customer_truck_assignments cta + WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL), + b.customer_truck_plate_number) AS "customerTruckPlateNumber", + COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ') + FROM freight.customer_truck_assignments cta + WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL), + b.customer_truck_driver_name) AS "customerTruckDriverName", + b.customer_truck_type AS "customerTruckType", + b.customer_truck_container_number AS "customerTruckContainerNumber" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id ${primaryContactUserJoin('company')}