versioning = $versioning ?? new economic_v2_versioning_service(); $this->economic = $economic; } public function getAllDistributions(string $date_from, string $date_to): array { $this->ensureVersionHistoryAvailable([ 'fixed_pricing', 'vehicle_subscriptions', 'discount_overrides', ]); $fixed_pricing = $this->getFixedPricingDistribution($date_from, $date_to); $wash_subscriptions = $this->getWashSubscriptionsDistribution($date_from, $date_to); return [ 'fixed_pricing' => $fixed_pricing, 'wash_subscriptions' => $wash_subscriptions, 'customer_prices' => $this->getCustomerPricesDistribution($date_from, $date_to), 'booked_department_75' => $this->buildBookedDepartment75Distribution( $date_from, $date_to, $fixed_pricing, $wash_subscriptions ), ]; } public function getBookedDepartment75Distribution(string $date_from, string $date_to): array { $this->ensureVersionHistoryAvailable([ 'fixed_pricing', 'vehicle_subscriptions', ]); return $this->buildBookedDepartment75Distribution( $date_from, $date_to, $this->getFixedPricingDistribution($date_from, $date_to), $this->getWashSubscriptionsDistribution($date_from, $date_to) ); } public function getFixedPricingDistribution(string $date_from, string $date_to): array { $this->ensureVersionHistoryAvailable(['fixed_pricing']); [$from_ts, $to_ts] = $this->buildDateRange($date_from, $date_to); $orders = $this->fetchOrdersInRange($from_ts, $to_ts); $order_items = $this->fetchOrderItemsByOrderIds(array_map(static fn($o) => (int)$o['id'], $orders)); $collected = $this->collectFixedPricingData($orders, $order_items); if (empty($collected['groups'])) { $fallback = $this->collectFixedPricingData($orders, $order_items, true); if (!empty($fallback['groups'])) { $fallback['warnings'][] = 'System order fallback used for fixed pricing (department 10).'; $collected = $fallback; } } $groups = $collected['groups']; $customer_transactions = $collected['customer_transactions']; $warnings = $collected['warnings']; $customers = []; $collective = [ 'total_fixed_price' => 0.0, 'total_original_price' => 0.0, 'total_department_totals' => [], 'total_department_totals_relative' => [], ]; foreach ($groups as $group) { $customer_number = (int)$group['customer_number']; if (!isset($customers[$customer_number])) { $customers[$customer_number] = $this->buildCustomerEnvelope($customer_number, $customer_transactions[$customer_number] ?? []); $customers[$customer_number]['meta']['fixed_pricing'] = [ 'price' => 0.0, 'original_price' => 0.0, 'department_totals' => [], 'department_totals_relative' => [], 'version_groups' => [], ]; } $group_original = (float)$group['original_price']; $group_price = (float)$group['price']; $relative_department_totals = []; $group_total_department_amount = array_sum($group['department_totals']); foreach ($group['department_totals'] as $department_id => $department_amount) { $department_id = (int)$department_id; $relative_amount = 0.0; if ($group_total_department_amount > 0.0) { $relative_amount = ((float)$department_amount / $group_total_department_amount) * $group_price; } $relative_department_totals[$department_id] = $relative_amount; if (!isset($customers[$customer_number]['meta']['fixed_pricing']['department_totals'][$department_id])) { $customers[$customer_number]['meta']['fixed_pricing']['department_totals'][$department_id] = 0.0; } if (!isset($customers[$customer_number]['meta']['fixed_pricing']['department_totals_relative'][$department_id])) { $customers[$customer_number]['meta']['fixed_pricing']['department_totals_relative'][$department_id] = 0.0; } $customers[$customer_number]['meta']['fixed_pricing']['department_totals'][$department_id] += (float)$department_amount; $customers[$customer_number]['meta']['fixed_pricing']['department_totals_relative'][$department_id] += (float)$relative_amount; if (!isset($collective['total_department_totals'][$department_id])) { $collective['total_department_totals'][$department_id] = 0.0; } if (!isset($collective['total_department_totals_relative'][$department_id])) { $collective['total_department_totals_relative'][$department_id] = 0.0; } $collective['total_department_totals'][$department_id] += (float)$department_amount; $collective['total_department_totals_relative'][$department_id] += (float)$relative_amount; } if ($group_total_department_amount <= 0.0) { $warnings[] = 'Fixed pricing group has no transaction basis for customer ' . $customer_number . ' in ' . $group['month']; } $customers[$customer_number]['meta']['fixed_pricing']['price'] += $group_price; $customers[$customer_number]['meta']['fixed_pricing']['original_price'] += $group_original; $customers[$customer_number]['meta']['fixed_pricing']['version_groups'][] = [ 'version_id' => (int)$group['version_id'], 'month' => (string)$group['month'], 'price' => round($group_price, 5), 'description' => (string)$group['description'], 'source' => (string)$group['source'], 'confidence' => round((float)$group['confidence'], 5), 'inferred' => (bool)$group['inferred'], 'effective_from' => (string)$group['effective_from'], 'effective_to' => $group['effective_to'] !== null ? (string)$group['effective_to'] : null, 'original_price' => round($group_original, 5), 'department_totals' => $this->roundMap($group['department_totals']), 'department_totals_relative' => $this->roundMap($relative_department_totals), 'order_ids' => array_values(array_unique(array_map('intval', $group['order_ids']))), ]; $collective['total_fixed_price'] += $group_price; $collective['total_original_price'] += $group_original; } $customers = array_values(array_map(function ($customer) { if (isset($customer['meta']['fixed_pricing'])) { $customer['meta']['fixed_pricing']['price'] = round((float)$customer['meta']['fixed_pricing']['price'], 5); $customer['meta']['fixed_pricing']['original_price'] = round((float)$customer['meta']['fixed_pricing']['original_price'], 5); $customer['meta']['fixed_pricing']['department_totals'] = $this->roundMap($customer['meta']['fixed_pricing']['department_totals']); $customer['meta']['fixed_pricing']['department_totals_relative'] = $this->roundMap($customer['meta']['fixed_pricing']['department_totals_relative']); } return $customer; }, $customers)); return [ 'customers' => $customers, 'collective_results' => [ 'total_fixed_price' => round((float)$collective['total_fixed_price'], 5), 'total_original_price' => round((float)$collective['total_original_price'], 5), 'total_department_totals' => $this->roundMap($collective['total_department_totals']), 'total_department_totals_relative' => $this->roundMap($collective['total_department_totals_relative']), 'total_department_totals_parsed' => $this->parseDepartmentMap($collective['total_department_totals']), 'total_department_totals_relative_parsed' => $this->parseDepartmentMap($collective['total_department_totals_relative']), ], 'warnings' => array_values(array_unique($warnings)), ]; } public function getWashSubscriptionsDistribution(string $date_from, string $date_to): array { $this->ensureVersionHistoryAvailable(['vehicle_subscriptions']); [$from_ts, $to_ts] = $this->buildDateRange($date_from, $date_to); $orders = $this->fetchOrdersInRange($from_ts, $to_ts); $order_items = $this->fetchOrderItemsByOrderIds(array_map(static fn($o) => (int)$o['id'], $orders)); $months = $this->listMonthKeys($from_ts, $to_ts); $collected = $this->collectWashSubscriptionData($orders, $order_items); if (empty($collected['groups'])) { $fallback = $this->collectWashSubscriptionData($orders, $order_items, true); if (!empty($fallback['groups'])) { $fallback['warnings'][] = 'System order fallback used for wash subscriptions (department 10).'; $collected = $fallback; } } $groups = $collected['groups']; $customer_transactions = $collected['customer_transactions']; $customer_department_month_map = $collected['customer_department_month_map']; $warnings = $collected['warnings']; $version_rows = $this->fetchVehicleSubscriptionVersionRows($from_ts, $to_ts); foreach ($version_rows as $row) { $customer_number = (int)$row['customer_number']; if (!$this->shouldIncludeCustomerNumber($customer_number)) { continue; } $reg = (string)$row['reg']; $version_id = (int)$row['id']; $vehicle_type = (int)$row['vehicle_type']; $monthly_price = $this->getSubscriptionMonthlyPrice($vehicle_type); if ($monthly_price <= 0.0) { continue; } foreach ($months as $month_key) { $month_start = $month_key . '-01 00:00:00'; $month_end = date('Y-m-t 23:59:59', strtotime($month_start)); if (!$this->versionOverlaps($row, $month_start, $month_end)) { continue; } $group_key = $customer_number . '|' . $reg . '|' . $version_id . '|' . $month_key; if (isset($groups[$group_key])) { continue; } $fallback_distribution = $this->buildSubscriptionFallbackDistribution( $customer_number, $month_key, $monthly_price, $customer_department_month_map ); $groups[$group_key] = [ 'customer_number' => $customer_number, 'reg' => $reg, 'vehicle_type' => $vehicle_type, 'version_id' => $version_id, 'month' => $month_key, 'monthly_price' => $monthly_price, 'source' => (string)($row['source'] ?? 'unknown'), 'confidence' => (float)($row['confidence'] ?? 0), 'inferred' => (bool)($row['inferred'] ?? false), 'distribution' => $fallback_distribution, 'order_ids' => [], 'fallback' => true, ]; $warnings[] = 'Fallback allocation used for subscription ' . $reg . ' customer ' . $customer_number . ' in ' . $month_key; } } $customers = []; $collective = [ 'total_subscription_price' => 0.0, 'subscription_price_department_distribution' => [], ]; foreach ($groups as $group) { $customer_number = (int)$group['customer_number']; if (!isset($customers[$customer_number])) { $customers[$customer_number] = $this->buildCustomerEnvelope($customer_number, $customer_transactions[$customer_number] ?? []); $customers[$customer_number]['meta']['subscription'] = [ 'subscription_total' => 0.0, 'subscription_price_department_distribution' => [], 'version_groups' => [], ]; } $allocation = $this->normalizeSubscriptionGroupAllocation($group['distribution'], (float)$group['monthly_price']); foreach ($allocation as $department_id => $amount) { if (!isset($customers[$customer_number]['meta']['subscription']['subscription_price_department_distribution'][$department_id])) { $customers[$customer_number]['meta']['subscription']['subscription_price_department_distribution'][$department_id] = 0.0; } $customers[$customer_number]['meta']['subscription']['subscription_price_department_distribution'][$department_id] += $amount; if (!isset($collective['subscription_price_department_distribution'][$department_id])) { $collective['subscription_price_department_distribution'][$department_id] = 0.0; } $collective['subscription_price_department_distribution'][$department_id] += $amount; } $customers[$customer_number]['meta']['subscription']['subscription_total'] += (float)$group['monthly_price']; $customers[$customer_number]['meta']['subscription']['version_groups'][] = [ 'version_id' => (int)$group['version_id'], 'month' => (string)$group['month'], 'reg' => (string)$group['reg'], 'vehicle_type' => (int)$group['vehicle_type'], 'monthly_price' => round((float)$group['monthly_price'], 5), 'source' => (string)$group['source'], 'confidence' => round((float)$group['confidence'], 5), 'inferred' => (bool)$group['inferred'], 'fallback' => (bool)$group['fallback'], 'department_distribution' => $this->roundMap($allocation), 'order_ids' => array_values(array_unique(array_map('intval', $group['order_ids']))), ]; $collective['total_subscription_price'] += (float)$group['monthly_price']; } $customers = array_values(array_map(function ($customer) { if (isset($customer['meta']['subscription'])) { $customer['meta']['subscription']['subscription_total'] = round((float)$customer['meta']['subscription']['subscription_total'], 5); $customer['meta']['subscription']['subscription_price_department_distribution'] = $this->roundMap( $customer['meta']['subscription']['subscription_price_department_distribution'] ); } return $customer; }, $customers)); return [ 'customers' => $customers, 'collective_results' => [ 'total_subscription_price' => round((float)$collective['total_subscription_price'], 5), 'subscription_price_department_distribution' => $this->roundMap($collective['subscription_price_department_distribution']), 'subscription_price_department_distribution_parsed' => $this->parseDepartmentMap($collective['subscription_price_department_distribution']), ], 'warnings' => array_values(array_unique($warnings)), ]; } public function getCustomerPricesDistribution(string $date_from, string $date_to): array { $this->ensureVersionHistoryAvailable(['discount_overrides']); [$from_ts, $to_ts] = $this->buildDateRange($date_from, $date_to); $orders = $this->fetchOrdersInRange($from_ts, $to_ts); $order_items = $this->fetchOrderItemsByOrderIds(array_map(static fn($o) => (int)$o['id'], $orders)); $customers = []; $collective = [ 'total_discount_amount' => 0.0, 'department_discount_totals' => [], ]; foreach ($orders as $order) { $order_id = (int)$order['id']; $customer_number = (int)$order['customer_id']; if (!$this->shouldIncludeCustomerNumber($customer_number)) { continue; } $department_id = (int)$order['department_id']; $created_at = (string)$order['created_at']; $order_is_included = $this->isOrderEligible($order); if (!$order_is_included) { continue; } $order_discount_total = 0.0; foreach (($order_items[$order_id] ?? []) as $item) { $product_id = (int)($item['product_id'] ?? 0); $quantity = (float)($item['quantity'] ?? 0); if ($product_id <= 0 || $quantity <= 0) { continue; } $base_price = (float)$this->getProductDepartmentPrice($product_id, $department_id); if ($base_price <= 0) { continue; } $discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at); $discount_percentage = (float)($discount_row['discount'] ?? 0); if ($discount_percentage <= 0) { continue; } $discount_amount = ($base_price * $quantity) * ($discount_percentage / 100); $order_discount_total += $discount_amount; } if (!isset($customers[$customer_number])) { $customers[$customer_number] = $this->buildCustomerEnvelope($customer_number, []); $customers[$customer_number]['meta']['customer_prices'] = [ 'discount_total' => 0.0, 'department_discount_totals' => [], ]; } $customers[$customer_number]['transactions'][] = $this->buildTransactionObject($order_id, $created_at, $department_id, $order_discount_total, $order_is_included); $customers[$customer_number]['meta']['customer_prices']['discount_total'] += $order_discount_total; if (!isset($customers[$customer_number]['meta']['customer_prices']['department_discount_totals'][$department_id])) { $customers[$customer_number]['meta']['customer_prices']['department_discount_totals'][$department_id] = 0.0; } $customers[$customer_number]['meta']['customer_prices']['department_discount_totals'][$department_id] += $order_discount_total; $collective['total_discount_amount'] += $order_discount_total; if (!isset($collective['department_discount_totals'][$department_id])) { $collective['department_discount_totals'][$department_id] = 0.0; } $collective['department_discount_totals'][$department_id] += $order_discount_total; } $customers = array_values(array_map(function ($customer) { if (isset($customer['meta']['customer_prices'])) { $customer['meta']['customer_prices']['discount_total'] = round((float)$customer['meta']['customer_prices']['discount_total'], 5); $customer['meta']['customer_prices']['department_discount_totals'] = $this->roundMap( $customer['meta']['customer_prices']['department_discount_totals'] ); } return $customer; }, $customers)); return [ 'customers' => $customers, 'collective_results' => [ 'total_discount_amount' => round((float)$collective['total_discount_amount'], 5), 'department_discount_totals' => $this->roundMap($collective['department_discount_totals']), 'department_discount_totals_parsed' => $this->parseDepartmentMap($collective['department_discount_totals']), ], 'warnings' => [], ]; } private function buildBookedDepartment75Distribution( string $date_from, string $date_to, array $fixed_pricing, array $wash_subscriptions ): array { $weight_index = $this->buildBookedDepartment75WeightIndex($fixed_pricing, $wash_subscriptions); $transaction_map = $this->buildBookedDepartment75TransactionMap($fixed_pricing, $wash_subscriptions); $warnings = []; $groups = $this->collectBookedDepartment75Groups($date_from, $date_to, $warnings); $customers = []; $collective = [ 'booked_net_amount' => 0.0, 'distributed_net_amount' => 0.0, 'undistributed_net_amount' => 0.0, 'department_distribution' => [], ]; foreach ($groups as $group) { $customer_number = (int)$group['customer_number']; if (!isset($customers[$customer_number])) { $customers[$customer_number] = $this->buildBookedDepartment75CustomerEnvelope( $customer_number, $transaction_map[$customer_number] ?? [] ); $customers[$customer_number]['meta']['booked_department_75'] = [ 'booked_net_amount' => 0.0, 'distributed_net_amount' => 0.0, 'undistributed_net_amount' => 0.0, 'department_distribution' => [], 'booked_groups' => [], ]; } $redistributed = $this->redistributeBookedDepartment75Group($group, $weight_index, $warnings); $distributed_amount = array_sum($redistributed['department_distribution']); $booked_amount = (float)$group['booked_net_amount']; $invoice_ids = array_values(array_unique(array_map('intval', $group['invoice_ids'] ?? []))); sort($invoice_ids); $customers[$customer_number]['meta']['booked_department_75']['booked_net_amount'] += $booked_amount; $customers[$customer_number]['meta']['booked_department_75']['distributed_net_amount'] += $distributed_amount; $customers[$customer_number]['meta']['booked_department_75']['undistributed_net_amount'] += (float)$redistributed['undistributed_net_amount']; foreach ($redistributed['department_distribution'] as $department_id => $amount) { $department_id = (int)$department_id; if (!isset($customers[$customer_number]['meta']['booked_department_75']['department_distribution'][$department_id])) { $customers[$customer_number]['meta']['booked_department_75']['department_distribution'][$department_id] = 0.0; } $customers[$customer_number]['meta']['booked_department_75']['department_distribution'][$department_id] += (float)$amount; if (!isset($collective['department_distribution'][$department_id])) { $collective['department_distribution'][$department_id] = 0.0; } $collective['department_distribution'][$department_id] += (float)$amount; } $customers[$customer_number]['meta']['booked_department_75']['booked_groups'][] = [ 'month' => (string)$group['month'], 'source_category' => (string)$group['source_category'], 'invoice_ids' => $invoice_ids, 'booked_net_amount' => round($booked_amount, 5), 'department_distribution' => $this->roundMap($redistributed['department_distribution']), 'undistributed_net_amount' => round((float)$redistributed['undistributed_net_amount'], 5), ]; $collective['booked_net_amount'] += $booked_amount; $collective['distributed_net_amount'] += $distributed_amount; $collective['undistributed_net_amount'] += (float)$redistributed['undistributed_net_amount']; } $customers = array_values(array_map(function (array $customer): array { if (!isset($customer['meta']['booked_department_75'])) { return $customer; } $customer['meta']['booked_department_75']['booked_net_amount'] = round( (float)$customer['meta']['booked_department_75']['booked_net_amount'], 5 ); $customer['meta']['booked_department_75']['distributed_net_amount'] = round( (float)$customer['meta']['booked_department_75']['distributed_net_amount'], 5 ); $customer['meta']['booked_department_75']['undistributed_net_amount'] = round( (float)$customer['meta']['booked_department_75']['undistributed_net_amount'], 5 ); $customer['meta']['booked_department_75']['department_distribution'] = $this->roundMap( $customer['meta']['booked_department_75']['department_distribution'] ); usort($customer['meta']['booked_department_75']['booked_groups'], static function (array $left, array $right): int { $month_compare = strcmp((string)$left['month'], (string)$right['month']); if ($month_compare !== 0) { return $month_compare; } return strcmp((string)$left['source_category'], (string)$right['source_category']); }); return $customer; }, $customers)); usort($customers, static fn(array $left, array $right): int => ((int)$left['customer_number']) <=> ((int)$right['customer_number'])); return [ 'customers' => $customers, 'collective_results' => [ 'booked_net_amount' => round((float)$collective['booked_net_amount'], 5), 'distributed_net_amount' => round((float)$collective['distributed_net_amount'], 5), 'undistributed_net_amount' => round((float)$collective['undistributed_net_amount'], 5), 'department_distribution' => $this->roundMap($collective['department_distribution']), 'department_distribution_parsed' => $this->parseDepartmentMap($collective['department_distribution']), ], 'warnings' => array_values(array_unique($warnings)), ]; } private function buildBookedDepartment75WeightIndex(array $fixed_pricing, array $wash_subscriptions): array { $weights = []; foreach (($fixed_pricing['customers'] ?? []) as $customer) { $customer_number = (int)($customer['customer_number'] ?? 0); foreach ((array)($customer['meta']['fixed_pricing']['version_groups'] ?? []) as $group) { $this->accumulateBookedDepartment75Weights( $weights, $customer_number, (string)($group['month'] ?? ''), 'fixed_pricing', (array)($group['department_totals_relative'] ?? []) ); } } foreach (($wash_subscriptions['customers'] ?? []) as $customer) { $customer_number = (int)($customer['customer_number'] ?? 0); foreach ((array)($customer['meta']['subscription']['version_groups'] ?? []) as $group) { $this->accumulateBookedDepartment75Weights( $weights, $customer_number, (string)($group['month'] ?? ''), 'wash_subscriptions', (array)($group['department_distribution'] ?? []) ); } } return $weights; } private function accumulateBookedDepartment75Weights( array &$weights, int $customer_number, string $month_key, string $source_category, array $distribution ): void { if ($customer_number <= 0 || $month_key === '' || $source_category === '') { return; } foreach ($distribution as $department_id => $amount) { $department_id = (int)$department_id; if (!$this->isDepartmentEligible($department_id)) { continue; } if (!isset($weights[$customer_number][$month_key][$source_category][$department_id])) { $weights[$customer_number][$month_key][$source_category][$department_id] = 0.0; } $weights[$customer_number][$month_key][$source_category][$department_id] += (float)$amount; } } private function buildBookedDepartment75TransactionMap(array $fixed_pricing, array $wash_subscriptions): array { $transaction_map = []; foreach ([$fixed_pricing, $wash_subscriptions] as $response) { foreach (($response['customers'] ?? []) as $customer) { $customer_number = (int)($customer['customer_number'] ?? 0); if ($customer_number <= 0) { continue; } foreach ((array)($customer['transactions'] ?? []) as $transaction) { $transaction_id = (int)($transaction['id'] ?? 0); if ($transaction_id > 0) { $transaction_map[$customer_number][$transaction_id] = $transaction; continue; } $transaction_map[$customer_number][] = $transaction; } } } return $transaction_map; } private function buildBookedDepartment75CustomerEnvelope(int $customer_number, array $transaction_map): array { try { return $this->buildCustomerEnvelope($customer_number, $transaction_map); } catch (\Throwable $e) { return [ 'id' => null, 'customer_number' => $customer_number, 'customer_name' => $this->customer_name_cache[$customer_number] ?? 'Unknown Customer', 'transactions' => array_values($transaction_map), 'requires_action' => false, 'meta' => [], ]; } } private function collectBookedDepartment75Groups(string $date_from, string $date_to, array &$warnings): array { $booked_invoices = $this->fetchBookedInvoicesInDateRange($date_from, $date_to, $warnings); $invoice_ids = []; foreach ($booked_invoices as $invoice_raw) { $invoice = $this->toArray($invoice_raw); $invoice_id = (int)($invoice['bookedInvoiceNumber'] ?? $invoice['booked_invoice_number'] ?? 0); if ($invoice_id > 0) { $invoice_ids[] = $invoice_id; } } $invoice_lines = $this->fetchBookedInvoiceLines($invoice_ids, $warnings); $groups = []; foreach ($booked_invoices as $invoice_raw) { $invoice = $this->toArray($invoice_raw); $invoice_id = (int)($invoice['bookedInvoiceNumber'] ?? $invoice['booked_invoice_number'] ?? 0); $customer_number = (int)($invoice['customer']['customerNumber'] ?? $invoice['customer']['customer_number'] ?? 0); $invoice_date = (string)($invoice['date'] ?? ''); if ($invoice_id <= 0 || $customer_number <= 0 || $invoice_date === '') { continue; } if (!$this->shouldIncludeCustomerNumber($customer_number)) { continue; } $month_key = substr($invoice_date, 0, 7); foreach ($this->parseBookedDepartment75InvoiceLines($invoice_id, (array)($invoice_lines[$invoice_id] ?? []), $warnings) as $line) { if (abs((float)$line['booked_net_amount']) <= self::EPSILON) { continue; } $source_category = (string)($line['source_category'] ?? 'unclassified'); $group_key = $customer_number . '|' . $month_key . '|' . $source_category; if (!isset($groups[$group_key])) { $groups[$group_key] = [ 'customer_number' => $customer_number, 'month' => $month_key, 'source_category' => $source_category, 'invoice_ids' => [], 'booked_net_amount' => 0.0, ]; } $groups[$group_key]['invoice_ids'][$invoice_id] = true; $groups[$group_key]['booked_net_amount'] += (float)$line['booked_net_amount']; } } foreach ($groups as &$group) { $group['invoice_ids'] = array_values(array_map('intval', array_keys((array)$group['invoice_ids']))); sort($group['invoice_ids']); } unset($group); return $groups; } private function parseBookedDepartment75InvoiceLines(int $invoice_id, array $invoice_lines, array &$warnings): array { $parsed = []; $active_category = null; foreach ($invoice_lines as $line_raw) { $line = $this->normalizeBookedDepartment75Line($line_raw); $description = trim((string)($line['description'] ?? '')); if ($description !== '' && $this->isBookedDepartment75TransactionHeader($description)) { $active_category = null; continue; } $marker = $this->resolveBookedDepartment75Marker($description); if ($marker !== null) { $active_category = $marker; continue; } if (!(bool)($line['billable'] ?? false)) { continue; } $department_share = (float)( $line['department_distribution'][(string)self::BOOKED_DEPARTMENT_75] ?? $line['department_distribution'][self::BOOKED_DEPARTMENT_75] ?? 0.0 ); if (abs($department_share) <= self::EPSILON) { continue; } $booked_net_amount = (float)($line['line_net_amount'] ?? 0.0) * ($department_share / 100.0); $source_category = $active_category; if ($source_category === null) { $warnings[] = 'Unable to classify booked department 75 line on invoice ' . $invoice_id . ' line ' . (int)($line['source_line_id'] ?? 0) . '; amount remains undistributed.'; $source_category = 'unclassified'; } $parsed[] = [ 'source_category' => $source_category, 'booked_net_amount' => $booked_net_amount, ]; } return $parsed; } private function normalizeBookedDepartment75Line(mixed $raw_line): array { $line = $this->toArray($raw_line); $product_number = $line['product']['productNumber'] ?? $line['productNumber'] ?? $line['product']['product_number'] ?? null; $quantity = isset($line['quantity']) ? (float)$line['quantity'] : 0.0; $unit_net_price = isset($line['unitNetPrice']) ? (float)$line['unitNetPrice'] : (isset($line['unit_net_price']) ? (float)$line['unit_net_price'] : 0.0); $line_net_amount = isset($line['totalNetAmount']) ? (float)$line['totalNetAmount'] : (isset($line['total_net_amount']) ? (float)$line['total_net_amount'] : $quantity * $unit_net_price); return [ 'description' => (string)($line['description'] ?? ''), 'product_number' => $product_number !== null ? (string)$product_number : null, 'quantity' => $quantity, 'line_net_amount' => $line_net_amount, 'billable' => ($product_number !== null) || abs($line_net_amount) > self::EPSILON || abs($quantity) > self::EPSILON, 'source_line_id' => (int)($line['lineNumber'] ?? $line['line_number'] ?? $line['number'] ?? $line['userInterfaceNumber'] ?? 0), 'department_distribution' => $this->extractBookedDepartment75Distribution($line), ]; } private function extractBookedDepartment75Distribution(array $line): array { $distribution = []; $departmental_distribution = $line['departmentalDistribution'] ?? $line['departmental_distribution'] ?? null; if (is_array($departmental_distribution)) { $distributions = $departmental_distribution['distributions'] ?? null; if (is_array($distributions)) { foreach ($distributions as $entry_raw) { $entry = $this->toArray($entry_raw); $department_number = $entry['department']['departmentNumber'] ?? $entry['department']['department_number'] ?? null; if ($department_number === null) { continue; } $distribution[(string)$department_number] = (float)($entry['percentage'] ?? 0.0); } } if (empty($distribution)) { $fallback_number = $departmental_distribution['departmentalDistributionNumber'] ?? $departmental_distribution['departmental_distribution_number'] ?? null; if ($fallback_number !== null) { $distribution[(string)$fallback_number] = 100.0; } } } if (empty($distribution)) { $department_number = $line['departmentNumber'] ?? $line['department_number'] ?? null; if ($department_number !== null) { $distribution[(string)$department_number] = 100.0; } } return $distribution; } private function resolveBookedDepartment75Marker(string $description): ?string { $normalized = strtolower(trim(preg_replace('/\s+/', ' ', $description))); return match ($normalized) { '# fast pris aftale', 'fast pris aftale' => 'fixed_pricing', '# vaskeabonnementer', 'vaskeabonnementer' => 'wash_subscriptions', default => null, }; } private function isBookedDepartment75TransactionHeader(string $description): bool { return str_starts_with($description, '[') && str_ends_with($description, ']'); } private function redistributeBookedDepartment75Group(array $group, array $weight_index, array &$warnings): array { $booked_amount = (float)($group['booked_net_amount'] ?? 0.0); if (abs($booked_amount) <= self::EPSILON) { return [ 'department_distribution' => [], 'undistributed_net_amount' => 0.0, ]; } $customer_number = (int)($group['customer_number'] ?? 0); $month_key = (string)($group['month'] ?? ''); $source_category = (string)($group['source_category'] ?? 'unclassified'); $invoice_ids = implode(', ', array_map('intval', (array)($group['invoice_ids'] ?? []))); if ($source_category === 'unclassified') { $warnings[] = 'Booked department 75 amount for customer ' . $customer_number . ' in ' . $month_key . ' on invoice(s) ' . $invoice_ids . ' could not be classified and remains undistributed.'; return [ 'department_distribution' => [], 'undistributed_net_amount' => $booked_amount, ]; } $weights = $weight_index[$customer_number][$month_key][$source_category] ?? []; $eligible_weights = []; foreach ($weights as $department_id => $amount) { $department_id = (int)$department_id; if (!$this->isDepartmentEligible($department_id)) { continue; } $eligible_weights[$department_id] = (float)$amount; } $weight_total = array_sum($eligible_weights); if (abs($weight_total) <= self::EPSILON) { $fallback_department_id = $this->getFallbackDistributionDepartmentId(); $warnings[] = 'Booked department 75 ' . $source_category . ' amount for customer ' . $customer_number . ' in ' . $month_key . ' on invoice(s) ' . $invoice_ids . ' has no redistribution basis and was assigned to fallback department ' . $fallback_department_id . '.'; return [ 'department_distribution' => [ $fallback_department_id => $booked_amount, ], 'undistributed_net_amount' => 0.0, ]; } $distribution = []; foreach ($eligible_weights as $department_id => $weight) { $distribution[$department_id] = $booked_amount * ($weight / $weight_total); } return [ 'department_distribution' => $distribution, 'undistributed_net_amount' => 0.0, ]; } protected function fetchBookedInvoicesInDateRange(string $date_from, string $date_to, array &$warnings): array { $cache_key = $date_from . '|' . $date_to; if (isset($this->booked_invoices_cache[$cache_key])) { return $this->booked_invoices_cache[$cache_key]; } $filters = [ '(date$gte:' . $date_from . '$and:date$lte:' . $date_to . ')' => '', ]; $all = []; for ($page = 0; $page < self::BOOKED_INVOICE_MAX_PAGES; $page++) { try { $response = $this->getEconomicClient()->invoices->booked->get( $filters, [ 'skipPages' => $page, 'pageSize' => self::BOOKED_INVOICE_PAGE_SIZE, ] ); } catch (\Throwable $e) { $warnings[] = 'Failed to fetch booked e-conomic invoices for department 75 distribution: ' . $e->getMessage(); break; } $collection = is_array($response->collection ?? null) ? $response->collection : []; $all = array_merge($all, $collection); if (count($collection) < self::BOOKED_INVOICE_PAGE_SIZE) { break; } if ($page + 1 >= self::BOOKED_INVOICE_MAX_PAGES) { $warnings[] = 'Reached pagination safety limit (max_pages=' . self::BOOKED_INVOICE_MAX_PAGES . ') while fetching booked department 75 invoices.'; } } $this->booked_invoices_cache[$cache_key] = $all; return $this->booked_invoices_cache[$cache_key]; } protected function fetchBookedInvoiceLines(array $invoice_ids, array &$warnings): array { $invoice_ids = array_values(array_unique(array_filter(array_map('intval', $invoice_ids), static fn(int $id): bool => $id > 0))); sort($invoice_ids); if (empty($invoice_ids)) { return []; } $cache_key = implode(',', $invoice_ids); if (isset($this->booked_invoice_lines_cache[$cache_key])) { return $this->booked_invoice_lines_cache[$cache_key]; } try { $this->booked_invoice_lines_cache[$cache_key] = $this->getEconomicClient()->invoices->booked->get_invoice_lines($invoice_ids); } catch (\Throwable $e) { $warnings[] = 'Failed to fetch booked invoice lines for department 75 distribution: ' . $e->getMessage(); $this->booked_invoice_lines_cache[$cache_key] = []; } return $this->booked_invoice_lines_cache[$cache_key]; } protected function ensureVersionHistoryAvailable(array $areas): void { if ($this->best_effort_backfill_attempted) { return; } foreach ($areas as $area) { $table = $this->getVersionTableForArea($area); if ($table === null || $this->versionTableHasRows($table)) { continue; } $this->best_effort_backfill_attempted = true; $this->versioning->runBestEffortBackfill(); $this->version_table_has_rows_cache = []; return; } } protected function getVersionTableForArea(string $area): ?string { return match ($area) { 'fixed_pricing' => 'customer_fixed_pricing_versions', 'vehicle_subscriptions' => 'customer_vehicle_subscription_versions', 'discount_overrides' => 'customer_discount_override_versions', default => null, }; } protected function versionTableHasRows(string $table): bool { if (array_key_exists($table, $this->version_table_has_rows_cache)) { return $this->version_table_has_rows_cache[$table]; } global $db; $allowed_tables = [ 'customer_fixed_pricing_versions' => true, 'customer_vehicle_subscription_versions' => true, 'customer_discount_override_versions' => true, ]; if (!isset($allowed_tables[$table])) { return $this->version_table_has_rows_cache[$table] = true; } $result = $db->query("SELECT 1 FROM $table LIMIT 1"); if (!$result) { return $this->version_table_has_rows_cache[$table] = false; } return $this->version_table_has_rows_cache[$table] = $result->num_rows > 0; } protected function collectFixedPricingData(array $orders, array $order_items_by_order_id, bool $system_order_fallback = false): array { $groups = []; $customer_transactions = []; $warnings = []; foreach ($orders as $order) { $order_id = (int)$order['id']; $customer_number = (int)$order['customer_id']; if (!$this->shouldIncludeCustomerNumber($customer_number)) { continue; } $department_id = (int)$order['department_id']; $created_at = (string)$order['created_at']; if ($system_order_fallback) { if (!$this->isSystemOrderCandidate($order, self::FIXED_PRICING_SYSTEM_ORDER_REFERENCE)) { continue; } } elseif (!$this->isOrderEligible($order)) { continue; } $distribution_department_id = $system_order_fallback ? $this->getFallbackDistributionDepartmentId() : $department_id; $fixed_version = $this->versioning->resolveFixedPricingVersionAt($customer_number, $created_at); if ($fixed_version === null) { continue; } $month_key = substr($created_at, 0, 7); $group_key = $customer_number . '|' . (int)$fixed_version['id'] . '|' . $month_key; if (!isset($groups[$group_key])) { $groups[$group_key] = [ 'customer_number' => $customer_number, 'version_id' => (int)$fixed_version['id'], 'month' => $month_key, 'price' => (float)($fixed_version['price'] ?? 0), 'description' => (string)($fixed_version['description'] ?? ''), 'source' => (string)($fixed_version['source'] ?? 'unknown'), 'confidence' => (float)($fixed_version['confidence'] ?? 0), 'inferred' => (bool)($fixed_version['inferred'] ?? false), 'effective_from' => (string)($fixed_version['effective_from'] ?? ''), 'effective_to' => $fixed_version['effective_to'] ?? null, 'original_price' => 0.0, 'department_totals' => [], 'order_ids' => [], ]; } $order_original_price = $this->calculateOrderOriginalPrice( $order_items_by_order_id[$order_id] ?? [], $customer_number, $distribution_department_id, $created_at ); $groups[$group_key]['original_price'] += $order_original_price; if (!isset($groups[$group_key]['department_totals'][$distribution_department_id])) { $groups[$group_key]['department_totals'][$distribution_department_id] = 0.0; } $groups[$group_key]['department_totals'][$distribution_department_id] += $order_original_price; $groups[$group_key]['order_ids'][] = $order_id; if (!isset($customer_transactions[$customer_number][$order_id])) { $customer_transactions[$customer_number][$order_id] = $this->buildTransactionObject( $order_id, $created_at, $department_id, null, $this->isOrderEligible($order) ); } } return [ 'groups' => $groups, 'customer_transactions' => $customer_transactions, 'warnings' => $warnings, ]; } protected function collectWashSubscriptionData(array $orders, array $order_items_by_order_id, bool $system_order_fallback = false): array { $groups = []; $customer_transactions = []; $customer_department_month_map = []; $warnings = []; foreach ($orders as $order) { $order_id = (int)$order['id']; $customer_number = (int)$order['customer_id']; if (!$this->shouldIncludeCustomerNumber($customer_number)) { continue; } $department_id = (int)$order['department_id']; $created_at = (string)$order['created_at']; if ($system_order_fallback) { if (!$this->isSystemOrderCandidate($order, self::WASH_SUBSCRIPTION_SYSTEM_ORDER_REFERENCE)) { continue; } } elseif (!$this->isOrderEligible($order)) { continue; } $distribution_department_id = $system_order_fallback ? $this->getFallbackDistributionDepartmentId() : $department_id; $month_key = substr($created_at, 0, 7); if (!isset($customer_department_month_map[$customer_number][$month_key][$distribution_department_id])) { $customer_department_month_map[$customer_number][$month_key][$distribution_department_id] = 0; } $customer_department_month_map[$customer_number][$month_key][$distribution_department_id]++; $candidates = $this->buildWashSubscriptionCandidates( $order, $order_items_by_order_id[$order_id] ?? [], $system_order_fallback ); if (empty($candidates)) { continue; } $active_subscriptions = $this->versioning->resolveVehicleSubscriptionVersionsAt($customer_number, $created_at); $matched_order = false; foreach ($candidates as $candidate) { $matching_version = $this->findMatchingSubscriptionVersion( $active_subscriptions, (string)$candidate['reg'], (int)$candidate['vehicle_type'] ); if ($matching_version === null) { continue; } $monthly_price = $this->getSubscriptionMonthlyPrice((int)$matching_version['vehicle_type']); if ($monthly_price <= 0.0) { $warnings[] = 'Subscription type ' . (int)$matching_version['vehicle_type'] . ' has no monthly price for customer ' . $customer_number; continue; } $group_key = $customer_number . '|' . (string)$matching_version['reg'] . '|' . (int)$matching_version['id'] . '|' . $month_key; if (!isset($groups[$group_key])) { $groups[$group_key] = [ 'customer_number' => $customer_number, 'reg' => (string)$matching_version['reg'], 'vehicle_type' => (int)$matching_version['vehicle_type'], 'version_id' => (int)$matching_version['id'], 'month' => $month_key, 'monthly_price' => $monthly_price, 'source' => (string)($matching_version['source'] ?? 'unknown'), 'confidence' => (float)($matching_version['confidence'] ?? 0), 'inferred' => (bool)($matching_version['inferred'] ?? false), 'distribution' => [], 'order_ids' => [], 'fallback' => false, ]; } if (!isset($groups[$group_key]['distribution'][$distribution_department_id])) { $groups[$group_key]['distribution'][$distribution_department_id] = 0; } $groups[$group_key]['distribution'][$distribution_department_id]++; $groups[$group_key]['order_ids'][] = $order_id; $matched_order = true; } if ($matched_order && !isset($customer_transactions[$customer_number][$order_id])) { $customer_transactions[$customer_number][$order_id] = $this->buildTransactionObject( $order_id, $created_at, $department_id, null, $this->isOrderEligible($order) ); } } return [ 'groups' => $groups, 'customer_transactions' => $customer_transactions, 'customer_department_month_map' => $customer_department_month_map, 'warnings' => $warnings, ]; } protected function buildDateRange(string $date_from, string $date_to): array { $from = date('Y-m-d 00:00:00', strtotime($date_from)); $to = date('Y-m-d 23:59:59', strtotime($date_to)); return [$from, $to]; } protected function fetchOrdersInRange(string $from_ts, string $to_ts): array { $cache_key = $from_ts . '|' . $to_ts; if (isset($this->orders_in_range_cache[$cache_key])) { return $this->orders_in_range_cache[$cache_key]; } global $db; $from = $db->escape_string($from_ts); $to = $db->escape_string($to_ts); $sql = "SELECT id, customer_id, department_id, created_at, include_in_invoice, reg_1, reference FROM orders WHERE deleted_at IS NULL AND created_at >= '$from' AND created_at <= '$to'"; $result = $db->query($sql); if (!$result) { $this->orders_in_range_cache[$cache_key] = []; return $this->orders_in_range_cache[$cache_key]; } $this->orders_in_range_cache[$cache_key] = $db->fetch_all($result); return $this->orders_in_range_cache[$cache_key]; } protected function fetchOrderItemsByOrderIds(array $order_ids): array { global $db; $order_ids = array_values(array_unique(array_filter(array_map('intval', $order_ids), static fn($id) => $id > 0))); sort($order_ids); if (empty($order_ids)) { return []; } $cache_key = implode(',', $order_ids); if (isset($this->order_items_by_order_ids_cache[$cache_key])) { return $this->order_items_by_order_ids_cache[$cache_key]; } $sql = "SELECT order_id, product_id, price, quantity, reference FROM order_items WHERE deleted_at IS NULL AND order_id IN (" . implode(',', $order_ids) . ")"; $result = $db->query($sql); if (!$result) { $this->order_items_by_order_ids_cache[$cache_key] = []; return $this->order_items_by_order_ids_cache[$cache_key]; } $rows = $db->fetch_all($result); $grouped = []; foreach ($rows as $row) { $order_id = (int)$row['order_id']; if (!isset($grouped[$order_id])) { $grouped[$order_id] = []; } $grouped[$order_id][] = $row; } $this->order_items_by_order_ids_cache[$cache_key] = $grouped; return $this->order_items_by_order_ids_cache[$cache_key]; } protected function fetchVehicleSubscriptionVersionRows(string $from_ts, string $to_ts): array { $cache_key = $from_ts . '|' . $to_ts; if (isset($this->vehicle_subscription_versions_in_range_cache[$cache_key])) { return $this->vehicle_subscription_versions_in_range_cache[$cache_key]; } global $db; $from = $db->escape_string($from_ts); $to = $db->escape_string($to_ts); $sql = "SELECT * FROM customer_vehicle_subscription_versions WHERE wash_subscription = 1 AND effective_from <= '$to' AND (effective_to IS NULL OR effective_to >= '$from')"; $result = $db->query($sql); if (!$result) { $this->vehicle_subscription_versions_in_range_cache[$cache_key] = []; return $this->vehicle_subscription_versions_in_range_cache[$cache_key]; } $this->vehicle_subscription_versions_in_range_cache[$cache_key] = $db->fetch_all($result); return $this->vehicle_subscription_versions_in_range_cache[$cache_key]; } private function versionOverlaps(array $version_row, string $from_ts, string $to_ts): bool { $version_from = (string)$version_row['effective_from']; $version_to = $version_row['effective_to'] !== null ? (string)$version_row['effective_to'] : null; if ($version_from > $to_ts) { return false; } if ($version_to !== null && $version_to < $from_ts) { return false; } return true; } protected function buildWashSubscriptionCandidates(array $order, array $order_items, bool $system_order_fallback = false): array { if (!$system_order_fallback) { $reg = trim((string)($order['reg_1'] ?? '')); if ($reg === '') { return []; } return [[ 'reg' => $reg, 'vehicle_type' => 0, ]]; } $candidates = []; foreach ($order_items as $item) { $reg = trim((string)($item['reference'] ?? '')); if ($reg === '') { continue; } $vehicle_type = (int)($item['product_id'] ?? 0); $candidate_key = strtoupper($reg) . '|' . $vehicle_type; if (isset($candidates[$candidate_key])) { continue; } $candidates[$candidate_key] = [ 'reg' => $reg, 'vehicle_type' => $vehicle_type, ]; } return array_values($candidates); } protected function findMatchingSubscriptionVersion(array $active_subscriptions, string $reg, int $vehicle_type = 0): ?array { $fallback_match = null; foreach ($active_subscriptions as $candidate) { if (strcasecmp((string)$candidate['reg'], $reg) !== 0) { continue; } if ($vehicle_type > 0 && (int)($candidate['vehicle_type'] ?? 0) === $vehicle_type) { return $candidate; } if ($fallback_match === null) { $fallback_match = $candidate; } } return $fallback_match; } protected function isSystemOrderCandidate(array $order, string $reference): bool { return (int)($order['department_id'] ?? 0) === self::SYSTEM_ORDER_DEPARTMENT_ID && strcasecmp(trim((string)($order['reference'] ?? '')), $reference) === 0; } private function buildSubscriptionFallbackDistribution( int $customer_number, string $month_key, float $monthly_price, array $customer_department_month_map ): array { $distribution = []; $department_counts = $customer_department_month_map[$customer_number][$month_key] ?? []; if (!empty($department_counts)) { $department_counts = $this->normalizeFallbackDepartmentCounts($department_counts); } if (!empty($department_counts)) { $total = (float)array_sum($department_counts); foreach ($department_counts as $department_id => $count) { $distribution[(int)$department_id] = $monthly_price * ((float)$count / max($total, 1.0)); } return $distribution; } $customer_default_department_id = $this->getCustomerDefaultDepartmentId($customer_number); $default_department = $customer_default_department_id !== null && $customer_default_department_id > 0 ? $customer_default_department_id : $this->getFallbackDistributionDepartmentId(); $distribution[$default_department] = $monthly_price; return $distribution; } /** * @param array $department_counts * @return array */ private function normalizeFallbackDepartmentCounts(array $department_counts): array { $normalized = []; foreach ($department_counts as $department_id => $count) { $department_id = (int)$department_id; if ($department_id === self::SYSTEM_ORDER_DEPARTMENT_ID || $department_id <= 0) { $department_id = $this->getFallbackDistributionDepartmentId(); } if (!isset($normalized[$department_id])) { $normalized[$department_id] = 0; } $normalized[$department_id] += $count; } return $normalized; } protected function getCustomerDefaultDepartmentId(int $customer_number): ?int { try { $default = (new users_o())->getUserByCustomerNumber($customer_number)->getDefaultDepartment(); return !empty($default) ? (int)$default : null; } catch (Exception $e) { return null; } } protected function getFallbackDistributionDepartmentId(): int { try { $department_id = (new economic())->getDefaultDistributionDepartmentId(); } catch (\Throwable $e) { $department_id = economic::DEFAULT_DISTRIBUTION_DEPARTMENT_ID; } if ($department_id <= 0 || $department_id === self::SYSTEM_ORDER_DEPARTMENT_ID) { return economic::DEFAULT_DISTRIBUTION_DEPARTMENT_ID; } return $department_id; } private function normalizeSubscriptionGroupAllocation(array $distribution, float $monthly_price): array { if (empty($distribution)) { return []; } $has_fractional = false; foreach ($distribution as $v) { if (abs((float)$v - round((float)$v)) > 0.00001) { $has_fractional = true; break; } } if (!$has_fractional) { $departments = array_keys($distribution); $count = count($departments); if ($count === 0) { return []; } $per_department = $monthly_price / $count; $out = []; foreach ($departments as $department_id) { $out[(int)$department_id] = $per_department; } return $out; } return array_map(static fn($amount) => (float)$amount, $distribution); } protected function calculateOrderOriginalPrice(array $order_items, int $customer_number, int $department_id, string $timestamp): float { $total = 0.0; foreach ($order_items as $item) { $product_id = (int)($item['product_id'] ?? 0); $quantity = (float)($item['quantity'] ?? 0); if ($product_id <= 0 || $quantity <= 0) { continue; } $explicit_price = (float)($item['price'] ?? 0); if ($explicit_price > 0) { $line_price = $explicit_price * $quantity; } else { $line_price = ((float)$this->getProductDepartmentPrice($product_id, $department_id)) * $quantity; } $discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp); $discount_percentage = (float)($discount_row['discount'] ?? 0); if ($discount_percentage > 0) { $line_price *= (1 - ($discount_percentage / 100)); } $total += $line_price; } return $total; } protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp): ?array { $cache_key = $customer_number . '|' . $product_id . '|' . substr($timestamp, 0, 19); if (array_key_exists($cache_key, $this->discount_resolution_cache)) { return $this->discount_resolution_cache[$cache_key]; } $direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp); if ($direct !== null && (int)($direct['discount'] ?? 0) > 0) { return $this->discount_resolution_cache[$cache_key] = $direct; } $product = $this->getProduct($product_id); if ($product !== null) { $category = (string)$product->category->value(); if ($category !== '') { $category_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, $category, $timestamp); if ($category_discount !== null && (int)($category_discount['discount'] ?? 0) > 0) { return $this->discount_resolution_cache[$cache_key] = $category_discount; } } } return $this->discount_resolution_cache[$cache_key] = null; } protected function getProductDepartmentPrice(int $product_id, int $department_id): float { if (!isset($this->product_department_price_cache[$department_id][$product_id])) { $product = $this->getProduct($product_id); if ($product === null) { $this->product_department_price_cache[$department_id][$product_id] = 0.0; } else { $this->product_department_price_cache[$department_id][$product_id] = (float)$product->getDepartmentPrice($department_id); } } return (float)$this->product_department_price_cache[$department_id][$product_id]; } protected function getSubscriptionMonthlyPrice(int $vehicle_type): float { $product = $this->getProduct($vehicle_type); if ($product === null) { return 0.0; } return (float)$product->getSubscriptionMonthlyPrice(); } private function getEconomicClient(): economic { if ($this->economic === null) { $this->economic = new economic(); } return $this->economic; } protected function toArray(mixed $value): array { if (is_array($value)) { return $value; } if (!is_object($value)) { return []; } return json_decode(json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), true) ?: []; } private function getProduct(int $product_id): ?products_o { if (!isset($this->product_cache[$product_id])) { $product = new products_o(); $product->select($product_id); if (!$product->exists()) { $this->product_cache[$product_id] = null; } else { $this->product_cache[$product_id] = $product; } } return $this->product_cache[$product_id]; } protected function buildCustomerEnvelope(int $customer_number, array $transaction_map): array { return [ 'id' => (new users_o())->getUserByCustomerNumber($customer_number)->id, 'customer_number' => $customer_number, 'customer_name' => $this->getCustomerName($customer_number), 'transactions' => array_values($transaction_map), 'requires_action' => false, 'meta' => [], ]; } protected function buildTransactionObject(int $order_id, string $created_at, int $department_id, ?float $amount = null, ?bool $included = null): array { $order = (new orders_o())->select($order_id); return [ 'id' => $order_id, 'date' => $created_at, 'amount' => round((float)($amount ?? (float)$order->getNetAmount()), 5), 'booked' => $order->isBooked(true), 'department_id' => $department_id, 'excluded' => !($included ?? $this->isDepartmentEligible($department_id)), ]; } protected function isOrderEligible(array $order): bool { $department_id = (int)($order['department_id'] ?? 0); if ($department_id === self::SYSTEM_ORDER_DEPARTMENT_ID || $department_id <= 0) { return false; } $override = orders_o::normalizeNullableBooleanValue($order['include_in_invoice'] ?? null); if ($override !== null) { return $override; } return $this->isDepartmentEligible($department_id); } private function getCustomerName(int $customer_number): string { if (!isset($this->customer_name_cache[$customer_number])) { $this->customer_name_cache[$customer_number] = (new users_o())->getCustomerName($customer_number) ?? 'Unknown Customer'; } return (string)$this->customer_name_cache[$customer_number]; } protected function shouldIncludeCustomerNumber(int $customer_number): bool { if ($customer_number <= 0) { return false; } if (array_key_exists($customer_number, $this->customer_inclusion_cache)) { return (bool)$this->customer_inclusion_cache[$customer_number]; } $rows = (new users_o())->getFieldsWhere(['customer_number' => $customer_number], ['id']); if (empty($rows)) { $this->customer_inclusion_cache[$customer_number] = false; return false; } try { $this->getCustomerName($customer_number); } catch (\Throwable $e) { $this->customer_inclusion_cache[$customer_number] = false; return false; } $this->customer_inclusion_cache[$customer_number] = true; return true; } protected function isDepartmentEligible(int $department_id): bool { if ($department_id === self::SYSTEM_ORDER_DEPARTMENT_ID || $department_id <= 0) { return false; } if (!array_key_exists($department_id, $this->department_excluded_cache)) { try { $this->department_excluded_cache[$department_id] = (new departments_o())->select($department_id)->isExcludedFromInvoicing(); } catch (Exception $e) { $this->department_excluded_cache[$department_id] = false; } } return !$this->department_excluded_cache[$department_id]; } protected function parseDepartmentMap(array $department_map): array { $parsed = []; foreach ($department_map as $department_id => $amount) { $parsed[$this->getDepartmentName((int)$department_id)] = round((float)$amount, 5); } return $parsed; } private function getDepartmentName(int $department_id): string { if (!isset($this->department_name_cache[$department_id])) { try { $name = (new departments_o())->select($department_id)->name->value(); $this->department_name_cache[$department_id] = !empty($name) ? (string)$name : 'Unknown Department (' . $department_id . ')'; } catch (Exception $e) { $this->department_name_cache[$department_id] = 'Unknown Department (' . $department_id . ')'; } } return (string)$this->department_name_cache[$department_id]; } private function roundMap(array $map): array { $out = []; foreach ($map as $key => $value) { $out[(string)$key] = round((float)$value, 5); } return $out; } private function listMonthKeys(string $from_ts, string $to_ts): array { $start = new DateTime(date('Y-m-01 00:00:00', strtotime($from_ts))); $end = new DateTime(date('Y-m-01 00:00:00', strtotime($to_ts))); $end->modify('+1 month'); $period = new DatePeriod($start, new DateInterval('P1M'), $end); $months = []; foreach ($period as $dt) { $months[] = $dt->format('Y-m'); } return $months; } }