370 lines
14 KiB
PHP
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 ?? '';
|
|
}
|
|
}
|
|
|