import copy import json import os import subprocess import sys import tempfile import unittest import zipfile from pathlib import Path from test_domain import material, workspace from domain import auto_match, enrich from exports import export_expense, export_ppt, export_travel from PIL import Image from openpyxl import load_workbook from pptx import Presentation class ExportTests(unittest.TestCase): def setUp(self): self.temporary = tempfile.TemporaryDirectory() self.root = Path(self.temporary.name) image = self.root / 'receipt.png' Image.new('RGB', (400, 200), 'white').save(image) self.state = enrich(auto_match(workspace([material('i', 'invoice', '100.00')], [material('p', 'payment', '100.00')]))) for item in self.state['invoices'] + self.state['payments']: item['previewPath'] = str(image) self.template = Path(__file__).parents[1] / 'personal-expense-template.xlsx' def tearDown(self): self.temporary.cleanup() def test_simple_ppt_with_photo_placeholder(self): destination = self.root / 'test.pptx' export_ppt(self.state, destination, True) deck = Presentation(destination) self.assertEqual(len(deck.slides), 3) self.assertEqual(deck.slide_width, 1280 * 12700) self.assertTrue(any('实物照片' in shape.text for shape in deck.slides[2].shapes if shape.has_text_frame)) def test_travel_category_excludes_photo(self): self.state['matches'][0]['category'] = '交通' destination = self.root / 'test.pptx' export_ppt(self.state, destination, False) self.assertEqual(len(Presentation(destination).slides), 2) def test_complex_payment_pagination(self): self.state['matches'][0]['payments'] *= 5 destination = self.root / 'test.pptx' export_ppt(self.state, destination, True) self.assertEqual(len(Presentation(destination).slides), 5) def test_expense_template_values_and_formulas(self): destination = self.root / 'expense.xlsx' match = self.state['matches'][0] export_expense(self.state, destination, self.template, {match['id']: '=不是公式'}, ['经办人']) workbook = load_workbook(destination) self.assertEqual(workbook.sheetnames, ['个人报销单']) sheet = workbook.active self.assertEqual(sheet['C9'].value, '=不是公式') self.assertEqual(sheet['C9'].data_type, 's') self.assertEqual(sheet['H9'].value, 100) self.assertEqual(sheet['G9'].value, 1) self.assertEqual(sheet['A30'].value, '经办人:') self.assertEqual(sheet['D30'].value, '') original = load_workbook(self.template)['1个人报销单'] formulas = {cell.coordinate: cell.value for row in original for cell in row if cell.data_type == 'f'} self.assertTrue(formulas) for coordinate, value in formulas.items(): self.assertEqual(sheet[coordinate].value, value) self.assertEqual(str(sheet.print_area).split('!')[-1], str(original.print_area).split('!')[-1]) def test_expense_pagination(self): self.state['matches'] = [copy.deepcopy(self.state['matches'][0]) for _ in range(14)] for index, match in enumerate(self.state['matches']): match['category'] = f'类别{index}' destination = self.root / 'expense.xlsx' export_expense(self.state, destination, self.template, {}, []) workbook = load_workbook(destination) self.assertEqual(workbook.sheetnames, ['个人报销单-1', '个人报销单-2']) self.assertEqual(workbook.worksheets[1]['H9'].value, 100) self.assertEqual(workbook.worksheets[1]['H10'].value, '') self.assertEqual(workbook.worksheets[1]['A30'].value, '部门长:') def test_expense_payee_is_text_on_every_page(self): self.state['matches'] = [copy.deepcopy(self.state['matches'][0]) for _ in range(14)] for index, match in enumerate(self.state['matches']): match['category'] = f'类别{index}' destination = self.root / 'payee.xlsx' profile = dict(recipient='测试收款人', bankName='=测试银行支行', accountNumber='0012345678901234567890', preparer='测试制单人') export_expense(self.state, destination, self.template, {}, [], profile) for sheet in load_workbook(destination).worksheets: for reference, value in [('C23', profile['recipient']), ('C24', profile['bankName']), ('C25', profile['accountNumber']), ('H6', profile['preparer'])]: self.assertEqual(sheet[reference].value, value) self.assertEqual(sheet[reference].data_type, 's') self.assertEqual(sheet['C25'].style_id, load_workbook(self.template)['1个人报销单']['C25'].style_id) def test_expense_without_profile_clears_template_identity(self): destination = self.root / 'empty-payee.xlsx' export_expense(self.state, destination, self.template, {}, []) sheet = load_workbook(destination).active for reference in ['C23', 'C24', 'C25', 'H6']: self.assertEqual(sheet[reference].value, '') def test_expense_preparer_defaults_to_recipient(self): destination = self.root / 'default-preparer.xlsx' export_expense(self.state, destination, self.template, {}, [], {'recipient': '测试收款人'}) self.assertEqual(load_workbook(destination).active['H6'].value, '测试收款人') def test_expense_profile_through_engine_process(self): binary = os.environ.get('RECEIPT_ENGINE_BINARY') command = [binary] if binary else [sys.executable, str(Path(__file__).resolve().parents[1] / 'engine.py')] destination = self.root / 'process-expense.xlsx' self.state['matches'] = [copy.deepcopy(self.state['matches'][0]) for _ in range(6)] for match in self.state['matches']: match['category'] = '交通' request = dict(operation='expense', state=self.state, destination=str(destination), categoryPurposes={'交通': '交通'}, payee=dict(recipient='测试收款人', bankName='测试支行', accountNumber='0001234567890123456789')) process = subprocess.run(command, input=json.dumps(request) + '\n', capture_output=True, text=True, timeout=60) self.assertEqual(process.returncode, 0, process.stdout + process.stderr) events = [json.loads(line) for line in process.stdout.splitlines()] self.assertEqual(events[-1]['event'], 'result') sheet = load_workbook(destination).active self.assertEqual(sheet['C23'].value, '测试收款人') self.assertEqual(sheet['C24'].value, '测试支行') self.assertEqual(sheet['C25'].value, '0001234567890123456789') self.assertEqual(sheet['C25'].data_type, 's') self.assertEqual(sheet['H6'].value, '测试收款人') self.assertEqual(sheet['C9'].value, '交通') self.assertEqual(sheet['G9'].value, 6) self.assertEqual(sheet['H9'].value, 600) self.assertEqual(sheet['H10'].value, '') def test_expense_engine_process_groups_selected_traffic_and_office(self): binary = os.environ.get('RECEIPT_ENGINE_BINARY') command = [binary] if binary else [sys.executable, str(Path(__file__).resolve().parents[1] / 'engine.py')] amounts = [['1058.00', '1058.00'], ['140.00'], ['300.00'], ['163.92'], ['253.45'], ['74.49'], ['444.00']] selected = [] for index, invoice_amounts in enumerate(amounts): match = copy.deepcopy(self.state['matches'][0]) match['id'] = f'selected-{index}' match['category'] = '办公用品' if index == 6 else '交通' match['invoices'] = [] for invoice_index, amount in enumerate(invoice_amounts): invoice = copy.deepcopy(self.state['matches'][0]['invoices'][0]) invoice['id'] = f'invoice-{index}-{invoice_index}' invoice['ocr']['amounts'] = [amount] match['invoices'].append(invoice) selected.append(match) self.state['matches'] = selected destination = self.root / 'two-categories.xlsx' request = dict(operation='expense', state=self.state, destination=str(destination), categoryPurposes={'交通': '交通', '办公用品': '办公用品'}) process = subprocess.run(command, input=json.dumps(request) + '\n', capture_output=True, text=True, timeout=60) self.assertEqual(process.returncode, 0, process.stdout + process.stderr) events = [json.loads(line) for line in process.stdout.splitlines()] self.assertEqual(events[-1]['result']['expenseGrouping'], 'category-v1') self.assertEqual(events[-1]['result']['expenseRowCount'], 2) workbook = load_workbook(destination) self.assertEqual(workbook.sheetnames, ['个人报销单']) sheet = workbook.active rows = [(sheet[f'C{row}'].value, sheet[f'G{row}'].value, sheet[f'H{row}'].value) for row in range(9, 22) if sheet[f'H{row}'].value not in ('', None)] self.assertEqual(rows, [('交通', 7, 3047.86), ('办公用品', 1, 444)]) self.assertEqual(round(sum(row[2] for row in rows), 2), 3491.86) self.assertEqual(sheet['F27'].value, '=SUM(H9:H21)') def test_expense_same_category_collapses_before_pagination(self): self.state['matches'] = [copy.deepcopy(self.state['matches'][0]) for _ in range(14)] for match in self.state['matches']: match['category'] = '交通' destination = self.root / 'collapsed.xlsx' export_expense(self.state, destination, self.template, {}, []) workbook = load_workbook(destination) self.assertEqual(workbook.sheetnames, ['个人报销单']) sheet = workbook.active self.assertEqual(sheet['C9'].value, '交通') self.assertEqual(sheet['G9'].value, 14) self.assertEqual(sheet['H9'].value, 1400) self.assertEqual(sheet['H10'].value, '') def test_expense_category_totals_counts_and_mixed_invoice_types(self): traffic = [copy.deepcopy(self.state['matches'][0]) for _ in range(6)] for match in traffic: match['category'] = '交通' match['invoices'][0]['ocr']['amounts'] = ['100.10', '2'] traffic[0]['invoices'][0]['ocr']['rawText'] = '专用发票' hotel = copy.deepcopy(traffic[0]) hotel['category'] = '住宿' hotel['invoices'] *= 2 hotel['invoices'][0]['ocr']['amounts'] = ['-1,200.30', '2'] self.state['matches'] = [traffic[0], hotel] + traffic[1:] original = copy.deepcopy(self.state) destination = self.root / 'grouped.xlsx' export_expense(self.state, destination, self.template, {}, [], category_purposes={'交通': '=交通用途'}) sheet = load_workbook(destination).active self.assertEqual(sheet['C9'].value, '=交通用途') self.assertEqual(sheet['C9'].data_type, 's') self.assertEqual(sheet['B9'].value, '专票/普票') self.assertEqual(sheet['G9'].value, 6) self.assertEqual(sheet['H9'].value, 600.6) self.assertEqual(sheet['C10'].value, '住宿') self.assertEqual(sheet['G10'].value, 2) self.assertEqual(sheet['H10'].value, 2400.6) self.assertEqual(sheet['H11'].value, '') self.assertEqual(self.state, original) def test_expense_empty_selection_is_rejected(self): self.state['matches'] = [] with self.assertRaisesRegex(ValueError, '勾选'): export_expense(self.state, self.root / 'empty.xlsx', self.template, {}, []) def test_travel_only_verified_invoices(self): travel = self.state['invoices'][0]['ocr']['travel'] travel.update(type='train', travelerName='张三', departure='北京南', destination='上海虹桥', departureTime='2026-06-24 15:00', transportNumber='G21') destination = self.root / 'travel.xlsx' export_travel(self.state, destination) sheet = load_workbook(destination).active self.assertEqual(sheet.max_row, 2) self.assertEqual(sheet['C2'].value, '张三') self.assertEqual(sheet['G2'].value, 'G21') def test_no_travel_raises(self): with self.assertRaises(ValueError): export_travel(self.state, self.root / 'travel.xlsx') if __name__ == '__main__': unittest.main()