Add unit tests for various modules: attachments grouping, department weather caching behaviors, economic module order sanitization, enriched order batching, and user cashier name lookups. Update related route logic for enhanced data fetching and caching integrations.

This commit is contained in:
Jeppe Bundgaard
2026-03-24 14:59:16 +01:00
parent 4a5dd3a83f
commit 6fdc8d466e
19 changed files with 1798 additions and 84 deletions
+1
View File
@@ -10455,6 +10455,7 @@ components:
DepartmentWeatherStatus:
type: string
description: Productivity health for the slot. `unknown` is returned when the slot has not started yet or has no employee-hours.
enum: [unknown, healthy, degraded, unhealthy]
DepartmentWeatherCondition:
+59 -12
View File
@@ -27,18 +27,40 @@ class attachments implements attachments_i
*/
public function list(string $type, int $object_id, array $options = []): array
{
if (count($options) === 0) {
$options = ['id', 'content', 'created_at', 'object_id', 'object_type', 'created_at', 'updated_at']; // Default fields to return
$rows = $this->fetchAttachmentRows($type, [$object_id], $options);
return array_map(fn(array $row): attachment => $this->toAttachment($row), $rows);
}
/**
* List attachments for multiple objects in one query.
*
* @param string $type
* @param int[] $object_ids
* @param array $options
* @return array<int, attachment[]>
*/
public function listMany(string $type, array $object_ids, array $options = []): array
{
$object_ids = array_values(array_unique(array_filter(array_map('intval', $object_ids), static fn(int $id): bool => $id > 0)));
if (empty($object_ids)) {
return [];
}
return array_map(function ($item) {
$item = (object)$item;
$item->content = json_decode($item->content, true); // Decode JSON content
return (new attachment())->populate((object)$item);
}, (new object_attachments_o())->getFieldsWhere([
'object_type' => $type,
'object_id' => $object_id,
'deleted_at' => null
], $options));
$rows = $this->fetchAttachmentRows($type, $object_ids, $options);
$grouped = [];
foreach ($object_ids as $object_id) {
$grouped[$object_id] = [];
}
foreach ($rows as $row) {
$object_id = (int)($row['object_id'] ?? 0);
if (!isset($grouped[$object_id])) {
$grouped[$object_id] = [];
}
$grouped[$object_id][] = $this->toAttachment($row);
}
return $grouped;
}
/**
@@ -107,4 +129,29 @@ class attachments implements attachments_i
}
return false;
}
}
protected function fetchAttachmentRows(string $type, array $object_ids, array $options = []): array
{
$options = $this->normalizeAttachmentOptions($options);
return (new object_attachments_o())->getFieldsWhereIn([
'object_type' => $type,
'object_id' => $object_ids,
'deleted_at' => null
], $options);
}
protected function toAttachment(array $item): attachment
{
$payload = (object)$item;
$payload->content = json_decode((string)$payload->content, true);
return (new attachment())->populate($payload);
}
protected function normalizeAttachmentOptions(array $options): array
{
if (count($options) === 0) {
return ['id', 'content', 'created_at', 'object_id', 'object_type', 'created_at', 'updated_at'];
}
return $options;
}
}
+166
View File
@@ -23,6 +23,7 @@ use objects\departments_o;
use objects\bookings_o;
use objects\logs_o;
use objects\users_o;
use routes\moduleWeatherAPIRoute;
if (!defined('WD')) {
exit;
@@ -105,6 +106,12 @@ $cron_tasks = [
'next_run' => 0,
'function' => 'PreRenderDynamicImagesCron',
],
'PreloadDepartmentWeatherResponsesCron' => [
'interval' => 60, // 1 minute
'last_run' => 0,
'next_run' => 0,
'function' => 'PreloadDepartmentWeatherResponsesCron',
],
'GoalsProgressAlertsCron' => [
'interval' => 60, // check every minute
'last_run' => 0,
@@ -626,6 +633,165 @@ function mergeUniqueIntValues(array $base, array $append): array
return array_values($result);
}
function PreloadDepartmentWeatherResponsesCron(): void
{
$start = microtime(true);
if (!defined('redis')) {
warn('PreloadDepartmentWeatherResponsesCron skipped: Redis is unavailable.');
return;
}
$maxDepartmentsRaw = getenv('DEPARTMENTS_WEATHER_PRELOAD_MAX_DEPARTMENTS');
$maxDepartmentsPerRun = max(
1,
(int)(
$maxDepartmentsRaw !== false && trim((string)$maxDepartmentsRaw) !== ''
? $maxDepartmentsRaw
: 25
)
);
$hotLimitRaw = getenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_LIMIT');
$hotLimit = max(
1,
(int)(
$hotLimitRaw !== false && trim((string)$hotLimitRaw) !== ''
? $hotLimitRaw
: 50
)
);
$hotTtlRaw = getenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL');
$hotTtl = max(
1,
(int)(
$hotTtlRaw !== false && trim((string)$hotTtlRaw) !== ''
? $hotTtlRaw
: 900
)
);
try {
$departmentRows = (new departments_o())->list(true);
} catch (Throwable $e) {
warn('PreloadDepartmentWeatherResponsesCron failed to list departments: ' . $e->getMessage());
return;
}
if (!is_array($departmentRows) || $departmentRows === []) {
echo "[" . date('Y-m-d H:i:s') . "][CRON] PreloadDepartmentWeatherResponsesCron: no departments found.\n";
return;
}
usort($departmentRows, static function (array $left, array $right): int {
$leftPriority = isset($left['order_priority']) ? (int)$left['order_priority'] : PHP_INT_MAX;
$rightPriority = isset($right['order_priority']) ? (int)$right['order_priority'] : PHP_INT_MAX;
if ($leftPriority !== $rightPriority) {
return $leftPriority <=> $rightPriority;
}
return ((int)($left['id'] ?? 0)) <=> ((int)($right['id'] ?? 0));
});
$departmentIds = [];
foreach ($departmentRows as $row) {
$departmentId = (int)($row['id'] ?? 0);
if ($departmentId < 1) {
continue;
}
if (isset($row['visible']) && (int)$row['visible'] === 0) {
continue;
}
$departmentIds[$departmentId] = true;
if (count($departmentIds) >= $maxDepartmentsPerRun) {
break;
}
}
// Fallback: if visibility is unavailable or all are hidden, include all departments up to cap.
if ($departmentIds === []) {
foreach ($departmentRows as $row) {
$departmentId = (int)($row['id'] ?? 0);
if ($departmentId < 1) {
continue;
}
$departmentIds[$departmentId] = true;
if (count($departmentIds) >= $maxDepartmentsPerRun) {
break;
}
}
}
$departmentIds = array_map('intval', array_keys($departmentIds));
sort($departmentIds, SORT_NUMERIC);
if ($departmentIds === []) {
echo "[" . date('Y-m-d H:i:s') . "][CRON] PreloadDepartmentWeatherResponsesCron: no preloadable departments.\n";
return;
}
$warmedSets = 0;
$failedSets = 0;
$totalEntries = 0;
$hotTargetsRequested = 0;
$hotTargetsWarmed = 0;
$hotTargets = moduleWeatherAPIRoute::getDepartmentWeatherHotPreloadTargets($hotLimit, $hotTtl);
$hotTargetsRequested = count($hotTargets);
foreach ($hotTargets as $target) {
try {
$result = moduleWeatherAPIRoute::preloadDepartmentWeatherTimelineCache(
$target['department_ids'] ?? [],
null,
null,
$target['timeline_range'] ?? null
);
if (($result['warmed'] ?? false) === true) {
$warmedSets++;
$hotTargetsWarmed++;
$totalEntries += (int)($result['entries'] ?? 0);
}
} catch (Throwable $e) {
$failedSets++;
warn('PreloadDepartmentWeatherResponsesCron failed for hot target: ' . $e->getMessage());
}
}
foreach ($departmentIds as $departmentId) {
try {
$result = moduleWeatherAPIRoute::preloadDepartmentWeatherTimelineCache([$departmentId]);
if (($result['warmed'] ?? false) === true) {
$warmedSets++;
$totalEntries += (int)($result['entries'] ?? 0);
}
} catch (Throwable $e) {
$failedSets++;
warn('PreloadDepartmentWeatherResponsesCron failed for department #' . $departmentId . ': ' . $e->getMessage());
}
}
if (count($departmentIds) > 1) {
try {
$result = moduleWeatherAPIRoute::preloadDepartmentWeatherTimelineCache($departmentIds);
if (($result['warmed'] ?? false) === true) {
$warmedSets++;
$totalEntries += (int)($result['entries'] ?? 0);
}
} catch (Throwable $e) {
$failedSets++;
warn('PreloadDepartmentWeatherResponsesCron failed for aggregate department set: ' . $e->getMessage());
}
}
$duration = round(microtime(true) - $start, 2);
echo "[" . date('Y-m-d H:i:s') . "][CRON] PreloadDepartmentWeatherResponsesCron completed. "
. "departments=" . count($departmentIds)
. " warmed_sets=$warmedSets failed_sets=$failedSets total_entries=$totalEntries "
. "hot_targets_requested=$hotTargetsRequested hot_targets_warmed=$hotTargetsWarmed "
. "max_departments_per_run=$maxDepartmentsPerRun hot_limit=$hotLimit hot_ttl=$hotTtl "
. "duration={$duration}s\n";
}
/**
* GoalsProgressAlertsCron
*
@@ -16,6 +16,14 @@ interface attachments_i
* @return attachment[] An array of attachment objects.
*/
public function list(string $type, int $object_id, array $options = []): array;
/**
* List attachments for multiple entities of the same type.
* @param string $type The parent entity type (e.g. orders, users, tasks).
* @param int[] $object_ids The parent entity IDs.
* @param array $options Optional projection columns.
* @return array<int, attachment[]> Map of object id => attachment list.
*/
public function listMany(string $type, array $object_ids, array $options = []): array;
/**
* Get an attachment by its ID.
* @param int $attachment_id The ID of the attachment to be retrieved.
@@ -45,4 +53,4 @@ interface attachments_i
* @see attachment_content
*/
public function update(int $attachment_id, attachment_content $attachment_content): bool;
}
}
@@ -38,6 +38,61 @@ class economic_module_orders extends db
return $this;
}
/**
* Ensure rows exist for the provided order IDs using one batched INSERT IGNORE.
*
* @param int[] $orderIds
*/
public function ensureRowsForOrderIds(array $orderIds): void
{
$orderIds = $this->normalizeOrderIds($orderIds);
if (empty($orderIds)) {
return;
}
$this->performEnsureRowsInsert($orderIds);
}
/**
* Get economic module payload for many orders as an id-keyed map.
*
* @param int[] $orderIds
* @return array<int, array{id:int, invoice_draft_id:int|null, invoice_id:int|null}>
*/
public function getByOrderIdsAsArray(array $orderIds): array
{
$orderIds = $this->normalizeOrderIds($orderIds);
if (empty($orderIds)) {
return [];
}
$rows = $this->fetchRowsByOrderIds($orderIds);
$byId = [];
foreach ($rows as $row) {
$id = (int)($row['id'] ?? 0);
if ($id <= 0) {
continue;
}
$byId[$id] = [
'id' => $id,
'invoice_draft_id' => isset($row['invoice_draft_id']) && $row['invoice_draft_id'] !== null ? (int)$row['invoice_draft_id'] : null,
'invoice_id' => isset($row['invoice_id']) && $row['invoice_id'] !== null ? (int)$row['invoice_id'] : null,
];
}
foreach ($orderIds as $orderId) {
if (!isset($byId[$orderId])) {
$byId[$orderId] = [
'id' => $orderId,
'invoice_draft_id' => null,
'invoice_id' => null,
];
}
}
return $byId;
}
public function getObjectProperties(): void
{
$this->economic_invoice_draft_id = new object_property($this->table, $this->id, 'invoice_draft_id', 'int', true);
@@ -91,4 +146,38 @@ class economic_module_orders extends db
}
return null;
}
}
/**
* @param int[] $orderIds
* @return int[]
*/
protected function normalizeOrderIds(array $orderIds): array
{
$orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds), static fn(int $id): bool => $id > 0)));
sort($orderIds);
return $orderIds;
}
/**
* @param int[] $orderIds
*/
protected function performEnsureRowsInsert(array $orderIds): void
{
global $db;
$values = implode(',', array_map(static fn(int $id): string => "($id)", $orderIds));
$sql = "INSERT IGNORE INTO $this->table (id) VALUES $values";
$db->query($sql);
}
/**
* @param int[] $orderIds
* @return array<int, array<string, mixed>>
*/
protected function fetchRowsByOrderIds(array $orderIds): array
{
return $this->getFieldsWhereIn(
['id' => $orderIds],
['id', 'invoice_draft_id', 'invoice_id']
);
}
}
+61
View File
@@ -1541,6 +1541,67 @@ class orders_o extends db
return (int)$row['wash_count'];
}
/**
* @param array<int> $department_ids
* @return array<int,array{department_id:int,hour_bucket:string,wash_count:int}>
* @throws Exception
*/
public function countWashesByHourForDepartments(string $date_start, string $date_end, array $department_ids): array
{
global /** @var db $db */
$db;
if (strtotime($date_start) === false || strtotime($date_end) === false) {
throw new Exception('Invalid date range provided');
}
if (strtotime($date_start) > strtotime($date_end)) {
throw new Exception('The start date cannot be after the end date');
}
$normalized_department_ids = [];
foreach ($department_ids as $department_id) {
$normalized_id = (int)$department_id;
if ($normalized_id > 0) {
$normalized_department_ids[$normalized_id] = true;
}
}
if ($normalized_department_ids === []) {
return [];
}
$department_ids_sql = implode(',', array_map('intval', array_keys($normalized_department_ids)));
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$sql = "SELECT o.department_id,
DATE_FORMAT(o.created_at, '%Y-%m-%d %H:00:00') AS hour_bucket,
COUNT(DISTINCT o.id) AS wash_count
FROM $this->table o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.department_id IN ($department_ids_sql)
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
AND o.deleted_at IS NULL
AND p.is_wash = 1
GROUP BY o.department_id, DATE_FORMAT(o.created_at, '%Y-%m-%d %H:00:00')";
$result = $db->query($sql);
if (!is_object($result) || $result->num_rows === 0) {
return [];
}
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = [
'department_id' => (int)($row['department_id'] ?? 0),
'hour_bucket' => (string)($row['hour_bucket'] ?? ''),
'wash_count' => (int)($row['wash_count'] ?? 0),
];
}
return $rows;
}
/**
* @throws Exception
* @retuns order_items_o[]
+53
View File
@@ -1519,6 +1519,59 @@ class users_o extends db
return $customer_names;
}
/**
* @param int[] $cashier_ids
* @return array<int, string> Map of cashier id => display name
*/
public function getCashierNames(array $cashier_ids): array
{
$cashier_ids = array_values(array_unique(array_filter(array_map('intval', $cashier_ids), static fn(int $id): bool => $id > 0)));
if (empty($cashier_ids)) {
return [];
}
$cache_key = 'cashier_name';
$virtual_cache_ids = array_map(static fn(int $id): string => "cashier_$id", $cashier_ids);
$cached_values = $this->getCachedForMultipleObjects($cache_key, $virtual_cache_ids);
$names = [];
$missing_ids = [];
foreach ($cashier_ids as $index => $cashier_id) {
$cached_name = $cached_values[$index] ?? null;
if ($cached_name !== null && $cached_name !== '') {
$names[$cashier_id] = (string)$cached_name;
continue;
}
$missing_ids[] = $cashier_id;
}
if (!empty($missing_ids)) {
$rows = $this->getFieldsWhereIn(
['id' => $missing_ids],
['id', 'display_name']
);
$fetched_names = [];
foreach ($rows as $row) {
$cashier_id = (int)($row['id'] ?? 0);
if ($cashier_id <= 0) {
continue;
}
$display_name = trim((string)($row['display_name'] ?? ''));
$fetched_names[$cashier_id] = ($display_name !== '') ? $display_name : 'Unknown Cashier';
}
foreach ($missing_ids as $cashier_id) {
$resolved_name = $fetched_names[$cashier_id] ?? 'Unknown Cashier';
$names[$cashier_id] = $resolved_name;
$virtual_cache_object_id = "cashier_$cashier_id";
$this->cache($cache_key, $resolved_name, $virtual_cache_object_id);
$this->setCachedExpiration($cache_key, self::$cashierNameCacheExpiration, $virtual_cache_object_id);
}
}
return $names;
}
public function getCashierName(int $cashier_id): string
{
$virtualCacheObjectID = "cashier_$cashier_id";
@@ -10,6 +10,7 @@ use classes\workfeed;
use DateInterval;
use DateTime;
use Exception;
use Throwable;
use objects\departments_o;
use objects\logs_o;
use objects\orders_o;
@@ -19,6 +20,11 @@ class moduleWeatherAPIRoute
{
use route_t;
private const DEPARTMENT_WEATHER_HOT_ACTIVITY_ZSET_KEY = 'departments_weather:hot_activity:v1';
private const DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY = 'departments_weather:refresh_queue:v1';
private const DEPARTMENT_WEATHER_HOT_DESCRIPTOR_PREFIX = 'departments_weather:hot_descriptor:v1:';
private const DEPARTMENT_WEATHER_REFRESH_LOCK_PREFIX = 'departments_weather:refresh_lock:v1:';
public function run(): void
{
global /** @var response $response */
@@ -105,9 +111,9 @@ class moduleWeatherAPIRoute
$timeline = $this->withCachedDepartmentWeatherTimeline(
$department_ids,
$timeline_range,
static function () use ($coordinates, $department_context, $department_ids, $departments, $timeline_range, $user): array {
$weather_days = self::resolveForecastDaysForTimelineRange($timeline_range['start'], $timeline_range['endExclusive']);
$forecast_result = self::fetchDepartmentForecastOrFallback($coordinates, $weather_days);
function () use ($coordinates, $department_context, $department_ids, $departments, $timeline_range, $user): array {
$weather_days = $this->resolveForecastDaysForTimelineRange($timeline_range['start'], $timeline_range['endExclusive']);
$forecast_result = $this->fetchDepartmentForecastOrFallback($coordinates, $weather_days);
if (is_string($forecast_result['fallback_reason'])) {
$fallback_message = match ($forecast_result['fallback_reason']) {
'invalid_department_coordinates' => 'Department weather fallback used due to missing or invalid department coordinates',
@@ -117,7 +123,7 @@ class moduleWeatherAPIRoute
(new logs_o())->add('modules_weatherapi', $department_context, 1, $user->id, 'DEPARTMENTS_WEATHER_FALLBACK', $fallback_message);
}
return self::buildDepartmentWeatherTimeline($department_ids, $departments, $forecast_result['forecast'], $timeline_range);
return $this->buildDepartmentWeatherTimeline($department_ids, $departments, $forecast_result['forecast'], $timeline_range);
}
);
@@ -143,6 +149,30 @@ class moduleWeatherAPIRoute
return max(0, (int)$raw);
}
private function getDepartmentWeatherStaleCacheTtl(int $fresh_ttl): int
{
if ($fresh_ttl <= 0) {
return 0;
}
$raw = getenv('DEPARTMENTS_WEATHER_STALE_TTL');
if ($raw === false || trim((string)$raw) === '') {
return max($fresh_ttl, 300);
}
return max($fresh_ttl, max(0, (int)$raw));
}
private static function getDepartmentWeatherHotActivityTtl(): int
{
$raw = getenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL');
if ($raw === false || trim((string)$raw) === '') {
return 900;
}
return max(1, (int)$raw);
}
private function getDepartmentWeatherCacheKey(array $department_ids, array $timeline_range): string
{
$normalized_ids = array_values(array_unique(array_map(static function (mixed $department_id): int {
@@ -164,6 +194,172 @@ class moduleWeatherAPIRoute
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
private static function getDepartmentWeatherHotDescriptorKey(string $hash): string
{
return self::DEPARTMENT_WEATHER_HOT_DESCRIPTOR_PREFIX . $hash;
}
private function buildDepartmentWeatherHotDescriptor(array $department_ids, array $timeline_range): ?array
{
$normalized_ids = $this->normalizeDepartmentIdsForCachePreload($department_ids);
if ($normalized_ids === []) {
return null;
}
$start = ($timeline_range['start'] ?? null) instanceof DateTime
? $timeline_range['start']->format('Y-m-d H:i:s')
: null;
$end_exclusive = ($timeline_range['endExclusive'] ?? null) instanceof DateTime
? $timeline_range['endExclusive']->format('Y-m-d H:i:s')
: null;
if (!is_string($start) || !is_string($end_exclusive) || $start === '' || $end_exclusive === '') {
return null;
}
return [
'department_ids' => $normalized_ids,
'start' => $start,
'end_exclusive' => $end_exclusive,
];
}
private function normalizeDepartmentWeatherHotDescriptor(array $descriptor): ?array
{
$department_ids = $this->normalizeDepartmentIdsForCachePreload((array)($descriptor['department_ids'] ?? []));
if ($department_ids === []) {
return null;
}
$start_raw = $descriptor['start'] ?? null;
$end_raw = $descriptor['end_exclusive'] ?? null;
if (!is_string($start_raw) || !is_string($end_raw)) {
return null;
}
try {
$start = new DateTime($start_raw);
$end_exclusive = new DateTime($end_raw);
} catch (Exception) {
return null;
}
if ($end_exclusive <= $start) {
return null;
}
return [
'department_ids' => $department_ids,
'timeline_range' => [
'start' => $start,
'endExclusive' => $end_exclusive,
],
];
}
private function upsertDepartmentWeatherHotDescriptor(array $descriptor, int $score): ?string
{
if (!defined('redis')) {
return null;
}
$encoded = json_encode($descriptor, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encoded) || $encoded === '') {
return null;
}
$hash = md5($encoded);
$activity_ttl = self::getDepartmentWeatherHotActivityTtl();
$cutoff = (string)($score - $activity_ttl);
try {
redis->setEx(self::getDepartmentWeatherHotDescriptorKey($hash), $encoded, $activity_ttl);
$client = redis->get_client();
$client->zadd(self::DEPARTMENT_WEATHER_HOT_ACTIVITY_ZSET_KEY, $score, $hash);
$client->zremrangebyscore(self::DEPARTMENT_WEATHER_HOT_ACTIVITY_ZSET_KEY, '-inf', $cutoff);
} catch (Throwable) {
return null;
}
return $hash;
}
private function recordDepartmentWeatherHotRequest(array $department_ids, array $timeline_range): ?string
{
$descriptor = $this->buildDepartmentWeatherHotDescriptor($department_ids, $timeline_range);
if ($descriptor === null) {
return null;
}
return $this->upsertDepartmentWeatherHotDescriptor($descriptor, time());
}
private function enqueueDepartmentWeatherRefreshSignal(string $cache_key, array $department_ids, array $timeline_range): void
{
if (!defined('redis')) {
return;
}
$descriptor = $this->buildDepartmentWeatherHotDescriptor($department_ids, $timeline_range);
if ($descriptor === null) {
return;
}
$hash = $this->upsertDepartmentWeatherHotDescriptor($descriptor, time());
if (!is_string($hash) || $hash === '') {
return;
}
try {
$lock_key = self::DEPARTMENT_WEATHER_REFRESH_LOCK_PREFIX . md5($cache_key);
if (!redis->set_if_absent_with_expiration($lock_key, '1', 30)) {
return;
}
$activity_ttl = self::getDepartmentWeatherHotActivityTtl();
$cutoff = (string)(time() - $activity_ttl);
$client = redis->get_client();
$client->zadd(self::DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY, time(), $hash);
$client->zremrangebyscore(self::DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY, '-inf', $cutoff);
} catch (Throwable) {
// Best-effort refresh signal enqueue.
}
}
private function decodeDepartmentWeatherCachePayload(string $cached): ?array
{
$decoded = json_decode($cached, true);
if (!is_array($decoded)) {
return null;
}
if (isset($decoded['timeline']) && is_array($decoded['timeline'])) {
$generated_at = null;
if (isset($decoded['generated_at']) && is_numeric($decoded['generated_at'])) {
$generated_at = max(0, (int)$decoded['generated_at']);
}
return [
'generated_at' => $generated_at,
'timeline' => $decoded['timeline'],
];
}
return [
'generated_at' => null,
'timeline' => $decoded,
];
}
private function encodeDepartmentWeatherCachePayload(array $timeline): ?string
{
$encoded = json_encode([
'generated_at' => time(),
'timeline' => $timeline,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return is_string($encoded) ? $encoded : null;
}
/**
* Best-effort Redis cache wrapper for department weather timeline payloads.
* Falls back to direct computation when Redis is unavailable or TTL is disabled.
@@ -171,41 +367,218 @@ class moduleWeatherAPIRoute
* @param callable():array $resolver
* @return array
*/
private function withCachedDepartmentWeatherTimeline(array $department_ids, array $timeline_range, callable $resolver): array
private function withCachedDepartmentWeatherTimeline(array $department_ids, array $timeline_range, callable $resolver, bool $record_hot_key = true): array
{
$cache_ttl = $this->getDepartmentWeatherCacheTtl();
if ($cache_ttl <= 0 || !defined('redis')) {
$fresh_ttl = $this->getDepartmentWeatherCacheTtl();
if ($fresh_ttl <= 0 || !defined('redis')) {
return (array)$resolver();
}
$stale_ttl = $this->getDepartmentWeatherStaleCacheTtl($fresh_ttl);
$cache_key = $this->getDepartmentWeatherCacheKey($department_ids, $timeline_range);
if ($record_hot_key) {
$this->recordDepartmentWeatherHotRequest($department_ids, $timeline_range);
}
try {
$cached = redis->get($cache_key);
if (is_string($cached) && $cached !== '') {
$decoded = json_decode($cached, true);
if (is_array($decoded)) {
return $decoded;
$decoded = $this->decodeDepartmentWeatherCachePayload($cached);
if (is_array($decoded) && isset($decoded['timeline']) && is_array($decoded['timeline'])) {
$age_seconds = $decoded['generated_at'] === null
? ($fresh_ttl + 1)
: max(0, time() - (int)$decoded['generated_at']);
if ($age_seconds <= $stale_ttl) {
if ($age_seconds > $fresh_ttl && $record_hot_key) {
$this->enqueueDepartmentWeatherRefreshSignal($cache_key, $department_ids, $timeline_range);
}
return $decoded['timeline'];
}
}
}
} catch (\Throwable $e) {
} catch (Throwable) {
// Best-effort cache read.
}
$result = (array)$resolver();
try {
$encoded = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$encoded = $this->encodeDepartmentWeatherCachePayload($result);
if (is_string($encoded)) {
redis->setEx($cache_key, $encoded, $cache_ttl);
redis->setEx($cache_key, $encoded, $stale_ttl);
}
} catch (\Throwable $e) {
} catch (Throwable) {
// Best-effort cache write.
}
return $result;
}
/**
* @return array<int,array{department_ids:array<int>,timeline_range:array{start:DateTime,endExclusive:DateTime}}>
*/
public static function getDepartmentWeatherHotPreloadTargets(int $limit, int $activity_ttl): array
{
if (!defined('redis')) {
return [];
}
$limit = max(1, $limit);
$activity_ttl = max(1, $activity_ttl);
$route = new self();
$targets = [];
$seen_hashes = [];
try {
$client = redis->get_client();
$cutoff = (string)(time() - $activity_ttl);
$client->zremrangebyscore(self::DEPARTMENT_WEATHER_HOT_ACTIVITY_ZSET_KEY, '-inf', $cutoff);
$client->zremrangebyscore(self::DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY, '-inf', $cutoff);
$candidate_hashes = [];
foreach ((array)$client->zrevrange(self::DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY, 0, max(0, $limit - 1)) as $hash) {
$hash = trim((string)$hash);
if ($hash === '' || isset($seen_hashes[$hash])) {
continue;
}
$seen_hashes[$hash] = true;
$candidate_hashes[] = $hash;
$client->zrem(self::DEPARTMENT_WEATHER_REFRESH_QUEUE_ZSET_KEY, $hash);
}
if (count($candidate_hashes) < $limit) {
$spill_limit = max($limit * 5, $limit);
foreach ((array)$client->zrevrange(self::DEPARTMENT_WEATHER_HOT_ACTIVITY_ZSET_KEY, 0, max(0, $spill_limit - 1)) as $hash) {
$hash = trim((string)$hash);
if ($hash === '' || isset($seen_hashes[$hash])) {
continue;
}
$seen_hashes[$hash] = true;
$candidate_hashes[] = $hash;
if (count($candidate_hashes) >= $spill_limit) {
break;
}
}
}
foreach ($candidate_hashes as $hash) {
$payload = redis->get(self::getDepartmentWeatherHotDescriptorKey($hash));
if (!is_string($payload) || $payload === '') {
continue;
}
$decoded = json_decode($payload, true);
if (!is_array($decoded)) {
continue;
}
$target = $route->normalizeDepartmentWeatherHotDescriptor($decoded);
if ($target === null) {
continue;
}
$targets[] = $target;
if (count($targets) >= $limit) {
break;
}
}
} catch (Throwable) {
return [];
}
return $targets;
}
/**
* Pre-compute and cache a department weather timeline for background preloading jobs.
*
* @return array{warmed:bool,department_ids:array<int>,cache_key:string|null,entries:int}
* @throws Exception
*/
public static function preloadDepartmentWeatherTimelineCache(
array $department_ids,
?string $date_from = null,
?string $date_to = null,
?array $timeline_range_override = null
): array
{
$route = new self();
$normalized_ids = $route->normalizeDepartmentIdsForCachePreload($department_ids);
if ($normalized_ids === []) {
return [
'warmed' => false,
'department_ids' => [],
'cache_key' => null,
'entries' => 0,
];
}
if (
is_array($timeline_range_override)
&& ($timeline_range_override['start'] ?? null) instanceof DateTime
&& ($timeline_range_override['endExclusive'] ?? null) instanceof DateTime
) {
$timeline_range = [
'start' => clone $timeline_range_override['start'],
'endExclusive' => clone $timeline_range_override['endExclusive'],
];
} else {
if (($date_from === null) xor ($date_to === null)) {
$date_from = null;
$date_to = null;
}
$timeline_range = $route->getDepartmentWeatherTimelineRange($date_from, $date_to);
}
$departments = $route->loadDepartmentsByIds($normalized_ids);
$coordinates = $route->resolveWeatherCoordinates($departments);
$timeline = $route->withCachedDepartmentWeatherTimeline(
$normalized_ids,
$timeline_range,
function () use ($coordinates, $normalized_ids, $departments, $timeline_range): array {
$weather_days = self::resolveForecastDaysForTimelineRange($timeline_range['start'], $timeline_range['endExclusive']);
$forecast_result = self::fetchDepartmentForecastOrFallback($coordinates, $weather_days);
return self::buildDepartmentWeatherTimeline($normalized_ids, $departments, $forecast_result['forecast'], $timeline_range);
},
false
);
return [
'warmed' => true,
'department_ids' => $normalized_ids,
'cache_key' => $route->getDepartmentWeatherCacheKey($normalized_ids, $timeline_range),
'entries' => count($timeline),
];
}
/**
* @param array<mixed> $department_ids
* @return array<int>
*/
private function normalizeDepartmentIdsForCachePreload(array $department_ids): array
{
$normalized = [];
foreach ($department_ids as $department_id) {
if (!is_numeric($department_id)) {
continue;
}
$value = (int)$department_id;
if ($value < 1) {
continue;
}
$normalized[$value] = true;
}
$ids = array_map('intval', array_keys($normalized));
sort($ids, SORT_NUMERIC);
return $ids;
}
private function parseDepartmentIdsFromRequest(): array
{
global $response;
@@ -455,13 +828,17 @@ class moduleWeatherAPIRoute
$workfeed_hours_by_slot = $departments === []
? []
: self::loadWorkfeedDepartmentHoursBySlot($departments, $timeline_start, $timeline_end_exclusive);
$current_slot_key = (new DateTime(date('Y-m-d H:00:00')))->format('Y-m-d H:00');
$washes_by_slot = $department_ids === []
? []
: self::loadDepartmentWashCountsBySlot($department_ids, $timeline_start, $timeline_end_exclusive);
$current_slot_start = new DateTime(date('Y-m-d H:00:00'));
$current_slot_key = $current_slot_start->format('Y-m-d H:00');
while ($slot < $timeline_end_exclusive) {
$slot_key = $slot->format('Y-m-d H:00');
$weather = $hourly_weather[$slot_key] ?? 'mostly_clear';
$hours = (float)($workfeed_hours_by_slot[$slot_key] ?? 0.0);
$washes = $department_ids === [] ? 0 : self::countWashesForHour($department_ids, $slot);
$washes = (int)($washes_by_slot[$slot_key] ?? 0);
$entries[] = [
'date' => $slot->format('Y-m-d'),
'time' => $slot->format('H:00'),
@@ -469,7 +846,7 @@ class moduleWeatherAPIRoute
'weather' => $weather,
'washes' => $washes,
'hours' => $hours,
'status' => self::calculateStatus($washes, $hours),
'status' => self::calculateStatus($washes, $hours, $slot <= $current_slot_start),
];
$slot->add(new DateInterval('PT1H'));
}
@@ -477,17 +854,26 @@ class moduleWeatherAPIRoute
return $entries;
}
private function calculateStatus(int $washes, float $hours): string
private function calculateStatus(int $washes, float $hours, bool $slot_started = true): string
{
if (!$slot_started) {
return 'unknown';
}
if ($hours <= 0) {
return 'unknown';
}
/**
* Below 1.0 cars/workhour = unhealthy (red)
* 1.0-1.3 cars/workhour = degraded (yellow)
* Above 1.3 cars/workhour = healthy (green)
*/
$ratio = $washes / $hours;
if ($ratio >= 0.8) {
if ($ratio >= 1.3) {
return 'healthy';
}
if ($ratio >= 0.4) {
if ($ratio >= 1.0) {
return 'degraded';
}
return 'unhealthy';
@@ -759,6 +1145,68 @@ class moduleWeatherAPIRoute
return round($hours, 2);
}
/**
* @throws Exception
*/
private function loadDepartmentWashCountsBySlot(array $department_ids, DateTime $timeline_start, DateTime $timeline_end_exclusive): array
{
$normalized_ids = $this->normalizeDepartmentIdsForCachePreload($department_ids);
if ($normalized_ids === []) {
return [];
}
$range_end = clone $timeline_end_exclusive;
$range_end->sub(new DateInterval('PT1S'));
if ($range_end < $timeline_start) {
return [];
}
try {
$rows = (new orders_o())->countWashesByHourForDepartments(
$timeline_start->format('Y-m-d H:i:s'),
$range_end->format('Y-m-d H:i:s'),
$normalized_ids
);
return $this->normalizeDepartmentWashCountRows($rows);
} catch (Exception) {
return [];
}
}
private function normalizeDepartmentWashCountRows(array $rows): array
{
$counts = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$bucket = trim((string)($row['hour_bucket'] ?? $row['hour'] ?? $row['slot'] ?? ''));
if ($bucket === '') {
continue;
}
try {
$slot_key = (new DateTime($bucket))->format('Y-m-d H:00');
} catch (Exception) {
continue;
}
$wash_count = (int)($row['wash_count'] ?? $row['count'] ?? 0);
if ($wash_count < 0) {
$wash_count = 0;
}
if (!isset($counts[$slot_key])) {
$counts[$slot_key] = 0;
}
$counts[$slot_key] += $wash_count;
}
return $counts;
}
/**
* @throws Exception
*/
+250 -48
View File
@@ -67,56 +67,21 @@ class ordersRoute
$orders = new orders_o();
$orders->setView('orders_with_invoice_collections');
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
$forcedFilters = $orders->forceRestrictFilters([
...($has_permission_other ? [
'department_id' => $department_ids
] : []),
...(!$has_permission_other && $effectiveCustomer !== null ? [
'customer_id' => $effectiveCustomer
] : []),
]);
$rawOrders = $orders->listObjectsWithPaginationIfSet(
null,
$forcedFilters
);
$response->success(
$orders->listObjectsWithPaginationIfSet(
function ($order) {
$order_obj = new orders_o();
// Get the order object
$order_obj->select((int)$order['id']);
// Add the invoice status to the order
$order['economic_invoice_module'] = (new economic_module_orders())->getByOrderId($order['id'])->asArray();
// Add the total amount to the order
$order['total_net_amount'] = $order_obj->getNetAmount();
// Add the stripe status to the order
$stripe_module_orders = (new stripe_module_orders_o())->select($order['id']);
if ($stripe_module_orders->exists()) {
$order['stripe_invoice_module'] = $stripe_module_orders->asArray();
}
// If the invoice collection is set, add it to the order
if (!empty($order['invoice_collection_id'])) {
$collected_order_invoices_obj = new collected_order_invoices_o();
$collected_order_invoices_obj->select((int)$order['invoice_collection_id']);
$order['invoice_collection'] = [
'id' => $order['invoice_collection_id'],
'closed_at' => $collected_order_invoices_obj->closed_at->value(),
'booked_invoice_id' => $collected_order_invoices_obj->booked_invoice_id->value() ?? null,
'processor' => (int)$collected_order_invoices_obj->processor->value() ?? null,
];
}
// Get the customer
$tmp_customer = (new users_o())->getUserByCustomerNumber((int)$order['customer_id']);
// Add the customer name to the order
$order['customer_name'] = (new users_o())->getCustomerName((int)$tmp_customer->customer_number->value());
$order['user_id'] = (int)$tmp_customer->id;
// Add the cashier name to the order
$order['cashier_name'] = (new users_o())->getCashierName((int)$order['cashier_id']);
$order['pending_handheld'] = $order_obj->isPendingHandheld();
$order['attachments'] = $order_obj->listAttachments();
$order['po'] = $order['po'] ?? null;
$order['lane'] = $order['lane'] ?? null;
/** @var array $order */
return $order;
},
$orders->forceRestrictFilters([
...($has_permission_other ? [
'department_id' => $department_ids
] : []),
...(!$has_permission_other && $effectiveCustomer !== null ? [
'customer_id' => $effectiveCustomer
] : []),
])
)
$this->enrichOrderListRows($rawOrders)
);
},
[
@@ -1007,6 +972,243 @@ class ordersRoute
}
}
/**
* @param array<int, array<string, mixed>> $orders
* @return array<int, array<string, mixed>>
*/
private function enrichOrderListRows(array $orders): array
{
if (empty($orders)) {
return [];
}
$orderIds = array_values(array_unique(array_filter(array_map('intval', array_column($orders, 'id')), static fn(int $id): bool => $id > 0)));
$customerNumbers = array_values(array_unique(array_filter(array_map('intval', array_column($orders, 'customer_id')), static fn(int $id): bool => $id > 0)));
$cashierIds = array_values(array_unique(array_filter(array_map('intval', array_column($orders, 'cashier_id')), static fn(int $id): bool => $id > 0)));
$netAmountsByOrderId = (new orders_o())->getNetAmountForOrders($orderIds);
$economicModules = new economic_module_orders();
$economicModules->ensureRowsForOrderIds($orderIds);
$economicByOrderId = $economicModules->getByOrderIdsAsArray($orderIds);
$stripeByOrderId = $this->getStripeModulesByOrderIds($orderIds);
$users = new users_o();
$customerNamesByCustomerNumber = $users->getCustomerNames($customerNumbers);
$userIdsByCustomerNumber = $this->getUserIdsByCustomerNumbers($customerNumbers);
$cashierNamesById = $users->getCashierNames($cashierIds);
$pendingHandheldByOrderId = $this->getPendingHandheldFlags($orderIds);
$attachmentsByOrderId = (new attachments())->listMany('orders', $orderIds);
foreach ($orders as &$order) {
$orderId = (int)($order['id'] ?? 0);
$customerNumber = (int)($order['customer_id'] ?? 0);
$cashierId = (int)($order['cashier_id'] ?? 0);
$order['economic_invoice_module'] = $economicByOrderId[$orderId] ?? [
'id' => $orderId,
'invoice_draft_id' => null,
'invoice_id' => null,
];
$order['total_net_amount'] = (float)($netAmountsByOrderId[$orderId] ?? 0);
if (isset($stripeByOrderId[$orderId])) {
$order['stripe_invoice_module'] = $stripeByOrderId[$orderId];
}
if (!empty($order['invoice_collection_id'])) {
$order['invoice_collection'] = [
'id' => $order['invoice_collection_id'],
'closed_at' => $order['closed_at'] ?? null,
'booked_invoice_id' => $order['booked_invoice_id'] ?? null,
'processor' => (int)($order['processor'] ?? 0),
];
}
$customerName = $customerNamesByCustomerNumber[(string)$customerNumber] ?? null;
if ($customerName === null && $customerNumber > 0) {
$customerName = $users->getCustomerName($customerNumber);
}
$order['customer_name'] = $customerName;
$order['user_id'] = (int)($userIdsByCustomerNumber[$customerNumber] ?? 0);
$order['cashier_name'] = $cashierNamesById[$cashierId] ?? 'Unknown Cashier';
$order['pending_handheld'] = (bool)($pendingHandheldByOrderId[$orderId] ?? false);
$order['attachments'] = $attachmentsByOrderId[$orderId] ?? [];
$order['po'] = $order['po'] ?? null;
$order['lane'] = $order['lane'] ?? null;
}
unset($order);
return $orders;
}
/**
* @param int[] $orderIds
* @return array<int, array<string, mixed>>
*/
private function getStripeModulesByOrderIds(array $orderIds): array
{
$orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds), static fn(int $id): bool => $id > 0)));
if (empty($orderIds)) {
return [];
}
$rows = (new stripe_module_orders_o())->getFieldsWhereIn(
['id' => $orderIds],
['id', 'invoice_id', 'customer_id', 'url', 'created_at']
);
$byOrderId = [];
foreach ($rows as $row) {
$orderId = (int)($row['id'] ?? 0);
if ($orderId <= 0) {
continue;
}
$invoiceId = trim((string)($row['invoice_id'] ?? ''));
if ($invoiceId === '') {
continue;
}
$stripeSnapshot = $this->getStripeInvoiceSnapshot($invoiceId);
$byOrderId[$orderId] = [
'id' => $orderId,
'invoice_id' => $invoiceId,
'customer_id' => (string)($row['customer_id'] ?? ''),
'url' => (string)($row['url'] ?? ''),
'created_at' => (string)($row['created_at'] ?? ''),
'paid' => (bool)($stripeSnapshot['paid'] ?? false),
'status' => $stripeSnapshot['status'] ?? null,
'amount_due' => $stripeSnapshot['amount_due'] ?? null,
'amount_paid' => $stripeSnapshot['amount_paid'] ?? null,
];
}
return $byOrderId;
}
/**
* @return array{paid: bool, status: mixed, amount_due: mixed, amount_paid: mixed}
*/
private function getStripeInvoiceSnapshot(string $invoiceId): array
{
$cached = $this->getCachedStripeInvoiceSnapshot($invoiceId);
if ($cached !== null) {
return $cached;
}
$invoice = (new stripe())->invoice->retrieve($invoiceId);
$snapshot = [
'paid' => (bool)($invoice->paid ?? false),
'status' => $invoice->status ?? null,
'amount_due' => $invoice->amount_due ?? null,
'amount_paid' => $invoice->amount_paid ?? null,
];
$this->cacheStripeInvoiceSnapshot($invoiceId, $snapshot);
return $snapshot;
}
/**
* @return array{paid: bool, status: mixed, amount_due: mixed, amount_paid: mixed}|null
*/
private function getCachedStripeInvoiceSnapshot(string $invoiceId): ?array
{
if (!defined('redis')) {
return null;
}
$cacheKey = 'orders_stripe_invoice_snapshot_' . $invoiceId;
$cachedRaw = redis->get($cacheKey);
if (!is_string($cachedRaw) || $cachedRaw === '') {
return null;
}
$decoded = json_decode($cachedRaw, true);
return is_array($decoded) ? $decoded : null;
}
/**
* @param array{paid: bool, status: mixed, amount_due: mixed, amount_paid: mixed} $snapshot
*/
private function cacheStripeInvoiceSnapshot(string $invoiceId, array $snapshot): void
{
if (!defined('redis')) {
return;
}
$encoded = json_encode($snapshot);
if (!is_string($encoded) || $encoded === '') {
return;
}
$cacheKey = 'orders_stripe_invoice_snapshot_' . $invoiceId;
redis->set($cacheKey, $encoded);
redis->expire($cacheKey, 30);
}
/**
* @param int[] $customerNumbers
* @return array<int, int> map: customer_number => user_id
*/
private function getUserIdsByCustomerNumbers(array $customerNumbers): array
{
$customerNumbers = array_values(array_unique(array_filter(array_map('intval', $customerNumbers), static fn(int $id): bool => $id > 0)));
if (empty($customerNumbers)) {
return [];
}
$rows = (new users_o())->getFieldsWhereIn(
['customer_number' => $customerNumbers],
['id', 'customer_number']
);
usort($rows, static fn(array $a, array $b): int => ((int)($a['id'] ?? 0)) <=> ((int)($b['id'] ?? 0)));
$map = [];
foreach ($rows as $row) {
$customerNumber = (int)($row['customer_number'] ?? 0);
if ($customerNumber <= 0 || isset($map[$customerNumber])) {
continue;
}
$map[$customerNumber] = (int)($row['id'] ?? 0);
}
// Keep parity with existing behavior that imports missing customer users via getUserByCustomerNumber.
foreach ($customerNumbers as $customerNumber) {
if (isset($map[$customerNumber])) {
continue;
}
$user = (new users_o())->getUserByCustomerNumber($customerNumber);
if ($user->exists()) {
$map[$customerNumber] = (int)$user->id;
}
}
return $map;
}
/**
* @param int[] $orderIds
* @return array<int, bool> map: order_id => pending_handheld
*/
private function getPendingHandheldFlags(array $orderIds): array
{
$orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds), static fn(int $id): bool => $id > 0)));
if (empty($orderIds)) {
return [];
}
$flags = [];
foreach ($orderIds as $orderId) {
$flags[$orderId] = false;
}
if (!defined('redis')) {
return $flags;
}
$cached = (new orders_o())->getCachedForMultipleObjects('pending_handheld_cache_indicator', $orderIds);
foreach ($orderIds as $index => $orderId) {
$flags[$orderId] = ((int)($cached[$index] ?? 0) === 1);
}
return $flags;
}
/**
* @param mixed $data
* @param response $response
@@ -0,0 +1,75 @@
<?php
app_require('classes/attachments.php');
use classes\attachments;
final class AttachmentsListManyTestDouble extends attachments
{
/** @var array<int, array<string, mixed>> */
public array $fixtureRows = [];
public function __construct()
{
// Intentionally skip module config bootstrap in unit tests.
}
protected function fetchAttachmentRows(string $type, array $object_ids, array $options = []): array
{
$wanted = array_flip(array_map('intval', $object_ids));
return array_values(array_filter(
$this->fixtureRows,
static fn(array $row): bool => isset($wanted[(int)($row['object_id'] ?? 0)])
));
}
}
/**
* @param array<int, object> $attachments
* @return array<int, array<string, mixed>>
*/
function attachments_to_shape(array $attachments): array
{
return array_map(static function (object $attachment): array {
return [
'id' => $attachment->id,
'object_id' => $attachment->object_id,
'document' => $attachment->content->document,
'other' => $attachment->content->other,
];
}, $attachments);
}
it('groups attachments by object id and preserves empty object groups', function (): void {
$attachments = new AttachmentsListManyTestDouble();
$attachments->fixtureRows = [
['id' => 1, 'object_type' => 'orders', 'object_id' => 10, 'content' => '{"document":"a.pdf","other":"x"}', 'created_at' => '2026-01-01 10:00:00', 'updated_at' => '2026-01-01 10:00:00'],
['id' => 2, 'object_type' => 'orders', 'object_id' => 10, 'content' => '{"document":"b.pdf","other":"y"}', 'created_at' => '2026-01-01 10:00:00', 'updated_at' => '2026-01-01 10:00:00'],
['id' => 3, 'object_type' => 'orders', 'object_id' => 12, 'content' => '{"document":"c.pdf","other":"z"}', 'created_at' => '2026-01-01 10:00:00', 'updated_at' => '2026-01-01 10:00:00'],
];
$grouped = $attachments->listMany('orders', [10, 11, 12]);
expect(array_keys($grouped))->toBe([10, 11, 12]);
expect(attachments_to_shape($grouped[10]))->toBe([
['id' => 1, 'object_id' => 10, 'document' => 'a.pdf', 'other' => 'x'],
['id' => 2, 'object_id' => 10, 'document' => 'b.pdf', 'other' => 'y'],
]);
expect($grouped[11])->toBe([]);
expect(attachments_to_shape($grouped[12]))->toBe([
['id' => 3, 'object_id' => 12, 'document' => 'c.pdf', 'other' => 'z'],
]);
});
it('keeps listMany payload parity with list for one object id', function (): void {
$attachments = new AttachmentsListManyTestDouble();
$attachments->fixtureRows = [
['id' => 21, 'object_type' => 'orders', 'object_id' => 100, 'content' => '{"document":"d.pdf","other":"foo"}', 'created_at' => '2026-01-01 10:00:00', 'updated_at' => '2026-01-01 10:00:00'],
['id' => 22, 'object_type' => 'orders', 'object_id' => 100, 'content' => '{"document":"e.pdf","other":"bar"}', 'created_at' => '2026-01-01 10:00:00', 'updated_at' => '2026-01-01 10:00:00'],
];
$single = $attachments->list('orders', 100);
$many = $attachments->listMany('orders', [100]);
expect(attachments_to_shape($single))->toBe(attachments_to_shape($many[100]));
});
@@ -0,0 +1,60 @@
<?php
app_require('objects/economic_module_orders.php');
use objects\economic_module_orders;
final class EconomicModuleOrdersBatchHelpersTestDouble extends economic_module_orders
{
/** @var int[] */
public array $insertedOrderIds = [];
/** @var int[] */
public array $fetchedOrderIds = [];
/** @var array<int, array<string, mixed>> */
public array $rows = [];
protected function performEnsureRowsInsert(array $orderIds): void
{
$this->insertedOrderIds = $orderIds;
}
protected function fetchRowsByOrderIds(array $orderIds): array
{
$this->fetchedOrderIds = $orderIds;
return $this->rows;
}
}
it('ensures economic module rows in one sanitized batch', function (): void {
$object = new EconomicModuleOrdersBatchHelpersTestDouble();
$object->ensureRowsForOrderIds([5, 0, -5, 3, 5, 2]);
expect($object->insertedOrderIds)->toBe([2, 3, 5]);
});
it('returns id keyed economic module payload with null defaults', function (): void {
$object = new EconomicModuleOrdersBatchHelpersTestDouble();
$object->rows = [
['id' => 3, 'invoice_draft_id' => 17, 'invoice_id' => null],
['id' => 5, 'invoice_draft_id' => null, 'invoice_id' => 912],
];
$result = $object->getByOrderIdsAsArray([5, 3, 7, 0, 5]);
expect($object->fetchedOrderIds)->toBe([3, 5, 7]);
expect($result[3])->toBe([
'id' => 3,
'invoice_draft_id' => 17,
'invoice_id' => null,
]);
expect($result[5])->toBe([
'id' => 5,
'invoice_draft_id' => null,
'invoice_id' => 912,
]);
expect($result[7])->toBe([
'id' => 7,
'invoice_draft_id' => null,
'invoice_id' => null,
]);
});
@@ -0,0 +1,26 @@
<?php
it('wires GET /orders through batched enrichment and stripe snapshot caching', function (): void {
$routeFile = app_path('routes/ordersRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
$start = strpos($content, "\$this->get('/orders'");
$end = strpos($content, "\$this->post('/orders'");
expect($start)->not->toBeFalse();
expect($end)->not->toBeFalse();
$ordersGetSection = substr($content, (int)$start, (int)$end - (int)$start);
expect($ordersGetSection)->toContain('$rawOrders = $orders->listObjectsWithPaginationIfSet(');
expect($ordersGetSection)->toContain('$this->enrichOrderListRows($rawOrders)');
expect($ordersGetSection)->not->toContain('$order_obj->select((int)$order[\'id\'])');
expect($ordersGetSection)->not->toContain('$collected_order_invoices_obj->select((int)$order[\'invoice_collection_id\'])');
expect($content)->toContain('getNetAmountForOrders($orderIds)');
expect($content)->toContain('ensureRowsForOrderIds($orderIds)');
expect($content)->toContain("listMany('orders', \$orderIds)");
expect($content)->toContain('orders_stripe_invoice_snapshot_');
expect($content)->toContain('redis->expire($cacheKey, 30);');
});
@@ -0,0 +1,21 @@
<?php
it('implements batched cashier name lookup with cache-first fallback behavior', function (): void {
$usersFile = app_path('objects/users_o.php');
$content = file_get_contents($usersFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('public function getCashierNames(array $cashier_ids): array');
expect($content)->toContain("getCachedForMultipleObjects(\$cache_key, \$virtual_cache_ids)");
expect($content)->toContain("getFieldsWhereIn(");
expect($content)->toContain("'Unknown Cashier'");
expect($content)->toContain("setCachedExpiration(\$cache_key, self::\$cashierNameCacheExpiration");
});
it('sanitizes and deduplicates cashier ids before batch lookup', function (): void {
$usersFile = app_path('objects/users_o.php');
$content = file_get_contents($usersFile);
expect($content)->not->toBeFalse();
expect($content)->toContain("array_values(array_unique(array_filter(array_map('intval', \$cashier_ids)");
});
@@ -16,15 +16,28 @@ function weather_cache_invoke_private(moduleWeatherAPIRoute $route, string $meth
beforeEach(function (): void {
$_SERVER['REQUEST_URI'] = '/departments/weather';
$this->oldTtl = getenv('DEPARTMENTS_WEATHER_CACHE_TTL');
$this->oldStaleTtl = getenv('DEPARTMENTS_WEATHER_STALE_TTL');
$this->oldHotTtl = getenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL');
});
afterEach(function (): void {
if ($this->oldTtl === false) {
putenv('DEPARTMENTS_WEATHER_CACHE_TTL');
return;
} else {
putenv('DEPARTMENTS_WEATHER_CACHE_TTL=' . $this->oldTtl);
}
putenv('DEPARTMENTS_WEATHER_CACHE_TTL=' . $this->oldTtl);
if ($this->oldStaleTtl === false) {
putenv('DEPARTMENTS_WEATHER_STALE_TTL');
} else {
putenv('DEPARTMENTS_WEATHER_STALE_TTL=' . $this->oldStaleTtl);
}
if ($this->oldHotTtl === false) {
putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL');
} else {
putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=' . $this->oldHotTtl);
}
});
it('uses sane ttl defaults and clamps negative ttl to zero', function (): void {
@@ -40,6 +53,29 @@ it('uses sane ttl defaults and clamps negative ttl to zero', function (): void {
expect(weather_cache_invoke_private($route, 'getDepartmentWeatherCacheTtl'))->toBe(0);
});
it('uses sane stale ttl defaults and clamps stale ttl below fresh ttl', function (): void {
$route = new moduleWeatherAPIRoute();
putenv('DEPARTMENTS_WEATHER_STALE_TTL');
expect(weather_cache_invoke_private($route, 'getDepartmentWeatherStaleCacheTtl', [60]))->toBe(300);
putenv('DEPARTMENTS_WEATHER_STALE_TTL=10');
expect(weather_cache_invoke_private($route, 'getDepartmentWeatherStaleCacheTtl', [60]))->toBe(60);
putenv('DEPARTMENTS_WEATHER_STALE_TTL=900');
expect(weather_cache_invoke_private($route, 'getDepartmentWeatherStaleCacheTtl', [60]))->toBe(900);
});
it('uses sane hot activity ttl defaults and clamps to a positive value', function (): void {
$route = new moduleWeatherAPIRoute();
putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL');
expect(weather_cache_invoke_private($route, 'getDepartmentWeatherHotActivityTtl'))->toBe(900);
putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=-5');
expect(weather_cache_invoke_private($route, 'getDepartmentWeatherHotActivityTtl'))->toBe(1);
});
it('builds deterministic cache keys for department sets and timeline ranges', function (): void {
$route = new moduleWeatherAPIRoute();
$range = [
@@ -0,0 +1,347 @@
<?php
app_require('routes/moduleWeatherAPIRoute.php');
use routes\moduleWeatherAPIRoute;
class DepartmentWeatherRedisClientFake
{
/** @var array<string,array<string,float>> */
public array $zsets = [];
public function reset(): void
{
$this->zsets = [];
}
public function zadd(string $key, int|float|string $score, string $member): void
{
if (!isset($this->zsets[$key])) {
$this->zsets[$key] = [];
}
$this->zsets[$key][$member] = (float)$score;
}
public function zrevrange(string $key, int $start, int $stop): array
{
$members = $this->zsets[$key] ?? [];
if ($members === []) {
return [];
}
arsort($members, SORT_NUMERIC);
$member_ids = array_keys($members);
if ($stop < 0) {
$stop = count($member_ids) + $stop;
}
$length = max(0, $stop - $start + 1);
if ($length === 0) {
return [];
}
return array_values(array_slice($member_ids, $start, $length));
}
public function zremrangebyscore(string $key, int|float|string $min, int|float|string $max): int
{
$members = $this->zsets[$key] ?? [];
if ($members === []) {
return 0;
}
$min_score = $this->toScoreBoundary($min, true);
$max_score = $this->toScoreBoundary($max, false);
$removed = 0;
foreach ($members as $member => $score) {
if ($score >= $min_score && $score <= $max_score) {
unset($members[$member]);
$removed++;
}
}
if ($members === []) {
unset($this->zsets[$key]);
} else {
$this->zsets[$key] = $members;
}
return $removed;
}
public function zrem(string $key, string $member): int
{
if (!isset($this->zsets[$key][$member])) {
return 0;
}
unset($this->zsets[$key][$member]);
if ($this->zsets[$key] === []) {
unset($this->zsets[$key]);
}
return 1;
}
private function toScoreBoundary(int|float|string $value, bool $is_min): float
{
if (is_string($value)) {
$trimmed = strtolower(trim($value));
if ($trimmed === '-inf') {
return -INF;
}
if ($trimmed === '+inf' || $trimmed === 'inf') {
return INF;
}
}
$numeric = (float)$value;
if (!is_finite($numeric)) {
return $is_min ? -INF : INF;
}
return $numeric;
}
}
class DepartmentWeatherRedisFake
{
/** @var array<string,string> */
public array $store = [];
/** @var array<int,array{key:string,ttl:int,value:string}> */
public array $setExCalls = [];
/** @var array<int,array{key:string,value:string,ttl:int}> */
public array $lockCalls = [];
public bool $lockResult = true;
private DepartmentWeatherRedisClientFake $client;
public function __construct()
{
$this->client = new DepartmentWeatherRedisClientFake();
}
public function reset(): void
{
$this->store = [];
$this->setExCalls = [];
$this->lockCalls = [];
$this->lockResult = true;
$this->client->reset();
}
public function get(string $key): ?string
{
return $this->store[$key] ?? null;
}
public function setEx(string $key, string $value, int $ttl): self
{
$this->store[$key] = $value;
$this->setExCalls[] = [
'key' => $key,
'ttl' => $ttl,
'value' => $value,
];
return $this;
}
public function set_if_absent_with_expiration(string $key, string $value, int $ttl): bool
{
$this->lockCalls[] = [
'key' => $key,
'value' => $value,
'ttl' => $ttl,
];
if (!$this->lockResult) {
return false;
}
if (array_key_exists($key, $this->store)) {
return false;
}
$this->store[$key] = $value;
return true;
}
public function get_client(): DepartmentWeatherRedisClientFake
{
return $this->client;
}
}
function department_weather_runtime_redis(): DepartmentWeatherRedisFake
{
if (!defined('redis')) {
define('redis', new DepartmentWeatherRedisFake());
}
/** @var DepartmentWeatherRedisFake $redis */
$redis = redis;
return $redis;
}
function department_weather_runtime_invoke_private(moduleWeatherAPIRoute $route, string $method, array $args = []): mixed
{
$reflection = new ReflectionClass($route);
$target = $reflection->getMethod($method);
$target->setAccessible(true);
return $target->invokeArgs($route, $args);
}
beforeEach(function (): void {
$_SERVER['REQUEST_URI'] = '/departments/weather';
$this->oldTtl = getenv('DEPARTMENTS_WEATHER_CACHE_TTL');
$this->oldStaleTtl = getenv('DEPARTMENTS_WEATHER_STALE_TTL');
$this->oldHotTtl = getenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL');
department_weather_runtime_redis()->reset();
});
afterEach(function (): void {
if ($this->oldTtl === false) {
putenv('DEPARTMENTS_WEATHER_CACHE_TTL');
} else {
putenv('DEPARTMENTS_WEATHER_CACHE_TTL=' . $this->oldTtl);
}
if ($this->oldStaleTtl === false) {
putenv('DEPARTMENTS_WEATHER_STALE_TTL');
} else {
putenv('DEPARTMENTS_WEATHER_STALE_TTL=' . $this->oldStaleTtl);
}
if ($this->oldHotTtl === false) {
putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL');
} else {
putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=' . $this->oldHotTtl);
}
});
it('serves fresh cached payloads without invoking the resolver', function (): void {
$route = new moduleWeatherAPIRoute();
putenv('DEPARTMENTS_WEATHER_CACHE_TTL=60');
putenv('DEPARTMENTS_WEATHER_STALE_TTL=300');
putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=900');
$range = [
'start' => new DateTime('2026-03-24 00:00:00'),
'endExclusive' => new DateTime('2026-03-25 00:00:00'),
];
$cacheKey = department_weather_runtime_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $range]);
department_weather_runtime_redis()->store[$cacheKey] = (string)json_encode([
'generated_at' => time() - 5,
'timeline' => [['source' => 'cache']],
]);
$calls = 0;
$result = department_weather_runtime_invoke_private($route, 'withCachedDepartmentWeatherTimeline', [
[1, 3, 5],
$range,
static function () use (&$calls): array {
$calls++;
return [['source' => 'resolver']];
},
]);
expect($result)->toBe([['source' => 'cache']]);
expect($calls)->toBe(0);
});
it('serves stale cached payloads and enqueues a refresh signal', function (): void {
$route = new moduleWeatherAPIRoute();
putenv('DEPARTMENTS_WEATHER_CACHE_TTL=60');
putenv('DEPARTMENTS_WEATHER_STALE_TTL=300');
putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=900');
$range = [
'start' => new DateTime('2026-03-24 00:00:00'),
'endExclusive' => new DateTime('2026-03-25 00:00:00'),
];
$cacheKey = department_weather_runtime_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $range]);
department_weather_runtime_redis()->store[$cacheKey] = (string)json_encode([
'generated_at' => time() - 120,
'timeline' => [['source' => 'stale-cache']],
]);
$calls = 0;
$result = department_weather_runtime_invoke_private($route, 'withCachedDepartmentWeatherTimeline', [
[1, 3, 5],
$range,
static function () use (&$calls): array {
$calls++;
return [['source' => 'resolver']];
},
]);
$targets = moduleWeatherAPIRoute::getDepartmentWeatherHotPreloadTargets(5, 900);
expect($result)->toBe([['source' => 'stale-cache']]);
expect($calls)->toBe(0);
expect(department_weather_runtime_redis()->lockCalls)->toHaveCount(1);
expect($targets)->not->toBeEmpty();
});
it('recomputes and rewrites cache payloads when stale window is exceeded', function (): void {
$route = new moduleWeatherAPIRoute();
putenv('DEPARTMENTS_WEATHER_CACHE_TTL=60');
putenv('DEPARTMENTS_WEATHER_STALE_TTL=300');
putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=900');
$range = [
'start' => new DateTime('2026-03-24 00:00:00'),
'endExclusive' => new DateTime('2026-03-25 00:00:00'),
];
$cacheKey = department_weather_runtime_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $range]);
department_weather_runtime_redis()->store[$cacheKey] = (string)json_encode([
'generated_at' => time() - 400,
'timeline' => [['source' => 'expired-cache']],
]);
$calls = 0;
$result = department_weather_runtime_invoke_private($route, 'withCachedDepartmentWeatherTimeline', [
[1, 3, 5],
$range,
static function () use (&$calls): array {
$calls++;
return [['source' => 'resolver']];
},
]);
$cacheWriteFound = false;
foreach (department_weather_runtime_redis()->setExCalls as $call) {
if ($call['key'] === $cacheKey && $call['ttl'] === 300) {
$cacheWriteFound = true;
break;
}
}
expect($result)->toBe([['source' => 'resolver']]);
expect($calls)->toBe(1);
expect($cacheWriteFound)->toBeTrue();
});
it('records hot keys order-insensitively and applies preload target caps', function (): void {
$route = new moduleWeatherAPIRoute();
putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=900');
$range = [
'start' => new DateTime('2026-03-24 00:00:00'),
'endExclusive' => new DateTime('2026-03-25 00:00:00'),
];
department_weather_runtime_invoke_private($route, 'recordDepartmentWeatherHotRequest', [[5, 1, 3], $range]);
department_weather_runtime_invoke_private($route, 'recordDepartmentWeatherHotRequest', [[1, 3, 5], $range]);
department_weather_runtime_invoke_private($route, 'recordDepartmentWeatherHotRequest', [[8], $range]);
department_weather_runtime_invoke_private($route, 'recordDepartmentWeatherHotRequest', [[9], $range]);
$hotSet = department_weather_runtime_redis()->get_client()->zsets['departments_weather:hot_activity:v1'] ?? [];
$targets = moduleWeatherAPIRoute::getDepartmentWeatherHotPreloadTargets(2, 900);
expect($hotSet)->toHaveCount(3);
expect($targets)->toHaveCount(2);
});
@@ -128,6 +128,9 @@ it('wires departments weather route through the forecast fallback path', functio
$content = (string)file_get_contents($routeFile);
expect($content)->toContain('fetchDepartmentForecastOrFallback');
expect($content)->toContain('$this->resolveForecastDaysForTimelineRange');
expect($content)->toContain('$this->buildDepartmentWeatherTimeline');
expect($content)->not->toContain('static function () use ($coordinates');
expect($content)->toContain('DEPARTMENTS_WEATHER_FALLBACK');
expect($content)->not->toContain('Selected department(s) do not have GPS coordinates configured');
});
@@ -0,0 +1,23 @@
<?php
it('registers and implements cron preloading for department weather cache', function (): void {
$cronContent = file_get_contents(app_path('cron/Cron.php'));
$routeContent = file_get_contents(app_path('routes/moduleWeatherAPIRoute.php'));
expect($cronContent)->not->toBeFalse();
expect($routeContent)->not->toBeFalse();
expect($cronContent)->toContain('PreloadDepartmentWeatherResponsesCron');
expect($cronContent)->toContain("'function' => 'PreloadDepartmentWeatherResponsesCron'");
expect($cronContent)->toContain("'interval' => 60");
expect($cronContent)->toContain('moduleWeatherAPIRoute::preloadDepartmentWeatherTimelineCache');
expect($cronContent)->toContain('moduleWeatherAPIRoute::getDepartmentWeatherHotPreloadTargets');
expect($cronContent)->toContain('DEPARTMENTS_WEATHER_PRELOAD_MAX_DEPARTMENTS');
expect($cronContent)->toContain('DEPARTMENTS_WEATHER_PRELOAD_HOT_LIMIT');
expect($cronContent)->toContain('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL');
expect($routeContent)->toContain('public static function preloadDepartmentWeatherTimelineCache');
expect($routeContent)->toContain('public static function getDepartmentWeatherHotPreloadTargets');
expect($routeContent)->toContain('withCachedDepartmentWeatherTimeline');
expect($routeContent)->toContain('getDepartmentWeatherCacheKey');
});
@@ -70,3 +70,13 @@ it('resolves forecast day count for a given timeline range with sane limits', fu
$pastDays = weather_timeline_range_invoke_private($route, 'resolveForecastDaysForTimelineRange', [$pastStart, $pastEnd]);
expect($pastDays)->toBe(1);
});
it('marks non-started slots as unknown regardless of wash-hour ratio', function (): void {
$route = new moduleWeatherAPIRoute();
$futureStatus = weather_timeline_range_invoke_private($route, 'calculateStatus', [10, 2.0, false]);
expect($futureStatus)->toBe('unknown');
$startedStatus = weather_timeline_range_invoke_private($route, 'calculateStatus', [10, 2.0, true]);
expect($startedStatus)->toBe('healthy');
});
@@ -119,3 +119,41 @@ it('normalizes department id input from scalar csv and nested array values', fun
expect($csv)->toBe(['1', '2', '3']);
expect($nested)->toBe([1, '2', '3', 4, '5']);
});
it('normalizes batched wash count rows into hourly slot totals', function (): void {
$route = new moduleWeatherAPIRoute();
$rows = [
[
'department_id' => 1,
'hour_bucket' => '2026-03-24 08:00:00',
'wash_count' => 3,
],
[
'department_id' => 2,
'hour_bucket' => '2026-03-24 08:15:00',
'wash_count' => 2,
],
[
'department_id' => 2,
'hour_bucket' => '2026-03-24 09:00:00',
'wash_count' => 4,
],
];
$counts = weather_route_invoke_private($route, 'normalizeDepartmentWashCountRows', [$rows]);
expect($counts)->toBe([
'2026-03-24 08:00' => 5,
'2026-03-24 09:00' => 4,
]);
});
it('wires department weather route to use batched wash aggregation', function (): void {
$routeContent = (string)file_get_contents(app_path('routes/moduleWeatherAPIRoute.php'));
$ordersContent = (string)file_get_contents(app_path('objects/orders_o.php'));
expect($routeContent)->toContain('loadDepartmentWashCountsBySlot');
expect($routeContent)->toContain('countWashesByHourForDepartments');
expect($ordersContent)->toContain('public function countWashesByHourForDepartments');
expect($ordersContent)->toContain('GROUP BY o.department_id');
});