*/ private static array $departmentNameCache = []; /** * Cache whether a department is excluded from invoicing. * @var array */ private static array $departmentExcludedFromInvoicingCache = []; private static ?bool $collectedOrderInvoicesHasDeletedAtColumn = null; /** * 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 { $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); } /** * @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 [ 'dateFrom' => '', 'dateTo' => '', ]; } /** * @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', 'includeRequiresAction', 'includeBooked', ]; $isPaginatedRequest = false; foreach ($paginationKeys as $key) { if ($response->isRequestParameterSet($key)) { $isPaginatedRequest = true; break; } } if (!$isPaginatedRequest) { return null; } return self::normalizePeriodPaginationOptions($response->getAllRequestParameters()); } 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'; } return [ 'periodView' => $periodView, 'page' => $page, 'limit' => $limit, 'search' => trim((string)($parameters['search'] ?? '')), 'flagTab' => $flagTab, 'includeRequiresAction' => self::parsePeriodBooleanOption( $parameters['includeRequiresAction'] ?? null, true ), 'includeBooked' => self::parsePeriodBooleanOption($parameters['includeBooked'] ?? null, true), ]; } 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::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); if (!empty($options['flagTab'])) { $types = self::filterPeriodTypesByFlagTab($types, (string)$options['flagTab']); } $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), ], 'order' => [ 'field' => 'customer_name', 'direction' => 'asc', ], ], ]; } 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 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 ($values as $value) { if (str_contains(self::normalizePeriodSearchTerm((string)$value), $search)) { return true; } } return false; } 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->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'); // Get the invoicing period for the user $period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers, $includeInvoicePeriodFlags); 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->post('/superuser/invoicing/period/flags', function () { global $response; $this->requirePermission('add_invoice_period_flag'); $user = (new authentication())->get_user(); if (!$user) { $response->error('Invalid session', 400); return; } try { $flag = (new invoice_period_flag_service())->createManualFlag( $this->getParametersAsArray(), (int)$user->id ); $response->success($flag); } catch (\InvalidArgumentException $e) { $response->error($e->getMessage(), 400); } catch (\Throwable $e) { $response->error($e->getMessage(), 500); } }, [ 'add_invoice_period_flag' => 'Add a manual invoice period flag', ] ); $this->patch('/superuser/invoicing/period/flags/{id}/status', function () { global $response; $this->requirePermission('update_invoice_period_flag_status'); $user = (new authentication())->get_user(); if (!$user) { $response->error('Invalid session', 400); return; } $id = (int)($this->fromRoute('id') ?? 0); try { $flag = (new invoice_period_flag_service())->updateManualFlagStatus( $id, (string)$this->getParameter('status'), $this->isParametersSet(['reason']) ? (string)$this->getParameter('reason') : null, (int)$user->id ); $response->success($flag); } catch (\InvalidArgumentException $e) { $response->error($e->getMessage(), 400); } catch (\Throwable $e) { $response->error($e->getMessage(), 500); } }, [ 'update_invoice_period_flag_status' => 'Update a manual invoice period flag status', ] ); $this->post('/superuser/invoicing/period/flags/automatic/status', function () { global $response; $this->requirePermission('update_invoice_period_flag_status'); $user = (new authentication())->get_user(); if (!$user) { $response->error('Invalid session', 400); return; } try { $flag = (new invoice_period_flag_service())->updateAutomaticFlagStatus( $this->getParametersAsArray(), (int)$user->id ); $response->success($flag); } catch (\InvalidArgumentException $e) { $response->error($e->getMessage(), 400); } catch (\Throwable $e) { $response->error($e->getMessage(), 500); } }, [ 'update_invoice_period_flag_status' => 'Update an automatic invoice period flag status', ] ); $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 (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); } $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'] ?? [], ); 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'); } 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(), ]; } /** * @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(); return [ 'id' => $transaction->id, 'date' => $transaction->created_at->value(), 'amount' => $transaction->temporary_net_amount, // Use the temporary net amount set earlier 'booked' => self::isTransactionBookedFromLocalState($transaction), 'department_id' => $departmentId, 'customer_number' => (int)$transaction->customer_id->value(), 'reference' => (string)$transaction->reference->value(), 'po' => (string)$transaction->po->value(), 'notes' => (string)$transaction->notes->value(), 'reg_1' => (string)$transaction->reg_1->value(), 'reg_2' => (string)$transaction->reg_2->value(), 'reg_3' => (string)$transaction->reg_3->value(), 'excluded' => !$transaction->isIncludedInInvoicing(), 'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null, 'queue_status' => null, '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'] ?? ''), 'excluded' => (bool)($transaction['excluded'] ?? ((int)($transaction['include_in_invoice_effective'] ?? 1) !== 1)), 'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : 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 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; } 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; } }