Add unit tests for Redis namespace safety, MotorAPI cache functionality, and configuration classes, alongside implementation of xlvask_automation_service
- Added tests to ensure Redis namespace safety for `db_object_t` and `users_o`. - Implemented `MotorApiCachedResultTest` to validate metadata caching behavior. - Introduced configuration classes for `xlvask_automatic_order_attachment_enabled` and `xlvask_automatic_order_creation_enabled`. - Developed `xlvask_automation_service` with supporting features for usage log evaluation, suggestion building, and order automation.
This commit is contained in:
@@ -630,7 +630,8 @@ class invoice_period_flag_service
|
||||
&& !$this->hasAttribute($attributes, $customerNumber, 'restrictTankCleaning')
|
||||
&& !$this->hasAttribute($attributes, $customerNumber, 'restrictSpotFree')
|
||||
&& !$this->hasAttribute($attributes, $customerNumber, 'restrictInteriorCleaning')
|
||||
&& !$this->hasAttribute($attributes, $customerNumber, 'exemptFromAdministrationFee')) {
|
||||
&& !$this->hasAttribute($attributes, $customerNumber, 'exemptFromAdministrationFee')
|
||||
&& !$this->hasAttribute($attributes, $customerNumber, 'onlyTankCleaning')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -638,6 +639,8 @@ class invoice_period_flag_service
|
||||
continue;
|
||||
}
|
||||
|
||||
$isTankCleaningProduct = $this->rowIsTankCleaningProduct($row);
|
||||
|
||||
if ($this->hasAttribute($attributes, $customerNumber, 'restrictAdditionalServices')
|
||||
&& (int)($row['related_item_id'] ?? 0) > 0
|
||||
&& (int)($row['item_price'] ?? 0) > 0) {
|
||||
@@ -652,8 +655,31 @@ class invoice_period_flag_service
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->hasAttribute($attributes, $customerNumber, 'restrictTankCleaning') && $isTankCleaningProduct) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
'customer_rule_restrict_tank_cleaning',
|
||||
'order_item',
|
||||
(int)$row['order_item_id'],
|
||||
null,
|
||||
$row,
|
||||
['product' => $this->productLabel($row)],
|
||||
$this->orderItemContext($row)
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->hasAttribute($attributes, $customerNumber, 'onlyTankCleaning') && !$isTankCleaningProduct) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
'customer_rule_only_tank_cleaning',
|
||||
'order_item',
|
||||
(int)$row['order_item_id'],
|
||||
null,
|
||||
$row,
|
||||
['product' => $this->productLabel($row)],
|
||||
$this->orderItemContext($row)
|
||||
);
|
||||
}
|
||||
|
||||
$restrictedProducts = [
|
||||
'restrictTankCleaning' => ['customer_rule_restrict_tank_cleaning', ['tank cleaning', 'tankcleaning', 'tankrens']],
|
||||
'restrictSpotFree' => ['customer_rule_restrict_spot_free', ['spot free', 'spotfree']],
|
||||
'restrictInteriorCleaning' => ['customer_rule_restrict_interior_cleaning', ['interior', 'indvendig']],
|
||||
'exemptFromAdministrationFee' => ['customer_rule_exempt_from_administration_fees', ['administration fee', 'administrationsgebyr', 'administration']],
|
||||
@@ -937,7 +963,14 @@ class invoice_period_flag_service
|
||||
$reg = strtoupper(trim((string)($row['reg_1'] ?? '')));
|
||||
$key = (int)$row['customer_number'] . '|' . $reg;
|
||||
$expectedProductId = (int)($vehicleTypeByCustomerReg[$key]['product_id'] ?? 0);
|
||||
if ($expectedProductId > 0 && $expectedProductId !== (int)$row['product_id']) {
|
||||
$expectedProductName = (string)($vehicleTypeByCustomerReg[$key]['product_name'] ?? '');
|
||||
if ($expectedProductId > 0
|
||||
&& !$this->primaryVehicleProductsMatch(
|
||||
(int)$row['product_id'],
|
||||
$this->productLabel($row),
|
||||
$expectedProductId,
|
||||
$expectedProductName
|
||||
)) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
'vehicle_subscription_type_mismatch',
|
||||
'order_item',
|
||||
@@ -946,7 +979,7 @@ class invoice_period_flag_service
|
||||
$row,
|
||||
[
|
||||
'product' => $this->productLabel($row),
|
||||
'expected_product' => (string)($vehicleTypeByCustomerReg[$key]['product_name'] ?? $expectedProductId),
|
||||
'expected_product' => $expectedProductName !== '' ? $expectedProductName : (string)$expectedProductId,
|
||||
],
|
||||
$this->orderItemContext($row) + ['expected_product_id' => $expectedProductId]
|
||||
);
|
||||
@@ -960,7 +993,12 @@ class invoice_period_flag_service
|
||||
continue;
|
||||
}
|
||||
$expectedProductId = (int)$history[$reg]['product_id'];
|
||||
if ($expectedProductId === (int)$row['product_id']) {
|
||||
if ($this->primaryVehicleProductsMatch(
|
||||
(int)$row['product_id'],
|
||||
$this->productLabel($row),
|
||||
$expectedProductId,
|
||||
(string)$history[$reg]['product_name']
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
$flags[] = $this->automaticFlag(
|
||||
@@ -1126,6 +1164,7 @@ class invoice_period_flag_service
|
||||
'customer_rule_restrict_spot_free' => "{$product} violates restricted Spot Free.",
|
||||
'customer_rule_restrict_interior_cleaning' => "{$product} violates restricted interior wash.",
|
||||
'customer_rule_exempt_from_administration_fees' => "{$product} is an administration fee for an exempt customer.",
|
||||
'customer_rule_only_tank_cleaning' => "{$product} violates the only tank cleaning rule.",
|
||||
'customer_rule_requires_reference' => "Order is missing a required reference.",
|
||||
'customer_rule_requires_po_number' => "Order is missing a required PO number.",
|
||||
'customer_rule_invoice_all_orders_individually' => "Invoice collection contains multiple orders for a customer requiring individual invoices.",
|
||||
@@ -1163,6 +1202,10 @@ class invoice_period_flag_service
|
||||
['type' => 'order', 'text' => 'Wash certificate'],
|
||||
['type' => 'text', 'text' => ' is attached without a wash certificate item.'],
|
||||
],
|
||||
'xlvask_missing_order_link' => [
|
||||
['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'],
|
||||
['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'],
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
@@ -1466,6 +1509,50 @@ class invoice_period_flag_service
|
||||
return $history;
|
||||
}
|
||||
|
||||
private function primaryVehicleProductsMatch(
|
||||
int $currentProductId,
|
||||
string $currentProductName,
|
||||
int $expectedProductId,
|
||||
string $expectedProductName
|
||||
): bool {
|
||||
if ($expectedProductId > 0 && $currentProductId === $expectedProductId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$currentVehicleType = $this->normalizePrimaryVehicleProductName($currentProductName);
|
||||
$expectedVehicleType = $this->normalizePrimaryVehicleProductName($expectedProductName);
|
||||
return $currentVehicleType !== ''
|
||||
&& $expectedVehicleType !== ''
|
||||
&& $currentVehicleType === $expectedVehicleType;
|
||||
}
|
||||
|
||||
private function normalizePrimaryVehicleProductName(string $productName): string
|
||||
{
|
||||
$normalized = strtolower(strtr($productName, [
|
||||
'Æ' => 'ae',
|
||||
'Ø' => 'oe',
|
||||
'Å' => 'aa',
|
||||
'æ' => 'ae',
|
||||
'ø' => 'oe',
|
||||
'å' => 'aa',
|
||||
]));
|
||||
$normalized = (string)preg_replace('/[^a-z0-9]+/', ' ', $normalized);
|
||||
$tokens = array_values(array_filter(
|
||||
explode(' ', trim($normalized)),
|
||||
static fn(string $token): bool => $token !== ''
|
||||
&& !in_array($token, [
|
||||
'indvendig',
|
||||
'indv',
|
||||
'interior',
|
||||
'internal',
|
||||
'vask',
|
||||
'wash',
|
||||
], true)
|
||||
));
|
||||
|
||||
return implode(' ', $tokens);
|
||||
}
|
||||
|
||||
private function getOrderItemsForPreview(int $orderId): array
|
||||
{
|
||||
global $db;
|
||||
@@ -1600,6 +1687,12 @@ class invoice_period_flag_service
|
||||
return false;
|
||||
}
|
||||
|
||||
private function rowIsTankCleaningProduct(array $row): bool
|
||||
{
|
||||
return (int)($row['product_category'] ?? 0) === 5
|
||||
|| $this->rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
|
||||
}
|
||||
|
||||
private function isIncludedOrderItem(array $row): bool
|
||||
{
|
||||
$value = $row['item_include_in_invoice'] ?? 1;
|
||||
|
||||
@@ -155,7 +155,7 @@ class motorapi implements motorapi_i
|
||||
// Get the cached result from the log/local database/cache
|
||||
$motorapi_lookups = new motorapi_lookups_o();
|
||||
// Add the cached value to the meta
|
||||
$response->add_meta('cached', true);
|
||||
self::addCachedMetaIfPossible($response);
|
||||
$cleaned_result = self::cleanJSON($motorapi_lookups->getCachedResult($licensePlate)->result->value());
|
||||
$object = json_decode($cleaned_result);
|
||||
if ($object === null) {
|
||||
@@ -165,6 +165,13 @@ class motorapi implements motorapi_i
|
||||
return json_decode($cleaned_result);
|
||||
}
|
||||
|
||||
public static function addCachedMetaIfPossible(mixed $response): void
|
||||
{
|
||||
if (is_object($response) && method_exists($response, 'add_meta')) {
|
||||
$response->add_meta('cached', true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
* @throws Exception If the module is not enabled, the license plate is invalid, the daily limit is exceeded, or the secret key is invalid
|
||||
@@ -385,4 +392,4 @@ class motorapi implements motorapi_i
|
||||
$motorapi_lookups = new motorapi_lookups_o();
|
||||
$motorapi_lookups->add($licensePlate, json_encode($response), $endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,11 +34,58 @@ class openai implements openai_i
|
||||
*/
|
||||
public function requireModuleEnabled(): void
|
||||
{
|
||||
if (!(bool)$this->config->enabled->getVariableValue()) {
|
||||
if (!$this->config->enabled->isTrue()) {
|
||||
throw new Exception('OpenAI module is not enabled.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a structured JSON text task to the OpenAI Responses API.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function jsonTask(string $schemaName, string $prompt, array $payload, array $schema, float $temperature = 0.1): array
|
||||
{
|
||||
$this->requireModuleEnabled();
|
||||
|
||||
$data = [
|
||||
'model' => $this->model,
|
||||
'input' => [
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => [
|
||||
[
|
||||
'type' => 'input_text',
|
||||
'text' => $prompt . "\n\nData:\n" . json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'text' => [
|
||||
'format' => [
|
||||
'type' => 'json_schema',
|
||||
'name' => $schemaName,
|
||||
'schema' => $schema,
|
||||
'strict' => true,
|
||||
],
|
||||
],
|
||||
'temperature' => $temperature,
|
||||
];
|
||||
|
||||
$response = $this->sendRequest($data);
|
||||
$output = $response['output'][0]['content'][0]['text'] ?? null;
|
||||
if (!is_string($output) || $output === '') {
|
||||
throw new Exception('Invalid response format from OpenAI API. (Missing text field)');
|
||||
}
|
||||
|
||||
$decoded = json_decode($output, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
|
||||
throw new Exception('Error parsing JSON response: ' . json_last_error_msg());
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
protected function getLPRSchema(): array
|
||||
{
|
||||
return [
|
||||
@@ -248,4 +295,4 @@ class openai implements openai_i
|
||||
//print_r($responseData);
|
||||
return $responseData;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,10 +28,81 @@ class xlvask_usage_logs_schema_bootstrap
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_at', 'DATETIME NULL AFTER WashItems');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_by', 'INT NULL AFTER ignored_at');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_reason', 'TEXT NULL AFTER ignored_by');
|
||||
self::ensureAutomationTables($db);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function ensureAutomationTables(object $db): void
|
||||
{
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `xlvask_automation_suggestions` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`usage_log_id` INT NOT NULL,
|
||||
`wash_id` VARCHAR(128) NOT NULL,
|
||||
`signature_hash` CHAR(64) NOT NULL,
|
||||
`signature_json` LONGTEXT NULL,
|
||||
`action` VARCHAR(32) NOT NULL,
|
||||
`status` VARCHAR(32) NOT NULL DEFAULT 'suggested',
|
||||
`confidence` DECIMAL(5,4) NOT NULL DEFAULT 0.0000,
|
||||
`source` VARCHAR(32) NOT NULL DEFAULT 'deterministic',
|
||||
`matched_order_id` INT NULL,
|
||||
`created_order_id` INT NULL,
|
||||
`proposed_order_json` LONGTEXT NULL,
|
||||
`candidate_order_json` LONGTEXT NULL,
|
||||
`reason` TEXT NULL,
|
||||
`created_by` INT NULL,
|
||||
`decided_by` INT NULL,
|
||||
`decided_at` DATETIME NULL,
|
||||
`executed_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_xlvask_automation_usage` (`usage_log_id`),
|
||||
KEY `idx_xlvask_automation_wash` (`wash_id`),
|
||||
KEY `idx_xlvask_automation_signature` (`signature_hash`),
|
||||
KEY `idx_xlvask_automation_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `xlvask_automation_feedback` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`usage_log_id` INT NULL,
|
||||
`wash_id` VARCHAR(128) NULL,
|
||||
`signature_hash` CHAR(64) NOT NULL,
|
||||
`signature_json` LONGTEXT NULL,
|
||||
`action` VARCHAR(32) NOT NULL,
|
||||
`decision` VARCHAR(32) NOT NULL,
|
||||
`order_id` INT NULL,
|
||||
`reason` TEXT NULL,
|
||||
`created_by` INT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_xlvask_feedback_signature_action` (`signature_hash`, `action`),
|
||||
KEY `idx_xlvask_feedback_usage` (`usage_log_id`),
|
||||
KEY `idx_xlvask_feedback_decision` (`decision`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `xlvask_automation_openai_cache` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`cache_key` CHAR(64) NOT NULL,
|
||||
`schema_name` VARCHAR(96) NOT NULL,
|
||||
`input_json` LONGTEXT NOT NULL,
|
||||
`result_json` LONGTEXT NOT NULL,
|
||||
`hits` INT NOT NULL DEFAULT 0,
|
||||
`last_hit_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_xlvask_openai_cache_key` (`cache_key`),
|
||||
KEY `idx_xlvask_openai_cache_schema` (`schema_name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
}
|
||||
|
||||
private static function addColumnIfMissing(object $db, string $table, string $column, string $definition): void
|
||||
{
|
||||
if (!self::columnExists($db, $table, $column)) {
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace xlvask\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class xlvask_automatic_order_attachment_enabled_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'xlvask',
|
||||
'automatic_order_attachment_enabled',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether XL Vask usage logs may automatically be attached to existing same-day employee orders.',
|
||||
'0',
|
||||
false,
|
||||
'false'
|
||||
);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace xlvask\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class xlvask_automatic_order_creation_enabled_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'xlvask',
|
||||
'automatic_order_creation_enabled',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether XL Vask usage logs may automatically create orders when no same-day order can be attached.',
|
||||
'0',
|
||||
false,
|
||||
'false'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace xlvask\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class xlvask_openai_integration_enabled_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'xlvask',
|
||||
'openai_integration_enabled',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether XL Vask automation may ask OpenAI for attachment or creation suggestions.',
|
||||
'0',
|
||||
false,
|
||||
'false'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace helpers;
|
||||
|
||||
require_once WD . '/classes/xlvask_automation_service.php';
|
||||
|
||||
use classes\xlvask_automation_service;
|
||||
use Exception;
|
||||
use objects\orders_o;
|
||||
use objects\users_o;
|
||||
@@ -67,6 +70,7 @@ class xlvask_tasks
|
||||
(new xlvask_customers_o())->importCustomers();
|
||||
(new xlvask_vehicles_o())->importVehicles();
|
||||
(new xlvask_usage_logs_o())->importUsageLogs();
|
||||
(new xlvask_automation_service())->runPending(null, null, [], 100, null);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -608,4 +612,4 @@ class xlvask_tasks
|
||||
$xlvask->requireModuleEnabled();
|
||||
// Run
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,17 @@
|
||||
namespace xlvask;
|
||||
require_once WD . '/modules/xlvask/config/xlvask_enabled_c.php';
|
||||
require_once WD . '/modules/xlvask/config/xlvask_synchronization_enabled_c.php';
|
||||
require_once WD . '/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php';
|
||||
require_once WD . '/modules/xlvask/config/xlvask_automatic_order_creation_enabled_c.php';
|
||||
require_once WD . '/modules/xlvask/config/xlvask_openai_integration_enabled_c.php';
|
||||
require_once WD . '/modules/xlvask/config/xlvask_username_c.php';
|
||||
require_once WD . '/modules/xlvask/config/xlvask_password_c.php';
|
||||
|
||||
use traits\module_config_t;
|
||||
use xlvask\config\xlvask_automatic_order_attachment_enabled_c;
|
||||
use xlvask\config\xlvask_automatic_order_creation_enabled_c;
|
||||
use xlvask\config\xlvask_enabled_c;
|
||||
use xlvask\config\xlvask_openai_integration_enabled_c;
|
||||
use xlvask\config\xlvask_password_c;
|
||||
use xlvask\config\xlvask_synchronization_enabled_c;
|
||||
use xlvask\config\xlvask_username_c;
|
||||
@@ -31,6 +37,18 @@ class xlvask_c
|
||||
* @var xlvask_synchronization_enabled_c $synchronization_enabled
|
||||
*/
|
||||
public xlvask_synchronization_enabled_c $synchronization_enabled;
|
||||
/**
|
||||
* @var xlvask_automatic_order_attachment_enabled_c $automatic_order_attachment_enabled
|
||||
*/
|
||||
public xlvask_automatic_order_attachment_enabled_c $automatic_order_attachment_enabled;
|
||||
/**
|
||||
* @var xlvask_automatic_order_creation_enabled_c $automatic_order_creation_enabled
|
||||
*/
|
||||
public xlvask_automatic_order_creation_enabled_c $automatic_order_creation_enabled;
|
||||
/**
|
||||
* @var xlvask_openai_integration_enabled_c $openai_integration_enabled
|
||||
*/
|
||||
public xlvask_openai_integration_enabled_c $openai_integration_enabled;
|
||||
/**
|
||||
* The username
|
||||
* @var xlvask_username_c
|
||||
@@ -53,12 +71,18 @@ class xlvask_c
|
||||
$this->allowUpdate([
|
||||
xlvask_enabled_c::class,
|
||||
xlvask_synchronization_enabled_c::class,
|
||||
xlvask_automatic_order_attachment_enabled_c::class,
|
||||
xlvask_automatic_order_creation_enabled_c::class,
|
||||
xlvask_openai_integration_enabled_c::class,
|
||||
xlvask_username_c::class,
|
||||
xlvask_password_c::class
|
||||
]);
|
||||
$this->enabled = new xlvask_enabled_c();
|
||||
$this->synchronization_enabled = new xlvask_synchronization_enabled_c();
|
||||
$this->automatic_order_attachment_enabled = new xlvask_automatic_order_attachment_enabled_c();
|
||||
$this->automatic_order_creation_enabled = new xlvask_automatic_order_creation_enabled_c();
|
||||
$this->openai_integration_enabled = new xlvask_openai_integration_enabled_c();
|
||||
$this->username = new xlvask_username_c();
|
||||
$this->password = new xlvask_password_c();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ class users_o extends db
|
||||
$this->setTable('users');
|
||||
}
|
||||
|
||||
private static function redisCache(): ?redis
|
||||
{
|
||||
return defined('redis') ? constant('redis') : null;
|
||||
}
|
||||
|
||||
public function edit(int $id, string $customer_number, string|null $role, string|null $password, string|null $display_name): void
|
||||
{
|
||||
@@ -59,12 +63,12 @@ class users_o extends db
|
||||
if ($old_res && $old_res->num_rows > 0) {
|
||||
$old_cn = (int)$old_res->fetch_assoc()['customer_number'];
|
||||
if ($old_cn !== 0 && $old_cn !== (int)$customer_number) {
|
||||
redis->clear_user_id_from_customer_number($old_cn);
|
||||
self::redisCache()?->clear_user_id_from_customer_number($old_cn);
|
||||
}
|
||||
}
|
||||
// Cache the mapping from customer_number to user_id (new value)
|
||||
redis->cache_user_id_from_customer_number((int)$customer_number, $this->id);
|
||||
redis->cache_customer_number_from_user_id($this->id, (int)$customer_number);
|
||||
self::redisCache()?->cache_user_id_from_customer_number((int)$customer_number, $this->id);
|
||||
self::redisCache()?->cache_customer_number_from_user_id($this->id, (int)$customer_number);
|
||||
|
||||
// Avoid SQL injection
|
||||
$customer_number = $db->escape_string($customer_number);
|
||||
@@ -235,8 +239,8 @@ class users_o extends db
|
||||
$this->id = (int)$db->insert_id();
|
||||
|
||||
// Cache the mapping from customer_number to user_id
|
||||
redis->cache_user_id_from_customer_number((int)$customer_number, $this->id);
|
||||
redis->cache_customer_number_from_user_id($this->id, (int)$customer_number);
|
||||
self::redisCache()?->cache_user_id_from_customer_number((int)$customer_number, $this->id);
|
||||
self::redisCache()?->cache_customer_number_from_user_id($this->id, (int)$customer_number);
|
||||
|
||||
// Set the values of the object properties
|
||||
$this->getObjectProperties();
|
||||
@@ -360,7 +364,7 @@ class users_o extends db
|
||||
{
|
||||
global $db;
|
||||
// Check Redis for existence (by checking if we have the customer number)
|
||||
$customer_number = redis->get_customer_number_from_user_id($id);
|
||||
$customer_number = self::redisCache()?->get_customer_number_from_user_id($id);
|
||||
if ($customer_number !== null) {
|
||||
$this->id = $id;
|
||||
$this->getObjectProperties();
|
||||
@@ -374,7 +378,7 @@ class users_o extends db
|
||||
$this->id = $id;
|
||||
$customer_number = (int)$result->fetch_assoc()['customer_number'];
|
||||
// Cache the result
|
||||
redis->cache_customer_number_from_user_id($id, $customer_number);
|
||||
self::redisCache()?->cache_customer_number_from_user_id($id, $customer_number);
|
||||
$this->getObjectProperties();
|
||||
}
|
||||
return $this;
|
||||
@@ -384,7 +388,7 @@ class users_o extends db
|
||||
{
|
||||
global $db;
|
||||
// Check Redis first
|
||||
$user_id = redis->get_user_id_from_customer_number($customer_number);
|
||||
$user_id = self::redisCache()?->get_user_id_from_customer_number($customer_number);
|
||||
if ($user_id !== null) {
|
||||
$this->id = (int)$user_id;
|
||||
$this->getObjectProperties();
|
||||
@@ -397,7 +401,7 @@ class users_o extends db
|
||||
if ($result->num_rows > 0) {
|
||||
$this->id = (int)$result->fetch_assoc()['id'];
|
||||
// Cache the result
|
||||
redis->cache_user_id_from_customer_number($customer_number, $this->id);
|
||||
self::redisCache()?->cache_user_id_from_customer_number($customer_number, $this->id);
|
||||
$this->getObjectProperties();
|
||||
} else {
|
||||
// Import the customer
|
||||
@@ -1073,22 +1077,22 @@ class users_o extends db
|
||||
public function clearAllUsersEconomicCustomerDiscountsFromCache(): void
|
||||
{
|
||||
// Get all the cached results matching the pattern 'users_*_economic_customer_discount_percentage'
|
||||
$cached_results = redis->get_keys('users_*_economic_customer_discount_percentage');
|
||||
$cached_results = self::redisCache()?->get_keys('users_*_economic_customer_discount_percentage') ?? [];
|
||||
// Loop through the cached results
|
||||
foreach ( $cached_results as $key ) {
|
||||
// Clear the cached discount percentage
|
||||
redis->delete($key);
|
||||
self::redisCache()?->delete($key);
|
||||
}
|
||||
}
|
||||
|
||||
public function clearAllUsersEconomicCustomerDetailsFromCache(): void
|
||||
{
|
||||
// Get all the cached results matching the pattern 'users_*_economic_customer'
|
||||
$cached_results = redis->get_keys('users_*_economic_customer');
|
||||
$cached_results = self::redisCache()?->get_keys('users_*_economic_customer') ?? [];
|
||||
// Loop through the cached results
|
||||
foreach ( $cached_results as $key ) {
|
||||
// Clear the cached economic customer details
|
||||
redis->delete($key);
|
||||
self::redisCache()?->delete($key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1096,7 +1100,7 @@ class users_o extends db
|
||||
{
|
||||
self::requireSelected();
|
||||
// Check if the discount percentage is cached
|
||||
$cached_discount_percentage = redis->get_economic_customer_discount_percentage($this->id);
|
||||
$cached_discount_percentage = self::redisCache()?->get_economic_customer_discount_percentage($this->id);
|
||||
if ($cached_discount_percentage !== null) {
|
||||
return $cached_discount_percentage;
|
||||
}
|
||||
@@ -1104,7 +1108,7 @@ class users_o extends db
|
||||
$economic = new economicCustomers();
|
||||
$discount_percentage = $economic->getCustomerDiscountPercentage($this->customer_number->value());
|
||||
// Cache the discount percentage
|
||||
redis->cache_economic_customer_discount_percentage($this->id, $discount_percentage);
|
||||
self::redisCache()?->cache_economic_customer_discount_percentage($this->id, $discount_percentage);
|
||||
return $discount_percentage;
|
||||
}
|
||||
|
||||
@@ -1143,7 +1147,7 @@ class users_o extends db
|
||||
public function isImportedFromEconomic($customerNumber): bool
|
||||
{
|
||||
// Check Redis first
|
||||
$user_id = redis->get_user_id_from_customer_number((int)$customerNumber);
|
||||
$user_id = self::redisCache()?->get_user_id_from_customer_number((int)$customerNumber);
|
||||
if ($user_id !== null) {
|
||||
return true;
|
||||
}
|
||||
@@ -1158,7 +1162,7 @@ class users_o extends db
|
||||
public function getUserIdFromEconomic($customerNumber): int
|
||||
{
|
||||
// Check Redis first
|
||||
$user_id = redis->get_user_id_from_customer_number((int)$customerNumber);
|
||||
$user_id = self::redisCache()?->get_user_id_from_customer_number((int)$customerNumber);
|
||||
if ($user_id !== null) {
|
||||
return (int)$user_id;
|
||||
}
|
||||
@@ -1168,7 +1172,7 @@ class users_o extends db
|
||||
$id = (int)$user[0]['id'];
|
||||
|
||||
// Cache the result
|
||||
redis->cache_user_id_from_customer_number((int)$customerNumber, $id);
|
||||
self::redisCache()?->cache_user_id_from_customer_number((int)$customerNumber, $id);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
namespace routes;
|
||||
|
||||
require_once WD . '/classes/xlvask_automation_service.php';
|
||||
|
||||
use classes\authentication;
|
||||
use classes\response;
|
||||
use classes\router;
|
||||
use classes\xlvask;
|
||||
use classes\xlvask_automation_service;
|
||||
use objects\orders_o;
|
||||
use objects\users_o;
|
||||
use objects\xlvask_customers_o;
|
||||
@@ -256,6 +259,7 @@ class moduleXLVaskRoute
|
||||
$xlvask_usage_logs_o = new \objects\xlvask_usage_logs_o();
|
||||
// Import usage logs
|
||||
$xlvask_usage_logs_o->importUsageLogs();
|
||||
(new xlvask_automation_service())->runPending(null, null, [], 100, null);
|
||||
// Response
|
||||
$response->success(
|
||||
'Usage logs imported',
|
||||
@@ -267,4 +271,4 @@ class moduleXLVaskRoute
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
namespace routes;
|
||||
|
||||
require_once WD . '/classes/xlvask_automation_service.php';
|
||||
|
||||
use classes\authentication;
|
||||
use classes\redis;
|
||||
use classes\response;
|
||||
use classes\stripe;
|
||||
use classes\xlvask;
|
||||
use classes\xlvask_automation_service;
|
||||
use objects\collected_order_invoices_o;
|
||||
use objects\departments_o;
|
||||
use objects\economic_module_orders;
|
||||
@@ -14,7 +17,6 @@ use objects\logs_o;
|
||||
use objects\orders_o;
|
||||
use objects\stripe_module_orders_o;
|
||||
use objects\stripe_payment_intents_o;
|
||||
use objects\users_o;
|
||||
use objects\xlvask_usage_logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
@@ -47,6 +49,8 @@ class xlvaskUsageLogsRoute
|
||||
(new logs_o())->add('xlvask_usage_orders', 'global', 1, $user->id, 'LIST_XLVASK_USAGE_ORDERS', 'User accessed the list of xlvask usage orders');
|
||||
$xlvask_usage_logs = new xlvask_usage_logs_o();
|
||||
$xlvask = new xlvask();
|
||||
$automation_service = new xlvask_automation_service();
|
||||
$linked_order_ids_by_wash_id = [];
|
||||
$xlvask->new($xlvask->helpers->xlvask_usage_log)->getDepartment();
|
||||
$orders_o = new orders_o();
|
||||
$xlvask_usage_log = $xlvask->new($xlvask->helpers->xlvask_usage_log);
|
||||
@@ -62,7 +66,8 @@ class xlvaskUsageLogsRoute
|
||||
// Make sure the Customer is not in the default customers list
|
||||
->setAdditionalWhereClause("`Customer` NOT IN ('" . implode("', '", $xlvask_usage_log::$default_customers) . "')")
|
||||
->listObjectsWithPaginationIfSet(
|
||||
function ($log) use ($response_includes_items_link, $response_includes_items, $xlvask_usage_logs, $user, $xlvask) {
|
||||
function ($log) use ($response_includes_items_link, $response_includes_items, $xlvask_usage_logs, $user, $xlvask, $automation_service, &$linked_order_ids_by_wash_id) {
|
||||
$automation = $automation_service->evaluateUsageLogRow($log, (int)$user->id, true);
|
||||
// Remove the 'id' field from the log
|
||||
$id = (int)$log['id'];
|
||||
unset($log['id']);
|
||||
@@ -72,6 +77,12 @@ class xlvaskUsageLogsRoute
|
||||
$tmp = $xlvask->new($xlvask->helpers->xlvask_usage_log);
|
||||
// Set the properties of the temporary object
|
||||
$tmp->setProperties($log);
|
||||
$wash_id = (string)$tmp->WashId;
|
||||
if ($wash_id !== '' && !array_key_exists($wash_id, $linked_order_ids_by_wash_id)) {
|
||||
$linked_order = (new orders_o())->selectByWashId($wash_id);
|
||||
$linked_order_ids_by_wash_id[$wash_id] = $linked_order !== null ? (int)$linked_order->id : null;
|
||||
}
|
||||
$linked_order_id = $wash_id !== '' ? $linked_order_ids_by_wash_id[$wash_id] : null;
|
||||
// Define the result structure
|
||||
$isEligibleForAutomaticContinuance = $tmp->isEligibleForAutomaticContinuance(true);
|
||||
// Generate a fast link key for the order, used to retrieve the order with items later.
|
||||
@@ -94,7 +105,10 @@ class xlvaskUsageLogsRoute
|
||||
return [
|
||||
'id' => $id, // Return the ID of the log
|
||||
'fast_link_key' => $fast_link_key ?? null, // Return the fast link key if it was generated
|
||||
'automation' => $automation,
|
||||
...$tmp_res['order'], // Return the simulated order from XLVask (with or without items)
|
||||
'usage_log_id' => $id,
|
||||
'linked_order_id' => $linked_order_id,
|
||||
];
|
||||
},
|
||||
$xlvask_usage_logs->forceRestrictFilters(
|
||||
@@ -160,6 +174,115 @@ class xlvaskUsageLogsRoute
|
||||
]
|
||||
);
|
||||
|
||||
$this->post('/modules/xlvask/services/usage/orders/automation/run', function () {
|
||||
global $response;
|
||||
$this->requirePermission('manage_xlvask_usage_automation');
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$ids = $this->isParametersSet(['ids']) ? $this->getParameter('ids') : [];
|
||||
if (!is_array($ids)) {
|
||||
$ids = [];
|
||||
}
|
||||
|
||||
$dateFrom = $this->isParametersSet(['dateFrom']) ? (string)$this->getParameter('dateFrom') : null;
|
||||
$dateTo = $this->isParametersSet(['dateTo']) ? (string)$this->getParameter('dateTo') : null;
|
||||
$limit = $this->isParametersSet(['limit']) ? (int)$this->getParameter('limit') : 100;
|
||||
|
||||
$response->success(
|
||||
(new xlvask_automation_service())->runPending($dateFrom, $dateTo, $ids, $limit, (int)$user->id)
|
||||
);
|
||||
},
|
||||
[
|
||||
'manage_xlvask_usage_automation' => 'Evaluate and execute XL Vask usage-log automation',
|
||||
]
|
||||
);
|
||||
|
||||
$this->post('/modules/xlvask/services/usage/orders/{id}/automation/evaluate', function () {
|
||||
global $response;
|
||||
$this->requirePermission('manage_xlvask_usage_automation');
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$id = (int)($this->fromRoute('id') ?? 0);
|
||||
if ($id < 1) {
|
||||
$response->error('Invalid XL Vask usage log id', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$response->success(
|
||||
(new xlvask_automation_service())->evaluateUsageLogById($id, (int)$user->id, true)
|
||||
);
|
||||
},
|
||||
[
|
||||
'manage_xlvask_usage_automation' => 'Evaluate XL Vask usage-log automation',
|
||||
]
|
||||
);
|
||||
|
||||
$this->post('/modules/xlvask/services/usage/orders/{id}/automation/accept', function () {
|
||||
global $response;
|
||||
$this->requirePermission('manage_xlvask_usage_automation');
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$id = (int)($this->fromRoute('id') ?? 0);
|
||||
if ($id < 1) {
|
||||
$response->error('Invalid XL Vask usage log id', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$suggestionId = $this->isParametersSet(['suggestion_id']) ? (int)$this->getParameter('suggestion_id') : null;
|
||||
$reason = $this->isParametersSet(['reason']) ? trim((string)$this->getParameter('reason')) : null;
|
||||
|
||||
$response->success(
|
||||
(new xlvask_automation_service())->acceptUsageLogById($id, (int)$user->id, $suggestionId, $reason)
|
||||
);
|
||||
},
|
||||
[
|
||||
'manage_xlvask_usage_automation' => 'Accept an XL Vask usage-log automation suggestion',
|
||||
]
|
||||
);
|
||||
|
||||
$this->post('/modules/xlvask/services/usage/orders/{id}/automation/deny', function () {
|
||||
global $response;
|
||||
$this->requirePermission('manage_xlvask_usage_automation');
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$id = (int)($this->fromRoute('id') ?? 0);
|
||||
if ($id < 1) {
|
||||
$response->error('Invalid XL Vask usage log id', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$suggestionId = $this->isParametersSet(['suggestion_id']) ? (int)$this->getParameter('suggestion_id') : null;
|
||||
$reason = $this->isParametersSet(['reason']) ? trim((string)$this->getParameter('reason')) : null;
|
||||
|
||||
$response->success(
|
||||
(new xlvask_automation_service())->denyUsageLogById($id, (int)$user->id, $suggestionId, $reason)
|
||||
);
|
||||
},
|
||||
[
|
||||
'manage_xlvask_usage_automation' => 'Deny an XL Vask usage-log automation suggestion',
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/modules/xlvask/services/usage/orders/fast-link', function () {
|
||||
global $response;
|
||||
self::requireParameters([
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
<?php
|
||||
|
||||
it('creates auth tokens and reports missing customer users clearly', function (): void {
|
||||
$script = tempnam(sys_get_temp_dir(), 'truckwash-create-token-') . '.php';
|
||||
$appRoot = addslashes(WD);
|
||||
file_put_contents($script, str_replace('__APP_ROOT__', $appRoot, <<<'PHP'
|
||||
<?php
|
||||
|
||||
namespace {
|
||||
define('WD', '__APP_ROOT__');
|
||||
}
|
||||
|
||||
namespace objects {
|
||||
class users_o
|
||||
{
|
||||
@@ -33,27 +43,47 @@ namespace objects {
|
||||
}
|
||||
|
||||
namespace {
|
||||
app_require('interfaces/authentication_i.php');
|
||||
app_require('classes/authentication.php');
|
||||
}
|
||||
require_once WD . '/interfaces/authentication_i.php';
|
||||
require_once WD . '/classes/authentication.php';
|
||||
|
||||
namespace {
|
||||
it('creates a 64-char auth token for an existing user', function (): void {
|
||||
\objects\users_o::$existing = [111111];
|
||||
\objects\tokens_o::$created = [];
|
||||
$result = [
|
||||
'success_token' => null,
|
||||
'created' => [],
|
||||
'missing_error' => null,
|
||||
];
|
||||
|
||||
$token = (new \classes\authentication())->create_token(111111);
|
||||
|
||||
expect($token)->toBeString()->toMatch('/^[a-f0-9]{64}$/');
|
||||
expect(\objects\tokens_o::$created)->toHaveCount(1);
|
||||
expect(\objects\tokens_o::$created[0]['user_id'])->toBe(123);
|
||||
expect(\objects\tokens_o::$created[0]['type'])->toBe('AUTH_TOKEN');
|
||||
});
|
||||
|
||||
it('throws a clear exception when customer user is missing', function (): void {
|
||||
\objects\users_o::$existing = [];
|
||||
\objects\users_o::$existing = [111111];
|
||||
\objects\tokens_o::$created = [];
|
||||
$result['success_token'] = (new \classes\authentication())->create_token(111111);
|
||||
$result['created'] = \objects\tokens_o::$created;
|
||||
|
||||
\objects\users_o::$existing = [];
|
||||
try {
|
||||
(new \classes\authentication())->create_token(222222);
|
||||
})->throws(\Exception::class, 'User not found for customer number: 222222');
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
$result['missing_error'] = $exception->getMessage();
|
||||
}
|
||||
|
||||
echo json_encode($result, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
PHP));
|
||||
|
||||
try {
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec(PHP_BINARY . ' ' . escapeshellarg($script), $output, $exitCode);
|
||||
|
||||
expect($exitCode)->toBe(0);
|
||||
$result = json_decode(implode("\n", $output), true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
expect($result['success_token'])->toBeString()->toMatch('/^[a-f0-9]{64}$/')
|
||||
->and($result['created'])->toHaveCount(1)
|
||||
->and($result['created'][0]['user_id'])->toBe(123)
|
||||
->and($result['created'][0]['type'])->toBe('AUTH_TOKEN')
|
||||
->and($result['missing_error'])->toBe('User not found for customer number: 222222');
|
||||
} finally {
|
||||
if (is_file($script)) {
|
||||
unlink($script);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
it('keeps db object Redis access namespace safe', function (): void {
|
||||
$content = file_get_contents(WD . '/traits/db_object_t.php');
|
||||
|
||||
expect($content)
|
||||
->not->toContain('redis->')
|
||||
->and($content)->toContain("constant('redis')");
|
||||
});
|
||||
@@ -111,6 +111,29 @@ it('builds interactive message parts for order and wash certificate warnings', f
|
||||
[],
|
||||
['department_id' => 1, 'order_id' => 9001, 'order_item_id' => 7001],
|
||||
]);
|
||||
$xlVaskFlag = invoice_period_flag_service_invoke('automaticFlag', [
|
||||
'xlvask_missing_order_link',
|
||||
'xlvask_usage_log',
|
||||
55,
|
||||
null,
|
||||
[
|
||||
'customer_number' => 424242,
|
||||
'customer_name' => 'Flagged Customer',
|
||||
'xlvask_usage_log_id' => 55,
|
||||
],
|
||||
[
|
||||
'wash_id' => 'wash-55',
|
||||
'registration_number' => 'AB12345',
|
||||
],
|
||||
[
|
||||
'customer_number' => 424242,
|
||||
'customer_name' => 'Flagged Customer',
|
||||
'xlvask_usage_log_id' => 55,
|
||||
'wash_id' => 'wash-55',
|
||||
'registration_number' => 'AB12345',
|
||||
'start_time' => '2026-05-11 10:00:00',
|
||||
],
|
||||
]);
|
||||
|
||||
expect($orderFlag['message_parts'])->toBe([
|
||||
['type' => 'order', 'text' => 'Order'],
|
||||
@@ -120,6 +143,10 @@ it('builds interactive message parts for order and wash certificate warnings', f
|
||||
['type' => 'order_item', 'text' => 'Wash certificate item'],
|
||||
['type' => 'text', 'text' => ' is present without a wash certificate.'],
|
||||
]);
|
||||
expect($xlVaskFlag['message_parts'])->toBe([
|
||||
['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'],
|
||||
['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes order item preview context for required order field warnings', function (): void {
|
||||
@@ -227,6 +254,159 @@ it('includes order item preview context for required order field warnings', func
|
||||
}
|
||||
});
|
||||
|
||||
it('allows tank cleaning products for only tank cleaning customers', function (): void {
|
||||
$baseRow = [
|
||||
'customer_number' => 424242,
|
||||
'customer_name' => 'Tank Customer',
|
||||
'order_id' => 61426,
|
||||
'invoice_collection_id' => 3090,
|
||||
'department_id' => 5,
|
||||
'related_item_id' => 0,
|
||||
'item_price' => 100,
|
||||
'order_reference' => 'REF',
|
||||
'order_po' => 'PO',
|
||||
'reg_1' => 'NI465',
|
||||
];
|
||||
$tankCleaningRow = $baseRow + [
|
||||
'order_item_id' => 801,
|
||||
'product_id' => 30,
|
||||
'product_name' => 'Tank cleaning 4 spulehoveder',
|
||||
'product_category' => 5,
|
||||
'category_name' => 'Tank cleaning',
|
||||
];
|
||||
$tankCleaningAddonRow = $baseRow + [
|
||||
'order_item_id' => 802,
|
||||
'product_id' => 33,
|
||||
'product_name' => 'Saebe/kemi, 1-4 spulehoveder',
|
||||
'product_category' => 5,
|
||||
'category_name' => 'Tank cleaning',
|
||||
];
|
||||
$washRow = $baseRow + [
|
||||
'order_item_id' => 803,
|
||||
'product_id' => 3,
|
||||
'product_name' => 'Forvogn',
|
||||
'product_category' => 1,
|
||||
'category_name' => 'Vask',
|
||||
];
|
||||
|
||||
$onlyTankCleaningFlags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [
|
||||
[$tankCleaningRow, $tankCleaningAddonRow, $washRow],
|
||||
[424242 => ['onlyTankCleaning' => true]],
|
||||
]);
|
||||
|
||||
expect(array_column($onlyTankCleaningFlags, 'definition_key'))->toBe(['customer_rule_only_tank_cleaning']);
|
||||
expect($onlyTankCleaningFlags[0]['target_id'])->toBe(803);
|
||||
|
||||
$restrictedTankCleaningFlags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [
|
||||
[$tankCleaningRow, $washRow],
|
||||
[424242 => ['restrictTankCleaning' => true]],
|
||||
]);
|
||||
|
||||
expect(array_column($restrictedTankCleaningFlags, 'definition_key'))->toBe(['customer_rule_restrict_tank_cleaning']);
|
||||
expect($restrictedTankCleaningFlags[0]['target_id'])->toBe(801);
|
||||
});
|
||||
|
||||
it('does not flag interior wash variants as historical primary product mismatches', function (): void {
|
||||
global $db;
|
||||
|
||||
$hadDb = array_key_exists('db', $GLOBALS);
|
||||
$previousDb = $GLOBALS['db'] ?? null;
|
||||
$db = new class {
|
||||
public function escape_string(string $value): string
|
||||
{
|
||||
return addslashes($value);
|
||||
}
|
||||
|
||||
public function query(string $sql): object|false
|
||||
{
|
||||
if (str_contains($sql, 'SHOW COLUMNS FROM `customer_vehicles`')) {
|
||||
return $this->result([]);
|
||||
}
|
||||
|
||||
if (str_contains($sql, 'FROM customer_vehicles')) {
|
||||
return $this->result([]);
|
||||
}
|
||||
|
||||
if (str_contains($sql, 'FROM orders o') && str_contains($sql, 'GROUP BY UPPER(TRIM(o.reg_1))')) {
|
||||
return $this->result([
|
||||
[
|
||||
'reg' => 'CN96636',
|
||||
'product_id' => 3,
|
||||
'product_name' => 'Forvogn',
|
||||
'usage_count' => 5,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function result(array $rows): object
|
||||
{
|
||||
return new class($rows) {
|
||||
public int $num_rows;
|
||||
public array $rows;
|
||||
|
||||
public function __construct(array $rows)
|
||||
{
|
||||
$this->rows = $rows;
|
||||
$this->num_rows = count($rows);
|
||||
}
|
||||
|
||||
public function fetch_assoc(): ?array
|
||||
{
|
||||
return array_shift($this->rows);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
$baseRow = [
|
||||
'customer_number' => 424242,
|
||||
'customer_name' => 'History Customer',
|
||||
'order_id' => 61311,
|
||||
'order_item_id' => 901,
|
||||
'invoice_collection_id' => 16912,
|
||||
'department_id' => 7,
|
||||
'reg_1' => 'CN96636',
|
||||
'is_wash' => 1,
|
||||
'related_item_id' => 0,
|
||||
'order_created_at' => '2026-05-11 08:05:21',
|
||||
];
|
||||
|
||||
$interiorVariantFlags = invoice_period_flag_service_invoke('detectVehicleTypeMismatches', [
|
||||
[
|
||||
$baseRow + [
|
||||
'product_id' => 99,
|
||||
'product_name' => 'Indvendig vask Forvogn',
|
||||
],
|
||||
],
|
||||
'2026-05-11 00:00:00',
|
||||
]);
|
||||
|
||||
expect($interiorVariantFlags)->toBe([]);
|
||||
|
||||
$mismatchFlags = invoice_period_flag_service_invoke('detectVehicleTypeMismatches', [
|
||||
[
|
||||
$baseRow + [
|
||||
'product_id' => 17,
|
||||
'product_name' => 'Bus',
|
||||
],
|
||||
],
|
||||
'2026-05-11 00:00:00',
|
||||
]);
|
||||
|
||||
expect(array_column($mismatchFlags, 'definition_key'))->toBe(['historical_primary_product_mismatch']);
|
||||
} finally {
|
||||
if ($hadDb) {
|
||||
$db = $previousDb;
|
||||
} else {
|
||||
unset($GLOBALS['db']);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('does not report duplicate primary vehicle products from duplicated detector rows for the same order item', function (): void {
|
||||
$row = [
|
||||
'customer_number' => 424242,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use classes\motorapi;
|
||||
|
||||
require_once WD . '/classes/motorapi.php';
|
||||
|
||||
it('only writes cached MotorAPI metadata when a response object exists', function (): void {
|
||||
motorapi::addCachedMetaIfPossible(null);
|
||||
|
||||
$response = new class {
|
||||
public array $meta = [];
|
||||
|
||||
public function add_meta(string $key, mixed $value): void
|
||||
{
|
||||
$this->meta[$key] = $value;
|
||||
}
|
||||
};
|
||||
|
||||
motorapi::addCachedMetaIfPossible($response);
|
||||
|
||||
expect($response->meta)->toBe(['cached' => true]);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
it('keeps users Redis access namespace safe', function (): void {
|
||||
$content = file_get_contents(WD . '/objects/users_o.php');
|
||||
|
||||
expect($content)
|
||||
->not->toContain('redis->')
|
||||
->and($content)->toContain("constant('redis')");
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
use classes\xlvask_automation_service;
|
||||
|
||||
require_once WD . '/classes/xlvask_automation_service.php';
|
||||
|
||||
it('normalizes registrations for XL Vask automation signatures', function (): void {
|
||||
expect(xlvask_automation_service::normalizeRegistrationForAutomation(' ec 21-233 '))
|
||||
->toBe('EC21233');
|
||||
});
|
||||
|
||||
it('builds stable XL Vask automation item signatures', function (): void {
|
||||
$items = [
|
||||
['product_id' => 20, 'quantity' => 1, 'price' => 275],
|
||||
['product_id' => 10, 'quantity' => 2, 'price' => 649],
|
||||
['product_id' => 20, 'quantity' => 1, 'price' => 0],
|
||||
];
|
||||
|
||||
expect(xlvask_automation_service::itemSignaturePartsForAutomation($items))
|
||||
->toBe([
|
||||
'10:2:649',
|
||||
'20:1:0',
|
||||
'20:1:275',
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes persisted XL Vask usage-log rows before helper hydration', function (): void {
|
||||
$row = xlvask_automation_service::normalizeUsageLogRowForAutomation([
|
||||
'id' => 47086,
|
||||
'WashId' => 'cc1eabc1-b4e1-425b-ad7c-dc68f8c97ceb',
|
||||
'WashItems' => '[{"OriginalProductName":"Bus","Count":1}]',
|
||||
]);
|
||||
|
||||
expect($row)
|
||||
->not->toHaveKey('id')
|
||||
->and($row['WashItems'])->toBe([
|
||||
[
|
||||
'OriginalProductName' => 'Bus',
|
||||
'Count' => 1,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds stable OpenAI cache keys for identical automation input', function (): void {
|
||||
$prompt = 'Prompt';
|
||||
$schemaName = 'xlvask_automation';
|
||||
$schema = [
|
||||
'required' => ['action'],
|
||||
'properties' => [
|
||||
'confidence' => ['type' => 'number'],
|
||||
'action' => ['type' => 'string'],
|
||||
],
|
||||
];
|
||||
$schemaWithDifferentKeyOrder = [
|
||||
'properties' => [
|
||||
'action' => ['type' => 'string'],
|
||||
'confidence' => ['type' => 'number'],
|
||||
],
|
||||
'required' => ['action'],
|
||||
];
|
||||
$payloadA = [
|
||||
'usage_log' => [
|
||||
'registration' => 'AB12345',
|
||||
'creation_allowed' => true,
|
||||
],
|
||||
'candidate_orders' => [
|
||||
['id' => 10, 'items' => [['product_id' => 1, 'quantity' => 1, 'price' => 100]]],
|
||||
],
|
||||
];
|
||||
$payloadB = [
|
||||
'candidate_orders' => [
|
||||
['items' => [['price' => 100, 'quantity' => 1, 'product_id' => 1]], 'id' => 10],
|
||||
],
|
||||
'usage_log' => [
|
||||
'creation_allowed' => true,
|
||||
'registration' => 'AB12345',
|
||||
],
|
||||
];
|
||||
|
||||
expect(xlvask_automation_service::openAiCacheKeyForAutomation($schemaName, $prompt, $payloadA, $schema, 0.1))
|
||||
->toBe(xlvask_automation_service::openAiCacheKeyForAutomation($schemaName, $prompt, $payloadB, $schemaWithDifferentKeyOrder, 0.1));
|
||||
});
|
||||
|
||||
it('changes OpenAI cache keys when automation eligibility input changes', function (): void {
|
||||
$schema = ['type' => 'object'];
|
||||
$newerWashPayload = ['usage_log' => ['creation_allowed' => false, 'age_bucket' => 'newer_than_6_hours']];
|
||||
$olderWashPayload = ['usage_log' => ['creation_allowed' => true, 'age_bucket' => 'older_than_6_hours']];
|
||||
|
||||
expect(xlvask_automation_service::openAiCacheKeyForAutomation('xlvask_automation', 'Prompt', $newerWashPayload, $schema, 0.1))
|
||||
->not->toBe(xlvask_automation_service::openAiCacheKeyForAutomation('xlvask_automation', 'Prompt', $olderWashPayload, $schema, 0.1));
|
||||
});
|
||||
|
||||
it('declares a persistent OpenAI cache table for XL Vask automation', function (): void {
|
||||
$bootstrapContent = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php');
|
||||
|
||||
expect($bootstrapContent)
|
||||
->toContain('xlvask_automation_openai_cache')
|
||||
->toContain('UNIQUE KEY `uniq_xlvask_openai_cache_key` (`cache_key`)');
|
||||
});
|
||||
|
||||
it('scores same-day orders with matching XL Vask products and extra add-ons as attach suggestions', function (): void {
|
||||
$usageItems = [
|
||||
['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']],
|
||||
['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']],
|
||||
['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']],
|
||||
['product_id' => 99, 'quantity' => 8, 'price' => 0, 'product' => ['name' => 'Halleje']],
|
||||
];
|
||||
$orderItems = [
|
||||
['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']],
|
||||
['product_id' => 50, 'quantity' => 1, 'price' => 319, 'product' => ['name' => 'Indvendig vask Forvogn']],
|
||||
['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']],
|
||||
['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']],
|
||||
];
|
||||
|
||||
$score = xlvask_automation_service::scoreItemMatchForAutomation($usageItems, $orderItems);
|
||||
|
||||
expect($score['source'])
|
||||
->toBe('fuzzy')
|
||||
->and($score['confidence'])->toBeGreaterThanOrEqual(0.70)
|
||||
->and($score['confidence'])->toBeLessThan(0.92)
|
||||
->and($score['reason'])->toContain('ekstra ydelser');
|
||||
});
|
||||
|
||||
it('does not score an order with only the primary product as a matching add-on attachment', function (): void {
|
||||
$usageItems = [
|
||||
['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']],
|
||||
['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']],
|
||||
['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']],
|
||||
];
|
||||
$orderItems = [
|
||||
['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']],
|
||||
['product_id' => 50, 'quantity' => 1, 'price' => 319, 'product' => ['name' => 'Indvendig vask Forvogn']],
|
||||
];
|
||||
|
||||
expect(xlvask_automation_service::scoreItemMatchForAutomation($usageItems, $orderItems)['confidence'])
|
||||
->toBe(0.0);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
it('exposes direct linked order metadata on XL Vask usage order rows', function (): void {
|
||||
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
|
||||
|
||||
expect($route)->not->toBeFalse();
|
||||
|
||||
$route = (string)$route;
|
||||
|
||||
expect($route)
|
||||
->toContain('$linked_order_ids_by_wash_id = []')
|
||||
->and($route)->toContain('array_key_exists($wash_id, $linked_order_ids_by_wash_id)')
|
||||
->and($route)->toContain('selectByWashId($wash_id)')
|
||||
->and($route)->toContain("'linked_order_id' => \$linked_order_id")
|
||||
->and($route)->toContain("'usage_log_id' => \$id");
|
||||
});
|
||||
@@ -1029,6 +1029,11 @@ trait db_object_t
|
||||
$this->table = $table;
|
||||
}
|
||||
|
||||
private static function dbObjectRedisCache(): ?object
|
||||
{
|
||||
return defined('redis') ? constant('redis') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache object
|
||||
* @param string $key The key to cache the object
|
||||
@@ -1045,7 +1050,7 @@ trait db_object_t
|
||||
$data = json_encode($data);
|
||||
}
|
||||
// Cache the data
|
||||
redis->set($this->table . '_' . $objectId . '_' . $key, $data);
|
||||
self::dbObjectRedisCache()?->set($this->table . '_' . $objectId . '_' . $key, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1060,9 +1065,9 @@ trait db_object_t
|
||||
return [];
|
||||
}
|
||||
|
||||
return redis->mget(array_map(function($objectId) use ($key) {
|
||||
return self::dbObjectRedisCache()?->mget(array_map(function($objectId) use ($key) {
|
||||
return $this->table . '_' . $objectId . '_' . $key;
|
||||
}, $objectIds));
|
||||
}, $objectIds)) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1081,7 +1086,7 @@ trait db_object_t
|
||||
$objectId = $this->id;
|
||||
}
|
||||
// Set the expiration time for the cached data
|
||||
redis->expire($this->table . '_' . $objectId . '_' . $key, $seconds);
|
||||
self::dbObjectRedisCache()?->expire($this->table . '_' . $objectId . '_' . $key, $seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1113,7 +1118,7 @@ trait db_object_t
|
||||
$objectId = $this->id;
|
||||
}
|
||||
// Get the cached data
|
||||
$data = redis->get($this->table . '_' . $objectId . '_' . $key) ?? null;
|
||||
$data = self::dbObjectRedisCache()?->get($this->table . '_' . $objectId . '_' . $key) ?? null;
|
||||
// if the data is a JSON string, convert it to an array
|
||||
if (is_string($data) && json_decode($data)) {
|
||||
$data = json_decode($data);
|
||||
@@ -1164,8 +1169,11 @@ trait db_object_t
|
||||
if (!$objectId) {
|
||||
$objectId = $this->id;
|
||||
}
|
||||
if (!defined('redis')) {
|
||||
return;
|
||||
}
|
||||
// Delete the cached data
|
||||
redis->delete($this->table . '_' . $objectId . '_' . $key);
|
||||
\constant('redis')->delete($this->table . '_' . $objectId . '_' . $key);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user