fix: stamp export self-haul truck arrival on receive to warehouse

Import's customer_truck_assignments.arrived_at is set by a separate later
gate action (release()'s arrival branch — the truck returning to collect
already-warehoused goods). Export has no equivalent second step: the truck
delivering cargo to the warehouse arrives and is received in the same act,
so its arrival was never recorded anywhere.

Add markCustomerTruckArrived, mirroring release()'s existing self-haul
departure UPDATE (plate-matched, COALESCE(arrived_at, NOW())), and call it
from receive()/bulkReceive() for EXPORT bookings
This commit is contained in:
Hagernesh
2026-08-08 09:24:59 +00:00
parent 3a78605893
commit c20f395f5f
2 changed files with 118 additions and 1 deletions

View File

@@ -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<void>;
function makeMarker() {
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
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();
});
});

View File

@@ -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<void> {
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')}