Add economic V2 modules for history backfill, comparison, and distribution services.
This commit is contained in:
+1031
-7
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]++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+120
@@ -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();
|
||||
}
|
||||
});
|
||||
+153
@@ -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)');
|
||||
});
|
||||
Reference in New Issue
Block a user