Add PreRenderDynamicImagesCron for dynamic image variant caching and idempotency guard for order bookings.
This commit is contained in:
@@ -11,11 +11,14 @@ use classes\xlvask;
|
||||
use classes\slack as Slack;
|
||||
use classes\email as Email;
|
||||
use classes\gatewayapi as GatewayAPI;
|
||||
use dynamicimages\images\machine_1;
|
||||
use goals\classes\goals_criteria;
|
||||
use goals\services\goals_progress_alert_renderer;
|
||||
use goals\helpers\goals_criteria_progress_alert_destination as Dest;
|
||||
use goals\helpers\goals_criteria_progress_alert_frequency as Freq;
|
||||
use objects\department_lanes_o;
|
||||
use objects\department_goals_o;
|
||||
use objects\department_selfserve_tasks_o;
|
||||
use objects\departments_o;
|
||||
use objects\bookings_o;
|
||||
use objects\logs_o;
|
||||
@@ -96,6 +99,12 @@ $cron_tasks = [
|
||||
'next_run' => 0,
|
||||
'function' => 'SystemSearchCacheMaintenanceCron',
|
||||
],
|
||||
'PreRenderDynamicImagesCron' => [
|
||||
'interval' => 900, // 15 minutes
|
||||
'last_run' => 0,
|
||||
'next_run' => 0,
|
||||
'function' => 'PreRenderDynamicImagesCron',
|
||||
],
|
||||
'GoalsProgressAlertsCron' => [
|
||||
'interval' => 60, // check every minute
|
||||
'last_run' => 0,
|
||||
@@ -249,6 +258,374 @@ function SystemSearchCacheMaintenanceCron(): void
|
||||
}
|
||||
}
|
||||
|
||||
function PreRenderDynamicImagesCron(): void
|
||||
{
|
||||
$start = microtime(true);
|
||||
$cacheTtlSeconds = 86400;
|
||||
$maxRendersPerRun = 500;
|
||||
|
||||
if (!defined('redis')) {
|
||||
warn('PreRenderDynamicImagesCron skipped: Redis is unavailable.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!extension_loaded('imagick')) {
|
||||
warn('PreRenderDynamicImagesCron skipped: Imagick extension is not loaded.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$laneRows = (new department_lanes_o())->getFieldsWhere([
|
||||
'deleted_at' => null,
|
||||
'dynamic_image_id' => '!null',
|
||||
], [
|
||||
'id',
|
||||
'department',
|
||||
'dynamic_image_id',
|
||||
'machine_type_id',
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
warn('PreRenderDynamicImagesCron failed to read lanes: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_array($laneRows) || count($laneRows) === 0) {
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] PreRenderDynamicImagesCron: no dynamic-image lanes found.\n";
|
||||
return;
|
||||
}
|
||||
|
||||
$variantsByCacheKey = [];
|
||||
$unsupportedImageIds = [];
|
||||
|
||||
foreach ($laneRows as $laneRow) {
|
||||
$dynamicImageId = (int)($laneRow['dynamic_image_id'] ?? 0);
|
||||
if ($dynamicImageId <= 0) {
|
||||
continue;
|
||||
}
|
||||
if ($dynamicImageId !== 1) {
|
||||
$unsupportedImageIds[$dynamicImageId] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
$taskGroups = collectDynamicImageTaskGroupsForLane($laneRow);
|
||||
if ($taskGroups === []) {
|
||||
$taskGroups = [[
|
||||
'vehicle_type' => null,
|
||||
'rows' => [],
|
||||
]];
|
||||
}
|
||||
|
||||
foreach ($taskGroups as $group) {
|
||||
$groupVariants = buildDynamicImageVariantsForTaskGroup(
|
||||
$dynamicImageId,
|
||||
normalizeDynamicImageVehicleType($group['vehicle_type'] ?? null),
|
||||
is_array($group['rows'] ?? null) ? $group['rows'] : []
|
||||
);
|
||||
|
||||
foreach ($groupVariants as $variant) {
|
||||
$cacheKey = buildDynamicImageCacheKey($variant);
|
||||
if ($cacheKey === '') {
|
||||
continue;
|
||||
}
|
||||
$variantsByCacheKey[$cacheKey] = $variant + ['cache_key' => $cacheKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$discovered = count($variantsByCacheKey);
|
||||
$rendered = 0;
|
||||
$alreadyCached = 0;
|
||||
$failed = 0;
|
||||
$skippedByCap = 0;
|
||||
$attempted = 0;
|
||||
|
||||
foreach ($variantsByCacheKey as $cacheKey => $variant) {
|
||||
if (redis->exists($cacheKey)) {
|
||||
$alreadyCached++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($attempted >= $maxRendersPerRun) {
|
||||
$skippedByCap++;
|
||||
continue;
|
||||
}
|
||||
$attempted++;
|
||||
|
||||
$imageData = renderDynamicImageVariant(
|
||||
(int)$variant['dynamic_image_id'],
|
||||
$variant['buttons'],
|
||||
(int)$variant['current_step'],
|
||||
(bool)$variant['only_current_step']
|
||||
);
|
||||
|
||||
if ($imageData === null) {
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
redis->setEx($cacheKey, $imageData, $cacheTtlSeconds);
|
||||
$rendered++;
|
||||
}
|
||||
|
||||
$duration = round(microtime(true) - $start, 2);
|
||||
$unsupportedList = empty($unsupportedImageIds) ? 'none' : implode(', ', array_keys($unsupportedImageIds));
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] PreRenderDynamicImagesCron completed. "
|
||||
. "discovered=$discovered rendered=$rendered cached=$alreadyCached failed=$failed "
|
||||
. "skipped_by_cap=$skippedByCap unsupported_image_ids=$unsupportedList duration={$duration}s\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array{vehicle_type:int|null,rows:array<int,array<string,mixed>>}>
|
||||
*/
|
||||
function collectDynamicImageTaskGroupsForLane(array $laneRow): array
|
||||
{
|
||||
$laneId = (int)($laneRow['id'] ?? 0);
|
||||
$departmentId = (int)($laneRow['department'] ?? 0);
|
||||
$machineTypeId = normalizeDynamicImageVehicleType($laneRow['machine_type_id'] ?? null);
|
||||
$tasksObject = new department_selfserve_tasks_o();
|
||||
|
||||
$rows = [];
|
||||
if ($machineTypeId !== null) {
|
||||
try {
|
||||
$rows = $tasksObject->getTasksForMachineType($machineTypeId);
|
||||
} catch (Throwable $e) {
|
||||
warn('PreRenderDynamicImagesCron: failed reading machine-type tasks for lane #' . $laneId . ': ' . $e->getMessage());
|
||||
$rows = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_array($rows) || $rows === []) {
|
||||
try {
|
||||
$rows = $tasksObject->getFieldsWhere([
|
||||
'department' => $departmentId,
|
||||
'lane' => $laneId,
|
||||
'deleted_at' => null,
|
||||
], [
|
||||
'id',
|
||||
'product',
|
||||
'order_priority',
|
||||
'buttons',
|
||||
'dynamic_images_vehicle_type',
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
warn('PreRenderDynamicImagesCron: failed reading legacy tasks for lane #' . $laneId . ': ' . $e->getMessage());
|
||||
$rows = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_array($rows) || $rows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$grouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$vehicleType = normalizeDynamicImageVehicleType($row['dynamic_images_vehicle_type'] ?? null);
|
||||
if ($vehicleType === null) {
|
||||
$productVehicleType = normalizeDynamicImageVehicleType($row['product'] ?? null);
|
||||
if ($productVehicleType !== null && $productVehicleType > 0) {
|
||||
$vehicleType = $productVehicleType;
|
||||
}
|
||||
}
|
||||
$groupKey = $vehicleType === null ? 'null' : 'v' . $vehicleType;
|
||||
if (!isset($grouped[$groupKey])) {
|
||||
$grouped[$groupKey] = [
|
||||
'vehicle_type' => $vehicleType,
|
||||
'rows' => [],
|
||||
];
|
||||
}
|
||||
$grouped[$groupKey]['rows'][] = $row;
|
||||
}
|
||||
|
||||
return array_values($grouped);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $taskRows
|
||||
* @return array<int,array{dynamic_image_id:int,buttons:array<int>|null,current_step:int,only_current_step:bool,vehicle_type:int|null}>
|
||||
*/
|
||||
function buildDynamicImageVariantsForTaskGroup(int $dynamicImageId, ?int $vehicleType, array $taskRows): array
|
||||
{
|
||||
$variants = [];
|
||||
|
||||
usort($taskRows, static function (array $a, array $b): int {
|
||||
$priorityA = (int)($a['order_priority'] ?? 0);
|
||||
$priorityB = (int)($b['order_priority'] ?? 0);
|
||||
if ($priorityA !== $priorityB) {
|
||||
return $priorityA <=> $priorityB;
|
||||
}
|
||||
return ((int)($a['id'] ?? 0)) <=> ((int)($b['id'] ?? 0));
|
||||
});
|
||||
|
||||
$buttonSets = [null];
|
||||
$runningButtons = [];
|
||||
|
||||
foreach ($taskRows as $row) {
|
||||
$buttons = parseDynamicImageButtons($row['buttons'] ?? null);
|
||||
if ($buttons !== []) {
|
||||
$buttonSets[] = $buttons;
|
||||
$runningButtons = mergeUniqueIntValues($runningButtons, $buttons);
|
||||
$buttonSets[] = $runningButtons;
|
||||
}
|
||||
}
|
||||
|
||||
$dedupedButtonSets = [];
|
||||
foreach ($buttonSets as $buttonSet) {
|
||||
$signature = $buttonSet === null ? 'null' : json_encode(array_values($buttonSet));
|
||||
if ($signature === false || isset($dedupedButtonSets[$signature])) {
|
||||
continue;
|
||||
}
|
||||
$dedupedButtonSets[$signature] = $buttonSet;
|
||||
}
|
||||
|
||||
foreach ($dedupedButtonSets as $buttons) {
|
||||
$buttonCount = is_array($buttons) ? count($buttons) : 0;
|
||||
$maxStep = min(max(0, $buttonCount + 3), 15);
|
||||
for ($currentStep = 0; $currentStep <= $maxStep; $currentStep++) {
|
||||
$variants[] = [
|
||||
'dynamic_image_id' => $dynamicImageId,
|
||||
'buttons' => $buttons,
|
||||
'current_step' => $currentStep,
|
||||
'only_current_step' => false,
|
||||
'vehicle_type' => $vehicleType,
|
||||
];
|
||||
$variants[] = [
|
||||
'dynamic_image_id' => $dynamicImageId,
|
||||
'buttons' => $buttons,
|
||||
'current_step' => $currentStep,
|
||||
'only_current_step' => true,
|
||||
'vehicle_type' => $vehicleType,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $buttons
|
||||
*/
|
||||
function buildDynamicImageCacheKey(array $variant): string
|
||||
{
|
||||
$cacheParams = [
|
||||
'dynamic_image_id' => (int)($variant['dynamic_image_id'] ?? 0),
|
||||
'buttons' => $variant['buttons'] ?? null,
|
||||
'current_step' => (int)($variant['current_step'] ?? 0),
|
||||
'only_current_step' => (bool)($variant['only_current_step'] ?? false),
|
||||
'vehicle_type' => $variant['vehicle_type'] ?? null,
|
||||
];
|
||||
|
||||
$json = json_encode($cacheParams);
|
||||
if ($json === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return 'dynamic_image:' . md5($json);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $buttons
|
||||
*/
|
||||
function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $currentStep, bool $onlyCurrentStep): ?string
|
||||
{
|
||||
$image = null;
|
||||
try {
|
||||
switch ($dynamicImageId) {
|
||||
case 1:
|
||||
$image = new machine_1();
|
||||
break;
|
||||
default:
|
||||
warn('PreRenderDynamicImagesCron: unsupported dynamic_image_id=' . $dynamicImageId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($buttons)) {
|
||||
$image->highlighted_buttons = $buttons;
|
||||
}
|
||||
$image->current_step = max(0, $currentStep);
|
||||
$image->only_generate_current_step = $onlyCurrentStep;
|
||||
$image->setup();
|
||||
|
||||
$dataUri = $image->exportAsBase64('png');
|
||||
if (!preg_match('/^data:image\/png;base64,(.*)$/', $dataUri, $matches)) {
|
||||
return null;
|
||||
}
|
||||
$imageData = base64_decode($matches[1], true);
|
||||
if ($imageData === false) {
|
||||
return null;
|
||||
}
|
||||
return $imageData;
|
||||
} catch (Throwable $e) {
|
||||
warn('PreRenderDynamicImagesCron: render failed for dynamic_image_id=' . $dynamicImageId . ': ' . $e->getMessage());
|
||||
return null;
|
||||
} finally {
|
||||
if (is_object($image) && method_exists($image, 'clearImage')) {
|
||||
try {
|
||||
$image->clearImage();
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return array<int>
|
||||
*/
|
||||
function parseDynamicImageButtons(mixed $value): array
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$normalized = department_selfserve_tasks_o::normalizeButtonsInput($value);
|
||||
return array_values(array_map('intval', $normalized));
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*/
|
||||
function normalizeDynamicImageVehicleType(mixed $value): ?int
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
if (is_string($value)) {
|
||||
$value = trim($value);
|
||||
if ($value === '' || strtolower($value) === 'null') {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
$normalized = (int)$value;
|
||||
if ($normalized < 0) {
|
||||
return null;
|
||||
}
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int> $base
|
||||
* @param array<int> $append
|
||||
* @return array<int>
|
||||
*/
|
||||
function mergeUniqueIntValues(array $base, array $append): array
|
||||
{
|
||||
$result = $base;
|
||||
foreach ($append as $value) {
|
||||
$intValue = (int)$value;
|
||||
if (!in_array($intValue, $result, true)) {
|
||||
$result[] = $intValue;
|
||||
}
|
||||
}
|
||||
return array_values($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* GoalsProgressAlertsCron
|
||||
*
|
||||
|
||||
@@ -561,7 +561,7 @@ class orderBookingRoute
|
||||
'reg_1' => (string)($data['reg_1'] ?? ''),
|
||||
'reg_2' => $this->normalizeNullableString($data['reg_2'] ?? null),
|
||||
'reg_3' => $this->normalizeNullableString($data['reg_3'] ?? null),
|
||||
'datetime' => (string)($data['datetime'] ?? ''),
|
||||
'datetime' => $this->normalizeDatetimeForFingerprint((string)($data['datetime'] ?? '')),
|
||||
'note' => $this->normalizeNullableString($data['note'] ?? null),
|
||||
'reference' => $this->normalizeNullableString($data['reference'] ?? null),
|
||||
'po' => $this->normalizeNullableString($data['po'] ?? null),
|
||||
@@ -619,6 +619,25 @@ class orderBookingRoute
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function normalizeDatetimeForFingerprint(string $datetime): string
|
||||
{
|
||||
$value = trim($datetime);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$formats = ['Y-m-d H:i:s', 'Y-m-d H:i', 'Y-m-d'];
|
||||
foreach ($formats as $format) {
|
||||
$parsed = \DateTime::createFromFormat($format, $value);
|
||||
if ($parsed !== false) {
|
||||
// Use minute precision so rapid retries do not bypass idempotency because of second-level drift.
|
||||
return $parsed->format('Y-m-d H:i');
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function getBookingFromIdempotencyCache(string $fingerprint): ?order_bookings_o
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
it('guards order booking creation with redis-backed idempotency', function (): void {
|
||||
$routeFile = app_path('routes/orderBookingRoute.php');
|
||||
expect(is_file($routeFile))->toBeTrue();
|
||||
|
||||
$code = (string)file_get_contents($routeFile);
|
||||
$normalized = preg_replace('/\s+/', ' ', $code);
|
||||
|
||||
expect($normalized)->toContain('$fingerprint = $this->buildBookingCreationFingerprint($data);');
|
||||
expect($normalized)->toContain("'datetime' => \$this->normalizeDatetimeForFingerprint((string)(\$data['datetime'] ?? ''))");
|
||||
expect($normalized)->toContain("return \$parsed->format('Y-m-d H:i');");
|
||||
expect($normalized)->toContain('reserveBookingCreationSlot($fingerprint)');
|
||||
expect($normalized)->toContain('storeBookingIdempotencyResult($fingerprint, (int)$order_bookings_o->id)');
|
||||
expect($normalized)->toContain('clearBookingCreationSlot($fingerprint)');
|
||||
expect($normalized)->toContain("order_booking:idempotency:lock:");
|
||||
expect($normalized)->toContain("order_booking:idempotency:result:");
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
it('registers dynamic image pre-render cron task and related helpers', function (): void {
|
||||
$content = file_get_contents(app_path('cron/Cron.php'));
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
expect($content)->toContain('PreRenderDynamicImagesCron');
|
||||
expect($content)->toContain("'interval' => 900");
|
||||
expect($content)->toContain('collectDynamicImageTaskGroupsForLane');
|
||||
expect($content)->toContain('buildDynamicImageVariantsForTaskGroup');
|
||||
expect($content)->toContain('renderDynamicImageVariant');
|
||||
expect($content)->toContain('dynamic_image:');
|
||||
expect($content)->toContain('machine_1');
|
||||
});
|
||||
Reference in New Issue
Block a user