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

370 lines
14 KiB
PHP

<?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 ?? '';
}
}