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 { global $response; $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 = $this->flagsForCustomerCard( $customer, $flagsByCustomerNumber[$customerNumber] ?? [], (string)$typeName ); 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 flagsForCustomerCard(array $customer, array $flags, string $typeName = ''): array { $transactionIds = []; $invoiceCollectionIds = []; foreach (($customer['transactions'] ?? []) as $transaction) { $orderId = (int)($transaction['id'] ?? 0); if ($orderId > 0) { $transactionIds[$orderId] = true; } $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); if ($invoiceCollectionId > 0) { $invoiceCollectionIds[$invoiceCollectionId] = true; } } return array_values(array_filter($flags, function (array $flag) use ($customer, $transactionIds, $invoiceCollectionIds, $typeName): bool { return $this->flagBelongsToCustomerCard($customer, $flag, $transactionIds, $invoiceCollectionIds, $typeName); })); } private function flagBelongsToCustomerCard( array $customer, array $flag, array $transactionIds, array $invoiceCollectionIds, string $typeName = '' ): bool { if ((string)($flag['status'] ?? self::STATUS_ACTIVE) !== self::STATUS_ACTIVE) { return false; } $customerNumber = (int)($customer['customer_number'] ?? 0); $targetType = (string)($flag['target_type'] ?? ''); if ($targetType === 'customer') { return (int)($flag['customer_number'] ?? $flag['target_id'] ?? 0) === $customerNumber; } if ($targetType === 'xlvask_usage_log') { return $typeName === 'all' && (int)($flag['customer_number'] ?? 0) === $customerNumber; } if (in_array($targetType, ['order', 'order_field', 'order_item', 'order_item_field'], true)) { $orderId = (int)($flag['order_id'] ?? $flag['context']['order_id'] ?? 0); if ($orderId < 1 && in_array($targetType, ['order', 'order_field'], true)) { $orderId = (int)($flag['target_id'] ?? 0); } return $orderId > 0 && isset($transactionIds[$orderId]); } if ($targetType === 'collected_order_invoice') { $invoiceCollectionId = (int)( $flag['invoice_collection_id'] ?? $flag['context']['invoice_collection_id'] ?? $flag['target_id'] ?? 0 ); return $invoiceCollectionId > 0 && isset($invoiceCollectionIds[$invoiceCollectionId]); } return false; } 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); } public function warmManualFlagsCache(): void { $flags = $this->fetchActiveManualFlagsFromDb(); try { (new redis())->cache_invoice_period_manual_flags($flags); } catch (Throwable) { } } private function fetchActiveManualFlagsFromDb(): 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" ); $flags = []; if ($result) { while ($row = $result->fetch_assoc()) { $flags[] = $this->formatStoredFlag($row); } } return $flags; } 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 getCachedManualFlags(): array { try { $flags = (new redis())->get_invoice_period_manual_flags(); } catch (Throwable) { $flags = null; } if (!is_array($flags)) { // Cache miss — read from the database and refresh Redis without hiding active flags. $flags = $this->fetchActiveManualFlagsFromDb(); try { (new redis())->cache_invoice_period_manual_flags($flags); } catch (Throwable) { } } return array_values(array_filter($flags, static function ($flag): bool { return is_array($flag); })); } 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 { if ($this->manualFlagsInstanceCache === null) { $this->manualFlagsInstanceCache = $this->getCachedManualFlags(); } $cachedFlags = $this->manualFlagsInstanceCache; if (empty($cachedFlags)) { 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 = []; foreach ($cachedFlags as $row) { $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 = $row; $flag['customer_number'] = $customerNumber; $flag['message'] = (string)$flag['reason']; $flags[] = $flag; } return $flags; } private function getAutomaticFlagsForPeriod(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array { try { $flags = (new redis())->get_invoice_period_automatic_flags($dateFrom, $dateTo); } catch (Throwable) { $flags = null; } if (!is_array($flags)) { $flags = $this->calculateAutomaticFlagsForPeriod($dateFrom, $dateTo); $this->cacheAutomaticFlagsForPeriod($dateFrom, $dateTo, $flags); } if ($onlyCustomerNumbers === null) { return $flags; } $allowed = array_fill_keys(array_map('intval', $onlyCustomerNumbers), true); return array_values(array_filter($flags, static function (array $flag) use ($allowed): bool { return isset($allowed[(int)($flag['customer_number'] ?? 0)]); })); } public function warmAutomaticFlagsForPeriod(string $dateFrom, string $dateTo): void { [$dateFrom, $dateTo] = $this->normalizePeriodDateRange($dateFrom, $dateTo); $this->cacheAutomaticFlagsForPeriod( $dateFrom, $dateTo, $this->calculateAutomaticFlagsForPeriod($dateFrom, $dateTo) ); } private function calculateAutomaticFlagsForPeriod(string $dateFrom, string $dateTo): array { [$dateFrom, $dateTo] = $this->normalizePeriodDateRange($dateFrom, $dateTo); $rows = $this->getPeriodOrderItemRows($dateFrom, $dateTo, null); $attributes = $this->getCustomerAttributes(null); return array_merge( $this->detectCustomerRuleViolations($rows, $attributes), $this->detectPriceMismatches($rows), $this->detectAbnormalQuantities($rows, $dateFrom, $dateTo), $this->detectVehicleTypeMismatches($rows, $dateFrom), $this->detectMissingXlVaskLinks($dateFrom, $dateTo, null) ); } private function cacheAutomaticFlagsForPeriod(string $dateFrom, string $dateTo, array $flags): void { try { (new redis())->cache_invoice_period_automatic_flags($dateFrom, $dateTo, $flags); } catch (Throwable) { } } 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 { [$dateFrom, $dateTo] = $this->normalizePeriodDateRange($dateFrom, $dateTo); try { $rows = (new redis())->get_invoice_period_order_item_rows($dateFrom, $dateTo); } catch (Throwable) { $rows = null; } if (!is_array($rows)) { $rows = $this->fetchOrderItemRowsFromDb($dateFrom, $dateTo); try { (new redis())->cache_invoice_period_order_item_rows($dateFrom, $dateTo, $rows); } catch (Throwable) { } } $this->seedOrderItemsPreviewCacheFromRows($rows); if ($onlyCustomerNumbers === null) { return $rows; } $allowed = array_fill_keys(array_map('intval', $onlyCustomerNumbers), true); return array_values(array_filter($rows, static function (array $row) use ($allowed): bool { return isset($allowed[(int)($row['customer_number'] ?? 0)]); })); } public function warmOrderItemRowsForPeriod(string $dateFrom, string $dateTo): void { [$dateFrom, $dateTo] = $this->normalizePeriodDateRange($dateFrom, $dateTo); $rows = $this->fetchOrderItemRowsFromDb($dateFrom, $dateTo); try { (new redis())->cache_invoice_period_order_item_rows($dateFrom, $dateTo, $rows); } catch (Throwable) { } } private function normalizePeriodDateRange(string $dateFrom, string $dateTo): array { return [ $this->normalizePeriodDate($dateFrom, true), $this->normalizePeriodDate($dateTo, false), ]; } private function normalizePeriodDate(string $date, bool $startOfDay): string { $timestamp = strtotime($date); if ($timestamp === false) { return $date; } return date($startOfDay ? 'Y-m-d 00:00:00' : 'Y-m-d 23:59:59', $timestamp); } private function fetchOrderItemRowsFromDb(string $dateFrom, string $dateTo): array { global $db; $escapedDateFrom = $db->escape_string($dateFrom); $escapedDateTo = $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, d.custom_pricing_only AS department_custom_pricing_only, 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, product_discount.fixed_price AS product_fixed_price, 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 departments d ON d.id = o.department_id 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, MAX(po.fixed_price) AS fixed_price 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 '{$escapedDateFrom}' AND '{$escapedDateTo}' AND o.deleted_at IS NULL 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); $this->seedOrderItemsPreviewCacheFromRows($rows); 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') && !$this->hasAttribute($attributes, $customerNumber, 'onlyTankCleaning')) { continue; } if ((int)($row['order_item_id'] ?? 0) < 1) { continue; } $isTankCleaningProduct = $this->rowIsTankCleaningProduct($row); 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) ); } if ($this->hasAttribute($attributes, $customerNumber, 'restrictTankCleaning') && $isTankCleaningProduct) { $flags[] = $this->automaticFlag( 'customer_rule_restrict_tank_cleaning', 'order_item', (int)$row['order_item_id'], null, $row, ['product' => $this->productLabel($row)], $this->orderItemContext($row) ); } if ($this->hasAttribute($attributes, $customerNumber, 'onlyTankCleaning') && !$isTankCleaningProduct) { $flags[] = $this->automaticFlag( 'customer_rule_only_tank_cleaning', 'order_item', (int)$row['order_item_id'], null, $row, ['product' => $this->productLabel($row)], $this->orderItemContext($row) ); } $restrictedProducts = [ '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 { $this->preloadEconomicCustomerDiscounts($rows); $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); $expectedProductName = (string)($vehicleTypeByCustomerReg[$key]['product_name'] ?? ''); if ($expectedProductId > 0 && !$this->primaryVehicleProductsMatch( (int)$row['product_id'], $this->productLabel($row), $expectedProductId, $expectedProductName )) { $flags[] = $this->automaticFlag( 'vehicle_subscription_type_mismatch', 'order_item', (int)$row['order_item_id'], null, $row, [ 'product' => $this->productLabel($row), 'expected_product' => $expectedProductName !== '' ? $expectedProductName : (string)$expectedProductId, ], $this->orderItemContext($row) + ['expected_product_id' => $expectedProductId] ); } } $history = $this->getPrimaryProductHistory($dateFrom, array_column($primaryRows, 'reg_1')); foreach ($primaryRows as $row) { $reg = strtoupper(trim((string)($row['reg_1'] ?? ''))); if ($reg === '' || !isset($history[$reg])) { continue; } $expectedProductId = (int)$history[$reg]['product_id']; if ($this->primaryVehicleProductsMatch( (int)$row['product_id'], $this->productLabel($row), $expectedProductId, (string)$history[$reg]['product_name'] )) { 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_only_tank_cleaning' => "{$product} violates the only tank cleaning rule.", 'customer_rule_requires_reference' => "Order is missing a required reference.", 'customer_rule_requires_po_number' => "Order is missing a required PO number.", 'customer_rule_invoice_all_orders_individually' => "Invoice collection contains multiple orders for a customer requiring individual invoices.", '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.'], ], 'xlvask_missing_order_link' => [ ['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'], ['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'], ], default => [], }; } 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 display_name FROM users WHERE customer_number = {$customerNumber} LIMIT 1"); if ($result && $result->num_rows > 0) { $row = $result->fetch_assoc(); $displayName = trim((string)($row['display_name'] ?? '')); return $displayName !== '' ? $displayName : '#' . $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 $registrationNumbers): array { global $db; $registrations = []; foreach ($registrationNumbers as $registrationNumber) { $registrationNumber = preg_replace('/[^A-Z0-9]/', '', strtoupper(trim((string)$registrationNumber))); $registrationNumber = is_string($registrationNumber) ? $registrationNumber : ''; if ($registrationNumber !== '') { $registrations[$registrationNumber] = true; } } if (empty($registrations)) { return []; } $dateFrom = $db->escape_string($dateFrom); $historyStart = $db->escape_string(date('Y-m-d H:i:s', strtotime($dateFrom . ' -18 months'))); $registrationFilter = implode(',', array_map(static function (string $registrationNumber) use ($db): string { return "'" . $db->escape_string($registrationNumber) . "'"; }, array_keys($registrations))); $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, '') <> '' AND o.reg_1 IN ({$registrationFilter}) GROUP BY UPPER(TRIM(o.reg_1)), oi.product_id, p.name ORDER BY reg, usage_count DESC, oi.product_id ASC" ); $history = []; $seenCounts = []; if ($result) { while ($row = $result->fetch_assoc()) { $reg = (string)$row['reg']; $item = [ 'product_id' => (int)$row['product_id'], 'product_name' => (string)($row['product_name'] ?? ''), 'count' => (int)$row['usage_count'], ]; if (!isset($seenCounts[$reg])) { $seenCounts[$reg] = 1; if ($item['count'] >= 3) { $history[$reg] = $item; } continue; } if ($seenCounts[$reg] === 1) { $seenCounts[$reg] = 2; if (isset($history[$reg]) && $item['count'] >= (int)$history[$reg]['count']) { unset($history[$reg]); } } } } return $history; } private function primaryVehicleProductsMatch( int $currentProductId, string $currentProductName, int $expectedProductId, string $expectedProductName ): bool { if ($expectedProductId > 0 && $currentProductId === $expectedProductId) { return true; } $currentVehicleType = $this->normalizePrimaryVehicleProductName($currentProductName); $expectedVehicleType = $this->normalizePrimaryVehicleProductName($expectedProductName); if ($currentVehicleType === '' || $expectedVehicleType === '') { return false; } return $currentVehicleType === $expectedVehicleType; } private function normalizePrimaryVehicleProductName(string $productName): string { $normalized = strtolower(strtr($productName, [ 'Æ' => 'ae', 'Ø' => 'oe', 'Å' => 'aa', 'æ' => 'ae', 'ø' => 'oe', 'å' => 'aa', ])); $normalized = (string)preg_replace('/[^a-z0-9]+/', ' ', $normalized); $tokens = array_values(array_filter( explode(' ', trim($normalized)), static fn(string $token): bool => $token !== '' && !in_array($token, [ 'indvendig', 'indv', 'interior', 'internal', 'vask', 'wash', ], true) )); return implode(' ', $tokens); } private function getOrderItemsForPreview(int $orderId): array { global $db; if ($orderId < 1) { return []; } if (array_key_exists($orderId, $this->orderItemsPreviewCache)) { return $this->orderItemsPreviewCache[$orderId]; } $result = $db->query( "SELECT oi.id, oi.product_id, oi.price, oi.quantity, p.name AS product_name 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" ); $rows = $result ? $db->fetch_all($result) : []; $this->orderItemsPreviewCache[$orderId] = array_map(static function (array $row): array { return [ 'id' => (int)($row['id'] ?? 0), 'product_id' => (int)($row['product_id'] ?? 0), 'product_name' => (string)($row['product_name'] ?? ''), 'quantity' => (int)($row['quantity'] ?? 0), 'price' => (int)($row['price'] ?? 0), ]; }, $rows); return $this->orderItemsPreviewCache[$orderId]; } private function seedOrderItemsPreviewCacheFromRows(array $rows): void { $grouped = []; foreach ($rows as $row) { $orderId = (int)($row['order_id'] ?? 0); if ($orderId < 1) { continue; } $grouped[$orderId] ??= []; $orderItemId = (int)($row['order_item_id'] ?? 0); if ($orderItemId < 1) { continue; } $grouped[$orderId][] = [ 'id' => $orderItemId, 'product_id' => (int)($row['product_id'] ?? 0), 'product_name' => (string)($row['product_name'] ?? ''), 'quantity' => (int)($row['item_quantity'] ?? 0), 'price' => (int)($row['item_price'] ?? 0), '_related_sort' => (int)($row['related_item_id'] ?? 0) > 0 ? 1 : 0, ]; } foreach ($grouped as $orderId => $items) { usort($items, static function (array $a, array $b): int { return ((int)$a['_related_sort'] <=> (int)$b['_related_sort']) ?: ((int)$a['id'] <=> (int)$b['id']); }); $this->orderItemsPreviewCache[(int)$orderId] = array_map(static function (array $item): array { unset($item['_related_sort']); return $item; }, $items); } } private function preloadEconomicCustomerDiscounts(array $rows): void { $customerUserIds = []; foreach ($rows as $row) { if ((int)($row['apply_category_discount'] ?? 0) !== 1) { continue; } $customerNumber = (int)($row['customer_number'] ?? 0); $userId = (int)($row['user_id'] ?? 0); if ($customerNumber < 1 || $userId < 1 || array_key_exists($customerNumber, $this->economicCustomerDiscountCache)) { continue; } $customerUserIds[$customerNumber] = $userId; } foreach ($customerUserIds as $customerNumber => $userId) { $discount = $this->getCachedEconomicCustomerDiscount($userId); if ($discount === null) { $discount = $this->loadEconomicCustomerDiscount((int)$customerNumber, $userId); } $this->economicCustomerDiscountCache[(int)$customerNumber] = $discount; } } private function getCachedEconomicCustomerDiscount(int $userId): ?int { if ($userId < 1 || !defined('redis')) { return null; } try { $cachedDiscount = constant('redis')->get_economic_customer_discount_percentage($userId); return $cachedDiscount === null ? null : (int)$cachedDiscount; } catch (Throwable $e) { return null; } } private function loadEconomicCustomerDiscount(int $customerNumber, int $userId): int { if ($customerNumber < 1 || $userId < 1 || !defined('redis')) { return 0; } try { $discount = (int)(new \customers\economicCustomers())->getCustomerDiscountPercentage($customerNumber); constant('redis')->cache_economic_customer_discount_percentage($userId, $discount); return $discount; } catch (Throwable $e) { return 0; } } private function calculateExpectedPrice(array $row): int { $customMissingPrice = $this->isCustomMissingDepartmentPrice($row); if ($customMissingPrice) { return \objects\products_o::CUSTOM_PRICING_MISSING_PRICE; } $fixedPrice = $this->rowProductFixedPrice($row); if ($fixedPrice !== null) { return $fixedPrice; } $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; $customMissingPrice = $this->isCustomMissingDepartmentPrice($row); $base = $departmentPrice ?? ($customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0)); $discount = $this->discountBreakdown($row); if ($customMissingPrice) { $discount['applied_discount_percentage'] = 0; } return [ 'product_price' => $customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0), 'department_price' => $departmentPrice, 'effective_base_price' => $base, 'product_fixed_price' => $this->rowProductFixedPrice($row), '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 isCustomMissingDepartmentPrice(array $row): bool { return $row['department_price'] === null && (bool)(int)($row['department_custom_pricing_only'] ?? 0); } 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; $appliedDiscount = $this->rowProductFixedPrice($row) !== null ? 0 : max($productDiscount, $categoryDiscount, $economicDiscount); return [ 'product_discount_percentage' => $productDiscount, 'category_discount_percentage' => $categoryDiscount, 'economic_customer_discount_percentage' => $economicDiscount, 'applied_discount_percentage' => $appliedDiscount, ]; } private function rowProductFixedPrice(array $row): ?int { return array_key_exists('product_fixed_price', $row) && $row['product_fixed_price'] !== null ? (int)$row['product_fixed_price'] : null; } 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]; } $discount = 0; $userId = (int)($row['user_id'] ?? 0); if ($userId > 0) { $discount = $this->getCachedEconomicCustomerDiscount($userId) ?? 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 rowIsTankCleaningProduct(array $row): bool { return customer_order_product_policy::isTankCleaningProductRow($row); } 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; } }