Files
2026-09-16 21:02:44 +08:00

261 lines
14 KiB
Python

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), 1)
self.assertEqual(deck.slide_width, 720 * 12700)
self.assertEqual(deck.slide_height, 960 * 12700)
self.assertTrue(any('实物照片' in shape.text for shape in deck.slides[0].shapes if shape.has_text_frame))
def test_travel_category_also_reserves_photo_area(self):
self.state['matches'][0]['category'] = '交通'
destination = self.root / 'test.pptx'
export_ppt(self.state, destination, False)
deck = Presentation(destination)
self.assertEqual(len(deck.slides), 1)
self.assertTrue(any(shape.name == 'manual-photo-area' for shape in deck.slides[0].shapes))
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), 3)
def test_ppt_layouts_are_portrait_and_reserve_photo_page(self):
scenarios = []
single = copy.deepcopy(self.state['matches'][0])
scenarios.append(single)
one_invoice_many_payments = copy.deepcopy(single)
one_invoice_many_payments['payments'] *= 3
scenarios.append(one_invoice_many_payments)
many_invoices_many_payments = copy.deepcopy(single)
many_invoices_many_payments['invoices'] *= 2
many_invoices_many_payments['payments'] *= 2
scenarios.append(many_invoices_many_payments)
for index, match in enumerate(scenarios):
destination = self.root / f'layout-{index}.pptx'
export_ppt(dict(matches=[match]), destination, True)
deck = Presentation(destination)
self.assertEqual(deck.slide_width, 720 * 12700)
self.assertEqual(deck.slide_height, 960 * 12700)
self.assertGreaterEqual(len(deck.slides), 1)
self.assertTrue(any(
any('实物照片粘贴区' in shape.text for shape in slide.shapes if shape.has_text_frame)
for slide in deck.slides
))
self.assertEqual(sum(shape.name.startswith('payment-') for slide in deck.slides for shape in slide.shapes),
len(match['payments']))
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()