Files
api/services/nginx/app/classes/economic_v2_distribution_service.php
T

1769 lines
75 KiB
PHP

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