Add invoice_period_flag_ classes to manage invoice period flags with schema, services, and flag lifecycle methods
- Introduced `invoice_period_flag_schema_bootstrap` to initialize the schema for invoice period flags. - Added `invoice_period_flag_service` to handle manual and automatic flag creation, updates, filtering, and context resolution. - Implemented lifecycle methods such as `createManualFlag`, `updateAutomaticFlagStatus`, and `applyFlagsToPeriodTypes` for handling invoice period flags and their usage in processing periods. - Included context-specific resolution methods for efficient flag management in invoicing workflows.
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Ensures additive schema for superuser invoice-period flags.
|
||||
*/
|
||||
class invoice_period_flag_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS invoice_period_flags (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
source VARCHAR(32) NOT NULL,
|
||||
severity VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
target_type VARCHAR(64) NOT NULL,
|
||||
target_id BIGINT NOT NULL,
|
||||
field VARCHAR(64) NULL,
|
||||
customer_number INT NULL,
|
||||
order_id BIGINT NULL,
|
||||
order_item_id BIGINT NULL,
|
||||
invoice_collection_id BIGINT NULL,
|
||||
xlvask_usage_log_id BIGINT NULL,
|
||||
definition_key VARCHAR(128) NULL,
|
||||
fingerprint VARCHAR(191) NULL,
|
||||
reason TEXT NULL,
|
||||
status_reason TEXT NULL,
|
||||
context_json JSON NULL,
|
||||
created_by INT NULL,
|
||||
status_changed_by INT NULL,
|
||||
status_changed_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_invoice_period_flags_auto_fingerprint (source, fingerprint),
|
||||
KEY idx_invoice_period_flags_target (target_type, target_id, status),
|
||||
KEY idx_invoice_period_flags_customer_status (customer_number, status),
|
||||
KEY idx_invoice_period_flags_source_status (source, status),
|
||||
KEY idx_invoice_period_flags_order (order_id),
|
||||
KEY idx_invoice_period_flags_order_item (order_item_id),
|
||||
KEY idx_invoice_period_flags_invoice_collection (invoice_collection_id),
|
||||
KEY idx_invoice_period_flags_xlvask (xlvask_usage_log_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
products_schema_bootstrap::ensureTables();
|
||||
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Ensures additive schema for product metadata used outside the core product form.
|
||||
*/
|
||||
class products_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self::tableExists($db, 'products')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self::columnExists($db, 'products', 'max_quantity_per_order')) {
|
||||
$db->query(
|
||||
"ALTER TABLE products
|
||||
ADD COLUMN max_quantity_per_order INT NULL DEFAULT NULL
|
||||
AFTER order_priority"
|
||||
);
|
||||
}
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function tableExists(object $db, string $table): bool
|
||||
{
|
||||
$table = self::escapeIdentifier($table);
|
||||
$result = $db->query("SHOW TABLES LIKE '{$table}'");
|
||||
|
||||
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function columnExists(object $db, string $table, string $column): bool
|
||||
{
|
||||
$table = self::escapeIdentifier($table);
|
||||
$column = self::escapeIdentifier($column);
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
|
||||
|
||||
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function escapeIdentifier(string $value): string
|
||||
{
|
||||
return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Ensures additive schema for XL Vask usage-log review state.
|
||||
*/
|
||||
class xlvask_usage_logs_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self::tableExists($db, 'xlvask_usage_logs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
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::$initialized = true;
|
||||
}
|
||||
|
||||
private static function addColumnIfMissing(object $db, string $table, string $column, string $definition): void
|
||||
{
|
||||
if (!self::columnExists($db, $table, $column)) {
|
||||
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
|
||||
}
|
||||
}
|
||||
|
||||
private static function tableExists(object $db, string $table): bool
|
||||
{
|
||||
$table = self::escapeIdentifier($table);
|
||||
$result = $db->query("SHOW TABLES LIKE '{$table}'");
|
||||
|
||||
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function columnExists(object $db, string $table, string $column): bool
|
||||
{
|
||||
$table = self::escapeIdentifier($table);
|
||||
$column = self::escapeIdentifier($column);
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
|
||||
|
||||
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function escapeIdentifier(string $value): string
|
||||
{
|
||||
return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
|
||||
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
|
||||
header("Access-Control-Allow-Credentials: true");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number, *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
|
||||
header("Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
||||
}
|
||||
|
||||
// OPTIONS requests are preflight requests for CORS, we can just return a 200 OK response
|
||||
@@ -25,7 +25,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
|
||||
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
|
||||
header("Access-Control-Allow-Credentials: true");
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: *');
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(200);
|
||||
|
||||
@@ -141,6 +141,21 @@ class xlvask_usage_log extends xlvask_helper
|
||||
* @see xlvask_wash_item
|
||||
*/
|
||||
public array $WashItems;
|
||||
/**
|
||||
* Timestamp for invoice-period ignore state, when the wash has been ignored by a superuser.
|
||||
* @var string|int|null $ignored_at
|
||||
*/
|
||||
public string|int|null $ignored_at;
|
||||
/**
|
||||
* Superuser id for invoice-period ignore state.
|
||||
* @var int|string|null $ignored_by
|
||||
*/
|
||||
public int|string|null $ignored_by;
|
||||
/**
|
||||
* Optional reason for invoice-period ignore state.
|
||||
* @var string|int|null $ignored_reason
|
||||
*/
|
||||
public string|int|null $ignored_reason;
|
||||
|
||||
private string $default_string = 'DEFAULT_STRING_1';
|
||||
private string $default_int = 'DEFAULT_INT_1';
|
||||
@@ -194,6 +209,9 @@ class xlvask_usage_log extends xlvask_helper
|
||||
$this->CustomerGuid = $this->default_string;
|
||||
$this->VehicleId = $this->default_string;
|
||||
$this->WashItems = []; // Initialize as an empty array
|
||||
$this->ignored_at = $this->default_string_nullable;
|
||||
$this->ignored_by = $this->default_int_nullable;
|
||||
$this->ignored_reason = $this->default_string_nullable;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,6 +244,9 @@ class xlvask_usage_log extends xlvask_helper
|
||||
'FinishStatus' => $this->default_int,
|
||||
'CustomerGuid' => $this->default_string,
|
||||
'VehicleId' => $this->default_string,
|
||||
'ignored_at' => $this->default_string_nullable,
|
||||
'ignored_by' => $this->default_int_nullable,
|
||||
'ignored_reason' => $this->default_string_nullable,
|
||||
];
|
||||
foreach ( $data as $key => $value ) {
|
||||
if (property_exists(self::class, $key)) {
|
||||
@@ -363,7 +384,8 @@ class xlvask_usage_log extends xlvask_helper
|
||||
'WashId', 'CustomerId', 'Customer', 'VatNumber', 'Location',
|
||||
'Hall', 'HallId', 'StartTime', 'FinishTime', 'RegistrationNumber',
|
||||
'VehicleType', 'IdentificationType', 'IdentificationId', 'Info',
|
||||
'Updated', 'Prepaid', 'FinishStatus', 'CustomerGuid', 'VehicleId'
|
||||
'Updated', 'Prepaid', 'FinishStatus', 'CustomerGuid', 'VehicleId',
|
||||
'ignored_at', 'ignored_by', 'ignored_reason',
|
||||
];
|
||||
foreach ( $properties as $property ) {
|
||||
if ($this->isEmptyOrDefault($this->{$property})) {
|
||||
@@ -639,4 +661,4 @@ class xlvask_usage_log extends xlvask_helper
|
||||
// Check if the wash is prepaid
|
||||
return !empty($this->Prepaid) && $this->Prepaid === 1; // Assuming 1 indicates a prepaid wash
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use classes\products_schema_bootstrap;
|
||||
use traits\db_object_t;
|
||||
|
||||
class products_o extends db
|
||||
@@ -70,6 +71,11 @@ class products_o extends db
|
||||
* @var object_property $order_priority
|
||||
*/
|
||||
public object_property $order_priority;
|
||||
/**
|
||||
* Optional upper quantity limit for a product on one order.
|
||||
* @var object_property $max_quantity_per_order
|
||||
*/
|
||||
public object_property $max_quantity_per_order;
|
||||
/**
|
||||
* The timestamp of when the object was created
|
||||
* @var object_property
|
||||
@@ -83,6 +89,7 @@ class products_o extends db
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
products_schema_bootstrap::ensureTables();
|
||||
$this->setTable('products');
|
||||
}
|
||||
|
||||
@@ -118,6 +125,7 @@ class products_o extends db
|
||||
$this->is_wash = new object_property($this->table, $this->id, 'is_wash', 'bool', false);
|
||||
$this->display_in_booking_form = new object_property($this->table, $this->id, 'display_in_booking_form', 'bool', false);
|
||||
$this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false);
|
||||
$this->max_quantity_per_order = new object_property($this->table, $this->id, 'max_quantity_per_order', 'int', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'string', false);
|
||||
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'string', false);
|
||||
}
|
||||
@@ -210,6 +218,7 @@ class products_o extends db
|
||||
'is_wash' => (bool)$this->is_wash->value(),
|
||||
'display_in_booking_form' => (bool)$this->display_in_booking_form->value(),
|
||||
'order_priority' => (int)$this->order_priority->value(),
|
||||
'max_quantity_per_order' => $this->max_quantity_per_order->value() === null ? null : (int)$this->max_quantity_per_order->value(),
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
'updated_at' => (string)$this->updated_at->value(),
|
||||
];
|
||||
@@ -286,4 +295,4 @@ class products_o extends db
|
||||
self::requireSelected();
|
||||
return $this->id === 41;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace objects;
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use classes\xlvask;
|
||||
use classes\xlvask_usage_logs_schema_bootstrap;
|
||||
use Exception;
|
||||
use helpers\xlvask_customer;
|
||||
use helpers\xlvask_usage_log;
|
||||
@@ -35,9 +36,13 @@ class xlvask_usage_logs_o extends db
|
||||
public object_property $CustomerGuid;
|
||||
public object_property $VehicleId;
|
||||
public object_property $WashItems;
|
||||
public object_property $ignored_at;
|
||||
public object_property $ignored_by;
|
||||
public object_property $ignored_reason;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
||||
$this->setTable('xlvask_usage_logs');
|
||||
}
|
||||
|
||||
@@ -77,6 +82,9 @@ class xlvask_usage_logs_o extends db
|
||||
$this->CustomerGuid = new object_property($this->table, $this->id, 'CustomerGuid', 'string', false);
|
||||
$this->VehicleId = new object_property($this->table, $this->id, 'VehicleId', 'string', false);
|
||||
$this->WashItems = new object_property($this->table, $this->id, 'WashItems', 'string', false);
|
||||
$this->ignored_at = new object_property($this->table, $this->id, 'ignored_at', 'string', false);
|
||||
$this->ignored_by = new object_property($this->table, $this->id, 'ignored_by', 'int', false);
|
||||
$this->ignored_reason = new object_property($this->table, $this->id, 'ignored_reason', 'string', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
@@ -150,4 +158,4 @@ class xlvask_usage_logs_o extends db
|
||||
));
|
||||
return $vehicles;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use classes\authentication;
|
||||
use classes\economic_transfer_queue;
|
||||
use classes\economic_v2_distribution_service;
|
||||
use classes\economic_v2_versioning_service;
|
||||
use classes\invoice_period_flag_service;
|
||||
use classes\invoicing_period_utils;
|
||||
use classes\slack;
|
||||
use Exception;
|
||||
@@ -274,6 +275,88 @@ class InvoicingPeriodRoute
|
||||
},
|
||||
[
|
||||
'superuser_invoicing_period' => 'Get the invoicing period for superusers',
|
||||
'list_invoice_period_flags' => 'List invoice period flags in the period response',
|
||||
]
|
||||
);
|
||||
|
||||
$this->post('/superuser/invoicing/period/flags', function () {
|
||||
global $response;
|
||||
$this->requirePermission('add_invoice_period_flag');
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$flag = (new invoice_period_flag_service())->createManualFlag(
|
||||
$this->getParametersAsArray(),
|
||||
(int)$user->id
|
||||
);
|
||||
$response->success($flag);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$response->error($e->getMessage(), 400);
|
||||
} catch (\Throwable $e) {
|
||||
$response->error($e->getMessage(), 500);
|
||||
}
|
||||
},
|
||||
[
|
||||
'add_invoice_period_flag' => 'Add a manual invoice period flag',
|
||||
]
|
||||
);
|
||||
|
||||
$this->patch('/superuser/invoicing/period/flags/{id}/status', function () {
|
||||
global $response;
|
||||
$this->requirePermission('update_invoice_period_flag_status');
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$id = (int)($this->fromRoute('id') ?? 0);
|
||||
try {
|
||||
$flag = (new invoice_period_flag_service())->updateManualFlagStatus(
|
||||
$id,
|
||||
(string)$this->getParameter('status'),
|
||||
$this->isParametersSet(['reason']) ? (string)$this->getParameter('reason') : null,
|
||||
(int)$user->id
|
||||
);
|
||||
$response->success($flag);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$response->error($e->getMessage(), 400);
|
||||
} catch (\Throwable $e) {
|
||||
$response->error($e->getMessage(), 500);
|
||||
}
|
||||
},
|
||||
[
|
||||
'update_invoice_period_flag_status' => 'Update a manual invoice period flag status',
|
||||
]
|
||||
);
|
||||
|
||||
$this->post('/superuser/invoicing/period/flags/automatic/status', function () {
|
||||
global $response;
|
||||
$this->requirePermission('update_invoice_period_flag_status');
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$flag = (new invoice_period_flag_service())->updateAutomaticFlagStatus(
|
||||
$this->getParametersAsArray(),
|
||||
(int)$user->id
|
||||
);
|
||||
$response->success($flag);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$response->error($e->getMessage(), 400);
|
||||
} catch (\Throwable $e) {
|
||||
$response->error($e->getMessage(), 500);
|
||||
}
|
||||
},
|
||||
[
|
||||
'update_invoice_period_flag_status' => 'Update an automatic invoice period flag status',
|
||||
]
|
||||
);
|
||||
|
||||
@@ -1214,6 +1297,14 @@ class InvoicingPeriodRoute
|
||||
$draftOverlay['by_collection_id'] ?? [],
|
||||
$draftOverlay['by_customer_number'] ?? [],
|
||||
);
|
||||
$types = self::debugGetTime(function () use ($types, $dateFrom, $dateTo, $onlyCustomerNumbers) {
|
||||
return (new invoice_period_flag_service())->applyFlagsToPeriodTypes(
|
||||
$types,
|
||||
$dateFrom,
|
||||
$dateTo,
|
||||
$onlyCustomerNumbers
|
||||
);
|
||||
}, 'invoice_period_flags');
|
||||
return [
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
@@ -1410,6 +1501,13 @@ class InvoicingPeriodRoute
|
||||
'amount' => $transaction->temporary_net_amount, // Use the temporary net amount set earlier
|
||||
'booked' => $transaction->isBooked(true),
|
||||
'department_id' => $departmentId,
|
||||
'customer_number' => (int)$transaction->customer_id->value(),
|
||||
'reference' => (string)$transaction->reference->value(),
|
||||
'po' => (string)$transaction->po->value(),
|
||||
'notes' => (string)$transaction->notes->value(),
|
||||
'reg_1' => (string)$transaction->reg_1->value(),
|
||||
'reg_2' => (string)$transaction->reg_2->value(),
|
||||
'reg_3' => (string)$transaction->reg_3->value(),
|
||||
'excluded' => !$transaction->isIncludedInInvoicing(),
|
||||
'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null,
|
||||
'queue_status' => null,
|
||||
|
||||
@@ -120,6 +120,46 @@ class xlvaskUsageLogsRoute
|
||||
]
|
||||
);
|
||||
|
||||
$this->patch('/modules/xlvask/services/usage/orders/{id}/ignore', function () {
|
||||
global $db, $response;
|
||||
$this->requirePermission('ignore_xlvask_usage_order');
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
$reason = $this->isParametersSet(['reason']) ? trim((string)$this->getParameter('reason')) : null;
|
||||
$reasonSql = $reason === null || $reason === ''
|
||||
? 'NULL'
|
||||
: "'" . $db->escape_string($reason) . "'";
|
||||
|
||||
(new xlvask_usage_logs_o())->structure();
|
||||
$db->query(
|
||||
"UPDATE xlvask_usage_logs
|
||||
SET ignored_at = NOW(),
|
||||
ignored_by = " . (int)$user->id . ",
|
||||
ignored_reason = {$reasonSql}
|
||||
WHERE id = {$id}"
|
||||
);
|
||||
|
||||
$response->success([
|
||||
'id' => $id,
|
||||
'ignored' => true,
|
||||
]);
|
||||
},
|
||||
[
|
||||
'ignore_xlvask_usage_order' => 'Ignore an XL Vask usage log for invoice period flagging',
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/modules/xlvask/services/usage/orders/fast-link', function () {
|
||||
global $response;
|
||||
self::requireParameters([
|
||||
@@ -204,4 +244,4 @@ class xlvaskUsageLogsRoute
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ CREATE TABLE IF NOT EXISTS `products` (
|
||||
`is_wash` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`display_in_booking_form` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`order_priority` INT NOT NULL DEFAULT 0,
|
||||
`max_quantity_per_order` INT NULL,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` DATETIME NULL,
|
||||
@@ -172,6 +173,18 @@ CREATE TABLE IF NOT EXISTS `department_categories` (
|
||||
KEY `idx_department_categories_department_id` (`department_id`),
|
||||
KEY `idx_department_categories_category_id` (`category_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'product_department_prices' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `product_department_prices` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`department_id` INT NOT NULL,
|
||||
`product_id` INT NOT NULL,
|
||||
`price` INT NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_product_department_prices_lookup` (`department_id`, `product_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'collected_order_invoices' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `collected_order_invoices` (
|
||||
@@ -296,6 +309,38 @@ CREATE TABLE IF NOT EXISTS `xlvask_vehicle_types` (
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_xlvask_vehicle_types_product` (`product`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'xlvask_usage_logs' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `xlvask_usage_logs` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`WashId` VARCHAR(191) NOT NULL,
|
||||
`CustomerId` VARCHAR(191) NULL,
|
||||
`Customer` VARCHAR(255) NULL,
|
||||
`VatNumber` VARCHAR(64) NULL,
|
||||
`Location` VARCHAR(255) NULL,
|
||||
`Hall` VARCHAR(255) NULL,
|
||||
`HallId` VARCHAR(191) NULL,
|
||||
`StartTime` VARCHAR(64) NULL,
|
||||
`FinishTime` VARCHAR(64) NULL,
|
||||
`RegistrationNumber` VARCHAR(64) NULL,
|
||||
`VehicleType` VARCHAR(191) NULL,
|
||||
`IdentificationType` VARCHAR(191) NULL,
|
||||
`IdentificationId` VARCHAR(191) NULL,
|
||||
`Info` TEXT NULL,
|
||||
`Updated` VARCHAR(64) NULL,
|
||||
`Prepaid` VARCHAR(64) NULL,
|
||||
`FinishStatus` VARCHAR(64) NULL,
|
||||
`CustomerGuid` VARCHAR(191) NULL,
|
||||
`VehicleId` VARCHAR(191) NULL,
|
||||
`WashItems` LONGTEXT NULL,
|
||||
`ignored_at` DATETIME NULL,
|
||||
`ignored_by` INT NULL,
|
||||
`ignored_reason` TEXT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_xlvask_usage_logs_wash_id` (`WashId`),
|
||||
KEY `idx_xlvask_usage_logs_customer` (`CustomerId`),
|
||||
KEY `idx_xlvask_usage_logs_start` (`StartTime`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'customer_vehicles_addons' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `customer_vehicles_addons` (
|
||||
@@ -485,6 +530,36 @@ CREATE TABLE IF NOT EXISTS `object_attachments` (
|
||||
KEY `idx_object_attachments_lookup` (`object_type`, `object_id`),
|
||||
KEY `idx_object_attachments_deleted_at` (`deleted_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'invoice_period_flags' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `invoice_period_flags` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`source` VARCHAR(32) NOT NULL,
|
||||
`severity` VARCHAR(32) NOT NULL,
|
||||
`status` VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
`target_type` VARCHAR(64) NOT NULL,
|
||||
`target_id` BIGINT NOT NULL,
|
||||
`field` VARCHAR(64) NULL,
|
||||
`customer_number` INT NULL,
|
||||
`order_id` BIGINT NULL,
|
||||
`order_item_id` BIGINT NULL,
|
||||
`invoice_collection_id` BIGINT NULL,
|
||||
`xlvask_usage_log_id` BIGINT NULL,
|
||||
`definition_key` VARCHAR(128) NULL,
|
||||
`fingerprint` VARCHAR(191) NULL,
|
||||
`reason` TEXT NULL,
|
||||
`status_reason` TEXT NULL,
|
||||
`context_json` JSON NULL,
|
||||
`created_by` INT NULL,
|
||||
`status_changed_by` INT NULL,
|
||||
`status_changed_at` DATETIME NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_invoice_period_flags_auto_fingerprint` (`source`, `fingerprint`),
|
||||
KEY `idx_invoice_period_flags_target` (`target_type`, `target_id`, `status`),
|
||||
KEY `idx_invoice_period_flags_customer_status` (`customer_number`, `status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
<?php
|
||||
|
||||
app_require('classes/invoice_period_flag_service.php');
|
||||
|
||||
use classes\invoice_period_flag_service;
|
||||
|
||||
function invoice_period_flag_service_instance(): invoice_period_flag_service
|
||||
{
|
||||
$reflection = new ReflectionClass(invoice_period_flag_service::class);
|
||||
/** @var invoice_period_flag_service $service */
|
||||
$service = $reflection->newInstanceWithoutConstructor();
|
||||
return $service;
|
||||
}
|
||||
|
||||
function invoice_period_flag_service_invoke(string $method, array $args = []): mixed
|
||||
{
|
||||
$service = invoice_period_flag_service_instance();
|
||||
$reflection = new ReflectionClass(invoice_period_flag_service::class);
|
||||
$target = $reflection->getMethod($method);
|
||||
$target->setAccessible(true);
|
||||
return $target->invokeArgs($service, $args);
|
||||
}
|
||||
|
||||
it('builds deterministic automatic flag fingerprints and interactive price message parts', function (): void {
|
||||
$row = [
|
||||
'customer_number' => 424242,
|
||||
'customer_name' => 'Flagged Customer',
|
||||
'order_id' => 9001,
|
||||
'order_item_id' => 7001,
|
||||
'invoice_collection_id' => 3001,
|
||||
];
|
||||
$params = [
|
||||
'product' => 'Spot Free',
|
||||
'expected_price' => 81,
|
||||
'actual_price' => 99,
|
||||
];
|
||||
$context = [
|
||||
'department_id' => 1,
|
||||
'order_id' => 9001,
|
||||
'order_item_id' => 7001,
|
||||
];
|
||||
|
||||
$flag = invoice_period_flag_service_invoke('automaticFlag', [
|
||||
'price_mismatch',
|
||||
'order_item_field',
|
||||
7001,
|
||||
'price',
|
||||
$row,
|
||||
$params,
|
||||
$context,
|
||||
]);
|
||||
$sameFlag = invoice_period_flag_service_invoke('automaticFlag', [
|
||||
'price_mismatch',
|
||||
'order_item_field',
|
||||
7001,
|
||||
'price',
|
||||
$row,
|
||||
$params,
|
||||
$context,
|
||||
]);
|
||||
$changedPriceFlag = invoice_period_flag_service_invoke('automaticFlag', [
|
||||
'price_mismatch',
|
||||
'order_item_field',
|
||||
7001,
|
||||
'price',
|
||||
$row,
|
||||
[
|
||||
...$params,
|
||||
'actual_price' => 100,
|
||||
],
|
||||
$context,
|
||||
]);
|
||||
|
||||
expect($flag['fingerprint'])->toBe($sameFlag['fingerprint']);
|
||||
expect($flag['fingerprint'])->not->toBe($changedPriceFlag['fingerprint']);
|
||||
expect($flag['id'])->toBe('auto:' . $flag['fingerprint']);
|
||||
expect($flag['message_key'])->toBe('invoice_period.flags.automatic.price_mismatch');
|
||||
expect($flag['message'])->toBe('Spot Free product price differs from expected.');
|
||||
expect($flag['message_parts'])->toBe([
|
||||
['type' => 'order_item', 'text' => 'Spot Free'],
|
||||
['type' => 'text', 'text' => ' product price differs from '],
|
||||
['type' => 'expected_price', 'text' => 'expected'],
|
||||
['type' => 'text', 'text' => '.'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds interactive message parts for order and wash certificate warnings', function (): void {
|
||||
$row = [
|
||||
'customer_number' => 424242,
|
||||
'customer_name' => 'Flagged Customer',
|
||||
'order_id' => 9001,
|
||||
'order_item_id' => 7001,
|
||||
'invoice_collection_id' => 3001,
|
||||
];
|
||||
|
||||
$orderFlag = invoice_period_flag_service_invoke('automaticFlag', [
|
||||
'multiple_identical_primary_vehicle_items',
|
||||
'order',
|
||||
9001,
|
||||
null,
|
||||
$row,
|
||||
[],
|
||||
['department_id' => 1, 'order_id' => 9001],
|
||||
]);
|
||||
$washCertificateFlag = invoice_period_flag_service_invoke('automaticFlag', [
|
||||
'wash_certificate_item_without_certificate',
|
||||
'order_item',
|
||||
7001,
|
||||
null,
|
||||
$row,
|
||||
[],
|
||||
['department_id' => 1, 'order_id' => 9001, 'order_item_id' => 7001],
|
||||
]);
|
||||
|
||||
expect($orderFlag['message_parts'])->toBe([
|
||||
['type' => 'order', 'text' => 'Order'],
|
||||
['type' => 'text', 'text' => ' contains multiple identical primary vehicle items.'],
|
||||
]);
|
||||
expect($washCertificateFlag['message_parts'])->toBe([
|
||||
['type' => 'order_item', 'text' => 'Wash certificate item'],
|
||||
['type' => 'text', 'text' => ' is present without a wash certificate.'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes order item preview context for required order field warnings', function (): void {
|
||||
global $db;
|
||||
|
||||
$hadDb = array_key_exists('db', $GLOBALS);
|
||||
$previousDb = $GLOBALS['db'] ?? null;
|
||||
$db = new class {
|
||||
public array $queries = [];
|
||||
|
||||
public function query(string $sql): object|false
|
||||
{
|
||||
$this->queries[] = $sql;
|
||||
|
||||
if (str_contains($sql, 'FROM order_items')) {
|
||||
return $this->result([
|
||||
[
|
||||
'id' => 91,
|
||||
'order_id' => 61415,
|
||||
'product_id' => 3,
|
||||
'reference' => '',
|
||||
'notes' => '',
|
||||
'price' => 649,
|
||||
'quantity' => 1,
|
||||
'related_item_id' => 0,
|
||||
'product_name' => 'Forvogn',
|
||||
'product_base_price' => 649,
|
||||
],
|
||||
[
|
||||
'id' => 92,
|
||||
'order_id' => 61415,
|
||||
'product_id' => 4,
|
||||
'reference' => '',
|
||||
'notes' => '',
|
||||
'price' => 599,
|
||||
'quantity' => 1,
|
||||
'related_item_id' => 0,
|
||||
'product_name' => 'Trailer',
|
||||
'product_base_price' => 599,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function fetch_all(object $result): array
|
||||
{
|
||||
return $result->rows;
|
||||
}
|
||||
|
||||
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 {
|
||||
$row = [
|
||||
'customer_number' => 424242,
|
||||
'customer_name' => 'Flagged Customer',
|
||||
'order_id' => 61415,
|
||||
'order_item_id' => 91,
|
||||
'invoice_collection_id' => 3001,
|
||||
'department_id' => 5,
|
||||
'order_reference' => '',
|
||||
'order_po' => '',
|
||||
'reg_1' => 'EC21233',
|
||||
];
|
||||
|
||||
$flags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [
|
||||
[$row],
|
||||
[424242 => ['requiresReferenceNumber' => true, 'usePONumbers' => true]],
|
||||
]);
|
||||
$byDefinition = [];
|
||||
foreach ($flags as $flag) {
|
||||
$byDefinition[$flag['definition_key']] = $flag;
|
||||
}
|
||||
|
||||
expect($byDefinition['customer_rule_requires_reference'] ?? null)->not->toBeNull();
|
||||
expect($byDefinition['customer_rule_requires_po_number'] ?? null)->not->toBeNull();
|
||||
expect($byDefinition['customer_rule_requires_reference']['context']['order_items'])->toHaveCount(2);
|
||||
expect($byDefinition['customer_rule_requires_reference']['context']['order_items'][0]['product_name'])->toBe('Forvogn');
|
||||
expect($byDefinition['customer_rule_requires_po_number']['context']['order_items'][1]['product_name'])->toBe('Trailer');
|
||||
} 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,
|
||||
'customer_name' => 'Flagged Customer',
|
||||
'order_id' => 9001,
|
||||
'order_item_id' => 7001,
|
||||
'product_id' => 3,
|
||||
'product_name' => 'Forvogn',
|
||||
'category_name' => 'Vask',
|
||||
'is_wash' => 1,
|
||||
'related_item_id' => 0,
|
||||
'item_quantity' => 1,
|
||||
'max_quantity_per_order' => null,
|
||||
'safety_seal' => '',
|
||||
'order_created_at' => '2026-05-11 10:00:00',
|
||||
'reg_1' => 'AB12345',
|
||||
];
|
||||
|
||||
$flags = invoice_period_flag_service_invoke('detectAbnormalQuantities', [
|
||||
[$row, $row],
|
||||
'2026-05-11 00:00:00',
|
||||
'2026-05-11 23:59:59',
|
||||
]);
|
||||
|
||||
expect(array_column($flags, 'definition_key'))->not->toContain('multiple_identical_primary_vehicle_items');
|
||||
});
|
||||
|
||||
it('uses attached wash certificate documents instead of safety seal text for certificate presence', function (): void {
|
||||
$row = [
|
||||
'customer_number' => 424242,
|
||||
'customer_name' => 'Flagged Customer',
|
||||
'order_id' => 9001,
|
||||
'order_item_id' => 7001,
|
||||
'product_id' => 41,
|
||||
'product_name' => 'Vaskecertifikat - Safety Seal',
|
||||
'category_name' => 'Tillæg',
|
||||
'is_wash' => 0,
|
||||
'related_item_id' => 0,
|
||||
'item_quantity' => 1,
|
||||
'max_quantity_per_order' => null,
|
||||
'safety_seal' => '',
|
||||
'has_wash_certificate_attachment' => 1,
|
||||
'order_created_at' => '2026-05-11 10:00:00',
|
||||
'reg_1' => 'AB12345',
|
||||
];
|
||||
|
||||
$flags = invoice_period_flag_service_invoke('detectAbnormalQuantities', [
|
||||
[$row],
|
||||
'2026-05-11 00:00:00',
|
||||
'2026-05-11 23:59:59',
|
||||
]);
|
||||
|
||||
expect(array_column($flags, 'definition_key'))->not->toContain('wash_certificate_item_without_certificate');
|
||||
});
|
||||
|
||||
it('loads wash certificate attachment presence from order attachment content', function (): void {
|
||||
global $db;
|
||||
|
||||
$hadDb = array_key_exists('db', $GLOBALS);
|
||||
$previousDb = $GLOBALS['db'] ?? null;
|
||||
$db = new class {
|
||||
public array $queries = [];
|
||||
|
||||
public function escape_string(string $value): string
|
||||
{
|
||||
return addslashes($value);
|
||||
}
|
||||
|
||||
public function query(string $sql)
|
||||
{
|
||||
$this->queries[] = $sql;
|
||||
|
||||
if (str_contains($sql, 'SHOW TABLES LIKE')) {
|
||||
return $this->result([['table' => 'object_attachments']]);
|
||||
}
|
||||
|
||||
if (str_contains($sql, 'FROM object_attachments')) {
|
||||
return $this->result([
|
||||
['object_id' => 9001, 'content' => json_encode(['other' => 'WASH_CERTIFICATE'])],
|
||||
['object_id' => 9002, 'content' => json_encode(['other' => 'invoice'])],
|
||||
]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function result(array $rows): object
|
||||
{
|
||||
return new class($rows) {
|
||||
public int $num_rows;
|
||||
private 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 {
|
||||
$attached = invoice_period_flag_service_invoke('getWashCertificateAttachmentOrderIds', [[9001, 9002, 9001]]);
|
||||
|
||||
expect($attached)->toBe([9001 => true]);
|
||||
expect($db->queries[1])->toContain("object_type IN ('orders','`orders`')");
|
||||
expect($db->queries[1])->toContain('deleted_at IS NULL');
|
||||
} finally {
|
||||
if ($hadDb) {
|
||||
$db = $previousDb;
|
||||
} else {
|
||||
unset($GLOBALS['db']);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the highest customer-specific discount in expected price breakdowns', function (): void {
|
||||
$row = [
|
||||
'customer_number' => 0,
|
||||
'product_base_price' => 150,
|
||||
'department_price' => 100,
|
||||
'product_discount_percentage' => 5,
|
||||
'category_discount_percentage' => 12,
|
||||
'apply_category_discount' => 1,
|
||||
];
|
||||
|
||||
$expected = invoice_period_flag_service_invoke('calculateExpectedPrice', [$row]);
|
||||
$breakdown = invoice_period_flag_service_invoke('priceBreakdown', [$row, $expected]);
|
||||
|
||||
expect($expected)->toBe(88);
|
||||
expect($breakdown)->toMatchArray([
|
||||
'product_price' => 150,
|
||||
'department_price' => 100,
|
||||
'effective_base_price' => 100,
|
||||
'product_discount_percentage' => 5,
|
||||
'category_discount_percentage' => 12,
|
||||
'economic_customer_discount_percentage' => 0,
|
||||
'applied_discount_percentage' => 12,
|
||||
'expected_price' => 88,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not report a price mismatch when a product-specific discount makes the expected price zero', function (): void {
|
||||
$row = [
|
||||
'customer_number' => 35131752,
|
||||
'customer_name' => 'BHS Logistics A/S',
|
||||
'order_id' => 61359,
|
||||
'order_item_id' => 7701,
|
||||
'invoice_collection_id' => 16891,
|
||||
'department_id' => 1,
|
||||
'product_id' => 24,
|
||||
'product_name' => 'Spot Free- Lastbil',
|
||||
'product_base_price' => 39,
|
||||
'department_price' => null,
|
||||
'product_discount_percentage' => 100,
|
||||
'category_discount_percentage' => 0,
|
||||
'apply_category_discount' => 0,
|
||||
'item_price' => 0,
|
||||
'item_quantity' => 1,
|
||||
'item_include_in_invoice' => 1,
|
||||
];
|
||||
|
||||
$expected = invoice_period_flag_service_invoke('calculateExpectedPrice', [$row]);
|
||||
$breakdown = invoice_period_flag_service_invoke('priceBreakdown', [$row, $expected]);
|
||||
$flags = invoice_period_flag_service_invoke('detectPriceMismatches', [[$row]]);
|
||||
|
||||
expect($expected)->toBe(0);
|
||||
expect($breakdown)->toMatchArray([
|
||||
'product_price' => 39,
|
||||
'effective_base_price' => 39,
|
||||
'product_discount_percentage' => 100,
|
||||
'applied_discount_percentage' => 100,
|
||||
'expected_price' => 0,
|
||||
]);
|
||||
expect($flags)->toBe([]);
|
||||
});
|
||||
|
||||
it('sorts manual flags before automatic warnings and preserves legacy circle indicators without flags', function (): void {
|
||||
$manual = [
|
||||
'id' => 12,
|
||||
'source' => 'manual',
|
||||
'status' => 'active',
|
||||
'created_at' => '2026-05-11 10:00:00',
|
||||
];
|
||||
$automatic = [
|
||||
'id' => 'auto:abc',
|
||||
'source' => 'automatic',
|
||||
'status' => 'active',
|
||||
'fingerprint' => 'abc',
|
||||
];
|
||||
$resolvedManual = [
|
||||
'id' => 13,
|
||||
'source' => 'manual',
|
||||
'status' => 'resolved',
|
||||
'created_at' => '2026-05-11 11:00:00',
|
||||
];
|
||||
$falsePositiveAutomatic = [
|
||||
'id' => 'auto:def',
|
||||
'source' => 'automatic',
|
||||
'status' => 'false_positive',
|
||||
'fingerprint' => 'def',
|
||||
];
|
||||
|
||||
$flags = [$automatic, $manual];
|
||||
usort($flags, static fn(array $a, array $b): int => invoice_period_flag_service_invoke('sortFlags', [$a, $b]));
|
||||
|
||||
expect($flags[0]['source'])->toBe('manual');
|
||||
expect(invoice_period_flag_service_invoke('countFlags', [$flags]))->toBe([
|
||||
'manual' => 1,
|
||||
'automatic' => 1,
|
||||
'total' => 2,
|
||||
]);
|
||||
expect(invoice_period_flag_service_invoke('countFlags', [[$resolvedManual, $falsePositiveAutomatic]]))->toBe([
|
||||
'manual' => 0,
|
||||
'automatic' => 0,
|
||||
'total' => 0,
|
||||
]);
|
||||
expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [['requires_action' => true], [$manual]]))
|
||||
->toBe('flag_red');
|
||||
expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [['requires_action' => true], [$automatic]]))
|
||||
->toBe('flag_yellow');
|
||||
expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [
|
||||
['requires_action' => true],
|
||||
[$resolvedManual, $falsePositiveAutomatic],
|
||||
]))->toBe('circle_red');
|
||||
expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [['requires_action' => true], []]))
|
||||
->toBe('circle_red');
|
||||
expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [['requires_action' => false], []]))
|
||||
->toBe('circle_green');
|
||||
expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [
|
||||
['requires_action' => false, 'draft' => ['is_action_blocked' => true]],
|
||||
[],
|
||||
]))->toBe('circle_yellow');
|
||||
});
|
||||
|
||||
it('formats stored manual flags with the creating superuser display name', function (): void {
|
||||
global $db;
|
||||
|
||||
$hadDb = array_key_exists('db', $GLOBALS);
|
||||
$previousDb = $GLOBALS['db'] ?? null;
|
||||
$db = new class {
|
||||
public function query(string $sql): object|false
|
||||
{
|
||||
if (str_contains($sql, 'SELECT display_name FROM users WHERE id = 42')) {
|
||||
return new class {
|
||||
public int $num_rows = 1;
|
||||
|
||||
public function fetch_assoc(): array
|
||||
{
|
||||
return ['display_name' => 'Jeppe'];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
$flag = invoice_period_flag_service_invoke('formatStoredFlag', [[
|
||||
'id' => 12,
|
||||
'source' => 'manual',
|
||||
'severity' => 'red',
|
||||
'status' => 'active',
|
||||
'target_type' => 'customer',
|
||||
'target_id' => 424242,
|
||||
'field' => null,
|
||||
'customer_number' => 424242,
|
||||
'order_id' => null,
|
||||
'order_item_id' => null,
|
||||
'invoice_collection_id' => null,
|
||||
'xlvask_usage_log_id' => null,
|
||||
'definition_key' => null,
|
||||
'fingerprint' => null,
|
||||
'reason' => 'Manual review',
|
||||
'status_reason' => null,
|
||||
'context_json' => null,
|
||||
'created_by' => 42,
|
||||
'status_changed_by' => null,
|
||||
'status_changed_at' => null,
|
||||
'created_at' => '2026-05-11 10:00:00',
|
||||
'updated_at' => '2026-05-11 10:00:00',
|
||||
]]);
|
||||
|
||||
expect($flag['created_by'])->toBe(42);
|
||||
expect($flag['created_by_name'])->toBe('Jeppe');
|
||||
} finally {
|
||||
if ($hadDb) {
|
||||
$db = $previousDb;
|
||||
} else {
|
||||
unset($GLOBALS['db']);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('validates supported manual flag fields by target type', function (): void {
|
||||
expect(invoice_period_flag_service_invoke('normalizeField', ['order_field', 'reference']))->toBe('reference');
|
||||
expect(invoice_period_flag_service_invoke('normalizeField', ['order_item_field', 'price']))->toBe('price');
|
||||
expect(invoice_period_flag_service_invoke('normalizeField', ['customer', '']))->toBeNull();
|
||||
|
||||
invoice_period_flag_service_invoke('normalizeField', ['order_field', 'price']);
|
||||
})->throws(InvalidArgumentException::class, 'Invalid order flag field.');
|
||||
|
||||
it('wires invoice period flag routes with explicit list create and update permissions', function (): void {
|
||||
$content = file_get_contents(app_path('routes/InvoicingPeriodRoute.php'));
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
$content = (string)$content;
|
||||
|
||||
expect($content)->toContain("\$this->get('/superuser/invoicing/period'");
|
||||
expect($content)->toContain("'list_invoice_period_flags' => 'List invoice period flags in the period response'");
|
||||
expect($content)->toContain("\$this->post('/superuser/invoicing/period/flags'");
|
||||
expect($content)->toContain("\$this->requirePermission('add_invoice_period_flag')");
|
||||
expect($content)->toContain("\$this->patch('/superuser/invoicing/period/flags/{id}/status'");
|
||||
expect($content)->toContain("\$this->post('/superuser/invoicing/period/flags/automatic/status'");
|
||||
expect($content)->toContain("\$this->requirePermission('update_invoice_period_flag_status')");
|
||||
});
|
||||
|
||||
it('uses the users display_name column in detector queries', function (): void {
|
||||
$content = file_get_contents(app_path('classes/invoice_period_flag_service.php'));
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
$content = (string)$content;
|
||||
|
||||
expect($content)->toContain('u.display_name AS customer_name');
|
||||
expect($content)->toContain('COALESCE(u.display_name, x.Customer) AS customer_name');
|
||||
expect($content)->not->toContain('u.name');
|
||||
});
|
||||
|
||||
it('aggregates customer price overrides by customer number for price mismatch detection', function (): void {
|
||||
$content = file_get_contents(app_path('classes/invoice_period_flag_service.php'));
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
$content = (string)$content;
|
||||
|
||||
expect($content)->toContain('MAX(po.percentage) AS percentage');
|
||||
expect($content)->toContain('GROUP BY discount_user.customer_number, po.product_or_category_id');
|
||||
expect($content)->toContain('product_discount.customer_number = o.customer_id');
|
||||
expect($content)->toContain('category_discount.customer_number = o.customer_id');
|
||||
expect($content)->not->toContain('po_product.user_id = u.id');
|
||||
});
|
||||
|
||||
it('guards optional customer vehicle deleted_at filtering behind a column check', function (): void {
|
||||
$content = file_get_contents(app_path('classes/invoice_period_flag_service.php'));
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
$content = (string)$content;
|
||||
|
||||
expect($content)->toContain("\$this->columnExists('customer_vehicles', 'deleted_at')");
|
||||
expect($content)->toContain('$deletedFilter');
|
||||
expect($content)->toContain('{$deletedFilter}');
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use helpers\xlvask_usage_log;
|
||||
|
||||
it('accepts persisted ignore metadata from xlvask usage log rows', function (): void {
|
||||
$log = new xlvask_usage_log();
|
||||
|
||||
$log->setProperties([
|
||||
'WashId' => 'wash-ignored-1',
|
||||
'CustomerId' => '35131752',
|
||||
'Customer' => 'BHS Logistics A/S',
|
||||
'VatNumber' => '35255156',
|
||||
'Location' => 'Aarhus C',
|
||||
'Hall' => 'AarhusC_1',
|
||||
'HallId' => 'hall-1',
|
||||
'StartTime' => '2026-05-11T08:23:23.000',
|
||||
'FinishTime' => '2026-05-11T08:31:23.000',
|
||||
'RegistrationNumber' => 'EX4451',
|
||||
'VehicleType' => 'Truck',
|
||||
'IdentificationType' => 'LPR',
|
||||
'IdentificationId' => 'EX4451',
|
||||
'Info' => 'EX4451',
|
||||
'Updated' => null,
|
||||
'Prepaid' => false,
|
||||
'FinishStatus' => 1,
|
||||
'CustomerGuid' => 'customer-guid-1',
|
||||
'VehicleId' => 'vehicle-id-1',
|
||||
'WashItems' => [],
|
||||
'ignored_at' => '2026-05-11 09:00:00',
|
||||
'ignored_by' => '42',
|
||||
'ignored_reason' => 'Already handled in period review',
|
||||
]);
|
||||
|
||||
expect($log->ignored_at)->toBe('2026-05-11 09:00:00')
|
||||
->and($log->ignored_by)->toBe(42)
|
||||
->and($log->ignored_reason)->toBe('Already handled in period review');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user