Initial commit: macOS receipt workspace

This commit is contained in:
于晓婷
2026-09-11 12:58:09 +08:00
commit bbacd1d6f3
40 changed files with 2790 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
import copy
import sys
import unittest
from decimal import Decimal
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from domain import auto_match, category, combinations, enrich, extract, invoice_amount, payment_total
def material(identifier, kind, amount, content='', dates=None, merchants=None):
ocr = extract(content.splitlines())
ocr.update(amounts=[amount] if isinstance(amount, str) else amount, dates=dates or [], merchants=merchants or [])
return dict(id=identifier, name=identifier + '.png', path=identifier, previewPath='', type=kind,
size=1, matched=False, ocr=ocr)
def workspace(invoices, payments):
return dict(rootPath='测试', invoices=invoices, payments=payments, photos=[], matches=[], warnings=[])
def train(identifier, traveler):
return material(identifier, 'invoice', '1058.00', f'铁路电子客票\n乘车人:{traveler}\n北京南 → 上海虹桥\n2026年06月24日 15:00\nG21次', ['2026-06-24'], ['中国铁路'])
class ExtractionTests(unittest.TestCase):
def test_train(self):
data = extract(['铁路电子客票', '乘车人:张三', '北京南 → 上海虹桥', '2026年08月12日 09:30', 'G101次', '票价 ¥553.00'])
self.assertEqual(data['travel'], dict(type='train', travelerName='张三', departure='北京南', destination='上海虹桥', departureTime='2026-08-12 09:30', transportNumber='G101'))
def test_flight(self):
travel = extract(['航空电子客票行程单', '旅客姓名:李四', '出发地:广州', '目的地:成都', '2026-08-15 14:20', '航班号 CZ3401'])['travel']
self.assertEqual((travel['type'], travel['travelerName'], travel['departure'], travel['destination'], travel['transportNumber']), ('flight', '李四', '广州', '成都', 'CZ3401'))
def test_invoice_date_is_not_departure(self):
travel = extract(['电子发票(铁路电子客票) 北京市税务局', '发票号码:26119110010005751560 开票日期:2026年06月26日', '北京南站 G21 上海虹桥站', '2026年06月24日 15:00开 17车04C号', '2107021990****0234 王天行', '票价:¥1058.00'])['travel']
self.assertEqual(travel['travelerName'], '王天行')
self.assertEqual(travel['departureTime'], '2026-06-24 15:00')
def test_invalid_date_does_not_abort_material(self):
self.assertEqual(extract(['2026年02月31日 金额 20'])['dates'], [])
def test_amount_order(self):
self.assertEqual(extract(['票价 ¥1058.00', '支付总额 2116'])['amounts'][0], '2116.00')
class MatchingTests(unittest.TestCase):
def test_original_many_to_many_fixture(self):
state = workspace([train('i1', '王天行'), train('i2', '王超文')], [
material('p1', 'payment', ['1058.00', '2116.00'], '12306订单 中国铁路 支付总额 2116', ['2026-06-24']),
material('p2', 'payment', '2116.00', '12306消费 中国铁路', ['2026-06-22'])])
auto_match(state)
self.assertEqual(len(state['matches']), 1)
match = state['matches'][0]
self.assertEqual((len(match['invoices']), len(match['payments']), match['score'], match['category']), (2, 2, 100, '交通'))
def test_shared_travel_payment_evidence(self):
state = workspace([train('i1', '张三'), train('i2', '李四')], [material('p1', 'payment', '2116.00', '12306 支付成功', ['2026-06-24'])])
auto_match(state)
self.assertEqual(state['matches'][0]['score'], 95)
def test_unique_amount(self):
state = workspace([material('i', 'invoice', '100.00')], [material('p', 'payment', '100.00')])
auto_match(state)
self.assertEqual(state['matches'][0]['score'], 75)
def test_ambiguous_stays_unmatched(self):
state = workspace([material('i', 'invoice', '100.00', dates=['2026-08-01'])], [material('p1', 'payment', '100.00', dates=['2026-08-01']), material('p2', 'payment', '100.00', dates=['2026-08-01'])])
self.assertFalse(auto_match(state)['matches'])
def test_high_score_keeps_original_tie_rule(self):
state = workspace([material('i', 'invoice', '100.00', dates=['2026-08-01'], merchants=['测试公司'])], [material('p1', 'payment', '100.00', dates=['2026-08-01'], merchants=['测试公司']), material('p2', 'payment', '100.00', dates=['2026-08-01'], merchants=['测试公司'])])
self.assertEqual(len(auto_match(state)['matches']), 1)
def test_split_payment(self):
state = workspace([material('i', 'invoice', '100.00')], [material('p1', 'payment', '30.00'), material('p2', 'payment', '70.00')])
self.assertEqual(len(auto_match(state)['matches'][0]['payments']), 2)
def test_ambiguous_split_payment(self):
state = workspace([material('i', 'invoice', '100.00')], [material('p1', 'payment', '30.00'), material('p2', 'payment', '70.00'), material('p3', 'payment', '40.00'), material('p4', 'payment', '60.00')])
self.assertFalse(auto_match(state)['matches'])
def test_filename_does_not_match(self):
state = workspace([material('same', 'invoice', '100.00')], [material('same', 'payment', '90.00')])
self.assertFalse(auto_match(state)['matches'])
def test_decimal_combinations(self):
options = combinations([('first', Decimal('.10')), ('second', Decimal('.20'))], Decimal('.30'))
self.assertEqual(options, [['first', 'second']])
def test_original_categories(self):
for content, expected in [('海棠酒店 住宿 房费 客房', '住宿'), ('餐厅 美团外卖 咖啡 饮品', '餐饮'), ('办公用品 打印纸 墨盒 文具', '办公用品'), ('京东商城 采购家具设备', '采购'), ('客户招待 商务宴请', '招待')]:
self.assertEqual(category([material('i', 'invoice', '100.00', content)]), expected)
def test_summary_duplicate(self):
self.assertEqual(payment_total([material('order', 'payment', '100.00', '订单详情 支付总额 100.00'), material('proof', 'payment', '100.00', '支付成功 实付金额 100.00')]), Decimal('100.00'))
def test_invoice_total_labeled(self):
self.assertEqual(invoice_amount(material('i', 'invoice', ['200.00', '100.00'], '发票金额 100.00')), Decimal('100.00'))
def test_enrich_preserves_group_identity(self):
state = workspace([material('i', 'invoice', '100.00')], [material('p', 'payment', '100.00')])
enrich(auto_match(state))
self.assertTrue(state['matches'][0]['invoices'][0]['matched'])
self.assertEqual(state['matches'][0]['expenseAmount'], 100)
if __name__ == '__main__':
unittest.main()