Files
api/services/nginx/app/classes/invoice_period_flag_service.php
T
Jeppe B c795df4aad Add invoice period review workflow (#336)
Improve the superuser invoice-period review API, stale-preview protection, queue visibility, review blockers, and e-conomic eligibility.
2026-08-02 19:20:50 +02:00

2251 lines
91 KiB
PHP

<?php
namespace classes;
use Throwable;
class invoice_period_flag_service
{
private const SOURCE_MANUAL = 'manual';
private const SOURCE_AUTOMATIC = 'automatic';
private const STATUS_ACTIVE = 'active';
private const VALID_STATUSES = ['active', 'resolved', 'ignored', 'false_positive'];
private const VALID_TARGETS = [
'collected_order_invoice',
'order',
'order_field',
'order_item',
'order_item_field',
'customer',
'xlvask_usage_log',
];
private const ORDER_FIELDS = ['customer', 'reference', 'po', 'notes'];
private const ORDER_ITEM_FIELDS = ['notes', 'quantity', 'reference', 'price'];
private const WASH_CERTIFICATE_PRODUCT_ID = 41;
private array $economicCustomerDiscountCache = [];
private array $userDisplayNameCache = [];
private array $orderItemsPreviewCache = [];
private ?array $manualFlagsInstanceCache = null;
public function __construct()
{
invoice_period_flag_schema_bootstrap::ensureTables();
price_overrides_schema_bootstrap::ensureColumns();
department_customer_price_overrides_schema_bootstrap::ensureTables();
}
public function createManualFlag(array $payload, int $userId): array
{
global $db;
$targetType = trim((string)($payload['target_type'] ?? ''));
$targetId = (int)($payload['target_id'] ?? 0);
$field = $this->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);
$flagId = (int)$db->insert_id();
$this->refreshManualFlagsCacheAfterMutation();
return $this->getStoredFlag($flagId);
}
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);
$this->refreshManualFlagsCacheAfterMutation();
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<string,array<int,array<string,mixed>>> $types
* @param int[]|null $onlyCustomerNumbers
* @return array<string,array<int,array<string,mixed>>>
*/
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;
}
/**
* Add only aggregate active manual-flag counts for readiness derivation.
* Flag details remain absent when the caller lacks list_invoice_period_flags.
*
* @param array<string,array<int,array<string,mixed>>> $types
* @param int[]|null $onlyCustomerNumbers
* @return array<string,array<int,array<string,mixed>>>
*/
public function applyManualFlagCountsToPeriodTypes(
array $types,
string $dateFrom,
string $dateTo,
?array $onlyCustomerNumbers = null
): array {
$context = $this->buildPeriodContext($types, $dateFrom, $dateTo, $onlyCustomerNumbers);
$manualFlags = $this->getManualFlagsForPeriod($context, $dateFrom, $dateTo, $onlyCustomerNumbers);
return $this->applyManualFlagCounts($types, $manualFlags);
}
private function applyManualFlagCounts(array $types, array $manualFlags): array
{
$flagsByCustomerNumber = [];
foreach ($manualFlags as $flag) {
$customerNumber = (int)($flag['customer_number'] ?? 0);
if ($customerNumber > 0) {
$flagsByCustomerNumber[$customerNumber][] = $flag;
}
}
foreach ($types as $typeName => $customers) {
foreach ($customers as $index => $customer) {
$customerNumber = (int)($customer['customer_number'] ?? 0);
$customerFlags = $this->flagsForCustomerCard(
$customer,
$flagsByCustomerNumber[$customerNumber] ?? [],
(string)$typeName
);
$activeManualCount = count(array_filter(
$customerFlags,
static fn(array $flag): bool => ($flag['source'] ?? null) === self::SOURCE_MANUAL
&& ($flag['status'] ?? self::STATUS_ACTIVE) === self::STATUS_ACTIVE
));
$existingAutomaticCount = (int)($customer['flag_counts']['automatic'] ?? 0);
$types[$typeName][$index]['flag_counts'] = [
'manual' => $activeManualCount,
'automatic' => $existingAutomaticCount,
'total' => $activeManualCount + $existingAutomaticCount,
];
unset($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 refreshManualFlagsCacheAfterMutation(): void
{
$this->manualFlagsInstanceCache = null;
$this->warmManualFlagsCache();
}
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,
CASE
WHEN d.custom_pricing_only = 1 THEN department_product_discount.percentage
ELSE product_discount.percentage
END AS product_discount_percentage,
CASE
WHEN d.custom_pricing_only = 1 THEN department_product_discount.fixed_price
ELSE product_discount.fixed_price
END AS product_fixed_price,
CASE
WHEN d.custom_pricing_only = 1 THEN GREATEST(
COALESCE(department_category_discount.percentage, 0),
COALESCE(department_global_discount.percentage, 0)
)
ELSE category_discount.percentage
END 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
LEFT JOIN (
SELECT department_id, user_id, product_or_category_id, MAX(percentage) AS percentage, MAX(fixed_price) AS fixed_price
FROM department_customer_price_overrides
WHERE is_category = 0
GROUP BY department_id, user_id, product_or_category_id
) department_product_discount
ON department_product_discount.department_id = o.department_id
AND department_product_discount.user_id = u.id
AND department_product_discount.product_or_category_id = p.id
LEFT JOIN (
SELECT department_id, user_id, product_or_category_id, MAX(percentage) AS percentage
FROM department_customer_price_overrides
WHERE is_category = 1 AND product_or_category_id <> 'global'
GROUP BY department_id, user_id, product_or_category_id
) department_category_discount
ON department_category_discount.department_id = o.department_id
AND department_category_discount.user_id = u.id
AND department_category_discount.product_or_category_id = p.category
LEFT JOIN (
SELECT department_id, user_id, MAX(percentage) AS percentage
FROM department_customer_price_overrides
WHERE is_category = 1 AND product_or_category_id = 'global'
GROUP BY department_id, user_id
) department_global_discount
ON department_global_discount.department_id = o.department_id
AND department_global_discount.user_id = u.id
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;
customer_rule_product_restriction_schema_bootstrap::ensureSchema();
$customerFilter = $this->customerFilterSql('u.customer_number', $onlyCustomerNumbers);
$result = $db->query(
"SELECT u.customer_number, ca.attribute, cp.product_id
FROM customer_attributes ca
JOIN users u ON u.id = ca.user_id
LEFT JOIN customer_rule_product_restrictions r ON r.attribute = ca.attribute
LEFT JOIN customer_rule_product_collections c ON c.attribute = r.attribute
LEFT JOIN customer_rule_product_collection_products cp ON cp.collection_id = c.id
WHERE 1=1 {$customerFilter}"
);
$attributes = [];
if (!$result) {
return $attributes;
}
while ($row = $result->fetch_assoc()) {
$customerNumber = (int)$row['customer_number'];
$attribute = (string)$row['attribute'];
$attributes[$customerNumber][$attribute] = true;
if ($row['product_id'] !== null && in_array($attribute, customer_rule_product_restriction_service::PRODUCT_IMPACT_ATTRIBUTES, true)) {
$attributes[$customerNumber]['__disabled_products'][(int)$row['product_id']][$attribute] = true;
}
}
return $attributes;
}
private function detectCustomerRuleViolations(array $rows, array $attributes): array
{
$flags = [];
$orders = [];
$collectionOrders = [];
$matchingRules = static fn(int $customerNumber, int $productId): array =>
array_keys($attributes[$customerNumber]['__disabled_products'][$productId] ?? []);
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;
}
$productRuleDefinitions = [
'restrictAdditionalServices' => 'customer_rule_restrict_addon_services',
'restrictTankCleaning' => 'customer_rule_restrict_tank_cleaning',
'onlyTankCleaning' => 'customer_rule_only_tank_cleaning',
'restrictSpotFree' => 'customer_rule_restrict_spot_free',
'restrictInteriorCleaning' => 'customer_rule_restrict_interior_cleaning',
];
foreach ($matchingRules($customerNumber, (int)($row['product_id'] ?? 0)) as $attribute) {
if (isset($productRuleDefinitions[$attribute])) {
$flags[] = $this->automaticFlag(
$productRuleDefinitions[$attribute],
'order_item',
(int)$row['order_item_id'],
null,
$row,
['product' => $this->productLabel($row)],
$this->orderItemContext($row)
);
}
}
if ($this->hasAttribute($attributes, $customerNumber, 'exemptFromAdministrationFee')
&& $this->rowMatchesProductTerms($row, ['administration fee', 'administrationsgebyr', 'administration'])) {
$flags[] = $this->automaticFlag(
'customer_rule_exempt_from_administration_fees',
'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 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;
}
}