feat(api): expose last_wash timestamp on /numberplatescans (TRU-78) (#401)

Resolves TRU-78 (DRIFT 17: License plate scan - show 'last washed'
timestamp on landing page / DHL use case).

The POS landing page already surfaces license plate scans via `GET
/numberplatescans`, but it has no way to tell the operator **when a
plate was last washed**. With DHL trailers going in and out several
times a day, the front-desk needs that hint to decide whether a trailer
needs another wash before pick-up.

## Changes

- **`orders_o::getLastWashTimestampForPlate(string $reg_1): ?string`** —
new helper that returns the `created_at` (MySQL DATETIME) of the most
recent non-deleted order for the plate that has at least one non-deleted
order item. Mirrors the contract used by
`customer_vehicles_o::getLastOrderByPlate()` so the timestamp is always
backed by a real wash.
- **`GET /numberplatescans`** now enriches each scan row with a
`last_wash` key (string or `null`). No breaking change to the existing
payload; new field is additive.
- **New Pest test**
`services/nginx/app/tests/Unit/Orders/OrderLastWashTimestampForPlateTest.php`
— static-analysis assertions for the helper definition and the route
wiring (matches the style of `OrderBookingsCountsRouteWiringTest`).

## Frontend companion

https://github.com/copenhagentruckwash/pleno-vue/pull/335 renders this
`last_wash` in the inline details of each scan row on the POS landing
page (`PosLastScannedLicensePlatesV2.vue`), with an "Aldrig vasket" /
"Never washed" fallback when the API returns `null`.

## Risk

- `getLastWashTimestampForPlate` does one extra indexed read per scan
row (`SELECT id FROM orders WHERE reg_1 = ? AND deleted_at IS NULL`).
The existing `isPlateSeenBefore` call already does the same, so the
route's per-row query count is unchanged in shape.
- The new field is additive and ignored by older clients, so this can
roll forward without a coordinated client release.

Co-authored-by: openclaw bugfix <openclaw@copenhagentruckwash.local>
This commit is contained in:
Jeppe B
2026-08-17 14:35:47 +00:00
committed by GitHub
co-authored by openclaw bugfix
parent 52d43fc16c
commit 64c5cd45db
3 changed files with 113 additions and 0 deletions
+62
View File
@@ -906,6 +906,68 @@ class orders_o extends db
return (bool)$count;
}
/**
* Get the timestamp of the most recent completed wash for a license plate.
* Used by the front page to show the "last washed" hint when a plate is
* scanned (DHL trailer pick-up use case, TRU-78 / DRIFT 17).
*
* Only orders that have at least one non-deleted order item are
* considered (mirrors the contract used by customer_vehicles_o::
* getLastOrderByPlate() so the timestamp is always backed by a real wash).
*
* @param string $reg_1 The license plate to look up
* @return string|null MySQL datetime string of the most recent qualifying
* order's `created_at`, or null when the plate has
* never been washed.
*/
public function getLastWashTimestampForPlate(string $reg_1): ?string
{
$normalized_reg_1 = trim($reg_1);
if ($normalized_reg_1 === '') {
return null;
}
$orders = self::getFieldsWhere([
'reg_1' => $normalized_reg_1,
'deleted_at' => null,
], [
'id',
]);
// Walk the orders newest-first and return the first one that actually
// has at least one non-deleted order item.
$candidate_ids = array_reverse(array_map(static function ($row) {
return (int)($row['id'] ?? 0);
}, $orders));
foreach ($candidate_ids as $order_id) {
if ($order_id <= 0) {
continue;
}
$has_items = (new order_items_o())->getFieldsWhere([
'order_id' => $order_id,
'deleted_at' => null,
], ['id']);
if (count($has_items) === 0) {
continue;
}
$details = self::getFieldsWhere([
'id' => $order_id,
'deleted_at' => null,
], ['created_at']);
$created_at = $details[0]['created_at'] ?? null;
if (is_string($created_at) && $created_at !== '') {
return $created_at;
}
}
return null;
}
public function getFixedPricingTransactions(int $customer_number, false $asArray, string|null $dateFrom = null, string|null $dateTo = null): array
{
// Get the fixed pricing transactions for a customer
@@ -120,11 +120,21 @@ class plateScansRoute
'type' => (int)$tmp_scan_vehicle['type'],
];
}
// TRU-78 / DRIFT 17: enrich each scan with the
// timestamp of the most recent completed wash for
// that plate so the POS landing page can show
// "last washed" at a glance when DHL trailers are
// being picked up.
$plate_value = (string)$scan['plate'];
$tmp_scan_last_wash = [
'last_wash' => (new orders_o())->getLastWashTimestampForPlate($plate_value),
];
// Return the object as an array
return [
...$scan,
...$tmp_scan_customer,
...$tmp_scan_seen_before,
...$tmp_scan_last_wash,
];
},
$number_plate_scans->forceRestrictFilters(
@@ -0,0 +1,41 @@
<?php
it('orders_o exposes getLastWashTimestampForPlate that filters out orders without order items', function (): void {
$ordersFile = app_path('objects/orders_o.php');
expect(is_file($ordersFile))->toBeTrue();
$ordersCode = preg_replace('/\s+/', ' ', (string)file_get_contents($ordersFile));
expect($ordersCode)->toContain('public function getLastWashTimestampForPlate(string $reg_1): ?string');
// The helper must look at non-deleted orders with non-deleted order items,
// matching the contract used by customer_vehicles_o::getLastOrderByPlate().
expect($ordersCode)->toContain("'reg_1' => $normalized_reg_1");
expect($ordersCode)->toContain("'deleted_at' => null");
expect($ordersCode)->toContain("'order_id' => $order_id");
expect($ordersCode)->toContain('return $created_at;');
expect($ordersCode)->toContain('return null;');
});
it('plateScansRoute enriches GET /numberplatescans with last_wash per scan (TRU-78)', function (): void {
$routeFile = app_path('routes/plateScansRoute.php');
$ordersFile = app_path('objects/orders_o.php');
expect(is_file($routeFile))->toBeTrue();
expect(is_file($ordersFile))->toBeTrue();
$routeCode = preg_replace('/\s+/', ' ', (string)file_get_contents($routeFile));
$ordersCode = preg_replace('/\s+/', ' ', (string)file_get_contents($ordersFile));
// The route already loads orders_o and customer_vehicles_o; verify the
// new enrichment is wired in the GET /numberplatescans handler.
expect($routeCode)->toContain("\$this->get('/numberplatescans', function () {");
expect($routeCode)->toContain("'last_wash'");
expect($routeCode)->toContain('getLastWashTimestampForPlate');
expect($routeCode)->toContain("'last_wash' => (new orders_o())->getLastWashTimestampForPlate");
expect($routeCode)->toContain('$tmp_scan_last_wash');
// The helper definition must live in orders_o so the enrichment is real,
// not a stub.
expect($ordersCode)->toContain('public function getLastWashTimestampForPlate(string $reg_1): ?string');
});