Add economic V2 modules for history backfill, comparison, and distribution services.

This commit is contained in:
Jeppe Bundgaard
2026-03-12 17:57:27 +01:00
parent 72454ff328
commit 4ef20a1c90
25 changed files with 5328 additions and 23 deletions
+1031 -7
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,369 @@
<?php
namespace classes;
class economic_v2_compare_engine
{
private const TOLERANCE = 0.01;
public static function compare(array $internal, ?array $draft, ?array $booked): array
{
$draft_result = self::compareTarget($internal, $draft, 'draft');
$booked_result = self::compareTarget($internal, $booked, 'booked');
return [
'totals' => [
'internal_net_total' => (float)($internal['totals']['net_total'] ?? 0.0),
],
'targets' => [
'draft' => $draft_result,
'booked' => $booked_result,
],
'warnings' => array_values(array_unique(array_merge(
(array)($internal['warnings'] ?? []),
(array)($draft_result['warnings'] ?? []),
(array)($booked_result['warnings'] ?? [])
))),
];
}
public static function compareTarget(array $internal, ?array $target, string $target_name): array
{
if ($target === null) {
return [
'target' => $target_name,
'status' => 'missing_target',
'overall_match' => false,
'totals' => [
'internal_net_total' => (float)($internal['totals']['net_total'] ?? 0.0),
'target_net_total' => null,
'difference' => null,
'abs_difference' => null,
'matches' => false,
],
'lines' => [
'summary' => [
'internal_billable_count' => (int)($internal['totals']['billable_line_count'] ?? 0),
'target_billable_count' => 0,
'mismatch_count' => (int)($internal['totals']['billable_line_count'] ?? 0),
],
'diff' => [],
],
'departments' => [
'matches' => false,
'diff' => [],
],
'mismatch_reasons' => ['missing_target'],
'warnings' => ['Missing ' . $target_name . ' invoice target'],
];
}
$totals = self::compareTotals(
(float)($internal['totals']['net_total'] ?? 0.0),
(float)($target['totals']['net_total'] ?? 0.0)
);
$lines = self::compareLines($internal['lines'] ?? [], $target['lines'] ?? []);
$departments = self::compareDepartments($internal['departments'] ?? [], $target['departments'] ?? []);
$mismatch_reasons = array_values(array_unique(array_merge(
$lines['mismatch_reasons'],
$departments['mismatch_reasons'],
$totals['matches'] ? [] : ['total_mismatch']
)));
$overall_match = $totals['matches'] && $lines['summary']['mismatch_count'] === 0 && $departments['matches'];
$status = $overall_match
? 'exact_match'
: ($totals['matches'] ? 'partial_mismatch' : 'total_mismatch');
return [
'target' => $target_name,
'status' => $status,
'overall_match' => $overall_match,
'totals' => $totals,
'lines' => [
'summary' => $lines['summary'],
'diff' => $lines['diff'],
],
'departments' => [
'matches' => $departments['matches'],
'diff' => $departments['diff'],
],
'mismatch_reasons' => $mismatch_reasons,
'warnings' => array_values(array_unique(array_merge(
(array)($target['warnings'] ?? []),
(array)$lines['warnings'],
(array)$departments['warnings']
))),
];
}
private static function compareTotals(float $internal_total, float $target_total): array
{
$difference = $target_total - $internal_total;
$abs = abs($difference);
return [
'internal_net_total' => round($internal_total, 5),
'target_net_total' => round($target_total, 5),
'difference' => round($difference, 5),
'abs_difference' => round($abs, 5),
'matches' => $abs <= self::TOLERANCE,
];
}
private static function compareLines(array $internal_lines, array $target_lines): array
{
$internal_billable = array_values(array_filter($internal_lines, static fn($l) => (bool)($l['billable'] ?? false)));
$target_billable = array_values(array_filter($target_lines, static fn($l) => (bool)($l['billable'] ?? false)));
$internal_grouped = self::groupByKey($internal_billable, 'match_key');
$target_grouped = self::groupByKey($target_billable, 'match_key');
$keys = array_values(array_unique(array_merge(array_keys($internal_grouped), array_keys($target_grouped))));
sort($keys);
$diff = [];
$mismatch_reasons = [];
$warnings = [];
$unmatched_internal = [];
$unmatched_target = [];
foreach ($keys as $key) {
$left = $internal_grouped[$key] ?? [];
$right = $target_grouped[$key] ?? [];
$max = max(count($left), count($right));
for ($i = 0; $i < $max; $i++) {
$internal_line = $left[$i] ?? null;
$target_line = $right[$i] ?? null;
if ($internal_line === null) {
$unmatched_target[] = $target_line;
continue;
}
if ($target_line === null) {
$unmatched_internal[] = $internal_line;
continue;
}
$reasons = self::lineMismatchReasons($internal_line, $target_line);
if (!empty($reasons)) {
$diff[] = [
'match_key' => $key,
'reasons' => $reasons,
'internal_line' => $internal_line,
'target_line' => $target_line,
];
$mismatch_reasons = array_merge($mismatch_reasons, $reasons);
}
}
}
// Secondary pairing by reference/description to convert missing/extra into explicit product mismatch when possible.
[$paired_diff, $still_unmatched_internal, $still_unmatched_target] = self::secondaryPairAndCompare($unmatched_internal, $unmatched_target);
$diff = array_merge($diff, $paired_diff);
foreach ($paired_diff as $entry) {
$mismatch_reasons = array_merge($mismatch_reasons, $entry['reasons']);
}
foreach ($still_unmatched_internal as $line) {
$diff[] = [
'match_key' => (string)($line['match_key'] ?? ''),
'reasons' => ['missing_in_target'],
'internal_line' => $line,
'target_line' => null,
];
$mismatch_reasons[] = 'missing_in_target';
}
foreach ($still_unmatched_target as $line) {
$diff[] = [
'match_key' => (string)($line['match_key'] ?? ''),
'reasons' => ['extra_in_target'],
'internal_line' => null,
'target_line' => $line,
];
$mismatch_reasons[] = 'extra_in_target';
}
$internal_non_billable = count($internal_lines) - count($internal_billable);
$target_non_billable = count($target_lines) - count($target_billable);
if ($internal_non_billable !== $target_non_billable) {
$warnings[] = 'Non-billable line count differs: internal=' . $internal_non_billable . ', target=' . $target_non_billable;
}
return [
'summary' => [
'internal_billable_count' => count($internal_billable),
'target_billable_count' => count($target_billable),
'mismatch_count' => count($diff),
],
'diff' => $diff,
'mismatch_reasons' => array_values(array_unique($mismatch_reasons)),
'warnings' => $warnings,
];
}
private static function secondaryPairAndCompare(array $unmatched_internal, array $unmatched_target): array
{
$left_by_secondary = self::groupByKey(array_values($unmatched_internal), 'secondary_key');
$right_by_secondary = self::groupByKey(array_values($unmatched_target), 'secondary_key');
$secondary_keys = array_values(array_unique(array_merge(array_keys($left_by_secondary), array_keys($right_by_secondary))));
sort($secondary_keys);
$paired_diff = [];
$left_remainder = [];
$right_remainder = [];
foreach ($secondary_keys as $secondary_key) {
$left = $left_by_secondary[$secondary_key] ?? [];
$right = $right_by_secondary[$secondary_key] ?? [];
$max = max(count($left), count($right));
for ($i = 0; $i < $max; $i++) {
$internal_line = $left[$i] ?? null;
$target_line = $right[$i] ?? null;
if ($internal_line === null) {
if ($target_line !== null) {
$right_remainder[] = $target_line;
}
continue;
}
if ($target_line === null) {
$left_remainder[] = $internal_line;
continue;
}
$reasons = self::lineMismatchReasons($internal_line, $target_line);
if ((string)($internal_line['product_number'] ?? '') !== (string)($target_line['product_number'] ?? '')) {
$reasons[] = 'product_mismatch';
}
$reasons = array_values(array_unique($reasons));
$paired_diff[] = [
'match_key' => (string)($internal_line['match_key'] ?? ''),
'reasons' => $reasons,
'internal_line' => $internal_line,
'target_line' => $target_line,
];
}
}
return [$paired_diff, $left_remainder, $right_remainder];
}
private static function lineMismatchReasons(array $internal_line, array $target_line): array
{
$reasons = [];
if ((string)($internal_line['product_number'] ?? '') !== (string)($target_line['product_number'] ?? '')) {
$reasons[] = 'product_mismatch';
}
if (self::normalizeText((string)($internal_line['description'] ?? '')) !== self::normalizeText((string)($target_line['description'] ?? ''))) {
$reasons[] = 'description_mismatch';
}
if (!self::matchesNumber((float)($internal_line['quantity'] ?? 0), (float)($target_line['quantity'] ?? 0))) {
$reasons[] = 'quantity_mismatch';
}
if (!self::matchesNumber((float)($internal_line['unit_net_price'] ?? 0), (float)($target_line['unit_net_price'] ?? 0))) {
$reasons[] = 'unit_price_mismatch';
}
if (!self::matchesNumber((float)($internal_line['line_net_amount'] ?? 0), (float)($target_line['line_net_amount'] ?? 0))) {
$reasons[] = 'line_total_mismatch';
}
if (!self::departmentDistributionMatches(
(array)($internal_line['department_distribution'] ?? []),
(array)($target_line['department_distribution'] ?? [])
)) {
$reasons[] = 'departmental_distribution_mismatch';
}
return $reasons;
}
private static function compareDepartments(array $internal_departments, array $target_departments): array
{
$keys = array_values(array_unique(array_merge(array_keys($internal_departments), array_keys($target_departments))));
sort($keys);
$diff = [];
$mismatch_reasons = [];
foreach ($keys as $key) {
$internal_amount = (float)($internal_departments[$key] ?? 0.0);
$target_amount = (float)($target_departments[$key] ?? 0.0);
$difference = $target_amount - $internal_amount;
$matches = abs($difference) <= self::TOLERANCE;
$diff[] = [
'department_key' => (string)$key,
'internal_amount' => round($internal_amount, 5),
'target_amount' => round($target_amount, 5),
'difference' => round($difference, 5),
'matches' => $matches,
];
if (!$matches) {
$mismatch_reasons[] = 'department_total_mismatch';
}
}
return [
'matches' => empty($mismatch_reasons),
'diff' => $diff,
'mismatch_reasons' => array_values(array_unique($mismatch_reasons)),
'warnings' => [],
];
}
private static function groupByKey(array $lines, string $preferred_key): array
{
$grouped = [];
foreach ($lines as $line) {
$secondary_key = self::normalizeText((string)($line['description'] ?? '')) .
'|ref:' . self::normalizeText((string)($line['reference'] ?? ''));
$line['secondary_key'] = $secondary_key;
$key = (string)($line[$preferred_key] ?? $secondary_key);
if (!isset($grouped[$key])) {
$grouped[$key] = [];
}
$grouped[$key][] = $line;
}
foreach ($grouped as &$bucket) {
usort($bucket, static function ($a, $b) {
return ((int)($a['source_line_id'] ?? 0)) <=> ((int)($b['source_line_id'] ?? 0));
});
}
return $grouped;
}
private static function departmentDistributionMatches(array $left, array $right): bool
{
$keys = array_values(array_unique(array_merge(array_keys($left), array_keys($right))));
foreach ($keys as $key) {
if (!self::matchesNumber((float)($left[$key] ?? 0.0), (float)($right[$key] ?? 0.0))) {
return false;
}
}
return true;
}
private static function matchesNumber(float $left, float $right): bool
{
return abs($left - $right) <= self::TOLERANCE;
}
private static function normalizeText(string $value): string
{
$value = strtolower(trim($value));
$value = preg_replace('/\s+/', ' ', $value);
return $value ?? '';
}
}
@@ -0,0 +1,812 @@
<?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 economic_v2_versioning_service $versioning;
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 = [];
public function __construct()
{
$this->versioning = new economic_v2_versioning_service();
}
public function getAllDistributions(string $date_from, string $date_to): array
{
return [
'fixed_pricing' => $this->getFixedPricingDistribution($date_from, $date_to),
'wash_subscriptions' => $this->getWashSubscriptionsDistribution($date_from, $date_to),
'customer_prices' => $this->getCustomerPricesDistribution($date_from, $date_to),
];
}
public function getFixedPricingDistribution(string $date_from, string $date_to): array
{
[$from_ts, $to_ts] = $this->buildDateRange($date_from, $date_to);
$orders = $this->fetchOrdersInRange($from_ts, $to_ts);
$order_items = $this->fetchOrderItemsByOrderIds(array_map(static fn($o) => (int)$o['id'], $orders));
$groups = [];
$customer_transactions = [];
$warnings = [];
foreach ($orders as $order) {
$order_id = (int)$order['id'];
$customer_number = (int)$order['customer_id'];
$department_id = (int)$order['department_id'];
$created_at = (string)$order['created_at'];
if (!$this->isDepartmentEligible($department_id)) {
continue;
}
$fixed_version = $this->versioning->resolveFixedPricingVersionAt($customer_number, $created_at);
if ($fixed_version === null) {
continue;
}
$month_key = substr($created_at, 0, 7);
$group_key = $customer_number . '|' . (int)$fixed_version['id'] . '|' . $month_key;
if (!isset($groups[$group_key])) {
$groups[$group_key] = [
'customer_number' => $customer_number,
'version_id' => (int)$fixed_version['id'],
'month' => $month_key,
'price' => (float)($fixed_version['price'] ?? 0),
'description' => (string)($fixed_version['description'] ?? ''),
'source' => (string)($fixed_version['source'] ?? 'unknown'),
'confidence' => (float)($fixed_version['confidence'] ?? 0),
'inferred' => (bool)($fixed_version['inferred'] ?? false),
'effective_from' => (string)($fixed_version['effective_from'] ?? ''),
'effective_to' => $fixed_version['effective_to'] ?? null,
'original_price' => 0.0,
'department_totals' => [],
'order_ids' => [],
];
}
$order_original_price = $this->calculateOrderOriginalPrice(
$order_items[$order_id] ?? [],
$customer_number,
$department_id,
$created_at
);
$groups[$group_key]['original_price'] += $order_original_price;
if (!isset($groups[$group_key]['department_totals'][$department_id])) {
$groups[$group_key]['department_totals'][$department_id] = 0.0;
}
$groups[$group_key]['department_totals'][$department_id] += $order_original_price;
$groups[$group_key]['order_ids'][] = $order_id;
if (!isset($customer_transactions[$customer_number][$order_id])) {
$customer_transactions[$customer_number][$order_id] = $this->buildTransactionObject($order_id, $created_at, $department_id);
}
}
$customers = [];
$collective = [
'total_fixed_price' => 0.0,
'total_original_price' => 0.0,
'total_department_totals' => [],
'total_department_totals_relative' => [],
];
foreach ($groups as $group) {
$customer_number = (int)$group['customer_number'];
if (!isset($customers[$customer_number])) {
$customers[$customer_number] = $this->buildCustomerEnvelope($customer_number, $customer_transactions[$customer_number] ?? []);
$customers[$customer_number]['meta']['fixed_pricing'] = [
'price' => 0.0,
'original_price' => 0.0,
'department_totals' => [],
'department_totals_relative' => [],
'version_groups' => [],
];
}
$group_original = (float)$group['original_price'];
$group_price = (float)$group['price'];
$relative_department_totals = [];
$group_total_department_amount = array_sum($group['department_totals']);
foreach ($group['department_totals'] as $department_id => $department_amount) {
$department_id = (int)$department_id;
$relative_amount = 0.0;
if ($group_total_department_amount > 0.0) {
$relative_amount = ((float)$department_amount / $group_total_department_amount) * $group_price;
}
$relative_department_totals[$department_id] = $relative_amount;
if (!isset($customers[$customer_number]['meta']['fixed_pricing']['department_totals'][$department_id])) {
$customers[$customer_number]['meta']['fixed_pricing']['department_totals'][$department_id] = 0.0;
}
if (!isset($customers[$customer_number]['meta']['fixed_pricing']['department_totals_relative'][$department_id])) {
$customers[$customer_number]['meta']['fixed_pricing']['department_totals_relative'][$department_id] = 0.0;
}
$customers[$customer_number]['meta']['fixed_pricing']['department_totals'][$department_id] += (float)$department_amount;
$customers[$customer_number]['meta']['fixed_pricing']['department_totals_relative'][$department_id] += (float)$relative_amount;
if (!isset($collective['total_department_totals'][$department_id])) {
$collective['total_department_totals'][$department_id] = 0.0;
}
if (!isset($collective['total_department_totals_relative'][$department_id])) {
$collective['total_department_totals_relative'][$department_id] = 0.0;
}
$collective['total_department_totals'][$department_id] += (float)$department_amount;
$collective['total_department_totals_relative'][$department_id] += (float)$relative_amount;
}
if ($group_total_department_amount <= 0.0) {
$warnings[] = 'Fixed pricing group has no transaction basis for customer ' . $customer_number . ' in ' . $group['month'];
}
$customers[$customer_number]['meta']['fixed_pricing']['price'] += $group_price;
$customers[$customer_number]['meta']['fixed_pricing']['original_price'] += $group_original;
$customers[$customer_number]['meta']['fixed_pricing']['version_groups'][] = [
'version_id' => (int)$group['version_id'],
'month' => (string)$group['month'],
'price' => round($group_price, 5),
'description' => (string)$group['description'],
'source' => (string)$group['source'],
'confidence' => round((float)$group['confidence'], 5),
'inferred' => (bool)$group['inferred'],
'effective_from' => (string)$group['effective_from'],
'effective_to' => $group['effective_to'] !== null ? (string)$group['effective_to'] : null,
'original_price' => round($group_original, 5),
'department_totals' => $this->roundMap($group['department_totals']),
'department_totals_relative' => $this->roundMap($relative_department_totals),
'order_ids' => array_values(array_unique(array_map('intval', $group['order_ids']))),
];
$collective['total_fixed_price'] += $group_price;
$collective['total_original_price'] += $group_original;
}
$customers = array_values(array_map(function ($customer) {
if (isset($customer['meta']['fixed_pricing'])) {
$customer['meta']['fixed_pricing']['price'] = round((float)$customer['meta']['fixed_pricing']['price'], 5);
$customer['meta']['fixed_pricing']['original_price'] = round((float)$customer['meta']['fixed_pricing']['original_price'], 5);
$customer['meta']['fixed_pricing']['department_totals'] = $this->roundMap($customer['meta']['fixed_pricing']['department_totals']);
$customer['meta']['fixed_pricing']['department_totals_relative'] = $this->roundMap($customer['meta']['fixed_pricing']['department_totals_relative']);
}
return $customer;
}, $customers));
return [
'customers' => $customers,
'collective_results' => [
'total_fixed_price' => round((float)$collective['total_fixed_price'], 5),
'total_original_price' => round((float)$collective['total_original_price'], 5),
'total_department_totals' => $this->roundMap($collective['total_department_totals']),
'total_department_totals_relative' => $this->roundMap($collective['total_department_totals_relative']),
'total_department_totals_parsed' => $this->parseDepartmentMap($collective['total_department_totals']),
'total_department_totals_relative_parsed' => $this->parseDepartmentMap($collective['total_department_totals_relative']),
],
'warnings' => array_values(array_unique($warnings)),
];
}
public function getWashSubscriptionsDistribution(string $date_from, string $date_to): array
{
[$from_ts, $to_ts] = $this->buildDateRange($date_from, $date_to);
$orders = $this->fetchOrdersInRange($from_ts, $to_ts);
$months = $this->listMonthKeys($from_ts, $to_ts);
$groups = [];
$customer_transactions = [];
$customer_department_month_map = [];
$warnings = [];
foreach ($orders as $order) {
$order_id = (int)$order['id'];
$customer_number = (int)$order['customer_id'];
$department_id = (int)$order['department_id'];
$created_at = (string)$order['created_at'];
$reg = trim((string)($order['reg_1'] ?? ''));
if (!$this->isDepartmentEligible($department_id)) {
continue;
}
$month_key = substr($created_at, 0, 7);
if (!isset($customer_department_month_map[$customer_number][$month_key][$department_id])) {
$customer_department_month_map[$customer_number][$month_key][$department_id] = 0;
}
$customer_department_month_map[$customer_number][$month_key][$department_id]++;
if ($reg === '') {
continue;
}
$active_subscriptions = $this->versioning->resolveVehicleSubscriptionVersionsAt($customer_number, $created_at);
$matching_version = null;
foreach ($active_subscriptions as $candidate) {
if (strcasecmp((string)$candidate['reg'], $reg) === 0) {
$matching_version = $candidate;
break;
}
}
if ($matching_version === null) {
continue;
}
$monthly_price = $this->getSubscriptionMonthlyPrice((int)$matching_version['vehicle_type']);
if ($monthly_price <= 0.0) {
$warnings[] = 'Subscription type ' . (int)$matching_version['vehicle_type'] . ' has no monthly price for customer ' . $customer_number;
continue;
}
$group_key = $customer_number . '|' . (string)$matching_version['reg'] . '|' . (int)$matching_version['id'] . '|' . $month_key;
if (!isset($groups[$group_key])) {
$groups[$group_key] = [
'customer_number' => $customer_number,
'reg' => (string)$matching_version['reg'],
'vehicle_type' => (int)$matching_version['vehicle_type'],
'version_id' => (int)$matching_version['id'],
'month' => $month_key,
'monthly_price' => $monthly_price,
'source' => (string)($matching_version['source'] ?? 'unknown'),
'confidence' => (float)($matching_version['confidence'] ?? 0),
'inferred' => (bool)($matching_version['inferred'] ?? false),
'distribution' => [],
'order_ids' => [],
'fallback' => false,
];
}
if (!isset($groups[$group_key]['distribution'][$department_id])) {
$groups[$group_key]['distribution'][$department_id] = 0;
}
$groups[$group_key]['distribution'][$department_id]++;
$groups[$group_key]['order_ids'][] = $order_id;
if (!isset($customer_transactions[$customer_number][$order_id])) {
$customer_transactions[$customer_number][$order_id] = $this->buildTransactionObject($order_id, $created_at, $department_id);
}
}
$version_rows = $this->fetchVehicleSubscriptionVersionRows($from_ts, $to_ts);
foreach ($version_rows as $row) {
$customer_number = (int)$row['customer_number'];
$reg = (string)$row['reg'];
$version_id = (int)$row['id'];
$vehicle_type = (int)$row['vehicle_type'];
$monthly_price = $this->getSubscriptionMonthlyPrice($vehicle_type);
if ($monthly_price <= 0.0) {
continue;
}
foreach ($months as $month_key) {
$month_start = $month_key . '-01 00:00:00';
$month_end = date('Y-m-t 23:59:59', strtotime($month_start));
if (!$this->versionOverlaps($row, $month_start, $month_end)) {
continue;
}
$group_key = $customer_number . '|' . $reg . '|' . $version_id . '|' . $month_key;
if (isset($groups[$group_key])) {
continue;
}
$fallback_distribution = $this->buildSubscriptionFallbackDistribution(
$customer_number,
$month_key,
$monthly_price,
$customer_department_month_map
);
$groups[$group_key] = [
'customer_number' => $customer_number,
'reg' => $reg,
'vehicle_type' => $vehicle_type,
'version_id' => $version_id,
'month' => $month_key,
'monthly_price' => $monthly_price,
'source' => (string)($row['source'] ?? 'unknown'),
'confidence' => (float)($row['confidence'] ?? 0),
'inferred' => (bool)($row['inferred'] ?? false),
'distribution' => $fallback_distribution,
'order_ids' => [],
'fallback' => true,
];
$warnings[] = 'Fallback allocation used for subscription ' . $reg . ' customer ' . $customer_number . ' in ' . $month_key;
}
}
$customers = [];
$collective = [
'total_subscription_price' => 0.0,
'subscription_price_department_distribution' => [],
];
foreach ($groups as $group) {
$customer_number = (int)$group['customer_number'];
if (!isset($customers[$customer_number])) {
$customers[$customer_number] = $this->buildCustomerEnvelope($customer_number, $customer_transactions[$customer_number] ?? []);
$customers[$customer_number]['meta']['subscription'] = [
'subscription_total' => 0.0,
'subscription_price_department_distribution' => [],
'version_groups' => [],
];
}
$allocation = $this->normalizeSubscriptionGroupAllocation($group['distribution'], (float)$group['monthly_price']);
foreach ($allocation as $department_id => $amount) {
if (!isset($customers[$customer_number]['meta']['subscription']['subscription_price_department_distribution'][$department_id])) {
$customers[$customer_number]['meta']['subscription']['subscription_price_department_distribution'][$department_id] = 0.0;
}
$customers[$customer_number]['meta']['subscription']['subscription_price_department_distribution'][$department_id] += $amount;
if (!isset($collective['subscription_price_department_distribution'][$department_id])) {
$collective['subscription_price_department_distribution'][$department_id] = 0.0;
}
$collective['subscription_price_department_distribution'][$department_id] += $amount;
}
$customers[$customer_number]['meta']['subscription']['subscription_total'] += (float)$group['monthly_price'];
$customers[$customer_number]['meta']['subscription']['version_groups'][] = [
'version_id' => (int)$group['version_id'],
'month' => (string)$group['month'],
'reg' => (string)$group['reg'],
'vehicle_type' => (int)$group['vehicle_type'],
'monthly_price' => round((float)$group['monthly_price'], 5),
'source' => (string)$group['source'],
'confidence' => round((float)$group['confidence'], 5),
'inferred' => (bool)$group['inferred'],
'fallback' => (bool)$group['fallback'],
'department_distribution' => $this->roundMap($allocation),
'order_ids' => array_values(array_unique(array_map('intval', $group['order_ids']))),
];
$collective['total_subscription_price'] += (float)$group['monthly_price'];
}
$customers = array_values(array_map(function ($customer) {
if (isset($customer['meta']['subscription'])) {
$customer['meta']['subscription']['subscription_total'] = round((float)$customer['meta']['subscription']['subscription_total'], 5);
$customer['meta']['subscription']['subscription_price_department_distribution'] = $this->roundMap(
$customer['meta']['subscription']['subscription_price_department_distribution']
);
}
return $customer;
}, $customers));
return [
'customers' => $customers,
'collective_results' => [
'total_subscription_price' => round((float)$collective['total_subscription_price'], 5),
'subscription_price_department_distribution' => $this->roundMap($collective['subscription_price_department_distribution']),
'subscription_price_department_distribution_parsed' => $this->parseDepartmentMap($collective['subscription_price_department_distribution']),
],
'warnings' => array_values(array_unique($warnings)),
];
}
public function getCustomerPricesDistribution(string $date_from, string $date_to): array
{
[$from_ts, $to_ts] = $this->buildDateRange($date_from, $date_to);
$orders = $this->fetchOrdersInRange($from_ts, $to_ts);
$order_items = $this->fetchOrderItemsByOrderIds(array_map(static fn($o) => (int)$o['id'], $orders));
$customers = [];
$collective = [
'total_discount_amount' => 0.0,
'department_discount_totals' => [],
];
foreach ($orders as $order) {
$order_id = (int)$order['id'];
$customer_number = (int)$order['customer_id'];
$department_id = (int)$order['department_id'];
$created_at = (string)$order['created_at'];
if (!$this->isDepartmentEligible($department_id)) {
continue;
}
$order_discount_total = 0.0;
foreach (($order_items[$order_id] ?? []) as $item) {
$product_id = (int)($item['product_id'] ?? 0);
$quantity = (float)($item['quantity'] ?? 0);
if ($product_id <= 0 || $quantity <= 0) {
continue;
}
$base_price = (float)$this->getProductDepartmentPrice($product_id, $department_id);
if ($base_price <= 0) {
continue;
}
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at);
$discount_percentage = (float)($discount_row['discount'] ?? 0);
if ($discount_percentage <= 0) {
continue;
}
$discount_amount = ($base_price * $quantity) * ($discount_percentage / 100);
$order_discount_total += $discount_amount;
}
if (!isset($customers[$customer_number])) {
$customers[$customer_number] = $this->buildCustomerEnvelope($customer_number, []);
$customers[$customer_number]['meta']['customer_prices'] = [
'discount_total' => 0.0,
'department_discount_totals' => [],
];
}
$customers[$customer_number]['transactions'][] = $this->buildTransactionObject($order_id, $created_at, $department_id, $order_discount_total);
$customers[$customer_number]['meta']['customer_prices']['discount_total'] += $order_discount_total;
if (!isset($customers[$customer_number]['meta']['customer_prices']['department_discount_totals'][$department_id])) {
$customers[$customer_number]['meta']['customer_prices']['department_discount_totals'][$department_id] = 0.0;
}
$customers[$customer_number]['meta']['customer_prices']['department_discount_totals'][$department_id] += $order_discount_total;
$collective['total_discount_amount'] += $order_discount_total;
if (!isset($collective['department_discount_totals'][$department_id])) {
$collective['department_discount_totals'][$department_id] = 0.0;
}
$collective['department_discount_totals'][$department_id] += $order_discount_total;
}
$customers = array_values(array_map(function ($customer) {
if (isset($customer['meta']['customer_prices'])) {
$customer['meta']['customer_prices']['discount_total'] = round((float)$customer['meta']['customer_prices']['discount_total'], 5);
$customer['meta']['customer_prices']['department_discount_totals'] = $this->roundMap(
$customer['meta']['customer_prices']['department_discount_totals']
);
}
return $customer;
}, $customers));
return [
'customers' => $customers,
'collective_results' => [
'total_discount_amount' => round((float)$collective['total_discount_amount'], 5),
'department_discount_totals' => $this->roundMap($collective['department_discount_totals']),
'department_discount_totals_parsed' => $this->parseDepartmentMap($collective['department_discount_totals']),
],
'warnings' => [],
];
}
private function buildDateRange(string $date_from, string $date_to): array
{
$from = date('Y-m-d 00:00:00', strtotime($date_from));
$to = date('Y-m-d 23:59:59', strtotime($date_to));
return [$from, $to];
}
private function fetchOrdersInRange(string $from_ts, string $to_ts): array
{
global $db;
$from = $db->escape_string($from_ts);
$to = $db->escape_string($to_ts);
$sql = "SELECT id, customer_id, department_id, created_at, reg_1
FROM orders
WHERE deleted_at IS NULL
AND created_at >= '$from'
AND created_at <= '$to'";
$result = $db->query($sql);
if (!$result) {
return [];
}
return $db->fetch_all($result);
}
private function fetchOrderItemsByOrderIds(array $order_ids): array
{
global $db;
$order_ids = array_values(array_unique(array_filter(array_map('intval', $order_ids), static fn($id) => $id > 0)));
if (empty($order_ids)) {
return [];
}
$sql = "SELECT order_id, product_id, price, quantity
FROM order_items
WHERE deleted_at IS NULL
AND order_id IN (" . implode(',', $order_ids) . ")";
$result = $db->query($sql);
if (!$result) {
return [];
}
$rows = $db->fetch_all($result);
$grouped = [];
foreach ($rows as $row) {
$order_id = (int)$row['order_id'];
if (!isset($grouped[$order_id])) {
$grouped[$order_id] = [];
}
$grouped[$order_id][] = $row;
}
return $grouped;
}
private function fetchVehicleSubscriptionVersionRows(string $from_ts, string $to_ts): array
{
global $db;
$from = $db->escape_string($from_ts);
$to = $db->escape_string($to_ts);
$sql = "SELECT *
FROM customer_vehicle_subscription_versions
WHERE wash_subscription = 1
AND effective_from <= '$to'
AND (effective_to IS NULL OR effective_to >= '$from')";
$result = $db->query($sql);
if (!$result) {
return [];
}
return $db->fetch_all($result);
}
private function versionOverlaps(array $version_row, string $from_ts, string $to_ts): bool
{
$version_from = (string)$version_row['effective_from'];
$version_to = $version_row['effective_to'] !== null ? (string)$version_row['effective_to'] : null;
if ($version_from > $to_ts) {
return false;
}
if ($version_to !== null && $version_to < $from_ts) {
return false;
}
return true;
}
private function buildSubscriptionFallbackDistribution(
int $customer_number,
string $month_key,
float $monthly_price,
array $customer_department_month_map
): array {
$distribution = [];
$department_counts = $customer_department_month_map[$customer_number][$month_key] ?? [];
if (!empty($department_counts)) {
$total = (float)array_sum($department_counts);
foreach ($department_counts as $department_id => $count) {
$distribution[(int)$department_id] = $monthly_price * ((float)$count / max($total, 1.0));
}
return $distribution;
}
$default_department = 1;
try {
$default = (new users_o())->getUserByCustomerNumber($customer_number)->getDefaultDepartment();
if (!empty($default)) {
$default_department = (int)$default;
}
} catch (Exception $e) {
// fall back to department 1
}
$distribution[$default_department] = $monthly_price;
return $distribution;
}
private function normalizeSubscriptionGroupAllocation(array $distribution, float $monthly_price): array
{
if (empty($distribution)) {
return [];
}
$has_fractional = false;
foreach ($distribution as $v) {
if (abs((float)$v - round((float)$v)) > 0.00001) {
$has_fractional = true;
break;
}
}
if (!$has_fractional) {
$departments = array_keys($distribution);
$count = count($departments);
if ($count === 0) {
return [];
}
$per_department = $monthly_price / $count;
$out = [];
foreach ($departments as $department_id) {
$out[(int)$department_id] = $per_department;
}
return $out;
}
return array_map(static fn($amount) => (float)$amount, $distribution);
}
private function calculateOrderOriginalPrice(array $order_items, int $customer_number, int $department_id, string $timestamp): float
{
$total = 0.0;
foreach ($order_items as $item) {
$product_id = (int)($item['product_id'] ?? 0);
$quantity = (float)($item['quantity'] ?? 0);
if ($product_id <= 0 || $quantity <= 0) {
continue;
}
$explicit_price = (float)($item['price'] ?? 0);
if ($explicit_price > 0) {
$line_price = $explicit_price * $quantity;
} else {
$line_price = ((float)$this->getProductDepartmentPrice($product_id, $department_id)) * $quantity;
}
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp);
$discount_percentage = (float)($discount_row['discount'] ?? 0);
if ($discount_percentage > 0) {
$line_price *= (1 - ($discount_percentage / 100));
}
$total += $line_price;
}
return $total;
}
private function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp): ?array
{
$cache_key = $customer_number . '|' . $product_id . '|' . substr($timestamp, 0, 19);
if (array_key_exists($cache_key, $this->discount_resolution_cache)) {
return $this->discount_resolution_cache[$cache_key];
}
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp);
if ($direct !== null && (int)($direct['discount'] ?? 0) > 0) {
return $this->discount_resolution_cache[$cache_key] = $direct;
}
$product = $this->getProduct($product_id);
if ($product !== null) {
$category = (string)$product->category->value();
if ($category !== '') {
$category_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, $category, $timestamp);
if ($category_discount !== null && (int)($category_discount['discount'] ?? 0) > 0) {
return $this->discount_resolution_cache[$cache_key] = $category_discount;
}
}
}
return $this->discount_resolution_cache[$cache_key] = null;
}
private function getProductDepartmentPrice(int $product_id, int $department_id): float
{
if (!isset($this->product_department_price_cache[$department_id][$product_id])) {
$product = $this->getProduct($product_id);
if ($product === null) {
$this->product_department_price_cache[$department_id][$product_id] = 0.0;
} else {
$this->product_department_price_cache[$department_id][$product_id] = (float)$product->getDepartmentPrice($department_id);
}
}
return (float)$this->product_department_price_cache[$department_id][$product_id];
}
private function getSubscriptionMonthlyPrice(int $vehicle_type): float
{
$product = $this->getProduct($vehicle_type);
if ($product === null) {
return 0.0;
}
return (float)$product->getSubscriptionMonthlyPrice();
}
private function getProduct(int $product_id): ?products_o
{
if (!isset($this->product_cache[$product_id])) {
$product = new products_o();
$product->select($product_id);
if (!$product->exists()) {
$this->product_cache[$product_id] = null;
} else {
$this->product_cache[$product_id] = $product;
}
}
return $this->product_cache[$product_id];
}
private function buildCustomerEnvelope(int $customer_number, array $transaction_map): array
{
return [
'id' => (new users_o())->getUserByCustomerNumber($customer_number)->id,
'customer_number' => $customer_number,
'customer_name' => $this->getCustomerName($customer_number),
'transactions' => array_values($transaction_map),
'requires_action' => false,
'meta' => [],
];
}
private function buildTransactionObject(int $order_id, string $created_at, int $department_id, ?float $amount = null): array
{
$order = (new orders_o())->select($order_id);
return [
'id' => $order_id,
'date' => $created_at,
'amount' => round((float)($amount ?? (float)$order->getNetAmount()), 5),
'booked' => $order->isBooked(true),
'department_id' => $department_id,
'excluded' => !$this->isDepartmentEligible($department_id),
];
}
private function getCustomerName(int $customer_number): string
{
if (!isset($this->customer_name_cache[$customer_number])) {
$this->customer_name_cache[$customer_number] = (new users_o())->getCustomerName($customer_number) ?? 'Unknown Customer';
}
return (string)$this->customer_name_cache[$customer_number];
}
private function isDepartmentEligible(int $department_id): bool
{
if ($department_id === 10 || $department_id <= 0) {
return false;
}
if (!array_key_exists($department_id, $this->department_excluded_cache)) {
try {
$this->department_excluded_cache[$department_id] = (new departments_o())->select($department_id)->isExcludedFromInvoicing();
} catch (Exception $e) {
$this->department_excluded_cache[$department_id] = false;
}
}
return !$this->department_excluded_cache[$department_id];
}
private function parseDepartmentMap(array $department_map): array
{
$parsed = [];
foreach ($department_map as $department_id => $amount) {
$parsed[$this->getDepartmentName((int)$department_id)] = round((float)$amount, 5);
}
return $parsed;
}
private function getDepartmentName(int $department_id): string
{
if (!isset($this->department_name_cache[$department_id])) {
try {
$name = (new departments_o())->select($department_id)->name->value();
$this->department_name_cache[$department_id] = !empty($name)
? (string)$name
: 'Unknown Department (' . $department_id . ')';
} catch (Exception $e) {
$this->department_name_cache[$department_id] = 'Unknown Department (' . $department_id . ')';
}
}
return (string)$this->department_name_cache[$department_id];
}
private function roundMap(array $map): array
{
$out = [];
foreach ($map as $key => $value) {
$out[(string)$key] = round((float)$value, 5);
}
return $out;
}
private function listMonthKeys(string $from_ts, string $to_ts): array
{
$start = new DateTime(date('Y-m-01 00:00:00', strtotime($from_ts)));
$end = new DateTime(date('Y-m-01 00:00:00', strtotime($to_ts)));
$end->modify('+1 month');
$period = new DatePeriod($start, new DateInterval('P1M'), $end);
$months = [];
foreach ($period as $dt) {
$months[] = $dt->format('Y-m');
}
return $months;
}
}
@@ -0,0 +1,290 @@
<?php
namespace classes;
use helpers\economic_invoice_booked;
use objects\collected_order_invoices_o;
use objects\order_items_o;
use objects\orders_o;
use objects\products_o;
class economic_v2_line_normalizer
{
public static function normalizeInternalCollectedInvoice(collected_order_invoices_o $invoice): array
{
$lines = [];
$warnings = [];
foreach ($invoice->getOrders() as $order_row) {
$order = (new orders_o())->select((int)$order_row['id']);
$department_id = (int)$order->department_id->value();
$order_items = (new order_items_o())->getAllItemsAsArray(
(int)$order->id,
['id', 'product_id', 'reference', 'notes', 'price', 'quantity', 'include_in_invoice']
);
foreach ($order_items as $row) {
if (!(bool)($row['include_in_invoice'] ?? false)) {
continue;
}
$product = (new products_o())->getProductById((int)$row['product_id']);
if (!$product->exists()) {
$warnings[] = 'Missing product for internal order item id ' . (int)$row['id'];
continue;
}
$product_number = $product->economic_product_id->value();
$quantity = (float)$row['quantity'];
$unit_net_price = (float)$row['price'];
$line_net_amount = $quantity * $unit_net_price;
$department_distribution = [$department_id => 100.0];
$line = [
'index' => count($lines),
'source' => 'internal',
'source_order_id' => (int)$order->id,
'source_line_id' => (int)$row['id'],
'line_type' => self::detectLineType($product_number, $unit_net_price, true),
'billable' => true,
'product_number' => $product_number !== null ? (string)$product_number : null,
'product_id' => (int)$row['product_id'],
'description' => (string)$product->name->value(),
'reference' => (string)($row['reference'] ?? ''),
'quantity' => $quantity,
'unit_net_price' => $unit_net_price,
'line_net_amount' => $line_net_amount,
'department_distribution' => $department_distribution,
];
$line['match_key'] = self::buildMatchKey($line);
$lines[] = $line;
}
}
return self::wrap('internal', $lines, $warnings);
}
public static function normalizeDraftInvoice(object|array|null $draft_invoice): array
{
if ($draft_invoice === null) {
return self::wrap('draft', [], ['Draft invoice missing']);
}
$data = self::toArray($draft_invoice);
$raw_lines = is_array($data['lines'] ?? null) ? $data['lines'] : [];
$lines = [];
foreach ($raw_lines as $raw_line) {
$line = self::normalizeEconomicLine($raw_line, 'draft');
$line['index'] = count($lines);
$line['source_line_id'] = isset($raw_line['lineNumber']) ? (int)$raw_line['lineNumber'] : (int)($raw_line['line_number'] ?? count($lines) + 1);
$line['match_key'] = self::buildMatchKey($line);
$lines[] = $line;
}
$wrapped = self::wrap('draft', $lines, []);
if (isset($data['netAmount'])) {
$wrapped['totals']['net_total'] = (float)$data['netAmount'];
} elseif (isset($data['net_amount'])) {
$wrapped['totals']['net_total'] = (float)$data['net_amount'];
}
$wrapped['totals']['difference_from_line_sum'] = round(
(float)$wrapped['totals']['net_total'] - (float)$wrapped['totals']['line_net_total'],
5
);
return $wrapped;
}
public static function normalizeBookedInvoice(object|array|null $booked_invoice): array
{
if ($booked_invoice === null) {
return self::wrap('booked', [], ['Booked invoice missing']);
}
if ($booked_invoice instanceof economic_invoice_booked) {
$data = $booked_invoice->toArray();
} else {
$data = self::toArray($booked_invoice);
}
$raw_lines = is_array($data['lines'] ?? null) ? $data['lines'] : [];
$lines = [];
foreach ($raw_lines as $raw_line) {
$line = self::normalizeEconomicLine($raw_line, 'booked');
$line['index'] = count($lines);
$line['source_line_id'] = (int)($raw_line['lineNumber'] ?? $raw_line['line_number'] ?? count($lines) + 1);
$line['match_key'] = self::buildMatchKey($line);
$lines[] = $line;
}
$wrapped = self::wrap('booked', $lines, []);
if (isset($data['netAmount'])) {
$wrapped['totals']['net_total'] = (float)$data['netAmount'];
} elseif (isset($data['net_amount'])) {
$wrapped['totals']['net_total'] = (float)$data['net_amount'];
}
$wrapped['totals']['difference_from_line_sum'] = round(
(float)$wrapped['totals']['net_total'] - (float)$wrapped['totals']['line_net_total'],
5
);
return $wrapped;
}
private static function normalizeEconomicLine(array $raw_line, string $source): array
{
$line = self::toArray($raw_line);
$product_number = $line['product']['productNumber']
?? $line['product']['product_number']
?? null;
$description = (string)($line['description'] ?? '');
$quantity = isset($line['quantity']) ? (float)$line['quantity'] : 0.0;
$unit_net_price = isset($line['unitNetPrice'])
? (float)$line['unitNetPrice']
: (isset($line['unit_net_price']) ? (float)$line['unit_net_price'] : 0.0);
$line_net_amount = isset($line['totalNetAmount'])
? (float)$line['totalNetAmount']
: (isset($line['total_net_amount']) ? (float)$line['total_net_amount'] : $quantity * $unit_net_price);
$department_distribution = self::extractDepartmentDistribution($line);
$billable = ($product_number !== null) || abs($line_net_amount) > 0.00001 || abs($quantity) > 0.00001;
return [
'source' => $source,
'source_order_id' => null,
'line_type' => self::detectLineType($product_number, $unit_net_price, $billable),
'billable' => $billable,
'product_number' => $product_number !== null ? (string)$product_number : null,
'product_id' => null,
'description' => $description,
'reference' => '',
'quantity' => $quantity,
'unit_net_price' => $unit_net_price,
'line_net_amount' => $line_net_amount,
'department_distribution' => $department_distribution,
];
}
private static function extractDepartmentDistribution(array $line): array
{
$distribution = [];
$dd = $line['departmentalDistribution'] ?? $line['departmental_distribution'] ?? null;
if (is_array($dd)) {
$distributions = $dd['distributions'] ?? null;
if (is_array($distributions)) {
foreach ($distributions as $entry) {
$department_number = $entry['department']['departmentNumber']
?? $entry['department']['department_number']
?? null;
if ($department_number === null) {
continue;
}
$distribution[(string)$department_number] = (float)($entry['percentage'] ?? 0.0);
}
}
if (empty($distribution) && isset($dd['departmentalDistributionNumber'])) {
$distribution[(string)$dd['departmentalDistributionNumber']] = 100.0;
} elseif (empty($distribution) && isset($dd['departmental_distribution_number'])) {
$distribution[(string)$dd['departmental_distribution_number']] = 100.0;
}
}
if (empty($distribution)) {
$distribution['unassigned'] = 100.0;
}
return $distribution;
}
private static function wrap(string $source, array $lines, array $warnings): array
{
$net_total = 0.0;
$line_net_total = 0.0;
$billable_count = 0;
$departments = [];
foreach ($lines as $line) {
$line_net_total += (float)$line['line_net_amount'];
if (!(bool)$line['billable']) {
continue;
}
$billable_count++;
$line_amount = (float)$line['line_net_amount'];
$net_total += $line_amount;
foreach ($line['department_distribution'] as $department_key => $percentage) {
if (!isset($departments[$department_key])) {
$departments[$department_key] = 0.0;
}
$departments[$department_key] += $line_amount * ((float)$percentage / 100);
}
}
return [
'source' => $source,
'totals' => [
'net_total' => round($net_total, 5),
'line_net_total' => round($line_net_total, 5),
'line_count' => count($lines),
'billable_line_count' => $billable_count,
],
'departments' => self::roundMap($departments),
'lines' => $lines,
'warnings' => $warnings,
];
}
private static function buildMatchKey(array $line): string
{
if (!empty($line['product_number'])) {
return 'product:' . strtolower(trim((string)$line['product_number'])) .
'|ref:' . strtolower(trim((string)($line['reference'] ?? '')));
}
return 'text:' . self::normalizeText((string)$line['description']);
}
private static function detectLineType(mixed $product_number, float $unit_net_price, bool $billable): string
{
if (!$billable) {
return 'text';
}
if ($product_number !== null && strtolower((string)$product_number) === 'totdiscount') {
return 'discount';
}
if ($unit_net_price < 0) {
return 'discount';
}
return $product_number !== null ? 'product' : 'text';
}
private static function normalizeText(string $text): string
{
$text = trim(strtolower($text));
$text = preg_replace('/\s+/', ' ', $text);
return $text ?? '';
}
private static function roundMap(array $map): array
{
$rounded = [];
foreach ($map as $k => $v) {
$rounded[(string)$k] = round((float)$v, 5);
}
return $rounded;
}
private static function toArray(object|array $value): array
{
if (is_array($value)) {
return $value;
}
return json_decode(json_encode($value, JSON_UNESCAPED_UNICODE), true) ?: [];
}
}
@@ -0,0 +1,503 @@
<?php
namespace classes;
class economic_v2_revenue_statistics_service
{
private const DEFAULT_PAGE_SIZE = 1000;
private const DEFAULT_MAX_PAGES = 10;
private const EPSILON = 0.00001;
private economic $economic;
/** @var array<int, array{customer_number:int,name:?string,barred:?bool,status:string}> */
private array $customer_cache = [];
public function __construct(?economic $economic = null)
{
$this->economic = $economic ?? new economic();
}
public function getBookedRevenueStatistics(array $filters = []): array
{
$normalized_filters = $this->normalizeFilters($filters);
$warnings = [];
$booked_invoices = $this->fetchBookedInvoices($normalized_filters, $warnings);
$invoice_ids = [];
foreach ($booked_invoices as $invoice) {
$invoice_id = (int)($invoice->bookedInvoiceNumber ?? 0);
if ($invoice_id > 0) {
$invoice_ids[] = $invoice_id;
}
}
$invoice_lines_map = [];
if (!empty($invoice_ids)) {
try {
$invoice_lines_map = $this->economic->invoices->booked->get_invoice_lines($invoice_ids);
} catch (\Throwable $e) {
$warnings[] = 'Unable to fetch booked invoice lines in bulk: ' . $e->getMessage();
}
}
$summary = [
'invoice_count' => 0,
'line_count' => 0,
'unique_customers' => 0,
'net_amount' => 0.0,
'vat_amount' => 0.0,
'gross_amount' => 0.0,
'average_invoice_net_amount' => 0.0,
];
$customers = [];
$departments = [];
$currencies = [];
$seen_customers = [];
$customer_filter_map = array_fill_keys($normalized_filters['customer_numbers'], true);
$department_filter_map = array_fill_keys($normalized_filters['department_numbers'], true);
$has_customer_filter = !empty($customer_filter_map);
$has_department_filter = !empty($department_filter_map);
foreach ($booked_invoices as $invoice) {
$invoice_id = (int)($invoice->bookedInvoiceNumber ?? 0);
if ($invoice_id <= 0) {
continue;
}
$customer_number = (int)($invoice->customer->customerNumber ?? 0);
if ($has_customer_filter && !isset($customer_filter_map[$customer_number])) {
continue;
}
$customer_snapshot = $this->resolveCustomerSnapshot($customer_number, $warnings);
if (!$this->passesBarredFilter($customer_snapshot['barred'], $normalized_filters['barred'])) {
continue;
}
$invoice_currency = strtoupper((string)($invoice->currency ?? ''));
if ($normalized_filters['currency'] !== null && $invoice_currency !== $normalized_filters['currency']) {
continue;
}
$invoice_lines = $invoice_lines_map[$invoice_id] ?? [];
$line_reduction = $this->reduceInvoiceLines(
$invoice_id,
$invoice_lines,
$departments,
$department_filter_map,
$has_department_filter
);
if ($has_department_filter && !$line_reduction['has_matching_departments']) {
continue;
}
$invoice_net = $has_department_filter
? (float)$line_reduction['net_amount']
: (float)($invoice->netAmount ?? $invoice->net_amount ?? $line_reduction['net_amount']);
$invoice_vat = $has_department_filter
? (float)$line_reduction['vat_amount']
: (float)($invoice->vatAmount ?? $invoice->vat_amount ?? $line_reduction['vat_amount']);
$invoice_gross = $has_department_filter
? (float)$line_reduction['gross_amount']
: (float)($invoice->grossAmount ?? $invoice->gross_amount ?? ($invoice_net + $invoice_vat));
if (
$has_department_filter &&
abs($invoice_net) < self::EPSILON &&
abs($invoice_vat) < self::EPSILON &&
abs($invoice_gross) < self::EPSILON
) {
continue;
}
$summary['invoice_count']++;
$summary['line_count'] += (int)$line_reduction['line_count'];
$summary['net_amount'] += $invoice_net;
$summary['vat_amount'] += $invoice_vat;
$summary['gross_amount'] += $invoice_gross;
if (!isset($seen_customers[$customer_number])) {
$seen_customers[$customer_number] = true;
}
if (!isset($customers[$customer_number])) {
$customers[$customer_number] = [
'customer_number' => $customer_number,
'customer_name' => $customer_snapshot['name'],
'barred' => $customer_snapshot['barred'],
'invoice_count' => 0,
'net_amount' => 0.0,
'vat_amount' => 0.0,
'gross_amount' => 0.0,
];
}
$customers[$customer_number]['invoice_count']++;
$customers[$customer_number]['net_amount'] += $invoice_net;
$customers[$customer_number]['vat_amount'] += $invoice_vat;
$customers[$customer_number]['gross_amount'] += $invoice_gross;
$currency_key = $invoice_currency !== '' ? $invoice_currency : 'UNKNOWN';
if (!isset($currencies[$currency_key])) {
$currencies[$currency_key] = [
'currency' => $currency_key,
'invoice_count' => 0,
'net_amount' => 0.0,
'vat_amount' => 0.0,
'gross_amount' => 0.0,
];
}
$currencies[$currency_key]['invoice_count']++;
$currencies[$currency_key]['net_amount'] += $invoice_net;
$currencies[$currency_key]['vat_amount'] += $invoice_vat;
$currencies[$currency_key]['gross_amount'] += $invoice_gross;
}
$summary['unique_customers'] = count($seen_customers);
if ($summary['invoice_count'] > 0) {
$summary['average_invoice_net_amount'] = $summary['net_amount'] / $summary['invoice_count'];
}
$customer_rows = array_values($customers);
usort($customer_rows, static function (array $a, array $b): int {
return $b['invoice_count'] <=> $a['invoice_count'];
});
$department_rows = [];
foreach ($departments as $department_key => $row) {
$department_rows[] = [
'department_key' => $department_key,
'department_number' => is_numeric((string)$department_key) ? (int)$department_key : null,
'invoice_count' => count($row['invoice_ids']),
'line_count' => $row['line_count'],
'net_amount' => $row['net_amount'],
'vat_amount' => $row['vat_amount'],
'gross_amount' => $row['gross_amount'],
];
}
usort($department_rows, static function (array $a, array $b): int {
return abs((float)$b['net_amount']) <=> abs((float)$a['net_amount']);
});
$currency_rows = array_values($currencies);
usort($currency_rows, static function (array $a, array $b): int {
return $b['invoice_count'] <=> $a['invoice_count'];
});
return [
'filters' => [
'dateFrom' => $normalized_filters['dateFrom'],
'dateTo' => $normalized_filters['dateTo'],
'customer_numbers' => array_values($normalized_filters['customer_numbers']),
'department_numbers' => array_values($normalized_filters['department_numbers']),
'currency' => $normalized_filters['currency'],
'barred' => $normalized_filters['barred'],
'max_pages' => $normalized_filters['max_pages'],
],
'summary' => $this->roundNumericValues($summary),
'customers' => $this->roundRows($customer_rows),
'departments' => $this->roundRows($department_rows),
'currencies' => $this->roundRows($currency_rows),
'warnings' => array_values(array_unique($warnings)),
];
}
private function normalizeFilters(array $filters): array
{
$date_from = (string)($filters['dateFrom'] ?? date('Y-m-01'));
$date_to = (string)($filters['dateTo'] ?? date('Y-m-d'));
$customer_numbers = $this->normalizeIntegerList($filters['customer_numbers'] ?? []);
$department_numbers = $this->normalizeIntegerList($filters['department_numbers'] ?? []);
$currency = isset($filters['currency']) && trim((string)$filters['currency']) !== ''
? strtoupper(trim((string)$filters['currency']))
: null;
$barred = strtolower(trim((string)($filters['barred'] ?? 'all')));
if (!in_array($barred, ['all', 'barred', 'active'], true)) {
$barred = 'all';
}
$max_pages = (int)($filters['max_pages'] ?? self::DEFAULT_MAX_PAGES);
$max_pages = max(1, min(200, $max_pages));
return [
'dateFrom' => $date_from,
'dateTo' => $date_to,
'customer_numbers' => $customer_numbers,
'department_numbers' => $department_numbers,
'currency' => $currency,
'barred' => $barred,
'max_pages' => $max_pages,
];
}
private function fetchBookedInvoices(array $normalized_filters, array &$warnings): array
{
$filters = [
'(date$gte:' . $normalized_filters['dateFrom'] . '$and:date$lte:' . $normalized_filters['dateTo'] . ')' => '',
];
if ($normalized_filters['currency'] !== null) {
$filters['currency'] = '$eq:' . $normalized_filters['currency'];
}
if (count($normalized_filters['customer_numbers']) === 1) {
$filters['customer.customerNumber'] = '$eq:' . $normalized_filters['customer_numbers'][0];
}
$all = [];
for ($page = 0; $page < $normalized_filters['max_pages']; $page++) {
$response = $this->economic->invoices->booked->get(
$filters,
[
'skipPages' => $page,
'pageSize' => self::DEFAULT_PAGE_SIZE,
]
);
$collection = is_array($response->collection ?? null) ? $response->collection : [];
$all = array_merge($all, $collection);
if (count($collection) < self::DEFAULT_PAGE_SIZE) {
break;
}
if ($page + 1 >= $normalized_filters['max_pages']) {
$warnings[] = 'Reached pagination safety limit (max_pages=' . $normalized_filters['max_pages'] . ').';
}
}
return $all;
}
/**
* @param array<int|string, mixed> $department_totals
* @param array<int, bool> $department_filter_map
* @return array{net_amount:float,vat_amount:float,gross_amount:float,line_count:int,has_matching_departments:bool}
*/
private function reduceInvoiceLines(
int $invoice_id,
array $invoice_lines,
array &$department_totals,
array $department_filter_map,
bool $has_department_filter
): array {
$invoice_net = 0.0;
$invoice_vat = 0.0;
$invoice_gross = 0.0;
$line_count = 0;
$has_matching_departments = false;
foreach ($invoice_lines as $line_raw) {
$line = $this->toArray($line_raw);
$quantity = isset($line['quantity']) ? (float)$line['quantity'] : 0.0;
$unit_net_price = isset($line['unitNetPrice'])
? (float)$line['unitNetPrice']
: (isset($line['unit_net_price']) ? (float)$line['unit_net_price'] : 0.0);
$line_net_amount = isset($line['totalNetAmount'])
? (float)$line['totalNetAmount']
: (isset($line['total_net_amount']) ? (float)$line['total_net_amount'] : $quantity * $unit_net_price);
$line_vat_amount = isset($line['vatAmount'])
? (float)$line['vatAmount']
: (isset($line['vat_amount']) ? (float)$line['vat_amount'] : $line_net_amount * ((float)($line['vatRate'] ?? 0.0) / 100));
$line_gross_amount = $line_net_amount + $line_vat_amount;
$is_billable = abs($line_net_amount) > self::EPSILON || abs($quantity) > self::EPSILON;
if (!$is_billable) {
continue;
}
$distribution = $this->extractDepartmentDistribution($line);
$matching_percentage_total = 0.0;
foreach ($distribution as $department_key => $percentage) {
if ($has_department_filter && !isset($department_filter_map[(int)$department_key])) {
continue;
}
$matching_percentage_total += (float)$percentage;
$has_matching_departments = true;
if (!isset($department_totals[$department_key])) {
$department_totals[$department_key] = [
'invoice_ids' => [],
'line_count' => 0,
'net_amount' => 0.0,
'vat_amount' => 0.0,
'gross_amount' => 0.0,
];
}
$ratio = ((float)$percentage / 100.0);
$department_totals[$department_key]['invoice_ids'][$invoice_id] = true;
$department_totals[$department_key]['line_count']++;
$department_totals[$department_key]['net_amount'] += $line_net_amount * $ratio;
$department_totals[$department_key]['vat_amount'] += $line_vat_amount * $ratio;
$department_totals[$department_key]['gross_amount'] += $line_gross_amount * $ratio;
}
if ($has_department_filter && $matching_percentage_total <= self::EPSILON) {
continue;
}
$factor = $has_department_filter ? ($matching_percentage_total / 100.0) : 1.0;
$invoice_net += $line_net_amount * $factor;
$invoice_vat += $line_vat_amount * $factor;
$invoice_gross += $line_gross_amount * $factor;
$line_count++;
}
return [
'net_amount' => $invoice_net,
'vat_amount' => $invoice_vat,
'gross_amount' => $invoice_gross,
'line_count' => $line_count,
'has_matching_departments' => $has_matching_departments,
];
}
/**
* @return array<int|string, float>
*/
private function extractDepartmentDistribution(array $line): array
{
$distribution = [];
$departmental_distribution = $line['departmentalDistribution'] ?? $line['departmental_distribution'] ?? null;
if (!is_array($departmental_distribution)) {
return ['unassigned' => 100.0];
}
$distributions = $departmental_distribution['distributions'] ?? null;
if (is_array($distributions)) {
foreach ($distributions as $entry_raw) {
$entry = $this->toArray($entry_raw);
$department_number = $entry['department']['departmentNumber']
?? $entry['department']['department_number']
?? null;
if ($department_number === null) {
continue;
}
$distribution[(int)$department_number] = (float)($entry['percentage'] ?? 0.0);
}
}
if (empty($distribution)) {
$fallback_number = $departmental_distribution['departmentalDistributionNumber']
?? $departmental_distribution['departmental_distribution_number']
?? null;
if ($fallback_number !== null) {
$distribution[(int)$fallback_number] = 100.0;
}
}
if (empty($distribution)) {
$distribution['unassigned'] = 100.0;
}
return $distribution;
}
/**
* @return array{customer_number:int,name:?string,barred:?bool,status:string}
*/
private function resolveCustomerSnapshot(int $customer_number, array &$warnings): array
{
if (isset($this->customer_cache[$customer_number])) {
return $this->customer_cache[$customer_number];
}
$snapshot = [
'customer_number' => $customer_number,
'name' => null,
'barred' => null,
'status' => 'unknown',
];
if ($customer_number <= 0) {
$this->customer_cache[$customer_number] = $snapshot;
return $snapshot;
}
try {
$raw = $this->economic->customers->customers->get($customer_number);
if (isset($raw->customerNumber)) {
$snapshot['name'] = isset($raw->name) ? (string)$raw->name : null;
$snapshot['barred'] = isset($raw->barred) ? (bool)$raw->barred : null;
$snapshot['status'] = 'resolved';
} else {
$warnings[] = 'Unable to resolve e-conomic customer ' . $customer_number . ' while evaluating barred filter.';
}
} catch (\Throwable $e) {
$warnings[] = 'Failed to fetch e-conomic customer ' . $customer_number . ': ' . $e->getMessage();
}
$this->customer_cache[$customer_number] = $snapshot;
return $snapshot;
}
private function passesBarredFilter(?bool $barred, string $mode): bool
{
return match ($mode) {
'barred' => $barred === true,
'active' => $barred !== true,
default => true,
};
}
private function toArray(mixed $value): array
{
if (is_array($value)) {
return $value;
}
if (!is_object($value)) {
return [];
}
return json_decode(json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), true) ?: [];
}
private function normalizeIntegerList(mixed $raw): array
{
$values = [];
if (is_array($raw)) {
$values = $raw;
} elseif (is_string($raw)) {
$values = explode(',', $raw);
} elseif (is_numeric($raw)) {
$values = [$raw];
}
$normalized = [];
foreach ($values as $value) {
$int_value = (int)$value;
if ($int_value > 0) {
$normalized[$int_value] = true;
}
}
return array_map('intval', array_keys($normalized));
}
private function roundRows(array $rows): array
{
$result = [];
foreach ($rows as $row) {
$result[] = $this->roundNumericValues($row);
}
return $result;
}
private function roundNumericValues(array $data): array
{
foreach ($data as $key => $value) {
if (is_array($value)) {
$data[$key] = $this->roundNumericValues($value);
continue;
}
if (is_float($value)) {
$data[$key] = round($value, 5);
}
}
return $data;
}
}
@@ -0,0 +1,109 @@
<?php
namespace classes;
/**
* Ensures additive V2 history tables exist.
*
* This project has no centralized migration runner, so we keep bootstrap
* idempotent and safe to call from runtime flows.
*/
class economic_v2_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
$queries = [
"CREATE TABLE IF NOT EXISTS customer_fixed_pricing_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
customer_number INT NOT NULL,
price INT NOT NULL,
description VARCHAR(255) NULL,
effective_from DATETIME NOT NULL,
effective_to DATETIME NULL,
source VARCHAR(64) NOT NULL DEFAULT 'live',
confidence DECIMAL(6,5) NOT NULL DEFAULT 1.00000,
inferred TINYINT(1) NOT NULL DEFAULT 0,
metadata_json TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_customer_fixed_versions_lookup (customer_number, effective_from, effective_to),
INDEX idx_customer_fixed_versions_source (source)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS customer_vehicle_subscription_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
vehicle_id INT NULL,
customer_number INT NOT NULL,
reg VARCHAR(64) NOT NULL,
vehicle_type INT NOT NULL,
wash_subscription TINYINT(1) NOT NULL,
effective_from DATETIME NOT NULL,
effective_to DATETIME NULL,
source VARCHAR(64) NOT NULL DEFAULT 'live',
confidence DECIMAL(6,5) NOT NULL DEFAULT 1.00000,
inferred TINYINT(1) NOT NULL DEFAULT 0,
metadata_json TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_vehicle_subscription_versions_lookup (customer_number, reg, effective_from, effective_to),
INDEX idx_vehicle_subscription_versions_vehicle (vehicle_id),
INDEX idx_vehicle_subscription_versions_source (source)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS customer_discount_override_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
customer_number INT NOT NULL,
is_category TINYINT(1) NOT NULL,
object_id VARCHAR(64) NOT NULL,
discount INT NOT NULL,
effective_from DATETIME NOT NULL,
effective_to DATETIME NULL,
source VARCHAR(64) NOT NULL DEFAULT 'live',
confidence DECIMAL(6,5) NOT NULL DEFAULT 1.00000,
inferred TINYINT(1) NOT NULL DEFAULT 0,
metadata_json TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_discount_override_versions_lookup (customer_number, is_category, object_id, effective_from, effective_to),
INDEX idx_discount_override_versions_user (user_id),
INDEX idx_discount_override_versions_source (source)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($queries as $sql) {
$db->query($sql);
}
self::$initialized = true;
}
public static function tableHasColumn(string $table, string $column): bool
{
global $db;
$table = $db->escape_string($table);
$column = $db->escape_string($column);
$database = $db->escape_string($db->getDatabase());
$sql = "SELECT COUNT(*) AS c
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = '$database'
AND TABLE_NAME = '$table'
AND COLUMN_NAME = '$column'";
$result = $db->query($sql);
if (!$result) {
return false;
}
$row = $result->fetch_assoc();
return ((int)($row['c'] ?? 0)) > 0;
}
}
@@ -0,0 +1,710 @@
<?php
namespace classes;
use DateTime;
use Exception;
class economic_v2_versioning_service
{
public function __construct()
{
economic_v2_schema_bootstrap::ensureTables();
}
public function recordFixedPricingVersion(
int $customer_number,
?int $price,
?string $description,
?string $effective_from = null,
string $source = 'live.fixed_pricing',
float $confidence = 1.0,
bool $inferred = false,
array $metadata = []
): array {
if ($price === null) {
return $this->closeActiveFixedPricingVersion(
$customer_number,
$effective_from,
$source,
$confidence,
$inferred,
$metadata
);
}
return $this->upsertVersion(
'customer_fixed_pricing_versions',
[
'customer_number' => $customer_number,
],
[
'price' => (int)$price,
'description' => $description ?? '',
],
$this->normalizeDatetime($effective_from),
$source,
$confidence,
$inferred,
$metadata
);
}
public function closeActiveFixedPricingVersion(
int $customer_number,
?string $effective_to = null,
string $source = 'live.fixed_pricing',
float $confidence = 1.0,
bool $inferred = false,
array $metadata = []
): array {
return $this->closeActiveVersion(
'customer_fixed_pricing_versions',
[
'customer_number' => $customer_number,
],
$this->normalizeDatetime($effective_to),
$source,
$confidence,
$inferred,
$metadata
);
}
public function recordVehicleSubscriptionVersion(
array $state,
?string $effective_from = null,
string $source = 'live.vehicle',
float $confidence = 1.0,
bool $inferred = false,
array $metadata = []
): array {
if (!isset($state['customer_number'], $state['reg'], $state['vehicle_type'], $state['wash_subscription'])) {
throw new Exception('Missing required vehicle version state keys');
}
return $this->upsertVersion(
'customer_vehicle_subscription_versions',
[
'customer_number' => (int)$state['customer_number'],
'reg' => (string)$state['reg'],
],
[
'vehicle_id' => isset($state['vehicle_id']) ? (int)$state['vehicle_id'] : null,
'vehicle_type' => (int)$state['vehicle_type'],
'wash_subscription' => (int)((bool)$state['wash_subscription']),
],
$this->normalizeDatetime($effective_from),
$source,
$confidence,
$inferred,
$metadata
);
}
public function closeActiveVehicleSubscriptionVersion(
int $customer_number,
string $reg,
?string $effective_to = null,
string $source = 'live.vehicle',
float $confidence = 1.0,
bool $inferred = false,
array $metadata = []
): array {
return $this->closeActiveVersion(
'customer_vehicle_subscription_versions',
[
'customer_number' => $customer_number,
'reg' => $reg,
],
$this->normalizeDatetime($effective_to),
$source,
$confidence,
$inferred,
$metadata
);
}
public function recordDiscountOverrideVersion(
int $user_id,
int $customer_number,
bool $is_category,
int|string $object_id,
?int $discount,
?string $effective_from = null,
string $source = 'live.discount_override',
float $confidence = 1.0,
bool $inferred = false,
array $metadata = []
): array {
$identity = [
'user_id' => $user_id,
'customer_number' => $customer_number,
'is_category' => (int)$is_category,
'object_id' => (string)$object_id,
];
if ($discount === null || (int)$discount === 0) {
return $this->closeActiveVersion(
'customer_discount_override_versions',
$identity,
$this->normalizeDatetime($effective_from),
$source,
$confidence,
$inferred,
$metadata
);
}
return $this->upsertVersion(
'customer_discount_override_versions',
$identity,
[
'discount' => (int)$discount,
],
$this->normalizeDatetime($effective_from),
$source,
$confidence,
$inferred,
$metadata
);
}
public function listFixedPricingVersions(int $customer_number, ?string $date_from = null, ?string $date_to = null): array
{
return $this->listVersions(
'customer_fixed_pricing_versions',
['customer_number' => $customer_number],
$date_from,
$date_to
);
}
public function listVehicleSubscriptionVersions(int $customer_number, ?string $date_from = null, ?string $date_to = null): array
{
return $this->listVersions(
'customer_vehicle_subscription_versions',
['customer_number' => $customer_number],
$date_from,
$date_to
);
}
public function listDiscountOverrideVersions(int $customer_number, ?string $date_from = null, ?string $date_to = null): array
{
return $this->listVersions(
'customer_discount_override_versions',
['customer_number' => $customer_number],
$date_from,
$date_to
);
}
public function resolveFixedPricingVersionAt(int $customer_number, string $timestamp): ?array
{
$rows = $this->resolveActiveVersions(
'customer_fixed_pricing_versions',
['customer_number' => $customer_number],
$timestamp,
'effective_from DESC, id DESC',
1
);
return $rows[0] ?? null;
}
public function resolveVehicleSubscriptionVersionsAt(int $customer_number, string $timestamp): array
{
$rows = $this->resolveActiveVersions(
'customer_vehicle_subscription_versions',
[
'customer_number' => $customer_number,
'wash_subscription' => 1,
],
$timestamp,
'reg ASC, effective_from DESC, id DESC'
);
$unique = [];
foreach ($rows as $row) {
$reg = (string)$row['reg'];
if (!isset($unique[$reg])) {
$unique[$reg] = $row;
}
}
return array_values($unique);
}
public function resolveDiscountOverrideAt(
int $customer_number,
bool $is_category,
int|string $object_id,
string $timestamp
): ?array {
$rows = $this->resolveActiveVersions(
'customer_discount_override_versions',
[
'customer_number' => $customer_number,
'is_category' => (int)$is_category,
'object_id' => (string)$object_id,
],
$timestamp,
'effective_from DESC, id DESC',
1
);
return $rows[0] ?? null;
}
public function runBestEffortBackfill(): array
{
global $db;
economic_v2_schema_bootstrap::ensureTables();
$report = [
'fixed_pricing' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0],
'vehicle_subscriptions' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0],
'discount_overrides' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0],
'inferred' => ['fixed_pricing' => 0, 'vehicle_subscriptions' => 0],
'warnings' => [],
];
// Fixed pricing current state.
$has_fixed_created_at = economic_v2_schema_bootstrap::tableHasColumn('customer_fixed_pricing', 'created_at');
$fixed_cols = $has_fixed_created_at
? 'customer_number, price, description, created_at'
: 'customer_number, price, description';
$fixed_rows = $this->fetchAll("SELECT $fixed_cols FROM customer_fixed_pricing");
foreach ($fixed_rows as $row) {
$effective_from = $has_fixed_created_at
? $this->normalizeDatetime((string)$row['created_at'])
: $this->normalizeDatetime(null);
$confidence = $has_fixed_created_at ? 0.8 : 0.6;
$result = $this->recordFixedPricingVersion(
(int)$row['customer_number'],
(int)$row['price'],
(string)($row['description'] ?? ''),
$effective_from,
'backfill.current_fixed_pricing',
$confidence,
true,
['table' => 'customer_fixed_pricing']
);
$this->incrementReportAction($report['fixed_pricing'], $result['action'] ?? 'noop');
}
// Infer fixed pricing start from synthetic fixed-price orders when no timeline exists.
$fixed_inferred = $this->fetchAll(
"SELECT o.customer_id AS customer_number, MIN(o.created_at) AS first_seen, MAX(oi.price) AS inferred_price
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.deleted_at IS NULL
AND oi.deleted_at IS NULL
AND o.reference = 'Fast pris aftale'
AND oi.product_id = 61
GROUP BY o.customer_id"
);
foreach ($fixed_inferred as $row) {
$customer_number = (int)$row['customer_number'];
if ($this->resolveFixedPricingVersionAt($customer_number, (string)$row['first_seen']) !== null) {
continue;
}
$price = (int)($row['inferred_price'] ?? 0);
if ($price <= 0) {
continue;
}
$this->recordFixedPricingVersion(
$customer_number,
$price,
'Inferred from fixed-pricing invoice order',
$this->normalizeDatetime((string)$row['first_seen']),
'backfill.inferred_fixed_pricing_order',
0.55,
true,
['reference' => 'Fast pris aftale', 'product_id' => 61]
);
$report['inferred']['fixed_pricing']++;
}
// Vehicle subscriptions current state.
$has_vehicle_created_at = economic_v2_schema_bootstrap::tableHasColumn('customer_vehicles', 'created_at');
$has_vehicle_deleted_at = economic_v2_schema_bootstrap::tableHasColumn('customer_vehicles', 'deleted_at');
$vehicle_cols = 'id, customer_id, reg, type, wash_subscription' .
($has_vehicle_created_at ? ', created_at' : '') .
($has_vehicle_deleted_at ? ', deleted_at' : '');
$vehicle_rows = $this->fetchAll("SELECT $vehicle_cols FROM customer_vehicles");
foreach ($vehicle_rows as $row) {
$effective_from = $has_vehicle_created_at
? $this->normalizeDatetime((string)$row['created_at'])
: $this->normalizeDatetime(null);
$confidence = $has_vehicle_created_at ? 0.75 : 0.55;
$result = $this->recordVehicleSubscriptionVersion(
[
'vehicle_id' => (int)$row['id'],
'customer_number' => (int)$row['customer_id'],
'reg' => (string)$row['reg'],
'vehicle_type' => (int)$row['type'],
'wash_subscription' => (bool)$row['wash_subscription'],
],
$effective_from,
'backfill.current_vehicle',
$confidence,
true,
['table' => 'customer_vehicles']
);
$this->incrementReportAction($report['vehicle_subscriptions'], $result['action'] ?? 'noop');
if ($has_vehicle_deleted_at && !empty($row['deleted_at'])) {
$close_result = $this->closeActiveVehicleSubscriptionVersion(
(int)$row['customer_id'],
(string)$row['reg'],
$this->normalizeDatetime((string)$row['deleted_at']),
'backfill.current_vehicle_deleted',
0.9,
true,
['table' => 'customer_vehicles']
);
$this->incrementReportAction($report['vehicle_subscriptions'], $close_result['action'] ?? 'noop');
}
}
// Infer subscriptions from synthetic subscription orders.
$subscription_inferred = $this->fetchAll(
"SELECT o.customer_id AS customer_number,
oi.reference AS reg,
oi.product_id AS vehicle_type,
MIN(o.created_at) AS first_seen
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.deleted_at IS NULL
AND oi.deleted_at IS NULL
AND o.reference = 'Vaskeabonnementer'
AND oi.reference <> ''
AND oi.quantity > 0
GROUP BY o.customer_id, oi.reference, oi.product_id"
);
foreach ($subscription_inferred as $row) {
$resolved = $this->resolveVehicleSubscriptionVersionsAt((int)$row['customer_number'], (string)$row['first_seen']);
$already = false;
foreach ($resolved as $active) {
if ((string)$active['reg'] === (string)$row['reg']) {
$already = true;
break;
}
}
if ($already) {
continue;
}
$this->recordVehicleSubscriptionVersion(
[
'vehicle_id' => null,
'customer_number' => (int)$row['customer_number'],
'reg' => (string)$row['reg'],
'vehicle_type' => (int)$row['vehicle_type'],
'wash_subscription' => true,
],
$this->normalizeDatetime((string)$row['first_seen']),
'backfill.inferred_subscription_order',
0.5,
true,
['reference' => 'Vaskeabonnementer']
);
$report['inferred']['vehicle_subscriptions']++;
}
// Discount overrides current state.
$has_override_created_at = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'created_at');
$discount_cols = 'po.user_id, u.customer_number, po.is_category, po.product_or_category_id, po.percentage' .
($has_override_created_at ? ', po.created_at' : '');
$discount_rows = $this->fetchAll(
"SELECT $discount_cols
FROM price_overrides po
JOIN users u ON u.id = po.user_id"
);
foreach ($discount_rows as $row) {
$effective_from = $has_override_created_at
? $this->normalizeDatetime((string)$row['created_at'])
: $this->normalizeDatetime(null);
$confidence = $has_override_created_at ? 0.85 : 0.6;
$result = $this->recordDiscountOverrideVersion(
(int)$row['user_id'],
(int)$row['customer_number'],
(bool)$row['is_category'],
(string)$row['product_or_category_id'],
(int)$row['percentage'],
$effective_from,
'backfill.current_discount_override',
$confidence,
true,
['table' => 'price_overrides']
);
$this->incrementReportAction($report['discount_overrides'], $result['action'] ?? 'noop');
}
return $report;
}
private function upsertVersion(
string $table,
array $identity,
array $values,
string $effective_from,
string $source,
float $confidence,
bool $inferred,
array $metadata
): array {
global $db;
$confidence = $this->normalizeConfidence($confidence);
$metadata_json = $db->escape_string(json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$source = $db->escape_string($source);
$effective_from = $db->escape_string($effective_from);
// Close the previous active interval when a new one starts.
$close_to = $db->escape_string($this->minusOneSecond($effective_from));
$identity_where = $this->buildWhereClause($identity);
$db->query(
"UPDATE $table
SET effective_to = '$close_to'
WHERE $identity_where
AND effective_from < '$effective_from'
AND (effective_to IS NULL OR effective_to >= '$effective_from')"
);
$existing = $this->fetchOne(
"SELECT id
FROM $table
WHERE $identity_where
AND effective_from = '$effective_from'
ORDER BY id DESC
LIMIT 1"
);
if ($existing !== null) {
$id = (int)$existing['id'];
$set_parts = [];
foreach ($values as $k => $v) {
$set_parts[] = $this->buildSetFragment($k, $v);
}
$set_parts[] = "source = '$source'";
$set_parts[] = "confidence = $confidence";
$set_parts[] = "inferred = " . ((int)$inferred);
$set_parts[] = "metadata_json = '$metadata_json'";
$db->query("UPDATE $table SET " . implode(', ', $set_parts) . " WHERE id = $id");
return [
'action' => 'updated',
'row' => $this->fetchOne("SELECT * FROM $table WHERE id = $id"),
];
}
$next_start = $this->fetchOne(
"SELECT effective_from
FROM $table
WHERE $identity_where
AND effective_from > '$effective_from'
ORDER BY effective_from ASC
LIMIT 1"
);
$effective_to_value = null;
if ($next_start !== null && !empty($next_start['effective_from'])) {
$effective_to_value = $this->minusOneSecond((string)$next_start['effective_from']);
}
$insert_data = [
...$identity,
...$values,
'effective_from' => $effective_from,
'effective_to' => $effective_to_value,
'source' => $source,
'confidence' => $confidence,
'inferred' => (int)$inferred,
'metadata_json' => $metadata_json,
];
$columns = [];
$values_sql = [];
foreach ($insert_data as $k => $v) {
$columns[] = $k;
$values_sql[] = $this->buildValueFragment($v);
}
$db->query(
"INSERT INTO $table (" . implode(', ', $columns) . ")
VALUES (" . implode(', ', $values_sql) . ")"
);
$id = (int)$db->insert_id();
return [
'action' => 'inserted',
'row' => $this->fetchOne("SELECT * FROM $table WHERE id = $id"),
];
}
private function closeActiveVersion(
string $table,
array $identity,
string $effective_to,
string $source,
float $confidence,
bool $inferred,
array $metadata
): array {
global $db;
$confidence = $this->normalizeConfidence($confidence);
$source = $db->escape_string($source);
$metadata_json = $db->escape_string(json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$effective_to = $db->escape_string($effective_to);
$identity_where = $this->buildWhereClause($identity);
$result = $db->query(
"UPDATE $table
SET effective_to = '$effective_to',
source = '$source',
confidence = $confidence,
inferred = " . ((int)$inferred) . ",
metadata_json = '$metadata_json'
WHERE $identity_where
AND effective_from <= '$effective_to'
AND (effective_to IS NULL OR effective_to > '$effective_to')"
);
if ($result && $db->conn()->affected_rows > 0) {
return ['action' => 'closed'];
}
return ['action' => 'noop'];
}
private function listVersions(string $table, array $identity, ?string $date_from, ?string $date_to): array
{
$where = $this->buildWhereClause($identity);
if ($date_from !== null) {
$date_from = $this->normalizeDatetime($date_from);
$where .= " AND (effective_to IS NULL OR effective_to >= '" . $this->escape($date_from) . "')";
}
if ($date_to !== null) {
$date_to = $this->normalizeDatetime($date_to);
$where .= " AND effective_from <= '" . $this->escape($date_to) . "'";
}
return $this->fetchAll("SELECT * FROM $table WHERE $where ORDER BY effective_from ASC, id ASC");
}
private function resolveActiveVersions(
string $table,
array $identity,
string $timestamp,
string $order_by,
?int $limit = null
): array {
$timestamp = $this->normalizeDatetime($timestamp);
$where = $this->buildWhereClause($identity);
$where .= " AND effective_from <= '" . $this->escape($timestamp) . "'";
$where .= " AND (effective_to IS NULL OR effective_to >= '" . $this->escape($timestamp) . "')";
$sql = "SELECT * FROM $table WHERE $where ORDER BY $order_by";
if ($limit !== null) {
$sql .= " LIMIT " . ((int)$limit);
}
return $this->fetchAll($sql);
}
private function buildWhereClause(array $identity): string
{
$parts = [];
foreach ($identity as $k => $v) {
if ($v === null) {
$parts[] = "$k IS NULL";
continue;
}
if (is_bool($v)) {
$parts[] = "$k = " . ((int)$v);
continue;
}
if (is_int($v) || is_float($v)) {
$parts[] = "$k = $v";
continue;
}
$parts[] = "$k = '" . $this->escape((string)$v) . "'";
}
return implode(' AND ', $parts);
}
private function buildSetFragment(string $key, mixed $value): string
{
return "$key = " . $this->buildValueFragment($value);
}
private function buildValueFragment(mixed $value): string
{
if ($value === null) {
return 'NULL';
}
if (is_bool($value)) {
return (string)((int)$value);
}
if (is_int($value) || is_float($value)) {
return (string)$value;
}
return "'" . $this->escape((string)$value) . "'";
}
private function normalizeDatetime(?string $value): string
{
if ($value === null || trim($value) === '') {
return date('Y-m-d H:i:s');
}
$dt = new DateTime($value);
return $dt->format('Y-m-d H:i:s');
}
private function minusOneSecond(string $datetime): string
{
$dt = new DateTime($datetime);
$dt->modify('-1 second');
return $dt->format('Y-m-d H:i:s');
}
private function normalizeConfidence(float $confidence): float
{
if ($confidence < 0) {
return 0.0;
}
if ($confidence > 1) {
return 1.0;
}
return round($confidence, 5);
}
private function escape(string $value): string
{
global $db;
return $db->escape_string($value);
}
private function fetchAll(string $sql): array
{
global $db;
$result = $db->query($sql);
if (!$result) {
return [];
}
return $db->fetch_all($result);
}
private function fetchOne(string $sql): ?array
{
$rows = $this->fetchAll($sql);
if (empty($rows)) {
return null;
}
return $rows[0];
}
private function incrementReportAction(array &$bucket, string $action): void
{
if (!isset($bucket[$action])) {
$bucket[$action] = 0;
}
$bucket[$action]++;
}
}
+4 -1
View File
@@ -66,6 +66,9 @@ if ($args[1] === 'run') {
case 'clearAllUsersEconomicCustomerDetails':
require_once 'cron/ClearAllUsersEconomicCustomerDetails.php';
break;
case 'economic-v2-backfill':
require_once 'cron/BackfillEconomicV2History.php';
break;
case 'economicOrderParser-test':
echo "Running the economicOrderParser test script";
require_once 'tests/economicOrderParser/EconomicOrderParserTest.php';
@@ -96,4 +99,4 @@ if ($args[1] === 'run') {
echo "[" . date('Y-m-d H:i:s') . "][CRON] Finished running the script\n";
} else {
echo "Invalid action";
}
}
@@ -0,0 +1,31 @@
<?php
use classes\economic_v2_versioning_service;
if (!defined('WD')) {
exit;
}
echo '[' . date('Y-m-d H:i:s') . '][ECONOMIC_V2] Starting best-effort history backfill' . PHP_EOL;
try {
$report = (new economic_v2_versioning_service())->runBestEffortBackfill();
echo json_encode(
[
'success' => true,
'report' => $report,
],
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT
) . PHP_EOL;
} catch (\Throwable $e) {
echo json_encode(
[
'success' => false,
'error' => $e->getMessage(),
],
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT
) . PHP_EOL;
throw $e;
}
echo '[' . date('Y-m-d H:i:s') . '][ECONOMIC_V2] Finished best-effort history backfill' . PHP_EOL;
@@ -98,10 +98,11 @@ class economicCustomers extends economic_m
* @param int $page
* @param int $limit
* @param string|null $search
* @param mixed $barred_filter Supports true/false values (bool, 1/0, true/false, barred/active)
* @return object The list of customers
* @throws Exception
*/
public function listCustomers(int $page, int $limit, string|null $search = null): object
public function listCustomers(int $page, int $limit, string|null $search = null, mixed $barred_filter = null): object
{
// Normalize pagination parameters
$page = max(1, $page); // Ensure it's at least 1
@@ -116,6 +117,8 @@ class economicCustomers extends economic_m
'city', 'country', 'email', 'telephoneAndFaxNumber', 'website', 'mobilePhone', 'corporateIdentificationNumber'
];
$filter_parts = [];
// If a search term is present, build the filter expressions
if (!empty($search)) {
// Escape special characters in the search string
@@ -131,11 +134,21 @@ class economicCustomers extends economic_m
$filters[] = $property . '$like:' . $escapedSearch;
}
// Join the filters with `$or:`
$filterString = implode('$or:', $filters);
// Join the filters with `$or:` and keep grouping explicit for later $and composition
$filter_parts[] = '(' . implode('$or:', $filters) . ')';
}
// URL encode and append the filter string
$url .= '&filter=' . urlencode($filterString);
// Optional barred filter support (all | true/barred | false/active)
$normalized_barred_filter = $this->normalizeBarredFilter($barred_filter);
if ($normalized_barred_filter !== null) {
$filter_parts[] = 'barred$eq:' . ($normalized_barred_filter ? 'true' : 'false');
}
if (!empty($filter_parts)) {
$filter_string = count($filter_parts) === 1
? $filter_parts[0]
: '(' . implode('$and:', $filter_parts) . ')';
$url .= '&filter=' . urlencode($filter_string);
}
// Send the GET request to the API endpoint
@@ -149,5 +162,24 @@ class economicCustomers extends economic_m
return $responseObject;
}
private function normalizeBarredFilter(mixed $value): ?bool
{
if ($value === null || $value === '') {
return null;
}
if (is_bool($value)) {
return $value;
}
if (is_int($value)) {
return $value === 1 ? true : ($value === 0 ? false : null);
}
$parsed = strtolower(trim((string)$value));
return match ($parsed) {
'1', 'true', 'yes', 'barred', 'only_barred' => true,
'0', 'false', 'no', 'active', 'not_barred' => false,
default => null,
};
}
}
}
@@ -3,6 +3,8 @@
namespace routes;
use classes\authentication;
use classes\economic_v2_distribution_service;
use classes\economic_v2_versioning_service;
use classes\invoicing_period_utils;
use classes\slack;
use Exception;
@@ -205,6 +207,135 @@ class InvoicingPeriodRoute
]
);
$this->get('/superuser/invoicing/period/distribution/v2/all', function () {
global $response;
$this->requirePermission('superuser_invoicing_period_distribution_v2');
$dateRange = $this->requireAndNormalizeDateRange();
$dateFrom = $dateRange['dateFrom'];
$dateTo = $dateRange['dateTo'];
$response->add_meta('date_from', $dateFrom);
$response->add_meta('date_to', $dateTo);
$service = new economic_v2_distribution_service();
$response->success($service->getAllDistributions($dateFrom, $dateTo));
},
[
'superuser_invoicing_period_distribution_v2' => 'Get historical version-aware departmental distribution (all categories).',
]
);
$this->get('/superuser/invoicing/period/distribution/v2/fixed-pricing', function () {
global $response;
$this->requirePermission('superuser_invoicing_period_distribution_v2');
$dateRange = $this->requireAndNormalizeDateRange();
$dateFrom = $dateRange['dateFrom'];
$dateTo = $dateRange['dateTo'];
$response->add_meta('date_from', $dateFrom);
$response->add_meta('date_to', $dateTo);
$service = new economic_v2_distribution_service();
$response->success($service->getFixedPricingDistribution($dateFrom, $dateTo));
},
[
'superuser_invoicing_period_distribution_v2' => 'Get historical version-aware fixed pricing distribution.',
]
);
$this->get('/superuser/invoicing/period/distribution/v2/wash-subscriptions', function () {
global $response;
$this->requirePermission('superuser_invoicing_period_distribution_v2');
$dateRange = $this->requireAndNormalizeDateRange();
$dateFrom = $dateRange['dateFrom'];
$dateTo = $dateRange['dateTo'];
$response->add_meta('date_from', $dateFrom);
$response->add_meta('date_to', $dateTo);
$service = new economic_v2_distribution_service();
$response->success($service->getWashSubscriptionsDistribution($dateFrom, $dateTo));
},
[
'superuser_invoicing_period_distribution_v2' => 'Get historical version-aware wash subscription distribution.',
]
);
$this->get('/superuser/invoicing/period/distribution/v2/customer-prices', function () {
global $response;
$this->requirePermission('superuser_invoicing_period_distribution_v2');
$dateRange = $this->requireAndNormalizeDateRange();
$dateFrom = $dateRange['dateFrom'];
$dateTo = $dateRange['dateTo'];
$response->add_meta('date_from', $dateFrom);
$response->add_meta('date_to', $dateTo);
$service = new economic_v2_distribution_service();
$response->success($service->getCustomerPricesDistribution($dateFrom, $dateTo));
},
[
'superuser_invoicing_period_distribution_v2' => 'Get historical version-aware customer discount distribution.',
]
);
$this->get('/superuser/customers/pricing-history', function () {
global $response;
$this->requirePermission('superuser_customer_pricing_history_v2');
self::requireParameters(['customer_number']);
self::requireType((int)self::getParameter('customer_number'), self::type_int());
$customer_number = (int)self::getParameter('customer_number');
self::requireMinValue($customer_number, 1);
self::requireMaxValue($customer_number, 999999999);
$dateRange = $this->requireAndNormalizeDateRange();
$dateFrom = $dateRange['dateFrom'];
$dateTo = $dateRange['dateTo'];
$versioning = new economic_v2_versioning_service();
$fixed_pricing = $versioning->listFixedPricingVersions($customer_number, $dateFrom, $dateTo);
$vehicle_subscriptions = $versioning->listVehicleSubscriptionVersions($customer_number, $dateFrom, $dateTo);
$discount_overrides = $versioning->listDiscountOverrideVersions($customer_number, $dateFrom, $dateTo);
$timeline = [];
foreach ($fixed_pricing as $row) {
$timeline[] = [
'type' => 'fixed_pricing',
...$row,
];
}
foreach ($vehicle_subscriptions as $row) {
$timeline[] = [
'type' => 'vehicle_subscription',
...$row,
];
}
foreach ($discount_overrides as $row) {
$timeline[] = [
'type' => 'discount_override',
...$row,
];
}
usort($timeline, static function ($a, $b) {
$left = strtotime((string)($a['effective_from'] ?? '1970-01-01 00:00:00'));
$right = strtotime((string)($b['effective_from'] ?? '1970-01-01 00:00:00'));
if ($left === $right) {
return ((int)($a['id'] ?? 0)) <=> ((int)($b['id'] ?? 0));
}
return $left <=> $right;
});
$response->add_meta('date_from', $dateFrom);
$response->add_meta('date_to', $dateTo);
$response->success([
'customer_number' => $customer_number,
'fixed_pricing' => $fixed_pricing,
'vehicle_subscriptions' => $vehicle_subscriptions,
'discount_overrides' => $discount_overrides,
'timeline' => $timeline,
]);
},
[
'superuser_customer_pricing_history_v2' => 'Get customer pricing/subscription/discount timeline with confidence and provenance.',
]
);
$this->get('/superuser/invoicing/period/distribution/wash-subscriptions/historical', function () {
// Require the user to be logged in
global $response;
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use classes\economic_v2_versioning_service;
use objects\customer_fixed_pricing_o;
use objects\logs_o;
use objects\users_o;
@@ -98,6 +99,31 @@ class customerFixedPricingRoute
}
// Add the fixed price
$customer_fixed_pricing_o->add((int)$customer_number, (int)$price, (string)$description);
try {
(new economic_v2_versioning_service())->recordFixedPricingVersion(
(int)$customer_number,
(int)$price,
(string)$description,
date('Y-m-d H:i:s'),
'live.fixed_pricing.route',
1.0,
false,
[
'route' => '/customer/pricing/fixed',
'method' => 'POST',
'actor_user_id' => (int)$user->id,
]
);
} catch (\Throwable $e) {
(new logs_o())->add(
'customer_fixed_pricing',
'global',
0,
(int)$user->id,
'CUSTOMER_ADD_FIXED_PRICING_VERSIONING_FAILED',
$e->getMessage()
);
}
// Log the action
(new logs_o())->add('customer_fixed_pricing', 'global', 0, $user->id, 'CUSTOMER_ADD_FIXED_PRICING', 'Fixed price added');
// Return success
@@ -136,6 +162,29 @@ class customerFixedPricingRoute
$fixed_pricing_object = $customer_fixed_pricing_o->selectByCustomerNumber((int)$customer_number);
// Delete the fixed price
$fixed_pricing_object->delete();
try {
(new economic_v2_versioning_service())->closeActiveFixedPricingVersion(
(int)$customer_number,
date('Y-m-d H:i:s'),
'live.fixed_pricing.route',
1.0,
false,
[
'route' => '/customer/pricing/fixed',
'method' => 'DELETE',
'actor_user_id' => (int)$user->id,
]
);
} catch (\Throwable $e) {
(new logs_o())->add(
'customer_fixed_pricing',
'global',
0,
(int)$user->id,
'CUSTOMER_DELETE_FIXED_PRICING_VERSIONING_FAILED',
$e->getMessage()
);
}
// Log the action
(new logs_o())->add('customer_fixed_pricing', 'global', 0, $user->id, 'CUSTOMER_DELETE_FIXED_PRICING', 'Fixed price deleted');
// Return success
@@ -146,4 +195,4 @@ class customerFixedPricingRoute
]
);
}
}
}
@@ -66,7 +66,7 @@ class customerSearchRoute
$page = self::fromRequest('page') ?? 1;
$limit = self::fromRequest('limit') ?? 100;
$search = self::fromRequest('search') ?? null;
$filter = self::fromRequest('filter') ?? null;
$barred = self::fromRequest('barred') ?? null;
// Log the incident
(new logs_o())->add('customers', 'global', 1, $user->id, 'LIST_CUSTOMERS', 'Successfully listed customers');
// Create the economic customers object
@@ -75,7 +75,7 @@ class customerSearchRoute
(int)$page,
(int)$limit,
$search,
$filter
$barred
);
// Parse the pagination meta from E-conomic to the standard format used in this application
$response->paginate(
@@ -83,7 +83,7 @@ class customerSearchRoute
$limit,
$result->pagination->results,
$search,
$filter
['barred' => $barred]
);
// Create the users object
$users_o = new users_o();
@@ -120,4 +120,4 @@ class customerSearchRoute
}
return $result;
}
}
}
@@ -4,6 +4,9 @@ namespace routes;
use classes\authentication;
use classes\economic;
use classes\economic_v2_compare_engine;
use classes\economic_v2_line_normalizer;
use classes\economic_v2_revenue_statistics_service;
use classes\response;
use classes\router;
use Exception;
@@ -227,6 +230,158 @@ class orderInvoicesRoute
]
);
/** Collected order invoices > E-conomic V2 details > GET */
$this->get('/collected-invoices/economic/v2/details', function () {
global $response;
self::requirePermission('view_collected_invoice_economic_v2_details');
$collected_invoice_id = $this->requireCollectedInvoiceId();
$payload = $this->buildEconomicV2DetailsPayload($collected_invoice_id);
$response->success($payload);
},
[
'view_collected_invoice_economic_v2_details' => 'View normalized internal/draft/booked e-conomic invoice details (V2).',
]
);
/** Collected order invoices > E-conomic V2 compare > GET */
$this->get('/collected-invoices/economic/v2/compare', function () {
global $response;
self::requirePermission('compare_collected_invoice_economic_v2');
$collected_invoice_id = $this->requireCollectedInvoiceId();
$details = $this->buildEconomicV2DetailsPayload($collected_invoice_id);
$comparison = economic_v2_compare_engine::compare(
$details['internal']['normalized'],
$details['draft']['exists'] ? $details['draft']['normalized'] : null,
$details['booked']['exists'] ? $details['booked']['normalized'] : null
);
$response->success([
'collected_invoice_id' => $collected_invoice_id,
'details' => $details,
'comparison' => $comparison,
'warnings' => array_values(array_unique(array_merge(
(array)($details['warnings'] ?? []),
(array)($comparison['warnings'] ?? [])
))),
]);
},
[
'compare_collected_invoice_economic_v2' => 'Compare normalized internal invoice with draft/booked e-conomic targets (V2).',
]
);
/** Collected order invoices > E-conomic V2 compare bulk > POST */
$this->post('/collected-invoices/economic/v2/compare/bulk', function () {
global $response;
self::requirePermission('compare_collected_invoice_economic_v2_bulk');
self::requireParameters(['collected_invoice_ids']);
$collected_invoice_ids = self::getParameter('collected_invoice_ids');
if (!is_array($collected_invoice_ids)) {
$response->error('collected_invoice_ids must be an array', 400);
}
$normalized_ids = array_values(array_unique(array_filter(array_map(static function ($id) {
return (int)$id;
}, $collected_invoice_ids), static function ($id) {
return $id > 0;
})));
if (empty($normalized_ids)) {
$response->error('collected_invoice_ids must contain at least one positive integer', 400);
}
if (count($normalized_ids) > 200) {
$response->error('Maximum 200 collected_invoice_ids per bulk compare request', 400);
}
$results = [];
$errors = [];
foreach ($normalized_ids as $collected_invoice_id) {
try {
$details = $this->buildEconomicV2DetailsPayload((int)$collected_invoice_id);
$comparison = economic_v2_compare_engine::compare(
$details['internal']['normalized'],
$details['draft']['exists'] ? $details['draft']['normalized'] : null,
$details['booked']['exists'] ? $details['booked']['normalized'] : null
);
$results[] = [
'collected_invoice_id' => (int)$collected_invoice_id,
'details' => $details,
'comparison' => $comparison,
'warnings' => array_values(array_unique(array_merge(
(array)($details['warnings'] ?? []),
(array)($comparison['warnings'] ?? [])
))),
];
} catch (Exception $e) {
$errors[] = [
'collected_invoice_id' => (int)$collected_invoice_id,
'error' => $e->getMessage(),
];
}
}
$response->success([
'requested' => count($normalized_ids),
'compared' => count($results),
'failed' => count($errors),
'results' => $results,
'errors' => $errors,
]);
},
[
'compare_collected_invoice_economic_v2_bulk' => 'Compare multiple collected invoices against draft/booked e-conomic targets (V2).',
]
);
/** Collected order invoices > E-conomic V2 revenue statistics > GET */
$this->get('/collected-invoices/economic/v2/revenue-statistics', function () {
global $response;
self::requirePermission('view_collected_invoice_economic_v2_revenue_statistics');
$dateFrom = (string)(self::fromRequest('dateFrom') ?? date('Y-m-01'));
$dateTo = (string)(self::fromRequest('dateTo') ?? date('Y-m-d'));
self::requireDateFormat($dateFrom, self::FORMAT_DATE());
self::requireDateFormat($dateTo, self::FORMAT_DATE());
if (strtotime($dateFrom) > strtotime($dateTo)) {
$response->error('dateFrom must be before or equal to dateTo', 400);
}
$barred = strtolower(trim((string)(self::fromRequest('barred') ?? 'all')));
self::requireInArray($barred, ['all', 'barred', 'active']);
$currency = self::fromRequest('currency');
$currency = ($currency !== null && trim($currency) !== '') ? strtoupper(trim($currency)) : null;
if ($currency !== null && !preg_match('/^[A-Z]{3}$/', $currency)) {
$response->error('currency must be a 3-letter ISO code (e.g. DKK)', 400);
}
$max_pages = (int)(self::fromRequest('max_pages') ?? 10);
self::requireMinValue($max_pages, 1);
self::requireMaxValue($max_pages, 200);
$payload = (new economic_v2_revenue_statistics_service())->getBookedRevenueStatistics([
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'customer_numbers' => $this->parseIntegerListParameter('customer_numbers'),
'department_numbers' => $this->parseIntegerListParameter('department_numbers'),
'currency' => $currency,
'barred' => $barred,
'max_pages' => $max_pages,
]);
$response->success($payload);
},
[
'view_collected_invoice_economic_v2_revenue_statistics' => 'View aggregated booked revenue statistics from e-conomic (V2), including barred-customer filtering.',
]
);
/** Collected order invoices > Ready to invoice > GET */
$this->get('/collected-invoices/ready-to-invoice', function () {
global $response;
@@ -1115,6 +1270,197 @@ class orderInvoicesRoute
);
}
private function requireCollectedInvoiceId(): int
{
self::requireParameters(['collected_invoice_id']);
self::requireType((int)self::getParameter('collected_invoice_id'), self::type_int());
$collected_invoice_id = (int)self::getParameter('collected_invoice_id');
self::requireMinValue($collected_invoice_id, 1);
self::requireMaxValue($collected_invoice_id, 999999999);
return $collected_invoice_id;
}
/**
* Build normalized V2 details for a collected invoice and available e-conomic targets.
* @throws Exception
*/
private function buildEconomicV2DetailsPayload(int $collected_invoice_id): array
{
$warnings = [];
$invoice = (new collected_order_invoices_o())->select($collected_invoice_id);
$invoice->requireSelected();
$draft_id = null;
$booked_id = null;
$draft_raw = null;
$booked_raw = null;
$customer = [
'internal_customer_number' => (int)$invoice->customer_number->value() > 0 ? (int)$invoice->customer_number->value() : null,
'draft_customer_number' => null,
'booked_customer_number' => null,
'exists' => false,
'name' => null,
'barred' => null,
];
$economic = new economic();
try {
$draft_id = $invoice->getInvoiceDraftId();
} catch (Exception $e) {
$warnings[] = 'Draft id unavailable: ' . $e->getMessage();
}
try {
$booked_id = $invoice->getInvoiceBookedId();
} catch (Exception $e) {
$warnings[] = 'Booked id unavailable: ' . $e->getMessage();
}
if ($draft_id !== null) {
try {
$draft_raw = $economic->invoices->draft->get((int)$draft_id);
} catch (Exception $e) {
$warnings[] = 'Unable to fetch draft invoice ' . (int)$draft_id . ': ' . $e->getMessage();
}
}
if ($booked_id !== null) {
try {
$booked_raw = $economic->invoices->booked->getFromId((int)$booked_id);
} catch (Exception $e) {
$warnings[] = 'Unable to fetch booked invoice ' . (int)$booked_id . ': ' . $e->getMessage();
}
}
$customer['draft_customer_number'] = $this->extractEconomicCustomerNumber($draft_raw);
$customer['booked_customer_number'] = $this->extractEconomicCustomerNumber($booked_raw);
if (
$customer['internal_customer_number'] !== null &&
$customer['draft_customer_number'] !== null &&
(int)$customer['internal_customer_number'] !== (int)$customer['draft_customer_number']
) {
$warnings[] = 'Draft invoice customer number mismatch: internal=' . (int)$customer['internal_customer_number'] . ', draft=' . (int)$customer['draft_customer_number'];
}
if (
$customer['internal_customer_number'] !== null &&
$customer['booked_customer_number'] !== null &&
(int)$customer['internal_customer_number'] !== (int)$customer['booked_customer_number']
) {
$warnings[] = 'Booked invoice customer number mismatch: internal=' . (int)$customer['internal_customer_number'] . ', booked=' . (int)$customer['booked_customer_number'];
}
if ($customer['internal_customer_number'] !== null) {
try {
$economic_customer_raw = $economic->customers->customers->get((int)$customer['internal_customer_number']);
if (isset($economic_customer_raw->customerNumber)) {
$customer['exists'] = true;
$customer['name'] = isset($economic_customer_raw->name) ? (string)$economic_customer_raw->name : null;
$customer['barred'] = isset($economic_customer_raw->barred) ? (bool)$economic_customer_raw->barred : null;
if ($customer['barred'] === true) {
$warnings[] = 'The e-conomic customer is barred.';
}
} else {
$warnings[] = 'Unable to resolve e-conomic customer ' . (int)$customer['internal_customer_number'] . '.';
}
} catch (\Throwable $e) {
$warnings[] = 'Failed to fetch e-conomic customer ' . (int)$customer['internal_customer_number'] . ': ' . $e->getMessage();
}
}
$internal_normalized = economic_v2_line_normalizer::normalizeInternalCollectedInvoice($invoice);
$draft_normalized = $draft_raw !== null
? economic_v2_line_normalizer::normalizeDraftInvoice($draft_raw)
: null;
$booked_normalized = $booked_raw !== null
? economic_v2_line_normalizer::normalizeBookedInvoice($booked_raw)
: null;
return [
'collected_invoice_id' => $collected_invoice_id,
'external_id' => (string)$invoice->external_id->value(),
'order_ids' => array_values(array_map(static function ($row) {
return (int)($row['id'] ?? 0);
}, $invoice->getOrderIds())),
'economic' => [
'draft_id' => $draft_id !== null ? (int)$draft_id : null,
'booked_id' => $booked_id !== null ? (int)$booked_id : null,
],
'customer' => $customer,
'internal' => [
'normalized' => $internal_normalized,
],
'draft' => [
'exists' => $draft_raw !== null,
'raw' => $this->toPlainArray($draft_raw),
'normalized' => $draft_normalized,
],
'booked' => [
'exists' => $booked_raw !== null,
'raw' => $this->toPlainArray($booked_raw),
'normalized' => $booked_normalized,
],
'warnings' => array_values(array_unique(array_merge(
$warnings,
(array)($internal_normalized['warnings'] ?? []),
(array)($draft_normalized['warnings'] ?? []),
(array)($booked_normalized['warnings'] ?? [])
))),
];
}
private function extractEconomicCustomerNumber(mixed $invoice_raw): ?int
{
if ($invoice_raw === null) {
return null;
}
$data = is_array($invoice_raw) ? $invoice_raw : $this->toPlainArray($invoice_raw);
$value = $data['customer']['customerNumber']
?? $data['customer']['customer_number']
?? $data['customerNumber']
?? $data['customer_number']
?? null;
if ($value === null) {
return null;
}
$customer_number = (int)$value;
return $customer_number > 0 ? $customer_number : null;
}
private function parseIntegerListParameter(string $parameter): array
{
if (!self::isParametersSet([$parameter])) {
return [];
}
$raw = self::getParameter($parameter);
$values = [];
if (is_array($raw)) {
$values = $raw;
} elseif (is_string($raw)) {
$values = explode(',', $raw);
} elseif (is_numeric($raw)) {
$values = [$raw];
}
$normalized = [];
foreach ($values as $value) {
$int_value = (int)$value;
if ($int_value > 0) {
$normalized[$int_value] = true;
}
}
return array_values(array_map('intval', array_keys($normalized)));
}
private function toPlainArray(mixed $value): mixed
{
if ($value === null || is_scalar($value)) {
return $value;
}
return json_decode(json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), true);
}
/**
* @param $collected_order_invoice
* @param users_o $users
@@ -1139,4 +1485,4 @@ class orderInvoicesRoute
'total_net_amount' => (float)$tmp_collected_order_invoices->getTotalAmount(),
];
}
}
}
+29 -1
View File
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use classes\economic_v2_versioning_service;
use classes\response;
use objects\logs_o;
use objects\users_o;
@@ -129,6 +130,33 @@ class userRoute
}
// Set the custom price
$targetUser->setCustomPrice($targetUser->id, $object_id, $discount, $is_category);
try {
(new economic_v2_versioning_service())->recordDiscountOverrideVersion(
(int)$targetUser->id,
(int)$targetUser->customer_number->value(),
(bool)$is_category,
(string)$object_id,
(int)$discount,
date('Y-m-d H:i:s'),
'live.discount_override.route',
1.0,
false,
[
'route' => '/superuser/user/discounts',
'method' => 'POST',
'actor_user_id' => (int)$user->id,
]
);
} catch (\Throwable $e) {
(new logs_o())->add(
'users',
'global',
0,
(int)$user->id,
'SET_CUSTOM_PRICE_VERSIONING_FAILED',
$e->getMessage()
);
}
// Log the incident
(new logs_o())->add('users', 'global', 1, $user->id, 'SET_CUSTOM_PRICE', 'Successfully set custom price');
// Return a success message
@@ -368,4 +396,4 @@ class userRoute
]
);
}
}
}
+125 -2
View File
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use classes\economic_v2_versioning_service;
use customers\economic_customer_mo;
use objects\bookings_o;
use objects\customer_vehicles_o;
@@ -180,6 +181,28 @@ class vehiclesRoute
$subscription ? 1 : 0,
$reference
);
try {
(new economic_v2_versioning_service())->recordVehicleSubscriptionVersion(
[
'vehicle_id' => (int)$vehicle->id,
'customer_number' => (int)$targetCustomer,
'reg' => (string)$reg,
'vehicle_type' => (int)$type,
'wash_subscription' => (bool)$subscription,
],
date('Y-m-d H:i:s'),
'live.vehicle.route',
1.0,
false,
[
'route' => '/vehicles',
'method' => 'POST',
'actor_user_id' => (int)($user->id ?? 0),
]
);
} catch (\Throwable $e) {
(new logs_o())->add('vehicles', 'global', 0, (int)($user->id ?? 0), 'ADD_VEHICLE_VERSIONING_FAILED', $e->getMessage());
}
(new logs_o())->add('vehicles', 'global', 1, (int)($user->id ?? 0), 'ADD_VEHICLE', 'Successfully added vehicle');
$response->success($vehicle->asArray());
@@ -204,6 +227,14 @@ class vehiclesRoute
(new logs_o())->add('vehicles', 'global', 1, (int)($user->id ?? 0), 'EDIT_VEHICLE', 'Vehicle not found');
$response->error('Vehicle not found', 404);
}
$before_state = [
'vehicle_id' => (int)$vehicle->id,
'customer_number' => (int)$vehicle->customer_id->value(),
'reg' => (string)$vehicle->reg->value(),
'vehicle_type' => (int)$vehicle->type->value(),
'wash_subscription' => (bool)$vehicle->wash_subscription->value(),
];
// Enforce access (own vs broader)
self::allowOwnOrDepartmentAccess(
$permission_own,
@@ -213,6 +244,22 @@ class vehiclesRoute
null,
'You are not allowed to edit vehicles from other users'
);
if (self::isParametersSet(['customer_id'])) {
$new_customer_number = (int)self::getParameter('customer_id');
self::requireType($new_customer_number, self::type_int());
self::requireMinValue($new_customer_number, 1);
self::requireMaxValue($new_customer_number, 9999999999);
// Require access for the destination customer context as well.
self::allowOwnOrDepartmentAccess(
$permission_own,
$permission_other,
$new_customer_number,
null,
null,
'You are not allowed to move vehicles to this customer'
);
$vehicle->customer_id->set($new_customer_number);
}
// Check all the fields, and if they are set, validate and set them
if (self::isParametersSet(['type'])) {
$type = (int)self::getParameter('type');
@@ -225,7 +272,6 @@ class vehiclesRoute
$vehicle->type->set(0);
// Turn off the subscription
$vehicle->wash_subscription->set(0);
return;
} else {
$products_o = new products_o();
$products_o->select((int)$type);
@@ -280,6 +326,61 @@ class vehiclesRoute
}
}
$vehicle->objectChanged();
$after_state = [
'vehicle_id' => (int)$vehicle->id,
'customer_number' => (int)$vehicle->customer_id->value(),
'reg' => (string)$vehicle->reg->value(),
'vehicle_type' => (int)$vehicle->type->value(),
'wash_subscription' => (bool)$vehicle->wash_subscription->value(),
];
$version_relevant_change = (
(int)$before_state['customer_number'] !== (int)$after_state['customer_number'] ||
(string)$before_state['reg'] !== (string)$after_state['reg'] ||
(int)$before_state['vehicle_type'] !== (int)$after_state['vehicle_type'] ||
(bool)$before_state['wash_subscription'] !== (bool)$after_state['wash_subscription']
);
if ($version_relevant_change) {
try {
$versioning = new economic_v2_versioning_service();
$effective_at = date('Y-m-d H:i:s');
$identity_changed = (
(int)$before_state['customer_number'] !== (int)$after_state['customer_number'] ||
(string)$before_state['reg'] !== (string)$after_state['reg']
);
if ($identity_changed) {
$versioning->closeActiveVehicleSubscriptionVersion(
(int)$before_state['customer_number'],
(string)$before_state['reg'],
$effective_at,
'live.vehicle.route',
1.0,
false,
[
'route' => '/vehicles',
'method' => 'PUT',
'actor_user_id' => (int)($user->id ?? 0),
'reason' => 'identity_change',
]
);
}
$versioning->recordVehicleSubscriptionVersion(
$after_state,
$effective_at,
'live.vehicle.route',
1.0,
false,
[
'route' => '/vehicles',
'method' => 'PUT',
'actor_user_id' => (int)($user->id ?? 0),
]
);
} catch (\Throwable $e) {
(new logs_o())->add('vehicles', 'global', 0, (int)($user->id ?? 0), 'EDIT_VEHICLE_VERSIONING_FAILED', $e->getMessage());
}
}
// Return the vehicle
$response->success($vehicle->asArray());
},
@@ -303,6 +404,11 @@ class vehiclesRoute
(new logs_o())->add('vehicles', 'global', 1, (int)($user->id ?? 0), 'DELETE_VEHICLE', 'Vehicle not found');
$response->error('Vehicle not found', 404);
}
$before_state = [
'customer_number' => (int)$vehicle->customer_id->value(),
'reg' => (string)$vehicle->reg->value(),
];
// Enforce access (own vs broader)
self::allowOwnOrDepartmentAccess(
$permission_own,
@@ -314,6 +420,23 @@ class vehiclesRoute
);
// Delete the vehicle
$vehicle->delete();
try {
(new economic_v2_versioning_service())->closeActiveVehicleSubscriptionVersion(
(int)$before_state['customer_number'],
(string)$before_state['reg'],
date('Y-m-d H:i:s'),
'live.vehicle.route',
1.0,
false,
[
'route' => '/vehicles',
'method' => 'DELETE',
'actor_user_id' => (int)($user->id ?? 0),
]
);
} catch (\Throwable $e) {
(new logs_o())->add('vehicles', 'global', 0, (int)($user->id ?? 0), 'DELETE_VEHICLE_VERSIONING_FAILED', $e->getMessage());
}
$response->success([
'success' => true,
'message' => 'Vehicle deleted successfully'
@@ -927,4 +1050,4 @@ class vehiclesRoute
$response->error('Notes are too long, they must be less than 250 characters', 400);
}
}
}
}
@@ -0,0 +1,120 @@
<?php
use classes\economic_v2_distribution_service;
use classes\economic_v2_versioning_service;
if (!function_exists('economic_v2_integration_db')) {
function economic_v2_integration_db(): \classes\db
{
if (!integration_enabled()) {
test()->markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run DB integration tests.');
}
$host = getenv('CONFIG_DB_HOST') ?: null;
$user = getenv('CONFIG_DB_USER') ?: null;
$password = getenv('CONFIG_DB_PASSWORD') ?: '';
$database = getenv('CONFIG_DB_DATABASE') ?: null;
if (!$host || !$user || !$database) {
test()->markTestSkipped('Missing DB env vars: CONFIG_DB_HOST/CONFIG_DB_USER/CONFIG_DB_DATABASE.');
}
app_require('classes/db.php');
app_require('classes/economic_v2_schema_bootstrap.php');
app_require('classes/economic_v2_versioning_service.php');
app_require('classes/economic_v2_distribution_service.php');
$GLOBALS['response'] = new class {
public function internal_server_error(string $message): void
{
throw new RuntimeException($message);
}
};
$db = new \classes\db([
'host' => $host,
'user' => $user,
'password' => $password,
'database' => $database,
]);
$db->connect();
$GLOBALS['db'] = $db;
return $db;
}
}
it('runs best-effort backfill repeatedly without introducing duplicate same-start rows', function (): void {
if (getenv('RUN_BACKFILL_INTEGRATION_TESTS') !== '1') {
test()->markTestSkipped('Set RUN_BACKFILL_INTEGRATION_TESTS=1 to run backfill integration test.');
}
$db = economic_v2_integration_db();
try {
$service = new economic_v2_versioning_service();
$first = $service->runBestEffortBackfill();
$second = $service->runBestEffortBackfill();
expect($first)->toHaveKey('fixed_pricing');
expect($first)->toHaveKey('vehicle_subscriptions');
expect($first)->toHaveKey('discount_overrides');
expect($second)->toHaveKey('fixed_pricing');
$dupFixed = $db->fetch_assoc($db->query(
"SELECT COUNT(*) AS c
FROM (
SELECT customer_number, effective_from, COUNT(*) AS cc
FROM customer_fixed_pricing_versions
WHERE source LIKE 'backfill.%'
GROUP BY customer_number, effective_from
HAVING cc > 1
) t"
));
$dupVehicle = $db->fetch_assoc($db->query(
"SELECT COUNT(*) AS c
FROM (
SELECT customer_number, reg, effective_from, COUNT(*) AS cc
FROM customer_vehicle_subscription_versions
WHERE source LIKE 'backfill.%'
GROUP BY customer_number, reg, effective_from
HAVING cc > 1
) t"
));
$dupDiscount = $db->fetch_assoc($db->query(
"SELECT COUNT(*) AS c
FROM (
SELECT user_id, customer_number, is_category, object_id, effective_from, COUNT(*) AS cc
FROM customer_discount_override_versions
WHERE source LIKE 'backfill.%'
GROUP BY user_id, customer_number, is_category, object_id, effective_from
HAVING cc > 1
) t"
));
expect((int)($dupFixed['c'] ?? 0))->toBe(0);
expect((int)($dupVehicle['c'] ?? 0))->toBe(0);
expect((int)($dupDiscount['c'] ?? 0))->toBe(0);
} finally {
$db->close();
}
});
it('resolves version-aware distribution payload shapes over a real date range', function (): void {
$db = economic_v2_integration_db();
try {
$service = new economic_v2_distribution_service();
$dateFrom = date('Y-m-01');
$dateTo = date('Y-m-d');
$fixed = $service->getFixedPricingDistribution($dateFrom, $dateTo);
$subscriptions = $service->getWashSubscriptionsDistribution($dateFrom, $dateTo);
$prices = $service->getCustomerPricesDistribution($dateFrom, $dateTo);
expect($fixed)->toHaveKey('customers');
expect($fixed)->toHaveKey('collective_results');
expect($subscriptions)->toHaveKey('customers');
expect($subscriptions)->toHaveKey('collective_results');
expect($prices)->toHaveKey('customers');
expect($prices)->toHaveKey('collective_results');
} finally {
$db->close();
}
});
@@ -0,0 +1,153 @@
<?php
use classes\db;
use classes\economic_v2_versioning_service;
function economic_v2_versioning_integration_db(): db
{
if (!integration_enabled()) {
test()->markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run DB integration tests.');
}
$host = getenv('CONFIG_DB_HOST') ?: null;
$user = getenv('CONFIG_DB_USER') ?: null;
$password = getenv('CONFIG_DB_PASSWORD') ?: '';
$database = getenv('CONFIG_DB_DATABASE') ?: null;
if (!$host || !$user || !$database) {
test()->markTestSkipped('Missing DB env vars: CONFIG_DB_HOST/CONFIG_DB_USER/CONFIG_DB_DATABASE.');
}
app_require('classes/db.php');
app_require('classes/economic_v2_schema_bootstrap.php');
app_require('classes/economic_v2_versioning_service.php');
$GLOBALS['response'] = new class {
public function internal_server_error(string $message): void
{
throw new RuntimeException($message);
}
};
$db = new db([
'host' => $host,
'user' => $user,
'password' => $password,
'database' => $database,
]);
$db->connect();
$GLOBALS['db'] = $db;
return $db;
}
it('creates closes and rotates fixed pricing versions without overlap', function (): void {
$db = economic_v2_versioning_integration_db();
$service = new economic_v2_versioning_service();
$customer = 99000000 + random_int(1000, 9999);
try {
$db->query("DELETE FROM customer_fixed_pricing_versions WHERE customer_number = $customer");
$first = $service->recordFixedPricingVersion($customer, 1000, 'Initial', '2026-01-01 00:00:00');
$second = $service->recordFixedPricingVersion($customer, 1200, 'Updated', '2026-02-01 00:00:00');
expect($first['action'])->toBe('inserted');
expect($second['action'])->toBe('inserted');
$rows = $db->fetch_all($db->query(
"SELECT id, effective_from, effective_to, price
FROM customer_fixed_pricing_versions
WHERE customer_number = $customer
ORDER BY effective_from ASC, id ASC"
));
expect(count($rows))->toBe(2);
expect((string)$rows[0]['effective_to'])->toBe('2026-01-31 23:59:59');
expect((int)$rows[1]['price'])->toBe(1200);
$service->closeActiveFixedPricingVersion($customer, '2026-02-15 00:00:00');
$row = $db->fetch_assoc($db->query(
"SELECT effective_to
FROM customer_fixed_pricing_versions
WHERE customer_number = $customer
ORDER BY effective_from DESC
LIMIT 1"
));
expect((string)$row['effective_to'])->toBe('2026-02-15 00:00:00');
} finally {
$db->query("DELETE FROM customer_fixed_pricing_versions WHERE customer_number = $customer");
$db->close();
}
});
it('tracks vehicle and discount version lifecycles with closure semantics', function (): void {
$db = economic_v2_versioning_integration_db();
$service = new economic_v2_versioning_service();
$customer = 99100000 + random_int(1000, 9999);
$userId = 700000 + random_int(1000, 9999);
$reg = 'ZZ' . random_int(1000, 9999);
try {
$db->query("DELETE FROM customer_vehicle_subscription_versions WHERE customer_number = $customer AND reg = '" . $db->escape_string($reg) . "'");
$db->query("DELETE FROM customer_discount_override_versions WHERE customer_number = $customer AND user_id = $userId");
$service->recordVehicleSubscriptionVersion([
'vehicle_id' => null,
'customer_number' => $customer,
'reg' => $reg,
'vehicle_type' => 1,
'wash_subscription' => true,
], '2026-01-01 00:00:00');
$service->recordVehicleSubscriptionVersion([
'vehicle_id' => null,
'customer_number' => $customer,
'reg' => $reg,
'vehicle_type' => 33,
'wash_subscription' => true,
], '2026-01-10 00:00:00');
$service->closeActiveVehicleSubscriptionVersion($customer, $reg, '2026-01-20 00:00:00');
$vehicleRows = $db->fetch_all($db->query(
"SELECT vehicle_type, effective_from, effective_to
FROM customer_vehicle_subscription_versions
WHERE customer_number = $customer
AND reg = '" . $db->escape_string($reg) . "'
ORDER BY effective_from ASC"
));
expect(count($vehicleRows))->toBe(2);
expect((string)$vehicleRows[0]['effective_to'])->toBe('2026-01-09 23:59:59');
expect((string)$vehicleRows[1]['effective_to'])->toBe('2026-01-20 00:00:00');
$service->recordDiscountOverrideVersion(
$userId,
$customer,
false,
33,
25,
'2026-01-01 00:00:00'
);
$service->recordDiscountOverrideVersion(
$userId,
$customer,
false,
33,
0,
'2026-01-15 00:00:00'
);
$discountRows = $db->fetch_all($db->query(
"SELECT discount, effective_from, effective_to
FROM customer_discount_override_versions
WHERE customer_number = $customer
AND user_id = $userId
AND is_category = 0
AND object_id = '33'
ORDER BY effective_from ASC"
));
expect(count($discountRows))->toBe(1);
expect((int)$discountRows[0]['discount'])->toBe(25);
expect((string)$discountRows[0]['effective_to'])->toBe('2026-01-15 00:00:00');
} finally {
$db->query("DELETE FROM customer_vehicle_subscription_versions WHERE customer_number = $customer");
$db->query("DELETE FROM customer_discount_override_versions WHERE customer_number = $customer AND user_id = $userId");
$db->close();
}
});
@@ -0,0 +1,19 @@
<?php
it('registers economic v2 backfill command in cli dispatcher', function (): void {
$cliFile = app_path('cli.php');
$content = file_get_contents($cliFile);
expect($content)->not->toBeFalse();
expect($content)->toContain("case 'economic-v2-backfill':");
expect($content)->toContain("require_once 'cron/BackfillEconomicV2History.php';");
});
it('provides a backfill cron script entrypoint', function (): void {
$script = app_path('cron/BackfillEconomicV2History.php');
expect(is_file($script))->toBeTrue();
$content = file_get_contents($script);
expect($content)->not->toBeFalse();
expect($content)->toContain('runBestEffortBackfill(');
});
@@ -0,0 +1,135 @@
<?php
app_require('classes/economic_v2_compare_engine.php');
use classes\economic_v2_compare_engine;
function economic_v2_test_invoice(array $overrides = []): array
{
$base = [
'source' => 'internal',
'totals' => [
'net_total' => 100.0,
'line_net_total' => 100.0,
'line_count' => 1,
'billable_line_count' => 1,
],
'departments' => [
'75' => 100.0,
],
'lines' => [
[
'source' => 'internal',
'source_line_id' => 1,
'line_type' => 'product',
'billable' => true,
'product_number' => '1',
'description' => 'Trakker',
'reference' => '',
'quantity' => 1.0,
'unit_net_price' => 100.0,
'line_net_amount' => 100.0,
'department_distribution' => ['75' => 100.0],
'match_key' => 'product:1|ref:',
],
],
'warnings' => [],
];
return array_replace_recursive($base, $overrides);
}
it('returns exact_match when totals lines and departments are identical', function (): void {
$internal = economic_v2_test_invoice();
$draft = economic_v2_test_invoice(['source' => 'draft']);
$booked = economic_v2_test_invoice(['source' => 'booked']);
$result = economic_v2_compare_engine::compare($internal, $draft, $booked);
expect($result['targets']['draft']['status'])->toBe('exact_match');
expect($result['targets']['booked']['status'])->toBe('exact_match');
expect($result['targets']['draft']['overall_match'])->toBeTrue();
expect($result['targets']['booked']['overall_match'])->toBeTrue();
});
it('returns total_mismatch when only totals differ', function (): void {
$internal = economic_v2_test_invoice();
$draft = economic_v2_test_invoice([
'source' => 'draft',
'totals' => ['net_total' => 125.0],
]);
$result = economic_v2_compare_engine::compareTarget($internal, $draft, 'draft');
expect($result['status'])->toBe('total_mismatch');
expect($result['totals']['matches'])->toBeFalse();
expect($result['mismatch_reasons'])->toContain('total_mismatch');
});
it('detects line-level mismatches for quantity and price', function (): void {
$internal = economic_v2_test_invoice();
$draft = economic_v2_test_invoice([
'source' => 'draft',
'lines' => [[
'source' => 'draft',
'source_line_id' => 1,
'line_type' => 'product',
'billable' => true,
'product_number' => '1',
'description' => 'Trakker',
'reference' => '',
'quantity' => 2.0,
'unit_net_price' => 95.0,
'line_net_amount' => 190.0,
'department_distribution' => ['75' => 100.0],
'match_key' => 'product:1|ref:',
]],
'totals' => ['net_total' => 100.0],
'departments' => ['75' => 100.0],
]);
$result = economic_v2_compare_engine::compareTarget($internal, $draft, 'draft');
$reasons = $result['lines']['diff'][0]['reasons'] ?? [];
expect($result['lines']['summary']['mismatch_count'])->toBeGreaterThan(0);
expect($reasons)->toContain('quantity_mismatch');
expect($reasons)->toContain('unit_price_mismatch');
});
it('detects departmental distribution mismatches', function (): void {
$internal = economic_v2_test_invoice();
$draft = economic_v2_test_invoice([
'source' => 'draft',
'lines' => [[
'source' => 'draft',
'source_line_id' => 1,
'line_type' => 'product',
'billable' => true,
'product_number' => '1',
'description' => 'Trakker',
'reference' => '',
'quantity' => 1.0,
'unit_net_price' => 100.0,
'line_net_amount' => 100.0,
'department_distribution' => ['10' => 100.0],
'match_key' => 'product:1|ref:',
]],
'departments' => ['10' => 100.0],
]);
$result = economic_v2_compare_engine::compareTarget($internal, $draft, 'draft');
$lineReasons = $result['lines']['diff'][0]['reasons'] ?? [];
expect($lineReasons)->toContain('departmental_distribution_mismatch');
expect($result['departments']['matches'])->toBeFalse();
expect($result['mismatch_reasons'])->toContain('department_total_mismatch');
});
it('returns missing_target when draft or booked target is unavailable', function (): void {
$internal = economic_v2_test_invoice();
$result = economic_v2_compare_engine::compareTarget($internal, null, 'booked');
expect($result['status'])->toBe('missing_target');
expect($result['overall_match'])->toBeFalse();
expect($result['mismatch_reasons'])->toContain('missing_target');
});
@@ -0,0 +1,99 @@
<?php
app_require('classes/economic_v2_line_normalizer.php');
use classes\economic_v2_line_normalizer;
it('normalizes draft invoice lines including departmental distributions', function (): void {
$draft = [
'netAmount' => 150,
'lines' => [
[
'lineNumber' => 1,
'description' => 'Subscription',
'quantity' => 2,
'unitNetPrice' => 75,
'totalNetAmount' => 150,
'product' => [
'productNumber' => 1,
],
'departmentalDistribution' => [
'distributions' => [
[
'percentage' => 60,
'department' => ['departmentNumber' => 75],
],
[
'percentage' => 40,
'department' => ['departmentNumber' => 10],
],
],
],
],
],
];
$normalized = economic_v2_line_normalizer::normalizeDraftInvoice($draft);
expect($normalized['source'])->toBe('draft');
expect($normalized['totals']['net_total'])->toBe(150.0);
expect($normalized['totals']['line_count'])->toBe(1);
expect($normalized['lines'][0]['product_number'])->toBe('1');
expect($normalized['lines'][0]['department_distribution']['75'])->toBe(60.0);
expect($normalized['lines'][0]['department_distribution']['10'])->toBe(40.0);
});
it('marks text-only zero-value lines as non-billable and keeps deterministic key', function (): void {
$draft = [
'lines' => [
[
'lineNumber' => 1,
'description' => '# Header line',
'quantity' => 0,
'unitNetPrice' => 0,
'totalNetAmount' => 0,
],
],
];
$normalized = economic_v2_line_normalizer::normalizeDraftInvoice($draft);
$line = $normalized['lines'][0];
expect($line['billable'])->toBeFalse();
expect($line['line_type'])->toBe('text');
expect($line['match_key'])->toStartWith('text:');
expect($line['department_distribution']['unassigned'])->toBe(100.0);
});
it('normalizes booked invoices and computes net total delta from lines', function (): void {
$booked = [
'net_amount' => 100,
'lines' => [
[
'line_number' => 1,
'description' => 'Wash',
'quantity' => 1,
'unit_net_price' => 90,
'total_net_amount' => 90,
'product' => ['product_number' => 33],
],
],
];
$normalized = economic_v2_line_normalizer::normalizeBookedInvoice($booked);
expect($normalized['source'])->toBe('booked');
expect($normalized['totals']['net_total'])->toBe(100.0);
expect($normalized['totals']['line_net_total'])->toBe(90.0);
expect($normalized['totals']['difference_from_line_sum'])->toBe(10.0);
});
it('contains internal normalization path with departmental metadata support', function (): void {
$classFile = app_path('classes/economic_v2_line_normalizer.php');
$content = file_get_contents($classFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('normalizeInternalCollectedInvoice(');
expect($content)->toContain("'department_distribution'");
expect($content)->toContain('buildMatchKey(');
});
@@ -0,0 +1,70 @@
<?php
function economic_v2_openapi_content_or_skip(): string
{
$candidates = [
dirname(__DIR__, 4) . DIRECTORY_SEPARATOR . 'openapi.yaml', // monorepo root in local workspace
dirname(__DIR__, 3) . DIRECTORY_SEPARATOR . 'openapi.yaml',
dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'openapi.yaml',
];
foreach ($candidates as $candidate) {
if (is_file($candidate)) {
$content = file_get_contents($candidate);
if ($content !== false) {
return $content;
}
}
}
test()->markTestSkipped('openapi.yaml is not available in this runtime environment.');
}
it('documents economic v2 invoice paths in openapi', function (): void {
$content = economic_v2_openapi_content_or_skip();
expect($content)->not->toBeFalse();
expect($content)->toContain('/collected-invoices/economic/v2/details:');
expect($content)->toContain('/collected-invoices/economic/v2/compare:');
expect($content)->toContain('/collected-invoices/economic/v2/compare/bulk:');
expect($content)->toContain('/collected-invoices/economic/v2/revenue-statistics:');
});
it('documents v2 historical distribution and pricing history paths in openapi', function (): void {
$content = economic_v2_openapi_content_or_skip();
expect($content)->not->toBeFalse();
expect($content)->toContain('/superuser/invoicing/period/distribution/v2/all:');
expect($content)->toContain('/superuser/invoicing/period/distribution/v2/fixed-pricing:');
expect($content)->toContain('/superuser/invoicing/period/distribution/v2/wash-subscriptions:');
expect($content)->toContain('/superuser/invoicing/period/distribution/v2/customer-prices:');
expect($content)->toContain('/superuser/customers/pricing-history:');
});
it('aligns legacy compare schema with runtime payload by removing stale required order_ids', function (): void {
$content = economic_v2_openapi_content_or_skip();
expect($content)->not->toBeFalse();
$start = strpos($content, 'CollectedInvoiceEconomicCompareResponse:');
$end = strpos($content, 'CollectedInvoiceEconomicV2DetailsResponse:');
expect($start)->not->toBeFalse();
expect($end)->not->toBeFalse();
$legacyBlock = substr($content, $start, $end - $start);
expect($legacyBlock)->toContain('- internal_total');
expect($legacyBlock)->not->toContain('order_ids:');
expect($legacyBlock)->not->toContain('- order_ids');
});
it('defines new reusable v2 schemas for normalization comparison versioning and distribution', function (): void {
$content = economic_v2_openapi_content_or_skip();
expect($content)->not->toBeFalse();
expect($content)->toContain('EconomicV2NormalizedLineItem:');
expect($content)->toContain('EconomicV2Comparison:');
expect($content)->toContain('CollectedInvoiceEconomicV2CustomerSummary:');
expect($content)->toContain('CollectedInvoiceEconomicV2RevenueStatisticsResponse:');
expect($content)->toContain('EconomicV2RevenueSummary:');
expect($content)->toContain('PricingHistoryVersionEntry:');
expect($content)->toContain('InvoicingDistributionV2AllResponse:');
});
@@ -0,0 +1,36 @@
<?php
it('supports barred filtering when listing e-conomic customers', function (): void {
$routeFile = app_path('routes/customerSearchRoute.php');
$routeContent = file_get_contents($routeFile);
expect($routeContent)->not->toBeFalse();
expect($routeContent)->toContain("fromRequest('barred')");
expect($routeContent)->toContain('listCustomers(');
$customersFile = app_path('modules/economic/customers/economicCustomers.php');
$customersContent = file_get_contents($customersFile);
expect($customersContent)->not->toBeFalse();
expect($customersContent)->toContain('normalizeBarredFilter(');
expect($customersContent)->toContain('barred$eq:');
});
it('implements a dedicated v2 e-conomic revenue statistics service and route', function (): void {
$routeFile = app_path('routes/orderInvoicesRoute.php');
$routeContent = file_get_contents($routeFile);
expect($routeContent)->not->toBeFalse();
expect($routeContent)->toContain('/collected-invoices/economic/v2/revenue-statistics');
expect($routeContent)->toContain("requirePermission('view_collected_invoice_economic_v2_revenue_statistics')");
expect($routeContent)->toContain('getBookedRevenueStatistics(');
$serviceFile = app_path('classes/economic_v2_revenue_statistics_service.php');
$serviceContent = file_get_contents($serviceFile);
expect($serviceContent)->not->toBeFalse();
expect($serviceContent)->toContain('class economic_v2_revenue_statistics_service');
expect($serviceContent)->toContain('passesBarredFilter(');
expect($serviceContent)->toContain('reduceInvoiceLines(');
});
@@ -0,0 +1,68 @@
<?php
it('registers all collected-invoice economic v2 routes with explicit permissions', function (): void {
$routeFile = app_path('routes/orderInvoicesRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('/collected-invoices/economic/v2/details');
expect($content)->toContain('/collected-invoices/economic/v2/compare');
expect($content)->toContain('/collected-invoices/economic/v2/compare/bulk');
expect($content)->toContain('/collected-invoices/economic/v2/revenue-statistics');
expect($content)->toContain("requirePermission('view_collected_invoice_economic_v2_details')");
expect($content)->toContain("requirePermission('compare_collected_invoice_economic_v2')");
expect($content)->toContain("requirePermission('compare_collected_invoice_economic_v2_bulk')");
expect($content)->toContain("requirePermission('view_collected_invoice_economic_v2_revenue_statistics')");
expect($content)->toContain("requireParameters(['collected_invoice_ids'])");
});
it('registers version-aware distribution and pricing history v2 routes', function (): void {
$routeFile = app_path('routes/InvoicingPeriodRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('/superuser/invoicing/period/distribution/v2/all');
expect($content)->toContain('/superuser/invoicing/period/distribution/v2/fixed-pricing');
expect($content)->toContain('/superuser/invoicing/period/distribution/v2/wash-subscriptions');
expect($content)->toContain('/superuser/invoicing/period/distribution/v2/customer-prices');
expect($content)->toContain('/superuser/customers/pricing-history');
expect($content)->toContain("requirePermission('superuser_invoicing_period_distribution_v2')");
expect($content)->toContain("requirePermission('superuser_customer_pricing_history_v2')");
});
it('writes fixed pricing versions from create and delete flows', function (): void {
$routeFile = app_path('routes/customerFixedPricingRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('recordFixedPricingVersion(');
expect($content)->toContain('closeActiveFixedPricingVersion(');
});
it('writes vehicle subscription versions for create update delete flows', function (): void {
$routeFile = app_path('routes/vehiclesRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('recordVehicleSubscriptionVersion(');
expect($content)->toContain('closeActiveVehicleSubscriptionVersion(');
expect($content)->toContain("if (self::isParametersSet(['customer_id']))");
});
it('writes discount override versions from superuser discounts route', function (): void {
$routeFile = app_path('routes/userRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('/superuser/user/discounts');
expect($content)->toContain('recordDiscountOverrideVersion(');
});
it('keeps legacy compare endpoint path for backward compatibility', function (): void {
$routeFile = app_path('routes/orderInvoicesRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('/collected-invoices/economic/compare');
expect($content)->toContain("requirePermission('compare_collected_invoice_economic')");
});
@@ -0,0 +1,45 @@
<?php
it('implements effective-range lifecycle methods for all versioned entities', function (): void {
$serviceFile = app_path('classes/economic_v2_versioning_service.php');
$content = file_get_contents($serviceFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('recordFixedPricingVersion(');
expect($content)->toContain('closeActiveFixedPricingVersion(');
expect($content)->toContain('recordVehicleSubscriptionVersion(');
expect($content)->toContain('closeActiveVehicleSubscriptionVersion(');
expect($content)->toContain('recordDiscountOverrideVersion(');
});
it('closes previous active interval before inserting a new version', function (): void {
$serviceFile = app_path('classes/economic_v2_versioning_service.php');
$content = file_get_contents($serviceFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('AND effective_from <');
expect($content)->toContain('AND (effective_to IS NULL OR effective_to >=');
expect($content)->toContain('minusOneSecond(');
});
it('includes best-effort backfill with provenance and confidence metadata', function (): void {
$serviceFile = app_path('classes/economic_v2_versioning_service.php');
$content = file_get_contents($serviceFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('runBestEffortBackfill(');
expect($content)->toContain('backfill.current_fixed_pricing');
expect($content)->toContain('backfill.current_vehicle');
expect($content)->toContain('backfill.current_discount_override');
expect($content)->toContain("'inferred' =>");
});
it('anchors historical resolution on order created_at timestamps in distribution service', function (): void {
$serviceFile = app_path('classes/economic_v2_distribution_service.php');
$content = file_get_contents($serviceFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('resolveFixedPricingVersionAt($customer_number, $created_at)');
expect($content)->toContain('resolveVehicleSubscriptionVersionsAt($customer_number, $created_at)');
expect($content)->toContain('resolveDiscountForProduct($customer_number, $product_id, $created_at)');
});