diff --git a/openapi.yaml b/openapi.yaml index 7a9af4ac..47df67e5 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2202,6 +2202,13 @@ paths: - $ref: '#/components/parameters/PageParam' - $ref: '#/components/parameters/PerPageParam' - $ref: '#/components/parameters/SearchParam' + - name: barred + in: query + required: false + description: Optional e-conomic barred customer filter. + schema: + type: string + enum: ['true', 'false', 'barred', 'active', '1', '0'] responses: '200': description: Success @@ -5125,6 +5132,215 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /collected-invoices/economic/v2/details: + get: + tags: + - Invoices + summary: Get deep V2 e-conomic invoice details + description: | + Returns normalized internal lines and best-effort fetched draft/booked e-conomic lines + for a collected invoice, including department distributions and warnings. + operationId: getCollectedInvoiceEconomicV2Details + parameters: + - name: collected_invoice_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Details resolved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2DetailsResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/v2/compare: + get: + tags: + - Invoices + summary: Compare internal invoice with draft/booked (V2) + operationId: compareCollectedInvoiceEconomicV2 + parameters: + - name: collected_invoice_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Comparison completed + content: + application/json: + schema: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2CompareResponse' + examples: + exactMatch: + summary: Exact match between internal and draft/booked + value: + collected_invoice_id: 123 + warnings: [] + comparison: + totals: + internal_net_total: 694 + targets: + draft: + target: draft + status: exact_match + overall_match: true + booked: + target: booked + status: exact_match + overall_match: true + partialMismatch: + summary: Partial mismatch with line and department differences + value: + collected_invoice_id: 123 + warnings: + - Non-billable line count differs + comparison: + totals: + internal_net_total: 694 + targets: + draft: + target: draft + status: partial_mismatch + overall_match: false + mismatch_reasons: + - quantity_mismatch + - department_total_mismatch + missingBooked: + summary: Missing booked target + value: + collected_invoice_id: 123 + comparison: + totals: + internal_net_total: 694 + targets: + booked: + target: booked + status: missing_target + overall_match: false + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/v2/compare/bulk: + post: + tags: + - Invoices + summary: Bulk compare collected invoices against draft/booked (V2) + operationId: compareCollectedInvoiceEconomicV2Bulk + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [collected_invoice_ids] + properties: + collected_invoice_ids: + type: array + minItems: 1 + maxItems: 200 + items: + type: integer + minimum: 1 + responses: + '200': + description: Bulk comparison completed + content: + application/json: + schema: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2CompareBulkResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/v2/revenue-statistics: + get: + tags: + - Invoices + summary: Get overall booked revenue statistics from e-conomic (V2) + description: | + Aggregates booked e-conomic revenue across invoices and lines, with optional filters + for date range, customer(s), department(s), currency, and barred-customer status. + operationId: getCollectedInvoiceEconomicV2RevenueStatistics + parameters: + - name: dateFrom + in: query + required: false + description: Start date (inclusive), defaults to first day of current month. + schema: + type: string + format: date + - name: dateTo + in: query + required: false + description: End date (inclusive), defaults to today. + schema: + type: string + format: date + - name: customer_numbers + in: query + required: false + description: Comma-separated customer numbers to include. + schema: + type: string + example: "42493959,42493960" + - name: department_numbers + in: query + required: false + description: Comma-separated department numbers to include. + schema: + type: string + example: "75,10" + - name: currency + in: query + required: false + description: Restrict to a specific invoice currency. + schema: + type: string + example: "DKK" + - name: barred + in: query + required: false + description: Filter by e-conomic customer barred status. + schema: + type: string + enum: [all, barred, active] + default: all + - name: max_pages + in: query + required: false + description: Safety cap for paginated e-conomic reads. + schema: + type: integer + minimum: 1 + maximum: 200 + default: 10 + responses: + '200': + description: Revenue statistics resolved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2RevenueStatisticsResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + /superuser/invoicing/period: get: tags: @@ -5195,6 +5411,147 @@ paths: application/json: schema: {} + /superuser/invoicing/period/distribution/v2/all: + get: + tags: + - Invoices + summary: Get version-aware historical distribution (all) + operationId: getInvoicingPeriodDistributionV2All + 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: Version-aware historical distribution (all categories) + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingDistributionV2AllResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/invoicing/period/distribution/v2/fixed-pricing: + get: + tags: + - Invoices + summary: Get version-aware historical fixed pricing distribution + operationId: getInvoicingPeriodDistributionV2FixedPricing + 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: Version-aware fixed pricing distribution + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingDistributionV2FixedPricingResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/invoicing/period/distribution/v2/wash-subscriptions: + get: + tags: + - Invoices + summary: Get version-aware historical wash subscription distribution + operationId: getInvoicingPeriodDistributionV2WashSubscriptions + 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: Version-aware wash subscription distribution + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingDistributionV2WashSubscriptionsResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/invoicing/period/distribution/v2/customer-prices: + get: + tags: + - Invoices + summary: Get version-aware historical customer-price discount distribution + operationId: getInvoicingPeriodDistributionV2CustomerPrices + 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: Version-aware customer-price discount distribution + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingDistributionV2CustomerPricesResponse' + '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: + - Invoices + summary: Get customer versioned pricing/subscription/discount timeline + operationId: getCustomerPricingHistoryV2 + parameters: + - name: customer_number + in: query + required: true + schema: + type: integer + minimum: 1 + - 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: Customer timeline resolved + content: + application/json: + schema: + $ref: '#/components/schemas/CustomerPricingHistoryResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + # Vehicles Endpoints /vehicles: get: @@ -9539,23 +9896,690 @@ components: type: number format: float nullable: true - description: draft_total minus booked_total when both are available + description: Selected e-conomic total (draft when available, otherwise booked) minus internal_total example: 0 internal_total: type: number format: float description: Internal total amount for the collected invoice example: 694 - order_ids: - type: array - description: List of order IDs included in the collected invoice - items: - type: integer - example: [38679, 39210] required: - collected_invoice_id - internal_total + + CollectedInvoiceEconomicV2DetailsResponse: + type: object + properties: + collected_invoice_id: + type: integer + external_id: + type: string + order_ids: + type: array + items: + type: integer + economic: + type: object + properties: + draft_id: + type: integer + nullable: true + booked_id: + type: integer + nullable: true + customer: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2CustomerSummary' + internal: + type: object + required: [normalized] + properties: + normalized: + $ref: '#/components/schemas/EconomicV2NormalizedInvoice' + draft: + type: object + properties: + exists: + type: boolean + raw: + type: object + nullable: true + additionalProperties: true + normalized: + allOf: + - $ref: '#/components/schemas/EconomicV2NormalizedInvoice' + nullable: true + booked: + type: object + properties: + exists: + type: boolean + raw: + type: object + nullable: true + additionalProperties: true + normalized: + allOf: + - $ref: '#/components/schemas/EconomicV2NormalizedInvoice' + nullable: true + warnings: + type: array + items: + type: string + required: + - collected_invoice_id - order_ids + - economic + - customer + - internal + - draft + - booked + - warnings + + CollectedInvoiceEconomicV2CustomerSummary: + type: object + properties: + internal_customer_number: + type: integer + nullable: true + draft_customer_number: + type: integer + nullable: true + booked_customer_number: + type: integer + nullable: true + exists: + type: boolean + name: + type: string + nullable: true + barred: + type: boolean + nullable: true + required: + - exists + + CollectedInvoiceEconomicV2CompareResponse: + type: object + properties: + collected_invoice_id: + type: integer + details: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2DetailsResponse' + comparison: + $ref: '#/components/schemas/EconomicV2Comparison' + warnings: + type: array + items: + type: string + required: + - collected_invoice_id + - details + - comparison + - warnings + + CollectedInvoiceEconomicV2CompareBulkResponse: + type: object + properties: + requested: + type: integer + compared: + type: integer + failed: + type: integer + results: + type: array + items: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2CompareResponse' + errors: + type: array + items: + type: object + properties: + collected_invoice_id: + type: integer + error: + type: string + required: + - requested + - compared + - failed + - results + - errors + + CollectedInvoiceEconomicV2RevenueStatisticsResponse: + type: object + properties: + filters: + type: object + properties: + dateFrom: + type: string + format: date + dateTo: + type: string + format: date + customer_numbers: + type: array + items: + type: integer + department_numbers: + type: array + items: + type: integer + currency: + type: string + nullable: true + barred: + type: string + enum: [all, barred, active] + max_pages: + type: integer + summary: + $ref: '#/components/schemas/EconomicV2RevenueSummary' + customers: + type: array + items: + $ref: '#/components/schemas/EconomicV2RevenueCustomerStat' + departments: + type: array + items: + $ref: '#/components/schemas/EconomicV2RevenueDepartmentStat' + currencies: + type: array + items: + $ref: '#/components/schemas/EconomicV2RevenueCurrencyStat' + warnings: + type: array + items: + type: string + required: + - filters + - summary + - customers + - departments + - currencies + - warnings + + EconomicV2RevenueSummary: + type: object + properties: + invoice_count: + type: integer + line_count: + type: integer + unique_customers: + type: integer + net_amount: + type: number + vat_amount: + type: number + gross_amount: + type: number + average_invoice_net_amount: + type: number + required: + - invoice_count + - line_count + - unique_customers + - net_amount + - vat_amount + - gross_amount + - average_invoice_net_amount + + EconomicV2RevenueCustomerStat: + type: object + properties: + customer_number: + type: integer + customer_name: + type: string + nullable: true + barred: + type: boolean + nullable: true + invoice_count: + type: integer + net_amount: + type: number + vat_amount: + type: number + gross_amount: + type: number + required: + - customer_number + - invoice_count + - net_amount + - vat_amount + - gross_amount + + EconomicV2RevenueDepartmentStat: + type: object + properties: + department_key: + type: string + department_number: + type: integer + nullable: true + invoice_count: + type: integer + line_count: + type: integer + net_amount: + type: number + vat_amount: + type: number + gross_amount: + type: number + required: + - department_key + - invoice_count + - line_count + - net_amount + - vat_amount + - gross_amount + + EconomicV2RevenueCurrencyStat: + type: object + properties: + currency: + type: string + invoice_count: + type: integer + net_amount: + type: number + vat_amount: + type: number + gross_amount: + type: number + required: + - currency + - invoice_count + - net_amount + - vat_amount + - gross_amount + + EconomicV2NormalizedInvoice: + type: object + properties: + source: + type: string + enum: [internal, draft, booked] + totals: + type: object + properties: + net_total: + type: number + line_net_total: + type: number + line_count: + type: integer + billable_line_count: + type: integer + difference_from_line_sum: + type: number + nullable: true + departments: + $ref: '#/components/schemas/EconomicV2DepartmentDistribution' + lines: + type: array + items: + $ref: '#/components/schemas/EconomicV2NormalizedLineItem' + warnings: + type: array + items: + type: string + required: + - source + - totals + - departments + - lines + - warnings + + EconomicV2NormalizedLineItem: + type: object + properties: + index: + type: integer + source: + type: string + source_order_id: + type: integer + nullable: true + source_line_id: + type: integer + nullable: true + line_type: + type: string + enum: [product, discount, text] + billable: + type: boolean + product_number: + type: string + nullable: true + product_id: + type: integer + nullable: true + description: + type: string + reference: + type: string + quantity: + type: number + unit_net_price: + type: number + line_net_amount: + type: number + department_distribution: + $ref: '#/components/schemas/EconomicV2DepartmentDistribution' + match_key: + type: string + required: + - source + - line_type + - billable + - description + - reference + - quantity + - unit_net_price + - line_net_amount + - department_distribution + - match_key + + EconomicV2DepartmentDistribution: + type: object + additionalProperties: + type: number + example: + "75": 100 + + EconomicV2Comparison: + type: object + properties: + totals: + type: object + properties: + internal_net_total: + type: number + targets: + type: object + properties: + draft: + $ref: '#/components/schemas/EconomicV2TargetComparison' + booked: + $ref: '#/components/schemas/EconomicV2TargetComparison' + warnings: + type: array + items: + type: string + required: + - totals + - targets + - warnings + + EconomicV2TargetComparison: + type: object + properties: + target: + type: string + enum: [draft, booked] + status: + type: string + enum: [exact_match, partial_mismatch, total_mismatch, missing_target] + overall_match: + type: boolean + totals: + $ref: '#/components/schemas/EconomicV2TotalsComparison' + lines: + type: object + properties: + summary: + type: object + properties: + internal_billable_count: + type: integer + target_billable_count: + type: integer + mismatch_count: + type: integer + diff: + type: array + items: + $ref: '#/components/schemas/EconomicV2LineDiffEntry' + departments: + type: object + properties: + matches: + type: boolean + diff: + type: array + items: + $ref: '#/components/schemas/EconomicV2DepartmentDiffEntry' + mismatch_reasons: + type: array + items: + type: string + warnings: + type: array + items: + type: string + required: + - target + - status + - overall_match + - totals + - lines + - departments + - mismatch_reasons + - warnings + + EconomicV2TotalsComparison: + type: object + properties: + internal_net_total: + type: number + nullable: true + target_net_total: + type: number + nullable: true + difference: + type: number + nullable: true + abs_difference: + type: number + nullable: true + matches: + type: boolean + required: + - matches + + EconomicV2LineDiffEntry: + type: object + properties: + match_key: + type: string + reasons: + type: array + items: + type: string + internal_line: + allOf: + - $ref: '#/components/schemas/EconomicV2NormalizedLineItem' + nullable: true + target_line: + allOf: + - $ref: '#/components/schemas/EconomicV2NormalizedLineItem' + nullable: true + required: + - match_key + - reasons + + EconomicV2DepartmentDiffEntry: + type: object + properties: + department_key: + type: string + internal_amount: + type: number + target_amount: + type: number + difference: + type: number + matches: + type: boolean + required: + - department_key + - internal_amount + - target_amount + - difference + - matches + + InvoicingDistributionV2Transaction: + type: object + properties: + id: + type: integer + date: + type: string + format: date-time + amount: + type: number + booked: + type: boolean + department_id: + type: integer + excluded: + type: boolean + required: [id, date, amount, booked, department_id, excluded] + + InvoicingDistributionV2Customer: + type: object + properties: + id: + type: integer + nullable: true + customer_number: + type: integer + customer_name: + type: string + transactions: + type: array + items: + $ref: '#/components/schemas/InvoicingDistributionV2Transaction' + requires_action: + type: boolean + meta: + type: object + additionalProperties: true + required: [customer_number, customer_name, transactions, requires_action, meta] + + InvoicingDistributionV2CategoryResponse: + type: object + properties: + customers: + type: array + items: + $ref: '#/components/schemas/InvoicingDistributionV2Customer' + collective_results: + type: object + additionalProperties: true + warnings: + type: array + items: + type: string + required: [customers, collective_results, warnings] + + InvoicingDistributionV2FixedPricingResponse: + $ref: '#/components/schemas/InvoicingDistributionV2CategoryResponse' + + InvoicingDistributionV2WashSubscriptionsResponse: + $ref: '#/components/schemas/InvoicingDistributionV2CategoryResponse' + + InvoicingDistributionV2CustomerPricesResponse: + $ref: '#/components/schemas/InvoicingDistributionV2CategoryResponse' + + InvoicingDistributionV2AllResponse: + type: object + properties: + fixed_pricing: + $ref: '#/components/schemas/InvoicingDistributionV2FixedPricingResponse' + wash_subscriptions: + $ref: '#/components/schemas/InvoicingDistributionV2WashSubscriptionsResponse' + customer_prices: + $ref: '#/components/schemas/InvoicingDistributionV2CustomerPricesResponse' + required: [fixed_pricing, wash_subscriptions, customer_prices] + + PricingHistoryVersionEntry: + type: object + properties: + id: + type: integer + type: + type: string + enum: [fixed_pricing, vehicle_subscription, discount_override] + customer_number: + type: integer + effective_from: + type: string + format: date-time + effective_to: + type: string + format: date-time + nullable: true + source: + type: string + confidence: + type: number + minimum: 0 + maximum: 1 + inferred: + type: boolean + metadata_json: + oneOf: + - type: string + - type: object + additionalProperties: true + - type: array + items: {} + nullable: true + required: + - id + - type + - customer_number + - effective_from + - source + - confidence + - inferred + + CustomerPricingHistoryResponse: + type: object + properties: + customer_number: + type: integer + fixed_pricing: + type: array + items: + type: object + additionalProperties: true + vehicle_subscriptions: + type: array + items: + type: object + additionalProperties: true + discount_overrides: + type: array + items: + type: object + additionalProperties: true + timeline: + type: array + items: + $ref: '#/components/schemas/PricingHistoryVersionEntry' + required: + - customer_number + - fixed_pricing + - vehicle_subscriptions + - discount_overrides + - timeline InvoicingFixedPricingDistributionResponse: type: object diff --git a/services/nginx/app/classes/economic_v2_compare_engine.php b/services/nginx/app/classes/economic_v2_compare_engine.php new file mode 100644 index 00000000..413ef341 --- /dev/null +++ b/services/nginx/app/classes/economic_v2_compare_engine.php @@ -0,0 +1,369 @@ + [ + 'internal_net_total' => (float)($internal['totals']['net_total'] ?? 0.0), + ], + 'targets' => [ + 'draft' => $draft_result, + 'booked' => $booked_result, + ], + 'warnings' => array_values(array_unique(array_merge( + (array)($internal['warnings'] ?? []), + (array)($draft_result['warnings'] ?? []), + (array)($booked_result['warnings'] ?? []) + ))), + ]; + } + + public static function compareTarget(array $internal, ?array $target, string $target_name): array + { + if ($target === null) { + return [ + 'target' => $target_name, + 'status' => 'missing_target', + 'overall_match' => false, + 'totals' => [ + 'internal_net_total' => (float)($internal['totals']['net_total'] ?? 0.0), + 'target_net_total' => null, + 'difference' => null, + 'abs_difference' => null, + 'matches' => false, + ], + 'lines' => [ + 'summary' => [ + 'internal_billable_count' => (int)($internal['totals']['billable_line_count'] ?? 0), + 'target_billable_count' => 0, + 'mismatch_count' => (int)($internal['totals']['billable_line_count'] ?? 0), + ], + 'diff' => [], + ], + 'departments' => [ + 'matches' => false, + 'diff' => [], + ], + 'mismatch_reasons' => ['missing_target'], + 'warnings' => ['Missing ' . $target_name . ' invoice target'], + ]; + } + + $totals = self::compareTotals( + (float)($internal['totals']['net_total'] ?? 0.0), + (float)($target['totals']['net_total'] ?? 0.0) + ); + + $lines = self::compareLines($internal['lines'] ?? [], $target['lines'] ?? []); + $departments = self::compareDepartments($internal['departments'] ?? [], $target['departments'] ?? []); + + $mismatch_reasons = array_values(array_unique(array_merge( + $lines['mismatch_reasons'], + $departments['mismatch_reasons'], + $totals['matches'] ? [] : ['total_mismatch'] + ))); + + $overall_match = $totals['matches'] && $lines['summary']['mismatch_count'] === 0 && $departments['matches']; + $status = $overall_match + ? 'exact_match' + : ($totals['matches'] ? 'partial_mismatch' : 'total_mismatch'); + + return [ + 'target' => $target_name, + 'status' => $status, + 'overall_match' => $overall_match, + 'totals' => $totals, + 'lines' => [ + 'summary' => $lines['summary'], + 'diff' => $lines['diff'], + ], + 'departments' => [ + 'matches' => $departments['matches'], + 'diff' => $departments['diff'], + ], + 'mismatch_reasons' => $mismatch_reasons, + 'warnings' => array_values(array_unique(array_merge( + (array)($target['warnings'] ?? []), + (array)$lines['warnings'], + (array)$departments['warnings'] + ))), + ]; + } + + private static function compareTotals(float $internal_total, float $target_total): array + { + $difference = $target_total - $internal_total; + $abs = abs($difference); + return [ + 'internal_net_total' => round($internal_total, 5), + 'target_net_total' => round($target_total, 5), + 'difference' => round($difference, 5), + 'abs_difference' => round($abs, 5), + 'matches' => $abs <= self::TOLERANCE, + ]; + } + + private static function compareLines(array $internal_lines, array $target_lines): array + { + $internal_billable = array_values(array_filter($internal_lines, static fn($l) => (bool)($l['billable'] ?? false))); + $target_billable = array_values(array_filter($target_lines, static fn($l) => (bool)($l['billable'] ?? false))); + + $internal_grouped = self::groupByKey($internal_billable, 'match_key'); + $target_grouped = self::groupByKey($target_billable, 'match_key'); + + $keys = array_values(array_unique(array_merge(array_keys($internal_grouped), array_keys($target_grouped)))); + sort($keys); + + $diff = []; + $mismatch_reasons = []; + $warnings = []; + $unmatched_internal = []; + $unmatched_target = []; + + foreach ($keys as $key) { + $left = $internal_grouped[$key] ?? []; + $right = $target_grouped[$key] ?? []; + $max = max(count($left), count($right)); + + for ($i = 0; $i < $max; $i++) { + $internal_line = $left[$i] ?? null; + $target_line = $right[$i] ?? null; + + if ($internal_line === null) { + $unmatched_target[] = $target_line; + continue; + } + if ($target_line === null) { + $unmatched_internal[] = $internal_line; + continue; + } + + $reasons = self::lineMismatchReasons($internal_line, $target_line); + if (!empty($reasons)) { + $diff[] = [ + 'match_key' => $key, + 'reasons' => $reasons, + 'internal_line' => $internal_line, + 'target_line' => $target_line, + ]; + $mismatch_reasons = array_merge($mismatch_reasons, $reasons); + } + } + } + + // Secondary pairing by reference/description to convert missing/extra into explicit product mismatch when possible. + [$paired_diff, $still_unmatched_internal, $still_unmatched_target] = self::secondaryPairAndCompare($unmatched_internal, $unmatched_target); + $diff = array_merge($diff, $paired_diff); + foreach ($paired_diff as $entry) { + $mismatch_reasons = array_merge($mismatch_reasons, $entry['reasons']); + } + + foreach ($still_unmatched_internal as $line) { + $diff[] = [ + 'match_key' => (string)($line['match_key'] ?? ''), + 'reasons' => ['missing_in_target'], + 'internal_line' => $line, + 'target_line' => null, + ]; + $mismatch_reasons[] = 'missing_in_target'; + } + foreach ($still_unmatched_target as $line) { + $diff[] = [ + 'match_key' => (string)($line['match_key'] ?? ''), + 'reasons' => ['extra_in_target'], + 'internal_line' => null, + 'target_line' => $line, + ]; + $mismatch_reasons[] = 'extra_in_target'; + } + + $internal_non_billable = count($internal_lines) - count($internal_billable); + $target_non_billable = count($target_lines) - count($target_billable); + if ($internal_non_billable !== $target_non_billable) { + $warnings[] = 'Non-billable line count differs: internal=' . $internal_non_billable . ', target=' . $target_non_billable; + } + + return [ + 'summary' => [ + 'internal_billable_count' => count($internal_billable), + 'target_billable_count' => count($target_billable), + 'mismatch_count' => count($diff), + ], + 'diff' => $diff, + 'mismatch_reasons' => array_values(array_unique($mismatch_reasons)), + 'warnings' => $warnings, + ]; + } + + private static function secondaryPairAndCompare(array $unmatched_internal, array $unmatched_target): array + { + $left_by_secondary = self::groupByKey(array_values($unmatched_internal), 'secondary_key'); + $right_by_secondary = self::groupByKey(array_values($unmatched_target), 'secondary_key'); + + $secondary_keys = array_values(array_unique(array_merge(array_keys($left_by_secondary), array_keys($right_by_secondary)))); + sort($secondary_keys); + + $paired_diff = []; + $left_remainder = []; + $right_remainder = []; + + foreach ($secondary_keys as $secondary_key) { + $left = $left_by_secondary[$secondary_key] ?? []; + $right = $right_by_secondary[$secondary_key] ?? []; + $max = max(count($left), count($right)); + for ($i = 0; $i < $max; $i++) { + $internal_line = $left[$i] ?? null; + $target_line = $right[$i] ?? null; + + if ($internal_line === null) { + if ($target_line !== null) { + $right_remainder[] = $target_line; + } + continue; + } + if ($target_line === null) { + $left_remainder[] = $internal_line; + continue; + } + + $reasons = self::lineMismatchReasons($internal_line, $target_line); + if ((string)($internal_line['product_number'] ?? '') !== (string)($target_line['product_number'] ?? '')) { + $reasons[] = 'product_mismatch'; + } + $reasons = array_values(array_unique($reasons)); + $paired_diff[] = [ + 'match_key' => (string)($internal_line['match_key'] ?? ''), + 'reasons' => $reasons, + 'internal_line' => $internal_line, + 'target_line' => $target_line, + ]; + } + } + + return [$paired_diff, $left_remainder, $right_remainder]; + } + + private static function lineMismatchReasons(array $internal_line, array $target_line): array + { + $reasons = []; + + if ((string)($internal_line['product_number'] ?? '') !== (string)($target_line['product_number'] ?? '')) { + $reasons[] = 'product_mismatch'; + } + + if (self::normalizeText((string)($internal_line['description'] ?? '')) !== self::normalizeText((string)($target_line['description'] ?? ''))) { + $reasons[] = 'description_mismatch'; + } + + if (!self::matchesNumber((float)($internal_line['quantity'] ?? 0), (float)($target_line['quantity'] ?? 0))) { + $reasons[] = 'quantity_mismatch'; + } + + if (!self::matchesNumber((float)($internal_line['unit_net_price'] ?? 0), (float)($target_line['unit_net_price'] ?? 0))) { + $reasons[] = 'unit_price_mismatch'; + } + + if (!self::matchesNumber((float)($internal_line['line_net_amount'] ?? 0), (float)($target_line['line_net_amount'] ?? 0))) { + $reasons[] = 'line_total_mismatch'; + } + + if (!self::departmentDistributionMatches( + (array)($internal_line['department_distribution'] ?? []), + (array)($target_line['department_distribution'] ?? []) + )) { + $reasons[] = 'departmental_distribution_mismatch'; + } + + return $reasons; + } + + private static function compareDepartments(array $internal_departments, array $target_departments): array + { + $keys = array_values(array_unique(array_merge(array_keys($internal_departments), array_keys($target_departments)))); + sort($keys); + + $diff = []; + $mismatch_reasons = []; + + foreach ($keys as $key) { + $internal_amount = (float)($internal_departments[$key] ?? 0.0); + $target_amount = (float)($target_departments[$key] ?? 0.0); + $difference = $target_amount - $internal_amount; + $matches = abs($difference) <= self::TOLERANCE; + + $diff[] = [ + 'department_key' => (string)$key, + 'internal_amount' => round($internal_amount, 5), + 'target_amount' => round($target_amount, 5), + 'difference' => round($difference, 5), + 'matches' => $matches, + ]; + if (!$matches) { + $mismatch_reasons[] = 'department_total_mismatch'; + } + } + + return [ + 'matches' => empty($mismatch_reasons), + 'diff' => $diff, + 'mismatch_reasons' => array_values(array_unique($mismatch_reasons)), + 'warnings' => [], + ]; + } + + private static function groupByKey(array $lines, string $preferred_key): array + { + $grouped = []; + foreach ($lines as $line) { + $secondary_key = self::normalizeText((string)($line['description'] ?? '')) . + '|ref:' . self::normalizeText((string)($line['reference'] ?? '')); + $line['secondary_key'] = $secondary_key; + $key = (string)($line[$preferred_key] ?? $secondary_key); + if (!isset($grouped[$key])) { + $grouped[$key] = []; + } + $grouped[$key][] = $line; + } + + foreach ($grouped as &$bucket) { + usort($bucket, static function ($a, $b) { + return ((int)($a['source_line_id'] ?? 0)) <=> ((int)($b['source_line_id'] ?? 0)); + }); + } + + return $grouped; + } + + private static function departmentDistributionMatches(array $left, array $right): bool + { + $keys = array_values(array_unique(array_merge(array_keys($left), array_keys($right)))); + foreach ($keys as $key) { + if (!self::matchesNumber((float)($left[$key] ?? 0.0), (float)($right[$key] ?? 0.0))) { + return false; + } + } + return true; + } + + private static function matchesNumber(float $left, float $right): bool + { + return abs($left - $right) <= self::TOLERANCE; + } + + private static function normalizeText(string $value): string + { + $value = strtolower(trim($value)); + $value = preg_replace('/\s+/', ' ', $value); + return $value ?? ''; + } +} + diff --git a/services/nginx/app/classes/economic_v2_distribution_service.php b/services/nginx/app/classes/economic_v2_distribution_service.php new file mode 100644 index 00000000..616f0557 --- /dev/null +++ b/services/nginx/app/classes/economic_v2_distribution_service.php @@ -0,0 +1,812 @@ +versioning = new economic_v2_versioning_service(); + } + + public function getAllDistributions(string $date_from, string $date_to): array + { + return [ + 'fixed_pricing' => $this->getFixedPricingDistribution($date_from, $date_to), + 'wash_subscriptions' => $this->getWashSubscriptionsDistribution($date_from, $date_to), + 'customer_prices' => $this->getCustomerPricesDistribution($date_from, $date_to), + ]; + } + + public function getFixedPricingDistribution(string $date_from, string $date_to): array + { + [$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)); + + $groups = []; + $customer_transactions = []; + $warnings = []; + + foreach ($orders as $order) { + $order_id = (int)$order['id']; + $customer_number = (int)$order['customer_id']; + $department_id = (int)$order['department_id']; + $created_at = (string)$order['created_at']; + + if (!$this->isDepartmentEligible($department_id)) { + continue; + } + + $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[$order_id] ?? [], + $customer_number, + $department_id, + $created_at + ); + $groups[$group_key]['original_price'] += $order_original_price; + if (!isset($groups[$group_key]['department_totals'][$department_id])) { + $groups[$group_key]['department_totals'][$department_id] = 0.0; + } + $groups[$group_key]['department_totals'][$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); + } + } + + $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 + { + [$from_ts, $to_ts] = $this->buildDateRange($date_from, $date_to); + $orders = $this->fetchOrdersInRange($from_ts, $to_ts); + $months = $this->listMonthKeys($from_ts, $to_ts); + + $groups = []; + $customer_transactions = []; + $customer_department_month_map = []; + $warnings = []; + + foreach ($orders as $order) { + $order_id = (int)$order['id']; + $customer_number = (int)$order['customer_id']; + $department_id = (int)$order['department_id']; + $created_at = (string)$order['created_at']; + $reg = trim((string)($order['reg_1'] ?? '')); + + if (!$this->isDepartmentEligible($department_id)) { + continue; + } + + $month_key = substr($created_at, 0, 7); + if (!isset($customer_department_month_map[$customer_number][$month_key][$department_id])) { + $customer_department_month_map[$customer_number][$month_key][$department_id] = 0; + } + $customer_department_month_map[$customer_number][$month_key][$department_id]++; + + if ($reg === '') { + continue; + } + + $active_subscriptions = $this->versioning->resolveVehicleSubscriptionVersionsAt($customer_number, $created_at); + $matching_version = null; + foreach ($active_subscriptions as $candidate) { + if (strcasecmp((string)$candidate['reg'], $reg) === 0) { + $matching_version = $candidate; + break; + } + } + 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'][$department_id])) { + $groups[$group_key]['distribution'][$department_id] = 0; + } + $groups[$group_key]['distribution'][$department_id]++; + $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); + } + } + + $version_rows = $this->fetchVehicleSubscriptionVersionRows($from_ts, $to_ts); + foreach ($version_rows as $row) { + $customer_number = (int)$row['customer_number']; + $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 + { + [$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']; + $department_id = (int)$order['department_id']; + $created_at = (string)$order['created_at']; + + if (!$this->isDepartmentEligible($department_id)) { + 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); + $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 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]; + } + + private function fetchOrdersInRange(string $from_ts, string $to_ts): array + { + global $db; + $from = $db->escape_string($from_ts); + $to = $db->escape_string($to_ts); + $sql = "SELECT id, customer_id, department_id, created_at, reg_1 + FROM orders + WHERE deleted_at IS NULL + AND created_at >= '$from' + AND created_at <= '$to'"; + $result = $db->query($sql); + if (!$result) { + return []; + } + return $db->fetch_all($result); + } + + private 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))); + if (empty($order_ids)) { + return []; + } + $sql = "SELECT order_id, product_id, price, quantity + FROM order_items + WHERE deleted_at IS NULL + AND order_id IN (" . implode(',', $order_ids) . ")"; + $result = $db->query($sql); + if (!$result) { + return []; + } + $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; + } + return $grouped; + } + + private function fetchVehicleSubscriptionVersionRows(string $from_ts, string $to_ts): array + { + 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) { + return []; + } + return $db->fetch_all($result); + } + + 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; + } + + 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)) { + $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; + } + + $default_department = 1; + try { + $default = (new users_o())->getUserByCustomerNumber($customer_number)->getDefaultDepartment(); + if (!empty($default)) { + $default_department = (int)$default; + } + } catch (Exception $e) { + // fall back to department 1 + } + + $distribution[$default_department] = $monthly_price; + return $distribution; + } + + 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); + } + + private 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; + } + + private 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; + } + + private 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]; + } + + private function getSubscriptionMonthlyPrice(int $vehicle_type): float + { + $product = $this->getProduct($vehicle_type); + if ($product === null) { + return 0.0; + } + return (float)$product->getSubscriptionMonthlyPrice(); + } + + 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]; + } + + private 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' => [], + ]; + } + + private function buildTransactionObject(int $order_id, string $created_at, int $department_id, ?float $amount = 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' => !$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]; + } + + private function isDepartmentEligible(int $department_id): bool + { + if ($department_id === 10 || $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]; + } + + private 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; + } +} diff --git a/services/nginx/app/classes/economic_v2_line_normalizer.php b/services/nginx/app/classes/economic_v2_line_normalizer.php new file mode 100644 index 00000000..e4ec69da --- /dev/null +++ b/services/nginx/app/classes/economic_v2_line_normalizer.php @@ -0,0 +1,290 @@ +getOrders() as $order_row) { + $order = (new orders_o())->select((int)$order_row['id']); + $department_id = (int)$order->department_id->value(); + $order_items = (new order_items_o())->getAllItemsAsArray( + (int)$order->id, + ['id', 'product_id', 'reference', 'notes', 'price', 'quantity', 'include_in_invoice'] + ); + + foreach ($order_items as $row) { + if (!(bool)($row['include_in_invoice'] ?? false)) { + continue; + } + + $product = (new products_o())->getProductById((int)$row['product_id']); + if (!$product->exists()) { + $warnings[] = 'Missing product for internal order item id ' . (int)$row['id']; + continue; + } + + $product_number = $product->economic_product_id->value(); + $quantity = (float)$row['quantity']; + $unit_net_price = (float)$row['price']; + $line_net_amount = $quantity * $unit_net_price; + $department_distribution = [$department_id => 100.0]; + + $line = [ + 'index' => count($lines), + 'source' => 'internal', + 'source_order_id' => (int)$order->id, + 'source_line_id' => (int)$row['id'], + 'line_type' => self::detectLineType($product_number, $unit_net_price, true), + 'billable' => true, + 'product_number' => $product_number !== null ? (string)$product_number : null, + 'product_id' => (int)$row['product_id'], + 'description' => (string)$product->name->value(), + 'reference' => (string)($row['reference'] ?? ''), + 'quantity' => $quantity, + 'unit_net_price' => $unit_net_price, + 'line_net_amount' => $line_net_amount, + 'department_distribution' => $department_distribution, + ]; + $line['match_key'] = self::buildMatchKey($line); + $lines[] = $line; + } + } + + return self::wrap('internal', $lines, $warnings); + } + + public static function normalizeDraftInvoice(object|array|null $draft_invoice): array + { + if ($draft_invoice === null) { + return self::wrap('draft', [], ['Draft invoice missing']); + } + + $data = self::toArray($draft_invoice); + $raw_lines = is_array($data['lines'] ?? null) ? $data['lines'] : []; + $lines = []; + + foreach ($raw_lines as $raw_line) { + $line = self::normalizeEconomicLine($raw_line, 'draft'); + $line['index'] = count($lines); + $line['source_line_id'] = isset($raw_line['lineNumber']) ? (int)$raw_line['lineNumber'] : (int)($raw_line['line_number'] ?? count($lines) + 1); + $line['match_key'] = self::buildMatchKey($line); + $lines[] = $line; + } + + $wrapped = self::wrap('draft', $lines, []); + if (isset($data['netAmount'])) { + $wrapped['totals']['net_total'] = (float)$data['netAmount']; + } elseif (isset($data['net_amount'])) { + $wrapped['totals']['net_total'] = (float)$data['net_amount']; + } + $wrapped['totals']['difference_from_line_sum'] = round( + (float)$wrapped['totals']['net_total'] - (float)$wrapped['totals']['line_net_total'], + 5 + ); + + return $wrapped; + } + + public static function normalizeBookedInvoice(object|array|null $booked_invoice): array + { + if ($booked_invoice === null) { + return self::wrap('booked', [], ['Booked invoice missing']); + } + + if ($booked_invoice instanceof economic_invoice_booked) { + $data = $booked_invoice->toArray(); + } else { + $data = self::toArray($booked_invoice); + } + + $raw_lines = is_array($data['lines'] ?? null) ? $data['lines'] : []; + $lines = []; + foreach ($raw_lines as $raw_line) { + $line = self::normalizeEconomicLine($raw_line, 'booked'); + $line['index'] = count($lines); + $line['source_line_id'] = (int)($raw_line['lineNumber'] ?? $raw_line['line_number'] ?? count($lines) + 1); + $line['match_key'] = self::buildMatchKey($line); + $lines[] = $line; + } + + $wrapped = self::wrap('booked', $lines, []); + if (isset($data['netAmount'])) { + $wrapped['totals']['net_total'] = (float)$data['netAmount']; + } elseif (isset($data['net_amount'])) { + $wrapped['totals']['net_total'] = (float)$data['net_amount']; + } + $wrapped['totals']['difference_from_line_sum'] = round( + (float)$wrapped['totals']['net_total'] - (float)$wrapped['totals']['line_net_total'], + 5 + ); + + return $wrapped; + } + + private static function normalizeEconomicLine(array $raw_line, string $source): array + { + $line = self::toArray($raw_line); + + $product_number = $line['product']['productNumber'] + ?? $line['product']['product_number'] + ?? null; + $description = (string)($line['description'] ?? ''); + $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); + + $department_distribution = self::extractDepartmentDistribution($line); + $billable = ($product_number !== null) || abs($line_net_amount) > 0.00001 || abs($quantity) > 0.00001; + + return [ + 'source' => $source, + 'source_order_id' => null, + 'line_type' => self::detectLineType($product_number, $unit_net_price, $billable), + 'billable' => $billable, + 'product_number' => $product_number !== null ? (string)$product_number : null, + 'product_id' => null, + 'description' => $description, + 'reference' => '', + 'quantity' => $quantity, + 'unit_net_price' => $unit_net_price, + 'line_net_amount' => $line_net_amount, + 'department_distribution' => $department_distribution, + ]; + } + + private static function extractDepartmentDistribution(array $line): array + { + $distribution = []; + $dd = $line['departmentalDistribution'] ?? $line['departmental_distribution'] ?? null; + if (is_array($dd)) { + $distributions = $dd['distributions'] ?? null; + if (is_array($distributions)) { + foreach ($distributions as $entry) { + $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) && isset($dd['departmentalDistributionNumber'])) { + $distribution[(string)$dd['departmentalDistributionNumber']] = 100.0; + } elseif (empty($distribution) && isset($dd['departmental_distribution_number'])) { + $distribution[(string)$dd['departmental_distribution_number']] = 100.0; + } + } + + if (empty($distribution)) { + $distribution['unassigned'] = 100.0; + } + + return $distribution; + } + + private static function wrap(string $source, array $lines, array $warnings): array + { + $net_total = 0.0; + $line_net_total = 0.0; + $billable_count = 0; + $departments = []; + + foreach ($lines as $line) { + $line_net_total += (float)$line['line_net_amount']; + if (!(bool)$line['billable']) { + continue; + } + $billable_count++; + $line_amount = (float)$line['line_net_amount']; + $net_total += $line_amount; + + foreach ($line['department_distribution'] as $department_key => $percentage) { + if (!isset($departments[$department_key])) { + $departments[$department_key] = 0.0; + } + $departments[$department_key] += $line_amount * ((float)$percentage / 100); + } + } + + return [ + 'source' => $source, + 'totals' => [ + 'net_total' => round($net_total, 5), + 'line_net_total' => round($line_net_total, 5), + 'line_count' => count($lines), + 'billable_line_count' => $billable_count, + ], + 'departments' => self::roundMap($departments), + 'lines' => $lines, + 'warnings' => $warnings, + ]; + } + + private static function buildMatchKey(array $line): string + { + if (!empty($line['product_number'])) { + return 'product:' . strtolower(trim((string)$line['product_number'])) . + '|ref:' . strtolower(trim((string)($line['reference'] ?? ''))); + } + + return 'text:' . self::normalizeText((string)$line['description']); + } + + private static function detectLineType(mixed $product_number, float $unit_net_price, bool $billable): string + { + if (!$billable) { + return 'text'; + } + + if ($product_number !== null && strtolower((string)$product_number) === 'totdiscount') { + return 'discount'; + } + + if ($unit_net_price < 0) { + return 'discount'; + } + + return $product_number !== null ? 'product' : 'text'; + } + + private static function normalizeText(string $text): string + { + $text = trim(strtolower($text)); + $text = preg_replace('/\s+/', ' ', $text); + return $text ?? ''; + } + + private static function roundMap(array $map): array + { + $rounded = []; + foreach ($map as $k => $v) { + $rounded[(string)$k] = round((float)$v, 5); + } + return $rounded; + } + + private static function toArray(object|array $value): array + { + if (is_array($value)) { + return $value; + } + return json_decode(json_encode($value, JSON_UNESCAPED_UNICODE), true) ?: []; + } +} diff --git a/services/nginx/app/classes/economic_v2_revenue_statistics_service.php b/services/nginx/app/classes/economic_v2_revenue_statistics_service.php new file mode 100644 index 00000000..a17a5107 --- /dev/null +++ b/services/nginx/app/classes/economic_v2_revenue_statistics_service.php @@ -0,0 +1,503 @@ + */ + private array $customer_cache = []; + + public function __construct(?economic $economic = null) + { + $this->economic = $economic ?? new economic(); + } + + public function getBookedRevenueStatistics(array $filters = []): array + { + $normalized_filters = $this->normalizeFilters($filters); + $warnings = []; + + $booked_invoices = $this->fetchBookedInvoices($normalized_filters, $warnings); + $invoice_ids = []; + foreach ($booked_invoices as $invoice) { + $invoice_id = (int)($invoice->bookedInvoiceNumber ?? 0); + if ($invoice_id > 0) { + $invoice_ids[] = $invoice_id; + } + } + + $invoice_lines_map = []; + if (!empty($invoice_ids)) { + try { + $invoice_lines_map = $this->economic->invoices->booked->get_invoice_lines($invoice_ids); + } catch (\Throwable $e) { + $warnings[] = 'Unable to fetch booked invoice lines in bulk: ' . $e->getMessage(); + } + } + + $summary = [ + 'invoice_count' => 0, + 'line_count' => 0, + 'unique_customers' => 0, + 'net_amount' => 0.0, + 'vat_amount' => 0.0, + 'gross_amount' => 0.0, + 'average_invoice_net_amount' => 0.0, + ]; + + $customers = []; + $departments = []; + $currencies = []; + $seen_customers = []; + + $customer_filter_map = array_fill_keys($normalized_filters['customer_numbers'], true); + $department_filter_map = array_fill_keys($normalized_filters['department_numbers'], true); + $has_customer_filter = !empty($customer_filter_map); + $has_department_filter = !empty($department_filter_map); + + foreach ($booked_invoices as $invoice) { + $invoice_id = (int)($invoice->bookedInvoiceNumber ?? 0); + if ($invoice_id <= 0) { + continue; + } + + $customer_number = (int)($invoice->customer->customerNumber ?? 0); + if ($has_customer_filter && !isset($customer_filter_map[$customer_number])) { + continue; + } + + $customer_snapshot = $this->resolveCustomerSnapshot($customer_number, $warnings); + if (!$this->passesBarredFilter($customer_snapshot['barred'], $normalized_filters['barred'])) { + continue; + } + + $invoice_currency = strtoupper((string)($invoice->currency ?? '')); + if ($normalized_filters['currency'] !== null && $invoice_currency !== $normalized_filters['currency']) { + continue; + } + + $invoice_lines = $invoice_lines_map[$invoice_id] ?? []; + $line_reduction = $this->reduceInvoiceLines( + $invoice_id, + $invoice_lines, + $departments, + $department_filter_map, + $has_department_filter + ); + + if ($has_department_filter && !$line_reduction['has_matching_departments']) { + continue; + } + + $invoice_net = $has_department_filter + ? (float)$line_reduction['net_amount'] + : (float)($invoice->netAmount ?? $invoice->net_amount ?? $line_reduction['net_amount']); + $invoice_vat = $has_department_filter + ? (float)$line_reduction['vat_amount'] + : (float)($invoice->vatAmount ?? $invoice->vat_amount ?? $line_reduction['vat_amount']); + $invoice_gross = $has_department_filter + ? (float)$line_reduction['gross_amount'] + : (float)($invoice->grossAmount ?? $invoice->gross_amount ?? ($invoice_net + $invoice_vat)); + + if ( + $has_department_filter && + abs($invoice_net) < self::EPSILON && + abs($invoice_vat) < self::EPSILON && + abs($invoice_gross) < self::EPSILON + ) { + continue; + } + + $summary['invoice_count']++; + $summary['line_count'] += (int)$line_reduction['line_count']; + $summary['net_amount'] += $invoice_net; + $summary['vat_amount'] += $invoice_vat; + $summary['gross_amount'] += $invoice_gross; + + if (!isset($seen_customers[$customer_number])) { + $seen_customers[$customer_number] = true; + } + + if (!isset($customers[$customer_number])) { + $customers[$customer_number] = [ + 'customer_number' => $customer_number, + 'customer_name' => $customer_snapshot['name'], + 'barred' => $customer_snapshot['barred'], + 'invoice_count' => 0, + 'net_amount' => 0.0, + 'vat_amount' => 0.0, + 'gross_amount' => 0.0, + ]; + } + $customers[$customer_number]['invoice_count']++; + $customers[$customer_number]['net_amount'] += $invoice_net; + $customers[$customer_number]['vat_amount'] += $invoice_vat; + $customers[$customer_number]['gross_amount'] += $invoice_gross; + + $currency_key = $invoice_currency !== '' ? $invoice_currency : 'UNKNOWN'; + if (!isset($currencies[$currency_key])) { + $currencies[$currency_key] = [ + 'currency' => $currency_key, + 'invoice_count' => 0, + 'net_amount' => 0.0, + 'vat_amount' => 0.0, + 'gross_amount' => 0.0, + ]; + } + $currencies[$currency_key]['invoice_count']++; + $currencies[$currency_key]['net_amount'] += $invoice_net; + $currencies[$currency_key]['vat_amount'] += $invoice_vat; + $currencies[$currency_key]['gross_amount'] += $invoice_gross; + } + + $summary['unique_customers'] = count($seen_customers); + if ($summary['invoice_count'] > 0) { + $summary['average_invoice_net_amount'] = $summary['net_amount'] / $summary['invoice_count']; + } + + $customer_rows = array_values($customers); + usort($customer_rows, static function (array $a, array $b): int { + return $b['invoice_count'] <=> $a['invoice_count']; + }); + + $department_rows = []; + foreach ($departments as $department_key => $row) { + $department_rows[] = [ + 'department_key' => $department_key, + 'department_number' => is_numeric((string)$department_key) ? (int)$department_key : null, + 'invoice_count' => count($row['invoice_ids']), + 'line_count' => $row['line_count'], + 'net_amount' => $row['net_amount'], + 'vat_amount' => $row['vat_amount'], + 'gross_amount' => $row['gross_amount'], + ]; + } + usort($department_rows, static function (array $a, array $b): int { + return abs((float)$b['net_amount']) <=> abs((float)$a['net_amount']); + }); + + $currency_rows = array_values($currencies); + usort($currency_rows, static function (array $a, array $b): int { + return $b['invoice_count'] <=> $a['invoice_count']; + }); + + return [ + 'filters' => [ + 'dateFrom' => $normalized_filters['dateFrom'], + 'dateTo' => $normalized_filters['dateTo'], + 'customer_numbers' => array_values($normalized_filters['customer_numbers']), + 'department_numbers' => array_values($normalized_filters['department_numbers']), + 'currency' => $normalized_filters['currency'], + 'barred' => $normalized_filters['barred'], + 'max_pages' => $normalized_filters['max_pages'], + ], + 'summary' => $this->roundNumericValues($summary), + 'customers' => $this->roundRows($customer_rows), + 'departments' => $this->roundRows($department_rows), + 'currencies' => $this->roundRows($currency_rows), + 'warnings' => array_values(array_unique($warnings)), + ]; + } + + private function normalizeFilters(array $filters): array + { + $date_from = (string)($filters['dateFrom'] ?? date('Y-m-01')); + $date_to = (string)($filters['dateTo'] ?? date('Y-m-d')); + + $customer_numbers = $this->normalizeIntegerList($filters['customer_numbers'] ?? []); + $department_numbers = $this->normalizeIntegerList($filters['department_numbers'] ?? []); + + $currency = isset($filters['currency']) && trim((string)$filters['currency']) !== '' + ? strtoupper(trim((string)$filters['currency'])) + : null; + + $barred = strtolower(trim((string)($filters['barred'] ?? 'all'))); + if (!in_array($barred, ['all', 'barred', 'active'], true)) { + $barred = 'all'; + } + + $max_pages = (int)($filters['max_pages'] ?? self::DEFAULT_MAX_PAGES); + $max_pages = max(1, min(200, $max_pages)); + + return [ + 'dateFrom' => $date_from, + 'dateTo' => $date_to, + 'customer_numbers' => $customer_numbers, + 'department_numbers' => $department_numbers, + 'currency' => $currency, + 'barred' => $barred, + 'max_pages' => $max_pages, + ]; + } + + private function fetchBookedInvoices(array $normalized_filters, array &$warnings): array + { + $filters = [ + '(date$gte:' . $normalized_filters['dateFrom'] . '$and:date$lte:' . $normalized_filters['dateTo'] . ')' => '', + ]; + if ($normalized_filters['currency'] !== null) { + $filters['currency'] = '$eq:' . $normalized_filters['currency']; + } + if (count($normalized_filters['customer_numbers']) === 1) { + $filters['customer.customerNumber'] = '$eq:' . $normalized_filters['customer_numbers'][0]; + } + + $all = []; + for ($page = 0; $page < $normalized_filters['max_pages']; $page++) { + $response = $this->economic->invoices->booked->get( + $filters, + [ + 'skipPages' => $page, + 'pageSize' => self::DEFAULT_PAGE_SIZE, + ] + ); + $collection = is_array($response->collection ?? null) ? $response->collection : []; + $all = array_merge($all, $collection); + + if (count($collection) < self::DEFAULT_PAGE_SIZE) { + break; + } + if ($page + 1 >= $normalized_filters['max_pages']) { + $warnings[] = 'Reached pagination safety limit (max_pages=' . $normalized_filters['max_pages'] . ').'; + } + } + + return $all; + } + + /** + * @param array $department_totals + * @param array $department_filter_map + * @return array{net_amount:float,vat_amount:float,gross_amount:float,line_count:int,has_matching_departments:bool} + */ + private function reduceInvoiceLines( + int $invoice_id, + array $invoice_lines, + array &$department_totals, + array $department_filter_map, + bool $has_department_filter + ): array { + $invoice_net = 0.0; + $invoice_vat = 0.0; + $invoice_gross = 0.0; + $line_count = 0; + $has_matching_departments = false; + + foreach ($invoice_lines as $line_raw) { + $line = $this->toArray($line_raw); + + $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); + $line_vat_amount = isset($line['vatAmount']) + ? (float)$line['vatAmount'] + : (isset($line['vat_amount']) ? (float)$line['vat_amount'] : $line_net_amount * ((float)($line['vatRate'] ?? 0.0) / 100)); + $line_gross_amount = $line_net_amount + $line_vat_amount; + + $is_billable = abs($line_net_amount) > self::EPSILON || abs($quantity) > self::EPSILON; + if (!$is_billable) { + continue; + } + + $distribution = $this->extractDepartmentDistribution($line); + $matching_percentage_total = 0.0; + foreach ($distribution as $department_key => $percentage) { + if ($has_department_filter && !isset($department_filter_map[(int)$department_key])) { + continue; + } + $matching_percentage_total += (float)$percentage; + $has_matching_departments = true; + + if (!isset($department_totals[$department_key])) { + $department_totals[$department_key] = [ + 'invoice_ids' => [], + 'line_count' => 0, + 'net_amount' => 0.0, + 'vat_amount' => 0.0, + 'gross_amount' => 0.0, + ]; + } + + $ratio = ((float)$percentage / 100.0); + $department_totals[$department_key]['invoice_ids'][$invoice_id] = true; + $department_totals[$department_key]['line_count']++; + $department_totals[$department_key]['net_amount'] += $line_net_amount * $ratio; + $department_totals[$department_key]['vat_amount'] += $line_vat_amount * $ratio; + $department_totals[$department_key]['gross_amount'] += $line_gross_amount * $ratio; + } + + if ($has_department_filter && $matching_percentage_total <= self::EPSILON) { + continue; + } + + $factor = $has_department_filter ? ($matching_percentage_total / 100.0) : 1.0; + $invoice_net += $line_net_amount * $factor; + $invoice_vat += $line_vat_amount * $factor; + $invoice_gross += $line_gross_amount * $factor; + $line_count++; + } + + return [ + 'net_amount' => $invoice_net, + 'vat_amount' => $invoice_vat, + 'gross_amount' => $invoice_gross, + 'line_count' => $line_count, + 'has_matching_departments' => $has_matching_departments, + ]; + } + + /** + * @return array + */ + private function extractDepartmentDistribution(array $line): array + { + $distribution = []; + $departmental_distribution = $line['departmentalDistribution'] ?? $line['departmental_distribution'] ?? null; + if (!is_array($departmental_distribution)) { + return ['unassigned' => 100.0]; + } + + $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[(int)$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[(int)$fallback_number] = 100.0; + } + } + + if (empty($distribution)) { + $distribution['unassigned'] = 100.0; + } + + return $distribution; + } + + /** + * @return array{customer_number:int,name:?string,barred:?bool,status:string} + */ + private function resolveCustomerSnapshot(int $customer_number, array &$warnings): array + { + if (isset($this->customer_cache[$customer_number])) { + return $this->customer_cache[$customer_number]; + } + + $snapshot = [ + 'customer_number' => $customer_number, + 'name' => null, + 'barred' => null, + 'status' => 'unknown', + ]; + + if ($customer_number <= 0) { + $this->customer_cache[$customer_number] = $snapshot; + return $snapshot; + } + + try { + $raw = $this->economic->customers->customers->get($customer_number); + if (isset($raw->customerNumber)) { + $snapshot['name'] = isset($raw->name) ? (string)$raw->name : null; + $snapshot['barred'] = isset($raw->barred) ? (bool)$raw->barred : null; + $snapshot['status'] = 'resolved'; + } else { + $warnings[] = 'Unable to resolve e-conomic customer ' . $customer_number . ' while evaluating barred filter.'; + } + } catch (\Throwable $e) { + $warnings[] = 'Failed to fetch e-conomic customer ' . $customer_number . ': ' . $e->getMessage(); + } + + $this->customer_cache[$customer_number] = $snapshot; + return $snapshot; + } + + private function passesBarredFilter(?bool $barred, string $mode): bool + { + return match ($mode) { + 'barred' => $barred === true, + 'active' => $barred !== true, + default => true, + }; + } + + private 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 normalizeIntegerList(mixed $raw): array + { + $values = []; + if (is_array($raw)) { + $values = $raw; + } elseif (is_string($raw)) { + $values = explode(',', $raw); + } elseif (is_numeric($raw)) { + $values = [$raw]; + } + + $normalized = []; + foreach ($values as $value) { + $int_value = (int)$value; + if ($int_value > 0) { + $normalized[$int_value] = true; + } + } + + return array_map('intval', array_keys($normalized)); + } + + private function roundRows(array $rows): array + { + $result = []; + foreach ($rows as $row) { + $result[] = $this->roundNumericValues($row); + } + return $result; + } + + private function roundNumericValues(array $data): array + { + foreach ($data as $key => $value) { + if (is_array($value)) { + $data[$key] = $this->roundNumericValues($value); + continue; + } + if (is_float($value)) { + $data[$key] = round($value, 5); + } + } + return $data; + } +} + diff --git a/services/nginx/app/classes/economic_v2_schema_bootstrap.php b/services/nginx/app/classes/economic_v2_schema_bootstrap.php new file mode 100644 index 00000000..655ebf7d --- /dev/null +++ b/services/nginx/app/classes/economic_v2_schema_bootstrap.php @@ -0,0 +1,109 @@ +query($sql); + } + + self::$initialized = true; + } + + public static function tableHasColumn(string $table, string $column): bool + { + global $db; + $table = $db->escape_string($table); + $column = $db->escape_string($column); + $database = $db->escape_string($db->getDatabase()); + + $sql = "SELECT COUNT(*) AS c + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = '$database' + AND TABLE_NAME = '$table' + AND COLUMN_NAME = '$column'"; + $result = $db->query($sql); + if (!$result) { + return false; + } + $row = $result->fetch_assoc(); + return ((int)($row['c'] ?? 0)) > 0; + } +} + diff --git a/services/nginx/app/classes/economic_v2_versioning_service.php b/services/nginx/app/classes/economic_v2_versioning_service.php new file mode 100644 index 00000000..13c9ab50 --- /dev/null +++ b/services/nginx/app/classes/economic_v2_versioning_service.php @@ -0,0 +1,710 @@ +closeActiveFixedPricingVersion( + $customer_number, + $effective_from, + $source, + $confidence, + $inferred, + $metadata + ); + } + + return $this->upsertVersion( + 'customer_fixed_pricing_versions', + [ + 'customer_number' => $customer_number, + ], + [ + 'price' => (int)$price, + 'description' => $description ?? '', + ], + $this->normalizeDatetime($effective_from), + $source, + $confidence, + $inferred, + $metadata + ); + } + + public function closeActiveFixedPricingVersion( + int $customer_number, + ?string $effective_to = null, + string $source = 'live.fixed_pricing', + float $confidence = 1.0, + bool $inferred = false, + array $metadata = [] + ): array { + return $this->closeActiveVersion( + 'customer_fixed_pricing_versions', + [ + 'customer_number' => $customer_number, + ], + $this->normalizeDatetime($effective_to), + $source, + $confidence, + $inferred, + $metadata + ); + } + + public function recordVehicleSubscriptionVersion( + array $state, + ?string $effective_from = null, + string $source = 'live.vehicle', + float $confidence = 1.0, + bool $inferred = false, + array $metadata = [] + ): array { + if (!isset($state['customer_number'], $state['reg'], $state['vehicle_type'], $state['wash_subscription'])) { + throw new Exception('Missing required vehicle version state keys'); + } + + return $this->upsertVersion( + 'customer_vehicle_subscription_versions', + [ + 'customer_number' => (int)$state['customer_number'], + 'reg' => (string)$state['reg'], + ], + [ + 'vehicle_id' => isset($state['vehicle_id']) ? (int)$state['vehicle_id'] : null, + 'vehicle_type' => (int)$state['vehicle_type'], + 'wash_subscription' => (int)((bool)$state['wash_subscription']), + ], + $this->normalizeDatetime($effective_from), + $source, + $confidence, + $inferred, + $metadata + ); + } + + public function closeActiveVehicleSubscriptionVersion( + int $customer_number, + string $reg, + ?string $effective_to = null, + string $source = 'live.vehicle', + float $confidence = 1.0, + bool $inferred = false, + array $metadata = [] + ): array { + return $this->closeActiveVersion( + 'customer_vehicle_subscription_versions', + [ + 'customer_number' => $customer_number, + 'reg' => $reg, + ], + $this->normalizeDatetime($effective_to), + $source, + $confidence, + $inferred, + $metadata + ); + } + + public function recordDiscountOverrideVersion( + int $user_id, + int $customer_number, + bool $is_category, + int|string $object_id, + ?int $discount, + ?string $effective_from = null, + string $source = 'live.discount_override', + float $confidence = 1.0, + bool $inferred = false, + array $metadata = [] + ): array { + $identity = [ + 'user_id' => $user_id, + 'customer_number' => $customer_number, + 'is_category' => (int)$is_category, + 'object_id' => (string)$object_id, + ]; + + if ($discount === null || (int)$discount === 0) { + return $this->closeActiveVersion( + 'customer_discount_override_versions', + $identity, + $this->normalizeDatetime($effective_from), + $source, + $confidence, + $inferred, + $metadata + ); + } + + return $this->upsertVersion( + 'customer_discount_override_versions', + $identity, + [ + 'discount' => (int)$discount, + ], + $this->normalizeDatetime($effective_from), + $source, + $confidence, + $inferred, + $metadata + ); + } + + public function listFixedPricingVersions(int $customer_number, ?string $date_from = null, ?string $date_to = null): array + { + return $this->listVersions( + 'customer_fixed_pricing_versions', + ['customer_number' => $customer_number], + $date_from, + $date_to + ); + } + + public function listVehicleSubscriptionVersions(int $customer_number, ?string $date_from = null, ?string $date_to = null): array + { + return $this->listVersions( + 'customer_vehicle_subscription_versions', + ['customer_number' => $customer_number], + $date_from, + $date_to + ); + } + + public function listDiscountOverrideVersions(int $customer_number, ?string $date_from = null, ?string $date_to = null): array + { + return $this->listVersions( + 'customer_discount_override_versions', + ['customer_number' => $customer_number], + $date_from, + $date_to + ); + } + + public function resolveFixedPricingVersionAt(int $customer_number, string $timestamp): ?array + { + $rows = $this->resolveActiveVersions( + 'customer_fixed_pricing_versions', + ['customer_number' => $customer_number], + $timestamp, + 'effective_from DESC, id DESC', + 1 + ); + return $rows[0] ?? null; + } + + public function resolveVehicleSubscriptionVersionsAt(int $customer_number, string $timestamp): array + { + $rows = $this->resolveActiveVersions( + 'customer_vehicle_subscription_versions', + [ + 'customer_number' => $customer_number, + 'wash_subscription' => 1, + ], + $timestamp, + 'reg ASC, effective_from DESC, id DESC' + ); + + $unique = []; + foreach ($rows as $row) { + $reg = (string)$row['reg']; + if (!isset($unique[$reg])) { + $unique[$reg] = $row; + } + } + return array_values($unique); + } + + public function resolveDiscountOverrideAt( + int $customer_number, + bool $is_category, + int|string $object_id, + string $timestamp + ): ?array { + $rows = $this->resolveActiveVersions( + 'customer_discount_override_versions', + [ + 'customer_number' => $customer_number, + 'is_category' => (int)$is_category, + 'object_id' => (string)$object_id, + ], + $timestamp, + 'effective_from DESC, id DESC', + 1 + ); + return $rows[0] ?? null; + } + + public function runBestEffortBackfill(): array + { + global $db; + economic_v2_schema_bootstrap::ensureTables(); + + $report = [ + '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' => [], + ]; + + // Fixed pricing current state. + $has_fixed_created_at = economic_v2_schema_bootstrap::tableHasColumn('customer_fixed_pricing', 'created_at'); + $fixed_cols = $has_fixed_created_at + ? 'customer_number, price, description, created_at' + : 'customer_number, price, description'; + $fixed_rows = $this->fetchAll("SELECT $fixed_cols FROM customer_fixed_pricing"); + foreach ($fixed_rows as $row) { + $effective_from = $has_fixed_created_at + ? $this->normalizeDatetime((string)$row['created_at']) + : $this->normalizeDatetime(null); + $confidence = $has_fixed_created_at ? 0.8 : 0.6; + $result = $this->recordFixedPricingVersion( + (int)$row['customer_number'], + (int)$row['price'], + (string)($row['description'] ?? ''), + $effective_from, + 'backfill.current_fixed_pricing', + $confidence, + true, + ['table' => 'customer_fixed_pricing'] + ); + $this->incrementReportAction($report['fixed_pricing'], $result['action'] ?? 'noop'); + } + + // Infer fixed pricing start from synthetic fixed-price orders when no timeline exists. + $fixed_inferred = $this->fetchAll( + "SELECT o.customer_id AS customer_number, MIN(o.created_at) AS first_seen, MAX(oi.price) AS inferred_price + FROM orders o + JOIN order_items oi ON oi.order_id = o.id + WHERE o.deleted_at IS NULL + AND oi.deleted_at IS NULL + AND o.reference = 'Fast pris aftale' + AND oi.product_id = 61 + GROUP BY o.customer_id" + ); + foreach ($fixed_inferred as $row) { + $customer_number = (int)$row['customer_number']; + if ($this->resolveFixedPricingVersionAt($customer_number, (string)$row['first_seen']) !== null) { + continue; + } + $price = (int)($row['inferred_price'] ?? 0); + if ($price <= 0) { + continue; + } + $this->recordFixedPricingVersion( + $customer_number, + $price, + 'Inferred from fixed-pricing invoice order', + $this->normalizeDatetime((string)$row['first_seen']), + 'backfill.inferred_fixed_pricing_order', + 0.55, + true, + ['reference' => 'Fast pris aftale', 'product_id' => 61] + ); + $report['inferred']['fixed_pricing']++; + } + + // Vehicle subscriptions current state. + $has_vehicle_created_at = economic_v2_schema_bootstrap::tableHasColumn('customer_vehicles', 'created_at'); + $has_vehicle_deleted_at = economic_v2_schema_bootstrap::tableHasColumn('customer_vehicles', 'deleted_at'); + $vehicle_cols = 'id, customer_id, reg, type, wash_subscription' . + ($has_vehicle_created_at ? ', created_at' : '') . + ($has_vehicle_deleted_at ? ', deleted_at' : ''); + $vehicle_rows = $this->fetchAll("SELECT $vehicle_cols FROM customer_vehicles"); + foreach ($vehicle_rows as $row) { + $effective_from = $has_vehicle_created_at + ? $this->normalizeDatetime((string)$row['created_at']) + : $this->normalizeDatetime(null); + $confidence = $has_vehicle_created_at ? 0.75 : 0.55; + $result = $this->recordVehicleSubscriptionVersion( + [ + 'vehicle_id' => (int)$row['id'], + 'customer_number' => (int)$row['customer_id'], + 'reg' => (string)$row['reg'], + 'vehicle_type' => (int)$row['type'], + 'wash_subscription' => (bool)$row['wash_subscription'], + ], + $effective_from, + 'backfill.current_vehicle', + $confidence, + true, + ['table' => 'customer_vehicles'] + ); + $this->incrementReportAction($report['vehicle_subscriptions'], $result['action'] ?? 'noop'); + + if ($has_vehicle_deleted_at && !empty($row['deleted_at'])) { + $close_result = $this->closeActiveVehicleSubscriptionVersion( + (int)$row['customer_id'], + (string)$row['reg'], + $this->normalizeDatetime((string)$row['deleted_at']), + 'backfill.current_vehicle_deleted', + 0.9, + true, + ['table' => 'customer_vehicles'] + ); + $this->incrementReportAction($report['vehicle_subscriptions'], $close_result['action'] ?? 'noop'); + } + } + + // Infer subscriptions from synthetic subscription orders. + $subscription_inferred = $this->fetchAll( + "SELECT o.customer_id AS customer_number, + oi.reference AS reg, + oi.product_id AS vehicle_type, + MIN(o.created_at) AS first_seen + FROM orders o + JOIN order_items oi ON oi.order_id = o.id + WHERE o.deleted_at IS NULL + AND oi.deleted_at IS NULL + AND o.reference = 'Vaskeabonnementer' + AND oi.reference <> '' + AND oi.quantity > 0 + GROUP BY o.customer_id, oi.reference, oi.product_id" + ); + foreach ($subscription_inferred as $row) { + $resolved = $this->resolveVehicleSubscriptionVersionsAt((int)$row['customer_number'], (string)$row['first_seen']); + $already = false; + foreach ($resolved as $active) { + if ((string)$active['reg'] === (string)$row['reg']) { + $already = true; + break; + } + } + if ($already) { + continue; + } + $this->recordVehicleSubscriptionVersion( + [ + 'vehicle_id' => null, + 'customer_number' => (int)$row['customer_number'], + 'reg' => (string)$row['reg'], + 'vehicle_type' => (int)$row['vehicle_type'], + 'wash_subscription' => true, + ], + $this->normalizeDatetime((string)$row['first_seen']), + 'backfill.inferred_subscription_order', + 0.5, + true, + ['reference' => 'Vaskeabonnementer'] + ); + $report['inferred']['vehicle_subscriptions']++; + } + + // Discount overrides current state. + $has_override_created_at = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'created_at'); + $discount_cols = 'po.user_id, u.customer_number, po.is_category, po.product_or_category_id, po.percentage' . + ($has_override_created_at ? ', po.created_at' : ''); + $discount_rows = $this->fetchAll( + "SELECT $discount_cols + FROM price_overrides po + JOIN users u ON u.id = po.user_id" + ); + foreach ($discount_rows as $row) { + $effective_from = $has_override_created_at + ? $this->normalizeDatetime((string)$row['created_at']) + : $this->normalizeDatetime(null); + $confidence = $has_override_created_at ? 0.85 : 0.6; + $result = $this->recordDiscountOverrideVersion( + (int)$row['user_id'], + (int)$row['customer_number'], + (bool)$row['is_category'], + (string)$row['product_or_category_id'], + (int)$row['percentage'], + $effective_from, + 'backfill.current_discount_override', + $confidence, + true, + ['table' => 'price_overrides'] + ); + $this->incrementReportAction($report['discount_overrides'], $result['action'] ?? 'noop'); + } + + return $report; + } + + private function upsertVersion( + string $table, + array $identity, + array $values, + string $effective_from, + string $source, + float $confidence, + bool $inferred, + array $metadata + ): array { + global $db; + + $confidence = $this->normalizeConfidence($confidence); + $metadata_json = $db->escape_string(json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + $source = $db->escape_string($source); + $effective_from = $db->escape_string($effective_from); + + // Close the previous active interval when a new one starts. + $close_to = $db->escape_string($this->minusOneSecond($effective_from)); + $identity_where = $this->buildWhereClause($identity); + $db->query( + "UPDATE $table + SET effective_to = '$close_to' + WHERE $identity_where + AND effective_from < '$effective_from' + AND (effective_to IS NULL OR effective_to >= '$effective_from')" + ); + + $existing = $this->fetchOne( + "SELECT id + FROM $table + WHERE $identity_where + AND effective_from = '$effective_from' + ORDER BY id DESC + LIMIT 1" + ); + + if ($existing !== null) { + $id = (int)$existing['id']; + $set_parts = []; + foreach ($values as $k => $v) { + $set_parts[] = $this->buildSetFragment($k, $v); + } + $set_parts[] = "source = '$source'"; + $set_parts[] = "confidence = $confidence"; + $set_parts[] = "inferred = " . ((int)$inferred); + $set_parts[] = "metadata_json = '$metadata_json'"; + $db->query("UPDATE $table SET " . implode(', ', $set_parts) . " WHERE id = $id"); + return [ + 'action' => 'updated', + 'row' => $this->fetchOne("SELECT * FROM $table WHERE id = $id"), + ]; + } + + $next_start = $this->fetchOne( + "SELECT effective_from + FROM $table + WHERE $identity_where + AND effective_from > '$effective_from' + ORDER BY effective_from ASC + LIMIT 1" + ); + $effective_to_value = null; + if ($next_start !== null && !empty($next_start['effective_from'])) { + $effective_to_value = $this->minusOneSecond((string)$next_start['effective_from']); + } + + $insert_data = [ + ...$identity, + ...$values, + 'effective_from' => $effective_from, + 'effective_to' => $effective_to_value, + 'source' => $source, + 'confidence' => $confidence, + 'inferred' => (int)$inferred, + 'metadata_json' => $metadata_json, + ]; + + $columns = []; + $values_sql = []; + foreach ($insert_data as $k => $v) { + $columns[] = $k; + $values_sql[] = $this->buildValueFragment($v); + } + + $db->query( + "INSERT INTO $table (" . implode(', ', $columns) . ") + VALUES (" . implode(', ', $values_sql) . ")" + ); + $id = (int)$db->insert_id(); + + return [ + 'action' => 'inserted', + 'row' => $this->fetchOne("SELECT * FROM $table WHERE id = $id"), + ]; + } + + private function closeActiveVersion( + string $table, + array $identity, + string $effective_to, + string $source, + float $confidence, + bool $inferred, + array $metadata + ): array { + global $db; + + $confidence = $this->normalizeConfidence($confidence); + $source = $db->escape_string($source); + $metadata_json = $db->escape_string(json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + $effective_to = $db->escape_string($effective_to); + $identity_where = $this->buildWhereClause($identity); + + $result = $db->query( + "UPDATE $table + SET effective_to = '$effective_to', + source = '$source', + confidence = $confidence, + inferred = " . ((int)$inferred) . ", + metadata_json = '$metadata_json' + WHERE $identity_where + AND effective_from <= '$effective_to' + AND (effective_to IS NULL OR effective_to > '$effective_to')" + ); + + if ($result && $db->conn()->affected_rows > 0) { + return ['action' => 'closed']; + } + + return ['action' => 'noop']; + } + + private function listVersions(string $table, array $identity, ?string $date_from, ?string $date_to): array + { + $where = $this->buildWhereClause($identity); + if ($date_from !== null) { + $date_from = $this->normalizeDatetime($date_from); + $where .= " AND (effective_to IS NULL OR effective_to >= '" . $this->escape($date_from) . "')"; + } + if ($date_to !== null) { + $date_to = $this->normalizeDatetime($date_to); + $where .= " AND effective_from <= '" . $this->escape($date_to) . "'"; + } + return $this->fetchAll("SELECT * FROM $table WHERE $where ORDER BY effective_from ASC, id ASC"); + } + + private function resolveActiveVersions( + string $table, + array $identity, + string $timestamp, + string $order_by, + ?int $limit = null + ): array { + $timestamp = $this->normalizeDatetime($timestamp); + $where = $this->buildWhereClause($identity); + $where .= " AND effective_from <= '" . $this->escape($timestamp) . "'"; + $where .= " AND (effective_to IS NULL OR effective_to >= '" . $this->escape($timestamp) . "')"; + $sql = "SELECT * FROM $table WHERE $where ORDER BY $order_by"; + if ($limit !== null) { + $sql .= " LIMIT " . ((int)$limit); + } + return $this->fetchAll($sql); + } + + private function buildWhereClause(array $identity): string + { + $parts = []; + foreach ($identity as $k => $v) { + if ($v === null) { + $parts[] = "$k IS NULL"; + continue; + } + if (is_bool($v)) { + $parts[] = "$k = " . ((int)$v); + continue; + } + if (is_int($v) || is_float($v)) { + $parts[] = "$k = $v"; + continue; + } + $parts[] = "$k = '" . $this->escape((string)$v) . "'"; + } + return implode(' AND ', $parts); + } + + private function buildSetFragment(string $key, mixed $value): string + { + return "$key = " . $this->buildValueFragment($value); + } + + private function buildValueFragment(mixed $value): string + { + if ($value === null) { + return 'NULL'; + } + if (is_bool($value)) { + return (string)((int)$value); + } + if (is_int($value) || is_float($value)) { + return (string)$value; + } + return "'" . $this->escape((string)$value) . "'"; + } + + private function normalizeDatetime(?string $value): string + { + if ($value === null || trim($value) === '') { + return date('Y-m-d H:i:s'); + } + $dt = new DateTime($value); + return $dt->format('Y-m-d H:i:s'); + } + + private function minusOneSecond(string $datetime): string + { + $dt = new DateTime($datetime); + $dt->modify('-1 second'); + return $dt->format('Y-m-d H:i:s'); + } + + private function normalizeConfidence(float $confidence): float + { + if ($confidence < 0) { + return 0.0; + } + if ($confidence > 1) { + return 1.0; + } + return round($confidence, 5); + } + + private function escape(string $value): string + { + global $db; + return $db->escape_string($value); + } + + private function fetchAll(string $sql): array + { + global $db; + $result = $db->query($sql); + if (!$result) { + return []; + } + return $db->fetch_all($result); + } + + private function fetchOne(string $sql): ?array + { + $rows = $this->fetchAll($sql); + if (empty($rows)) { + return null; + } + return $rows[0]; + } + + private function incrementReportAction(array &$bucket, string $action): void + { + if (!isset($bucket[$action])) { + $bucket[$action] = 0; + } + $bucket[$action]++; + } +} + diff --git a/services/nginx/app/cli.php b/services/nginx/app/cli.php index 4388e638..4f9b5a4d 100644 --- a/services/nginx/app/cli.php +++ b/services/nginx/app/cli.php @@ -66,6 +66,9 @@ if ($args[1] === 'run') { case 'clearAllUsersEconomicCustomerDetails': require_once 'cron/ClearAllUsersEconomicCustomerDetails.php'; break; + case 'economic-v2-backfill': + require_once 'cron/BackfillEconomicV2History.php'; + break; case 'economicOrderParser-test': echo "Running the economicOrderParser test script"; require_once 'tests/economicOrderParser/EconomicOrderParserTest.php'; @@ -96,4 +99,4 @@ if ($args[1] === 'run') { echo "[" . date('Y-m-d H:i:s') . "][CRON] Finished running the script\n"; } else { echo "Invalid action"; -} \ No newline at end of file +} diff --git a/services/nginx/app/cron/BackfillEconomicV2History.php b/services/nginx/app/cron/BackfillEconomicV2History.php new file mode 100644 index 00000000..5ae353cb --- /dev/null +++ b/services/nginx/app/cron/BackfillEconomicV2History.php @@ -0,0 +1,31 @@ +runBestEffortBackfill(); + echo json_encode( + [ + 'success' => true, + 'report' => $report, + ], + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT + ) . PHP_EOL; +} catch (\Throwable $e) { + echo json_encode( + [ + 'success' => false, + 'error' => $e->getMessage(), + ], + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT + ) . PHP_EOL; + throw $e; +} + +echo '[' . date('Y-m-d H:i:s') . '][ECONOMIC_V2] Finished best-effort history backfill' . PHP_EOL; diff --git a/services/nginx/app/modules/economic/customers/economicCustomers.php b/services/nginx/app/modules/economic/customers/economicCustomers.php index c26d160c..0ec805ee 100644 --- a/services/nginx/app/modules/economic/customers/economicCustomers.php +++ b/services/nginx/app/modules/economic/customers/economicCustomers.php @@ -98,10 +98,11 @@ class economicCustomers extends economic_m * @param int $page * @param int $limit * @param string|null $search + * @param mixed $barred_filter Supports true/false values (bool, 1/0, true/false, barred/active) * @return object The list of customers * @throws Exception */ - public function listCustomers(int $page, int $limit, string|null $search = null): object + public function listCustomers(int $page, int $limit, string|null $search = null, mixed $barred_filter = null): object { // Normalize pagination parameters $page = max(1, $page); // Ensure it's at least 1 @@ -116,6 +117,8 @@ class economicCustomers extends economic_m 'city', 'country', 'email', 'telephoneAndFaxNumber', 'website', 'mobilePhone', 'corporateIdentificationNumber' ]; + $filter_parts = []; + // If a search term is present, build the filter expressions if (!empty($search)) { // Escape special characters in the search string @@ -131,11 +134,21 @@ class economicCustomers extends economic_m $filters[] = $property . '$like:' . $escapedSearch; } - // Join the filters with `$or:` - $filterString = implode('$or:', $filters); + // Join the filters with `$or:` and keep grouping explicit for later $and composition + $filter_parts[] = '(' . implode('$or:', $filters) . ')'; + } - // URL encode and append the filter string - $url .= '&filter=' . urlencode($filterString); + // Optional barred filter support (all | true/barred | false/active) + $normalized_barred_filter = $this->normalizeBarredFilter($barred_filter); + if ($normalized_barred_filter !== null) { + $filter_parts[] = 'barred$eq:' . ($normalized_barred_filter ? 'true' : 'false'); + } + + if (!empty($filter_parts)) { + $filter_string = count($filter_parts) === 1 + ? $filter_parts[0] + : '(' . implode('$and:', $filter_parts) . ')'; + $url .= '&filter=' . urlencode($filter_string); } // Send the GET request to the API endpoint @@ -149,5 +162,24 @@ class economicCustomers extends economic_m return $responseObject; } + private function normalizeBarredFilter(mixed $value): ?bool + { + if ($value === null || $value === '') { + return null; + } + if (is_bool($value)) { + return $value; + } + if (is_int($value)) { + return $value === 1 ? true : ($value === 0 ? false : null); + } + $parsed = strtolower(trim((string)$value)); + return match ($parsed) { + '1', 'true', 'yes', 'barred', 'only_barred' => true, + '0', 'false', 'no', 'active', 'not_barred' => false, + default => null, + }; + } -} \ No newline at end of file + +} diff --git a/services/nginx/app/routes/InvoicingPeriodRoute.php b/services/nginx/app/routes/InvoicingPeriodRoute.php index 6bee3a96..32a76af1 100644 --- a/services/nginx/app/routes/InvoicingPeriodRoute.php +++ b/services/nginx/app/routes/InvoicingPeriodRoute.php @@ -3,6 +3,8 @@ namespace routes; use classes\authentication; +use classes\economic_v2_distribution_service; +use classes\economic_v2_versioning_service; use classes\invoicing_period_utils; use classes\slack; use Exception; @@ -205,6 +207,135 @@ class InvoicingPeriodRoute ] ); + $this->get('/superuser/invoicing/period/distribution/v2/all', function () { + global $response; + $this->requirePermission('superuser_invoicing_period_distribution_v2'); + $dateRange = $this->requireAndNormalizeDateRange(); + $dateFrom = $dateRange['dateFrom']; + $dateTo = $dateRange['dateTo']; + $response->add_meta('date_from', $dateFrom); + $response->add_meta('date_to', $dateTo); + + $service = new economic_v2_distribution_service(); + $response->success($service->getAllDistributions($dateFrom, $dateTo)); + }, + [ + 'superuser_invoicing_period_distribution_v2' => 'Get historical version-aware departmental distribution (all categories).', + ] + ); + + $this->get('/superuser/invoicing/period/distribution/v2/fixed-pricing', function () { + global $response; + $this->requirePermission('superuser_invoicing_period_distribution_v2'); + $dateRange = $this->requireAndNormalizeDateRange(); + $dateFrom = $dateRange['dateFrom']; + $dateTo = $dateRange['dateTo']; + $response->add_meta('date_from', $dateFrom); + $response->add_meta('date_to', $dateTo); + + $service = new economic_v2_distribution_service(); + $response->success($service->getFixedPricingDistribution($dateFrom, $dateTo)); + }, + [ + 'superuser_invoicing_period_distribution_v2' => 'Get historical version-aware fixed pricing distribution.', + ] + ); + + $this->get('/superuser/invoicing/period/distribution/v2/wash-subscriptions', function () { + global $response; + $this->requirePermission('superuser_invoicing_period_distribution_v2'); + $dateRange = $this->requireAndNormalizeDateRange(); + $dateFrom = $dateRange['dateFrom']; + $dateTo = $dateRange['dateTo']; + $response->add_meta('date_from', $dateFrom); + $response->add_meta('date_to', $dateTo); + + $service = new economic_v2_distribution_service(); + $response->success($service->getWashSubscriptionsDistribution($dateFrom, $dateTo)); + }, + [ + 'superuser_invoicing_period_distribution_v2' => 'Get historical version-aware wash subscription distribution.', + ] + ); + + $this->get('/superuser/invoicing/period/distribution/v2/customer-prices', function () { + global $response; + $this->requirePermission('superuser_invoicing_period_distribution_v2'); + $dateRange = $this->requireAndNormalizeDateRange(); + $dateFrom = $dateRange['dateFrom']; + $dateTo = $dateRange['dateTo']; + $response->add_meta('date_from', $dateFrom); + $response->add_meta('date_to', $dateTo); + + $service = new economic_v2_distribution_service(); + $response->success($service->getCustomerPricesDistribution($dateFrom, $dateTo)); + }, + [ + 'superuser_invoicing_period_distribution_v2' => 'Get historical version-aware customer discount distribution.', + ] + ); + + $this->get('/superuser/customers/pricing-history', function () { + global $response; + $this->requirePermission('superuser_customer_pricing_history_v2'); + self::requireParameters(['customer_number']); + self::requireType((int)self::getParameter('customer_number'), self::type_int()); + $customer_number = (int)self::getParameter('customer_number'); + self::requireMinValue($customer_number, 1); + self::requireMaxValue($customer_number, 999999999); + + $dateRange = $this->requireAndNormalizeDateRange(); + $dateFrom = $dateRange['dateFrom']; + $dateTo = $dateRange['dateTo']; + + $versioning = new economic_v2_versioning_service(); + $fixed_pricing = $versioning->listFixedPricingVersions($customer_number, $dateFrom, $dateTo); + $vehicle_subscriptions = $versioning->listVehicleSubscriptionVersions($customer_number, $dateFrom, $dateTo); + $discount_overrides = $versioning->listDiscountOverrideVersions($customer_number, $dateFrom, $dateTo); + + $timeline = []; + foreach ($fixed_pricing as $row) { + $timeline[] = [ + 'type' => 'fixed_pricing', + ...$row, + ]; + } + foreach ($vehicle_subscriptions as $row) { + $timeline[] = [ + 'type' => 'vehicle_subscription', + ...$row, + ]; + } + foreach ($discount_overrides as $row) { + $timeline[] = [ + 'type' => 'discount_override', + ...$row, + ]; + } + usort($timeline, static function ($a, $b) { + $left = strtotime((string)($a['effective_from'] ?? '1970-01-01 00:00:00')); + $right = strtotime((string)($b['effective_from'] ?? '1970-01-01 00:00:00')); + if ($left === $right) { + return ((int)($a['id'] ?? 0)) <=> ((int)($b['id'] ?? 0)); + } + return $left <=> $right; + }); + + $response->add_meta('date_from', $dateFrom); + $response->add_meta('date_to', $dateTo); + $response->success([ + 'customer_number' => $customer_number, + 'fixed_pricing' => $fixed_pricing, + 'vehicle_subscriptions' => $vehicle_subscriptions, + 'discount_overrides' => $discount_overrides, + 'timeline' => $timeline, + ]); + }, + [ + 'superuser_customer_pricing_history_v2' => 'Get customer pricing/subscription/discount timeline with confidence and provenance.', + ] + ); + $this->get('/superuser/invoicing/period/distribution/wash-subscriptions/historical', function () { // Require the user to be logged in global $response; diff --git a/services/nginx/app/routes/customerFixedPricingRoute.php b/services/nginx/app/routes/customerFixedPricingRoute.php index 5d6af848..7005c895 100644 --- a/services/nginx/app/routes/customerFixedPricingRoute.php +++ b/services/nginx/app/routes/customerFixedPricingRoute.php @@ -3,6 +3,7 @@ namespace routes; use classes\authentication; +use classes\economic_v2_versioning_service; use objects\customer_fixed_pricing_o; use objects\logs_o; use objects\users_o; @@ -98,6 +99,31 @@ class customerFixedPricingRoute } // Add the fixed price $customer_fixed_pricing_o->add((int)$customer_number, (int)$price, (string)$description); + try { + (new economic_v2_versioning_service())->recordFixedPricingVersion( + (int)$customer_number, + (int)$price, + (string)$description, + date('Y-m-d H:i:s'), + 'live.fixed_pricing.route', + 1.0, + false, + [ + 'route' => '/customer/pricing/fixed', + 'method' => 'POST', + 'actor_user_id' => (int)$user->id, + ] + ); + } catch (\Throwable $e) { + (new logs_o())->add( + 'customer_fixed_pricing', + 'global', + 0, + (int)$user->id, + 'CUSTOMER_ADD_FIXED_PRICING_VERSIONING_FAILED', + $e->getMessage() + ); + } // Log the action (new logs_o())->add('customer_fixed_pricing', 'global', 0, $user->id, 'CUSTOMER_ADD_FIXED_PRICING', 'Fixed price added'); // Return success @@ -136,6 +162,29 @@ class customerFixedPricingRoute $fixed_pricing_object = $customer_fixed_pricing_o->selectByCustomerNumber((int)$customer_number); // Delete the fixed price $fixed_pricing_object->delete(); + try { + (new economic_v2_versioning_service())->closeActiveFixedPricingVersion( + (int)$customer_number, + date('Y-m-d H:i:s'), + 'live.fixed_pricing.route', + 1.0, + false, + [ + 'route' => '/customer/pricing/fixed', + 'method' => 'DELETE', + 'actor_user_id' => (int)$user->id, + ] + ); + } catch (\Throwable $e) { + (new logs_o())->add( + 'customer_fixed_pricing', + 'global', + 0, + (int)$user->id, + 'CUSTOMER_DELETE_FIXED_PRICING_VERSIONING_FAILED', + $e->getMessage() + ); + } // Log the action (new logs_o())->add('customer_fixed_pricing', 'global', 0, $user->id, 'CUSTOMER_DELETE_FIXED_PRICING', 'Fixed price deleted'); // Return success @@ -146,4 +195,4 @@ class customerFixedPricingRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/customerSearchRoute.php b/services/nginx/app/routes/customerSearchRoute.php index dd6b7263..ba7c9271 100644 --- a/services/nginx/app/routes/customerSearchRoute.php +++ b/services/nginx/app/routes/customerSearchRoute.php @@ -66,7 +66,7 @@ class customerSearchRoute $page = self::fromRequest('page') ?? 1; $limit = self::fromRequest('limit') ?? 100; $search = self::fromRequest('search') ?? null; - $filter = self::fromRequest('filter') ?? null; + $barred = self::fromRequest('barred') ?? null; // Log the incident (new logs_o())->add('customers', 'global', 1, $user->id, 'LIST_CUSTOMERS', 'Successfully listed customers'); // Create the economic customers object @@ -75,7 +75,7 @@ class customerSearchRoute (int)$page, (int)$limit, $search, - $filter + $barred ); // Parse the pagination meta from E-conomic to the standard format used in this application $response->paginate( @@ -83,7 +83,7 @@ class customerSearchRoute $limit, $result->pagination->results, $search, - $filter + ['barred' => $barred] ); // Create the users object $users_o = new users_o(); @@ -120,4 +120,4 @@ class customerSearchRoute } return $result; } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/orderInvoicesRoute.php b/services/nginx/app/routes/orderInvoicesRoute.php index 25e90065..e2c1c282 100644 --- a/services/nginx/app/routes/orderInvoicesRoute.php +++ b/services/nginx/app/routes/orderInvoicesRoute.php @@ -4,6 +4,9 @@ namespace routes; use classes\authentication; use classes\economic; +use classes\economic_v2_compare_engine; +use classes\economic_v2_line_normalizer; +use classes\economic_v2_revenue_statistics_service; use classes\response; use classes\router; use Exception; @@ -227,6 +230,158 @@ class orderInvoicesRoute ] ); + /** Collected order invoices > E-conomic V2 details > GET */ + $this->get('/collected-invoices/economic/v2/details', function () { + global $response; + self::requirePermission('view_collected_invoice_economic_v2_details'); + $collected_invoice_id = $this->requireCollectedInvoiceId(); + + $payload = $this->buildEconomicV2DetailsPayload($collected_invoice_id); + $response->success($payload); + }, + [ + 'view_collected_invoice_economic_v2_details' => 'View normalized internal/draft/booked e-conomic invoice details (V2).', + ] + ); + + /** Collected order invoices > E-conomic V2 compare > GET */ + $this->get('/collected-invoices/economic/v2/compare', function () { + global $response; + self::requirePermission('compare_collected_invoice_economic_v2'); + $collected_invoice_id = $this->requireCollectedInvoiceId(); + + $details = $this->buildEconomicV2DetailsPayload($collected_invoice_id); + $comparison = economic_v2_compare_engine::compare( + $details['internal']['normalized'], + $details['draft']['exists'] ? $details['draft']['normalized'] : null, + $details['booked']['exists'] ? $details['booked']['normalized'] : null + ); + + $response->success([ + 'collected_invoice_id' => $collected_invoice_id, + 'details' => $details, + 'comparison' => $comparison, + 'warnings' => array_values(array_unique(array_merge( + (array)($details['warnings'] ?? []), + (array)($comparison['warnings'] ?? []) + ))), + ]); + }, + [ + 'compare_collected_invoice_economic_v2' => 'Compare normalized internal invoice with draft/booked e-conomic targets (V2).', + ] + ); + + /** Collected order invoices > E-conomic V2 compare bulk > POST */ + $this->post('/collected-invoices/economic/v2/compare/bulk', function () { + global $response; + self::requirePermission('compare_collected_invoice_economic_v2_bulk'); + self::requireParameters(['collected_invoice_ids']); + + $collected_invoice_ids = self::getParameter('collected_invoice_ids'); + if (!is_array($collected_invoice_ids)) { + $response->error('collected_invoice_ids must be an array', 400); + } + + $normalized_ids = array_values(array_unique(array_filter(array_map(static function ($id) { + return (int)$id; + }, $collected_invoice_ids), static function ($id) { + return $id > 0; + }))); + + if (empty($normalized_ids)) { + $response->error('collected_invoice_ids must contain at least one positive integer', 400); + } + + if (count($normalized_ids) > 200) { + $response->error('Maximum 200 collected_invoice_ids per bulk compare request', 400); + } + + $results = []; + $errors = []; + + foreach ($normalized_ids as $collected_invoice_id) { + try { + $details = $this->buildEconomicV2DetailsPayload((int)$collected_invoice_id); + $comparison = economic_v2_compare_engine::compare( + $details['internal']['normalized'], + $details['draft']['exists'] ? $details['draft']['normalized'] : null, + $details['booked']['exists'] ? $details['booked']['normalized'] : null + ); + + $results[] = [ + 'collected_invoice_id' => (int)$collected_invoice_id, + 'details' => $details, + 'comparison' => $comparison, + 'warnings' => array_values(array_unique(array_merge( + (array)($details['warnings'] ?? []), + (array)($comparison['warnings'] ?? []) + ))), + ]; + } catch (Exception $e) { + $errors[] = [ + 'collected_invoice_id' => (int)$collected_invoice_id, + 'error' => $e->getMessage(), + ]; + } + } + + $response->success([ + 'requested' => count($normalized_ids), + 'compared' => count($results), + 'failed' => count($errors), + 'results' => $results, + 'errors' => $errors, + ]); + }, + [ + 'compare_collected_invoice_economic_v2_bulk' => 'Compare multiple collected invoices against draft/booked e-conomic targets (V2).', + ] + ); + + /** Collected order invoices > E-conomic V2 revenue statistics > GET */ + $this->get('/collected-invoices/economic/v2/revenue-statistics', function () { + global $response; + self::requirePermission('view_collected_invoice_economic_v2_revenue_statistics'); + + $dateFrom = (string)(self::fromRequest('dateFrom') ?? date('Y-m-01')); + $dateTo = (string)(self::fromRequest('dateTo') ?? date('Y-m-d')); + self::requireDateFormat($dateFrom, self::FORMAT_DATE()); + self::requireDateFormat($dateTo, self::FORMAT_DATE()); + if (strtotime($dateFrom) > strtotime($dateTo)) { + $response->error('dateFrom must be before or equal to dateTo', 400); + } + + $barred = strtolower(trim((string)(self::fromRequest('barred') ?? 'all'))); + self::requireInArray($barred, ['all', 'barred', 'active']); + + $currency = self::fromRequest('currency'); + $currency = ($currency !== null && trim($currency) !== '') ? strtoupper(trim($currency)) : null; + if ($currency !== null && !preg_match('/^[A-Z]{3}$/', $currency)) { + $response->error('currency must be a 3-letter ISO code (e.g. DKK)', 400); + } + + $max_pages = (int)(self::fromRequest('max_pages') ?? 10); + self::requireMinValue($max_pages, 1); + self::requireMaxValue($max_pages, 200); + + $payload = (new economic_v2_revenue_statistics_service())->getBookedRevenueStatistics([ + 'dateFrom' => $dateFrom, + 'dateTo' => $dateTo, + 'customer_numbers' => $this->parseIntegerListParameter('customer_numbers'), + 'department_numbers' => $this->parseIntegerListParameter('department_numbers'), + 'currency' => $currency, + 'barred' => $barred, + 'max_pages' => $max_pages, + ]); + + $response->success($payload); + }, + [ + 'view_collected_invoice_economic_v2_revenue_statistics' => 'View aggregated booked revenue statistics from e-conomic (V2), including barred-customer filtering.', + ] + ); + /** Collected order invoices > Ready to invoice > GET */ $this->get('/collected-invoices/ready-to-invoice', function () { global $response; @@ -1115,6 +1270,197 @@ class orderInvoicesRoute ); } + private function requireCollectedInvoiceId(): int + { + self::requireParameters(['collected_invoice_id']); + self::requireType((int)self::getParameter('collected_invoice_id'), self::type_int()); + $collected_invoice_id = (int)self::getParameter('collected_invoice_id'); + self::requireMinValue($collected_invoice_id, 1); + self::requireMaxValue($collected_invoice_id, 999999999); + return $collected_invoice_id; + } + + /** + * Build normalized V2 details for a collected invoice and available e-conomic targets. + * @throws Exception + */ + private function buildEconomicV2DetailsPayload(int $collected_invoice_id): array + { + $warnings = []; + $invoice = (new collected_order_invoices_o())->select($collected_invoice_id); + $invoice->requireSelected(); + + $draft_id = null; + $booked_id = null; + $draft_raw = null; + $booked_raw = null; + $customer = [ + 'internal_customer_number' => (int)$invoice->customer_number->value() > 0 ? (int)$invoice->customer_number->value() : null, + 'draft_customer_number' => null, + 'booked_customer_number' => null, + 'exists' => false, + 'name' => null, + 'barred' => null, + ]; + + $economic = new economic(); + + try { + $draft_id = $invoice->getInvoiceDraftId(); + } catch (Exception $e) { + $warnings[] = 'Draft id unavailable: ' . $e->getMessage(); + } + + try { + $booked_id = $invoice->getInvoiceBookedId(); + } catch (Exception $e) { + $warnings[] = 'Booked id unavailable: ' . $e->getMessage(); + } + + if ($draft_id !== null) { + try { + $draft_raw = $economic->invoices->draft->get((int)$draft_id); + } catch (Exception $e) { + $warnings[] = 'Unable to fetch draft invoice ' . (int)$draft_id . ': ' . $e->getMessage(); + } + } + + if ($booked_id !== null) { + try { + $booked_raw = $economic->invoices->booked->getFromId((int)$booked_id); + } catch (Exception $e) { + $warnings[] = 'Unable to fetch booked invoice ' . (int)$booked_id . ': ' . $e->getMessage(); + } + } + + $customer['draft_customer_number'] = $this->extractEconomicCustomerNumber($draft_raw); + $customer['booked_customer_number'] = $this->extractEconomicCustomerNumber($booked_raw); + if ( + $customer['internal_customer_number'] !== null && + $customer['draft_customer_number'] !== null && + (int)$customer['internal_customer_number'] !== (int)$customer['draft_customer_number'] + ) { + $warnings[] = 'Draft invoice customer number mismatch: internal=' . (int)$customer['internal_customer_number'] . ', draft=' . (int)$customer['draft_customer_number']; + } + if ( + $customer['internal_customer_number'] !== null && + $customer['booked_customer_number'] !== null && + (int)$customer['internal_customer_number'] !== (int)$customer['booked_customer_number'] + ) { + $warnings[] = 'Booked invoice customer number mismatch: internal=' . (int)$customer['internal_customer_number'] . ', booked=' . (int)$customer['booked_customer_number']; + } + if ($customer['internal_customer_number'] !== null) { + try { + $economic_customer_raw = $economic->customers->customers->get((int)$customer['internal_customer_number']); + if (isset($economic_customer_raw->customerNumber)) { + $customer['exists'] = true; + $customer['name'] = isset($economic_customer_raw->name) ? (string)$economic_customer_raw->name : null; + $customer['barred'] = isset($economic_customer_raw->barred) ? (bool)$economic_customer_raw->barred : null; + if ($customer['barred'] === true) { + $warnings[] = 'The e-conomic customer is barred.'; + } + } else { + $warnings[] = 'Unable to resolve e-conomic customer ' . (int)$customer['internal_customer_number'] . '.'; + } + } catch (\Throwable $e) { + $warnings[] = 'Failed to fetch e-conomic customer ' . (int)$customer['internal_customer_number'] . ': ' . $e->getMessage(); + } + } + + $internal_normalized = economic_v2_line_normalizer::normalizeInternalCollectedInvoice($invoice); + $draft_normalized = $draft_raw !== null + ? economic_v2_line_normalizer::normalizeDraftInvoice($draft_raw) + : null; + $booked_normalized = $booked_raw !== null + ? economic_v2_line_normalizer::normalizeBookedInvoice($booked_raw) + : null; + + return [ + 'collected_invoice_id' => $collected_invoice_id, + 'external_id' => (string)$invoice->external_id->value(), + 'order_ids' => array_values(array_map(static function ($row) { + return (int)($row['id'] ?? 0); + }, $invoice->getOrderIds())), + 'economic' => [ + 'draft_id' => $draft_id !== null ? (int)$draft_id : null, + 'booked_id' => $booked_id !== null ? (int)$booked_id : null, + ], + 'customer' => $customer, + 'internal' => [ + 'normalized' => $internal_normalized, + ], + 'draft' => [ + 'exists' => $draft_raw !== null, + 'raw' => $this->toPlainArray($draft_raw), + 'normalized' => $draft_normalized, + ], + 'booked' => [ + 'exists' => $booked_raw !== null, + 'raw' => $this->toPlainArray($booked_raw), + 'normalized' => $booked_normalized, + ], + 'warnings' => array_values(array_unique(array_merge( + $warnings, + (array)($internal_normalized['warnings'] ?? []), + (array)($draft_normalized['warnings'] ?? []), + (array)($booked_normalized['warnings'] ?? []) + ))), + ]; + } + + private function extractEconomicCustomerNumber(mixed $invoice_raw): ?int + { + if ($invoice_raw === null) { + return null; + } + $data = is_array($invoice_raw) ? $invoice_raw : $this->toPlainArray($invoice_raw); + $value = $data['customer']['customerNumber'] + ?? $data['customer']['customer_number'] + ?? $data['customerNumber'] + ?? $data['customer_number'] + ?? null; + if ($value === null) { + return null; + } + $customer_number = (int)$value; + return $customer_number > 0 ? $customer_number : null; + } + + private function parseIntegerListParameter(string $parameter): array + { + if (!self::isParametersSet([$parameter])) { + return []; + } + + $raw = self::getParameter($parameter); + $values = []; + if (is_array($raw)) { + $values = $raw; + } elseif (is_string($raw)) { + $values = explode(',', $raw); + } elseif (is_numeric($raw)) { + $values = [$raw]; + } + + $normalized = []; + foreach ($values as $value) { + $int_value = (int)$value; + if ($int_value > 0) { + $normalized[$int_value] = true; + } + } + + return array_values(array_map('intval', array_keys($normalized))); + } + + private function toPlainArray(mixed $value): mixed + { + if ($value === null || is_scalar($value)) { + return $value; + } + return json_decode(json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), true); + } + /** * @param $collected_order_invoice * @param users_o $users @@ -1139,4 +1485,4 @@ class orderInvoicesRoute 'total_net_amount' => (float)$tmp_collected_order_invoices->getTotalAmount(), ]; } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/userRoute.php b/services/nginx/app/routes/userRoute.php index a447c849..6d3e2b68 100644 --- a/services/nginx/app/routes/userRoute.php +++ b/services/nginx/app/routes/userRoute.php @@ -3,6 +3,7 @@ namespace routes; use classes\authentication; +use classes\economic_v2_versioning_service; use classes\response; use objects\logs_o; use objects\users_o; @@ -129,6 +130,33 @@ class userRoute } // Set the custom price $targetUser->setCustomPrice($targetUser->id, $object_id, $discount, $is_category); + try { + (new economic_v2_versioning_service())->recordDiscountOverrideVersion( + (int)$targetUser->id, + (int)$targetUser->customer_number->value(), + (bool)$is_category, + (string)$object_id, + (int)$discount, + date('Y-m-d H:i:s'), + 'live.discount_override.route', + 1.0, + false, + [ + 'route' => '/superuser/user/discounts', + 'method' => 'POST', + 'actor_user_id' => (int)$user->id, + ] + ); + } catch (\Throwable $e) { + (new logs_o())->add( + 'users', + 'global', + 0, + (int)$user->id, + 'SET_CUSTOM_PRICE_VERSIONING_FAILED', + $e->getMessage() + ); + } // Log the incident (new logs_o())->add('users', 'global', 1, $user->id, 'SET_CUSTOM_PRICE', 'Successfully set custom price'); // Return a success message @@ -368,4 +396,4 @@ class userRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/vehiclesRoute.php b/services/nginx/app/routes/vehiclesRoute.php index 8c31730e..54245607 100644 --- a/services/nginx/app/routes/vehiclesRoute.php +++ b/services/nginx/app/routes/vehiclesRoute.php @@ -3,6 +3,7 @@ namespace routes; use classes\authentication; +use classes\economic_v2_versioning_service; use customers\economic_customer_mo; use objects\bookings_o; use objects\customer_vehicles_o; @@ -180,6 +181,28 @@ class vehiclesRoute $subscription ? 1 : 0, $reference ); + try { + (new economic_v2_versioning_service())->recordVehicleSubscriptionVersion( + [ + 'vehicle_id' => (int)$vehicle->id, + 'customer_number' => (int)$targetCustomer, + 'reg' => (string)$reg, + 'vehicle_type' => (int)$type, + 'wash_subscription' => (bool)$subscription, + ], + date('Y-m-d H:i:s'), + 'live.vehicle.route', + 1.0, + false, + [ + 'route' => '/vehicles', + 'method' => 'POST', + 'actor_user_id' => (int)($user->id ?? 0), + ] + ); + } catch (\Throwable $e) { + (new logs_o())->add('vehicles', 'global', 0, (int)($user->id ?? 0), 'ADD_VEHICLE_VERSIONING_FAILED', $e->getMessage()); + } (new logs_o())->add('vehicles', 'global', 1, (int)($user->id ?? 0), 'ADD_VEHICLE', 'Successfully added vehicle'); $response->success($vehicle->asArray()); @@ -204,6 +227,14 @@ class vehiclesRoute (new logs_o())->add('vehicles', 'global', 1, (int)($user->id ?? 0), 'EDIT_VEHICLE', 'Vehicle not found'); $response->error('Vehicle not found', 404); } + + $before_state = [ + 'vehicle_id' => (int)$vehicle->id, + 'customer_number' => (int)$vehicle->customer_id->value(), + 'reg' => (string)$vehicle->reg->value(), + 'vehicle_type' => (int)$vehicle->type->value(), + 'wash_subscription' => (bool)$vehicle->wash_subscription->value(), + ]; // Enforce access (own vs broader) self::allowOwnOrDepartmentAccess( $permission_own, @@ -213,6 +244,22 @@ class vehiclesRoute null, 'You are not allowed to edit vehicles from other users' ); + if (self::isParametersSet(['customer_id'])) { + $new_customer_number = (int)self::getParameter('customer_id'); + self::requireType($new_customer_number, self::type_int()); + self::requireMinValue($new_customer_number, 1); + self::requireMaxValue($new_customer_number, 9999999999); + // Require access for the destination customer context as well. + self::allowOwnOrDepartmentAccess( + $permission_own, + $permission_other, + $new_customer_number, + null, + null, + 'You are not allowed to move vehicles to this customer' + ); + $vehicle->customer_id->set($new_customer_number); + } // Check all the fields, and if they are set, validate and set them if (self::isParametersSet(['type'])) { $type = (int)self::getParameter('type'); @@ -225,7 +272,6 @@ class vehiclesRoute $vehicle->type->set(0); // Turn off the subscription $vehicle->wash_subscription->set(0); - return; } else { $products_o = new products_o(); $products_o->select((int)$type); @@ -280,6 +326,61 @@ class vehiclesRoute } } $vehicle->objectChanged(); + + $after_state = [ + 'vehicle_id' => (int)$vehicle->id, + 'customer_number' => (int)$vehicle->customer_id->value(), + 'reg' => (string)$vehicle->reg->value(), + 'vehicle_type' => (int)$vehicle->type->value(), + 'wash_subscription' => (bool)$vehicle->wash_subscription->value(), + ]; + + $version_relevant_change = ( + (int)$before_state['customer_number'] !== (int)$after_state['customer_number'] || + (string)$before_state['reg'] !== (string)$after_state['reg'] || + (int)$before_state['vehicle_type'] !== (int)$after_state['vehicle_type'] || + (bool)$before_state['wash_subscription'] !== (bool)$after_state['wash_subscription'] + ); + if ($version_relevant_change) { + try { + $versioning = new economic_v2_versioning_service(); + $effective_at = date('Y-m-d H:i:s'); + $identity_changed = ( + (int)$before_state['customer_number'] !== (int)$after_state['customer_number'] || + (string)$before_state['reg'] !== (string)$after_state['reg'] + ); + if ($identity_changed) { + $versioning->closeActiveVehicleSubscriptionVersion( + (int)$before_state['customer_number'], + (string)$before_state['reg'], + $effective_at, + 'live.vehicle.route', + 1.0, + false, + [ + 'route' => '/vehicles', + 'method' => 'PUT', + 'actor_user_id' => (int)($user->id ?? 0), + 'reason' => 'identity_change', + ] + ); + } + $versioning->recordVehicleSubscriptionVersion( + $after_state, + $effective_at, + 'live.vehicle.route', + 1.0, + false, + [ + 'route' => '/vehicles', + 'method' => 'PUT', + 'actor_user_id' => (int)($user->id ?? 0), + ] + ); + } catch (\Throwable $e) { + (new logs_o())->add('vehicles', 'global', 0, (int)($user->id ?? 0), 'EDIT_VEHICLE_VERSIONING_FAILED', $e->getMessage()); + } + } // Return the vehicle $response->success($vehicle->asArray()); }, @@ -303,6 +404,11 @@ class vehiclesRoute (new logs_o())->add('vehicles', 'global', 1, (int)($user->id ?? 0), 'DELETE_VEHICLE', 'Vehicle not found'); $response->error('Vehicle not found', 404); } + + $before_state = [ + 'customer_number' => (int)$vehicle->customer_id->value(), + 'reg' => (string)$vehicle->reg->value(), + ]; // Enforce access (own vs broader) self::allowOwnOrDepartmentAccess( $permission_own, @@ -314,6 +420,23 @@ class vehiclesRoute ); // Delete the vehicle $vehicle->delete(); + try { + (new economic_v2_versioning_service())->closeActiveVehicleSubscriptionVersion( + (int)$before_state['customer_number'], + (string)$before_state['reg'], + date('Y-m-d H:i:s'), + 'live.vehicle.route', + 1.0, + false, + [ + 'route' => '/vehicles', + 'method' => 'DELETE', + 'actor_user_id' => (int)($user->id ?? 0), + ] + ); + } catch (\Throwable $e) { + (new logs_o())->add('vehicles', 'global', 0, (int)($user->id ?? 0), 'DELETE_VEHICLE_VERSIONING_FAILED', $e->getMessage()); + } $response->success([ 'success' => true, 'message' => 'Vehicle deleted successfully' @@ -927,4 +1050,4 @@ class vehiclesRoute $response->error('Notes are too long, they must be less than 250 characters', 400); } } -} \ No newline at end of file +} diff --git a/services/nginx/app/tests/Integration/Invoicing/EconomicV2BackfillAndDistributionIntegrationTest.php b/services/nginx/app/tests/Integration/Invoicing/EconomicV2BackfillAndDistributionIntegrationTest.php new file mode 100644 index 00000000..082c15b5 --- /dev/null +++ b/services/nginx/app/tests/Integration/Invoicing/EconomicV2BackfillAndDistributionIntegrationTest.php @@ -0,0 +1,120 @@ +markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run DB integration tests.'); + } + + $host = getenv('CONFIG_DB_HOST') ?: null; + $user = getenv('CONFIG_DB_USER') ?: null; + $password = getenv('CONFIG_DB_PASSWORD') ?: ''; + $database = getenv('CONFIG_DB_DATABASE') ?: null; + if (!$host || !$user || !$database) { + test()->markTestSkipped('Missing DB env vars: CONFIG_DB_HOST/CONFIG_DB_USER/CONFIG_DB_DATABASE.'); + } + + app_require('classes/db.php'); + app_require('classes/economic_v2_schema_bootstrap.php'); + app_require('classes/economic_v2_versioning_service.php'); + app_require('classes/economic_v2_distribution_service.php'); + + $GLOBALS['response'] = new class { + public function internal_server_error(string $message): void + { + throw new RuntimeException($message); + } + }; + + $db = new \classes\db([ + 'host' => $host, + 'user' => $user, + 'password' => $password, + 'database' => $database, + ]); + $db->connect(); + $GLOBALS['db'] = $db; + return $db; + } +} + +it('runs best-effort backfill repeatedly without introducing duplicate same-start rows', function (): void { + if (getenv('RUN_BACKFILL_INTEGRATION_TESTS') !== '1') { + test()->markTestSkipped('Set RUN_BACKFILL_INTEGRATION_TESTS=1 to run backfill integration test.'); + } + + $db = economic_v2_integration_db(); + try { + $service = new economic_v2_versioning_service(); + $first = $service->runBestEffortBackfill(); + $second = $service->runBestEffortBackfill(); + + expect($first)->toHaveKey('fixed_pricing'); + expect($first)->toHaveKey('vehicle_subscriptions'); + expect($first)->toHaveKey('discount_overrides'); + expect($second)->toHaveKey('fixed_pricing'); + + $dupFixed = $db->fetch_assoc($db->query( + "SELECT COUNT(*) AS c + FROM ( + SELECT customer_number, effective_from, COUNT(*) AS cc + FROM customer_fixed_pricing_versions + WHERE source LIKE 'backfill.%' + GROUP BY customer_number, effective_from + HAVING cc > 1 + ) t" + )); + $dupVehicle = $db->fetch_assoc($db->query( + "SELECT COUNT(*) AS c + FROM ( + SELECT customer_number, reg, effective_from, COUNT(*) AS cc + FROM customer_vehicle_subscription_versions + WHERE source LIKE 'backfill.%' + GROUP BY customer_number, reg, effective_from + HAVING cc > 1 + ) t" + )); + $dupDiscount = $db->fetch_assoc($db->query( + "SELECT COUNT(*) AS c + FROM ( + SELECT user_id, customer_number, is_category, object_id, effective_from, COUNT(*) AS cc + FROM customer_discount_override_versions + WHERE source LIKE 'backfill.%' + GROUP BY user_id, customer_number, is_category, object_id, effective_from + HAVING cc > 1 + ) t" + )); + + expect((int)($dupFixed['c'] ?? 0))->toBe(0); + expect((int)($dupVehicle['c'] ?? 0))->toBe(0); + expect((int)($dupDiscount['c'] ?? 0))->toBe(0); + } finally { + $db->close(); + } +}); + +it('resolves version-aware distribution payload shapes over a real date range', function (): void { + $db = economic_v2_integration_db(); + try { + $service = new economic_v2_distribution_service(); + $dateFrom = date('Y-m-01'); + $dateTo = date('Y-m-d'); + + $fixed = $service->getFixedPricingDistribution($dateFrom, $dateTo); + $subscriptions = $service->getWashSubscriptionsDistribution($dateFrom, $dateTo); + $prices = $service->getCustomerPricesDistribution($dateFrom, $dateTo); + + expect($fixed)->toHaveKey('customers'); + expect($fixed)->toHaveKey('collective_results'); + expect($subscriptions)->toHaveKey('customers'); + expect($subscriptions)->toHaveKey('collective_results'); + expect($prices)->toHaveKey('customers'); + expect($prices)->toHaveKey('collective_results'); + } finally { + $db->close(); + } +}); diff --git a/services/nginx/app/tests/Integration/Invoicing/EconomicV2VersioningServiceIntegrationTest.php b/services/nginx/app/tests/Integration/Invoicing/EconomicV2VersioningServiceIntegrationTest.php new file mode 100644 index 00000000..34c09578 --- /dev/null +++ b/services/nginx/app/tests/Integration/Invoicing/EconomicV2VersioningServiceIntegrationTest.php @@ -0,0 +1,153 @@ +markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run DB integration tests.'); + } + + $host = getenv('CONFIG_DB_HOST') ?: null; + $user = getenv('CONFIG_DB_USER') ?: null; + $password = getenv('CONFIG_DB_PASSWORD') ?: ''; + $database = getenv('CONFIG_DB_DATABASE') ?: null; + if (!$host || !$user || !$database) { + test()->markTestSkipped('Missing DB env vars: CONFIG_DB_HOST/CONFIG_DB_USER/CONFIG_DB_DATABASE.'); + } + + app_require('classes/db.php'); + app_require('classes/economic_v2_schema_bootstrap.php'); + app_require('classes/economic_v2_versioning_service.php'); + + $GLOBALS['response'] = new class { + public function internal_server_error(string $message): void + { + throw new RuntimeException($message); + } + }; + + $db = new db([ + 'host' => $host, + 'user' => $user, + 'password' => $password, + 'database' => $database, + ]); + $db->connect(); + $GLOBALS['db'] = $db; + return $db; +} + +it('creates closes and rotates fixed pricing versions without overlap', function (): void { + $db = economic_v2_versioning_integration_db(); + $service = new economic_v2_versioning_service(); + $customer = 99000000 + random_int(1000, 9999); + + try { + $db->query("DELETE FROM customer_fixed_pricing_versions WHERE customer_number = $customer"); + + $first = $service->recordFixedPricingVersion($customer, 1000, 'Initial', '2026-01-01 00:00:00'); + $second = $service->recordFixedPricingVersion($customer, 1200, 'Updated', '2026-02-01 00:00:00'); + + expect($first['action'])->toBe('inserted'); + expect($second['action'])->toBe('inserted'); + + $rows = $db->fetch_all($db->query( + "SELECT id, effective_from, effective_to, price + FROM customer_fixed_pricing_versions + WHERE customer_number = $customer + ORDER BY effective_from ASC, id ASC" + )); + expect(count($rows))->toBe(2); + expect((string)$rows[0]['effective_to'])->toBe('2026-01-31 23:59:59'); + expect((int)$rows[1]['price'])->toBe(1200); + + $service->closeActiveFixedPricingVersion($customer, '2026-02-15 00:00:00'); + $row = $db->fetch_assoc($db->query( + "SELECT effective_to + FROM customer_fixed_pricing_versions + WHERE customer_number = $customer + ORDER BY effective_from DESC + LIMIT 1" + )); + expect((string)$row['effective_to'])->toBe('2026-02-15 00:00:00'); + } finally { + $db->query("DELETE FROM customer_fixed_pricing_versions WHERE customer_number = $customer"); + $db->close(); + } +}); + +it('tracks vehicle and discount version lifecycles with closure semantics', function (): void { + $db = economic_v2_versioning_integration_db(); + $service = new economic_v2_versioning_service(); + $customer = 99100000 + random_int(1000, 9999); + $userId = 700000 + random_int(1000, 9999); + $reg = 'ZZ' . random_int(1000, 9999); + + try { + $db->query("DELETE FROM customer_vehicle_subscription_versions WHERE customer_number = $customer AND reg = '" . $db->escape_string($reg) . "'"); + $db->query("DELETE FROM customer_discount_override_versions WHERE customer_number = $customer AND user_id = $userId"); + + $service->recordVehicleSubscriptionVersion([ + 'vehicle_id' => null, + 'customer_number' => $customer, + 'reg' => $reg, + 'vehicle_type' => 1, + 'wash_subscription' => true, + ], '2026-01-01 00:00:00'); + $service->recordVehicleSubscriptionVersion([ + 'vehicle_id' => null, + 'customer_number' => $customer, + 'reg' => $reg, + 'vehicle_type' => 33, + 'wash_subscription' => true, + ], '2026-01-10 00:00:00'); + $service->closeActiveVehicleSubscriptionVersion($customer, $reg, '2026-01-20 00:00:00'); + + $vehicleRows = $db->fetch_all($db->query( + "SELECT vehicle_type, effective_from, effective_to + FROM customer_vehicle_subscription_versions + WHERE customer_number = $customer + AND reg = '" . $db->escape_string($reg) . "' + ORDER BY effective_from ASC" + )); + expect(count($vehicleRows))->toBe(2); + expect((string)$vehicleRows[0]['effective_to'])->toBe('2026-01-09 23:59:59'); + expect((string)$vehicleRows[1]['effective_to'])->toBe('2026-01-20 00:00:00'); + + $service->recordDiscountOverrideVersion( + $userId, + $customer, + false, + 33, + 25, + '2026-01-01 00:00:00' + ); + $service->recordDiscountOverrideVersion( + $userId, + $customer, + false, + 33, + 0, + '2026-01-15 00:00:00' + ); + + $discountRows = $db->fetch_all($db->query( + "SELECT discount, effective_from, effective_to + FROM customer_discount_override_versions + WHERE customer_number = $customer + AND user_id = $userId + AND is_category = 0 + AND object_id = '33' + ORDER BY effective_from ASC" + )); + expect(count($discountRows))->toBe(1); + expect((int)$discountRows[0]['discount'])->toBe(25); + expect((string)$discountRows[0]['effective_to'])->toBe('2026-01-15 00:00:00'); + } finally { + $db->query("DELETE FROM customer_vehicle_subscription_versions WHERE customer_number = $customer"); + $db->query("DELETE FROM customer_discount_override_versions WHERE customer_number = $customer AND user_id = $userId"); + $db->close(); + } +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2CliBackfillCommandTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2CliBackfillCommandTest.php new file mode 100644 index 00000000..a1031acf --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2CliBackfillCommandTest.php @@ -0,0 +1,19 @@ +not->toBeFalse(); + expect($content)->toContain("case 'economic-v2-backfill':"); + expect($content)->toContain("require_once 'cron/BackfillEconomicV2History.php';"); +}); + +it('provides a backfill cron script entrypoint', function (): void { + $script = app_path('cron/BackfillEconomicV2History.php'); + expect(is_file($script))->toBeTrue(); + + $content = file_get_contents($script); + expect($content)->not->toBeFalse(); + expect($content)->toContain('runBestEffortBackfill('); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2CompareEngineTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2CompareEngineTest.php new file mode 100644 index 00000000..85af1b4c --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2CompareEngineTest.php @@ -0,0 +1,135 @@ + 'internal', + 'totals' => [ + 'net_total' => 100.0, + 'line_net_total' => 100.0, + 'line_count' => 1, + 'billable_line_count' => 1, + ], + 'departments' => [ + '75' => 100.0, + ], + 'lines' => [ + [ + 'source' => 'internal', + 'source_line_id' => 1, + 'line_type' => 'product', + 'billable' => true, + 'product_number' => '1', + 'description' => 'Trakker', + 'reference' => '', + 'quantity' => 1.0, + 'unit_net_price' => 100.0, + 'line_net_amount' => 100.0, + 'department_distribution' => ['75' => 100.0], + 'match_key' => 'product:1|ref:', + ], + ], + 'warnings' => [], + ]; + + return array_replace_recursive($base, $overrides); +} + +it('returns exact_match when totals lines and departments are identical', function (): void { + $internal = economic_v2_test_invoice(); + $draft = economic_v2_test_invoice(['source' => 'draft']); + $booked = economic_v2_test_invoice(['source' => 'booked']); + + $result = economic_v2_compare_engine::compare($internal, $draft, $booked); + + expect($result['targets']['draft']['status'])->toBe('exact_match'); + expect($result['targets']['booked']['status'])->toBe('exact_match'); + expect($result['targets']['draft']['overall_match'])->toBeTrue(); + expect($result['targets']['booked']['overall_match'])->toBeTrue(); +}); + +it('returns total_mismatch when only totals differ', function (): void { + $internal = economic_v2_test_invoice(); + $draft = economic_v2_test_invoice([ + 'source' => 'draft', + 'totals' => ['net_total' => 125.0], + ]); + + $result = economic_v2_compare_engine::compareTarget($internal, $draft, 'draft'); + + expect($result['status'])->toBe('total_mismatch'); + expect($result['totals']['matches'])->toBeFalse(); + expect($result['mismatch_reasons'])->toContain('total_mismatch'); +}); + +it('detects line-level mismatches for quantity and price', function (): void { + $internal = economic_v2_test_invoice(); + $draft = economic_v2_test_invoice([ + 'source' => 'draft', + 'lines' => [[ + 'source' => 'draft', + 'source_line_id' => 1, + 'line_type' => 'product', + 'billable' => true, + 'product_number' => '1', + 'description' => 'Trakker', + 'reference' => '', + 'quantity' => 2.0, + 'unit_net_price' => 95.0, + 'line_net_amount' => 190.0, + 'department_distribution' => ['75' => 100.0], + 'match_key' => 'product:1|ref:', + ]], + 'totals' => ['net_total' => 100.0], + 'departments' => ['75' => 100.0], + ]); + + $result = economic_v2_compare_engine::compareTarget($internal, $draft, 'draft'); + $reasons = $result['lines']['diff'][0]['reasons'] ?? []; + + expect($result['lines']['summary']['mismatch_count'])->toBeGreaterThan(0); + expect($reasons)->toContain('quantity_mismatch'); + expect($reasons)->toContain('unit_price_mismatch'); +}); + +it('detects departmental distribution mismatches', function (): void { + $internal = economic_v2_test_invoice(); + $draft = economic_v2_test_invoice([ + 'source' => 'draft', + 'lines' => [[ + 'source' => 'draft', + 'source_line_id' => 1, + 'line_type' => 'product', + 'billable' => true, + 'product_number' => '1', + 'description' => 'Trakker', + 'reference' => '', + 'quantity' => 1.0, + 'unit_net_price' => 100.0, + 'line_net_amount' => 100.0, + 'department_distribution' => ['10' => 100.0], + 'match_key' => 'product:1|ref:', + ]], + 'departments' => ['10' => 100.0], + ]); + + $result = economic_v2_compare_engine::compareTarget($internal, $draft, 'draft'); + $lineReasons = $result['lines']['diff'][0]['reasons'] ?? []; + + expect($lineReasons)->toContain('departmental_distribution_mismatch'); + expect($result['departments']['matches'])->toBeFalse(); + expect($result['mismatch_reasons'])->toContain('department_total_mismatch'); +}); + +it('returns missing_target when draft or booked target is unavailable', function (): void { + $internal = economic_v2_test_invoice(); + $result = economic_v2_compare_engine::compareTarget($internal, null, 'booked'); + + expect($result['status'])->toBe('missing_target'); + expect($result['overall_match'])->toBeFalse(); + expect($result['mismatch_reasons'])->toContain('missing_target'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2LineNormalizerTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2LineNormalizerTest.php new file mode 100644 index 00000000..09e8a4b3 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2LineNormalizerTest.php @@ -0,0 +1,99 @@ + 150, + 'lines' => [ + [ + 'lineNumber' => 1, + 'description' => 'Subscription', + 'quantity' => 2, + 'unitNetPrice' => 75, + 'totalNetAmount' => 150, + 'product' => [ + 'productNumber' => 1, + ], + 'departmentalDistribution' => [ + 'distributions' => [ + [ + 'percentage' => 60, + 'department' => ['departmentNumber' => 75], + ], + [ + 'percentage' => 40, + 'department' => ['departmentNumber' => 10], + ], + ], + ], + ], + ], + ]; + + $normalized = economic_v2_line_normalizer::normalizeDraftInvoice($draft); + + expect($normalized['source'])->toBe('draft'); + expect($normalized['totals']['net_total'])->toBe(150.0); + expect($normalized['totals']['line_count'])->toBe(1); + expect($normalized['lines'][0]['product_number'])->toBe('1'); + expect($normalized['lines'][0]['department_distribution']['75'])->toBe(60.0); + expect($normalized['lines'][0]['department_distribution']['10'])->toBe(40.0); +}); + +it('marks text-only zero-value lines as non-billable and keeps deterministic key', function (): void { + $draft = [ + 'lines' => [ + [ + 'lineNumber' => 1, + 'description' => '# Header line', + 'quantity' => 0, + 'unitNetPrice' => 0, + 'totalNetAmount' => 0, + ], + ], + ]; + + $normalized = economic_v2_line_normalizer::normalizeDraftInvoice($draft); + $line = $normalized['lines'][0]; + + expect($line['billable'])->toBeFalse(); + expect($line['line_type'])->toBe('text'); + expect($line['match_key'])->toStartWith('text:'); + expect($line['department_distribution']['unassigned'])->toBe(100.0); +}); + +it('normalizes booked invoices and computes net total delta from lines', function (): void { + $booked = [ + 'net_amount' => 100, + 'lines' => [ + [ + 'line_number' => 1, + 'description' => 'Wash', + 'quantity' => 1, + 'unit_net_price' => 90, + 'total_net_amount' => 90, + 'product' => ['product_number' => 33], + ], + ], + ]; + + $normalized = economic_v2_line_normalizer::normalizeBookedInvoice($booked); + + expect($normalized['source'])->toBe('booked'); + expect($normalized['totals']['net_total'])->toBe(100.0); + expect($normalized['totals']['line_net_total'])->toBe(90.0); + expect($normalized['totals']['difference_from_line_sum'])->toBe(10.0); +}); + +it('contains internal normalization path with departmental metadata support', function (): void { + $classFile = app_path('classes/economic_v2_line_normalizer.php'); + $content = file_get_contents($classFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('normalizeInternalCollectedInvoice('); + expect($content)->toContain("'department_distribution'"); + expect($content)->toContain('buildMatchKey('); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2OpenApiSpecTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2OpenApiSpecTest.php new file mode 100644 index 00000000..6c291ec6 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2OpenApiSpecTest.php @@ -0,0 +1,70 @@ +markTestSkipped('openapi.yaml is not available in this runtime environment.'); +} + +it('documents economic v2 invoice paths in openapi', function (): void { + $content = economic_v2_openapi_content_or_skip(); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('/collected-invoices/economic/v2/details:'); + expect($content)->toContain('/collected-invoices/economic/v2/compare:'); + expect($content)->toContain('/collected-invoices/economic/v2/compare/bulk:'); + expect($content)->toContain('/collected-invoices/economic/v2/revenue-statistics:'); +}); + +it('documents v2 historical distribution and pricing history paths in openapi', function (): void { + $content = economic_v2_openapi_content_or_skip(); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('/superuser/invoicing/period/distribution/v2/all:'); + 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/customers/pricing-history:'); +}); + +it('aligns legacy compare schema with runtime payload by removing stale required order_ids', function (): void { + $content = economic_v2_openapi_content_or_skip(); + + expect($content)->not->toBeFalse(); + $start = strpos($content, 'CollectedInvoiceEconomicCompareResponse:'); + $end = strpos($content, 'CollectedInvoiceEconomicV2DetailsResponse:'); + expect($start)->not->toBeFalse(); + expect($end)->not->toBeFalse(); + + $legacyBlock = substr($content, $start, $end - $start); + expect($legacyBlock)->toContain('- internal_total'); + expect($legacyBlock)->not->toContain('order_ids:'); + expect($legacyBlock)->not->toContain('- order_ids'); +}); + +it('defines new reusable v2 schemas for normalization comparison versioning and distribution', function (): void { + $content = economic_v2_openapi_content_or_skip(); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('EconomicV2NormalizedLineItem:'); + expect($content)->toContain('EconomicV2Comparison:'); + expect($content)->toContain('CollectedInvoiceEconomicV2CustomerSummary:'); + expect($content)->toContain('CollectedInvoiceEconomicV2RevenueStatisticsResponse:'); + expect($content)->toContain('EconomicV2RevenueSummary:'); + expect($content)->toContain('PricingHistoryVersionEntry:'); + expect($content)->toContain('InvoicingDistributionV2AllResponse:'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2RevenueAndBarredSupportTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2RevenueAndBarredSupportTest.php new file mode 100644 index 00000000..ef15726b --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2RevenueAndBarredSupportTest.php @@ -0,0 +1,36 @@ +not->toBeFalse(); + expect($routeContent)->toContain("fromRequest('barred')"); + expect($routeContent)->toContain('listCustomers('); + + $customersFile = app_path('modules/economic/customers/economicCustomers.php'); + $customersContent = file_get_contents($customersFile); + + expect($customersContent)->not->toBeFalse(); + expect($customersContent)->toContain('normalizeBarredFilter('); + expect($customersContent)->toContain('barred$eq:'); +}); + +it('implements a dedicated v2 e-conomic revenue statistics service and route', function (): void { + $routeFile = app_path('routes/orderInvoicesRoute.php'); + $routeContent = file_get_contents($routeFile); + + expect($routeContent)->not->toBeFalse(); + expect($routeContent)->toContain('/collected-invoices/economic/v2/revenue-statistics'); + expect($routeContent)->toContain("requirePermission('view_collected_invoice_economic_v2_revenue_statistics')"); + expect($routeContent)->toContain('getBookedRevenueStatistics('); + + $serviceFile = app_path('classes/economic_v2_revenue_statistics_service.php'); + $serviceContent = file_get_contents($serviceFile); + + expect($serviceContent)->not->toBeFalse(); + expect($serviceContent)->toContain('class economic_v2_revenue_statistics_service'); + expect($serviceContent)->toContain('passesBarredFilter('); + expect($serviceContent)->toContain('reduceInvoiceLines('); +}); + diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2RouteAndVersioningHooksTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2RouteAndVersioningHooksTest.php new file mode 100644 index 00000000..68e55715 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2RouteAndVersioningHooksTest.php @@ -0,0 +1,68 @@ +not->toBeFalse(); + expect($content)->toContain('/collected-invoices/economic/v2/details'); + expect($content)->toContain('/collected-invoices/economic/v2/compare'); + expect($content)->toContain('/collected-invoices/economic/v2/compare/bulk'); + expect($content)->toContain('/collected-invoices/economic/v2/revenue-statistics'); + expect($content)->toContain("requirePermission('view_collected_invoice_economic_v2_details')"); + expect($content)->toContain("requirePermission('compare_collected_invoice_economic_v2')"); + expect($content)->toContain("requirePermission('compare_collected_invoice_economic_v2_bulk')"); + expect($content)->toContain("requirePermission('view_collected_invoice_economic_v2_revenue_statistics')"); + expect($content)->toContain("requireParameters(['collected_invoice_ids'])"); +}); + +it('registers version-aware distribution and pricing history v2 routes', function (): void { + $routeFile = app_path('routes/InvoicingPeriodRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('/superuser/invoicing/period/distribution/v2/all'); + 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/customers/pricing-history'); + expect($content)->toContain("requirePermission('superuser_invoicing_period_distribution_v2')"); + expect($content)->toContain("requirePermission('superuser_customer_pricing_history_v2')"); +}); + +it('writes fixed pricing versions from create and delete flows', function (): void { + $routeFile = app_path('routes/customerFixedPricingRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('recordFixedPricingVersion('); + expect($content)->toContain('closeActiveFixedPricingVersion('); +}); + +it('writes vehicle subscription versions for create update delete flows', function (): void { + $routeFile = app_path('routes/vehiclesRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('recordVehicleSubscriptionVersion('); + expect($content)->toContain('closeActiveVehicleSubscriptionVersion('); + expect($content)->toContain("if (self::isParametersSet(['customer_id']))"); +}); + +it('writes discount override versions from superuser discounts route', function (): void { + $routeFile = app_path('routes/userRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('/superuser/user/discounts'); + expect($content)->toContain('recordDiscountOverrideVersion('); +}); + +it('keeps legacy compare endpoint path for backward compatibility', function (): void { + $routeFile = app_path('routes/orderInvoicesRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('/collected-invoices/economic/compare'); + expect($content)->toContain("requirePermission('compare_collected_invoice_economic')"); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2VersioningServiceStructureTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2VersioningServiceStructureTest.php new file mode 100644 index 00000000..a26870d2 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2VersioningServiceStructureTest.php @@ -0,0 +1,45 @@ +not->toBeFalse(); + expect($content)->toContain('recordFixedPricingVersion('); + expect($content)->toContain('closeActiveFixedPricingVersion('); + expect($content)->toContain('recordVehicleSubscriptionVersion('); + expect($content)->toContain('closeActiveVehicleSubscriptionVersion('); + expect($content)->toContain('recordDiscountOverrideVersion('); +}); + +it('closes previous active interval before inserting a new version', function (): void { + $serviceFile = app_path('classes/economic_v2_versioning_service.php'); + $content = file_get_contents($serviceFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('AND effective_from <'); + expect($content)->toContain('AND (effective_to IS NULL OR effective_to >='); + expect($content)->toContain('minusOneSecond('); +}); + +it('includes best-effort backfill with provenance and confidence metadata', function (): void { + $serviceFile = app_path('classes/economic_v2_versioning_service.php'); + $content = file_get_contents($serviceFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('runBestEffortBackfill('); + expect($content)->toContain('backfill.current_fixed_pricing'); + expect($content)->toContain('backfill.current_vehicle'); + expect($content)->toContain('backfill.current_discount_override'); + expect($content)->toContain("'inferred' =>"); +}); + +it('anchors historical resolution on order created_at timestamps in distribution service', function (): void { + $serviceFile = app_path('classes/economic_v2_distribution_service.php'); + $content = file_get_contents($serviceFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('resolveFixedPricingVersionAt($customer_number, $created_at)'); + expect($content)->toContain('resolveVehicleSubscriptionVersionsAt($customer_number, $created_at)'); + expect($content)->toContain('resolveDiscountForProduct($customer_number, $product_id, $created_at)'); +});