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:
@@ -10455,6 +10455,7 @@ components:
|
|||||||
|
|
||||||
DepartmentWeatherStatus:
|
DepartmentWeatherStatus:
|
||||||
type: string
|
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]
|
enum: [unknown, healthy, degraded, unhealthy]
|
||||||
|
|
||||||
DepartmentWeatherCondition:
|
DepartmentWeatherCondition:
|
||||||
|
|||||||
@@ -27,18 +27,40 @@ class attachments implements attachments_i
|
|||||||
*/
|
*/
|
||||||
public function list(string $type, int $object_id, array $options = []): array
|
public function list(string $type, int $object_id, array $options = []): array
|
||||||
{
|
{
|
||||||
if (count($options) === 0) {
|
$rows = $this->fetchAttachmentRows($type, [$object_id], $options);
|
||||||
$options = ['id', 'content', 'created_at', 'object_id', 'object_type', 'created_at', 'updated_at']; // Default fields to return
|
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;
|
$rows = $this->fetchAttachmentRows($type, $object_ids, $options);
|
||||||
$item->content = json_decode($item->content, true); // Decode JSON content
|
$grouped = [];
|
||||||
return (new attachment())->populate((object)$item);
|
foreach ($object_ids as $object_id) {
|
||||||
}, (new object_attachments_o())->getFieldsWhere([
|
$grouped[$object_id] = [];
|
||||||
'object_type' => $type,
|
}
|
||||||
'object_id' => $object_id,
|
|
||||||
'deleted_at' => null
|
foreach ($rows as $row) {
|
||||||
], $options));
|
$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;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use objects\departments_o;
|
|||||||
use objects\bookings_o;
|
use objects\bookings_o;
|
||||||
use objects\logs_o;
|
use objects\logs_o;
|
||||||
use objects\users_o;
|
use objects\users_o;
|
||||||
|
use routes\moduleWeatherAPIRoute;
|
||||||
|
|
||||||
if (!defined('WD')) {
|
if (!defined('WD')) {
|
||||||
exit;
|
exit;
|
||||||
@@ -105,6 +106,12 @@ $cron_tasks = [
|
|||||||
'next_run' => 0,
|
'next_run' => 0,
|
||||||
'function' => 'PreRenderDynamicImagesCron',
|
'function' => 'PreRenderDynamicImagesCron',
|
||||||
],
|
],
|
||||||
|
'PreloadDepartmentWeatherResponsesCron' => [
|
||||||
|
'interval' => 60, // 1 minute
|
||||||
|
'last_run' => 0,
|
||||||
|
'next_run' => 0,
|
||||||
|
'function' => 'PreloadDepartmentWeatherResponsesCron',
|
||||||
|
],
|
||||||
'GoalsProgressAlertsCron' => [
|
'GoalsProgressAlertsCron' => [
|
||||||
'interval' => 60, // check every minute
|
'interval' => 60, // check every minute
|
||||||
'last_run' => 0,
|
'last_run' => 0,
|
||||||
@@ -626,6 +633,165 @@ function mergeUniqueIntValues(array $base, array $append): array
|
|||||||
return array_values($result);
|
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
|
* GoalsProgressAlertsCron
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ interface attachments_i
|
|||||||
* @return attachment[] An array of attachment objects.
|
* @return attachment[] An array of attachment objects.
|
||||||
*/
|
*/
|
||||||
public function list(string $type, int $object_id, array $options = []): array;
|
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.
|
* Get an attachment by its ID.
|
||||||
* @param int $attachment_id The ID of the attachment to be retrieved.
|
* @param int $attachment_id The ID of the attachment to be retrieved.
|
||||||
@@ -45,4 +53,4 @@ interface attachments_i
|
|||||||
* @see attachment_content
|
* @see attachment_content
|
||||||
*/
|
*/
|
||||||
public function update(int $attachment_id, attachment_content $attachment_content): bool;
|
public function update(int $attachment_id, attachment_content $attachment_content): bool;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,61 @@ class economic_module_orders extends db
|
|||||||
return $this;
|
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
|
public function getObjectProperties(): void
|
||||||
{
|
{
|
||||||
$this->economic_invoice_draft_id = new object_property($this->table, $this->id, 'invoice_draft_id', 'int', true);
|
$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;
|
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']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1541,6 +1541,67 @@ class orders_o extends db
|
|||||||
return (int)$row['wash_count'];
|
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
|
* @throws Exception
|
||||||
* @retuns order_items_o[]
|
* @retuns order_items_o[]
|
||||||
|
|||||||
@@ -1519,6 +1519,59 @@ class users_o extends db
|
|||||||
return $customer_names;
|
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
|
public function getCashierName(int $cashier_id): string
|
||||||
{
|
{
|
||||||
$virtualCacheObjectID = "cashier_$cashier_id";
|
$virtualCacheObjectID = "cashier_$cashier_id";
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use classes\workfeed;
|
|||||||
use DateInterval;
|
use DateInterval;
|
||||||
use DateTime;
|
use DateTime;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use Throwable;
|
||||||
use objects\departments_o;
|
use objects\departments_o;
|
||||||
use objects\logs_o;
|
use objects\logs_o;
|
||||||
use objects\orders_o;
|
use objects\orders_o;
|
||||||
@@ -19,6 +20,11 @@ class moduleWeatherAPIRoute
|
|||||||
{
|
{
|
||||||
use route_t;
|
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
|
public function run(): void
|
||||||
{
|
{
|
||||||
global /** @var response $response */
|
global /** @var response $response */
|
||||||
@@ -105,9 +111,9 @@ class moduleWeatherAPIRoute
|
|||||||
$timeline = $this->withCachedDepartmentWeatherTimeline(
|
$timeline = $this->withCachedDepartmentWeatherTimeline(
|
||||||
$department_ids,
|
$department_ids,
|
||||||
$timeline_range,
|
$timeline_range,
|
||||||
static function () use ($coordinates, $department_context, $department_ids, $departments, $timeline_range, $user): array {
|
function () use ($coordinates, $department_context, $department_ids, $departments, $timeline_range, $user): array {
|
||||||
$weather_days = self::resolveForecastDaysForTimelineRange($timeline_range['start'], $timeline_range['endExclusive']);
|
$weather_days = $this->resolveForecastDaysForTimelineRange($timeline_range['start'], $timeline_range['endExclusive']);
|
||||||
$forecast_result = self::fetchDepartmentForecastOrFallback($coordinates, $weather_days);
|
$forecast_result = $this->fetchDepartmentForecastOrFallback($coordinates, $weather_days);
|
||||||
if (is_string($forecast_result['fallback_reason'])) {
|
if (is_string($forecast_result['fallback_reason'])) {
|
||||||
$fallback_message = match ($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',
|
'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);
|
(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);
|
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
|
private function getDepartmentWeatherCacheKey(array $department_ids, array $timeline_range): string
|
||||||
{
|
{
|
||||||
$normalized_ids = array_values(array_unique(array_map(static function (mixed $department_id): int {
|
$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));
|
], 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.
|
* Best-effort Redis cache wrapper for department weather timeline payloads.
|
||||||
* Falls back to direct computation when Redis is unavailable or TTL is disabled.
|
* Falls back to direct computation when Redis is unavailable or TTL is disabled.
|
||||||
@@ -171,41 +367,218 @@ class moduleWeatherAPIRoute
|
|||||||
* @param callable():array $resolver
|
* @param callable():array $resolver
|
||||||
* @return array
|
* @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();
|
$fresh_ttl = $this->getDepartmentWeatherCacheTtl();
|
||||||
if ($cache_ttl <= 0 || !defined('redis')) {
|
if ($fresh_ttl <= 0 || !defined('redis')) {
|
||||||
return (array)$resolver();
|
return (array)$resolver();
|
||||||
}
|
}
|
||||||
|
$stale_ttl = $this->getDepartmentWeatherStaleCacheTtl($fresh_ttl);
|
||||||
|
|
||||||
$cache_key = $this->getDepartmentWeatherCacheKey($department_ids, $timeline_range);
|
$cache_key = $this->getDepartmentWeatherCacheKey($department_ids, $timeline_range);
|
||||||
|
if ($record_hot_key) {
|
||||||
|
$this->recordDepartmentWeatherHotRequest($department_ids, $timeline_range);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$cached = redis->get($cache_key);
|
$cached = redis->get($cache_key);
|
||||||
if (is_string($cached) && $cached !== '') {
|
if (is_string($cached) && $cached !== '') {
|
||||||
$decoded = json_decode($cached, true);
|
$decoded = $this->decodeDepartmentWeatherCachePayload($cached);
|
||||||
if (is_array($decoded)) {
|
if (is_array($decoded) && isset($decoded['timeline']) && is_array($decoded['timeline'])) {
|
||||||
return $decoded;
|
$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.
|
// Best-effort cache read.
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = (array)$resolver();
|
$result = (array)$resolver();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$encoded = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
$encoded = $this->encodeDepartmentWeatherCachePayload($result);
|
||||||
if (is_string($encoded)) {
|
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.
|
// Best-effort cache write.
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
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
|
private function parseDepartmentIdsFromRequest(): array
|
||||||
{
|
{
|
||||||
global $response;
|
global $response;
|
||||||
@@ -455,13 +828,17 @@ class moduleWeatherAPIRoute
|
|||||||
$workfeed_hours_by_slot = $departments === []
|
$workfeed_hours_by_slot = $departments === []
|
||||||
? []
|
? []
|
||||||
: self::loadWorkfeedDepartmentHoursBySlot($departments, $timeline_start, $timeline_end_exclusive);
|
: 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) {
|
while ($slot < $timeline_end_exclusive) {
|
||||||
$slot_key = $slot->format('Y-m-d H:00');
|
$slot_key = $slot->format('Y-m-d H:00');
|
||||||
$weather = $hourly_weather[$slot_key] ?? 'mostly_clear';
|
$weather = $hourly_weather[$slot_key] ?? 'mostly_clear';
|
||||||
$hours = (float)($workfeed_hours_by_slot[$slot_key] ?? 0.0);
|
$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[] = [
|
$entries[] = [
|
||||||
'date' => $slot->format('Y-m-d'),
|
'date' => $slot->format('Y-m-d'),
|
||||||
'time' => $slot->format('H:00'),
|
'time' => $slot->format('H:00'),
|
||||||
@@ -469,7 +846,7 @@ class moduleWeatherAPIRoute
|
|||||||
'weather' => $weather,
|
'weather' => $weather,
|
||||||
'washes' => $washes,
|
'washes' => $washes,
|
||||||
'hours' => $hours,
|
'hours' => $hours,
|
||||||
'status' => self::calculateStatus($washes, $hours),
|
'status' => self::calculateStatus($washes, $hours, $slot <= $current_slot_start),
|
||||||
];
|
];
|
||||||
$slot->add(new DateInterval('PT1H'));
|
$slot->add(new DateInterval('PT1H'));
|
||||||
}
|
}
|
||||||
@@ -477,17 +854,26 @@ class moduleWeatherAPIRoute
|
|||||||
return $entries;
|
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) {
|
if ($hours <= 0) {
|
||||||
return 'unknown';
|
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;
|
$ratio = $washes / $hours;
|
||||||
if ($ratio >= 0.8) {
|
if ($ratio >= 1.3) {
|
||||||
return 'healthy';
|
return 'healthy';
|
||||||
}
|
}
|
||||||
if ($ratio >= 0.4) {
|
if ($ratio >= 1.0) {
|
||||||
return 'degraded';
|
return 'degraded';
|
||||||
}
|
}
|
||||||
return 'unhealthy';
|
return 'unhealthy';
|
||||||
@@ -759,6 +1145,68 @@ class moduleWeatherAPIRoute
|
|||||||
return round($hours, 2);
|
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
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -67,56 +67,21 @@ class ordersRoute
|
|||||||
$orders = new orders_o();
|
$orders = new orders_o();
|
||||||
$orders->setView('orders_with_invoice_collections');
|
$orders->setView('orders_with_invoice_collections');
|
||||||
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
|
$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(
|
$response->success(
|
||||||
$orders->listObjectsWithPaginationIfSet(
|
$this->enrichOrderListRows($rawOrders)
|
||||||
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
|
|
||||||
] : []),
|
|
||||||
])
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -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 mixed $data
|
||||||
* @param response $response
|
* @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 {
|
beforeEach(function (): void {
|
||||||
$_SERVER['REQUEST_URI'] = '/departments/weather';
|
$_SERVER['REQUEST_URI'] = '/departments/weather';
|
||||||
$this->oldTtl = getenv('DEPARTMENTS_WEATHER_CACHE_TTL');
|
$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 {
|
afterEach(function (): void {
|
||||||
if ($this->oldTtl === false) {
|
if ($this->oldTtl === false) {
|
||||||
putenv('DEPARTMENTS_WEATHER_CACHE_TTL');
|
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 {
|
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);
|
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 {
|
it('builds deterministic cache keys for department sets and timeline ranges', function (): void {
|
||||||
$route = new moduleWeatherAPIRoute();
|
$route = new moduleWeatherAPIRoute();
|
||||||
$range = [
|
$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);
|
$content = (string)file_get_contents($routeFile);
|
||||||
|
|
||||||
expect($content)->toContain('fetchDepartmentForecastOrFallback');
|
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)->toContain('DEPARTMENTS_WEATHER_FALLBACK');
|
||||||
expect($content)->not->toContain('Selected department(s) do not have GPS coordinates configured');
|
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]);
|
$pastDays = weather_timeline_range_invoke_private($route, 'resolveForecastDaysForTimelineRange', [$pastStart, $pastEnd]);
|
||||||
expect($pastDays)->toBe(1);
|
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($csv)->toBe(['1', '2', '3']);
|
||||||
expect($nested)->toBe([1, '2', '3', 4, '5']);
|
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');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user