import copy import json import os import subprocess import sys import unittest from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from domain import auto_match, enrich from match_explanation import explain_match, single_checks from test_domain import material, train, workspace class MatchExplanationTests(unittest.TestCase): def pair(self, dates=None, merchants=None, content=''): return workspace( [material('invoice', 'invoice', '100.00', content, dates, merchants)], [material('payment', 'payment', '100.00', content, dates, merchants)]) def explained(self, state): return enrich(auto_match(state))['matches'][0] def assert_scores(self, match, score, evidence_score=None): explanation = match['explanation'] self.assertEqual(match['score'], score) self.assertTrue(explanation['checks']) self.assertEqual(sum(item['points'] for item in explanation['checks']), explanation['rawScore']) self.assertEqual(explanation['version'], 2) self.assertEqual(sum(item['maximum'] for item in explanation['checks']), 100) self.assertEqual(explanation['rawScore'], score if evidence_score is None else evidence_score) self.assertTrue(all(0 <= item['points'] <= item['maximum'] for item in explanation['checks'])) self.assertEqual(sum(item['maximum'] - item['points'] for item in explanation['checks']), 100 - explanation['rawScore']) def test_90_missing_merchant_and_order_not_amount(self): match = self.explained(self.pair(dates=['2026-09-01'])) self.assert_scores(match, 90, 65) checks = match['explanation']['checks'] self.assertEqual([item['points'] for item in checks], [40, 0, 10, 0, 15]) self.assertEqual([item['maximum'] for item in checks], [40, 20, 10, 15, 15]) self.assertEqual(checks[1]['issue'], '商户未识别') self.assertEqual(checks[3]['issue'], '单号未识别') self.assertIn('不是金额', match['explanation']['note']) def test_75_missing_dates_and_merchants(self): match = self.explained(self.pair()) self.assert_scores(match, 75, 55) self.assertEqual(match['explanation']['checks'][2]['issue'], '日期未识别') self.assertIn('发票和付款截图', match['explanation']['checks'][2]['detail']) def test_70_nonunique_with_rejected_alternative(self): state = self.pair(dates=['2026-09-01']) state['payments'].append(material('other', 'payment', '100.00')) match = self.explained(state) self.assert_scores(match, 70, 50) self.assertEqual(match['explanation']['checks'][-1]['points'], 0) self.assertIn('其他', match['explanation']['checks'][-1]['detail']) def test_old_capped_100_is_now_75_when_date_and_order_missing(self): match = self.explained(self.pair(merchants=['海棠餐厅'])) self.assert_scores(match, 100, 75) self.assertEqual(match['explanation']['checks'][2]['points'], 0) self.assertIn('每项', match['explanation']['note']) def test_all_fields_total_exactly_100_without_cap(self): match = self.explained(self.pair(['2026-09-01'], ['海棠餐厅'], '订单号 ORDER12345678')) self.assert_scores(match, 100) self.assertEqual(match['explanation']['rawScore'], 100) def test_date_tiers_and_one_sided_missing(self): for dates, points, score in [(['2026-09-04'], 10, 90), (['2026-09-05'], 5, 83), (['2026-09-09'], 0, 75), ([], 0, 75)]: with self.subTest(dates=dates): state = self.pair(['2026-09-01']) state['payments'][0]['ocr']['dates'] = dates match = self.explained(state) self.assert_scores(match, score, 55 + points) self.assertEqual(match['explanation']['checks'][2]['points'], points) if not dates: self.assertIn('付款截图未识别', match['explanation']['checks'][2]['detail']) def test_merchant_partial_and_below_threshold(self): for merchant, old_points, points in [('海棠餐饮', 13, 10), ('其他商户', 0, 0)]: state = self.pair(merchants=['海棠餐厅']) state['payments'][0]['ocr']['merchants'] = [merchant] match = self.explained(state) self.assert_scores(match, 75 + old_points, 55 + points) self.assertIn('35%', match['explanation']['checks'][1]['detail']) self.assertIn(merchant, match['explanation']['checks'][1]['detail']) def test_different_order_numbers(self): state = self.pair(content='订单号 ORDER12345678') state['payments'][0]['ocr']['orderNumbers'] = ['OTHER12345678'] match = self.explained(state) self.assert_scores(match, 75, 55) self.assertEqual(match['explanation']['checks'][3]['issue'], '单号不一致') def test_shared_trip_95_and_100(self): for count in [1, 2]: state = workspace([train('first', '张三'), train('second', '李四')], [material(f'payment-{index}', 'payment', '2116.00', '12306 支付成功', ['2026-06-24']) for index in range(count)]) match = self.explained(state) self.assert_scores(match, 95 if count == 1 else 100) self.assertEqual(match['explanation']['checks'][1]['points'], 0 if count == 1 else 5) def test_combined_invoices_90_nontravel(self): state = workspace( [material('first', 'invoice', '30.00', dates=['2026-09-01']), material('second', 'invoice', '70.00', dates=['2026-09-01'])], [material('pay', 'payment', '100.00', '订单号 ORDER12345678', ['2026-09-01']), material('order', 'payment', '100.00', '订单号 ORDER12345678', ['2026-09-01'])]) match = self.explained(state) self.assert_scores(match, 90) self.assertEqual([item['points'] for item in match['explanation']['checks']], [70, 10, 10, 0]) self.assertIn('不适用', match['explanation']['checks'][-1]['detail']) def test_split_payment_fixed_95_is_not_fabricated_deduction(self): state = workspace([material('invoice', 'invoice', '100.00')], [material('first', 'payment', '30.00'), material('second', 'payment', '70.00')]) match = self.explained(state) self.assert_scores(match, 95) self.assertEqual(match['explanation']['checks'][-1]['title'], '规则保留分') self.assertIn('固定', match['explanation']['checks'][-1]['detail']) def test_legacy_refresh_preserves_pairing_and_historical_uniqueness(self): state = auto_match(self.pair(['2026-09-01'])) original = copy.deepcopy(state['matches'][0]) state['invoices'].append(material('later', 'invoice', '100.00')) enriched = enrich(state) match = enriched['matches'][0] self.assert_scores(match, 90, 65) for field in ['id', 'score', 'reasons', 'category']: self.assertEqual(match[field], original[field]) self.assertEqual([item['id'] for item in match['invoices']], [item['id'] for item in original['invoices']]) self.assertEqual(enrich(enriched), enriched) def test_previous_explanations_upgrade_without_rematching(self): for dates, merchants, content, expected in [ ([], [], '', 55), (['2026-09-01'], ['海棠餐厅'], '订单号 ORDER12345678', 100) ]: state = auto_match(self.pair(dates, merchants, content)) match = state['matches'][0] checks = single_checks(match) match['explanation'] = dict(title='旧版评分', rawScore=sum(item['points'] for item in checks), checks=checks, note='旧版封顶说明') previous_id, previous_score = match['id'], match['score'] enrich(state) self.assert_scores(match, previous_score, expected) self.assertEqual(match['id'], previous_id) previous = copy.deepcopy(state) self.assertEqual(enrich(state), previous) def test_manual_and_unknown_records(self): match = self.explained(self.pair()) match['matchType'] = 'manual' self.assertIsNone(explain_match(match)) match['matchType'] = 'auto' match['reasons'] = ['历史匹配'] self.assertEqual(explain_match(match)['checks'], []) self.assertIn('不推测', explain_match(match)['note']) def test_changed_ocr_does_not_invent_historical_scores(self): match = self.explained(self.pair()) match['invoices'][0]['ocr']['dates'] = ['2026-09-01'] match['payments'][0]['ocr']['dates'] = ['2026-09-01'] self.assertEqual(explain_match(match)['checks'], []) self.assertIn('不一致', explain_match(match)['note']) def test_engine_process_backfills_existing_workspace(self): state = auto_match(self.pair(['2026-09-01'])) engine = Path(__file__).resolve().parents[1] / 'engine.py' command = [os.environ['RECEIPT_ENGINE_BINARY']] if os.environ.get('RECEIPT_ENGINE_BINARY') else [sys.executable, str(engine)] result = subprocess.run(command, input=json.dumps(dict(operation='refresh', state=state)), capture_output=True, text=True, timeout=60) self.assertEqual(result.returncode, 0, result.stderr) response = json.loads(result.stdout.splitlines()[-1]) self.assertEqual(response['event'], 'result') match = response['result']['matches'][0] self.assert_scores(match, 90, 65) self.assertEqual(match['id'], state['matches'][0]['id']) if __name__ == '__main__': unittest.main()