*/ private static array $departmentNameCache = []; /** * Cache whether a department is excluded from invoicing. * @var array */ private static array $departmentExcludedFromInvoicingCache = []; private static ?bool $collectedOrderInvoicesHasDeletedAtColumn = null; private static bool $suppressInvoicePeriodExternalEffects = false; /** * Local-only booked status caches used by the period response. * The period endpoint must not call e-conomic for each order. * @var array */ private static array $periodOrderBookedCache = []; private static array $periodInvoiceCollectionBookedCache = []; /** * @throws Exception */ private static function getDepartmentNameCached(int $departmentId): string { if (!isset(self::$departmentNameCache[$departmentId])) { $departmentName = (new \objects\departments_o())->select($departmentId)->name->value(); self::$departmentNameCache[$departmentId] = !empty($departmentName) ? $departmentName : 'Unknown Department (' . $departmentId . ')'; } return self::$departmentNameCache[$departmentId]; } /** * @throws Exception */ private static function isDepartmentExcludedFromInvoicingCached(int $departmentId): bool { if (!array_key_exists($departmentId, self::$departmentExcludedFromInvoicingCache)) { self::$departmentExcludedFromInvoicingCache[$departmentId] = (new \objects\departments_o()) ->select($departmentId) ->isExcludedFromInvoicing(); } return self::$departmentExcludedFromInvoicingCache[$departmentId]; } private static function getLocalCustomerName(int $customerNumber): string { $names = (new users_o())->getCustomerNames([$customerNumber], false); return (string)($names[$customerNumber] ?? 'Unknown Customer'); } private static function getEconomicFallbackDepartmentId(): int { $department_id = (new economic())->getDefaultDistributionDepartmentId(); return $department_id > 0 ? $department_id : economic::DEFAULT_DISTRIBUTION_DEPARTMENT_ID; } /** * Slack summaries are expensive on request latency, so they are opt-in. * Enable with query param `sendSlackSummary=1` or env `INVOICING_PERIOD_SEND_SLACK_SUMMARY=true`. */ private static function shouldSendSlackSummary(): bool { if (self::$suppressInvoicePeriodExternalEffects) { return false; } $requestOverride = $_GET['sendSlackSummary'] ?? null; if ($requestOverride !== null) { return in_array(strtolower((string)$requestOverride), ['1', 'true', 'yes'], true); } $envFlag = getenv('INVOICING_PERIOD_SEND_SLACK_SUMMARY'); if ($envFlag === false) { return false; } return in_array(strtolower((string)$envFlag), ['1', 'true', 'yes'], true); } /** * Resolve the backend-controlled object-tree rollout. Missing config is deliberately disabled. * Module: InvoicingPeriod. Variables: object_tree_v2_enabled and * object_tree_v2_superuser_allowlist (JSON array or comma-separated IDs). */ public static function isInvoicePeriodObjectTreeV2Enabled(int $actorUserId): bool { global $db; if ($actorUserId < 1) { return false; } try { $result = $db->query( "SELECT variable, value FROM module_config WHERE module = 'InvoicingPeriod' AND variable IN ( 'object_tree_v2_enabled', 'object_tree_v2_superuser_allowlist', 'object_tree_v2_allowlisted_user_ids' )" ); if ($result) { $config = []; while ($row = $result->fetch_assoc()) { $config[(string)$row['variable']] = $row['value']; } if (self::isTruthyObjectTreeConfigValue($config['object_tree_v2_enabled'] ?? null)) { return invoice_collection_schema_bootstrap::hasRequiredColumns(); } $allowlist = $config['object_tree_v2_superuser_allowlist'] ?? $config['object_tree_v2_allowlisted_user_ids'] ?? null; if (in_array($actorUserId, self::parseObjectTreeIntegerList($allowlist), true)) { return invoice_collection_schema_bootstrap::hasRequiredColumns(); } if ($config !== []) { return false; } } } catch (\Throwable) { // Deployment/test fallback below; the default remains disabled. } return self::isTruthyObjectTreeConfigValue(getenv('INVOICING_PERIOD_OBJECT_TREE_V2')) && invoice_collection_schema_bootstrap::hasRequiredColumns(); } private static function isTruthyObjectTreeConfigValue(mixed $value): bool { return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true); } /** @return int[] */ private static function parseObjectTreeIntegerList(mixed $value): array { if ($value === null || $value === '') { return []; } $decoded = is_string($value) ? json_decode($value, true) : null; $values = is_array($decoded) ? $decoded : explode(',', (string)$value); return array_values(array_unique(array_filter( array_map('intval', $values), static fn(int $id): bool => $id > 0 ))); } /** @return int[] */ private static function setInvoicePeriodObjectTreeV2CanaryUser(int $actorUserId, bool $enabled): array { global $db; if ($actorUserId < 1) { throw new \InvalidArgumentException('Invalid canary user'); } $lockResult = $db->query("SELECT GET_LOCK('invoice_period_object_tree_rollout', 5) AS acquired"); $lockRow = is_object($lockResult) && method_exists($lockResult, 'fetch_assoc') ? $lockResult->fetch_assoc() : null; if (!is_array($lockRow) || (int)($lockRow['acquired'] ?? 0) !== 1) { throw new \RuntimeException('Could not lock invoice period rollout configuration'); } try { $result = $db->query( "SELECT value FROM module_config WHERE module = 'InvoicingPeriod' AND variable = 'object_tree_v2_superuser_allowlist' LIMIT 1" ); if ($result === false || !is_object($result) || !method_exists($result, 'fetch_assoc')) { throw new \RuntimeException('Could not read invoice period rollout configuration'); } $row = $result->fetch_assoc(); $seedValue = is_array($row) ? ($row['value'] ?? null) : null; if (!is_array($row)) { $legacyResult = $db->query( "SELECT value FROM module_config WHERE module = 'InvoicingPeriod' AND variable = 'object_tree_v2_allowlisted_user_ids' LIMIT 1" ); if ($legacyResult === false || !is_object($legacyResult) || !method_exists($legacyResult, 'fetch_assoc')) { throw new \RuntimeException('Could not read legacy invoice period rollout configuration'); } $legacyRow = $legacyResult->fetch_assoc(); $seedValue = is_array($legacyRow) ? ($legacyRow['value'] ?? null) : null; } $allowlist = self::parseObjectTreeIntegerList($seedValue); $allowlist = array_values(array_filter( $allowlist, static fn(int $userId): bool => $userId !== $actorUserId )); if ($enabled) { $allowlist[] = $actorUserId; } $allowlist = array_values(array_unique($allowlist)); sort($allowlist, SORT_NUMERIC); $value = $db->escape_string((string)json_encode($allowlist, JSON_THROW_ON_ERROR)); $query = is_array($row) ? "UPDATE module_config SET value = '{$value}', type = 'json' WHERE module = 'InvoicingPeriod' AND variable = 'object_tree_v2_superuser_allowlist'" : "INSERT INTO module_config (module, variable, value, type) VALUES ('InvoicingPeriod', 'object_tree_v2_superuser_allowlist', '{$value}', 'json')"; if ($db->query($query) === false) { throw new \RuntimeException('Could not update invoice period rollout configuration'); } return $allowlist; } finally { try { $db->query("SELECT RELEASE_LOCK('invoice_period_object_tree_rollout')"); } catch (\Throwable) { // The connection also releases advisory locks automatically. } } } private static function applyInvoicePeriodObjectTreeCapability(array $period, bool $enabled): array { foreach (($period['types'] ?? []) as $type => $customers) { if (!is_array($customers)) { continue; } foreach ($customers as $index => $customer) { if (is_array($customer)) { $period['types'][$type][$index]['capabilities']['object_tree_v2'] = $enabled; } } } $period['capabilities']['object_tree_v2'] = $enabled; return $period; } /** * @return array{dateFrom:string,dateTo:string} */ private function requireAndNormalizeDateRange(): array { global $response; self::requireParameters([ 'dateFrom', 'dateTo', ]); $dateFrom = (string)$this->getParameter('dateFrom'); $dateTo = (string)$this->getParameter('dateTo'); try { return invoicing_period_utils::normalizeDateRange($dateFrom, $dateTo); } catch (\InvalidArgumentException $e) { $response->error($e->getMessage(), 400); } } /** * @return int[]|null */ private function getOptionalCustomerNumbersParameter(): ?array { if (!self::isParametersSet(['customerNumbers'])) { return null; } return self::normalizeCustomerNumbers(self::getParameter('customerNumbers')); } /** * @return int[] */ private static function normalizeCustomerNumbers(mixed $customerNumbers): array { if ($customerNumbers === null || $customerNumbers === '') { return []; } $rawValues = is_array($customerNumbers) ? $customerNumbers : explode(',', (string)$customerNumbers); $normalized = []; foreach ($rawValues as $value) { $parsed = (int)trim((string)$value); if ($parsed < 1) { continue; } $normalized[$parsed] = $parsed; } return array_values($normalized); } /** * @param int[]|null $onlyCustomerNumbers * @return int[] */ private static function filterCustomerNumbers(array $customerNumbers, ?array $onlyCustomerNumbers = null): array { $customerNumbers = self::normalizeCustomerNumbers($customerNumbers); if ($onlyCustomerNumbers === null) { return $customerNumbers; } $allowed = array_fill_keys(self::normalizeCustomerNumbers($onlyCustomerNumbers), true); if (empty($allowed)) { return []; } return array_values(array_filter($customerNumbers, static function (int $customerNumber) use ($allowed): bool { return isset($allowed[$customerNumber]); })); } /** * @param array> $customers * @return array> */ private static function indexCustomersByNumber(array $customers): array { $customersByNumber = []; foreach ($customers as $customer) { $customerNumber = (int)($customer['customer_number'] ?? 0); if ($customerNumber > 0) { $customersByNumber[$customerNumber] = $customer; } } return $customersByNumber; } /** * Response cache TTL (seconds) for v2 distribution endpoints. * Set `INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL` to override. */ private function getDistributionV2CacheTtl(): int { $raw = getenv('INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL'); if ($raw === false || trim((string)$raw) === '') { return 300; } return max(0, (int)$raw); } private function getDistributionV2CacheKey(string $scope, string $dateFrom, string $dateTo): string { return 'invoicing_period:distribution:v2:' . $scope . ':' . md5($dateFrom . '|' . $dateTo); } /** * Best-effort Redis cache wrapper for v2 distribution payloads. * Falls back to direct computation when Redis is unavailable or TTL is disabled. * * @param callable():array $resolver * @return array */ private function withCachedDistributionV2(string $scope, string $dateFrom, string $dateTo, callable $resolver): array { $cacheTtl = $this->getDistributionV2CacheTtl(); if ($cacheTtl <= 0 || !defined('redis')) { return (array)$resolver(); } $cacheKey = $this->getDistributionV2CacheKey($scope, $dateFrom, $dateTo); try { $cached = redis->get($cacheKey); if (is_string($cached) && $cached !== '') { $decoded = json_decode($cached, true); if (is_array($decoded)) { return $decoded; } } } catch (\Throwable $e) { // Best-effort cache read. } $result = (array)$resolver(); try { $encoded = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if (is_string($encoded)) { redis->setEx($cacheKey, $encoded, $cacheTtl); } } catch (\Throwable $e) { // Best-effort cache write. } return $result; } /** * @param array $collective_results * @return array * @throws Exception */ private static function parseTheDepartmentIdsToDepartmentNames(array $collective_results): array { foreach ( $collective_results['total_department_totals'] as $department_id => $amount ) { $collective_results['total_department_totals_parsed'][self::getDepartmentNameCached((int)$department_id)] = $amount; } foreach ( $collective_results['total_department_totals_relative'] as $department_id => $amount ) { $collective_results['total_department_totals_relative_parsed'][self::getDepartmentNameCached((int)$department_id)] = $amount; } return $collective_results; } private static function jsonFragment(mixed $value): string { $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); return is_string($json) ? $json : 'null'; } private static function streamInvoicingPeriodResponse(array $period): void { global $response; header('Content-Type: application/json; charset=utf-8'); http_response_code(200); echo '{"success":true,"data":{'; echo '"dateFrom":' . self::jsonFragment($period['dateFrom'] ?? null); echo ',"dateTo":' . self::jsonFragment($period['dateTo'] ?? null); echo ',"types":{'; $types = is_array($period['types'] ?? null) ? $period['types'] : []; $firstType = true; foreach ($types as $typeName => $customers) { if (!$firstType) { echo ','; } $firstType = false; echo self::jsonFragment((string)$typeName) . ':['; $firstCustomer = true; foreach ((array)$customers as $customer) { if (!$firstCustomer) { echo ','; } $firstCustomer = false; echo self::jsonFragment($customer); } echo ']'; } echo '}'; foreach ($period as $key => $value) { if (in_array((string)$key, ['dateFrom', 'dateTo', 'types'], true)) { continue; } echo ',' . self::jsonFragment((string)$key) . ':' . self::jsonFragment($value); } echo '}'; echo ',"meta":' . self::jsonFragment($response->get_meta()); echo ',"includes":' . self::jsonFragment($response->get_includes()); echo '}'; exit; } private static function periodTypeNames(): array { return [ 'all', 'vehicle_subscriptions', 'fixed_pricing', 'tank_cleaning', 'special_arrangements', 'invoice_per_order', 'possible_duplicates', ]; } private static function getPeriodPaginationOptionsFromRequest(): ?array { global $response; $paginationKeys = [ 'periodView', 'page', 'limit', 'search', 'flagTab', 'includeRequiresAction', 'includeBooked', 'reviewState', 'severity', 'invoiceState', 'departmentId', 'sort', 'direction', ]; $isPaginatedRequest = false; foreach ($paginationKeys as $key) { if ($response->isRequestParameterSet($key)) { $isPaginatedRequest = true; break; } } if (!$isPaginatedRequest) { return null; } $parameters = $response->getAllRequestParameters(); foreach (['reviewState', 'severity', 'invoiceState', 'departmentId'] as $repeatableKey) { $repeatedValues = self::getRepeatedPeriodQueryValues($repeatableKey); if ($repeatedValues !== []) { $parameters[$repeatableKey] = $repeatedValues; } } return self::normalizePeriodPaginationOptions($parameters); } private static function getRepeatedPeriodQueryValues(string $key): array { $queryString = (string)($_SERVER['QUERY_STRING'] ?? ''); if ($queryString === '') { return []; } $values = []; foreach (explode('&', $queryString) as $part) { [$rawName, $rawValue] = array_pad(explode('=', $part, 2), 2, ''); $name = urldecode($rawName); if ($name === $key || $name === $key . '[]') { $values[] = urldecode(str_replace('+', ' ', $rawValue)); } } return $values; } private static function normalizePeriodPaginationOptions(array $parameters): array { $allowedViews = array_fill_keys(self::periodTypeNames(), true); $periodView = trim((string)($parameters['periodView'] ?? 'all')); if ($periodView === '' || !isset($allowedViews[$periodView])) { $periodView = 'all'; } $page = (int)($parameters['page'] ?? 1); if ($page < 1) { $page = 1; } $limitParameter = strtolower(trim((string)($parameters['limit'] ?? '100'))); if ($limitParameter === 'all') { $limit = 'all'; } else { $limit = (int)$limitParameter; if ($limit < 1) { $limit = 100; } $limit = min(500, $limit); } $allowedFlagTabs = ['all' => true, 'red' => true, 'yellow' => true, 'none' => true, 'filters' => true]; $flagTab = trim((string)($parameters['flagTab'] ?? 'all')); if ($flagTab === '' || !isset($allowedFlagTabs[$flagTab])) { $flagTab = 'all'; } $allowedSortFields = ['priority', 'customer_name', 'customer_number', 'total_amount']; $sort = trim((string)($parameters['sort'] ?? 'customer_name')); if (!in_array($sort, $allowedSortFields, true)) { $sort = 'customer_name'; } $direction = strtolower(trim((string)($parameters['direction'] ?? 'asc'))); if (!in_array($direction, ['asc', 'desc'], true)) { $direction = 'asc'; } return [ 'periodView' => $periodView, 'page' => $page, 'limit' => $limit, 'search' => trim((string)($parameters['search'] ?? '')), 'flagTab' => $flagTab, 'reviewStates' => self::normalizePeriodFilterValues( $parameters['reviewState'] ?? null, ['blocked', 'attention', 'queued', 'ready', 'completed'] ), 'severities' => self::normalizePeriodFilterValues( $parameters['severity'] ?? null, ['red', 'yellow', 'blue', 'green'] ), 'invoiceStates' => self::normalizePeriodFilterValues( $parameters['invoiceState'] ?? null, ['open', 'closed', 'economic_draft', 'economic_booked'] ), 'departmentIds' => self::normalizePeriodIntegerFilterValues($parameters['departmentId'] ?? null), 'sort' => $sort, 'direction' => $direction, 'includeRequiresAction' => self::parsePeriodBooleanOption( $parameters['includeRequiresAction'] ?? null, true ), 'includeBooked' => self::parsePeriodBooleanOption($parameters['includeBooked'] ?? null, true), ]; } private static function normalizePeriodFilterValues(mixed $value, array $allowed): array { $values = is_array($value) ? $value : [$value]; $normalized = []; foreach ($values as $entry) { foreach (explode(',', (string)$entry) as $candidate) { $candidate = strtolower(trim($candidate)); if ($candidate !== '' && in_array($candidate, $allowed, true)) { $normalized[$candidate] = true; } } } return array_keys($normalized); } private static function normalizePeriodIntegerFilterValues(mixed $value): array { $values = is_array($value) ? $value : [$value]; $normalized = []; foreach ($values as $entry) { foreach (explode(',', (string)$entry) as $candidate) { $candidate = (int)trim($candidate); if ($candidate > 0) { $normalized[$candidate] = true; } } } return array_map('intval', array_keys($normalized)); } private static function parsePeriodBooleanOption(mixed $value, bool $default): bool { if ($value === null || $value === '') { return $default; } if (is_bool($value)) { return $value; } $normalized = strtolower(trim((string)$value)); if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) { return false; } if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) { return true; } return $default; } private static function applyPeriodPagination(array $period, array $options): array { $types = is_array($period['types'] ?? null) ? $period['types'] : []; $types = self::ensurePeriodTypeKeys($types); $types = self::enrichPeriodCustomerMetaFromTypes($types); $types = self::enrichPeriodCustomerReview($types); $types = self::filterPeriodTypesBySearch($types, (string)($options['search'] ?? '')); $types = self::filterPeriodTypesByVisibility( $types, (bool)($options['includeRequiresAction'] ?? true), (bool)($options['includeBooked'] ?? true) ); $periodView = (string)($options['periodView'] ?? 'all'); if (!array_key_exists($periodView, $types)) { $periodView = 'all'; } $typeCounts = self::summarizePeriodTypes($types); $facets = self::summarizePeriodReviewFacets($types[$periodView] ?? []); if (!empty($options['flagTab'])) { $types = self::filterPeriodTypesByFlagTab($types, (string)$options['flagTab']); } $types = self::filterPeriodTypesByWorkflow($types, $options); $types = self::sortPeriodTypes( $types, (string)($options['sort'] ?? 'customer_name'), (string)($options['direction'] ?? 'asc') ); $total = count($types[$periodView] ?? []); $limit = $options['limit'] ?? 100; $isAllLimit = $limit === 'all'; $perPage = $isAllLimit ? 'all' : max(1, min(500, (int)$limit)); $totalPages = $isAllLimit || $total === 0 ? 1 : (int)ceil($total / $perPage); $page = $isAllLimit ? 1 : max(1, (int)($options['page'] ?? 1)); $page = min($page, $totalPages); $pagedTypes = array_fill_keys(array_keys($types), []); if ($isAllLimit) { $pagedTypes[$periodView] = array_values($types[$periodView] ?? []); } else { $offset = ($page - 1) * $perPage; $pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage); } $period['types'] = $pagedTypes; $period['type_counts'] = $typeCounts; $period['type_totals'] = self::summarizePeriodTypeTotals($types); return [ 'period' => $period, 'pagination' => [ 'page' => $page, 'per_page' => $perPage, 'total' => $total, 'total_pages' => $totalPages, 'search' => (string)($options['search'] ?? ''), 'filters' => [ 'includeRequiresAction' => (bool)($options['includeRequiresAction'] ?? true), 'includeBooked' => (bool)($options['includeBooked'] ?? true), 'flagTab' => (string)($options['flagTab'] ?? 'all'), 'reviewState' => array_values($options['reviewStates'] ?? []), 'severity' => array_values($options['severities'] ?? []), 'invoiceState' => array_values($options['invoiceStates'] ?? []), 'departmentId' => array_values($options['departmentIds'] ?? []), ], 'order' => [ 'field' => (string)($options['sort'] ?? 'customer_name'), 'direction' => (string)($options['direction'] ?? 'asc'), ], 'facets' => $facets, ], ]; } private static function enrichPeriodCustomerReview(array $types): array { foreach ($types as $typeName => $customers) { foreach ((array)$customers as $index => $customer) { if (is_array($customer)) { $types[$typeName][$index]['review'] = self::derivePeriodCustomerReview($customer); } } } return $types; } private static function derivePeriodCustomerReview(array $customer): array { $flagCounts = self::getActivePeriodFlagCounts($customer); $counts = [ 'active_manual_flags' => $flagCounts['manual'], 'active_automatic_flags' => $flagCounts['automatic'], 'collection_errors' => 0, 'active_queue_jobs' => 0, 'booked_transactions' => 0, 'unbooked_transactions' => 0, ]; $reasons = []; foreach ((array)($customer['invoice_collections'] ?? []) as $collection) { if (is_array($collection) && trim((string)($collection['error_message'] ?? '')) !== '') { $counts['collection_errors']++; } } foreach ((array)($customer['transactions'] ?? []) as $transaction) { if (!is_array($transaction) || (bool)($transaction['excluded'] ?? false)) { continue; } $key = (bool)($transaction['booked'] ?? false) ? 'booked_transactions' : 'unbooked_transactions'; $counts[$key]++; } if ((bool)($customer['queue']['has_active_job'] ?? false)) { $counts['active_queue_jobs'] = max( 1, count((array)($customer['queue']['invoice_collection_ids'] ?? [])) ); } if ($counts['active_manual_flags'] > 0) { $reasons[] = self::periodReviewReason('manual_flags', 'red', $counts['active_manual_flags']); } if ($counts['collection_errors'] > 0) { $reasons[] = self::periodReviewReason('collection_errors', 'red', $counts['collection_errors']); } if ((bool)($customer['draft']['is_action_blocked'] ?? false)) { $reasons[] = self::periodReviewReason('draft_blocks_action', 'red', 1); } $requiresAttention = (bool)($customer['requires_action'] ?? false) && $counts['unbooked_transactions'] === 0; if ($requiresAttention) { $reasons[] = self::periodReviewReason('requires_action', 'yellow', 1); } if ($counts['active_automatic_flags'] > 0) { $reasons[] = self::periodReviewReason('automatic_warnings', 'yellow', $counts['active_automatic_flags']); } if ($counts['active_queue_jobs'] > 0) { $reasons[] = self::periodReviewReason('export_in_progress', 'blue', $counts['active_queue_jobs']); } if ($counts['unbooked_transactions'] > 0) { $reasons[] = self::periodReviewReason('unbooked_transactions', 'green', $counts['unbooked_transactions']); } if ($counts['active_manual_flags'] > 0) { [$state, $severity, $nextAction] = ['blocked', 'red', 'resolve_manual_flags']; } elseif ($counts['collection_errors'] > 0) { [$state, $severity, $nextAction] = ['blocked', 'red', 'resolve_collection_errors']; } elseif ((bool)($customer['draft']['is_action_blocked'] ?? false)) { [$state, $severity, $nextAction] = ['blocked', 'red', 'resolve_draft']; } elseif ($counts['active_automatic_flags'] > 0 || $requiresAttention) { [$state, $severity, $nextAction] = ['attention', 'yellow', 'review_warnings']; } elseif ($counts['active_queue_jobs'] > 0) { [$state, $severity, $nextAction] = ['queued', 'blue', 'wait_for_export']; } elseif ($counts['booked_transactions'] > 0 && $counts['unbooked_transactions'] === 0) { [$state, $severity, $nextAction] = ['completed', 'green', 'none']; } else { [$state, $severity, $nextAction] = ['ready', 'green', 'create_invoice']; } return [ 'state' => $state, 'severity' => $severity, 'reasons' => $reasons, 'next_action' => $nextAction, 'is_actionable' => in_array($state, ['blocked', 'attention', 'ready'], true), 'counts' => $counts, ]; } private static function periodReviewReason(string $code, string $severity, int $count): array { return ['code' => $code, 'severity' => $severity, 'count' => $count]; } private static function filterPeriodTypesByFlagTab(array $types, string $flagTab): array { if (in_array($flagTab, ['all', 'filters', ''], true)) { return $types; } foreach ($types as $viewName => $entries) { $types[$viewName] = array_values(array_filter( is_array($entries) ? $entries : [], static function (array $customer) use ($flagTab): bool { $tab = 'none'; $flagCounts = self::getActivePeriodFlagCounts($customer); if ($flagCounts['manual'] > 0) { $tab = 'red'; } elseif ($flagCounts['automatic'] > 0) { $tab = 'yellow'; } return $tab === $flagTab; } )); } return $types; } private static function filterPeriodTypesByWorkflow(array $types, array $options): array { $reviewStates = array_fill_keys((array)($options['reviewStates'] ?? []), true); $severities = array_fill_keys((array)($options['severities'] ?? []), true); $invoiceStates = array_fill_keys((array)($options['invoiceStates'] ?? []), true); $departmentIds = array_fill_keys(array_map('intval', (array)($options['departmentIds'] ?? [])), true); if ($reviewStates === [] && $severities === [] && $invoiceStates === [] && $departmentIds === []) { return $types; } foreach ($types as $typeName => $customers) { $types[$typeName] = array_values(array_filter( is_array($customers) ? $customers : [], static function (array $customer) use ( $reviewStates, $severities, $invoiceStates, $departmentIds ): bool { if ($reviewStates !== [] && !isset($reviewStates[(string)($customer['review']['state'] ?? '')])) { return false; } if ($severities !== [] && !isset($severities[(string)($customer['review']['severity'] ?? '')])) { return false; } if ($invoiceStates !== [] && !self::periodCustomerMatchesInvoiceStates($customer, $invoiceStates)) { return false; } if ($departmentIds !== [] && !self::periodCustomerMatchesDepartmentIds($customer, $departmentIds)) { return false; } return true; } )); } return $types; } private static function periodCustomerMatchesInvoiceStates(array $customer, array $allowed): bool { foreach ((array)($customer['invoice_collections'] ?? []) as $collection) { if (is_array($collection) && isset($allowed[(string)($collection['state'] ?? '')])) { return true; } } foreach ((array)($customer['transactions'] ?? []) as $transaction) { if (is_array($transaction) && isset($allowed[(string)($transaction['invoice_state'] ?? '')])) { return true; } } return false; } private static function periodCustomerMatchesDepartmentIds(array $customer, array $allowed): bool { foreach ((array)($customer['transactions'] ?? []) as $transaction) { if (is_array($transaction) && isset($allowed[(int)($transaction['department_id'] ?? 0)])) { return true; } } return false; } private static function sortPeriodTypes(array $types, string $field, string $direction): array { foreach ($types as $typeName => $customers) { $customers = is_array($customers) ? array_values($customers) : []; usort($customers, static function (array $a, array $b) use ($field, $direction): int { $comparison = self::comparePeriodCustomers($a, $b, $field); if ($comparison !== 0) { return $direction === 'desc' ? -$comparison : $comparison; } return ((int)($a['customer_number'] ?? 0)) <=> ((int)($b['customer_number'] ?? 0)); }); $types[$typeName] = $customers; } return $types; } private static function comparePeriodCustomers(array $a, array $b, string $field): int { if ($field === 'priority') { $rank = ['blocked' => 0, 'attention' => 1, 'queued' => 2, 'ready' => 3, 'completed' => 4]; return ($rank[(string)($a['review']['state'] ?? '')] ?? 99) <=> ($rank[(string)($b['review']['state'] ?? '')] ?? 99); } if ($field === 'customer_number') { return ((int)($a['customer_number'] ?? 0)) <=> ((int)($b['customer_number'] ?? 0)); } if ($field === 'total_amount') { return self::getPeriodCustomerTotalAmount($a) <=> self::getPeriodCustomerTotalAmount($b); } return strnatcasecmp((string)($a['customer_name'] ?? ''), (string)($b['customer_name'] ?? '')); } private static function summarizePeriodReviewFacets(array $customers): array { $facets = [ 'review_states' => [], 'severities' => [], 'invoice_states' => [], 'department_ids' => [], ]; foreach ($customers as $customer) { if (!is_array($customer)) { continue; } self::incrementPeriodFacet($facets['review_states'], (string)($customer['review']['state'] ?? '')); self::incrementPeriodFacet($facets['severities'], (string)($customer['review']['severity'] ?? '')); $customerInvoiceStates = []; $customerDepartmentIds = []; foreach ((array)($customer['invoice_collections'] ?? []) as $collection) { if (is_array($collection)) { $state = (string)($collection['state'] ?? ''); if ($state !== '') { $customerInvoiceStates[$state] = true; } } } foreach ((array)($customer['transactions'] ?? []) as $transaction) { if (!is_array($transaction)) { continue; } $state = (string)($transaction['invoice_state'] ?? ''); if ($state !== '') { $customerInvoiceStates[$state] = true; } $departmentId = (int)($transaction['department_id'] ?? 0); if ($departmentId > 0) { $customerDepartmentIds[$departmentId] = true; } } foreach (array_keys($customerInvoiceStates) as $state) { self::incrementPeriodFacet($facets['invoice_states'], (string)$state); } foreach (array_keys($customerDepartmentIds) as $departmentId) { self::incrementPeriodFacet($facets['department_ids'], (string)$departmentId); } } foreach ($facets as $name => $counts) { ksort($counts, SORT_NATURAL); $facets[$name] = array_map( static fn(string|int $value, int $count): array => ['value' => (string)$value, 'count' => $count], array_keys($counts), array_values($counts) ); } return $facets; } private static function incrementPeriodFacet(array &$counts, string $value): void { if ($value !== '') { $counts[$value] = ($counts[$value] ?? 0) + 1; } } private static function ensurePeriodTypeKeys(array $types): array { foreach (self::periodTypeNames() as $typeName) { if (!array_key_exists($typeName, $types) || !is_array($types[$typeName])) { $types[$typeName] = []; } } return $types; } private static function enrichPeriodCustomerMetaFromTypes(array $types): array { $metaByCustomerNumber = []; foreach (['fixed_pricing', 'vehicle_subscriptions'] as $typeName) { foreach (($types[$typeName] ?? []) as $customer) { $customerNumber = (int)($customer['customer_number'] ?? 0); if ($customerNumber < 1) { continue; } $meta = is_array($customer['meta'] ?? null) ? $customer['meta'] : []; if ($meta === []) { continue; } $metaByCustomerNumber[$customerNumber] = array_merge( $metaByCustomerNumber[$customerNumber] ?? [], $meta ); } } if ($metaByCustomerNumber === []) { return $types; } foreach ($types as $typeName => $customers) { foreach ($customers as $index => $customer) { $customerNumber = (int)($customer['customer_number'] ?? 0); if ($customerNumber < 1 || !isset($metaByCustomerNumber[$customerNumber])) { continue; } $types[$typeName][$index]['meta'] = array_merge( is_array($customer['meta'] ?? null) ? $customer['meta'] : [], $metaByCustomerNumber[$customerNumber] ); } } return $types; } private static function filterPeriodTypesBySearch(array $types, string $search): array { $search = self::normalizePeriodSearchTerm($search); if ($search === '') { return $types; } foreach ($types as $typeName => $customers) { $types[$typeName] = array_values(array_filter( is_array($customers) ? $customers : [], static fn(array $customer): bool => self::periodCustomerMatchesSearch($customer, $search) )); } return $types; } private static function filterPeriodTypesByVisibility( array $types, bool $includeRequiresAction, bool $includeBooked ): array { foreach ($types as $typeName => $customers) { $types[$typeName] = array_values(array_filter( is_array($customers) ? $customers : [], static function (array $customer) use ($includeRequiresAction, $includeBooked): bool { if (!$includeRequiresAction && (bool)($customer['requires_action'] ?? false)) { return false; } if ( !$includeBooked && !((bool)($customer['requires_action'] ?? false)) && self::areAllPeriodCustomerTransactionsBooked($customer) ) { return false; } return true; } )); } return $types; } private static function periodCustomerMatchesSearch(array $customer, string $search): bool { $values = [ $customer['customer_number'] ?? '', $customer['customer_name'] ?? '', ]; foreach (($customer['transactions'] ?? []) as $transaction) { if (!is_array($transaction)) { continue; } foreach (['id', 'reference', 'po', 'notes', 'reg_1', 'reg_2', 'reg_3'] as $field) { $values[] = $transaction[$field] ?? ''; } } foreach (['invoice_collections', 'flags', 'review', 'queue', 'draft', 'meta', 'flag_counts'] as $field) { self::appendPeriodSearchValues($values, $customer[$field] ?? null); } foreach ($values as $value) { if (str_contains(self::normalizePeriodSearchTerm((string)$value), $search)) { return true; } } return false; } private static function appendPeriodSearchValues(array &$values, mixed $value): void { if (is_array($value)) { foreach ($value as $entry) { self::appendPeriodSearchValues($values, $entry); } return; } if (is_scalar($value)) { $values[] = $value; } } private static function normalizePeriodSearchTerm(string $value): string { return mb_strtolower(trim($value), 'UTF-8'); } private static function areAllPeriodCustomerTransactionsBooked(array $customer): bool { $transactions = is_array($customer['transactions'] ?? null) ? $customer['transactions'] : []; foreach ($transactions as $transaction) { if (!is_array($transaction) || (bool)($transaction['booked'] ?? false) !== true) { return false; } } return true; } private static function summarizePeriodTypes(array $types): array { $counts = []; foreach ($types as $typeName => $customers) { $counts[$typeName] = self::summarizePeriodType(is_array($customers) ? $customers : []); } return $counts; } private static function summarizePeriodTypeTotals(array $types): array { $totals = []; foreach ($types as $typeName => $customers) { $totals[$typeName] = self::summarizePeriodTypeTotalsForCustomers( is_array($customers) ? $customers : [] ); } return $totals; } private static function summarizePeriodTypeTotalsForCustomers(array $customers): array { $total = 0.0; $booked = 0.0; foreach ($customers as $customer) { if (!is_array($customer)) { continue; } $total += self::getPeriodCustomerTotalAmount($customer); $booked += self::sumPeriodCustomerTransactions($customer, true); } return [ 'total' => $total, 'booked' => $booked, 'not_booked' => $total - $booked, ]; } private static function getPeriodCustomerTotalAmount(array $customer): float { $fixedPrice = $customer['meta']['fixed_pricing']['price'] ?? null; if ($fixedPrice !== null && $fixedPrice !== '') { return (float)$fixedPrice; } return self::sumPeriodCustomerTransactions($customer, false); } private static function sumPeriodCustomerTransactions(array $customer, bool $bookedOnly): float { $total = 0.0; $transactions = is_array($customer['transactions'] ?? null) ? $customer['transactions'] : []; foreach ($transactions as $transaction) { if (!is_array($transaction)) { continue; } if ((bool)($transaction['excluded'] ?? false)) { continue; } if ($bookedOnly && (bool)($transaction['booked'] ?? false) !== true) { continue; } $total += (float)($transaction['amount'] ?? $transaction['net_amount'] ?? 0); } return $total; } private static function summarizePeriodType(array $customers): array { $requiresAction = 0; $draft = 0; $manualFlags = 0; $automaticFlags = 0; foreach ($customers as $customer) { if ((bool)($customer['requires_action'] ?? false)) { $requiresAction++; } if (($customer['draft']['is_action_blocked'] ?? false) === true) { $draft++; } $flagCounts = self::getActivePeriodFlagCounts($customer); if ($flagCounts['manual'] > 0) { $manualFlags++; } if ($flagCounts['automatic'] > 0) { $automaticFlags++; } } return [ 'requires_action' => $requiresAction, 'draft' => $draft, 'manual_flags' => $manualFlags, 'automatic_flags' => $automaticFlags, 'completed' => max(0, count($customers) - $requiresAction - $draft), 'total' => count($customers), ]; } private static function getActivePeriodFlagCounts(array $customer): array { $manual = 0; $automatic = 0; if (is_array($customer['flags'] ?? null)) { foreach ($customer['flags'] as $flag) { if (!is_array($flag) || (string)($flag['status'] ?? 'active') !== 'active') { continue; } if (($flag['source'] ?? null) === 'manual') { $manual++; } elseif (($flag['source'] ?? null) === 'automatic') { $automatic++; } } return [ 'manual' => $manual, 'automatic' => $automatic, 'total' => $manual + $automatic, ]; } return [ 'manual' => (int)($customer['flag_counts']['manual'] ?? 0), 'automatic' => (int)($customer['flag_counts']['automatic'] ?? 0), 'total' => (int)($customer['flag_counts']['total'] ?? 0), ]; } public function run(): void { $this->post('/superuser/invoicing/period/object-tree/canary', function () { global $response; $this->requirePermission('superuser'); self::requireParameters(['enabled']); $user = (new authentication())->get_user(); if (!$user) { $response->error('Invalid session', 400); } $enabled = filter_var( $this->getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); if ($enabled === null) { $response->error('Parameter enabled must be a boolean', 400); } $allowlist = self::setInvoicePeriodObjectTreeV2CanaryUser((int)$user->id, $enabled); $effectiveEnabled = self::isInvoicePeriodObjectTreeV2Enabled((int)$user->id); if ($enabled && !$effectiveEnabled) { self::setInvoicePeriodObjectTreeV2CanaryUser((int)$user->id, false); (new logs_o())->add( 'invoicing_period', 'global', 1, (int)$user->id, 'OBJECT_TREE_V2_CANARY_ENABLE_FAILED', 'Invoice period object-tree canary failed schema readiness verification and was rolled back' ); $response->error('Invoice period object-tree schema is not ready', 503); } (new logs_o())->add( 'invoicing_period', 'global', 1, (int)$user->id, $enabled ? 'OBJECT_TREE_V2_CANARY_ENABLED' : 'OBJECT_TREE_V2_CANARY_DISABLED', $enabled ? 'Enabled invoice period object-tree canary for current superuser' : 'Disabled invoice period object-tree canary for current superuser' ); $response->success([ 'configured_enabled' => $enabled, 'effective_enabled' => $effectiveEnabled, 'user_id' => (int)$user->id, 'allowlisted_user_ids' => $allowlist, ]); }, [ 'superuser' => 'Enable or disable the invoice period object-tree canary for the current superuser', ]); $this->get('/superuser/invoicing/period', function () { // Require the user to be logged in global $response; $this->requirePermission('superuser_invoicing_period'); // Get the user object $user = (new authentication())->get_user(); // Check if the request was successful if ($user) { $dateRange = $this->requireAndNormalizeDateRange(); $dateFrom = $dateRange['dateFrom']; $dateTo = $dateRange['dateTo']; $customerNumbers = $this->getOptionalCustomerNumbersParameter(); // Add date from and date to to the response meta $response->add_meta('date_from', $dateFrom); $response->add_meta('date_to', $dateTo); if ($customerNumbers !== null) { $response->add_meta('customer_numbers', $customerNumbers); } $paginationOptions = self::getPeriodPaginationOptionsFromRequest(); $includeInvoicePeriodFlags = $this->hasPermission('list_invoice_period_flags'); $objectTreeV2Enabled = self::isInvoicePeriodObjectTreeV2Enabled((int)$user->id); // Get the invoicing period for the user $period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers, $includeInvoicePeriodFlags); $period = self::applyInvoicePeriodObjectTreeCapability($period, $objectTreeV2Enabled); $response->add_meta('invoice_period_object_tree_v2', $objectTreeV2Enabled); if ($paginationOptions !== null) { $paginated = self::applyPeriodPagination($period, $paginationOptions); $period = $paginated['period']; $response->add_meta('pagination', $paginated['pagination']); } self::streamInvoicingPeriodResponse($period); } else { // Log the incident (new logs_o())->add('vehicles', 'global', 1, 0, 'LIST_OWN_VEHICLES', 'No user found, or invalid session'); // Return an error $response->error('Invalid session', 400); } }, [ 'superuser_invoicing_period' => 'Get the invoicing period for superusers', 'list_invoice_period_flags' => 'List invoice period flags in the period response', ] ); $this->get('/superuser/invoicing/period/tree', function () { global $response; $this->requirePermission('superuser_invoicing_period'); $user = (new authentication())->get_user(); if (!$user) { $response->error('Invalid session', 400); } if (!self::isInvoicePeriodObjectTreeV2Enabled((int)$user->id)) { $response->error('Invoice-period object tree is not enabled.', 403); } self::requireParameters(['customerNumber', 'dateFrom', 'dateTo']); $customerNumber = (int)$this->getParameter('customerNumber'); if ($customerNumber < 1) { $response->error('customerNumber must be a positive integer', 400); } $dateRange = $this->requireAndNormalizeDateRange(); try { $tree = $this->buildInvoicePeriodTreeSnapshot( $customerNumber, $dateRange['dateFrom'], $dateRange['dateTo'], (int)$user->id ); $response->success($tree); } catch (invoice_collection_bulk_action_validation $e) { $response->error($e->getMessage(), 400); } catch (invoice_collection_bulk_action_conflict $e) { $response->error($e->getMessage(), 409); } catch (\Throwable $e) { $response->error($e->getMessage(), 500); } }, [ 'superuser_invoicing_period' => 'Get one selected customer invoice-period object tree snapshot.', 'list_invoice_period_flags' => 'Include invoice period flags when permitted.', ] ); $this->post('/superuser/invoicing/period/flags', function () { global $response; $this->requirePermission('add_invoice_period_flag'); $user = (new authentication())->get_user(); if (!$user) { $response->error('Invalid session', 400); } try { $flag = (new invoice_period_flag_service())->createManualFlag( $this->getParametersAsArray(), (int)$user->id ); $response->success($flag); } catch (\InvalidArgumentException $e) { $response->error($e->getMessage(), 400); } catch (\Throwable $e) { $response->error($e->getMessage(), 500); } }, [ 'add_invoice_period_flag' => 'Add a manual invoice period flag', ] ); $this->patch('/superuser/invoicing/period/flags/{id}/status', function () { global $response; $this->requirePermission('update_invoice_period_flag_status'); $user = (new authentication())->get_user(); if (!$user) { $response->error('Invalid session', 400); } $id = (int)($this->fromRoute('id') ?? 0); try { $flag = (new invoice_period_flag_service())->updateManualFlagStatus( $id, (string)$this->getParameter('status'), $this->isParametersSet(['reason']) ? (string)$this->getParameter('reason') : null, (int)$user->id ); $response->success($flag); } catch (\InvalidArgumentException $e) { $response->error($e->getMessage(), 400); } catch (\Throwable $e) { $response->error($e->getMessage(), 500); } }, [ 'update_invoice_period_flag_status' => 'Update a manual invoice period flag status', ] ); $this->post('/superuser/invoicing/period/flags/automatic/status', function () { global $response; $this->requirePermission('update_invoice_period_flag_status'); $user = (new authentication())->get_user(); if (!$user) { $response->error('Invalid session', 400); } try { $flag = (new invoice_period_flag_service())->updateAutomaticFlagStatus( $this->getParametersAsArray(), (int)$user->id ); $response->success($flag); } catch (\InvalidArgumentException $e) { $response->error($e->getMessage(), 400); } catch (\Throwable $e) { $response->error($e->getMessage(), 500); } }, [ 'update_invoice_period_flag_status' => 'Update an automatic invoice period flag status', ] ); $this->get('/superuser/invoicing/period/distribution/all', function () { // Require the user to be logged in global $response; $this->requirePermission('superuser_invoicing_period'); $dateRange = $this->requireAndNormalizeDateRange(); $dateFrom = $dateRange['dateFrom']; $dateTo = $dateRange['dateTo']; // Add date from and date to to the response meta $response->add_meta('date_from', $dateFrom); $response->add_meta('date_to', $dateTo); $result = [ 'subscriptions' => self::getTransactionsWithItemsNotIncludedInInvoices(self::getVehicleSubscriptions($dateFrom, $dateTo)), 'fixed_pricing' => self::getOriginalPrice(self::getFixedPricing($dateFrom, $dateTo)), ]; $response->success($result); }, [ 'superuser_invoicing_period' => 'Get the invoicing period for superusers', ] ); $this->get('/superuser/invoicing/period/distribution/fixed-pricing', function () { // Require the user to be logged in global $response; $this->requirePermission('superuser_invoicing_period'); $dateRange = $this->requireAndNormalizeDateRange(); $dateFrom = $dateRange['dateFrom']; $dateTo = $dateRange['dateTo']; // Add date from and date to to the response meta $response->add_meta('date_from', $dateFrom); $response->add_meta('date_to', $dateTo); $response->success([...self::getOriginalPrice(self::getFixedPricing($dateFrom, $dateTo))]); }, [ 'superuser_invoicing_period' => 'Get the invoicing period for superusers', ] ); $this->get('/superuser/invoicing/period/distribution/wash-subscriptions', function () { // Require the user to be logged in global $response; $this->requirePermission('superuser_invoicing_period'); $dateRange = $this->requireAndNormalizeDateRange(); $dateFrom = $dateRange['dateFrom']; $dateTo = $dateRange['dateTo']; // Add date from and date to to the response meta $response->add_meta('date_from', $dateFrom); $response->add_meta('date_to', $dateTo); $response->success([...self::getTransactionsWithItemsNotIncludedInInvoices(self::getVehicleSubscriptions($dateFrom, $dateTo))]); }, [ 'superuser_invoicing_period' => 'Get the invoicing period for superusers', ] ); $this->get('/superuser/invoicing/period/distribution/v2/all', function () { global $response; $this->requirePermission('superuser_invoicing_period_distribution_v2'); $dateRange = $this->requireAndNormalizeDateRange(); $dateFrom = $dateRange['dateFrom']; $dateTo = $dateRange['dateTo']; $response->add_meta('date_from', $dateFrom); $response->add_meta('date_to', $dateTo); $response->success($this->withCachedDistributionV2('all', $dateFrom, $dateTo, static function () use ($dateFrom, $dateTo) { return (new economic_v2_distribution_service())->getAllDistributions($dateFrom, $dateTo); })); }, [ 'superuser_invoicing_period_distribution_v2' => 'Get historical version-aware departmental distribution (all categories).', ] ); $this->get('/superuser/invoicing/period/distribution/v2/fixed-pricing', function () { global $response; $this->requirePermission('superuser_invoicing_period_distribution_v2'); $dateRange = $this->requireAndNormalizeDateRange(); $dateFrom = $dateRange['dateFrom']; $dateTo = $dateRange['dateTo']; $response->add_meta('date_from', $dateFrom); $response->add_meta('date_to', $dateTo); $response->success($this->withCachedDistributionV2('fixed-pricing', $dateFrom, $dateTo, static function () use ($dateFrom, $dateTo) { return (new economic_v2_distribution_service())->getFixedPricingDistribution($dateFrom, $dateTo); })); }, [ 'superuser_invoicing_period_distribution_v2' => 'Get historical version-aware fixed pricing distribution.', ] ); $this->get('/superuser/invoicing/period/distribution/v2/wash-subscriptions', function () { global $response; $this->requirePermission('superuser_invoicing_period_distribution_v2'); $dateRange = $this->requireAndNormalizeDateRange(); $dateFrom = $dateRange['dateFrom']; $dateTo = $dateRange['dateTo']; $response->add_meta('date_from', $dateFrom); $response->add_meta('date_to', $dateTo); $response->success($this->withCachedDistributionV2('wash-subscriptions', $dateFrom, $dateTo, static function () use ($dateFrom, $dateTo) { return (new economic_v2_distribution_service())->getWashSubscriptionsDistribution($dateFrom, $dateTo); })); }, [ 'superuser_invoicing_period_distribution_v2' => 'Get historical version-aware wash subscription distribution.', ] ); $this->get('/superuser/invoicing/period/distribution/v2/customer-prices', function () { global $response; $this->requirePermission('superuser_invoicing_period_distribution_v2'); $dateRange = $this->requireAndNormalizeDateRange(); $dateFrom = $dateRange['dateFrom']; $dateTo = $dateRange['dateTo']; $response->add_meta('date_from', $dateFrom); $response->add_meta('date_to', $dateTo); $response->success($this->withCachedDistributionV2('customer-prices', $dateFrom, $dateTo, static function () use ($dateFrom, $dateTo) { return (new economic_v2_distribution_service())->getCustomerPricesDistribution($dateFrom, $dateTo); })); }, [ 'superuser_invoicing_period_distribution_v2' => 'Get historical version-aware customer discount distribution.', ] ); $this->get('/superuser/invoicing/period/distribution/v2/booked-department-75', function () { global $response; $this->requirePermission('superuser_invoicing_period_distribution_v2'); $dateRange = $this->requireAndNormalizeDateRange(); $dateFrom = $dateRange['dateFrom']; $dateTo = $dateRange['dateTo']; $response->add_meta('date_from', $dateFrom); $response->add_meta('date_to', $dateTo); $response->success($this->withCachedDistributionV2('booked-department-75', $dateFrom, $dateTo, static function () use ($dateFrom, $dateTo) { return (new economic_v2_distribution_service())->getBookedDepartment75Distribution($dateFrom, $dateTo); })); }, [ 'superuser_invoicing_period_distribution_v2' => 'Get booked e-conomic department 75 redistribution based on actual booked net amounts.', ] ); $this->get('/superuser/customers/pricing-history', function () { global $response; $this->requirePermission('superuser_customer_pricing_history_v2'); self::requireParameters(['customer_number']); self::requireType((int)self::getParameter('customer_number'), self::type_int()); $customer_number = (int)self::getParameter('customer_number'); self::requireMinValue($customer_number, 1); self::requireMaxValue($customer_number, 999999999); $dateRange = $this->requireAndNormalizeDateRange(); $dateFrom = $dateRange['dateFrom']; $dateTo = $dateRange['dateTo']; $versioning = new economic_v2_versioning_service(); $fixed_pricing = $versioning->listFixedPricingVersions($customer_number, $dateFrom, $dateTo); $vehicle_subscriptions = $versioning->listVehicleSubscriptionVersions($customer_number, $dateFrom, $dateTo); $discount_overrides = $versioning->listDiscountOverrideVersions($customer_number, $dateFrom, $dateTo); $timeline = []; foreach ($fixed_pricing as $row) { $timeline[] = [ 'type' => 'fixed_pricing', ...$row, ]; } foreach ($vehicle_subscriptions as $row) { $timeline[] = [ 'type' => 'vehicle_subscription', ...$row, ]; } foreach ($discount_overrides as $row) { $timeline[] = [ 'type' => 'discount_override', ...$row, ]; } usort($timeline, static function ($a, $b) { $left = strtotime((string)($a['effective_from'] ?? '1970-01-01 00:00:00')); $right = strtotime((string)($b['effective_from'] ?? '1970-01-01 00:00:00')); if ($left === $right) { return ((int)($a['id'] ?? 0)) <=> ((int)($b['id'] ?? 0)); } return $left <=> $right; }); $response->add_meta('date_from', $dateFrom); $response->add_meta('date_to', $dateTo); $response->success([ 'customer_number' => $customer_number, 'fixed_pricing' => $fixed_pricing, 'vehicle_subscriptions' => $vehicle_subscriptions, 'discount_overrides' => $discount_overrides, 'timeline' => $timeline, ]); }, [ 'superuser_customer_pricing_history_v2' => 'Get customer pricing/subscription/discount timeline with confidence and provenance.', ] ); $this->get('/superuser/invoicing/period/distribution/wash-subscriptions/historical', function () { // Require the user to be logged in global $response; $this->requirePermission('superuser_invoicing_period'); $dateRange = $this->requireAndNormalizeDateRange(); $dateFrom = $dateRange['dateFrom']; $dateTo = $dateRange['dateTo']; // Get all orders in the date range that have: // - Department ID: 10 // - Reference: Vaskeabonnementer $orders_o = new orders_o(); $orders = $orders_o->getFieldsWhere([ 'department_id' => 10, 'reference' => 'Vaskeabonnementer', ], ['id', 'created_at']); // Filter out the orders to only include those in the date range $orders = array_filter($orders, function ($order) use ($dateFrom, $dateTo) { $order_date = strtotime($order['created_at']); return $order_date >= strtotime($dateFrom) && $order_date <= strtotime($dateTo); }); // Construct the order objects $orders = array_map(function ($order) { return (new orders_o())->select((int)$order['id']); }, $orders); // Filter out deleted orders $orders = array_filter($orders, function ($order) { return $order->deleted_at->value() === null; }); // Define the warnings variable $warnings = []; // Define logs variable $logs = []; // Define the customer variable $unique_customers = []; foreach ( $orders as $order ) { $customer_id = (int)$order->customer_id->value(); if (!in_array($customer_id, $unique_customers)) { $unique_customers[] = $customer_id; } else { $warnings[] = "Duplicate order for customer ID " . $customer_id . " in order ID " . $order->id; } } // Define the total subscription amount $total_subscription_amount = 0; // Define the collection of order items for all subscriptions $all_subscription_order_items = []; // Add up the total subscription amount and collect the order items /** @var orders_o $order */ foreach ( $orders as $order ) { $customer_id = (int)$order->customer_id->value(); $logs[] = "[{$order->id}] Processing order items for customer ID {$customer_id}"; // Get the order items for the order $tmp_items = $order->getOrderItemObjects(); /** @var order_items_o $item */ foreach ( $tmp_items as $item ) { $price = (int)$item->price->value(); $amount = (int)$item->quantity->value(); $total_subscription_amount += $price * $amount; $all_subscription_order_items[] = $item; $logs[] = "[{$order->created_at->value()}] Processing order item ID {$item->id}, price: {$price}, quantity: {$amount}, subtotal: +" . ($price * $amount) . " (total: {$total_subscription_amount}, items: " . count($all_subscription_order_items) . ")"; } } unset($tmp_items, $item, $order, $customer_id, $price, $amount); // Define the total fixed pricing amount $total_fixed_pricing_amount = 0; $all_fixed_pricing_orders = []; $all_fixed_pricing_order_items = []; // Get all fixed pricing orders in the date range $fixed_pricing_orders = $orders_o->getFieldsWhere([ 'department_id' => 10, 'reference' => 'Fast pris aftale', ], ['id', 'created_at']); // Filter out the orders to only include those in the date range $fixed_pricing_orders = array_filter($fixed_pricing_orders, function ($order) use ($dateFrom, $dateTo) { $order_date = strtotime($order['created_at']); $dateFrom = date('Y-m-d 00:00:00', strtotime($dateFrom)); $dateToMonthEnd = date('Y-m-d 23:59:59', strtotime($dateTo)); return $order_date >= strtotime($dateFrom) && $order_date <= strtotime($dateToMonthEnd); }); // Construct the order objects $fixed_pricing_orders = array_map(function ($order) { return (new orders_o())->select((int)$order['id']); }, $fixed_pricing_orders); // Filter out deleted orders $fixed_pricing_orders = array_filter($fixed_pricing_orders, function ($order) { /** @var orders_o $order */ return !$order->deleted_at->value(); }); $unique_customers_fixed_pricing = []; // Add up the total fixed pricing amount and collect the order items foreach ( $fixed_pricing_orders as $order ) { $customer_id = (int)$order->customer_id->value(); $logs[] = "[{$order->id}] Processing fixed pricing order items for customer ID {$customer_id}"; if (!in_array($customer_id, $unique_customers_fixed_pricing)) { $unique_customers_fixed_pricing[] = $customer_id; } else { $warnings[] = "Duplicate fixed pricing order for customer ID " . $customer_id . " in order ID " . $order->id; } $all_fixed_pricing_orders[] = $order; // Get the order items for the order $tmp_items = $order->getOrderItemObjects(); /** @var order_items_o $item */ foreach ( $tmp_items as $item ) { $price = (int)$item->price->value(); $amount = (int)$item->quantity->value(); $total_fixed_pricing_amount += $price * $amount; $all_fixed_pricing_order_items[] = $item; $logs[] = "[{$order->created_at->value()}] Processing fixed pricing order item ID {$item->id}, price: {$price}, quantity: {$amount}, subtotal: +" . ($price * $amount) . " (total fixed pricing: {$total_fixed_pricing_amount}, items: " . count($all_fixed_pricing_order_items) . ")"; } } // Get the total combined amount $total_combined_amount = $total_subscription_amount + $total_fixed_pricing_amount; // Return the orders $response->success([ "hello" => "world", "orders" => count($orders), "unique_customers" => count($unique_customers), "warnings" => $warnings, "logs" => $logs, "total_subscription_amount" => $total_subscription_amount, "all_subscription_order_items" => count($all_subscription_order_items), "total_fixed_pricing_amount" => $total_fixed_pricing_amount, "all_fixed_pricing_order_items" => count($all_fixed_pricing_order_items), "total_combined_amount" => $total_combined_amount, "unique_customers_fixed_pricing" => count($unique_customers_fixed_pricing), "fixed_pricing_orders" => count($fixed_pricing_orders), ]); }); } /** * Get the transactions with items, that are not included in invoices. (include_in_invoice = 0 - order_items) * Requirements: * - The transaction must not be deleted. (deleted_at is null - orders)) * - The transaction contains at least one item that is not included in invoices. (include_in_invoice = 0 - order_items) * - The transaction must be within the specified date range. (created_at between dateFrom and dateTo - orders) * - The transaction item must not be deleted. (deleted_at is null - order_items) * Returns an array of customers with their transactions and items. * @param array $customersWithSubscriptions The customers with subscriptions. * @param array $dateRange The date range to filter the transactions. (dateFrom, dateTo) * @return array The customers with their transactions and items. * @throws Exception * @example * [ * [ * 'customer_number' => 12345678, * 'customer_name' => 'Customer Name', * 'transactions' => [ * [ * 'id' => 1, * 'date' => '2023-01-01', * 'amount' => 100.00, * 'booked' => true, * ], * [ * 'id' => 2, * 'date' => '2023-01-02', * 'amount' => 200.00, * 'booked' => false, * ], * ], * 'requires_action' => false, * 'meta' => [ * 'subscription' => [ * 'id' => 1, * 'name' => 'Subscription Name', * 'price' => 100.00, * 'description' => 'Subscription Description', * ], * ], * ], * [ * 'customer_number' => 87654321, * 'customer_name' => 'Another Customer', * 'transactions' => [ * [ * 'id' => 3, * 'date' => '2023-01-03', * 'amount' => 300.00, * 'booked' => true, * ], * ], * 'requires_action' => true, * 'meta' => [ * 'subscription' => [ * 'id' => 2, * 'name' => 'Another Subscription', * 'price' => 200.00, * 'description' => 'Another Description', * ], * ], * ], * ] * @see orders_o::getTransactionsWithItemsNotIncludedInInvoices * @see order_items_o::include_in_invoice * @see orders_o::deleted_at * @see order_items_o::deleted_at * @see orders_o::created_at * @see orders_o::customer_id * @see self::getVehicleSubscriptions() */ private static function getTransactionsWithItemsNotIncludedInInvoices(array $customersWithSubscriptions): array { global $response; // Loop through each customer and get their transactions with items not included in invoices foreach ( $customersWithSubscriptions as &$customer ) { // Initialize the meta['subscription'] array if it doesn't exist if (!isset($customer['meta']['subscription'])) { $customer['meta']['subscription'] = [ 'vehicles' => [], // Array of vehicle registration numbers 'subscription_total' => 0, // Total price of the subscriptions for the customer 'subscriptions' => [], // Array of subscriptions for the customer (['registration' => ['type' => (int), 'price' => (int), 'distributions' => <(int: departmentId)>[]]) 'subscription_price_department_distribution' => [ // departmentId => price / (number of unique distributions) // This is calculated after the subscriptions have been added // This is used to distribute the subscription price across the transactions ] ]; } // Get the vehicles for the customer $vehicles_o = new customer_vehicles_o(); // Get the vehicles for the customer (as an array of customer_vehicles_o objects) $customer_vehicles = array_map(function ($vehicle) { return (new customer_vehicles_o())->select((int)$vehicle['id']); }, $vehicles_o->getFieldsWhere([ 'customer_id' => (int)$customer['customer_number'], 'wash_subscription' => 1, // Only get vehicles with a wash subscription ], ['id'])); // Add the vehicle registration numbers to the meta['subscription']['vehicles'] array foreach ( $customer_vehicles as $vehicle ) { if ($vehicle instanceof customer_vehicles_o) { $customer['meta']['subscription']['vehicles'][] = $vehicle->reg->value(); } else { $customer['meta']['subscription']['vehicles'][] = 'Unknown Vehicle'; } } // Calculate the total price of the subscriptions for the customer foreach ( $customer_vehicles as $vehicle ) { if ($vehicle instanceof customer_vehicles_o) { // Get the transaction ids covered by the subscription for the vehicle // This is done to be able to distribute the subscription price across the transactions // We only want to get the transactions that are in the customer's transactions array, (this is to avoid getting transactions that are outside the date range) // We also want to make sure that the transactions are for the correct vehicle (reg_1 = vehicle reg), and that the transactions are not deleted (deleted_at is null) $transaction_ids_covered_by_subscription = array_map(function ($transaction) { return (int)$transaction; }, $vehicle->getSubscriptionAppliedTransactionsFromList(array_map( function ($transaction) { return (int)$transaction['id']; }, (new orders_o())->getFieldsWhereIn([ 'id' => array_map(function ($transaction) { return (int)$transaction['id']; }, $customer['transactions']), 'reg_1' => $vehicle->reg->value(), ], ['id']) ))); // Get the subscription details for the vehicle $subscription = [ 'type' => (int)$vehicle->type->value(), 'price' => (int)(new products_o())->select((int)$vehicle->type->value())->getSubscriptionMonthlyPrice(), // Get the unique department ids for the transactions covered by the subscription 'distributions' => array_unique(array_map(function ($transaction_id) { // Get the department id for the transaction(s) covered by the subscription $transaction = (new orders_o())->select((int)$transaction_id); return (int)$transaction->department_id->value(); }, $transaction_ids_covered_by_subscription)), ]; $customer['meta']['subscription']['subscriptions'][$vehicle->reg->value()] = $subscription; $customer['meta']['subscription']['subscription_total'] += $subscription['price']; // Add the subscription price per transaction to the meta['subscription']['subscription_price_department_distribution'] array foreach ( $subscription['distributions'] as $department_id ) { if (!isset($customer['meta']['subscription']['subscription_price_department_distribution'][$department_id])) { $customer['meta']['subscription']['subscription_price_department_distribution'][$department_id] = 0; } // Divide the subscription price by the number of unique distributions $customer['meta']['subscription']['subscription_price_department_distribution'][$department_id] += $subscription['price'] / count($subscription['distributions']); } // If the vehicle has no transactions covered by the subscription, we need to run through the list of fallback options if (count($transaction_ids_covered_by_subscription) === 0) { // Run fallback options self::attemptSubscriptionFallbacks($vehicle, $customer); } } } } // Get the department distribution for all customers (sum of all customers) $collective_results = [ 'total_subscription_price' => 0, 'subscription_price_department_distribution' => [ // departmentId => price ], ]; unset($customer); // Unset the reference to avoid issues foreach ( $customersWithSubscriptions as $customer ) { if (isset($customer['meta']['subscription'])) { $collective_results['total_subscription_price'] += $customer['meta']['subscription']['subscription_total']; foreach ( $customer['meta']['subscription']['subscription_price_department_distribution'] as $department_id => $price ) { if (!isset($collective_results['subscription_price_department_distribution'][$department_id])) { $collective_results['subscription_price_department_distribution'][$department_id] = 0; } $collective_results['subscription_price_department_distribution'][$department_id] += $price; } // Verify that the sum of the department distribution equals the total subscription price for the customer $sum_of_distribution = array_sum($customer['meta']['subscription']['subscription_price_department_distribution']); $difference = $customer['meta']['subscription']['subscription_total'] - $sum_of_distribution; if (abs($difference) > 0.01) { // Debug info $debug_info = [ 'customer_number' => $customer['customer_number'], 'subscription_total' => $customer['meta']['subscription']['subscription_total'], 'sum_of_distribution' => $sum_of_distribution, 'difference' => $difference, ]; // Send slack alert $message = "Subscription price distribution mismatch for customer " . $customer['customer_number'] . " (" . self::getLocalCustomerName((int)$customer['customer_number']) . ")\n"; $message .= "Subscription total: " . $customer['meta']['subscription']['subscription_total'] . "\n"; $message .= "Distribution total: " . $sum_of_distribution . "\n"; $message .= "Difference: " . $difference . "\n"; $message .= "Debug info: " . print_r($debug_info, true); // Describe what to check if (!self::$suppressInvoicePeriodExternalEffects) { (new slack())->send_message($message, 'Subscription Price Distribution Mismatch'); } // Throw an error throw new Exception('Subscription price distribution does not equal total subscription price for customer ' . $customer['customer_number'] . '. Difference: ' . $difference); } } } // Parse the department ids to department names $parsed_distribution = []; foreach ( $collective_results['subscription_price_department_distribution'] as $department_id => $price ) { $parsed_distribution[self::getDepartmentNameCached((int)$department_id)] = $price; } $collective_results['subscription_price_department_distribution_parsed'] = $parsed_distribution; // Include the collective results in the response $response->add_include('collective_subscription_results', $collective_results); if (self::shouldSendSlackSummary()) { // Send summary to slack $slack_message = "Subscription Price Distribution Summary:\n"; $slack_message .= "Total Subscription Price: " . $collective_results['total_subscription_price'] . "\n"; $slack_message .= "Department Distribution:\n"; $tmp_total = 0; foreach ( $collective_results['subscription_price_department_distribution_parsed'] as $department_name => $price ) { $slack_message .= "- " . $department_name . ": " . $price . "\n"; $tmp_total += $price; } $slack_message .= "Total Distribution: " . $tmp_total . "\n"; (new slack())->send_message($slack_message, 'Subscription Price Distribution Summary'); } return $customersWithSubscriptions; } /** * Attempt fallback options to find transactions covered by the subscription. * This is used when a vehicle has a subscription, but no transactions in the given date range. * This function processes the following fallback options: * 1. Attempt to divide the subscription price across the departments the customer has subscription transactions in. * 2. Attempt to get the last transaction the vehicle was washed in, and use that department. * 3. If no transactions are found, assign the subscription to the departments used in the most recent 10 transactions by the customer. * 4. If no departments are found, assign the subscription to a default department (e.g., department ID 1). * @param customer_vehicles_o $vehicle The vehicle object. * @param array $customer The customer object (by reference). * @retuns bool True if a fallback was applied, false otherwise. * @throws Exception If an error occurs while processing the fallbacks. */ private static function attemptSubscriptionFallbacks(customer_vehicles_o $vehicle, array &$customer): bool { if (self::debug) echo "Attempting fallbacks for vehicle " . $vehicle->reg->value() . " of customer " . $customer['customer_number'] . " - " . self::getLocalCustomerName((int)$customer['customer_number']) . "\n"; // Run the fallback options in order $subscription_price = (int)(new products_o())->select((int)$vehicle->type->value())->getSubscriptionMonthlyPrice(); if (self::divideSubscriptionAcrossCustomerDepartments($customer, $subscription_price)) { return true; } if (self::useLastTransactionDepartment($customer, $vehicle, $subscription_price)) { return true; } if (self::useRecentCustomerTransactions($customer, $subscription_price)) { return true; } if (self::useDefaultDepartment($customer, $subscription_price)) { return true; } // If no fallback was applied, return false if (self::debug) echo "All fallbacks failed for vehicle " . $vehicle->reg->value() . " of customer " . $customer['customer_number'] . "\n"; return false; // No fallback applied } const debug = false; // 1. Attempt to divide the subscription price across the departments the customer has transactions in. private static function divideSubscriptionAcrossCustomerDepartments(array &$customer, int $subscription_price): bool { if (self::debug) echo "Fallback 1: Dividing subscription price across customer departments\n"; // Get the unique department ids for the customer's transactions $department_ids = array_unique(array_map(/** * @throws Exception */ function ($transaction) { $transaction_obj = (new orders_o())->select((int)$transaction['id']); return (int)$transaction_obj->department_id->value(); }, $customer['transactions'])); // Remove department id 10 (automatic) from the list $department_ids = array_filter($department_ids, function ($department_id) { return $department_id !== 10; }); if (count($department_ids) > 0) { foreach ( $department_ids as $department_id ) { // If the department id is 10 (automatic), skip it if ($department_id === 10) { continue; } if (!isset($customer['meta']['subscription']['subscription_price_department_distribution'][$department_id])) { $customer['meta']['subscription']['subscription_price_department_distribution'][$department_id] = 0; } // Divide the subscription price by the number of unique departments $customer['meta']['subscription']['subscription_price_department_distribution'][$department_id] += $subscription_price / count($department_ids); } // Debug: if (self::debug) echo "Fallback 1 applied: Divided subscription price across customer departments. Departments: " . implode(', ', $department_ids) . "\n"; return true; // Fallback applied } if (self::debug) echo "Fallback 1 not applied: No departments found in customer's transactions\n"; return false; // No departments found } // 2. Attempt to get the last transaction the vehicle was washed in, and use that department. /** * @throws Exception */ private static function useLastTransactionDepartment(array &$customer, customer_vehicles_o $vehicle, int $subscription_price): bool { if (self::debug) echo "Fallback 2: Using last transactions departments\n"; // Get the last transaction the vehicle was washed in $last_transaction_ids = $vehicle->getLastTransactions(10); // Get the last 10 transactions $distribution_department_ids = []; // Array to hold the department ids from the last transactions, together with their counts (1 = 10%, 2 = 20%, etc.) // Calculate the department distribution from the last transactions foreach ( $last_transaction_ids as $transaction_id ) { $transaction = (new orders_o())->select((int)$transaction_id); if ($transaction instanceof orders_o) { $department_id = (int)$transaction->department_id->value(); // If the department id is 10 (automatic), skip it if ($department_id === 10) { continue; } if (!isset($distribution_department_ids[$department_id])) { $distribution_department_ids[$department_id] = 0; } $distribution_department_ids[$department_id]++; } } // If we have department ids from the last transactions, use them to distribute the subscription price if (count($distribution_department_ids) > 0) { $total_counts = array_sum($distribution_department_ids); foreach ( $distribution_department_ids as $department_id => $count ) { if (!isset($customer['meta']['subscription']['subscription_price_department_distribution'][$department_id])) { $customer['meta']['subscription']['subscription_price_department_distribution'][$department_id] = 0; } // Distribute the subscription price based on the count of the department in the last transactions $customer['meta']['subscription']['subscription_price_department_distribution'][$department_id] += ($count / $total_counts) * $subscription_price; } // Debug: if (self::debug) echo "Fallback 2 applied: Used last transaction departments. Departments: " . implode(', ', array_keys($distribution_department_ids)) . "\n"; return true; // Fallback applied } if (self::debug) echo "Fallback 2 not applied: No last transaction found for vehicle\n"; return false; // No last transaction found } // 3. If no transactions are found, assign the subscription to the departments used in the most recent 10 transactions by the customer. private static function useRecentCustomerTransactions(array &$customer, int $subscription_price): bool { if (self::debug) echo "Fallback 3: Using recent customer transactions\n"; // Get the last 10 transactions of the customer $recent_transaction_ids = array_slice(array_map(function ($transaction) { return (int)$transaction['id']; }, (new orders_o())->getFieldsWhere([ 'customer_id' => (int)$customer['customer_number'], 'deleted_at' => null, ], ['id', 'created_at'])), 0, 10); $distribution_department_ids = []; // Array to hold the department ids from the recent transactions, together with their counts (1 = 10%, 2 = 20%, etc.) // Calculate the department distribution from the recent transactions foreach ( $recent_transaction_ids as $transaction_id ) { $transaction = (new orders_o())->select((int)$transaction_id); if ($transaction instanceof orders_o) { $department_id = (int)$transaction->department_id->value(); // If the department id is 10 (automatic), skip it if ($department_id === 10) { continue; } if (!isset($distribution_department_ids[$department_id])) { $distribution_department_ids[$department_id] = 0; } $distribution_department_ids[$department_id]++; } } // If we have department ids from the recent transactions, use them to distribute the subscription price if (count($distribution_department_ids) > 0) { $total_counts = array_sum($distribution_department_ids); foreach ( $distribution_department_ids as $department_id => $count ) { // If the department id is 10 (automatic), skip it if ($department_id === 10) { continue; } if (!isset($customer['meta']['subscription']['subscription_price_department_distribution'][$department_id])) { $customer['meta']['subscription']['subscription_price_department_distribution'][$department_id] = 0; } // Distribute the subscription price based on the count of the department in the recent transactions $customer['meta']['subscription']['subscription_price_department_distribution'][$department_id] += ($count / $total_counts) * $subscription_price; } // Debug: if (self::debug) echo "Fallback 3 applied: Used recent customer transaction departments. Departments: " . implode(', ', array_keys($distribution_department_ids)) . "\n"; return true; // Fallback applied } if (self::debug) echo "Fallback 3 not applied: No recent transactions found for customer\n"; return false; // No recent transactions found } // 4. If no departments are found, assign the subscription to customer default department // or fallback to e-conomic default distribution department. private static function useDefaultDepartment(array &$customer, int $subscription_price): bool { if (self::debug) echo "Fallback 4: Using default department\n"; $customer_default_department_id = (int)(new users_o)->getUserByCustomerNumber((int)$customer['customer_number'])->getDefaultDepartment(); $default_department_id = $customer_default_department_id > 0 ? $customer_default_department_id : self::getEconomicFallbackDepartmentId(); if ($default_department_id > 0) { if (!isset($customer['meta']['subscription']['subscription_price_department_distribution'][$default_department_id])) { $customer['meta']['subscription']['subscription_price_department_distribution'][$default_department_id] = 0; } $customer['meta']['subscription']['subscription_price_department_distribution'][$default_department_id] += $subscription_price; // Debug: if (self::debug) echo "Fallback 4 applied: Used default department ID " . $default_department_id . "\n"; return true; // Fallback applied } if (self::debug) echo "Fallback 4 not applied: No default department found for customer\n"; return false; // No default department found } /** * Get the original price for each customer, and transaction in the fixed pricing array. * * @param array $fixed_pricing The fixed pricing array. * @return array The fixed pricing array with the original price added. * @throws Exception */ private static function getOriginalPrice(array $fixed_pricing): array { global $response; $order_items = new order_items_o(); foreach ( $fixed_pricing as &$customer ) { if (isset($customer['meta']['fixed_pricing'])) { $original_price = 0; $department_totals = []; // Array to hold totals per department $eligible_transaction_department_ids = []; foreach ( $customer['transactions'] as $transaction ) { $department_id = (int)($transaction['department_id'] ?? 0); $excluded = (bool)($transaction['excluded'] ?? false); // Skip excluded transactions and automatic department. if ($excluded || $department_id === 10) { continue; } $eligible_transaction_department_ids[(int)$transaction['id']] = $department_id; } if (!empty($eligible_transaction_department_ids)) { $transaction_original_prices = []; $product_cache = []; $department_price_cache = []; $discount_cache = []; $user = (new users_o())->getUserByCustomerNumber((int)$customer['customer_number']); $rows = $order_items->getFieldsWhere( ['order_id' => array_keys($eligible_transaction_department_ids)], ['order_id', 'product_id', 'price', 'quantity'] ); foreach ( $rows as $row ) { $order_id = (int)$row['order_id']; $department_id = (int)($eligible_transaction_department_ids[$order_id] ?? 0); if ($department_id <= 0) { continue; } $product_id = (int)$row['product_id']; $price = (int)$row['price']; $quantity = (int)$row['quantity']; if ($price > 0 && $quantity > 0) { $transaction_original_prices[$order_id] = (int)(($transaction_original_prices[$order_id] ?? 0) + ($price * $quantity)); continue; } if (!isset($product_cache[$product_id])) { $product_cache[$product_id] = (new products_o())->select($product_id); } if (!isset($department_price_cache[$department_id][$product_id])) { $department_price_cache[$department_id][$product_id] = $product_cache[$product_id]->getDepartmentPriceResolution($department_id); } if (!array_key_exists($product_id, $discount_cache)) { $unit_price = (int)$department_price_cache[$department_id][$product_id]['price']; if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$department_id][$product_id])) { $unit_price = $user->applyProductCustomerPricing($product_id, $unit_price, false, $department_id); } $discount_cache[$product_id] = $unit_price; } $post_discount = (int)$discount_cache[$product_id] * $quantity; $transaction_original_prices[$order_id] = (int)(($transaction_original_prices[$order_id] ?? 0) + $post_discount); } foreach ( $eligible_transaction_department_ids as $transaction_id => $department_id ) { $transaction_original_price = (int)($transaction_original_prices[$transaction_id] ?? 0); $original_price += $transaction_original_price; // Initialize the department total if it doesn't exist if (!isset($department_totals[$department_id])) { $department_totals[$department_id] = 0; } // Add the transaction amount to the department total $department_totals[$department_id] += $transaction_original_price; } } else { $customer_default_department_id = (int)(new users_o())->getUserByCustomerNumber((int)$customer['customer_number'])->getDefaultDepartment(); $fallback_department_id = $customer_default_department_id > 0 ? $customer_default_department_id : self::getEconomicFallbackDepartmentId(); if ($fallback_department_id > 0) { $department_totals[$fallback_department_id] = (float)$customer['meta']['fixed_pricing']['price']; } } $customer['meta']['fixed_pricing']['original_price'] = $original_price; $customer['meta']['fixed_pricing']['department_totals'] = $department_totals; // Take the relative price of the department totals in relation to the fixed price // This is done to see how much each department contributes to the fixed price $total = array_sum($department_totals); foreach ( $department_totals as $department_id => $amount ) { if ($total > 0) { $department_totals[$department_id] = ($amount / $total) * $customer['meta']['fixed_pricing']['price']; } else { $department_totals[$department_id] = 0; } } $customer['meta']['fixed_pricing']['department_totals_relative'] = $department_totals; } } // Calculate the total original price for all customers $collective_results = [ 'total_fixed_price' => 0, 'total_original_price' => 0, 'total_department_totals' => [], 'total_department_totals_relative' => [], ]; unset($customer); // Unset the reference to avoid issues foreach ( $fixed_pricing as $customer ) { if (isset($customer['meta']['fixed_pricing'])) { $collective_results['total_fixed_price'] += $customer['meta']['fixed_pricing']['price']; $collective_results['total_original_price'] += $customer['meta']['fixed_pricing']['original_price']; // Sum the department totals foreach ( $customer['meta']['fixed_pricing']['department_totals'] as $department_id => $amount ) { if (!isset($collective_results['total_department_totals'][$department_id])) { $collective_results['total_department_totals'][$department_id] = 0; } $collective_results['total_department_totals'][$department_id] += $amount; } // Sum the relative department totals foreach ( $customer['meta']['fixed_pricing']['department_totals_relative'] as $department_id => $amount ) { if (!isset($collective_results['total_department_totals_relative'][$department_id])) { $collective_results['total_department_totals_relative'][$department_id] = 0; } $collective_results['total_department_totals_relative'][$department_id] += $amount; } } } // Parse the department ids to department names $collective_results = self::parseTheDepartmentIdsToDepartmentNames($collective_results); // Include the collective results in the response $response->add_include('collective_fixed_pricing_results', $collective_results); if (self::shouldSendSlackSummary()) { // Send slack message with the collective results $slack_message = "Fixed Pricing Invoicing Period Summary:\n"; $slack_message .= "Total Fixed Price: " . number_format($collective_results['total_fixed_price'], 2) . " DKK\n"; $slack_message .= "Total Original Price: " . number_format($collective_results['total_original_price'], 2) . " DKK\n"; $slack_message .= "Department Totals:\n"; $tmp_sum = 0; foreach ( $collective_results['total_department_totals_parsed'] as $department_name => $amount ) { $slack_message .= "- " . $department_name . ": " . number_format($amount, 2) . " DKK\n"; $tmp_sum += $amount; } $slack_message .= "Total Department Totals: " . number_format($tmp_sum, 2) . " DKK\n"; $slack_message .= "Relative Department Totals:\n"; $tmp_sum = 0; foreach ( $collective_results['total_department_totals_relative_parsed'] as $department_name => $amount ) { $slack_message .= "- " . $department_name . ": " . number_format($amount, 2) . " DKK\n"; $tmp_sum += $amount; } $slack_message .= "Total Relative Department Totals: " . number_format($tmp_sum, 2) . " DKK\n"; (new slack())->send_message($slack_message, 'Fixed Pricing Invoicing Period Summary'); } return $fixed_pricing; } /** * @throws Exception */ private static function getInvoicingPeriod( string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers = null, bool $includeInvoicePeriodFlags = false ): array { //$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo) $onlyCustomerNumbers = $onlyCustomerNumbers !== null ? self::normalizeCustomerNumbers($onlyCustomerNumbers) : null; $customersWithTransactions = self::debugGetTime(function () use ($dateFrom, $dateTo, $onlyCustomerNumbers) { return self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); }, 'customers_with_transactions'); $types = []; // Add the customers with transactions to the types array $types['all'] = $customersWithTransactions; $types['vehicle_subscriptions'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { return self::getVehicleSubscriptions($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'vehicle_subscriptions'); $types['fixed_pricing'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { return self::getFixedPricing($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'fixed_pricing'); $types['tank_cleaning'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { return self::getTankCleaning($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'tank_cleaning'); $types['special_arrangements'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { return self::getSpecialArrangements($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'special_arrangements'); $types['invoice_per_order'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { return self::getInvoicingPerOrder($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'invoice_per_order'); $types['possible_duplicates'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { return self::getPossibleDuplicates($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'possible_duplicates'); $queueOverlay = self::debugGetTime(function () use ($dateFrom, $dateTo) { return self::getActiveCollectedInvoiceQueueOverlay($dateFrom, $dateTo); }, 'active_collected_invoice_queue_overlay'); $types = self::applyCollectedInvoiceQueueOverlayToPeriodTypes( $types, $queueOverlay['by_collection_id'] ?? [], $queueOverlay['by_customer_number'] ?? [], ); $draftOverlay = self::debugGetTime(function () use ($types, $dateFrom, $dateTo) { return self::getValidCollectedInvoiceDraftOverlay($types, $dateFrom, $dateTo); }, 'valid_collected_invoice_draft_overlay'); $types = self::applyCollectedInvoiceDraftOverlayToPeriodTypes( $types, $draftOverlay['by_collection_id'] ?? [], $draftOverlay['by_customer_number'] ?? [], ); $invoiceCollectionMetadata = self::debugGetTime(function () use ($types) { return self::getPeriodInvoiceCollectionMetadata($types); }, 'invoice_collection_metadata'); $types = self::applyPeriodInvoiceStateOverlayToTypes($types, $invoiceCollectionMetadata); if ($includeInvoicePeriodFlags) { $types = self::debugGetTime(function () use ($types, $dateFrom, $dateTo, $onlyCustomerNumbers) { return (new invoice_period_flag_service())->applyFlagsToPeriodTypes( $types, $dateFrom, $dateTo, $onlyCustomerNumbers ); }, 'invoice_period_flags'); } else { $types = self::debugGetTime(function () use ($types, $dateFrom, $dateTo, $onlyCustomerNumbers) { return (new invoice_period_flag_service())->applyManualFlagCountsToPeriodTypes( $types, $dateFrom, $dateTo, $onlyCustomerNumbers ); }, 'invoice_period_manual_flag_counts'); } $types = self::enrichPeriodCustomerReview($types); return [ 'dateFrom' => $dateFrom, 'dateTo' => $dateTo, 'types' => $types, ]; } /** * Debugging function to measure the time taken by a function. * * @param callable $function The function to debug. * @return mixed The result of the function. */ private static function debugGetTime(callable $function, string $debug_label = ''): mixed { global $response; $start_time = microtime(true) * 1000; // Start time in milliseconds $result = $function(); $end_time = microtime(true) * 1000; // End time in milliseconds $execution_time = $end_time - $start_time; $response->add_include( 'debug_invoicing_period_' . $debug_label, [ 'execution_time' => $execution_time, ] ); return $result; } /** * @throws Exception */ private static function getCustomersWithTransactions(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers = null): array { $onlyCustomerNumbers = $onlyCustomerNumbers !== null ? self::normalizeCustomerNumbers($onlyCustomerNumbers) : null; if ($onlyCustomerNumbers !== null && empty($onlyCustomerNumbers)) { return []; } $customer_number_transactions = []; self::debugGetTime(function () use ($onlyCustomerNumbers, $dateFrom, $dateTo, &$customer_number_transactions) { $customer_number_transactions = (new orders_o())->getPeriodTransactionsForCustomersInDateRange( $onlyCustomerNumbers, $dateFrom, $dateTo ); }, 'get_transactions_for_customers_in_date_range'); $customer_numbers = []; $tmp = []; self::debugGetTime(function () use ($customer_number_transactions, &$customer_numbers) { foreach ( $customer_number_transactions as $customer_number => $transactions ) { $customer_number = (int)$customer_number; if (empty($customer_number)) { continue; } if (isset($customer_numbers[$customer_number])) { continue; } $firstTransaction = is_array($transactions) ? ($transactions[0] ?? []) : []; $userId = (int)($firstTransaction['user_id'] ?? 0); $customer_numbers[$customer_number] = $userId > 0 ? $userId : null; } }, 'process_customer_numbers'); self::debugGetTime(static function (): void { // Net totals are resolved by getPeriodTransactionsForCustomersInDateRange(). }, 'calculate_transaction_totals'); // Get the customer names from the cache $customer_names = (new \objects\users_o())->getCustomerNames(array_keys($customer_numbers), false); // Process the customer numbers to ensure they are unique self::debugGetTime(function () use ($customer_numbers, $customer_number_transactions, &$tmp, $customer_names) { foreach ( $customer_numbers as $customer_number => $user_id ) { if (empty($customer_number)) { // Skip if the customer number is empty continue; } // Check if the customer number is already in the array if (isset($tmp[$customer_number])) { // If the customer number is already in the array, skip it continue; } // If the customer number is not in the array, add it // Construct the customer object with transactions $tmp[] = self::constructCustomerObject( (int)$customer_number, $customer_names[(int)$customer_number] ?? 'Unknown Customer', $customer_number_transactions[(int)$customer_number] ?? [], false, (int)$user_id > 0 ? (int)$user_id : null, ); } }, 'construct_customer_objects'); return $tmp; } /** * @param int $customer_number * @param string $customer_name * @param orders_o[] $transactions * @param bool $requires_action * @return array * @throws Exception */ protected static function constructCustomerObject( int $customer_number, string $customer_name, array $transactions = [], bool $requires_action = false, ?int $user_id = null, ?array $meta = null ): array { $user = null; if ($user_id === null) { $user = (new users_o())->getUserByCustomerNumber((int)$customer_number); } return [ 'id' => $user_id ?? ($user !== null && $user->exists() ? $user->id : null), 'customer_number' => $customer_number, 'customer_name' => $customer_name, 'transactions' => $parsed_transactions = array_map(function ($transaction) { return self::constructTransactionObject($transaction); }, $transactions), 'requires_action' => self::checkRequiresAction($parsed_transactions, $requires_action), 'meta' => $meta ?? [], 'queue' => self::getDefaultQueueSummary(), 'draft' => self::getDefaultDraftSummary(), 'invoice_collections' => [], ]; } /** * @throws Exception */ private static function constructTransactionObject(mixed $transaction): array { if (is_array($transaction)) { return self::constructTransactionObjectFromPeriodRow($transaction); } if (!$transaction instanceof orders_o) { throw new \InvalidArgumentException('Invalid period transaction row.'); } $departmentId = (int)$transaction->department_id->value(); $invoiceCollectionId = (int)$transaction->invoice_collection_id->value(); $booked = self::isTransactionBookedFromLocalState($transaction); $completedAt = !empty($transaction->completed_at->value()) ? (string)$transaction->completed_at->value() : null; return [ 'id' => $transaction->id, 'date' => $transaction->created_at->value(), 'amount' => $transaction->temporary_net_amount, // Use the temporary net amount set earlier 'booked' => $booked, 'department_id' => $departmentId, 'customer_number' => (int)$transaction->customer_id->value(), 'reference' => (string)$transaction->reference->value(), 'po' => (string)$transaction->po->value(), 'notes' => (string)$transaction->notes->value(), 'reg_1' => (string)$transaction->reg_1->value(), 'reg_2' => (string)$transaction->reg_2->value(), 'reg_3' => (string)$transaction->reg_3->value(), 'completed_at' => $completedAt, 'excluded' => !$transaction->isIncludedInInvoicing(), 'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null, 'invoice_state' => self::periodOrderState($booked, $completedAt), 'queue_status' => null, 'queue_job_id' => null, ]; } private static function constructTransactionObjectFromPeriodRow(array $transaction): array { $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); return [ 'id' => (int)($transaction['id'] ?? $transaction['order_id'] ?? 0), 'date' => (string)($transaction['date'] ?? $transaction['created_at'] ?? ''), 'amount' => (float)($transaction['amount'] ?? $transaction['net_amount'] ?? 0), 'booked' => (bool)($transaction['booked'] ?? false), 'department_id' => (int)($transaction['department_id'] ?? 0), 'customer_number' => (int)($transaction['customer_number'] ?? $transaction['customer_id'] ?? 0), 'reference' => (string)($transaction['reference'] ?? $transaction['order_reference'] ?? ''), 'po' => (string)($transaction['po'] ?? $transaction['order_po'] ?? ''), 'notes' => (string)($transaction['notes'] ?? $transaction['order_notes'] ?? ''), 'reg_1' => (string)($transaction['reg_1'] ?? ''), 'reg_2' => (string)($transaction['reg_2'] ?? ''), 'reg_3' => (string)($transaction['reg_3'] ?? ''), 'completed_at' => !empty($transaction['completed_at']) ? (string)$transaction['completed_at'] : null, 'excluded' => (bool)($transaction['excluded'] ?? ((int)($transaction['include_in_invoice_effective'] ?? 1) !== 1)), 'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null, 'invoice_state' => self::periodOrderState( (bool)($transaction['booked'] ?? false), !empty($transaction['completed_at']) ? (string)$transaction['completed_at'] : null ), 'queue_status' => $transaction['queue_status'] ?? null, 'queue_job_id' => $transaction['queue_job_id'] ?? null, ]; } /** * Resolve booked state from local stored invoice metadata only. * Remote e-conomic invoice lookups are intentionally avoided here because this method runs for every * transaction in the period response. */ private static function isTransactionBookedFromLocalState(orders_o $transaction): bool { global $db; $orderId = (int)$transaction->id; if ($orderId < 1) { return false; } if (array_key_exists($orderId, self::$periodOrderBookedCache)) { return self::$periodOrderBookedCache[$orderId]; } $invoiceCollectionId = (int)$transaction->invoice_collection_id->value(); if ($invoiceCollectionId > 0) { if (!array_key_exists($invoiceCollectionId, self::$periodInvoiceCollectionBookedCache)) { $result = $db->query("SELECT booked_invoice_id FROM collected_order_invoices WHERE id = {$invoiceCollectionId} LIMIT 1"); $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : null; self::$periodInvoiceCollectionBookedCache[$invoiceCollectionId] = !empty($row['booked_invoice_id'] ?? null); } return self::$periodOrderBookedCache[$orderId] = self::$periodInvoiceCollectionBookedCache[$invoiceCollectionId]; } $result = $db->query("SELECT invoice_id FROM economic_module_orders WHERE id = {$orderId} LIMIT 1"); $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : null; return self::$periodOrderBookedCache[$orderId] = !empty($row['invoice_id'] ?? null); } private static function periodOrderState(bool $booked, ?string $completedAt): string { if ($booked) { return 'economic_booked'; } return !empty($completedAt) ? 'closed' : 'open'; } private static function checkRequiresAction(array $parsed_transactions, bool $requires_action): bool { // If requires_action is already set to true, return true if ($requires_action) { return true; } // Check if any transaction is not booked foreach ( $parsed_transactions as $transaction ) { if (!$transaction['booked'] && !$transaction['excluded']) { return true; } } // If all transactions are booked, return false return false; } private static function getDefaultQueueSummary(): array { return [ 'has_active_job' => false, 'statuses' => [], 'invoice_collection_ids' => [], 'is_action_blocked' => false, ]; } private static function getDefaultDraftSummary(): array { return [ 'has_valid_draft' => false, 'invoice_collection_ids' => [], 'is_action_blocked' => false, ]; } private static function getActiveCollectedInvoiceQueueOverlay(string $dateFrom, string $dateTo): array { $overlay = [ 'by_collection_id' => [], 'by_customer_number' => [], ]; try { $queue = new economic_transfer_queue(); $offset = 0; $limit = 250; do { $jobs = $queue->listJobs( [ economic_transfer_queue::STATUS_QUEUED, economic_transfer_queue::STATUS_PROCESSING, ], $limit, $offset, economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT ); foreach ($jobs as $job) { $normalizedJob = self::normalizeCollectedInvoiceQueueJob($job, $dateFrom, $dateTo); if ($normalizedJob === null || empty($normalizedJob['is_period_relevant'])) { continue; } $invoiceCollectionId = (int)($normalizedJob['invoice_collection_id'] ?? 0); if ($invoiceCollectionId > 0 && !isset($overlay['by_collection_id'][$invoiceCollectionId])) { $overlay['by_collection_id'][$invoiceCollectionId] = $normalizedJob; } $customerNumber = (int)($normalizedJob['customer_number'] ?? 0); if ($customerNumber > 0) { $overlay['by_customer_number'][$customerNumber] = $overlay['by_customer_number'][$customerNumber] ?? []; $overlay['by_customer_number'][$customerNumber][] = $normalizedJob; } } $offset += count($jobs); } while (count($jobs) === $limit); } catch (\Throwable) { return $overlay; } return $overlay; } private static function normalizeCollectedInvoiceQueueJob(array $job, string $dateFrom, string $dateTo): ?array { $invoiceCollectionId = (int)( $job['payload']['collected_invoice_id'] ?? $job['collected_invoice_id'] ?? $job['invoice_collection_id'] ?? 0 ); if ($invoiceCollectionId < 1) { return null; } try { $invoiceCollection = (new collected_order_invoices_o())->select($invoiceCollectionId); if (!$invoiceCollection->exists()) { return null; } $customerNumber = (int)$invoiceCollection->customer_number->value(); $closedAt = (string)$invoiceCollection->closed_at->value(); $createdAt = (string)$invoiceCollection->created_at->value(); return [ 'queue_job_id' => (int)($job['id'] ?? $job['queue_job_id'] ?? 0), 'queue_status' => (string)($job['status'] ?? $job['queue_status'] ?? ''), 'invoice_collection_id' => $invoiceCollectionId, 'customer_number' => $customerNumber, 'created_at' => $createdAt, 'closed_at' => $closedAt, 'is_period_relevant' => self::isInvoiceCollectionRelevantToPeriod($createdAt, $closedAt, $dateFrom, $dateTo), ]; } catch (\Throwable) { return null; } } private static function isInvoiceCollectionRelevantToPeriod( ?string $createdAt, ?string $closedAt, string $dateFrom, string $dateTo ): bool { return self::isTimestampWithinPeriod($closedAt, $dateFrom, $dateTo) || self::isTimestampWithinPeriod($createdAt, $dateFrom, $dateTo); } private static function isTimestampWithinPeriod(?string $timestamp, string $dateFrom, string $dateTo): bool { if (empty($timestamp)) { return false; } $normalizedTimestamp = substr((string)$timestamp, 0, 10); return $normalizedTimestamp >= $dateFrom && $normalizedTimestamp <= $dateTo; } private static function collectedOrderInvoicesHasDeletedAtColumn(): bool { if (self::$collectedOrderInvoicesHasDeletedAtColumn !== null) { return self::$collectedOrderInvoicesHasDeletedAtColumn; } global $db; try { $result = $db->query("SHOW COLUMNS FROM `collected_order_invoices` LIKE 'deleted_at'"); self::$collectedOrderInvoicesHasDeletedAtColumn = $result !== false && (int)$result->num_rows > 0; } catch (\Throwable) { self::$collectedOrderInvoicesHasDeletedAtColumn = false; } return self::$collectedOrderInvoicesHasDeletedAtColumn; } private static function applyCollectedInvoiceQueueOverlayToPeriodTypes( array $types, array $queueJobsByCollectionId, array $queueJobsByCustomerNumber ): array { foreach ($types as $type => $customers) { if (!is_array($customers)) { continue; } $types[$type] = array_map(function ($customer) use ($queueJobsByCollectionId, $queueJobsByCustomerNumber) { if (!is_array($customer)) { return $customer; } return self::applyCollectedInvoiceQueueOverlayToCustomer( $customer, $queueJobsByCollectionId, $queueJobsByCustomerNumber ); }, $customers); } return $types; } private static function applyCollectedInvoiceQueueOverlayToCustomer( array $customer, array $queueJobsByCollectionId, array $queueJobsByCustomerNumber ): array { $customerNumber = (int)($customer['customer_number'] ?? 0); $activeCustomerJobs = array_values(array_filter( $queueJobsByCustomerNumber[$customerNumber] ?? [], static function ($job): bool { return !empty($job['is_period_relevant']); } )); $transactions = []; $actionableTransactionCount = 0; $queuedActionableTransactionCount = 0; foreach (($customer['transactions'] ?? []) as $transaction) { if (!is_array($transaction)) { continue; } $transaction['invoice_collection_id'] = isset($transaction['invoice_collection_id']) && (int)$transaction['invoice_collection_id'] > 0 ? (int)$transaction['invoice_collection_id'] : null; $transaction['queue_status'] = $transaction['queue_status'] ?? null; $transaction['queue_job_id'] = $transaction['queue_job_id'] ?? null; $isActionable = !($transaction['booked'] ?? false) && !($transaction['excluded'] ?? false); if ($isActionable) { $actionableTransactionCount++; } $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); if ($invoiceCollectionId > 0 && isset($queueJobsByCollectionId[$invoiceCollectionId])) { $queueJob = $queueJobsByCollectionId[$invoiceCollectionId]; $transaction['queue_status'] = $queueJob['queue_status'] ?? null; $transaction['queue_job_id'] = $queueJob['queue_job_id'] ?? null; if ($isActionable) { $queuedActionableTransactionCount++; } } $transactions[] = $transaction; } $customerLevelQueueBlock = false; if ($actionableTransactionCount === 0 && self::customerSupportsCustomerLevelQueueBlocking($customer) && !empty($activeCustomerJobs)) { $customerLevelQueueBlock = true; } $isActionBlocked = false; if ($actionableTransactionCount > 0) { $isActionBlocked = $queuedActionableTransactionCount > 0 && $queuedActionableTransactionCount === $actionableTransactionCount; } elseif ($customerLevelQueueBlock) { $isActionBlocked = true; } $statuses = []; $invoiceCollectionIds = []; foreach ($activeCustomerJobs as $job) { $status = (string)($job['queue_status'] ?? ''); if ($status !== '' && !in_array($status, $statuses, true)) { $statuses[] = $status; } $invoiceCollectionId = (int)($job['invoice_collection_id'] ?? 0); if ($invoiceCollectionId > 0 && !in_array($invoiceCollectionId, $invoiceCollectionIds, true)) { $invoiceCollectionIds[] = $invoiceCollectionId; } } $customer['transactions'] = $transactions; $customer['queue'] = [ 'has_active_job' => !empty($activeCustomerJobs), 'statuses' => $statuses, 'invoice_collection_ids' => $invoiceCollectionIds, 'is_action_blocked' => $isActionBlocked, ]; if ($isActionBlocked) { $customer['requires_action'] = false; } return $customer; } private static function getValidCollectedInvoiceDraftOverlay(array $types, string $dateFrom, string $dateTo): array { $overlay = [ 'by_collection_id' => [], 'by_customer_number' => [], ]; $candidateInvoiceCollectionIds = []; $customerLevelCandidateNumbers = []; foreach ($types as $customers) { if (!is_array($customers)) { continue; } foreach ($customers as $customer) { if (!is_array($customer)) { continue; } foreach (($customer['transactions'] ?? []) as $transaction) { $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); if ($invoiceCollectionId > 0) { $candidateInvoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId; } } $customerNumber = (int)($customer['customer_number'] ?? 0); if ($customerNumber > 0 && self::customerSupportsCustomerLevelQueueBlocking($customer)) { $customerLevelCandidateNumbers[$customerNumber] = $customerNumber; } } } if (empty($candidateInvoiceCollectionIds) && empty($customerLevelCandidateNumbers)) { return $overlay; } global $db; try { $whereCandidates = []; if (!empty($candidateInvoiceCollectionIds)) { $whereCandidates[] = 'id IN (' . implode(',', array_map('intval', array_values($candidateInvoiceCollectionIds))) . ')'; } if (!empty($customerLevelCandidateNumbers)) { $dateFromEscaped = $db->escape_string($dateFrom); $dateToEscaped = $db->escape_string($dateTo); $whereCandidates[] = '(customer_number IN (' . implode(',', array_map('intval', array_values($customerLevelCandidateNumbers))) . ') AND ( DATE(closed_at) BETWEEN \'' . $dateFromEscaped . '\' AND \'' . $dateToEscaped . '\' OR DATE(created_at) BETWEEN \'' . $dateFromEscaped . '\' AND \'' . $dateToEscaped . '\' ))'; } if (!defined('\objects\ECONOMIC_PROCESSOR')) { class_exists(collected_order_invoices_o::class); } $processor = defined('\objects\ECONOMIC_PROCESSOR') ? (int)constant('\objects\ECONOMIC_PROCESSOR') : 1; $deletedAtFilter = self::collectedOrderInvoicesHasDeletedAtColumn() ? 'deleted_at IS NULL AND ' : ''; $sql = "SELECT id, customer_number, created_at, closed_at FROM collected_order_invoices WHERE {$deletedAtFilter}processor = $processor AND external_id IS NOT NULL AND external_id <> '' AND booked_invoice_id IS NULL AND error_message IS NULL AND (" . implode(' OR ', $whereCandidates) . ")"; $result = $db->query($sql); if (!$result) { return $overlay; } while ($row = $result->fetch_assoc()) { $invoiceCollectionId = (int)($row['id'] ?? 0); $customerNumber = (int)($row['customer_number'] ?? 0); if ($invoiceCollectionId < 1 || $customerNumber < 1) { continue; } $normalizedDraft = [ 'invoice_collection_id' => $invoiceCollectionId, 'customer_number' => $customerNumber, 'created_at' => (string)($row['created_at'] ?? ''), 'closed_at' => (string)($row['closed_at'] ?? ''), 'is_period_relevant' => self::isInvoiceCollectionRelevantToPeriod( (string)($row['created_at'] ?? ''), (string)($row['closed_at'] ?? ''), $dateFrom, $dateTo ), ]; $overlay['by_collection_id'][$invoiceCollectionId] = $normalizedDraft; $overlay['by_customer_number'][$customerNumber] = $overlay['by_customer_number'][$customerNumber] ?? []; $overlay['by_customer_number'][$customerNumber][] = $normalizedDraft; } } catch (\Throwable) { return $overlay; } return $overlay; } private static function applyCollectedInvoiceDraftOverlayToPeriodTypes( array $types, array $draftsByCollectionId, array $draftsByCustomerNumber ): array { foreach ($types as $type => $customers) { if (!is_array($customers)) { continue; } $types[$type] = array_map(function ($customer) use ($draftsByCollectionId, $draftsByCustomerNumber) { if (!is_array($customer)) { return $customer; } return self::applyCollectedInvoiceDraftOverlayToCustomer( $customer, $draftsByCollectionId, $draftsByCustomerNumber ); }, $customers); } return $types; } private static function applyCollectedInvoiceDraftOverlayToCustomer( array $customer, array $draftsByCollectionId, array $draftsByCustomerNumber ): array { $customerNumber = (int)($customer['customer_number'] ?? 0); $activeCustomerDrafts = array_values(array_filter( $draftsByCustomerNumber[$customerNumber] ?? [], static function ($draft): bool { return !empty($draft['is_period_relevant']); } )); $transactions = []; $actionableTransactionCount = 0; $coveredActionableTransactionCount = 0; $queuedActionableTransactionCount = 0; $draftActionableTransactionCount = 0; $invoiceCollectionIds = []; foreach (($customer['transactions'] ?? []) as $transaction) { if (!is_array($transaction)) { continue; } $transaction['invoice_collection_id'] = isset($transaction['invoice_collection_id']) && (int)$transaction['invoice_collection_id'] > 0 ? (int)$transaction['invoice_collection_id'] : null; $isActionable = !($transaction['booked'] ?? false) && !($transaction['excluded'] ?? false); if (!$isActionable) { $transactions[] = $transaction; continue; } $actionableTransactionCount++; $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); $isQueued = !empty($transaction['queue_status']); $isDraft = $invoiceCollectionId > 0 && isset($draftsByCollectionId[$invoiceCollectionId]); if ($isQueued || $isDraft) { $coveredActionableTransactionCount++; } if ($isQueued) { $queuedActionableTransactionCount++; } if ($isDraft) { $draftActionableTransactionCount++; $invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId; } $transactions[] = $transaction; } foreach ($activeCustomerDrafts as $draft) { $invoiceCollectionId = (int)($draft['invoice_collection_id'] ?? 0); if ($invoiceCollectionId > 0) { $invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId; } } $isDraftActionBlocked = false; $queue = is_array($customer['queue'] ?? null) ? $customer['queue'] : self::getDefaultQueueSummary(); if ($actionableTransactionCount > 0 && $coveredActionableTransactionCount === $actionableTransactionCount) { if ($queuedActionableTransactionCount > 0) { $queue['is_action_blocked'] = true; } elseif ($draftActionableTransactionCount > 0) { $isDraftActionBlocked = true; } } elseif ( $actionableTransactionCount === 0 && self::customerSupportsCustomerLevelQueueBlocking($customer) && !empty($activeCustomerDrafts) && empty($queue['is_action_blocked']) ) { $isDraftActionBlocked = true; } $customer['transactions'] = $transactions; $customer['queue'] = $queue; $customer['draft'] = [ 'has_valid_draft' => !empty($invoiceCollectionIds), 'invoice_collection_ids' => array_values($invoiceCollectionIds), 'is_action_blocked' => $isDraftActionBlocked, ]; if ($isDraftActionBlocked || !empty($queue['is_action_blocked'])) { $customer['requires_action'] = false; } return $customer; } /** * Fetch the collected-invoice rows referenced by the complete period payload in one local query. * No e-conomic calls are allowed from the period endpoint. * * @return array> Rows keyed by collected invoice ID. */ private static function getPeriodInvoiceCollectionMetadata(array $types): array { global $db; $invoiceCollectionIds = []; foreach ($types as $customers) { if (!is_array($customers)) { continue; } foreach ($customers as $customer) { if (!is_array($customer)) { continue; } foreach (($customer['transactions'] ?? []) as $transaction) { $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); if ($invoiceCollectionId > 0) { $invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId; } } foreach (['queue', 'draft'] as $summaryKey) { foreach (($customer[$summaryKey]['invoice_collection_ids'] ?? []) as $invoiceCollectionId) { $invoiceCollectionId = (int)$invoiceCollectionId; if ($invoiceCollectionId > 0) { $invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId; } } } } } if (empty($invoiceCollectionIds)) { return []; } $sql = 'SELECT id, customer_number, name, notes, processor, external_id, booked_invoice_id, ' . 'po_number, error_message, closed_at, created_at, updated_at ' . 'FROM collected_order_invoices ' . 'WHERE id IN (' . implode(',', array_map('intval', array_values($invoiceCollectionIds))) . ') ' . 'ORDER BY id'; try { $result = $db->query($sql); if (!$result) { return []; } $metadata = []; while ($row = $result->fetch_assoc()) { $invoiceCollectionId = (int)($row['id'] ?? 0); if ($invoiceCollectionId < 1) { continue; } $row['id'] = $invoiceCollectionId; $row['invoice_collection_id'] = $invoiceCollectionId; $row['customer_number'] = (int)($row['customer_number'] ?? 0); $row['processor'] = (int)($row['processor'] ?? 0); $row['booked_invoice_id'] = !empty($row['booked_invoice_id']) ? (int)$row['booked_invoice_id'] : null; $row['state'] = self::periodInvoiceCollectionState($row); $metadata[$invoiceCollectionId] = $row; } return $metadata; } catch (\Throwable) { return []; } } private static function periodInvoiceCollectionState(array $invoiceCollection): string { if (!empty($invoiceCollection['booked_invoice_id'])) { return 'economic_booked'; } if (!defined('\\objects\\ECONOMIC_PROCESSOR')) { class_exists(collected_order_invoices_o::class); } $economicProcessor = defined('\\objects\\ECONOMIC_PROCESSOR') ? (int)constant('\\objects\\ECONOMIC_PROCESSOR') : 1; if ( (int)($invoiceCollection['processor'] ?? 0) === $economicProcessor && trim((string)($invoiceCollection['external_id'] ?? '')) !== '' && trim((string)($invoiceCollection['error_message'] ?? '')) === '' ) { return 'economic_draft'; } return !empty($invoiceCollection['closed_at']) ? 'closed' : 'open'; } private static function applyPeriodInvoiceStateOverlayToTypes(array $types, array $invoiceCollectionsById): array { foreach ($types as $type => $customers) { if (!is_array($customers)) { continue; } $types[$type] = array_map(static function ($customer) use ($invoiceCollectionsById) { if (!is_array($customer)) { return $customer; } return self::applyPeriodInvoiceStateOverlayToCustomer($customer, $invoiceCollectionsById); }, $customers); } return $types; } private static function applyPeriodInvoiceStateOverlayToCustomer(array $customer, array $invoiceCollectionsById): array { $customerNumber = (int)($customer['customer_number'] ?? 0); $collectionStats = []; $invoiceCollectionIds = []; $transactions = []; foreach (($customer['transactions'] ?? []) as $transaction) { if (!is_array($transaction)) { continue; } $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); $invoiceCollection = $invoiceCollectionsById[$invoiceCollectionId] ?? null; $canUseCollection = is_array($invoiceCollection) && (int)($invoiceCollection['customer_number'] ?? 0) === $customerNumber; if ($canUseCollection) { $transaction['invoice_state'] = (string)$invoiceCollection['state']; $transaction['booked'] = $transaction['invoice_state'] === 'economic_booked'; $invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId; $collectionStats[$invoiceCollectionId] = $collectionStats[$invoiceCollectionId] ?? [ 'order_ids' => [], 'total_net_amount' => 0.0, ]; $orderId = (int)($transaction['id'] ?? 0); if ($orderId > 0) { $collectionStats[$invoiceCollectionId]['order_ids'][$orderId] = $orderId; } $collectionStats[$invoiceCollectionId]['total_net_amount'] += (float)($transaction['amount'] ?? 0); } else { $transaction['invoice_state'] = (string)($transaction['invoice_state'] ?? self::periodOrderState( (bool)($transaction['booked'] ?? false), !empty($transaction['completed_at']) ? (string)$transaction['completed_at'] : null )); } $transactions[] = $transaction; } foreach (['queue', 'draft'] as $summaryKey) { foreach (($customer[$summaryKey]['invoice_collection_ids'] ?? []) as $invoiceCollectionId) { $invoiceCollectionId = (int)$invoiceCollectionId; if ( $invoiceCollectionId > 0 && isset($invoiceCollectionsById[$invoiceCollectionId]) && (int)($invoiceCollectionsById[$invoiceCollectionId]['customer_number'] ?? 0) === $customerNumber ) { $invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId; } } } $invoiceCollections = []; foreach ($invoiceCollectionIds as $invoiceCollectionId) { $invoiceCollection = $invoiceCollectionsById[$invoiceCollectionId]; $stats = $collectionStats[$invoiceCollectionId] ?? [ 'order_ids' => [], 'total_net_amount' => 0.0, ]; $orderIds = array_values($stats['order_ids']); $invoiceCollection['order_ids'] = $orderIds; $invoiceCollection['order_count'] = count($orderIds); $invoiceCollection['total_net_amount'] = (float)$stats['total_net_amount']; $invoiceCollections[] = $invoiceCollection; } $customer['transactions'] = $transactions; $customer['invoice_collections'] = $invoiceCollections; return $customer; } /** * @return array>> */ private static function getInvoicePeriodTreeSnapshotRows( array $invoiceCollectionIds, ?int $customerNumber = null, ?string $dateFrom = null, ?string $dateTo = null ): array { global $db; $invoiceCollectionIds = array_values(array_unique(array_filter(array_map('intval', $invoiceCollectionIds)))); if ($invoiceCollectionIds === [] && ($customerNumber === null || $dateFrom === null || $dateTo === null)) { return []; } $collectionCondition = $invoiceCollectionIds === [] ? '0 = 1' : 'o.invoice_collection_id IN (' . implode(',', $invoiceCollectionIds) . ')'; if ($customerNumber !== null && $customerNumber > 0 && $dateFrom !== null && $dateTo !== null) { $safeFrom = $db->escape_string($dateFrom); $safeTo = $db->escape_string($dateTo); $collectionCondition = '(' . $collectionCondition . ") OR ( o.customer_id = {$customerNumber} AND (o.invoice_collection_id IS NULL OR o.invoice_collection_id = 0) AND o.created_at BETWEEN '{$safeFrom}' AND '{$safeTo}' )"; } $sql = "SELECT o.id AS order_id, o.invoice_collection_id, o.customer_id, o.reference, o.notes, o.department_id, o.reg_1, o.reg_2, o.reg_3, o.created_at, o.completed_at, o.include_in_invoice AS order_include_in_invoice, o.po, o.safety_seal, o.booking_id, o.wash_id, oi.id AS order_item_id, oi.product_id, oi.reference AS order_item_reference, oi.notes AS order_item_notes, oi.price, oi.quantity, oi.related_item_id, oi.include_in_invoice AS order_item_include_in_invoice, p.name AS product_name FROM orders o LEFT JOIN order_items oi ON oi.order_id = o.id AND oi.deleted_at IS NULL LEFT JOIN products p ON p.id = oi.product_id WHERE ({$collectionCondition}) AND o.deleted_at IS NULL ORDER BY o.invoice_collection_id ASC, o.id ASC, oi.id ASC"; $result = $db->query($sql); if (!$result) { throw new \RuntimeException('Failed to load the complete invoice-period collection order tree.'); } $rowsByCollection = []; while ($row = $result->fetch_assoc()) { $collectionId = (int)($row['invoice_collection_id'] ?? 0); $rowsByCollection[$collectionId > 0 ? $collectionId : 0][] = $row; } return $rowsByCollection; } /** * @param array> $rows * @return array> */ private static function buildInvoicePeriodTreeSnapshotOrders(array $rows, int $customerNumber): array { $orders = []; foreach ($rows as $row) { if ((int)($row['customer_id'] ?? 0) !== $customerNumber) { continue; } $orderId = (int)($row['order_id'] ?? 0); if ($orderId < 1) { continue; } if (!isset($orders[$orderId])) { $completedAt = !empty($row['completed_at']) ? (string)$row['completed_at'] : null; $orders[$orderId] = [ 'id' => $orderId, 'date' => (string)($row['created_at'] ?? ''), 'amount' => 0.0, 'booked' => false, 'department_id' => (int)($row['department_id'] ?? 0), 'customer_number' => (int)($row['customer_id'] ?? 0), 'reference' => (string)($row['reference'] ?? ''), 'po' => (string)($row['po'] ?? ''), 'notes' => (string)($row['notes'] ?? ''), 'reg_1' => (string)($row['reg_1'] ?? ''), 'reg_2' => (string)($row['reg_2'] ?? ''), 'reg_3' => (string)($row['reg_3'] ?? ''), 'completed_at' => $completedAt, 'excluded' => (int)($row['order_include_in_invoice'] ?? 1) !== 1, 'invoice_collection_id' => (int)($row['invoice_collection_id'] ?? 0), 'booking_id' => (int)($row['booking_id'] ?? 0) ?: null, 'wash_id' => !empty($row['wash_id']) ? (string)$row['wash_id'] : null, 'safety_seal' => (string)($row['safety_seal'] ?? ''), 'invoice_state' => self::periodOrderState(false, $completedAt), 'queue_status' => null, 'queue_job_id' => null, 'order_items' => [], ]; } $itemId = (int)($row['order_item_id'] ?? 0); if ($itemId > 0) { $quantity = (float)($row['quantity'] ?? 0); $price = (float)($row['price'] ?? 0); $includeInInvoice = (int)($row['order_item_include_in_invoice'] ?? 1) === 1; $orders[$orderId]['order_items'][] = [ 'id' => $itemId, 'order_item_id' => $itemId, 'order_id' => $orderId, 'product_id' => (int)($row['product_id'] ?? 0), 'product_name' => (string)($row['product_name'] ?? ''), 'reference' => (string)($row['order_item_reference'] ?? ''), 'notes' => (string)($row['order_item_notes'] ?? ''), 'price' => $price, 'quantity' => $quantity, 'related_item_id' => !empty($row['related_item_id']) ? (int)$row['related_item_id'] : null, 'include_in_invoice' => $includeInInvoice ? 1 : 0, ]; if ($includeInInvoice) { $orders[$orderId]['amount'] += $price * $quantity; } } } return array_values($orders); } private function buildInvoicePeriodTreeSnapshot( int $customerNumber, string $dateFrom, string $dateTo, int $actorUserId ): array { $previousSideEffectSuppression = self::$suppressInvoicePeriodExternalEffects; self::$suppressInvoicePeriodExternalEffects = true; try { $period = self::getInvoicingPeriod( $dateFrom, $dateTo, [$customerNumber], $this->hasPermission('list_invoice_period_flags') ); } finally { self::$suppressInvoicePeriodExternalEffects = $previousSideEffectSuppression; } $customer = self::mergeInvoicePeriodTreeCustomer($period['types'] ?? [], $customerNumber); $collectionMetadata = self::invoicePeriodTreeCollectionMetadata( $period['types'] ?? [], $customerNumber ); $collectionIds = array_keys($collectionMetadata); $rowsByCollection = self::getInvoicePeriodTreeSnapshotRows( $collectionIds, $customerNumber, $dateFrom, $dateTo ); $ordersByCollection = []; foreach ($collectionIds as $collectionId) { $ordersByCollection[$collectionId] = self::buildInvoicePeriodTreeSnapshotOrders( $rowsByCollection[$collectionId] ?? [], $customerNumber ); } $uncollectedOrders = self::buildInvoicePeriodTreeSnapshotOrders( $rowsByCollection[0] ?? [], $customerNumber ); self::enrichInvoicePeriodTreeOrders($ordersByCollection, $uncollectedOrders); $collections = []; foreach ($collectionMetadata as $collectionId => $metadata) { $orders = $ordersByCollection[$collectionId] ?? []; foreach ($orders as &$order) { $createdAt = (string)($order['date'] ?? ''); $order['in_selected_period'] = $createdAt >= $dateFrom && $createdAt <= $dateTo; $order['total_net_amount'] = (float)($order['amount'] ?? 0); $order['items'] = $order['order_items'] ?? []; unset($order['order_items']); } unset($order); $periodOrders = array_values(array_filter( $orders, static fn(array $order): bool => !empty($order['in_selected_period']) )); $collectionObject = (new collected_order_invoices_o())->select((int)$collectionId); $supersession = $collectionObject->exists() ? $collectionObject->getSupersessionMetadata() : null; $notes = preg_replace( '/\n?\[\[invoice_collection_superseded:(\{.*?\})\]\]/', '', (string)($metadata['notes'] ?? '') ); $collections[] = [ 'id' => (int)$collectionId, 'customer_number' => $customerNumber, 'name' => (string)($metadata['name'] ?? ''), 'notes' => rtrim((string)$notes), 'processor' => (int)($metadata['processor'] ?? 0), 'external_id' => !empty($metadata['external_id']) ? (string)$metadata['external_id'] : null, 'booked_invoice_id' => (int)($metadata['booked_invoice_id'] ?? 0) ?: null, 'po_number' => !empty($metadata['po_number']) ? (string)$metadata['po_number'] : null, 'error_message' => !empty($metadata['error_message']) ? (string)$metadata['error_message'] : null, 'closed_at' => !empty($metadata['closed_at']) ? (string)$metadata['closed_at'] : null, 'created_at' => $metadata['created_at'] ?? null, 'updated_at' => $metadata['updated_at'] ?? null, 'state' => (string)($metadata['state'] ?? self::periodInvoiceCollectionState($metadata)), 'in_selected_period' => $periodOrders !== [], 'complete_order_count' => count($orders), 'complete_total_net_amount' => array_sum(array_column($orders, 'total_net_amount')), 'period_order_count' => count($periodOrders), 'period_total_net_amount' => array_sum(array_column($periodOrders, 'total_net_amount')), 'superseded_by_invoice_collection_id' => $supersession['target_invoice_collection_id'] ?? null, 'superseded_by_user_id' => $supersession['superseded_by_user_id'] ?? null, 'superseded_at' => $supersession['superseded_at'] ?? null, 'orders' => $orders, ]; } foreach ($uncollectedOrders as &$order) { $order['in_selected_period'] = true; $order['total_net_amount'] = (float)($order['amount'] ?? 0); $order['items'] = $order['order_items'] ?? []; unset($order['order_items']); } unset($order); $binding = (new invoice_collection_bulk_action_service())->createSnapshotBinding( $actorUserId, $customerNumber, substr($dateFrom, 0, 10), substr($dateTo, 0, 10), array_map(static fn(array $collection): int => (int)$collection['id'], $collections) ); $agreements = self::invoicePeriodTreeAgreements($period['types'] ?? [], $customerNumber); $payments = []; foreach ([...array_values($ordersByCollection), $uncollectedOrders] as $orderGroup) { foreach ($orderGroup as $order) { foreach (($order['payments'] ?? []) as $payment) { $payment['invoice_collection_id'] = (int)($order['invoice_collection_id'] ?? 0) ?: null; $payments[] = $payment; } } } return [ 'complete' => true, 'snapshot_revision' => (string)$binding['snapshot_revision'], 'customer_number' => $customerNumber, // Keep the public snapshot identity aligned with the Y-m-d request // contract; full-day timestamps are internal query boundaries. 'date_from' => substr($dateFrom, 0, 10), 'date_to' => substr($dateTo, 0, 10), 'capabilities' => [ 'object_tree_v2' => true, 'actions' => [ invoice_collection_bulk_action_service::ACTION_CLEAN_CUSTOMER_RULES => $this->hasPermission('reset_collected_invoice_economic'), invoice_collection_bulk_action_service::ACTION_MERGE => $this->hasPermission('move_collected_invoice'), invoice_collection_bulk_action_service::ACTION_SPLIT_BY_MONTH => $this->hasPermission('split_collected_invoice'), invoice_collection_bulk_action_service::ACTION_RESET_HIDDEN_PRICES => $this->hasPermission('reset_collected_invoice_economic'), invoice_collection_bulk_action_service::ACTION_QUEUE_ECONOMIC => $this->hasPermission('add_collected_invoice_economic'), ], ], 'customer' => $customer, 'collections' => $collections, 'uncollected_orders' => $uncollectedOrders, 'agreements' => $agreements, 'payments' => $payments, 'economic_invoices' => array_values(array_map(static fn(array $collection): array => [ 'invoice_collection_id' => (int)$collection['id'], 'state' => (string)$collection['state'], 'external_id' => $collection['external_id'], 'booked_invoice_id' => $collection['booked_invoice_id'], 'available_type' => !empty($collection['booked_invoice_id']) ? 'booked' : (!empty($collection['external_id']) ? 'draft' : null), ], $collections)), ]; } private static function mergeInvoicePeriodTreeCustomer(array $types, int $customerNumber): array { $matches = []; foreach ($types as $customers) { if (!is_array($customers)) { continue; } foreach ($customers as $customer) { if (is_array($customer) && (int)($customer['customer_number'] ?? 0) === $customerNumber) { $matches[] = $customer; } } } $customer = $matches[0] ?? [ 'id' => null, 'customer_number' => $customerNumber, 'customer_name' => self::getLocalCustomerName($customerNumber), 'requires_action' => false, 'meta' => [], ]; foreach ($matches as $match) { $customer['requires_action'] = !empty($customer['requires_action']) || !empty($match['requires_action']); $customer['meta'] = array_replace_recursive( is_array($customer['meta'] ?? null) ? $customer['meta'] : [], is_array($match['meta'] ?? null) ? $match['meta'] : [] ); foreach (['flags', 'flag_counts', 'review', 'queue', 'draft'] as $key) { if (!empty($match[$key])) { $customer[$key] = $match[$key]; } } } unset($customer['transactions'], $customer['invoice_collections'], $customer['tree_snapshot']); $customer['capabilities']['object_tree_v2'] = true; return $customer; } /** @return array> */ private static function invoicePeriodTreeCollectionMetadata(array $types, int $customerNumber): array { $metadata = []; foreach ($types as $customers) { if (!is_array($customers)) { continue; } foreach ($customers as $customer) { if (!is_array($customer) || (int)($customer['customer_number'] ?? 0) !== $customerNumber) { continue; } foreach (($customer['invoice_collections'] ?? []) as $collection) { $id = (int)($collection['id'] ?? $collection['invoice_collection_id'] ?? 0); if ($id > 0 && (int)($collection['customer_number'] ?? $customerNumber) === $customerNumber) { $metadata[$id] = is_array($collection) ? $collection : []; } } } } ksort($metadata, SORT_NUMERIC); return $metadata; } private static function invoicePeriodTreeAgreements(array $types, int $customerNumber): array { $agreements = []; foreach ($types as $type => $customers) { if ($type === 'all' || !is_array($customers)) { continue; } foreach ($customers as $customer) { if (!is_array($customer) || (int)($customer['customer_number'] ?? 0) !== $customerNumber) { continue; } $meta = is_array($customer['meta'] ?? null) ? $customer['meta'] : []; if ($meta !== []) { $agreements[] = [ 'type' => (string)$type, 'requires_action' => (bool)($customer['requires_action'] ?? false), 'meta' => $meta, ]; } } } return $agreements; } /** * Add locally available child domains without exposing attachment storage object names. * * @param array>> $ordersByCollection * @param array> $uncollectedOrders */ private static function enrichInvoicePeriodTreeOrders( array &$ordersByCollection, array &$uncollectedOrders ): void { global $db; $orderIds = []; foreach ($ordersByCollection as $orders) { foreach ($orders as $order) { $orderIds[(int)$order['id']] = (int)$order['id']; } } foreach ($uncollectedOrders as $order) { $orderIds[(int)$order['id']] = (int)$order['id']; } $orderIds = array_values(array_filter($orderIds)); if ($orderIds === []) { return; } $ids = implode(',', $orderIds); $attachments = []; $attachmentResult = $db->query( "SELECT id, object_id, content, created_at, updated_at FROM object_attachments WHERE object_id IN ({$ids}) AND object_type = 'orders' AND deleted_at IS NULL ORDER BY object_id ASC, id ASC" ); if (!$attachmentResult) { throw new \RuntimeException('Failed to load invoice-period tree attachments.'); } if ($attachmentResult) { while ($row = $attachmentResult->fetch_assoc()) { $content = json_decode((string)($row['content'] ?? ''), true); $content = is_array($content) ? $content : []; $other = is_scalar($content['other'] ?? null) ? (string)$content['other'] : null; $isWashCertificate = strtolower((string)$other) === 'wash_certificate'; $attachments[(int)$row['object_id']][] = [ 'id' => (int)$row['id'], 'kind' => !empty($content['document']) ? 'document' : (!empty($content['image']) ? 'image' : 'other'), 'filename' => !$isWashCertificate && $other !== '' ? $other : null, 'has_file' => !empty($content['document']) || !empty($content['image']), 'is_wash_certificate' => $isWashCertificate, 'created_at' => $row['created_at'] ?? null, 'updated_at' => $row['updated_at'] ?? null, ]; } } $bookings = []; $bookingResult = $db->query( "SELECT id, customer_number, department, reg_1, reg_2, reg_3, datetime, note, reference, po, pickup, items, order_id, created_at, updated_at FROM order_bookings WHERE order_id IN ({$ids}) AND deleted_at IS NULL ORDER BY order_id ASC, id ASC" ); if (!$bookingResult) { throw new \RuntimeException('Failed to load invoice-period tree bookings.'); } if ($bookingResult) { while ($row = $bookingResult->fetch_assoc()) { $items = json_decode((string)($row['items'] ?? ''), true); $bookings[(int)$row['order_id']][] = [ 'id' => (int)$row['id'], 'customer_number' => (int)$row['customer_number'], 'department_id' => (int)$row['department'], 'registrations' => array_values(array_filter([ (string)($row['reg_1'] ?? ''), (string)($row['reg_2'] ?? ''), (string)($row['reg_3'] ?? ''), ], static fn(string $reg): bool => $reg !== '')), 'datetime' => $row['datetime'] ?? null, 'note' => (string)($row['note'] ?? ''), 'reference' => (string)($row['reference'] ?? ''), 'po' => (string)($row['po'] ?? ''), 'pickup' => (bool)($row['pickup'] ?? false), 'items' => is_array($items) ? $items : [], 'created_at' => $row['created_at'] ?? null, 'updated_at' => $row['updated_at'] ?? null, ]; } } $payments = []; $economicResult = $db->query( "SELECT id, invoice_draft_id, invoice_id, created_at, updated_at FROM economic_module_orders WHERE id IN ({$ids})" ); if (!$economicResult) { throw new \RuntimeException('Failed to load invoice-period tree e-conomic links.'); } if ($economicResult) { while ($row = $economicResult->fetch_assoc()) { $orderId = (int)$row['id']; $payments[$orderId][] = [ 'provider' => 'economic', 'order_id' => $orderId, 'state' => !empty($row['invoice_id']) ? 'booked' : (!empty($row['invoice_draft_id']) ? 'draft' : 'unlinked'), 'invoice_draft_id' => (int)($row['invoice_draft_id'] ?? 0) ?: null, 'invoice_id' => (int)($row['invoice_id'] ?? 0) ?: null, 'created_at' => $row['created_at'] ?? null, 'updated_at' => $row['updated_at'] ?? null, ]; } } $stripeResult = $db->query( "SELECT smo.id, smo.invoice_id, smo.customer_id, smo.email_sent, smo.created_at, spi.payment_intent_id FROM stripe_module_orders smo LEFT JOIN stripe_payment_intents spi ON spi.order_id = smo.id WHERE smo.id IN ({$ids})" ); if (!$stripeResult) { throw new \RuntimeException('Failed to load invoice-period tree Stripe links.'); } if ($stripeResult) { while ($row = $stripeResult->fetch_assoc()) { $orderId = (int)$row['id']; $payments[$orderId][] = [ 'provider' => 'stripe', 'order_id' => $orderId, 'state' => !empty($row['invoice_id']) || !empty($row['payment_intent_id']) ? 'linked' : 'unlinked', 'invoice_id' => $row['invoice_id'] ?? null, 'payment_intent_id' => $row['payment_intent_id'] ?? null, 'email_sent_at' => $row['email_sent'] ?? null, 'created_at' => $row['created_at'] ?? null, ]; } } $xlvask = []; $washIds = []; $allOrders = []; foreach ($ordersByCollection as $orders) { foreach ($orders as $order) { $allOrders[] = $order; } } $allOrders = [...$allOrders, ...$uncollectedOrders]; foreach ($allOrders as $order) { if (!empty($order['wash_id'])) { $washIds[(string)$order['wash_id']] = true; } } if ($washIds !== []) { $quoted = implode(',', array_map( static fn(string $washId): string => "'" . $db->escape_string($washId) . "'", array_keys($washIds) )); $xlvaskResult = $db->query( "SELECT id, WashId, CustomerId, Customer, Location, Hall, HallId, StartTime, FinishTime, RegistrationNumber, VehicleType, FinishStatus, cached_total_net_amount, cached_primary_product_name, cached_amount_at FROM xlvask_usage_logs WHERE WashId IN ({$quoted}) ORDER BY id ASC" ); if (!$xlvaskResult) { throw new \RuntimeException('Failed to load invoice-period tree XL Vask usage.'); } if ($xlvaskResult) { while ($row = $xlvaskResult->fetch_assoc()) { $xlvask[(string)$row['WashId']][] = [ 'usage_log_id' => (int)$row['id'], 'wash_id' => (string)$row['WashId'], 'customer_id' => $row['CustomerId'] ?? null, 'customer_name' => $row['Customer'] ?? null, 'location' => $row['Location'] ?? null, 'hall' => $row['Hall'] ?? null, 'hall_id' => $row['HallId'] ?? null, 'start_time' => $row['StartTime'] ?? null, 'finish_time' => $row['FinishTime'] ?? null, 'registration_number' => $row['RegistrationNumber'] ?? null, 'vehicle_type' => $row['VehicleType'] ?? null, 'finish_status' => $row['FinishStatus'] ?? null, 'total_net_amount' => $row['cached_total_net_amount'] === null ? null : (float)$row['cached_total_net_amount'], 'primary_product_name' => $row['cached_primary_product_name'] ?? null, 'amount_cached_at' => $row['cached_amount_at'] ?? null, ]; } } } $enrich = static function (array &$orders) use ($attachments, $bookings, $payments, $xlvask): void { foreach ($orders as &$order) { $orderId = (int)$order['id']; $order['attachments'] = $attachments[$orderId] ?? []; $order['bookings'] = $bookings[$orderId] ?? []; $order['payments'] = $payments[$orderId] ?? []; $order['xlvask'] = !empty($order['wash_id']) ? ($xlvask[(string)$order['wash_id']] ?? []) : []; } unset($order); }; foreach ($ordersByCollection as &$orders) { $enrich($orders); } unset($orders); $enrich($uncollectedOrders); } private static function customerSupportsCustomerLevelQueueBlocking(array $customer): bool { $meta = $customer['meta'] ?? []; return isset($meta['fixed_pricing']) || isset($meta['wash_subscription']) || !empty($meta['has_vehicle_subscription']); } /** * @throws Exception */ private static function getVehicleSubscriptions(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // If customersWithTransactions is not provided, get all customers with transactions in the specified date range if ($customersWithTransactions === null) { $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); } // Since these are monthly subscriptions, we don't need to filter by transactions $customer_numbers = self::filterCustomerNumbers( (new \objects\users_o())->getCustomersWithVehicleSubscriptions(), $onlyCustomerNumbers ); // Get all customers with vehicle subscriptions $subscriptions = []; $customer_names = (new \objects\users_o())->getCustomerNames(array_map('intval', $customer_numbers), false); $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); /** @var int $customer_number */ foreach ( $customer_numbers as $customer_number ) { $customer = $customers_by_number[(int)$customer_number] ?? null; if ($customer !== null) { $customer['meta'] = array_merge($customer['meta'] ?? [], [ 'has_vehicle_subscription' => true, ]); $subscriptions[] = $customer; continue; } $subscriptions[] = self::constructCustomerObject( (int)$customer_number, $customer_names[(int)$customer_number] ?? 'Unknown Customer', [], true, null, [ 'has_vehicle_subscription' => true, ] ); } return $subscriptions; } /** * Get the customer from the list of customers with transactions. * * @param int $customer_number The customer number to search for. * @param array $customersWithTransactions The list of customers with transactions. * @return array|null The customer object if found, null otherwise. */ private static function getCustomerFromList(int $customer_number, array $customersWithTransactions): ?array { $direct = $customersWithTransactions[$customer_number] ?? null; if (is_array($direct) && (int)($direct['customer_number'] ?? 0) === $customer_number) { return $direct; } // Search for the customer in the list of customers with transactions foreach ( $customersWithTransactions as $customer ) { if ($customer['customer_number'] === $customer_number) { return $customer; } } // If the customer is not found, return null return null; } /** * @throws Exception */ private static function getFixedPricing(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // Get all customers with fixed pricing $customer_numbers = self::filterCustomerNumbers( (new \objects\users_o())->getCustomersWithFixedPricing(), $onlyCustomerNumbers ); // If customersWithTransactions is not provided, only resolve transaction customers for fixed-pricing customers. if ($customersWithTransactions === null) { $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $customer_numbers); } $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); // Get the customers fixed pricing $tmp_fixed_pricing = array_map(function ($arr) { // Return the customer number and price return [ 'customer_number' => (int)$arr['customer_number'], 'price' => (float)$arr['price'], 'description' => (string)($arr['description'] ?? ''), ]; }, (new \objects\customer_fixed_pricing_o())->getFieldsWhere(['customer_number' => $customer_numbers], ['customer_number', 'price', 'description'])); $fixed_pricing_by_customer_number = []; foreach ( $tmp_fixed_pricing as $item ) { $fixed_pricing_by_customer_number[(int)$item['customer_number']] = $item; } $customer_names = (new \objects\users_o())->getCustomerNames(array_map('intval', $customer_numbers), false); // Get all customers with fixed pricing $fixed_pricing = []; /** @var int $customer_number */ foreach ( $customer_numbers as $customer_number ) { $customer_number = (int)$customer_number; $fixed_pricing[] = $customers_by_number[$customer_number] ?? null; // Check if the last entry is null, if so, create a new customer object if (end($fixed_pricing) === null) { $fixed_pricing[count($fixed_pricing) - 1] = self::constructCustomerObject( $customer_number, $customer_names[$customer_number] ?? 'Unknown Customer', [], true, ); } // Add the fixed pricing to the customer object $fixed_pricing[count($fixed_pricing) - 1]['meta']['fixed_pricing'] = $fixed_pricing_by_customer_number[$customer_number] ?? null; } return $fixed_pricing; } /** * Get an object from an array based on a callback function. * * @param array $array The array to search in. * @param callable $callback The callback function to use for searching. * @return mixed|null The found object or null if not found. */ private static function getObjectFromArray(array $array, callable $callback): mixed { // Iterate through the array and apply the callback function to each element foreach ( $array as $item ) { // If the callback returns true, return the item if ($callback($item)) { return $item; } } // If no item matches the callback, return null return null; } /** * @throws Exception */ private static function getTankCleaning(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // If customersWithTransactions is not provided, get all customers with transactions in the specified date range if ($customersWithTransactions === null) { $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); } // Filter out customers that do not have any transactions in the specified date range $customer_numbers = self::filterCustomerNumbers( (new \objects\users_o())->getCustomersWithTankCleaning(), $onlyCustomerNumbers ); self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions); // Get all customers with tank cleaning $tank_cleaning = []; $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); /** @var int $customer_number */ foreach ( $customer_numbers as $customer_number ) { $customer = $customers_by_number[(int)$customer_number] ?? null; if ($customer !== null) { $tank_cleaning[] = $customer; } // Add the tank cleaning to the list if it has transactions } return $tank_cleaning; } /** * Filters the customer numbers based on whether they have transactions in the specified date range. * * @param array $customer_numbers The customer numbers to filter. * @param array $customersWithTransactions The customers with transactions in the specified date range. */ private static function filterCustomersWithTransactions(array &$customer_numbers, array $customersWithTransactions): void { $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); $customer_numbers = array_values(array_filter($customer_numbers, static function ($customer_number) use ($customers_by_number): bool { return isset($customers_by_number[(int)$customer_number]); })); } /** * @throws Exception */ private static function getSpecialArrangements(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // If customersWithTransactions is not provided, get all customers with transactions in the specified date range if ($customersWithTransactions === null) { $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); } // Get all customers with tank cleaning $customer_numbers = self::filterCustomerNumbers( (new \objects\users_o())->getCustomersWithSpecialArrangements(), $onlyCustomerNumbers ); // Filter out customers that do not have any transactions in the specified date range self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions); $special_arrangements = []; $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); /** @var int $customer_number */ foreach ( $customer_numbers as $customer_number ) { $customer = $customers_by_number[(int)$customer_number] ?? null; if ($customer !== null) { $special_arrangements[] = $customer; } } return $special_arrangements; } /** * @throws Exception */ private static function getInvoicingPerOrder(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // Get all customers with the invoicing per order attribute $customer_numbers = self::filterCustomerNumbers( (new users_o())->getCustomerNumbersWithAttributes(['invoiceAllOrdersIndividually']), $onlyCustomerNumbers ); // Filter out customers that do not have any transactions in the specified date range if ($customersWithTransactions === null) { $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); } self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions); $invoicing_per_order = []; $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); /** @var int $customer_number */ foreach ( $customer_numbers as $customer_number ) { // Add the invoicing per order to the list $customer = $customers_by_number[(int)$customer_number] ?? null; if ($customer !== null) { $invoicing_per_order[] = $customer; } } return $invoicing_per_order; } /** * @throws Exception */ private static function getPossibleDuplicates(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // If customersWithTransactions is not provided, get all customers with transactions in the specified date range if ($customersWithTransactions === null) { $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); } $allowedCustomerNumbers = $onlyCustomerNumbers !== null ? array_fill_keys(self::normalizeCustomerNumbers($onlyCustomerNumbers), true) : null; $ordersByRegistration = []; foreach ($customersWithTransactions as $customer) { foreach (($customer['transactions'] ?? []) as $transaction) { $transaction = self::constructTransactionObject($transaction); $registration = trim((string)($transaction['reg_1'] ?? '')); if ($registration === '') { continue; } $customerNumber = (int)($transaction['customer_number'] ?? 0); if ($allowedCustomerNumbers !== null && !isset($allowedCustomerNumbers[$customerNumber])) { continue; } $ordersByRegistration[$registration][] = [ 'id' => (int)$transaction['id'], 'created_at' => (string)$transaction['date'], 'customer_number' => $customerNumber, 'object' => $transaction, ]; } } $orders = invoicing_period_utils::filterPossibleDuplicates($ordersByRegistration, 86400); $tmp_customer_arr = []; // Remove duplicates from the customer numbers $possible_duplicates = []; $customer_names = []; foreach ($orders as $order) { $customer_names[(int)($order[0]['customer_number'] ?? 0)] = true; } $customer_names = (new \objects\users_o())->getCustomerNames(array_keys($customer_names), false); $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); /** @var int $customer_number */ foreach ( $orders as $order ) { // Get the customer number from the order $customer_number = (int)($order[0]['customer_number'] ?? 0); if ($allowedCustomerNumbers !== null && !isset($allowedCustomerNumbers[$customer_number])) { continue; } // Check if the customer number is already in the array if (isset($tmp_customer_arr[$customer_number])) { continue; } // Add the customer number to the array $tmp_customer_arr[$customer_number] = true; // Get the customer from the list of customers with transactions $customer = $customers_by_number[$customer_number] ?? null; // Add the customer to the possible duplicates array $possible_duplicates[] = self::constructCustomerObject( $customer_number, $customer_names[$customer_number] ?? 'Unknown Customer', array_map(function ($transaction) { // Construct the transaction object from the order return $transaction['object']; }, $order), false, // Requires action because there are possible duplicates $customer['id'] ?? null // Use the id from the customer object if available ); } return $possible_duplicates; } }