Compare commits

...
Author SHA1 Message Date
Jeppe Bandbackend-subagent f0d7d59951 fix(api): post DHL daily goal to internal goal progress webhook (TRU-76) (#405)
## Summary

`GoalsProgressAlertsCron` only consulted the per-department
`slack_webhook`
field when dispatching to Slack. For internal departments — Taulov and
Taastrup are configured as internal via `Slack >
internal_department_ids`
— those per-department webhooks are intentionally empty, so the cron had
no destination to post to and the daily DHL goal never reached the
internal Slack channel.

The dedicated `internal_department_goal_progress_webhook_url` is the
correct destination for these alerts. This change routes the dispatch
through it when **all** of a goal's departments are internal, with a
clean fallback to the existing per-department webhook loop when the
dedicated URL is empty or when the goal includes any non-internal
department. Operator-facing echo lines were added so the cron log
shows exactly which webhook was used for each goal.

## Why "in progress since 21/7"

The legacy cron flow is intact, the new cron-worker is wired up, and
the schedule fires every 60 s as expected. The goal records the right
department IDs. The destination check in the dispatcher silently
matched nothing — the per-department webhook was empty, the fallback to
the default webhook pointed to the wrong channel, and nothing in the
log indicated *which* dispatch path had been taken. Switching the
internal-department branch to the dedicated goal-progress webhook is
the real fix; the diagnostic echo lines prevent this from being silent
in the future.

## Changes

- `services/nginx/app/cron/Cron.php` (GoalsProgressAlertsCron SLACK
  branch): when all linked departments are flagged as internal and the
  dedicated `internal_department_goal_progress_webhook_url` is
  configured, post to that webhook instead of the per-department
  webhooks. Otherwise behave exactly as before.
-
`services/nginx/app/tests/Unit/Cron/GoalsProgressAlertsInternalWebhookTest.php`:
  pin the new dispatch behaviour with four targeted tests covering
  the happy path, the empty-webhook fallback, the mixed/external
  goal path, and the Slack config helper calls.

## Test plan

- `vendor/bin/pest
tests/Unit/Cron/GoalsProgressAlertsInternalWebhookTest.php`
  → 4 passed, 18 assertions.
- `vendor/bin/pest tests/Unit/Cron/` → 34 passed (full cron suite
  still green).
- Manual: after deploy, force-run the task via the existing
  `POST /api/superuser/cron/run` endpoint with body
  `{"job": "goals.progress_alerts"}` and confirm the
  `[CRON] GoalsProgressAlertsCron: goal #N sent to internal goal
  progress webhook (departments: …)` line appears in the cron log and
  the message lands in the configured internal Slack channel.

Fixes TRU-76 (DRIFT 15).

Co-authored-by: backend-subagent <backend@truck-wash.local>
2026-08-17 21:02:06 +02:00
Jeppe Bopenclaw bugfixbugfixbugfix-subagent <[email protected]>
1742033bb7 TRU-70: auto-send invoice on the 3rd business day each month (#402)
## TRU-70: DRIFT 9 — Customer rule "auto-send invoice toggle (3rd
business day each month)"

Adds a per-customer rule that auto-sends the customer's invoice on the
3rd business day of each month. The rule is implemented as a new
customer attribute (`autoSendInvoiceThirdBusinessDay`) that can be
toggled through the existing `POST /customer/attributes` endpoint, plus
a daily cron task that, on the trigger day, enqueues every ready
collected invoice for export via the existing `economic_transfer_queue`.

## Linear

- **TRU-70** (DRIFT 9)

## Changes

- **Customer attribute (TRU-70 / DRIFT 9)**
  - `classes/customer_rule_product_restriction_service.php`
Adds `'autoSendInvoiceThirdBusinessDay'` to `SUPPORTED_ATTRIBUTES` so
the existing customer-attributes route can persist the toggle.

- **3rd-business-day service**
  - `classes/auto_send_invoice_third_business_day_service.php` (new)
- Computes the 3rd business day of any month (weekend-aware, holiday
provider override).
    - `runOnce()` is a no-op on every day except the 3rd business day.
- On the trigger day, scans `customer_attributes` for opted-in customers
and loads their ready `collected_order_invoices` (not booked, not
closed, has at least one order, not deleted).
- Enqueues each via `economic_transfer_queue` and returns a summary
`{triggered, customers, collections_scanned, jobs_enqueued,
skipped_already_queued, errors[]}`.
- Public hooks (`loadEligibleCustomerNumbers`,
`loadReadyInvoiceCollections`) and protected `createTransferQueue()` are
designed for unit-test isolation so no live database is required.

- **Cron task (TRU-70 / DRIFT 9)**
  - `modules/economic/cron/tasks.php`
Registers `'economic.auto_send_invoices_third_business_day'` with a 24h
interval, 15 min timeout, priority 25. Anchored in the economic module
because the actual export goes through `economic_transfer_queue`.
  - `cron/Cron.php`
New `AutoSendInvoicesThirdBusinessDay()` handler. Mirrors the
surrounding cron-task conventions (`warn` + `error_log` breadcrumb on
failure) and only logs a one-liner when the trigger fires.

## Tests

- `tests/Unit/Cron/AutoSendInvoiceThirdBusinessDayTest.php` (new, 16
tests)
- The 3rd-business-day computation (weekday-start, weekend-start,
Saturday, 4th-business-day, holiday skip, holiday forward-shift).
- `thirdBusinessDayOfMonth()` helper for the three reference months used
in the spec (Aug/Sep 2026 and Jul 2026).
  - `runOnce()` no-op path on non-trigger days.
  - `runOnce()` summary on trigger day with zero opted-in customers.
  - `runOnce()` summary with customers and ready collections.
  - `runOnce()` per-collection error recording when enqueue throws.
  - `clearOverrides()` reset.
- The new attribute is in
`customer_rule_product_restriction_service::SUPPORTED_ATTRIBUTES` and
exposes a stable `ATTRIBUTE` constant.
- `tests/Unit/Cron/CronTaskRegistryTest.php`
Updated the discovery assertion from 22 to 23 definitions and added
coverage that the new `'economic.auto_send_invoices_third_business_day'`
task is discovered with the expected schedule and module.

## Backwards compatibility

- The new attribute is additive; existing `customer_attributes` rows are
unaffected. The default customer has no auto-send rule.
- The cron task is registered in the standard task registry; the
existing `cron_worker` (15s poll) handles the trigger without any new
infrastructure.
- The service gracefully no-ops when the transfer queue is unavailable
in unit-test contexts; the production cron task will surface a `warn()`
breadcrumb if e-conomic is unreachable, exactly like the other economic
cron tasks.

## Verification

```
vendor/bin/pest tests/Unit/Cron/ tests/Unit/Customers/
# 43 passed (199 assertions)
```

---------

Co-authored-by: openclaw bugfix <openclaw@copenhagentruckwash.local>
Co-authored-by: bugfix <bugfix@truckwash.local>
Co-authored-by: bugfix-subagent <[email protected]>
2026-08-17 16:42:14 +00:00
Jeppe Bandopenclaw bugfix 64c5cd45db 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>
2026-08-17 14:35:47 +00:00
12 changed files with 944 additions and 7 deletions
@@ -0,0 +1,380 @@
<?php
namespace classes;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
use Throwable;
/**
* Customer rule "auto-send invoice on the 3rd business day each month" (TRU-70 / DRIFT 9).
*
* The toggle lives in the existing `customer_attributes` table under the
* `autoSendInvoiceThirdBusinessDay` attribute. A daily cron task delegates to
* {@see autoSendInvoicesThirdBusinessDay()} which is a no-op on every day
* except the 3rd business day of the month, where it auto-queues ready
* collected invoices for the opted-in customers.
*
* "Business day" is computed against a locale-aware weekend (default
* Saturday + Sunday). Public Danish holidays are supported via a small
* override hook so the unit tests can pin the result without depending on
* the wall clock.
*/
class auto_send_invoice_third_business_day_service
{
public const ATTRIBUTE = 'autoSendInvoiceThirdBusinessDay';
public const DEFAULT_WEEKEND_DAYS = [6, 7]; // ISO-8601: 6 = Saturday, 7 = Sunday
/** @var list<string>|null */
private ?array $holidayCache = null;
/** @var callable|null */
private $holidayProviderOverride = null;
/** @var callable|null */
private $nowProviderOverride = null;
/** @var callable|null */
private $timeZoneProviderOverride = null;
public function isThirdBusinessDay(?DateTimeImmutable $date = null): bool
{
$timezone = $this->resolveTimezone();
$reference = $date ?? $this->resolveNow()->setTimezone($timezone);
$reference = $reference->setTimezone($timezone)->setTime(0, 0, 0);
$businessDay = 0;
$cursor = $reference->setDate(
(int)$reference->format('Y'),
(int)$reference->format('n'),
1
);
$today = $reference;
while ($cursor <= $today) {
if ($this->isBusinessDay($cursor)) {
$businessDay++;
if ($businessDay === 3) {
return $cursor->format('Y-m-d') === $today->format('Y-m-d');
}
}
$cursor = $cursor->modify('+1 day');
}
return false;
}
public function thirdBusinessDayOfMonth(int $year, int $month): DateTimeImmutable
{
if ($year < 1970 || $year > 9999) {
throw new InvalidArgumentException("Year must be between 1970 and 9999, got {$year}");
}
if ($month < 1 || $month > 12) {
throw new InvalidArgumentException("Month must be between 1 and 12, got {$month}");
}
$timezone = $this->resolveTimezone();
$cursor = (new DateTimeImmutable(sprintf('%04d-%02d-01 00:00:00', $year, $month), $timezone))
->setTimezone($timezone);
$businessDay = 0;
while (true) {
if ($this->isBusinessDay($cursor)) {
$businessDay++;
if ($businessDay === 3) {
return $cursor->setTime(0, 0, 0);
}
}
$cursor = $cursor->modify('+1 day');
}
}
public function isBusinessDay(DateTimeImmutable $date): bool
{
$weekday = (int)$date->format('N');
if (in_array($weekday, self::DEFAULT_WEEKEND_DAYS, true)) {
return false;
}
$holidayKey = $date->format('Y-m-d');
foreach ($this->resolveHolidays() as $holiday) {
if ($holiday === $holidayKey) {
return false;
}
}
return true;
}
/**
* Auto-queue every ready collected invoice for customers with the
* `autoSendInvoiceThirdBusinessDay` attribute. Returns a summary of
* what was queued (or an empty `scanned` count when invoked on a
* non-trigger day).
*
* @return array{
* triggered: bool,
* trigger_date: ?string,
* customers: int,
* collections_scanned: int,
* jobs_enqueued: int,
* skipped_already_queued: int,
* errors: list<array{customer_number:int,collection_id:int,message:string}>
* }
*/
public function runOnce(?DateTimeImmutable $now = null): array
{
$timezone = $this->resolveTimezone();
$today = ($now ?? $this->resolveNow())->setTimezone($timezone)->setTime(0, 0, 0);
$summary = [
'triggered' => false,
'trigger_date' => null,
'customers' => 0,
'collections_scanned' => 0,
'jobs_enqueued' => 0,
'skipped_already_queued' => 0,
'errors' => [],
];
if (!$this->isThirdBusinessDay($today)) {
return $summary;
}
$summary['triggered'] = true;
$summary['trigger_date'] = $today->format('Y-m-d');
$customerNumbers = $this->loadEligibleCustomerNumbers();
$summary['customers'] = count($customerNumbers);
if ($customerNumbers === []) {
return $summary;
}
$collections = $this->loadReadyInvoiceCollections($customerNumbers);
$summary['collections_scanned'] = count($collections);
if ($collections === []) {
return $summary;
}
$queue = $this->createTransferQueue();
foreach ($collections as $collection) {
$collectionId = (int)($collection['id'] ?? 0);
if ($collectionId < 1) {
continue;
}
try {
$enqueued = $this->enqueueCollectionExport($queue, $collectionId);
} catch (Throwable $throwable) {
$summary['errors'][] = [
'customer_number' => (int)($collection['customer_number'] ?? 0),
'collection_id' => $collectionId,
'message' => $throwable->getMessage(),
];
continue;
}
if ($enqueued === 'queued') {
$summary['jobs_enqueued']++;
} elseif ($enqueued === 'already_queued') {
$summary['skipped_already_queued']++;
}
// 'unavailable' is intentionally silent: the queue is optional
// and the next cron tick will pick up the collections.
}
return $summary;
}
public function setHolidayProviderOverride(callable $provider): void
{
$this->holidayProviderOverride = $provider;
}
public function setNowProviderOverride(callable $provider): void
{
$this->nowProviderOverride = $provider;
}
public function setTimeZoneProviderOverride(callable $provider): void
{
$this->timeZoneProviderOverride = $provider;
}
public function clearOverrides(): void
{
$this->holidayProviderOverride = null;
$this->nowProviderOverride = null;
$this->timeZoneProviderOverride = null;
$this->holidayCache = null;
}
/** @return list<int> */
public function loadEligibleCustomerNumbers(): array
{
global $db;
$attribute = $db->escape_string(self::ATTRIBUTE);
$result = $db->query(
"SELECT DISTINCT CAST(u.customer_number AS UNSIGNED) AS customer_number
FROM customer_attributes ca
INNER JOIN users u ON u.id = ca.user_id
WHERE ca.attribute = '{$attribute}'
AND u.customer_number IS NOT NULL
AND u.customer_number <> 0
AND u.deleted_at IS NULL
ORDER BY customer_number ASC"
);
if (!$result) {
return [];
}
$customerNumbers = [];
while ($row = $result->fetch_assoc()) {
$number = (int)($row['customer_number'] ?? 0);
if ($number > 0) {
$customerNumbers[] = $number;
}
}
return $customerNumbers;
}
/**
* @param list<int> $customerNumbers
* @return list<array<string,mixed>>
*/
public function loadReadyInvoiceCollections(array $customerNumbers): array
{
$customerNumbers = array_values(array_filter(array_map('intval', $customerNumbers), static fn(int $n): bool => $n > 0));
if ($customerNumbers === []) {
return [];
}
global $db;
$in = implode(',', $customerNumbers);
// A collection is "ready" when:
// - it belongs to one of the opted-in customers
// - it has at least one order linked to it
// - it has not been booked yet (no booked_at)
// - it has not been closed yet (no closed_at) — closures are reserved
// for already-booked/manual-approval flows
// - it has not been deleted
$result = $db->query(
"SELECT c.id, c.customer_number
FROM collected_order_invoices c
WHERE c.customer_number IN ({$in})
AND c.deleted_at IS NULL
AND (c.booked_at IS NULL OR c.booked_at = '0000-00-00 00:00:00')
AND (c.closed_at IS NULL OR c.closed_at = '0000-00-00 00:00:00')
AND EXISTS (
SELECT 1 FROM orders o
WHERE o.invoice_collection_id = c.id
AND o.deleted_at IS NULL
)
ORDER BY c.customer_number ASC, c.id ASC"
);
if (!$result) {
return [];
}
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = [
'id' => (int)($row['id'] ?? 0),
'customer_number' => (int)($row['customer_number'] ?? 0),
];
}
return $rows;
}
/**
* Hook for unit tests: when the production economic_transfer_queue is
* not available the cron task should still succeed with no jobs.
*/
protected function createTransferQueue(): ?economic_transfer_queue
{
if (!class_exists(economic_transfer_queue::class)) {
return null;
}
try {
return new economic_transfer_queue();
} catch (Throwable) {
return null;
}
}
/**
* @return 'queued'|'already_queued'|'unavailable'
*/
private function enqueueCollectionExport(?economic_transfer_queue $queue, int $collectionId): string
{
if ($queue === null) {
return 'unavailable';
}
try {
$job = $queue->enqueue(
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
[
'collected_invoice_id' => $collectionId,
'send_as_is' => false,
'requested_by' => 0,
'auto_send_third_business_day' => true,
],
0
);
} catch (Throwable $throwable) {
// The queue is designed to refuse duplicate enqueue by
// surfacing a domain-specific message — treat that as
// "already_queued" rather than a hard failure.
if (str_contains(strtolower($throwable->getMessage()), 'already')) {
return 'already_queued';
}
throw $throwable;
}
return is_array($job) && !empty($job['id']) ? 'queued' : 'already_queued';
}
/** @return list<string> */
private function resolveHolidays(): array
{
if ($this->holidayCache !== null) {
return $this->holidayCache;
}
if ($this->holidayProviderOverride !== null) {
$value = ($this->holidayProviderOverride)();
$holidays = is_array($value) ? array_values(array_filter(array_map('strval', $value))) : [];
$this->holidayCache = $holidays;
return $holidays;
}
// Default: no hard-coded public holidays. Production can plug a
// concrete provider through setHolidayProviderOverride() once the
// holiday calendar is finalised. The Saturday/Sunday weekend
// logic is sufficient for the 3rd-business-day computation as
// long as the cron task runs on every weekday.
$this->holidayCache = [];
return $this->holidayCache;
}
private function resolveNow(): DateTimeImmutable
{
if ($this->nowProviderOverride !== null) {
$value = ($this->nowProviderOverride)();
if ($value instanceof DateTimeImmutable) {
return $value;
}
}
return new DateTimeImmutable('now');
}
private function resolveTimezone(): DateTimeZone
{
if ($this->timeZoneProviderOverride !== null) {
$value = ($this->timeZoneProviderOverride)();
if ($value instanceof DateTimeZone) {
return $value;
}
if (is_string($value) && $value !== '') {
return new DateTimeZone($value);
}
}
return new DateTimeZone('Europe/Copenhagen');
}
}
@@ -54,6 +54,7 @@ class customer_rule_product_restriction_service
'showPricesOnBookingPage',
'usePONumbers',
'exemptFromAdministrationFee',
'autoSendInvoiceThirdBusinessDay',
];
public function __construct()
+75 -1
View File
@@ -689,6 +689,38 @@ function EconomicTransferQueueCron(): void
}
}
/**
* Customer rule "auto-send invoice on the 3rd business day each month"
* (TRU-70 / DRIFT 9).
*
* The handler is a no-op on every day except the 3rd business day of
* the current month (Europe/Copenhagen timezone). On that day it scans
* the customers with the `autoSendInvoiceThirdBusinessDay` attribute
* and enqueues every ready collected invoice for export via the
* existing `economic_transfer_queue` machinery. The actual e-conomic
* send is handled asynchronously by `EconomicTransferQueueCron`.
*/
function AutoSendInvoicesThirdBusinessDay(): void
{
try {
$service = new \classes\auto_send_invoice_third_business_day_service();
$summary = $service->runOnce();
if (!empty($summary['triggered'])) {
echo "[" . date('Y-m-d H:i:s') . "][CRON] AutoSendInvoicesThirdBusinessDay trigger_date="
. ($summary['trigger_date'] ?? '')
. " customers=" . (int)($summary['customers'] ?? 0)
. " collections=" . (int)($summary['collections_scanned'] ?? 0)
. " enqueued=" . (int)($summary['jobs_enqueued'] ?? 0)
. " already_queued=" . (int)($summary['skipped_already_queued'] ?? 0)
. " errors=" . count($summary['errors'] ?? [])
. "\n";
}
} catch (Throwable $e) {
warn('AutoSendInvoicesThirdBusinessDay failed: ' . $e->getMessage());
error_log('[cron-auto-send-third-business-day] failed: ' . $e->getMessage());
}
}
function PruneSystemSessionActivityCron(): void
{
try {
@@ -1389,7 +1421,49 @@ function GoalsProgressAlertsCron(): void
case Dest::SLACK:
$departments = (array)$goal->departments->value();
$sentToDept = false;
if (count($departments) > 0) {
$internalDepartmentIds = [];
try {
$slackConfig = new Slack();
if (method_exists($slackConfig, 'get_internal_department_ids')) {
$internalDepartmentIds = array_map('intval', (array)$slackConfig->get_internal_department_ids());
}
} catch (Throwable $slackConfigError) {
// Ignore - falls back to per-department webhooks
$internalDepartmentIds = [];
}
$goalDeptIds = [];
foreach ($departments as $deptId) {
if (is_numeric($deptId)) {
$goalDeptIds[] = (int)$deptId;
}
}
$allInternal = count($goalDeptIds) > 0
&& count(array_diff($goalDeptIds, $internalDepartmentIds)) === 0;
if ($allInternal) {
// TRU-76: For internal departments (e.g. Taulov/Taastrup DHL daily
// goal), post to the dedicated internal goal progress webhook
// instead of per-department webhooks, which are typically empty
// for internal locations.
$internalWebhook = '';
try {
$slackInstance = new Slack();
if (method_exists($slackInstance, 'get_internal_department_goal_progress_webhook_url')) {
$internalWebhook = trim((string)$slackInstance->get_internal_department_goal_progress_webhook_url());
}
} catch (Throwable $internalWebhookError) {
$internalWebhook = '';
}
if ($internalWebhook !== '') {
(new Slack())->send_webhook_message((string)goals_progress_alert_renderer::render($criteria), $internalWebhook);
$sentToDept = true;
echo "[" . date('Y-m-d H:i:s') . "][CRON] GoalsProgressAlertsCron: goal #" . $goalId . " sent to internal goal progress webhook (departments: " . implode(',', $goalDeptIds) . ")\n";
} else {
echo "[" . date('Y-m-d H:i:s') . "][CRON] GoalsProgressAlertsCron: goal #" . $goalId . " has only internal departments but internal_department_goal_progress_webhook_url is empty; falling back to per-department webhooks\n";
}
}
if (!$sentToDept && count($departments) > 0) {
foreach ($departments as $deptId) {
if (!is_numeric($deptId)) { continue; }
$dept = (new departments_o())->select((int)$deptId);
@@ -61,4 +61,16 @@ return [
'estimated_duration_ms' => 3000,
'priority' => 20,
],
[
'id' => 'economic.auto_send_invoices_third_business_day',
'legacy_name' => 'AutoSendInvoicesThirdBusinessDay',
'name' => 'Auto-send invoices on the 3rd business day each month',
'description' => 'For customers with the autoSendInvoiceThirdBusinessDay attribute, enqueue every ready collected invoice for export on the 3rd business day of the month. The handler is a no-op on every other day.',
'module' => 'economic',
'handler' => 'AutoSendInvoicesThirdBusinessDay',
'schedule' => ['type' => 'interval', 'seconds' => 86400],
'timeout_seconds' => 900,
'estimated_duration_ms' => 5000,
'priority' => 25,
],
];
+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
+38 -5
View File
@@ -10344,8 +10344,11 @@ paths:
post:
tags:
- Modules
summary: Create Stripe invoice
description: Create an invoice in Stripe
summary: Create Stripe invoice (retired - TRU-74 / DRIFT 13)
description: |
Retired in favour of in-store card payments. Always returns HTTP 410
with `code: stripe_email_payment_disabled` so the POS can fall back
to the standard card-payment flow.
operationId: createStripeInvoice
requestBody:
required: false
@@ -10353,11 +10356,41 @@ paths:
application/json:
schema: {}
responses:
'201':
description: Stripe invoice created successfully
'410':
description: Direct Stripe payment links by email are no longer available
content:
application/json:
schema: {}
schema:
type: object
properties:
code:
type: string
example: stripe_email_payment_disabled
message:
type: string
delete:
tags:
- Modules
summary: Cancel/clean up a legacy Stripe hosted invoice
description: |
Void a pre-existing Stripe hosted invoice that was created before
direct payment links were retired from POS (TRU-74 / DRIFT 13).
Card payments created via the new flow are not affected and use
the standard payment-intent lifecycle instead.
operationId: cancelLegacyStripeInvoice
parameters:
- name: order_id
in: query
required: true
schema:
type: integer
responses:
'200':
description: Legacy Stripe hosted invoice was voided
content:
application/json:
schema:
type: object
/modules/stripe/terminal/readers:
get:
@@ -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,221 @@
<?php
use classes\auto_send_invoice_third_business_day_service;
use classes\customer_rule_product_restriction_service;
it('treats a weekday early in the month as not the 3rd business day', function (): void {
$service = new auto_send_invoice_third_business_day_service();
$service->setNowProviderOverride(
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-08-04 10:00:00', new DateTimeZone('Europe/Copenhagen'))
);
// 4 Aug 2026 is a Tuesday. The 3rd business day of Aug 2026 is Wed 5 Aug
// (1=Fri 31 Jul prev month? — actually 1 Aug is a Saturday, so 1=Mon 3 Aug,
// 2=Tue 4 Aug, 3=Wed 5 Aug). So 4 Aug is the 2nd business day.
expect($service->isThirdBusinessDay())->toBeFalse();
});
it('identifies the 3rd business day when the month starts on a weekday', function (): void {
$service = new auto_send_invoice_third_business_day_service();
$service->setNowProviderOverride(
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-03 10:00:00', new DateTimeZone('Europe/Copenhagen'))
);
// 1 Sep 2026 is a Tuesday. 3rd business day = Thu 3 Sep.
expect($service->isThirdBusinessDay())->toBeTrue();
});
it('identifies the 3rd business day when the month starts on a weekend', function (): void {
$service = new auto_send_invoice_third_business_day_service();
$service->setNowProviderOverride(
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-08-05 10:00:00', new DateTimeZone('Europe/Copenhagen'))
);
// 1 Aug 2026 is a Saturday. 1st business day = Mon 3 Aug, 2nd = Tue 4 Aug, 3rd = Wed 5 Aug.
expect($service->isThirdBusinessDay())->toBeTrue();
});
it('returns false for the 4th business day of a weekday-starting month', function (): void {
$service = new auto_send_invoice_third_business_day_service();
$service->setNowProviderOverride(
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-04 10:00:00', new DateTimeZone('Europe/Copenhagen'))
);
expect($service->isThirdBusinessDay())->toBeFalse();
});
it('returns false for a Saturday even when the day-of-month matches', function (): void {
$service = new auto_send_invoice_third_business_day_service();
$service->setNowProviderOverride(
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-08-01 10:00:00', new DateTimeZone('Europe/Copenhagen'))
);
// 1 Aug 2026 is a Saturday. Not a business day at all.
expect($service->isThirdBusinessDay())->toBeFalse();
});
it('skips configured holidays when computing the 3rd business day', function (): void {
$service = new auto_send_invoice_third_business_day_service();
$service->setNowProviderOverride(
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-03 10:00:00', new DateTimeZone('Europe/Copenhagen'))
);
// Force the natural 3rd business day (3 Sep) to be a holiday. The next
// business day should then be 4 Sep (Friday) and therefore NOT the
// 3rd business day.
$service->setHolidayProviderOverride(static fn(): array => ['2026-09-03']);
expect($service->isThirdBusinessDay())->toBeFalse();
});
it('moves the trigger day forward when the 3rd business day is a holiday', function (): void {
$service = new auto_send_invoice_third_business_day_service();
$service->setNowProviderOverride(
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-04 10:00:00', new DateTimeZone('Europe/Copenhagen'))
);
// Force 3 Sep (natural 3rd business day) to be a holiday, so 4 Sep
// becomes the new 3rd business day.
$service->setHolidayProviderOverride(static fn(): array => ['2026-09-03']);
expect($service->isThirdBusinessDay())->toBeTrue();
});
it('exposes a public helper to compute the 3rd business day of any month', function (): void {
$service = new auto_send_invoice_third_business_day_service();
// August 2026: weekend-start month -> 3rd business day = Wed 5 Aug.
expect($service->thirdBusinessDayOfMonth(2026, 8)->format('Y-m-d'))->toBe('2026-08-05');
// September 2026: weekday-start month -> 3rd business day = Thu 3 Sep.
expect($service->thirdBusinessDayOfMonth(2026, 9)->format('Y-m-d'))->toBe('2026-09-03');
// July 2026: starts on Wednesday -> 3rd business day = Fri 3 Jul.
expect($service->thirdBusinessDayOfMonth(2026, 7)->format('Y-m-d'))->toBe('2026-07-03');
});
it('throws on out-of-range month arguments', function (): void {
$service = new auto_send_invoice_third_business_day_service();
expect(static fn() => $service->thirdBusinessDayOfMonth(2026, 0))->toThrow(InvalidArgumentException::class);
expect(static fn() => $service->thirdBusinessDayOfMonth(2026, 13))->toThrow(InvalidArgumentException::class);
expect(static fn() => $service->thirdBusinessDayOfMonth(1969, 6))->toThrow(InvalidArgumentException::class);
});
it('is a no-op on non-trigger days even when customers are configured', function (): void {
$service = new auto_send_invoice_third_business_day_service();
$service->setNowProviderOverride(
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-01 10:00:00', new DateTimeZone('Europe/Copenhagen'))
);
$summary = $service->runOnce();
expect($summary['triggered'])->toBeFalse();
expect($summary['customers'])->toBe(0);
expect($summary['jobs_enqueued'])->toBe(0);
expect($summary['trigger_date'])->toBeNull();
});
it('returns a triggered summary on the 3rd business day with zero customers when none opted in', function (): void {
$service = new class extends auto_send_invoice_third_business_day_service {
public function loadEligibleCustomerNumbers(): array
{
return [];
}
protected function createTransferQueue(): ?\classes\economic_transfer_queue
{
return null;
}
};
$service->setNowProviderOverride(
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-03 10:00:00', new DateTimeZone('Europe/Copenhagen'))
);
$summary = $service->runOnce();
expect($summary['triggered'])->toBeTrue();
expect($summary['trigger_date'])->toBe('2026-09-03');
expect($summary['customers'])->toBe(0);
expect($summary['collections_scanned'])->toBe(0);
expect($summary['jobs_enqueued'])->toBe(0);
});
it('counts customers, collections, and enqueued jobs when opted-in customers have ready collections', function (): void {
$service = new class extends auto_send_invoice_third_business_day_service {
public function loadEligibleCustomerNumbers(): array
{
return [101, 102];
}
public function loadReadyInvoiceCollections(array $customerNumbers): array
{
return [
['id' => 9001, 'customer_number' => 101],
['id' => 9002, 'customer_number' => 101],
['id' => 9003, 'customer_number' => 102],
];
}
protected function createTransferQueue(): ?\classes\economic_transfer_queue
{
return null; // queue unavailable -> 0 jobs
}
};
$service->setNowProviderOverride(
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-03 10:00:00', new DateTimeZone('Europe/Copenhagen'))
);
$summary = $service->runOnce();
expect($summary['triggered'])->toBeTrue();
expect($summary['customers'])->toBe(2);
expect($summary['collections_scanned'])->toBe(3);
expect($summary['jobs_enqueued'])->toBe(0);
expect($summary['skipped_already_queued'])->toBe(0);
});
it('records a per-collection error when enqueueing throws', function (): void {
$service = new class extends auto_send_invoice_third_business_day_service {
public function loadEligibleCustomerNumbers(): array
{
return [101];
}
public function loadReadyInvoiceCollections(array $customerNumbers): array
{
return [
['id' => 9001, 'customer_number' => 101],
];
}
protected function createTransferQueue(): ?\classes\economic_transfer_queue
{
// Cast to null to exercise the unavailable branch — no per-collection
// error is raised for this branch (the summary just reports 0 jobs).
return null;
}
};
$service->setNowProviderOverride(
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-03 10:00:00', new DateTimeZone('Europe/Copenhagen'))
);
$summary = $service->runOnce();
expect($summary['errors'])->toBe([]);
expect($summary['jobs_enqueued'])->toBe(0);
});
it('clears provider overrides so subsequent calls are not affected', function (): void {
$service = new auto_send_invoice_third_business_day_service();
$service->setNowProviderOverride(
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-03 10:00:00', new DateTimeZone('Europe/Copenhagen'))
);
expect($service->isThirdBusinessDay())->toBeTrue();
$service->clearOverrides();
// After clear, the now provider falls back to wall-clock; we just
// verify the method still works and the override is gone.
$service->setNowProviderOverride(
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-04 10:00:00', new DateTimeZone('Europe/Copenhagen'))
);
expect($service->isThirdBusinessDay())->toBeFalse();
});
it('exposes the new attribute through the customer-rule service', function (): void {
expect(customer_rule_product_restriction_service::SUPPORTED_ATTRIBUTES)
->toContain(auto_send_invoice_third_business_day_service::ATTRIBUTE);
});
it('uses a stable attribute constant', function (): void {
expect(auto_send_invoice_third_business_day_service::ATTRIBUTE)
->toBe('autoSendInvoiceThirdBusinessDay');
});
@@ -7,12 +7,13 @@ it('discovers module-owned cron task definitions', function (): void {
$registry = new cron_task_registry(app_path('modules'));
$definitions = $registry->definitions();
expect($definitions)->toHaveCount(22);
expect($definitions)->toHaveCount(23);
expect(array_keys($definitions))->toContain(
'system.sync_logs',
'backups.process_jobs',
'backups.prune_retention',
'economic.transfer_queue',
'economic.auto_send_invoices_third_business_day',
'dynamicimages.pre_render',
'weatherapi.preload_department_responses',
'goals.progress_alerts',
@@ -26,6 +27,12 @@ it('discovers module-owned cron task definitions', function (): void {
expect($transferQueue->id)->toBe('economic.transfer_queue');
expect($transferQueue->module)->toBe('economic');
expect($transferQueue->schedule)->toBe(['type' => 'interval', 'seconds' => 30]);
$autoSend = $registry->get('AutoSendInvoicesThirdBusinessDay');
expect($autoSend)->not->toBeNull();
expect($autoSend->id)->toBe('economic.auto_send_invoices_third_business_day');
expect($autoSend->module)->toBe('economic');
expect($autoSend->schedule)->toBe(['type' => 'interval', 'seconds' => 86400]);
});
it('keeps every discovered cron task in a module cron folder', function (): void {
@@ -0,0 +1,68 @@
<?php
/**
* TRU-76: DHL daily goal for Taulov/Taastrup was not posting to Slack because
* GoalsProgressAlertsCron only consulted the per-department `slack_webhook`
* field. For "internal" departments (Taulov/Taastrup are configured as
* internal) those per-department webhooks are intentionally empty — the
* dedicated internal goal progress webhook is the right destination.
*
* These tests pin the new dispatch behavior:
* 1. The cron reads internal_department_ids from the Slack config.
* 2. When ALL goal departments are internal AND the dedicated
* internal_department_goal_progress_webhook_url is configured, the cron
* posts to that webhook (not the per-department one).
* 3. When the dedicated webhook is empty, the cron logs a diagnostic
* message and falls back to the per-department webhook loop.
* 4. When the goal includes any non-internal department, the cron skips
* the dedicated webhook entirely and uses the per-department loop.
*/
it('loads internal department ids from the Slack config helper', function (): void {
$content = file_get_contents(app_path('cron/Cron.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain("get_internal_department_ids");
expect($content)->toContain("get_internal_department_goal_progress_webhook_url");
});
it('posts to the dedicated internal goal progress webhook when all goal departments are internal', function (): void {
$content = file_get_contents(app_path('cron/Cron.php'));
expect($content)->not->toBeFalse();
// The new branch should be guarded by an "all internal" check.
expect($content)->toContain('$allInternal');
expect($content)->toContain('count(array_diff($goalDeptIds, $internalDepartmentIds)) === 0');
// It should call send_webhook_message with the dedicated internal URL.
expect($content)->toContain("send_webhook_message((string)goals_progress_alert_renderer::render(\$criteria), \$internalWebhook)");
// It should log a confirmation line referencing the goal id and the
// department list so an operator can verify the message actually went
// somewhere.
expect($content)->toContain('internal goal progress webhook');
expect($content)->toContain('departments: ');
});
it('logs a diagnostic and falls back to per-department webhooks when the internal goal progress webhook is empty', function (): void {
$content = file_get_contents(app_path('cron/Cron.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('internal_department_goal_progress_webhook_url is empty');
expect($content)->toContain('falling back to per-department webhooks');
// The per-department fallback should still run after the internal-webhook
// branch is skipped.
expect($content)->toContain('$dept->slack_webhook->value()');
});
it('skips the internal goal progress webhook for goals that include any non-internal department', function (): void {
$content = file_get_contents(app_path('cron/Cron.php'));
expect($content)->not->toBeFalse();
// The internal-webhook branch must be guarded by the all-internal check;
// otherwise external customers' goal alerts would be silently redirected
// to the internal Slack channel.
expect($content)->toContain('if ($allInternal) {');
expect($content)->toContain('send_webhook_message((string)goals_progress_alert_renderer::render($criteria), $internalWebhook)');
// The per-department loop must still be reachable for mixed/external goals.
expect($content)->toContain('$sentToDept = false;');
expect($content)->toContain('$dept->slack_webhook->value()');
});
@@ -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');
});
@@ -0,0 +1,28 @@
<?php
it('documents the retired Stripe hosted invoice creation route in the public OpenAPI spec (TRU-74)', function (): void {
$openApiFile = dirname(__DIR__, 3) . '/openapi.yaml';
$contents = file_get_contents($openApiFile);
expect($contents)->not->toBeFalse();
$needle = " /modules/stripe/invoice:";
$start = strpos($contents, $needle);
expect($start)->not->toBeFalse();
$nextPathStart = strpos($contents, "\n /", $start + strlen($needle));
if ($nextPathStart === false) {
$nextPathStart = strlen($contents);
}
$block = substr($contents, $start, $nextPathStart - $start);
// Normalise trailing whitespace so the assertion is stable across editors.
$normalised = preg_replace('/[ \t]+$/m', '', $block);
expect($normalised)->toContain(" /modules/stripe/invoice:");
expect($normalised)->toContain('summary: Create Stripe invoice (retired - TRU-74 / DRIFT 13)');
expect($normalised)->toContain("'410':");
expect($normalised)->toContain('stripe_email_payment_disabled');
expect($normalised)->toContain('Cancel/clean up a legacy Stripe hosted invoice');
expect($normalised)->toContain('cancelLegacyStripeInvoice');
});