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:
Jeppe Bundgaard
2026-05-11 21:34:57 +02:00
parent 6d4066be1c
commit c1b66a81cc
13 changed files with 2777 additions and 7 deletions
@@ -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);
}
}
+2 -2
View File
@@ -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
}
}
}
+10 -1
View File
@@ -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');
});