1626 lines
69 KiB
PHP
1626 lines
69 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 $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'];
|
|
$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'];
|
|
$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 buildBookedDepartment75Distribution(
|
|
string $date_from,
|
|
string $date_to,
|
|
array $fixed_pricing,
|
|
array $wash_subscriptions
|
|
): array {
|
|
$weight_index = $this->buildBookedDepartment75WeightIndex($fixed_pricing, $wash_subscriptions);
|
|
$transaction_map = $this->buildBookedDepartment75TransactionMap($fixed_pricing, $wash_subscriptions);
|
|
$warnings = [];
|
|
$groups = $this->collectBookedDepartment75Groups($date_from, $date_to, $warnings);
|
|
|
|
$customers = [];
|
|
$collective = [
|
|
'booked_net_amount' => 0.0,
|
|
'distributed_net_amount' => 0.0,
|
|
'undistributed_net_amount' => 0.0,
|
|
'department_distribution' => [],
|
|
];
|
|
|
|
foreach ($groups as $group) {
|
|
$customer_number = (int)$group['customer_number'];
|
|
if (!isset($customers[$customer_number])) {
|
|
$customers[$customer_number] = $this->buildBookedDepartment75CustomerEnvelope(
|
|
$customer_number,
|
|
$transaction_map[$customer_number] ?? []
|
|
);
|
|
$customers[$customer_number]['meta']['booked_department_75'] = [
|
|
'booked_net_amount' => 0.0,
|
|
'distributed_net_amount' => 0.0,
|
|
'undistributed_net_amount' => 0.0,
|
|
'department_distribution' => [],
|
|
'booked_groups' => [],
|
|
];
|
|
}
|
|
|
|
$redistributed = $this->redistributeBookedDepartment75Group($group, $weight_index, $warnings);
|
|
$distributed_amount = array_sum($redistributed['department_distribution']);
|
|
$booked_amount = (float)$group['booked_net_amount'];
|
|
$invoice_ids = array_values(array_unique(array_map('intval', $group['invoice_ids'] ?? [])));
|
|
sort($invoice_ids);
|
|
|
|
$customers[$customer_number]['meta']['booked_department_75']['booked_net_amount'] += $booked_amount;
|
|
$customers[$customer_number]['meta']['booked_department_75']['distributed_net_amount'] += $distributed_amount;
|
|
$customers[$customer_number]['meta']['booked_department_75']['undistributed_net_amount'] += (float)$redistributed['undistributed_net_amount'];
|
|
foreach ($redistributed['department_distribution'] as $department_id => $amount) {
|
|
$department_id = (int)$department_id;
|
|
if (!isset($customers[$customer_number]['meta']['booked_department_75']['department_distribution'][$department_id])) {
|
|
$customers[$customer_number]['meta']['booked_department_75']['department_distribution'][$department_id] = 0.0;
|
|
}
|
|
$customers[$customer_number]['meta']['booked_department_75']['department_distribution'][$department_id] += (float)$amount;
|
|
|
|
if (!isset($collective['department_distribution'][$department_id])) {
|
|
$collective['department_distribution'][$department_id] = 0.0;
|
|
}
|
|
$collective['department_distribution'][$department_id] += (float)$amount;
|
|
}
|
|
|
|
$customers[$customer_number]['meta']['booked_department_75']['booked_groups'][] = [
|
|
'month' => (string)$group['month'],
|
|
'source_category' => (string)$group['source_category'],
|
|
'invoice_ids' => $invoice_ids,
|
|
'booked_net_amount' => round($booked_amount, 5),
|
|
'department_distribution' => $this->roundMap($redistributed['department_distribution']),
|
|
'undistributed_net_amount' => round((float)$redistributed['undistributed_net_amount'], 5),
|
|
];
|
|
|
|
$collective['booked_net_amount'] += $booked_amount;
|
|
$collective['distributed_net_amount'] += $distributed_amount;
|
|
$collective['undistributed_net_amount'] += (float)$redistributed['undistributed_net_amount'];
|
|
}
|
|
|
|
$customers = array_values(array_map(function (array $customer): array {
|
|
if (!isset($customer['meta']['booked_department_75'])) {
|
|
return $customer;
|
|
}
|
|
|
|
$customer['meta']['booked_department_75']['booked_net_amount'] = round(
|
|
(float)$customer['meta']['booked_department_75']['booked_net_amount'],
|
|
5
|
|
);
|
|
$customer['meta']['booked_department_75']['distributed_net_amount'] = round(
|
|
(float)$customer['meta']['booked_department_75']['distributed_net_amount'],
|
|
5
|
|
);
|
|
$customer['meta']['booked_department_75']['undistributed_net_amount'] = round(
|
|
(float)$customer['meta']['booked_department_75']['undistributed_net_amount'],
|
|
5
|
|
);
|
|
$customer['meta']['booked_department_75']['department_distribution'] = $this->roundMap(
|
|
$customer['meta']['booked_department_75']['department_distribution']
|
|
);
|
|
usort($customer['meta']['booked_department_75']['booked_groups'], static function (array $left, array $right): int {
|
|
$month_compare = strcmp((string)$left['month'], (string)$right['month']);
|
|
if ($month_compare !== 0) {
|
|
return $month_compare;
|
|
}
|
|
|
|
return strcmp((string)$left['source_category'], (string)$right['source_category']);
|
|
});
|
|
|
|
return $customer;
|
|
}, $customers));
|
|
usort($customers, static fn(array $left, array $right): int => ((int)$left['customer_number']) <=> ((int)$right['customer_number']));
|
|
|
|
return [
|
|
'customers' => $customers,
|
|
'collective_results' => [
|
|
'booked_net_amount' => round((float)$collective['booked_net_amount'], 5),
|
|
'distributed_net_amount' => round((float)$collective['distributed_net_amount'], 5),
|
|
'undistributed_net_amount' => round((float)$collective['undistributed_net_amount'], 5),
|
|
'department_distribution' => $this->roundMap($collective['department_distribution']),
|
|
'department_distribution_parsed' => $this->parseDepartmentMap($collective['department_distribution']),
|
|
],
|
|
'warnings' => array_values(array_unique($warnings)),
|
|
];
|
|
}
|
|
|
|
private function buildBookedDepartment75WeightIndex(array $fixed_pricing, array $wash_subscriptions): array
|
|
{
|
|
$weights = [];
|
|
|
|
foreach (($fixed_pricing['customers'] ?? []) as $customer) {
|
|
$customer_number = (int)($customer['customer_number'] ?? 0);
|
|
foreach ((array)($customer['meta']['fixed_pricing']['version_groups'] ?? []) as $group) {
|
|
$this->accumulateBookedDepartment75Weights(
|
|
$weights,
|
|
$customer_number,
|
|
(string)($group['month'] ?? ''),
|
|
'fixed_pricing',
|
|
(array)($group['department_totals_relative'] ?? [])
|
|
);
|
|
}
|
|
}
|
|
|
|
foreach (($wash_subscriptions['customers'] ?? []) as $customer) {
|
|
$customer_number = (int)($customer['customer_number'] ?? 0);
|
|
foreach ((array)($customer['meta']['subscription']['version_groups'] ?? []) as $group) {
|
|
$this->accumulateBookedDepartment75Weights(
|
|
$weights,
|
|
$customer_number,
|
|
(string)($group['month'] ?? ''),
|
|
'wash_subscriptions',
|
|
(array)($group['department_distribution'] ?? [])
|
|
);
|
|
}
|
|
}
|
|
|
|
return $weights;
|
|
}
|
|
|
|
private function accumulateBookedDepartment75Weights(
|
|
array &$weights,
|
|
int $customer_number,
|
|
string $month_key,
|
|
string $source_category,
|
|
array $distribution
|
|
): void {
|
|
if ($customer_number <= 0 || $month_key === '' || $source_category === '') {
|
|
return;
|
|
}
|
|
|
|
foreach ($distribution as $department_id => $amount) {
|
|
$department_id = (int)$department_id;
|
|
if (!$this->isDepartmentEligible($department_id)) {
|
|
continue;
|
|
}
|
|
|
|
if (!isset($weights[$customer_number][$month_key][$source_category][$department_id])) {
|
|
$weights[$customer_number][$month_key][$source_category][$department_id] = 0.0;
|
|
}
|
|
$weights[$customer_number][$month_key][$source_category][$department_id] += (float)$amount;
|
|
}
|
|
}
|
|
|
|
private function buildBookedDepartment75TransactionMap(array $fixed_pricing, array $wash_subscriptions): array
|
|
{
|
|
$transaction_map = [];
|
|
foreach ([$fixed_pricing, $wash_subscriptions] as $response) {
|
|
foreach (($response['customers'] ?? []) as $customer) {
|
|
$customer_number = (int)($customer['customer_number'] ?? 0);
|
|
if ($customer_number <= 0) {
|
|
continue;
|
|
}
|
|
|
|
foreach ((array)($customer['transactions'] ?? []) as $transaction) {
|
|
$transaction_id = (int)($transaction['id'] ?? 0);
|
|
if ($transaction_id > 0) {
|
|
$transaction_map[$customer_number][$transaction_id] = $transaction;
|
|
continue;
|
|
}
|
|
|
|
$transaction_map[$customer_number][] = $transaction;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $transaction_map;
|
|
}
|
|
|
|
private function buildBookedDepartment75CustomerEnvelope(int $customer_number, array $transaction_map): array
|
|
{
|
|
try {
|
|
return $this->buildCustomerEnvelope($customer_number, $transaction_map);
|
|
} catch (Exception $e) {
|
|
return [
|
|
'id' => null,
|
|
'customer_number' => $customer_number,
|
|
'customer_name' => $this->getCustomerName($customer_number),
|
|
'transactions' => array_values($transaction_map),
|
|
'requires_action' => false,
|
|
'meta' => [],
|
|
];
|
|
}
|
|
}
|
|
|
|
private function collectBookedDepartment75Groups(string $date_from, string $date_to, array &$warnings): array
|
|
{
|
|
$booked_invoices = $this->fetchBookedInvoicesInDateRange($date_from, $date_to, $warnings);
|
|
$invoice_ids = [];
|
|
foreach ($booked_invoices as $invoice_raw) {
|
|
$invoice = $this->toArray($invoice_raw);
|
|
$invoice_id = (int)($invoice['bookedInvoiceNumber'] ?? $invoice['booked_invoice_number'] ?? 0);
|
|
if ($invoice_id > 0) {
|
|
$invoice_ids[] = $invoice_id;
|
|
}
|
|
}
|
|
|
|
$invoice_lines = $this->fetchBookedInvoiceLines($invoice_ids, $warnings);
|
|
$groups = [];
|
|
|
|
foreach ($booked_invoices as $invoice_raw) {
|
|
$invoice = $this->toArray($invoice_raw);
|
|
$invoice_id = (int)($invoice['bookedInvoiceNumber'] ?? $invoice['booked_invoice_number'] ?? 0);
|
|
$customer_number = (int)($invoice['customer']['customerNumber'] ?? $invoice['customer']['customer_number'] ?? 0);
|
|
$invoice_date = (string)($invoice['date'] ?? '');
|
|
|
|
if ($invoice_id <= 0 || $customer_number <= 0 || $invoice_date === '') {
|
|
continue;
|
|
}
|
|
|
|
$month_key = substr($invoice_date, 0, 7);
|
|
foreach ($this->parseBookedDepartment75InvoiceLines($invoice_id, (array)($invoice_lines[$invoice_id] ?? []), $warnings) as $line) {
|
|
if (abs((float)$line['booked_net_amount']) <= self::EPSILON) {
|
|
continue;
|
|
}
|
|
|
|
$source_category = (string)($line['source_category'] ?? 'unclassified');
|
|
$group_key = $customer_number . '|' . $month_key . '|' . $source_category;
|
|
if (!isset($groups[$group_key])) {
|
|
$groups[$group_key] = [
|
|
'customer_number' => $customer_number,
|
|
'month' => $month_key,
|
|
'source_category' => $source_category,
|
|
'invoice_ids' => [],
|
|
'booked_net_amount' => 0.0,
|
|
];
|
|
}
|
|
|
|
$groups[$group_key]['invoice_ids'][$invoice_id] = true;
|
|
$groups[$group_key]['booked_net_amount'] += (float)$line['booked_net_amount'];
|
|
}
|
|
}
|
|
|
|
foreach ($groups as &$group) {
|
|
$group['invoice_ids'] = array_values(array_map('intval', array_keys((array)$group['invoice_ids'])));
|
|
sort($group['invoice_ids']);
|
|
}
|
|
unset($group);
|
|
|
|
return $groups;
|
|
}
|
|
|
|
private function parseBookedDepartment75InvoiceLines(int $invoice_id, array $invoice_lines, array &$warnings): array
|
|
{
|
|
$parsed = [];
|
|
$active_category = null;
|
|
|
|
foreach ($invoice_lines as $line_raw) {
|
|
$line = $this->normalizeBookedDepartment75Line($line_raw);
|
|
$description = trim((string)($line['description'] ?? ''));
|
|
if ($description !== '' && $this->isBookedDepartment75TransactionHeader($description)) {
|
|
$active_category = null;
|
|
continue;
|
|
}
|
|
|
|
$marker = $this->resolveBookedDepartment75Marker($description);
|
|
if ($marker !== null) {
|
|
$active_category = $marker;
|
|
continue;
|
|
}
|
|
|
|
if (!(bool)($line['billable'] ?? false)) {
|
|
continue;
|
|
}
|
|
|
|
$department_share = (float)(
|
|
$line['department_distribution'][(string)self::BOOKED_DEPARTMENT_75]
|
|
?? $line['department_distribution'][self::BOOKED_DEPARTMENT_75]
|
|
?? 0.0
|
|
);
|
|
if (abs($department_share) <= self::EPSILON) {
|
|
continue;
|
|
}
|
|
|
|
$booked_net_amount = (float)($line['line_net_amount'] ?? 0.0) * ($department_share / 100.0);
|
|
$source_category = $active_category;
|
|
if ($source_category === null) {
|
|
$warnings[] = 'Unable to classify booked department 75 line on invoice '
|
|
. $invoice_id
|
|
. ' line '
|
|
. (int)($line['source_line_id'] ?? 0)
|
|
. '; amount remains undistributed.';
|
|
$source_category = 'unclassified';
|
|
}
|
|
|
|
$parsed[] = [
|
|
'source_category' => $source_category,
|
|
'booked_net_amount' => $booked_net_amount,
|
|
];
|
|
}
|
|
|
|
return $parsed;
|
|
}
|
|
|
|
private function normalizeBookedDepartment75Line(mixed $raw_line): array
|
|
{
|
|
$line = $this->toArray($raw_line);
|
|
$product_number = $line['product']['productNumber']
|
|
?? $line['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) {
|
|
$warnings[] = 'Booked department 75 '
|
|
. $source_category
|
|
. ' amount for customer '
|
|
. $customer_number
|
|
. ' in '
|
|
. $month_key
|
|
. ' on invoice(s) '
|
|
. $invoice_ids
|
|
. ' has no redistribution basis and remains undistributed.';
|
|
|
|
return [
|
|
'department_distribution' => [],
|
|
'undistributed_net_amount' => $booked_amount,
|
|
];
|
|
}
|
|
|
|
$distribution = [];
|
|
foreach ($eligible_weights as $department_id => $weight) {
|
|
$distribution[$department_id] = $booked_amount * ($weight / $weight_total);
|
|
}
|
|
|
|
return [
|
|
'department_distribution' => $distribution,
|
|
'undistributed_net_amount' => 0.0,
|
|
];
|
|
}
|
|
|
|
protected function fetchBookedInvoicesInDateRange(string $date_from, string $date_to, array &$warnings): array
|
|
{
|
|
$cache_key = $date_from . '|' . $date_to;
|
|
if (isset($this->booked_invoices_cache[$cache_key])) {
|
|
return $this->booked_invoices_cache[$cache_key];
|
|
}
|
|
|
|
$filters = [
|
|
'(date$gte:' . $date_from . '$and:date$lte:' . $date_to . ')' => '',
|
|
];
|
|
$all = [];
|
|
|
|
for ($page = 0; $page < self::BOOKED_INVOICE_MAX_PAGES; $page++) {
|
|
try {
|
|
$response = $this->getEconomicClient()->invoices->booked->get(
|
|
$filters,
|
|
[
|
|
'skipPages' => $page,
|
|
'pageSize' => self::BOOKED_INVOICE_PAGE_SIZE,
|
|
]
|
|
);
|
|
} catch (\Throwable $e) {
|
|
$warnings[] = 'Failed to fetch booked e-conomic invoices for department 75 distribution: ' . $e->getMessage();
|
|
break;
|
|
}
|
|
|
|
$collection = is_array($response->collection ?? null) ? $response->collection : [];
|
|
$all = array_merge($all, $collection);
|
|
|
|
if (count($collection) < self::BOOKED_INVOICE_PAGE_SIZE) {
|
|
break;
|
|
}
|
|
|
|
if ($page + 1 >= self::BOOKED_INVOICE_MAX_PAGES) {
|
|
$warnings[] = 'Reached pagination safety limit (max_pages='
|
|
. self::BOOKED_INVOICE_MAX_PAGES
|
|
. ') while fetching booked department 75 invoices.';
|
|
}
|
|
}
|
|
|
|
$this->booked_invoices_cache[$cache_key] = $all;
|
|
return $this->booked_invoices_cache[$cache_key];
|
|
}
|
|
|
|
protected function fetchBookedInvoiceLines(array $invoice_ids, array &$warnings): array
|
|
{
|
|
$invoice_ids = array_values(array_unique(array_filter(array_map('intval', $invoice_ids), static fn(int $id): bool => $id > 0)));
|
|
sort($invoice_ids);
|
|
if (empty($invoice_ids)) {
|
|
return [];
|
|
}
|
|
|
|
$cache_key = implode(',', $invoice_ids);
|
|
if (isset($this->booked_invoice_lines_cache[$cache_key])) {
|
|
return $this->booked_invoice_lines_cache[$cache_key];
|
|
}
|
|
|
|
try {
|
|
$this->booked_invoice_lines_cache[$cache_key] = $this->getEconomicClient()->invoices->booked->get_invoice_lines($invoice_ids);
|
|
} catch (\Throwable $e) {
|
|
$warnings[] = 'Failed to fetch booked invoice lines for department 75 distribution: ' . $e->getMessage();
|
|
$this->booked_invoice_lines_cache[$cache_key] = [];
|
|
}
|
|
|
|
return $this->booked_invoice_lines_cache[$cache_key];
|
|
}
|
|
|
|
protected function ensureVersionHistoryAvailable(array $areas): void
|
|
{
|
|
if ($this->best_effort_backfill_attempted) {
|
|
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'];
|
|
$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->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_by_order_id[$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);
|
|
}
|
|
}
|
|
|
|
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'];
|
|
$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->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]++;
|
|
|
|
$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'][$department_id])) {
|
|
$groups[$group_key]['distribution'][$department_id] = 0;
|
|
}
|
|
$groups[$group_key]['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);
|
|
}
|
|
}
|
|
|
|
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, 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)) {
|
|
$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);
|
|
}
|
|
|
|
protected function calculateOrderOriginalPrice(array $order_items, int $customer_number, int $department_id, string $timestamp): float
|
|
{
|
|
$total = 0.0;
|
|
foreach ($order_items as $item) {
|
|
$product_id = (int)($item['product_id'] ?? 0);
|
|
$quantity = (float)($item['quantity'] ?? 0);
|
|
if ($product_id <= 0 || $quantity <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$explicit_price = (float)($item['price'] ?? 0);
|
|
if ($explicit_price > 0) {
|
|
$line_price = $explicit_price * $quantity;
|
|
} else {
|
|
$line_price = ((float)$this->getProductDepartmentPrice($product_id, $department_id)) * $quantity;
|
|
}
|
|
|
|
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp);
|
|
$discount_percentage = (float)($discount_row['discount'] ?? 0);
|
|
if ($discount_percentage > 0) {
|
|
$line_price *= (1 - ($discount_percentage / 100));
|
|
}
|
|
$total += $line_price;
|
|
}
|
|
return $total;
|
|
}
|
|
|
|
protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp): ?array
|
|
{
|
|
$cache_key = $customer_number . '|' . $product_id . '|' . substr($timestamp, 0, 19);
|
|
if (array_key_exists($cache_key, $this->discount_resolution_cache)) {
|
|
return $this->discount_resolution_cache[$cache_key];
|
|
}
|
|
|
|
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp);
|
|
if ($direct !== null && (int)($direct['discount'] ?? 0) > 0) {
|
|
return $this->discount_resolution_cache[$cache_key] = $direct;
|
|
}
|
|
|
|
$product = $this->getProduct($product_id);
|
|
if ($product !== null) {
|
|
$category = (string)$product->category->value();
|
|
if ($category !== '') {
|
|
$category_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, $category, $timestamp);
|
|
if ($category_discount !== null && (int)($category_discount['discount'] ?? 0) > 0) {
|
|
return $this->discount_resolution_cache[$cache_key] = $category_discount;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $this->discount_resolution_cache[$cache_key] = null;
|
|
}
|
|
|
|
protected function getProductDepartmentPrice(int $product_id, int $department_id): float
|
|
{
|
|
if (!isset($this->product_department_price_cache[$department_id][$product_id])) {
|
|
$product = $this->getProduct($product_id);
|
|
if ($product === null) {
|
|
$this->product_department_price_cache[$department_id][$product_id] = 0.0;
|
|
} else {
|
|
$this->product_department_price_cache[$department_id][$product_id] = (float)$product->getDepartmentPrice($department_id);
|
|
}
|
|
}
|
|
return (float)$this->product_department_price_cache[$department_id][$product_id];
|
|
}
|
|
|
|
protected function getSubscriptionMonthlyPrice(int $vehicle_type): float
|
|
{
|
|
$product = $this->getProduct($vehicle_type);
|
|
if ($product === null) {
|
|
return 0.0;
|
|
}
|
|
return (float)$product->getSubscriptionMonthlyPrice();
|
|
}
|
|
|
|
private function getEconomicClient(): economic
|
|
{
|
|
if ($this->economic === null) {
|
|
$this->economic = new economic();
|
|
}
|
|
|
|
return $this->economic;
|
|
}
|
|
|
|
protected function toArray(mixed $value): array
|
|
{
|
|
if (is_array($value)) {
|
|
return $value;
|
|
}
|
|
if (!is_object($value)) {
|
|
return [];
|
|
}
|
|
|
|
return json_decode(json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), true) ?: [];
|
|
}
|
|
|
|
private function getProduct(int $product_id): ?products_o
|
|
{
|
|
if (!isset($this->product_cache[$product_id])) {
|
|
$product = new products_o();
|
|
$product->select($product_id);
|
|
if (!$product->exists()) {
|
|
$this->product_cache[$product_id] = null;
|
|
} else {
|
|
$this->product_cache[$product_id] = $product;
|
|
}
|
|
}
|
|
return $this->product_cache[$product_id];
|
|
}
|
|
|
|
protected function buildCustomerEnvelope(int $customer_number, array $transaction_map): array
|
|
{
|
|
return [
|
|
'id' => (new users_o())->getUserByCustomerNumber($customer_number)->id,
|
|
'customer_number' => $customer_number,
|
|
'customer_name' => $this->getCustomerName($customer_number),
|
|
'transactions' => array_values($transaction_map),
|
|
'requires_action' => false,
|
|
'meta' => [],
|
|
];
|
|
}
|
|
|
|
protected function buildTransactionObject(int $order_id, string $created_at, int $department_id, ?float $amount = null): 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];
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|