From c1b66a81cc00fcf047a0153282ca308dc07706ec Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 11 May 2026 21:34:57 +0200 Subject: [PATCH] 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. --- .../invoice_period_flag_schema_bootstrap.php | 64 + .../classes/invoice_period_flag_service.php | 1691 +++++++++++++++++ .../app/classes/products_schema_bootstrap.php | 68 + .../xlvask_usage_logs_schema_bootstrap.php | 71 + services/nginx/app/index.php | 4 +- .../xlvask/helpers/xlvask_usage_log.php | 26 +- services/nginx/app/objects/products_o.php | 11 +- .../nginx/app/objects/xlvask_usage_logs_o.php | 10 +- .../nginx/app/routes/InvoicingPeriodRoute.php | 98 + .../nginx/app/routes/xlvaskUsageLogsRoute.php | 42 +- .../tests/Support/Api/ApiSchemaBootstrap.php | 75 + .../InvoicePeriodFlagServiceTest.php | 586 ++++++ .../Unit/XLVask/XLVaskUsageLogHelperTest.php | 38 + 13 files changed, 2777 insertions(+), 7 deletions(-) create mode 100644 services/nginx/app/classes/invoice_period_flag_schema_bootstrap.php create mode 100644 services/nginx/app/classes/invoice_period_flag_service.php create mode 100644 services/nginx/app/classes/products_schema_bootstrap.php create mode 100644 services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php create mode 100644 services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php create mode 100644 services/nginx/app/tests/Unit/XLVask/XLVaskUsageLogHelperTest.php diff --git a/services/nginx/app/classes/invoice_period_flag_schema_bootstrap.php b/services/nginx/app/classes/invoice_period_flag_schema_bootstrap.php new file mode 100644 index 00000000..ee25fd5f --- /dev/null +++ b/services/nginx/app/classes/invoice_period_flag_schema_bootstrap.php @@ -0,0 +1,64 @@ +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; + } +} diff --git a/services/nginx/app/classes/invoice_period_flag_service.php b/services/nginx/app/classes/invoice_period_flag_service.php new file mode 100644 index 00000000..783c6c3f --- /dev/null +++ b/services/nginx/app/classes/invoice_period_flag_service.php @@ -0,0 +1,1691 @@ +normalizeField($targetType, $payload['field'] ?? null); + $reason = trim((string)($payload['reason'] ?? '')); + + if (!in_array($targetType, self::VALID_TARGETS, true)) { + throw new \InvalidArgumentException('Invalid flag target type.'); + } + if ($targetId < 1) { + throw new \InvalidArgumentException('Flag target id is required.'); + } + if ($reason === '') { + throw new \InvalidArgumentException('Manual flag reason is required.'); + } + + $context = $this->resolveTargetContext($targetType, $targetId, $field); + $contextJson = $this->jsonSql($context); + + $sql = sprintf( + "INSERT INTO invoice_period_flags + (source, severity, status, target_type, target_id, field, customer_number, order_id, order_item_id, + invoice_collection_id, xlvask_usage_log_id, reason, context_json, created_by) + VALUES + ('%s', 'red', 'active', '%s', %d, %s, %s, %s, %s, %s, %s, '%s', %s, %s)", + self::SOURCE_MANUAL, + $db->escape_string($targetType), + $targetId, + $this->nullableStringSql($field), + $this->nullableIntSql($context['customer_number'] ?? null), + $this->nullableIntSql($context['order_id'] ?? null), + $this->nullableIntSql($context['order_item_id'] ?? null), + $this->nullableIntSql($context['invoice_collection_id'] ?? null), + $this->nullableIntSql($context['xlvask_usage_log_id'] ?? null), + $db->escape_string($reason), + $contextJson, + $this->nullableIntSql($userId > 0 ? $userId : null) + ); + $db->query($sql); + + return $this->getStoredFlag((int)$db->insert_id()); + } + + public function updateManualFlagStatus(int $id, string $status, ?string $reason, int $userId): array + { + global $db; + + $status = trim($status); + if (!in_array($status, self::VALID_STATUSES, true) || $status === self::STATUS_ACTIVE) { + throw new \InvalidArgumentException('Invalid manual flag status.'); + } + if ($id < 1) { + throw new \InvalidArgumentException('Flag id is required.'); + } + + $sql = sprintf( + "UPDATE invoice_period_flags + SET status = '%s', + status_reason = %s, + status_changed_by = %s, + status_changed_at = NOW() + WHERE id = %d AND source = '%s'", + $db->escape_string($status), + $this->nullableStringSql($reason), + $this->nullableIntSql($userId > 0 ? $userId : null), + $id, + self::SOURCE_MANUAL + ); + $db->query($sql); + + return $this->getStoredFlag($id); + } + + public function updateAutomaticFlagStatus(array $payload, int $userId): array + { + global $db; + + $fingerprint = trim((string)($payload['fingerprint'] ?? '')); + $status = trim((string)($payload['status'] ?? '')); + $targetType = trim((string)($payload['target_type'] ?? '')); + $targetId = (int)($payload['target_id'] ?? 0); + $field = $this->normalizeField($targetType, $payload['field'] ?? null); + $definitionKey = trim((string)($payload['definition_key'] ?? '')); + $reason = isset($payload['reason']) ? trim((string)$payload['reason']) : null; + + if ($fingerprint === '') { + throw new \InvalidArgumentException('Automatic flag fingerprint is required.'); + } + if (!in_array($status, self::VALID_STATUSES, true) || $status === self::STATUS_ACTIVE) { + throw new \InvalidArgumentException('Invalid automatic flag status.'); + } + if (!in_array($targetType, self::VALID_TARGETS, true)) { + throw new \InvalidArgumentException('Invalid automatic flag target type.'); + } + if ($targetId < 1) { + throw new \InvalidArgumentException('Automatic flag target id is required.'); + } + + $context = $this->resolveTargetContext($targetType, $targetId, $field); + $contextJson = $this->jsonSql($context); + + $sql = sprintf( + "INSERT INTO invoice_period_flags + (source, severity, status, target_type, target_id, field, customer_number, order_id, order_item_id, + invoice_collection_id, xlvask_usage_log_id, definition_key, fingerprint, status_reason, context_json, + created_by, status_changed_by, status_changed_at) + VALUES + ('%s', 'yellow', '%s', '%s', %d, %s, %s, %s, %s, %s, %s, %s, '%s', %s, %s, %s, %s, NOW()) + ON DUPLICATE KEY UPDATE + status = VALUES(status), + target_type = VALUES(target_type), + target_id = VALUES(target_id), + field = VALUES(field), + customer_number = VALUES(customer_number), + order_id = VALUES(order_id), + order_item_id = VALUES(order_item_id), + invoice_collection_id = VALUES(invoice_collection_id), + xlvask_usage_log_id = VALUES(xlvask_usage_log_id), + definition_key = VALUES(definition_key), + status_reason = VALUES(status_reason), + context_json = VALUES(context_json), + status_changed_by = VALUES(status_changed_by), + status_changed_at = NOW()", + self::SOURCE_AUTOMATIC, + $db->escape_string($status), + $db->escape_string($targetType), + $targetId, + $this->nullableStringSql($field), + $this->nullableIntSql($context['customer_number'] ?? null), + $this->nullableIntSql($context['order_id'] ?? null), + $this->nullableIntSql($context['order_item_id'] ?? null), + $this->nullableIntSql($context['invoice_collection_id'] ?? null), + $this->nullableIntSql($context['xlvask_usage_log_id'] ?? null), + $this->nullableStringSql($definitionKey !== '' ? $definitionKey : null), + $db->escape_string($fingerprint), + $this->nullableStringSql($reason), + $contextJson, + $this->nullableIntSql($userId > 0 ? $userId : null), + $this->nullableIntSql($userId > 0 ? $userId : null) + ); + $db->query($sql); + + return $this->getStoredAutomaticFlag($fingerprint); + } + + /** + * @param array>> $types + * @param int[]|null $onlyCustomerNumbers + * @return array>> + */ + public function applyFlagsToPeriodTypes(array $types, string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers = null): array + { + $context = $this->buildPeriodContext($types, $dateFrom, $dateTo, $onlyCustomerNumbers); + $manualFlags = $this->getManualFlagsForPeriod($context, $dateFrom, $dateTo, $onlyCustomerNumbers); + $automaticFlags = $this->getAutomaticFlagsForPeriod($dateFrom, $dateTo, $onlyCustomerNumbers); + $automaticFlags = $this->filterSuppressedAutomaticFlags($automaticFlags); + $allFlags = array_merge($manualFlags, $automaticFlags); + + $types = $this->ensureFlagOnlyCustomers($types, $allFlags); + $flagsByCustomerNumber = []; + foreach ($allFlags as $flag) { + $customerNumber = (int)($flag['customer_number'] ?? 0); + if ($customerNumber < 1) { + continue; + } + $flagsByCustomerNumber[$customerNumber][] = $flag; + } + + foreach ($types as $typeName => $customers) { + foreach ($customers as $index => $customer) { + $customerNumber = (int)($customer['customer_number'] ?? 0); + $flags = $flagsByCustomerNumber[$customerNumber] ?? []; + usort($flags, [$this, 'sortFlags']); + $types[$typeName][$index]['flags'] = array_values($flags); + $types[$typeName][$index]['flag_counts'] = $this->countFlags($flags); + $types[$typeName][$index]['status_indicator'] = $this->statusIndicatorForCustomer( + $types[$typeName][$index], + $flags + ); + } + } + + return $types; + } + + private function getStoredFlag(int $id): array + { + global $db; + + $result = $db->query("SELECT * FROM invoice_period_flags WHERE id = {$id} LIMIT 1"); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : null; + if (!$row) { + throw new \RuntimeException('Flag not found.'); + } + + return $this->formatStoredFlag($row); + } + + private function getStoredAutomaticFlag(string $fingerprint): array + { + global $db; + + $fingerprint = $db->escape_string($fingerprint); + $result = $db->query( + "SELECT * FROM invoice_period_flags + WHERE source = '" . self::SOURCE_AUTOMATIC . "' AND fingerprint = '{$fingerprint}' + LIMIT 1" + ); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : null; + if (!$row) { + throw new \RuntimeException('Automatic flag decision not found.'); + } + + return $this->formatStoredFlag($row); + } + + private function formatStoredFlag(array $row): array + { + $context = []; + if (!empty($row['context_json'])) { + $decoded = json_decode((string)$row['context_json'], true); + $context = is_array($decoded) ? $decoded : []; + } + + return [ + 'id' => (int)$row['id'], + 'source' => (string)$row['source'], + 'severity' => (string)$row['severity'], + 'status' => (string)$row['status'], + 'target_type' => (string)$row['target_type'], + 'target_id' => (int)$row['target_id'], + 'field' => $row['field'], + 'customer_number' => $row['customer_number'] === null ? null : (int)$row['customer_number'], + 'order_id' => $row['order_id'] === null ? null : (int)$row['order_id'], + 'order_item_id' => $row['order_item_id'] === null ? null : (int)$row['order_item_id'], + 'invoice_collection_id' => $row['invoice_collection_id'] === null ? null : (int)$row['invoice_collection_id'], + 'xlvask_usage_log_id' => $row['xlvask_usage_log_id'] === null ? null : (int)$row['xlvask_usage_log_id'], + 'definition_key' => $row['definition_key'], + 'fingerprint' => $row['fingerprint'], + 'reason' => $row['reason'], + 'status_reason' => $row['status_reason'], + 'context' => $context, + 'created_by' => $row['created_by'] === null ? null : (int)$row['created_by'], + 'created_by_name' => $this->getUserDisplayName($row['created_by'] === null ? null : (int)$row['created_by']), + 'status_changed_by' => $row['status_changed_by'] === null ? null : (int)$row['status_changed_by'], + 'status_changed_at' => $row['status_changed_at'], + 'created_at' => $row['created_at'], + 'updated_at' => $row['updated_at'], + 'message' => (string)($row['reason'] ?? ''), + ]; + } + + private function buildPeriodContext(array $types, string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array + { + $customerNumbers = []; + $orderIds = []; + $invoiceCollectionIds = []; + $orderToCustomer = []; + $invoiceCollectionToCustomer = []; + + foreach ($types as $customers) { + foreach ($customers as $customer) { + $customerNumber = (int)($customer['customer_number'] ?? 0); + if ($customerNumber > 0) { + $customerNumbers[$customerNumber] = true; + } + foreach (($customer['transactions'] ?? []) as $transaction) { + $orderId = (int)($transaction['id'] ?? 0); + if ($orderId > 0) { + $orderIds[$orderId] = true; + $orderToCustomer[$orderId] = $customerNumber; + } + $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId > 0) { + $invoiceCollectionIds[$invoiceCollectionId] = true; + $invoiceCollectionToCustomer[$invoiceCollectionId] = $customerNumber; + } + } + } + } + + $orderItemToOrder = $this->getOrderItemToOrderMap(array_keys($orderIds)); + $xlvaskPeriodRows = $this->getXlVaskPeriodRows($dateFrom, $dateTo, $onlyCustomerNumbers); + + return [ + 'customer_numbers' => array_keys($customerNumbers), + 'order_ids' => array_keys($orderIds), + 'invoice_collection_ids' => array_keys($invoiceCollectionIds), + 'order_to_customer' => $orderToCustomer, + 'invoice_collection_to_customer' => $invoiceCollectionToCustomer, + 'order_item_to_order' => $orderItemToOrder, + 'xlvask_period_rows' => $xlvaskPeriodRows, + ]; + } + + private function getManualFlagsForPeriod(array $context, string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array + { + global $db; + + $result = $db->query( + "SELECT * FROM invoice_period_flags + WHERE source = '" . self::SOURCE_MANUAL . "' + AND status = '" . self::STATUS_ACTIVE . "' + ORDER BY created_at ASC, id ASC" + ); + + if (!$result || $result->num_rows === 0) { + return []; + } + + $allowedCustomerNumbers = $onlyCustomerNumbers !== null + ? array_fill_keys(array_map('intval', $onlyCustomerNumbers), true) + : null; + $periodCustomerNumbers = array_fill_keys(array_map('intval', $context['customer_numbers']), true); + $orderToCustomer = $context['order_to_customer']; + $invoiceCollectionToCustomer = $context['invoice_collection_to_customer']; + $orderItemToOrder = $context['order_item_to_order']; + $xlvaskRows = []; + foreach ($context['xlvask_period_rows'] as $row) { + $xlvaskRows[(int)$row['id']] = $row; + } + + $flags = []; + while ($row = $result->fetch_assoc()) { + $targetType = (string)$row['target_type']; + $targetId = (int)$row['target_id']; + $customerNumber = null; + + if ($targetType === 'customer') { + if (!isset($periodCustomerNumbers[$targetId])) { + continue; + } + $customerNumber = $targetId; + } elseif ($targetType === 'order' || $targetType === 'order_field') { + if (!isset($orderToCustomer[$targetId])) { + continue; + } + $customerNumber = (int)$orderToCustomer[$targetId]; + } elseif ($targetType === 'order_item' || $targetType === 'order_item_field') { + $orderId = (int)($orderItemToOrder[$targetId] ?? 0); + if ($orderId < 1 || !isset($orderToCustomer[$orderId])) { + continue; + } + $customerNumber = (int)$orderToCustomer[$orderId]; + } elseif ($targetType === 'collected_order_invoice') { + if (!isset($invoiceCollectionToCustomer[$targetId])) { + continue; + } + $customerNumber = (int)$invoiceCollectionToCustomer[$targetId]; + } elseif ($targetType === 'xlvask_usage_log') { + if (!isset($xlvaskRows[$targetId])) { + continue; + } + $customerNumber = (int)($xlvaskRows[$targetId]['customer_number'] ?? 0); + } + + if ($customerNumber === null || $customerNumber < 1) { + continue; + } + if ($allowedCustomerNumbers !== null && !isset($allowedCustomerNumbers[$customerNumber])) { + continue; + } + + $flag = $this->formatStoredFlag($row); + $flag['customer_number'] = $customerNumber; + $flag['message'] = (string)$flag['reason']; + $flags[] = $flag; + } + + return $flags; + } + + private function getAutomaticFlagsForPeriod(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array + { + $rows = $this->getPeriodOrderItemRows($dateFrom, $dateTo, $onlyCustomerNumbers); + $attributes = $this->getCustomerAttributes($onlyCustomerNumbers); + + return array_merge( + $this->detectCustomerRuleViolations($rows, $attributes), + $this->detectPriceMismatches($rows), + $this->detectAbnormalQuantities($rows, $dateFrom, $dateTo), + $this->detectVehicleTypeMismatches($rows, $dateFrom), + $this->detectMissingXlVaskLinks($dateFrom, $dateTo, $onlyCustomerNumbers) + ); + } + + private function filterSuppressedAutomaticFlags(array $flags): array + { + global $db; + + $fingerprints = array_values(array_unique(array_filter(array_map( + static fn(array $flag): string => (string)($flag['fingerprint'] ?? ''), + $flags + )))); + + if (empty($fingerprints)) { + return $flags; + } + + $in = implode(',', array_map(static function (string $fingerprint) use ($db): string { + return "'" . $db->escape_string($fingerprint) . "'"; + }, $fingerprints)); + + $suppressed = []; + $result = $db->query( + "SELECT fingerprint FROM invoice_period_flags + WHERE source = '" . self::SOURCE_AUTOMATIC . "' + AND status IN ('resolved', 'ignored', 'false_positive') + AND fingerprint IN ({$in})" + ); + if ($result) { + while ($row = $result->fetch_assoc()) { + $suppressed[(string)$row['fingerprint']] = true; + } + } + + return array_values(array_filter($flags, static function (array $flag) use ($suppressed): bool { + return !isset($suppressed[(string)($flag['fingerprint'] ?? '')]); + })); + } + + private function getPeriodOrderItemRows(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array + { + global $db; + + $customerFilter = $this->customerFilterSql('o.customer_id', $onlyCustomerNumbers); + $dateFrom = $db->escape_string($dateFrom); + $dateTo = $db->escape_string($dateTo); + + $sql = " + SELECT + o.id AS order_id, + o.customer_id AS customer_number, + u.id AS user_id, + u.display_name AS customer_name, + o.reference AS order_reference, + o.po AS order_po, + o.notes AS order_notes, + o.department_id, + o.reg_1, + o.invoice_collection_id, + o.wash_id, + o.safety_seal, + o.created_at AS order_created_at, + oi.id AS order_item_id, + oi.product_id, + oi.reference AS item_reference, + oi.notes AS item_notes, + oi.price AS item_price, + oi.quantity AS item_quantity, + oi.related_item_id, + oi.include_in_invoice AS item_include_in_invoice, + p.name AS product_name, + p.price AS product_base_price, + p.category AS product_category, + p.apply_category_discount, + p.is_wash, + p.subscription_allowed, + p.max_quantity_per_order, + c.name AS category_name, + pdp.price AS department_price, + product_discount.percentage AS product_discount_percentage, + category_discount.percentage AS category_discount_percentage + FROM orders o + LEFT JOIN ( + SELECT customer_number, MIN(id) AS id, MAX(display_name) AS display_name + FROM users + WHERE customer_number IS NOT NULL AND customer_number <> 0 + GROUP BY customer_number + ) u ON u.customer_number = o.customer_id + LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '') + LEFT JOIN products p ON p.id = oi.product_id + LEFT JOIN categories c ON c.id = p.category + LEFT JOIN product_department_prices pdp ON pdp.department_id = o.department_id AND pdp.product_id = p.id + LEFT JOIN ( + SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage + FROM price_overrides po + INNER JOIN users discount_user ON discount_user.id = po.user_id + WHERE po.is_category = 0 + GROUP BY discount_user.customer_number, po.product_or_category_id + ) product_discount + ON product_discount.customer_number = o.customer_id + AND product_discount.product_or_category_id = p.id + LEFT JOIN ( + SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage + FROM price_overrides po + INNER JOIN users discount_user ON discount_user.id = po.user_id + WHERE po.is_category = 1 + GROUP BY discount_user.customer_number, po.product_or_category_id + ) category_discount + ON category_discount.customer_number = o.customer_id + AND category_discount.product_or_category_id = p.category + WHERE o.created_at BETWEEN '{$dateFrom}' AND '{$dateTo}' + AND o.deleted_at IS NULL + {$customerFilter} + ORDER BY o.customer_id, o.id, oi.id"; + + $result = $db->query($sql); + $rows = $result ? $db->fetch_all($result) : []; + $certificateAttachmentOrderIds = $this->getWashCertificateAttachmentOrderIds(array_column($rows, 'order_id')); + + foreach ($rows as &$row) { + $orderId = (int)($row['order_id'] ?? 0); + $row['has_wash_certificate_attachment'] = isset($certificateAttachmentOrderIds[$orderId]) ? 1 : 0; + } + unset($row); + + return $rows; + } + + private function getWashCertificateAttachmentOrderIds(array $orderIds): array + { + global $db; + + $orderIds = array_values(array_unique(array_filter( + array_map('intval', $orderIds), + static fn(int $orderId): bool => $orderId > 0 + ))); + if (empty($orderIds) || !$this->tableExists('object_attachments')) { + return []; + } + + $objectTypes = []; + foreach (['orders', '`orders`'] as $type) { + $objectTypes[] = "'" . $db->escape_string($type) . "'"; + } + $in = implode(',', $orderIds); + $result = $db->query( + "SELECT object_id, content + FROM object_attachments + WHERE object_type IN (" . implode(',', $objectTypes) . ") + AND object_id IN ({$in}) + AND deleted_at IS NULL" + ); + + $attached = []; + if (!$result) { + return $attached; + } + + while ($row = $result->fetch_assoc()) { + $content = json_decode((string)($row['content'] ?? ''), true); + $other = is_array($content) ? ($content['other'] ?? null) : null; + if (is_string($other) && strtolower(trim($other)) === 'wash_certificate') { + $attached[(int)$row['object_id']] = true; + } + } + + return $attached; + } + + private function getCustomerAttributes(?array $onlyCustomerNumbers): array + { + global $db; + + $customerFilter = $this->customerFilterSql('u.customer_number', $onlyCustomerNumbers); + $result = $db->query( + "SELECT u.customer_number, ca.attribute + FROM customer_attributes ca + JOIN users u ON u.id = ca.user_id + WHERE 1=1 {$customerFilter}" + ); + + $attributes = []; + if (!$result) { + return $attributes; + } + + while ($row = $result->fetch_assoc()) { + $customerNumber = (int)$row['customer_number']; + $attributes[$customerNumber][(string)$row['attribute']] = true; + } + + return $attributes; + } + + private function detectCustomerRuleViolations(array $rows, array $attributes): array + { + $flags = []; + $orders = []; + $collectionOrders = []; + + foreach ($rows as $row) { + $customerNumber = (int)$row['customer_number']; + $orderId = (int)$row['order_id']; + if ($orderId > 0 && !isset($orders[$orderId])) { + $orders[$orderId] = $row; + } + $invoiceCollectionId = (int)($row['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId > 0) { + $collectionOrders[$customerNumber][$invoiceCollectionId][$orderId] = true; + } + + if (!$this->hasAttribute($attributes, $customerNumber, 'restrictAdditionalServices') + && !$this->hasAttribute($attributes, $customerNumber, 'restrictTankCleaning') + && !$this->hasAttribute($attributes, $customerNumber, 'restrictSpotFree') + && !$this->hasAttribute($attributes, $customerNumber, 'restrictInteriorCleaning') + && !$this->hasAttribute($attributes, $customerNumber, 'exemptFromAdministrationFee')) { + continue; + } + + if ((int)($row['order_item_id'] ?? 0) < 1) { + continue; + } + + if ($this->hasAttribute($attributes, $customerNumber, 'restrictAdditionalServices') + && (int)($row['related_item_id'] ?? 0) > 0 + && (int)($row['item_price'] ?? 0) > 0) { + $flags[] = $this->automaticFlag( + 'customer_rule_restrict_addon_services', + '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']], + ]; + + foreach ($restrictedProducts as $attribute => [$definitionKey, $terms]) { + if ($this->hasAttribute($attributes, $customerNumber, $attribute) + && $this->rowMatchesProductTerms($row, $terms)) { + $flags[] = $this->automaticFlag( + $definitionKey, + 'order_item', + (int)$row['order_item_id'], + null, + $row, + ['product' => $this->productLabel($row)], + $this->orderItemContext($row) + ); + } + } + } + + foreach ($orders as $orderId => $row) { + $customerNumber = (int)$row['customer_number']; + if ($this->hasAttribute($attributes, $customerNumber, 'requiresReferenceNumber') + && trim((string)($row['order_reference'] ?? '')) === '') { + $context = $this->orderContext($row); + $context['order_items'] = $this->getOrderItemsForPreview((int)$orderId); + $flags[] = $this->automaticFlag( + 'customer_rule_requires_reference', + 'order_field', + $orderId, + 'reference', + $row, + [], + $context + ); + } + if ($this->hasAttribute($attributes, $customerNumber, 'usePONumbers') + && trim((string)($row['order_po'] ?? '')) === '') { + $context = $this->orderContext($row); + $context['order_items'] = $this->getOrderItemsForPreview((int)$orderId); + $flags[] = $this->automaticFlag( + 'customer_rule_requires_po_number', + 'order_field', + $orderId, + 'po', + $row, + [], + $context + ); + } + } + + foreach ($collectionOrders as $customerNumber => $collections) { + if (!$this->hasAttribute($attributes, (int)$customerNumber, 'invoiceAllOrdersIndividually')) { + continue; + } + foreach ($collections as $invoiceCollectionId => $orderSet) { + if (count($orderSet) <= 1) { + continue; + } + $row = $orders[(int)array_key_first($orderSet)] ?? ['customer_number' => $customerNumber, 'invoice_collection_id' => $invoiceCollectionId]; + $flags[] = $this->automaticFlag( + 'customer_rule_invoice_all_orders_individually', + 'collected_order_invoice', + (int)$invoiceCollectionId, + null, + $row, + ['count' => count($orderSet)], + $this->invoiceCollectionContext($row) + ); + } + } + + return $this->dedupeAutomaticFlags($flags); + } + + private function detectPriceMismatches(array $rows): array + { + $flags = []; + foreach ($rows as $row) { + $orderItemId = (int)($row['order_item_id'] ?? 0); + if ($orderItemId < 1 || !$this->isIncludedOrderItem($row)) { + continue; + } + + $expected = $this->calculateExpectedPrice($row); + $actual = (int)($row['item_price'] ?? 0); + if ($actual === $expected) { + continue; + } + + $context = $this->orderItemContext($row); + $context['actual_price'] = $actual; + $context['expected_price'] = $expected; + $context['expected_price_breakdown'] = $this->priceBreakdown($row, $expected); + $context['order_items'] = $this->getOrderItemsForPreview((int)$row['order_id']); + + $flags[] = $this->automaticFlag( + 'price_mismatch', + 'order_item_field', + $orderItemId, + 'price', + $row, + [ + 'product' => $this->productLabel($row), + 'expected' => 'expected', + 'actual_price' => $actual, + 'expected_price' => $expected, + ], + $context + ); + } + + return $flags; + } + + private function detectAbnormalQuantities(array $rows, string $dateFrom, string $dateTo): array + { + $flags = []; + $primaryByOrderProduct = []; + $orders = []; + $washCertificateByOrder = []; + $hasWashCertificateAttachmentByOrder = []; + $fixedPricingGroups = []; + $subscriptionGroups = []; + + foreach ($rows as $row) { + $orderId = (int)$row['order_id']; + $orderItemId = (int)($row['order_item_id'] ?? 0); + if ($orderId > 0 && !isset($orders[$orderId])) { + $orders[$orderId] = $row; + } + if ($orderId > 0 && $this->rowHasWashCertificateAttachment($row)) { + $hasWashCertificateAttachmentByOrder[$orderId] = true; + } + if ($orderItemId < 1) { + continue; + } + + if ($this->isPrimaryVehicleItem($row)) { + $primaryByOrderProduct[$orderId][(int)$row['product_id']][$orderItemId] ??= $row; + } + + $limit = (int)($row['max_quantity_per_order'] ?? 0); + if ($limit > 0 && (int)($row['item_quantity'] ?? 0) > $limit) { + $flags[] = $this->automaticFlag( + 'quantity_exceeds_product_limit', + 'order_item_field', + $orderItemId, + 'quantity', + $row, + [ + 'product' => $this->productLabel($row), + 'quantity' => (int)$row['item_quantity'], + 'limit' => $limit, + ], + $this->orderItemContext($row) + ['quantity_limit' => $limit] + ); + } + + if ($this->isWashCertificateProduct($row)) { + $washCertificateByOrder[$orderId][] = $row; + if (!$this->rowHasWashCertificateAttachment($row)) { + $context = $this->orderItemContext($row); + $context['order_items'] = $this->getOrderItemsForPreview((int)$row['order_id']); + $flags[] = $this->automaticFlag( + 'wash_certificate_item_without_certificate', + 'order_item', + $orderItemId, + null, + $row, + ['product' => $this->productLabel($row)], + $context + ); + } + } + + $monthKey = date('Y-m', strtotime((string)$row['order_created_at'])); + if ($this->rowMatchesProductTerms($row, ['fixed pricing', 'fastpris', 'fixed price'])) { + $fixedPricingGroups[(int)$row['customer_number']][$monthKey][] = $row; + } + if ($this->rowMatchesProductTerms($row, ['subscription', 'abonnement', 'vaskeabonnement'])) { + $reg = strtoupper(trim((string)($row['reg_1'] ?? ''))); + $subscriptionGroups[(int)$row['customer_number']][$reg][$monthKey][(int)$row['product_id']][] = $row; + } + } + + foreach ($primaryByOrderProduct as $orderId => $products) { + foreach ($products as $productId => $items) { + if (count($items) <= 1) { + continue; + } + $items = array_values($items); + $row = $items[0]; + $context = $this->orderContext($row); + $context['order_items'] = $this->getOrderItemsForPreview((int)$row['order_id']); + $flags[] = $this->automaticFlag( + 'multiple_identical_primary_vehicle_items', + 'order', + (int)$orderId, + null, + $row, + ['product' => $this->productLabel($row), 'count' => count($items)], + $context + ); + } + } + + foreach ($orders as $orderId => $row) { + if (isset($hasWashCertificateAttachmentByOrder[$orderId]) && empty($washCertificateByOrder[$orderId])) { + $context = $this->orderContext($row); + $context['order_items'] = $this->getOrderItemsForPreview((int)$orderId); + $flags[] = $this->automaticFlag( + 'wash_certificate_attached_without_item', + 'order', + (int)$orderId, + null, + $row, + [], + $context + ); + } + } + + foreach ($fixedPricingGroups as $customerGroups) { + foreach ($customerGroups as $items) { + if (count($items) <= 1) { + continue; + } + $row = $items[0]; + $flags[] = $this->automaticFlag( + 'multiple_fixed_pricing_items_same_month', + 'order_item', + (int)$row['order_item_id'], + null, + $row, + ['count' => count($items)], + $this->orderItemContext($row) + ); + } + } + + foreach ($subscriptionGroups as $customerGroups) { + foreach ($customerGroups as $regGroups) { + foreach ($regGroups as $monthGroups) { + foreach ($monthGroups as $items) { + if (count($items) <= 1) { + continue; + } + $row = $items[0]; + $flags[] = $this->automaticFlag( + 'duplicate_vehicle_subscription_charge_same_month', + 'order_item', + (int)$row['order_item_id'], + null, + $row, + ['product' => $this->productLabel($row), 'count' => count($items)], + $this->orderItemContext($row) + ); + } + } + } + } + + return $this->dedupeAutomaticFlags($flags); + } + + private function detectVehicleTypeMismatches(array $rows, string $dateFrom): array + { + global $db; + + $flags = []; + $primaryRows = array_values(array_filter($rows, fn(array $row): bool => $this->isPrimaryVehicleItem($row))); + if (empty($primaryRows)) { + return []; + } + + $vehicleTypeByCustomerReg = $this->getVehicleSubscriptionTypeMap($primaryRows); + foreach ($primaryRows as $row) { + $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']) { + $flags[] = $this->automaticFlag( + 'vehicle_subscription_type_mismatch', + 'order_item', + (int)$row['order_item_id'], + null, + $row, + [ + 'product' => $this->productLabel($row), + 'expected_product' => (string)($vehicleTypeByCustomerReg[$key]['product_name'] ?? $expectedProductId), + ], + $this->orderItemContext($row) + ['expected_product_id' => $expectedProductId] + ); + } + } + + $history = $this->getPrimaryProductHistory($dateFrom); + foreach ($primaryRows as $row) { + $reg = strtoupper(trim((string)($row['reg_1'] ?? ''))); + if ($reg === '' || !isset($history[$reg])) { + continue; + } + $expectedProductId = (int)$history[$reg]['product_id']; + if ($expectedProductId === (int)$row['product_id']) { + continue; + } + $flags[] = $this->automaticFlag( + 'historical_primary_product_mismatch', + 'order_item', + (int)$row['order_item_id'], + null, + $row, + [ + 'product' => $this->productLabel($row), + 'expected_product' => (string)$history[$reg]['product_name'], + ], + $this->orderItemContext($row) + [ + 'expected_product_id' => $expectedProductId, + 'expected_product_name' => $history[$reg]['product_name'], + 'history_count' => (int)$history[$reg]['count'], + ] + ); + } + + return $this->dedupeAutomaticFlags($flags); + } + + private function detectMissingXlVaskLinks(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array + { + $flags = []; + foreach ($this->getXlVaskPeriodRows($dateFrom, $dateTo, $onlyCustomerNumbers, true) as $row) { + $flags[] = $this->automaticFlag( + 'xlvask_missing_order_link', + 'xlvask_usage_log', + (int)$row['id'], + null, + [ + 'customer_number' => (int)$row['customer_number'], + 'customer_name' => (string)($row['customer_name'] ?? ''), + 'order_id' => null, + 'order_item_id' => null, + 'invoice_collection_id' => null, + 'xlvask_usage_log_id' => (int)$row['id'], + ], + [ + 'wash_id' => (string)$row['wash_id'], + 'registration_number' => (string)($row['registration_number'] ?? ''), + ], + [ + 'customer_number' => (int)$row['customer_number'], + 'customer_name' => (string)($row['customer_name'] ?? ''), + 'xlvask_usage_log_id' => (int)$row['id'], + 'wash_id' => (string)$row['wash_id'], + 'registration_number' => (string)($row['registration_number'] ?? ''), + 'start_time' => (string)($row['start_time'] ?? ''), + ] + ); + } + + return $flags; + } + + private function getXlVaskPeriodRows(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers, bool $onlyMissingLinks = false): array + { + global $db; + + if (!$this->tableExists('xlvask_usage_logs')) { + return []; + } + + $dateFrom = $db->escape_string($dateFrom); + $dateTo = $db->escape_string($dateTo); + $customerFilter = $this->customerFilterSql('CAST(x.CustomerId AS UNSIGNED)', $onlyCustomerNumbers); + $missingFilter = $onlyMissingLinks + ? "AND linked_order.id IS NULL" + : ''; + + $sql = " + SELECT + x.id, + x.WashId AS wash_id, + CAST(x.CustomerId AS UNSIGNED) AS customer_number, + COALESCE(u.display_name, x.Customer) AS customer_name, + x.RegistrationNumber AS registration_number, + x.StartTime AS start_time + FROM xlvask_usage_logs x + LEFT JOIN users u ON u.customer_number = CAST(x.CustomerId AS UNSIGNED) + LEFT JOIN orders linked_order + ON linked_order.wash_id = x.WashId + AND linked_order.deleted_at IS NULL + AND linked_order.created_at BETWEEN '{$dateFrom}' AND '{$dateTo}' + WHERE STR_TO_DATE(REPLACE(SUBSTRING(x.StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s') + BETWEEN '{$dateFrom}' AND '{$dateTo}' + AND COALESCE(x.ignored_at, '') = '' + AND COALESCE(x.FinishStatus, '') = '1' + AND CAST(x.CustomerId AS UNSIGNED) > 0 + {$customerFilter} + {$missingFilter}"; + + try { + $result = $db->query($sql); + return $result ? $db->fetch_all($result) : []; + } catch (Throwable) { + return []; + } + } + + private function automaticFlag( + string $definitionKey, + string $targetType, + int $targetId, + ?string $field, + array $row, + array $messageParams, + array $context + ): array { + $customerNumber = (int)($row['customer_number'] ?? $context['customer_number'] ?? 0); + $orderId = isset($context['order_id']) ? (int)$context['order_id'] : (isset($row['order_id']) ? (int)$row['order_id'] : null); + $orderItemId = isset($context['order_item_id']) ? (int)$context['order_item_id'] : (isset($row['order_item_id']) ? (int)$row['order_item_id'] : null); + $invoiceCollectionId = isset($context['invoice_collection_id']) ? (int)$context['invoice_collection_id'] : (isset($row['invoice_collection_id']) ? (int)$row['invoice_collection_id'] : null); + $xlvaskUsageLogId = isset($context['xlvask_usage_log_id']) ? (int)$context['xlvask_usage_log_id'] : null; + + $fingerprint = sha1(json_encode([ + $definitionKey, + $targetType, + $targetId, + $field, + $messageParams['actual_price'] ?? null, + $messageParams['expected_price'] ?? null, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + + $message = $this->automaticMessage($definitionKey, $messageParams); + + return [ + 'id' => 'auto:' . $fingerprint, + 'source' => self::SOURCE_AUTOMATIC, + 'severity' => 'yellow', + 'status' => self::STATUS_ACTIVE, + 'target_type' => $targetType, + 'target_id' => $targetId, + 'field' => $field, + 'customer_number' => $customerNumber, + 'customer_name' => (string)($row['customer_name'] ?? $context['customer_name'] ?? ''), + 'order_id' => $orderId, + 'order_item_id' => $orderItemId, + 'invoice_collection_id' => $invoiceCollectionId, + 'xlvask_usage_log_id' => $xlvaskUsageLogId, + 'definition_key' => $definitionKey, + 'fingerprint' => $fingerprint, + 'reason' => null, + 'message_key' => 'invoice_period.flags.automatic.' . $definitionKey, + 'message_params' => $messageParams, + 'message' => $message, + 'message_parts' => $this->messageParts($definitionKey, $messageParams), + 'context' => $context, + ]; + } + + private function automaticMessage(string $definitionKey, array $params): string + { + $product = (string)($params['product'] ?? 'Item'); + $expectedProduct = (string)($params['expected_product'] ?? 'expected product'); + return match ($definitionKey) { + 'price_mismatch' => "{$product} product price differs from expected.", + 'customer_rule_restrict_addon_services' => "{$product} violates restricted addon services.", + 'customer_rule_restrict_tank_cleaning' => "{$product} violates restricted tank cleaning.", + '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_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.", + 'quantity_exceeds_product_limit' => "{$product} quantity exceeds the product limit.", + 'multiple_identical_primary_vehicle_items' => "Order contains multiple identical primary vehicle items.", + 'wash_certificate_item_without_certificate' => "Wash certificate item is present without a wash certificate.", + 'wash_certificate_attached_without_item' => "Wash certificate is attached without a wash certificate item.", + 'multiple_fixed_pricing_items_same_month' => "Multiple fixed pricing items exist in the same month.", + 'duplicate_vehicle_subscription_charge_same_month' => "Duplicate vehicle subscription charges exist in the same month.", + 'vehicle_subscription_type_mismatch' => "{$product} does not match the vehicle subscription type {$expectedProduct}.", + 'historical_primary_product_mismatch' => "{$product} differs from the registration number's usual product {$expectedProduct}.", + 'xlvask_missing_order_link' => "XL Vask wash is neither ignored nor linked to an order in the selected period.", + default => "Automatically detected invoice-period issue.", + }; + } + + private function messageParts(string $definitionKey, array $params): array + { + return match ($definitionKey) { + 'price_mismatch' => [ + ['type' => 'order_item', 'text' => (string)($params['product'] ?? 'Item')], + ['type' => 'text', 'text' => ' product price differs from '], + ['type' => 'expected_price', 'text' => 'expected'], + ['type' => 'text', 'text' => '.'], + ], + 'multiple_identical_primary_vehicle_items' => [ + ['type' => 'order', 'text' => 'Order'], + ['type' => 'text', 'text' => ' contains multiple identical primary vehicle items.'], + ], + 'wash_certificate_item_without_certificate' => [ + ['type' => 'order_item', 'text' => 'Wash certificate item'], + ['type' => 'text', 'text' => ' is present without a wash certificate.'], + ], + 'wash_certificate_attached_without_item' => [ + ['type' => 'order', 'text' => 'Wash certificate'], + ['type' => 'text', 'text' => ' is attached without a wash certificate item.'], + ], + default => [], + }; + } + + private function resolveTargetContext(string $targetType, int $targetId, ?string $field): array + { + global $db; + + if ($targetType === 'customer') { + return ['customer_number' => $targetId]; + } + + if ($targetType === 'order' || $targetType === 'order_field') { + $result = $db->query("SELECT id, customer_id, invoice_collection_id, department_id FROM orders WHERE id = {$targetId} LIMIT 1"); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : []; + return [ + 'customer_number' => isset($row['customer_id']) ? (int)$row['customer_id'] : null, + 'order_id' => $targetId, + 'invoice_collection_id' => isset($row['invoice_collection_id']) ? (int)$row['invoice_collection_id'] : null, + 'department_id' => isset($row['department_id']) ? (int)$row['department_id'] : null, + 'field' => $field, + ]; + } + + if ($targetType === 'order_item' || $targetType === 'order_item_field') { + $result = $db->query( + "SELECT oi.id, oi.order_id, o.customer_id, o.invoice_collection_id, o.department_id + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.id = {$targetId} + LIMIT 1" + ); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : []; + return [ + 'customer_number' => isset($row['customer_id']) ? (int)$row['customer_id'] : null, + 'order_id' => isset($row['order_id']) ? (int)$row['order_id'] : null, + 'order_item_id' => $targetId, + 'invoice_collection_id' => isset($row['invoice_collection_id']) ? (int)$row['invoice_collection_id'] : null, + 'department_id' => isset($row['department_id']) ? (int)$row['department_id'] : null, + 'field' => $field, + ]; + } + + if ($targetType === 'collected_order_invoice') { + $result = $db->query("SELECT id, customer_number FROM collected_order_invoices WHERE id = {$targetId} LIMIT 1"); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : []; + return [ + 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, + 'invoice_collection_id' => $targetId, + ]; + } + + if ($targetType === 'xlvask_usage_log') { + $result = $db->query("SELECT id, CustomerId, WashId, RegistrationNumber, StartTime FROM xlvask_usage_logs WHERE id = {$targetId} LIMIT 1"); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : []; + return [ + 'customer_number' => isset($row['CustomerId']) ? (int)$row['CustomerId'] : null, + 'xlvask_usage_log_id' => $targetId, + 'wash_id' => (string)($row['WashId'] ?? ''), + 'registration_number' => (string)($row['RegistrationNumber'] ?? ''), + 'start_time' => (string)($row['StartTime'] ?? ''), + ]; + } + + return []; + } + + private function normalizeField(string $targetType, mixed $field): ?string + { + $field = trim((string)($field ?? '')); + if ($field === '') { + return null; + } + if ($targetType === 'order_field' && !in_array($field, self::ORDER_FIELDS, true)) { + throw new \InvalidArgumentException('Invalid order flag field.'); + } + if ($targetType === 'order_item_field' && !in_array($field, self::ORDER_ITEM_FIELDS, true)) { + throw new \InvalidArgumentException('Invalid order item flag field.'); + } + return $field; + } + + private function statusIndicatorForCustomer(array $customer, array $flags): string + { + $counts = $this->countFlags($flags); + if ($counts['manual'] > 0) { + return 'flag_red'; + } + if ($counts['automatic'] > 0) { + return 'flag_yellow'; + } + if (($customer['draft']['is_action_blocked'] ?? false) === true) { + return 'circle_yellow'; + } + return ($customer['requires_action'] ?? false) ? 'circle_red' : 'circle_green'; + } + + private function countFlags(array $flags): array + { + $manual = 0; + $automatic = 0; + foreach ($flags as $flag) { + if (($flag['status'] ?? self::STATUS_ACTIVE) !== self::STATUS_ACTIVE) { + continue; + } + if (($flag['source'] ?? '') === self::SOURCE_MANUAL) { + $manual++; + } elseif (($flag['source'] ?? '') === self::SOURCE_AUTOMATIC) { + $automatic++; + } + } + return [ + 'manual' => $manual, + 'automatic' => $automatic, + 'total' => $manual + $automatic, + ]; + } + + private function sortFlags(array $a, array $b): int + { + $sourceOrder = [self::SOURCE_MANUAL => 0, self::SOURCE_AUTOMATIC => 1]; + $sourceCompare = ($sourceOrder[$a['source'] ?? ''] ?? 99) <=> ($sourceOrder[$b['source'] ?? ''] ?? 99); + if ($sourceCompare !== 0) { + return $sourceCompare; + } + return strcmp((string)($a['created_at'] ?? $a['fingerprint'] ?? ''), (string)($b['created_at'] ?? $b['fingerprint'] ?? '')); + } + + private function ensureFlagOnlyCustomers(array $types, array $flags): array + { + if (!isset($types['all']) || !is_array($types['all'])) { + $types['all'] = []; + } + + $existing = []; + foreach ($types['all'] as $customer) { + $customerNumber = (int)($customer['customer_number'] ?? 0); + if ($customerNumber > 0) { + $existing[$customerNumber] = true; + } + } + + foreach ($flags as $flag) { + $customerNumber = (int)($flag['customer_number'] ?? 0); + if ($customerNumber < 1 || isset($existing[$customerNumber])) { + continue; + } + $types['all'][] = [ + 'id' => null, + 'customer_number' => $customerNumber, + 'customer_name' => (string)($flag['customer_name'] ?? $this->getCustomerName($customerNumber)), + 'transactions' => [], + 'requires_action' => false, + 'meta' => ['flag_only' => true], + 'queue' => ['has_active_job' => false, 'statuses' => [], 'invoice_collection_ids' => [], 'is_action_blocked' => false], + 'draft' => ['has_valid_draft' => false, 'invoice_collection_ids' => [], 'is_action_blocked' => false], + ]; + $existing[$customerNumber] = true; + } + + return $types; + } + + private function getCustomerName(int $customerNumber): string + { + global $db; + $result = $db->query("SELECT name FROM users WHERE customer_number = {$customerNumber} LIMIT 1"); + if ($result && $result->num_rows > 0) { + $row = $result->fetch_assoc(); + return (string)($row['name'] ?? ('#' . $customerNumber)); + } + return '#' . $customerNumber; + } + + private function getUserDisplayName(?int $userId): ?string + { + global $db; + if ($userId === null || $userId < 1) { + return null; + } + if (array_key_exists($userId, $this->userDisplayNameCache)) { + return $this->userDisplayNameCache[$userId]; + } + + $result = $db->query("SELECT display_name FROM users WHERE id = {$userId} LIMIT 1"); + if (!$result || $result->num_rows === 0) { + $this->userDisplayNameCache[$userId] = null; + return null; + } + + $row = $result->fetch_assoc(); + $displayName = trim((string)($row['display_name'] ?? '')); + $this->userDisplayNameCache[$userId] = $displayName === '' ? null : $displayName; + return $this->userDisplayNameCache[$userId]; + } + + private function getOrderItemToOrderMap(array $orderIds): array + { + global $db; + $orderIds = array_values(array_filter(array_map('intval', $orderIds))); + if (empty($orderIds)) { + return []; + } + $in = implode(',', $orderIds); + $result = $db->query("SELECT id, order_id FROM order_items WHERE order_id IN ({$in})"); + $map = []; + if ($result) { + while ($row = $result->fetch_assoc()) { + $map[(int)$row['id']] = (int)$row['order_id']; + } + } + return $map; + } + + private function getVehicleSubscriptionTypeMap(array $primaryRows): array + { + global $db; + $pairs = []; + foreach ($primaryRows as $row) { + $customerNumber = (int)$row['customer_number']; + $reg = strtoupper(trim((string)($row['reg_1'] ?? ''))); + if ($customerNumber > 0 && $reg !== '') { + $pairs[$customerNumber . '|' . $reg] = [$customerNumber, $reg]; + } + } + if (empty($pairs)) { + return []; + } + + $customerNumbers = implode(',', array_unique(array_map(static fn($pair): int => (int)$pair[0], $pairs))); + $deletedFilter = $this->columnExists('customer_vehicles', 'deleted_at') + ? "AND cv.deleted_at IS NULL" + : ""; + $result = $db->query( + "SELECT cv.customer_id, UPPER(TRIM(cv.reg)) AS reg, cv.type AS product_id, p.name AS product_name + FROM customer_vehicles cv + LEFT JOIN products p ON p.id = cv.type + WHERE cv.customer_id IN ({$customerNumbers}) + AND cv.wash_subscription = 1 + {$deletedFilter}" + ); + $map = []; + if ($result) { + while ($row = $result->fetch_assoc()) { + $key = (int)$row['customer_id'] . '|' . strtoupper(trim((string)$row['reg'])); + if (isset($pairs[$key])) { + $map[$key] = [ + 'product_id' => (int)$row['product_id'], + 'product_name' => (string)($row['product_name'] ?? ''), + ]; + } + } + } + return $map; + } + + private function getPrimaryProductHistory(string $dateFrom): array + { + global $db; + $dateFrom = $db->escape_string($dateFrom); + $historyStart = $db->escape_string(date('Y-m-d H:i:s', strtotime($dateFrom . ' -18 months'))); + $result = $db->query( + "SELECT UPPER(TRIM(o.reg_1)) AS reg, oi.product_id, p.name AS product_name, COUNT(*) AS usage_count + FROM orders o + JOIN order_items oi ON oi.order_id = o.id + JOIN products p ON p.id = oi.product_id + WHERE o.created_at >= '{$historyStart}' + AND o.created_at < '{$dateFrom}' + AND o.deleted_at IS NULL + AND (oi.deleted_at IS NULL OR oi.deleted_at = '') + AND p.is_wash = 1 + AND COALESCE(oi.related_item_id, 0) = 0 + AND COALESCE(o.reg_1, '') <> '' + GROUP BY UPPER(TRIM(o.reg_1)), oi.product_id, p.name + ORDER BY reg, usage_count DESC" + ); + + $byReg = []; + if ($result) { + while ($row = $result->fetch_assoc()) { + $byReg[(string)$row['reg']][] = [ + 'product_id' => (int)$row['product_id'], + 'product_name' => (string)($row['product_name'] ?? ''), + 'count' => (int)$row['usage_count'], + ]; + } + } + + $history = []; + foreach ($byReg as $reg => $items) { + $top = $items[0] ?? null; + $second = $items[1] ?? null; + if (!$top || (int)$top['count'] < 3) { + continue; + } + if ($second && (int)$second['count'] >= (int)$top['count']) { + continue; + } + $history[$reg] = $top; + } + return $history; + } + + private function getOrderItemsForPreview(int $orderId): array + { + global $db; + if ($orderId < 1) { + return []; + } + $result = $db->query( + "SELECT oi.id, oi.order_id, oi.product_id, oi.reference, oi.notes, oi.price, oi.quantity, + oi.related_item_id, p.name AS product_name, p.price AS product_base_price + FROM order_items oi + LEFT JOIN products p ON p.id = oi.product_id + WHERE oi.order_id = {$orderId} + AND (oi.deleted_at IS NULL OR oi.deleted_at = '') + ORDER BY oi.related_item_id IS NOT NULL, oi.id" + ); + return $result ? $db->fetch_all($result) : []; + } + + private function calculateExpectedPrice(array $row): int + { + $base = $row['department_price'] !== null ? (int)$row['department_price'] : (int)($row['product_base_price'] ?? 0); + $discount = $this->discountBreakdown($row)['applied_discount_percentage']; + return (int)round($base * (1 - ($discount / 100))); + } + + private function priceBreakdown(array $row, int $expected): array + { + $departmentPrice = $row['department_price'] !== null ? (int)$row['department_price'] : null; + $base = $departmentPrice ?? (int)($row['product_base_price'] ?? 0); + $discount = $this->discountBreakdown($row); + + return [ + 'product_price' => (int)($row['product_base_price'] ?? 0), + 'department_price' => $departmentPrice, + 'effective_base_price' => $base, + 'product_discount_percentage' => $discount['product_discount_percentage'], + 'category_discount_percentage' => $discount['category_discount_percentage'], + 'economic_customer_discount_percentage' => $discount['economic_customer_discount_percentage'], + 'applied_discount_percentage' => $discount['applied_discount_percentage'], + 'expected_price' => $expected, + ]; + } + + private function discountBreakdown(array $row): array + { + $productDiscount = (int)($row['product_discount_percentage'] ?? 0); + $categoryApplied = (int)($row['apply_category_discount'] ?? 0) === 1; + $categoryDiscount = $categoryApplied ? (int)($row['category_discount_percentage'] ?? 0) : 0; + $economicDiscount = $categoryApplied ? $this->economicCustomerDiscountPercentage($row) : 0; + + return [ + 'product_discount_percentage' => $productDiscount, + 'category_discount_percentage' => $categoryDiscount, + 'economic_customer_discount_percentage' => $economicDiscount, + 'applied_discount_percentage' => max($productDiscount, $categoryDiscount, $economicDiscount), + ]; + } + + private function economicCustomerDiscountPercentage(array $row): int + { + $customerNumber = (int)($row['customer_number'] ?? 0); + if ($customerNumber < 1) { + return 0; + } + if (array_key_exists($customerNumber, $this->economicCustomerDiscountCache)) { + return $this->economicCustomerDiscountCache[$customerNumber]; + } + + try { + $user = (new \objects\users_o())->getUserByCustomerNumber($customerNumber); + $discount = (int)$user->getEconomicCustomerDiscountPercentage(); + } catch (Throwable $e) { + $discount = 0; + } + + $this->economicCustomerDiscountCache[$customerNumber] = $discount; + return $discount; + } + + private function orderContext(array $row): array + { + return [ + 'customer_number' => (int)($row['customer_number'] ?? 0), + 'customer_name' => (string)($row['customer_name'] ?? ''), + 'order_id' => (int)($row['order_id'] ?? 0), + 'invoice_collection_id' => (int)($row['invoice_collection_id'] ?? 0) ?: null, + 'department_id' => (int)($row['department_id'] ?? 0) ?: null, + 'reg_1' => (string)($row['reg_1'] ?? ''), + ]; + } + + private function orderItemContext(array $row): array + { + return $this->orderContext($row) + [ + 'order_item_id' => (int)($row['order_item_id'] ?? 0), + 'product_id' => (int)($row['product_id'] ?? 0), + 'product_name' => $this->productLabel($row), + ]; + } + + private function invoiceCollectionContext(array $row): array + { + return [ + 'customer_number' => (int)($row['customer_number'] ?? 0), + 'customer_name' => (string)($row['customer_name'] ?? ''), + 'invoice_collection_id' => (int)($row['invoice_collection_id'] ?? 0), + ]; + } + + private function productLabel(array $row): string + { + $name = trim((string)($row['product_name'] ?? '')); + return $name !== '' ? $name : 'Item #' . (int)($row['product_id'] ?? 0); + } + + private function hasAttribute(array $attributes, int $customerNumber, string $attribute): bool + { + return isset($attributes[$customerNumber][$attribute]); + } + + private function rowMatchesProductTerms(array $row, array $terms): bool + { + $haystack = strtolower(trim( + (string)($row['product_name'] ?? '') . ' ' . + (string)($row['category_name'] ?? '') + )); + foreach ($terms as $term) { + if ($term !== '' && str_contains($haystack, strtolower($term))) { + return true; + } + } + return false; + } + + private function isIncludedOrderItem(array $row): bool + { + $value = $row['item_include_in_invoice'] ?? 1; + return $value === null || $value === '' || (int)$value === 1; + } + + private function isPrimaryVehicleItem(array $row): bool + { + return (int)($row['order_item_id'] ?? 0) > 0 + && (int)($row['is_wash'] ?? 0) === 1 + && (int)($row['related_item_id'] ?? 0) === 0; + } + + private function isWashCertificateProduct(array $row): bool + { + return (int)($row['product_id'] ?? 0) === self::WASH_CERTIFICATE_PRODUCT_ID + || $this->rowMatchesProductTerms($row, ['wash certificate', 'vaskecertifikat']); + } + + private function rowHasWashCertificateAttachment(array $row): bool + { + return (int)($row['has_wash_certificate_attachment'] ?? 0) === 1; + } + + private function dedupeAutomaticFlags(array $flags): array + { + $deduped = []; + foreach ($flags as $flag) { + $deduped[(string)$flag['fingerprint']] = $flag; + } + return array_values($deduped); + } + + private function customerFilterSql(string $column, ?array $onlyCustomerNumbers): string + { + if ($onlyCustomerNumbers === null) { + return ''; + } + $numbers = array_values(array_filter(array_map('intval', $onlyCustomerNumbers), static fn(int $value): bool => $value > 0)); + if (empty($numbers)) { + return ' AND 1=0'; + } + return ' AND ' . $column . ' IN (' . implode(',', array_unique($numbers)) . ')'; + } + + private function nullableIntSql(mixed $value): string + { + if ($value === null || $value === '') { + return 'NULL'; + } + return (string)(int)$value; + } + + private function nullableStringSql(?string $value): string + { + global $db; + if ($value === null || trim($value) === '') { + return 'NULL'; + } + return "'" . $db->escape_string($value) . "'"; + } + + private function jsonSql(array $value): string + { + global $db; + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + return 'NULL'; + } + return "'" . $db->escape_string($json) . "'"; + } + + private function tableExists(string $table): bool + { + global $db; + $table = str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $table); + $result = $db->query("SHOW TABLES LIKE '{$table}'"); + return $result !== false && is_object($result) && property_exists($result, 'num_rows') && (int)$result->num_rows > 0; + } + + private function columnExists(string $table, string $column): bool + { + global $db; + $table = str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $table); + $column = str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $column); + $result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'"); + return $result !== false && is_object($result) && property_exists($result, 'num_rows') && (int)$result->num_rows > 0; + } +} diff --git a/services/nginx/app/classes/products_schema_bootstrap.php b/services/nginx/app/classes/products_schema_bootstrap.php new file mode 100644 index 00000000..3963f1d9 --- /dev/null +++ b/services/nginx/app/classes/products_schema_bootstrap.php @@ -0,0 +1,68 @@ +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); + } +} diff --git a/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php b/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php new file mode 100644 index 00000000..29cd3ed1 --- /dev/null +++ b/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php @@ -0,0 +1,71 @@ +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); + } +} diff --git a/services/nginx/app/index.php b/services/nginx/app/index.php index 71487479..79e61a36 100644 --- a/services/nginx/app/index.php +++ b/services/nginx/app/index.php @@ -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); diff --git a/services/nginx/app/modules/xlvask/helpers/xlvask_usage_log.php b/services/nginx/app/modules/xlvask/helpers/xlvask_usage_log.php index 8f06f093..f827a17c 100644 --- a/services/nginx/app/modules/xlvask/helpers/xlvask_usage_log.php +++ b/services/nginx/app/modules/xlvask/helpers/xlvask_usage_log.php @@ -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 } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/products_o.php b/services/nginx/app/objects/products_o.php index 6dc691d0..3c61150a 100644 --- a/services/nginx/app/objects/products_o.php +++ b/services/nginx/app/objects/products_o.php @@ -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; } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/xlvask_usage_logs_o.php b/services/nginx/app/objects/xlvask_usage_logs_o.php index 6d1c6d49..792b4623 100644 --- a/services/nginx/app/objects/xlvask_usage_logs_o.php +++ b/services/nginx/app/objects/xlvask_usage_logs_o.php @@ -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; } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/InvoicingPeriodRoute.php b/services/nginx/app/routes/InvoicingPeriodRoute.php index 474d7471..4dddd124 100644 --- a/services/nginx/app/routes/InvoicingPeriodRoute.php +++ b/services/nginx/app/routes/InvoicingPeriodRoute.php @@ -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, diff --git a/services/nginx/app/routes/xlvaskUsageLogsRoute.php b/services/nginx/app/routes/xlvaskUsageLogsRoute.php index 5cb0ae19..556f233a 100644 --- a/services/nginx/app/routes/xlvaskUsageLogsRoute.php +++ b/services/nginx/app/routes/xlvaskUsageLogsRoute.php @@ -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 ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php index 62f8fb99..f0c79e9d 100644 --- a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php +++ b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php @@ -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, ]; } diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php new file mode 100644 index 00000000..3dbf68da --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php @@ -0,0 +1,586 @@ +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}'); +}); diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskUsageLogHelperTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageLogHelperTest.php new file mode 100644 index 00000000..7e8d7074 --- /dev/null +++ b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageLogHelperTest.php @@ -0,0 +1,38 @@ +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'); +}); +