187 lines
9.6 KiB
Python
187 lines
9.6 KiB
Python
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()
|