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