From 6aa04eb95100f79b25637862760ba0311e53bd29 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 16 Mar 2026 13:40:03 +0100 Subject: [PATCH] Add logic for booked department 75 redistribution with fixed pricing and subscription weights. Update OpenAPI spec, unit tests, and service integration. --- openapi.yaml | 130 +++- .../economic_v2_distribution_service.php | 595 +++++++++++++++++- .../nginx/app/routes/InvoicingPeriodRoute.php | 18 + ...icV2BookedDepartment75DistributionTest.php | 374 +++++++++++ .../Invoicing/EconomicV2OpenApiSpecTest.php | 2 + .../EconomicV2RouteAndVersioningHooksTest.php | 1 + 6 files changed, 1116 insertions(+), 4 deletions(-) create mode 100644 services/nginx/app/tests/Unit/Invoicing/EconomicV2BookedDepartment75DistributionTest.php diff --git a/openapi.yaml b/openapi.yaml index 49772f82..43b8637c 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -5721,6 +5721,33 @@ paths: '403': { $ref: '#/components/responses/Forbidden' } '500': { $ref: '#/components/responses/InternalServerError' } + /superuser/invoicing/period/distribution/v2/booked-department-75: + get: + tags: + - Invoices + summary: Get booked e-conomic department 75 redistribution + operationId: getInvoicingPeriodDistributionV2BookedDepartment75 + parameters: + - name: dateFrom + in: query + required: true + schema: { type: string, format: date } + - name: dateTo + in: query + required: true + schema: { type: string, format: date } + responses: + '200': + description: Actual booked e-conomic department 75 net amounts redistributed to internal departments + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Response' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + /superuser/customers/pricing-history: get: tags: @@ -11207,6 +11234,105 @@ components: InvoicingDistributionV2CustomerPricesResponse: $ref: '#/components/schemas/InvoicingDistributionV2CategoryResponse' + InvoicingDistributionV2BookedDepartment75Group: + type: object + properties: + month: + type: string + example: '2026-01' + source_category: + type: string + enum: [fixed_pricing, wash_subscriptions, unclassified] + invoice_ids: + type: array + items: + type: integer + booked_net_amount: + type: number + department_distribution: + $ref: '#/components/schemas/EconomicV2DepartmentDistribution' + undistributed_net_amount: + type: number + required: + - month + - source_category + - invoice_ids + - booked_net_amount + - department_distribution + - undistributed_net_amount + + InvoicingDistributionV2BookedDepartment75Meta: + type: object + properties: + booked_net_amount: + type: number + distributed_net_amount: + type: number + undistributed_net_amount: + type: number + department_distribution: + $ref: '#/components/schemas/EconomicV2DepartmentDistribution' + booked_groups: + type: array + items: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Group' + required: + - booked_net_amount + - distributed_net_amount + - undistributed_net_amount + - department_distribution + - booked_groups + + InvoicingDistributionV2BookedDepartment75Customer: + allOf: + - $ref: '#/components/schemas/InvoicingDistributionV2Customer' + - type: object + properties: + meta: + type: object + properties: + booked_department_75: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Meta' + required: + - booked_department_75 + + InvoicingDistributionV2BookedDepartment75CollectiveResults: + type: object + properties: + booked_net_amount: + type: number + distributed_net_amount: + type: number + undistributed_net_amount: + type: number + department_distribution: + $ref: '#/components/schemas/EconomicV2DepartmentDistribution' + department_distribution_parsed: + type: object + additionalProperties: + type: number + required: + - booked_net_amount + - distributed_net_amount + - undistributed_net_amount + - department_distribution + - department_distribution_parsed + + InvoicingDistributionV2BookedDepartment75Response: + type: object + properties: + customers: + type: array + items: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Customer' + collective_results: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75CollectiveResults' + warnings: + type: array + items: + type: string + required: [customers, collective_results, warnings] + InvoicingDistributionV2AllResponse: type: object properties: @@ -11216,7 +11342,9 @@ components: $ref: '#/components/schemas/InvoicingDistributionV2WashSubscriptionsResponse' customer_prices: $ref: '#/components/schemas/InvoicingDistributionV2CustomerPricesResponse' - required: [fixed_pricing, wash_subscriptions, customer_prices] + booked_department_75: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Response' + required: [fixed_pricing, wash_subscriptions, customer_prices, booked_department_75] PricingHistoryVersionEntry: type: object diff --git a/services/nginx/app/classes/economic_v2_distribution_service.php b/services/nginx/app/classes/economic_v2_distribution_service.php index bed5ab62..3062e74e 100644 --- a/services/nginx/app/classes/economic_v2_distribution_service.php +++ b/services/nginx/app/classes/economic_v2_distribution_service.php @@ -14,10 +14,15 @@ use objects\users_o; class economic_v2_distribution_service { private const SYSTEM_ORDER_DEPARTMENT_ID = 10; + private const BOOKED_DEPARTMENT_75 = 75; + private const BOOKED_INVOICE_PAGE_SIZE = 1000; + private const BOOKED_INVOICE_MAX_PAGES = 10; + private const EPSILON = 0.00001; private const FIXED_PRICING_SYSTEM_ORDER_REFERENCE = 'Fast pris aftale'; private const WASH_SUBSCRIPTION_SYSTEM_ORDER_REFERENCE = 'Vaskeabonnementer'; private economic_v2_versioning_service $versioning; + private ?economic $economic = null; private array $department_name_cache = []; private array $department_excluded_cache = []; private array $customer_name_cache = []; @@ -26,13 +31,16 @@ class economic_v2_distribution_service private array $discount_resolution_cache = []; private array $orders_in_range_cache = []; private array $order_items_by_order_ids_cache = []; + private array $booked_invoices_cache = []; + private array $booked_invoice_lines_cache = []; private array $vehicle_subscription_versions_in_range_cache = []; private array $version_table_has_rows_cache = []; private bool $best_effort_backfill_attempted = false; - public function __construct(?economic_v2_versioning_service $versioning = null) + public function __construct(?economic_v2_versioning_service $versioning = null, ?economic $economic = null) { $this->versioning = $versioning ?? new economic_v2_versioning_service(); + $this->economic = $economic; } public function getAllDistributions(string $date_from, string $date_to): array @@ -43,13 +51,37 @@ class economic_v2_distribution_service 'discount_overrides', ]); + $fixed_pricing = $this->getFixedPricingDistribution($date_from, $date_to); + $wash_subscriptions = $this->getWashSubscriptionsDistribution($date_from, $date_to); + return [ - 'fixed_pricing' => $this->getFixedPricingDistribution($date_from, $date_to), - 'wash_subscriptions' => $this->getWashSubscriptionsDistribution($date_from, $date_to), + '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']); @@ -401,6 +433,542 @@ class economic_v2_distribution_service ]; } + 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 (Exception $e) { + return [ + 'id' => null, + 'customer_number' => $customer_number, + 'customer_name' => $this->getCustomerName($customer_number), + '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; + } + + $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['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'] ?? 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)) { + return $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; + } + } + + 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) { + $warnings[] = 'Booked department 75 ' + . $source_category + . ' amount for customer ' + . $customer_number + . ' in ' + . $month_key + . ' on invoice(s) ' + . $invoice_ids + . ' has no redistribution basis and remains undistributed.'; + + return [ + 'department_distribution' => [], + 'undistributed_net_amount' => $booked_amount, + ]; + } + + $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) { @@ -919,6 +1487,27 @@ class economic_v2_distribution_service 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])) { diff --git a/services/nginx/app/routes/InvoicingPeriodRoute.php b/services/nginx/app/routes/InvoicingPeriodRoute.php index b94fc892..61ff5bce 100644 --- a/services/nginx/app/routes/InvoicingPeriodRoute.php +++ b/services/nginx/app/routes/InvoicingPeriodRoute.php @@ -340,6 +340,24 @@ class InvoicingPeriodRoute ] ); + $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'); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2BookedDepartment75DistributionTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2BookedDepartment75DistributionTest.php new file mode 100644 index 00000000..300ad19f --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2BookedDepartment75DistributionTest.php @@ -0,0 +1,374 @@ +fixedVersion; + } + + public function resolveVehicleSubscriptionVersionsAt(int $customer_number, string $timestamp): array + { + return $this->subscriptionVersions; + } + + public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp): ?array + { + return null; + } + + public function runBestEffortBackfill(): array + { + return [ + 'fixed_pricing' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0], + 'vehicle_subscriptions' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0], + 'discount_overrides' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0], + 'inferred' => ['fixed_pricing' => 0, 'vehicle_subscriptions' => 0], + 'warnings' => [], + ]; + } + } +} + +if (!class_exists('TestableEconomicV2BookedDepartment75DistributionService')) { + class TestableEconomicV2BookedDepartment75DistributionService extends economic_v2_distribution_service + { + public array $stubOrders = []; + public array $stubOrderItems = []; + public array $stubVersionRows = []; + public array $subscriptionPrices = []; + public array $stubBookedInvoices = []; + public array $stubBookedInvoiceLines = []; + + public function __construct(?economic_v2_versioning_service $versioning = null) + { + parent::__construct($versioning); + } + + protected function fetchOrdersInRange(string $from_ts, string $to_ts): array + { + return $this->stubOrders; + } + + protected function fetchOrderItemsByOrderIds(array $order_ids): array + { + return $this->stubOrderItems; + } + + protected function fetchVehicleSubscriptionVersionRows(string $from_ts, string $to_ts): array + { + return $this->stubVersionRows; + } + + protected function calculateOrderOriginalPrice(array $order_items, int $customer_number, int $department_id, string $timestamp): float + { + $total = 0.0; + foreach ($order_items as $item) { + $total += (float)($item['price'] ?? 0.0) * (float)($item['quantity'] ?? 0.0); + } + + return $total; + } + + protected function getSubscriptionMonthlyPrice(int $vehicle_type): float + { + return (float)($this->subscriptionPrices[$vehicle_type] ?? 0.0); + } + + protected function fetchBookedInvoicesInDateRange(string $date_from, string $date_to, array &$warnings): array + { + return $this->stubBookedInvoices; + } + + protected function fetchBookedInvoiceLines(array $invoice_ids, array &$warnings): array + { + return $this->stubBookedInvoiceLines; + } + + protected function buildCustomerEnvelope(int $customer_number, array $transaction_map): array + { + return [ + 'id' => $customer_number, + 'customer_number' => $customer_number, + 'customer_name' => 'Customer ' . $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): array + { + return [ + 'id' => $order_id, + 'date' => $created_at, + 'amount' => round((float)($amount ?? 0.0), 5), + 'booked' => true, + 'department_id' => $department_id, + 'excluded' => !$this->isDepartmentEligible($department_id), + ]; + } + + protected function isDepartmentEligible(int $department_id): bool + { + return $department_id > 0 && $department_id !== 10; + } + + protected function parseDepartmentMap(array $department_map): array + { + $parsed = []; + foreach ($department_map as $department_id => $amount) { + $parsed['Department ' . $department_id] = round((float)$amount, 5); + } + + return $parsed; + } + + protected function versionTableHasRows(string $table): bool + { + return true; + } + } +} + +it('redistributes booked department 75 net amounts using fixed pricing and wash subscription weights', function (): void { + $versioning = new FakeEconomicV2BookedDepartment75VersioningService(); + $versioning->fixedVersion = [ + 'id' => 91, + 'price' => 500.0, + 'description' => 'Fixed pricing agreement', + 'source' => 'test.fixed', + 'confidence' => 1.0, + 'inferred' => false, + 'effective_from' => '2026-01-01 00:00:00', + 'effective_to' => null, + ]; + $versioning->subscriptionVersions = [[ + 'id' => 42, + 'reg' => 'AB12345', + 'vehicle_type' => 77, + 'source' => 'test.subscription', + 'confidence' => 1.0, + 'inferred' => false, + ]]; + + $service = new TestableEconomicV2BookedDepartment75DistributionService($versioning); + $service->subscriptionPrices = [ + 77 => 120.0, + ]; + $service->stubOrders = [ + [ + 'id' => 101, + 'customer_id' => 12345, + 'department_id' => 1, + 'created_at' => '2026-01-10 10:00:00', + 'reg_1' => '', + 'reference' => 'Internal fixed pricing basis', + ], + [ + 'id' => 102, + 'customer_id' => 12345, + 'department_id' => 2, + 'created_at' => '2026-01-11 10:00:00', + 'reg_1' => 'AB12345', + 'reference' => '', + ], + ]; + $service->stubOrderItems = [ + 101 => [[ + 'product_id' => 61, + 'price' => 400.0, + 'quantity' => 1, + 'reference' => '', + ]], + 102 => [[ + 'product_id' => 77, + 'price' => 60.0, + 'quantity' => 2, + 'reference' => 'AB12345', + ]], + ]; + $service->stubBookedInvoices = [[ + 'bookedInvoiceNumber' => 7001, + 'date' => '2026-01-31', + 'customer' => [ + 'customerNumber' => 12345, + ], + ]]; + $service->stubBookedInvoiceLines = [ + 7001 => [ + ['lineNumber' => 1, 'description' => '[ 01/01/2026 00:00 Auto #999 ]'], + ['lineNumber' => 2, 'description' => 'Reference:'], + ['lineNumber' => 3, 'description' => '# Fast pris aftale'], + [ + 'lineNumber' => 4, + 'description' => 'Fixed price', + 'quantity' => 1, + 'unitNetPrice' => 200, + 'totalNetAmount' => 200, + 'product' => ['productNumber' => 61], + 'departmentalDistribution' => [ + 'distributions' => [ + ['percentage' => 50, 'department' => ['departmentNumber' => 75]], + ['percentage' => 50, 'department' => ['departmentNumber' => 1]], + ], + ], + ], + [ + 'lineNumber' => 5, + 'description' => 'Rabat', + 'quantity' => 1, + 'unitNetPrice' => -40, + 'totalNetAmount' => -40, + 'product' => ['productNumber' => 'TotDiscount'], + 'departmentalDistribution' => [ + 'distributions' => [ + ['percentage' => 50, 'department' => ['departmentNumber' => 75]], + ['percentage' => 50, 'department' => ['departmentNumber' => 1]], + ], + ], + ], + ['lineNumber' => 6, 'description' => '[ 01/01/2026 00:00 Auto #1000 ]'], + ['lineNumber' => 7, 'description' => 'Reference:'], + ['lineNumber' => 8, 'description' => '# Vaskeabonnementer'], + [ + 'lineNumber' => 9, + 'description' => 'Subscription', + 'quantity' => 1, + 'unitNetPrice' => 120, + 'totalNetAmount' => 120, + 'product' => ['productNumber' => 77], + 'departmentalDistribution' => [ + 'distributions' => [ + ['percentage' => 100, 'department' => ['departmentNumber' => 75]], + ], + ], + ], + ], + ]; + + $result = $service->getBookedDepartment75Distribution('2026-01-01', '2026-01-31'); + + expect($result['warnings'])->toBe([]); + expect($result['customers'])->toHaveCount(1); + + $meta = $result['customers'][0]['meta']['booked_department_75']; + expect($meta['booked_net_amount'])->toBe(200.0); + expect($meta['distributed_net_amount'])->toBe(200.0); + expect($meta['undistributed_net_amount'])->toBe(0.0); + expect($meta['department_distribution']['1'])->toBe(61.53846); + expect($meta['department_distribution']['2'])->toBe(138.46154); + + $groups = []; + foreach ($meta['booked_groups'] as $group) { + $groups[$group['source_category']] = $group; + } + + expect($groups['fixed_pricing']['booked_net_amount'])->toBe(80.0); + expect($groups['fixed_pricing']['department_distribution']['1'])->toBe(61.53846); + expect($groups['fixed_pricing']['department_distribution']['2'])->toBe(18.46154); + expect($groups['fixed_pricing']['undistributed_net_amount'])->toBe(0.0); + expect($groups['wash_subscriptions']['booked_net_amount'])->toBe(120.0); + expect($groups['wash_subscriptions']['department_distribution']['2'])->toBe(120.0); + expect($groups['wash_subscriptions']['undistributed_net_amount'])->toBe(0.0); + + expect($result['collective_results']['booked_net_amount'])->toBe(200.0); + expect($result['collective_results']['distributed_net_amount'])->toBe(200.0); + expect($result['collective_results']['undistributed_net_amount'])->toBe(0.0); + expect($result['collective_results']['department_distribution']['1'])->toBe(61.53846); + expect($result['collective_results']['department_distribution']['2'])->toBe(138.46154); +}); + +it('keeps classified booked department 75 amounts undistributed when no monthly basis exists', function (): void { + $service = new TestableEconomicV2BookedDepartment75DistributionService(new FakeEconomicV2BookedDepartment75VersioningService()); + $service->stubBookedInvoices = [[ + 'bookedInvoiceNumber' => 7002, + 'date' => '2026-01-31', + 'customer' => [ + 'customerNumber' => 67890, + ], + ]]; + $service->stubBookedInvoiceLines = [ + 7002 => [ + ['lineNumber' => 1, 'description' => '[ 01/01/2026 00:00 Auto #1001 ]'], + ['lineNumber' => 2, 'description' => 'Reference:'], + ['lineNumber' => 3, 'description' => '# Fast pris aftale'], + [ + 'lineNumber' => 4, + 'description' => 'Fixed price', + 'quantity' => 1, + 'unitNetPrice' => 60, + 'totalNetAmount' => 60, + 'product' => ['productNumber' => 61], + 'departmentalDistribution' => [ + 'distributions' => [ + ['percentage' => 100, 'department' => ['departmentNumber' => 75]], + ], + ], + ], + ], + ]; + + $result = $service->getBookedDepartment75Distribution('2026-01-01', '2026-01-31'); + + expect($result['customers'])->toHaveCount(1); + expect($result['customers'][0]['meta']['booked_department_75']['booked_groups'][0]['source_category'])->toBe('fixed_pricing'); + expect($result['customers'][0]['meta']['booked_department_75']['distributed_net_amount'])->toBe(0.0); + expect($result['customers'][0]['meta']['booked_department_75']['undistributed_net_amount'])->toBe(60.0); + expect($result['collective_results']['undistributed_net_amount'])->toBe(60.0); + expect(implode("\n", $result['warnings']))->toContain('has no redistribution basis'); +}); + +it('keeps unclassified booked department 75 lines undistributed with warnings', function (): void { + $service = new TestableEconomicV2BookedDepartment75DistributionService(new FakeEconomicV2BookedDepartment75VersioningService()); + $service->stubBookedInvoices = [[ + 'bookedInvoiceNumber' => 7003, + 'date' => '2026-01-31', + 'customer' => [ + 'customerNumber' => 77777, + ], + ]]; + $service->stubBookedInvoiceLines = [ + 7003 => [ + ['lineNumber' => 1, 'description' => '[ 01/01/2026 00:00 Auto #1002 ]'], + [ + 'lineNumber' => 2, + 'description' => 'Unknown booked line', + 'quantity' => 1, + 'unitNetPrice' => 25, + 'totalNetAmount' => 25, + 'product' => ['productNumber' => 99], + 'departmentalDistribution' => [ + 'distributions' => [ + ['percentage' => 100, 'department' => ['departmentNumber' => 75]], + ], + ], + ], + ], + ]; + + $result = $service->getBookedDepartment75Distribution('2026-01-01', '2026-01-31'); + + expect($result['customers'])->toHaveCount(1); + expect($result['customers'][0]['meta']['booked_department_75']['booked_groups'][0]['source_category'])->toBe('unclassified'); + expect($result['customers'][0]['meta']['booked_department_75']['distributed_net_amount'])->toBe(0.0); + expect($result['customers'][0]['meta']['booked_department_75']['undistributed_net_amount'])->toBe(25.0); + expect($result['collective_results']['undistributed_net_amount'])->toBe(25.0); + $warning_text = implode("\n", $result['warnings']); + expect($warning_text)->toContain('Unable to classify booked department 75 line'); + expect($warning_text)->toContain('could not be classified and remains undistributed'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2OpenApiSpecTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2OpenApiSpecTest.php index 6c291ec6..7cf8c692 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicV2OpenApiSpecTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2OpenApiSpecTest.php @@ -38,6 +38,7 @@ it('documents v2 historical distribution and pricing history paths in openapi', expect($content)->toContain('/superuser/invoicing/period/distribution/v2/fixed-pricing:'); expect($content)->toContain('/superuser/invoicing/period/distribution/v2/wash-subscriptions:'); expect($content)->toContain('/superuser/invoicing/period/distribution/v2/customer-prices:'); + expect($content)->toContain('/superuser/invoicing/period/distribution/v2/booked-department-75:'); expect($content)->toContain('/superuser/customers/pricing-history:'); }); @@ -67,4 +68,5 @@ it('defines new reusable v2 schemas for normalization comparison versioning and expect($content)->toContain('EconomicV2RevenueSummary:'); expect($content)->toContain('PricingHistoryVersionEntry:'); expect($content)->toContain('InvoicingDistributionV2AllResponse:'); + expect($content)->toContain('InvoicingDistributionV2BookedDepartment75Response:'); }); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2RouteAndVersioningHooksTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2RouteAndVersioningHooksTest.php index 3a145c4e..edd28917 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicV2RouteAndVersioningHooksTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2RouteAndVersioningHooksTest.php @@ -25,6 +25,7 @@ it('registers version-aware distribution and pricing history v2 routes', functio expect($content)->toContain('/superuser/invoicing/period/distribution/v2/fixed-pricing'); expect($content)->toContain('/superuser/invoicing/period/distribution/v2/wash-subscriptions'); expect($content)->toContain('/superuser/invoicing/period/distribution/v2/customer-prices'); + expect($content)->toContain('/superuser/invoicing/period/distribution/v2/booked-department-75'); expect($content)->toContain('/superuser/customers/pricing-history'); expect($content)->toContain("requirePermission('superuser_invoicing_period_distribution_v2')"); expect($content)->toContain("requirePermission('superuser_customer_pricing_history_v2')");