289 lines
16 KiB
Python
289 lines
16 KiB
Python
import copy
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import unittest
|
||
import zipfile
|
||
from pathlib import Path
|
||
|
||
from lxml import etree as ET
|
||
from openpyxl import load_workbook
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||
from expense_template import (CONTENT, MAIN, PACKAGE, REL, Template, encoded, export_custom_expense,
|
||
inspect_template, set_cell, tag, validate_template, xml)
|
||
from exports import export_expense
|
||
from test_domain import material, workspace
|
||
|
||
|
||
class ExpenseTemplateTests(unittest.TestCase):
|
||
def setUp(self):
|
||
self.temporary = tempfile.TemporaryDirectory()
|
||
self.root = Path(self.temporary.name)
|
||
self.builtin = Path(__file__).resolve().parents[1] / 'personal-expense-template.xlsx'
|
||
self.state = workspace([], [])
|
||
self.state['matches'] = [self.match('traffic-1', '交通', '30.10'), self.match('traffic-2', '交通', '70.20'),
|
||
self.match('office', '办公用品', '444.00')]
|
||
self.template = self.root / '旧报销单.xlsx'
|
||
old = dict(matches=[self.match('old-' + str(index), '旧类别' + str(index), '999.00') for index in range(7)])
|
||
export_expense(old, self.template, self.builtin, {}, ['旧岗位'],
|
||
dict(recipient='旧收款人', bankName='旧开户行', accountNumber='9999999', preparer='旧制单人'))
|
||
self.mapping = inspect_template(self.template)['sheets'][0]['mapping']
|
||
|
||
def tearDown(self):
|
||
self.temporary.cleanup()
|
||
|
||
def match(self, identifier, category, amount):
|
||
return dict(id=identifier, category=category, invoices=[material(identifier, 'invoice', amount)], payments=[])
|
||
|
||
def export(self, mapping=None, template=None, state=None, payee=None):
|
||
output = self.root / 'output.xlsx'
|
||
metadata = export_custom_expense(state or self.state, output, template or self.template, mapping or self.mapping,
|
||
{}, ['经办人', '财务'], payee or {},
|
||
{'交通': '交通', '办公用品': '=办公用品'})
|
||
return output, metadata
|
||
|
||
def rewrite(self, path, parts):
|
||
with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as archive:
|
||
for name, data in parts.items():
|
||
archive.writestr(name, data)
|
||
|
||
def alternate(self):
|
||
template = Template(self.template)
|
||
sheet, path, _ = template.sheet('个人报销单')
|
||
sheet.set('name', '组 B 报销')
|
||
document = ET.Element(tag('worksheet'), nsmap={None: MAIN})
|
||
ET.SubElement(document, tag('sheetData'))
|
||
values = {'A1': '自定义组 B 报销单', 'A4': '序号', 'B4': '用途', 'D4': '单据数量', 'F4': '金额',
|
||
'G4': '备注', 'B5': '旧内容1', 'F5': 500, 'G5': '旧备注', 'B6': '旧内容2', 'F6': 800,
|
||
'A10': '收款人', 'B10': '旧收款人', 'A11': '开户行', 'B11': '旧银行',
|
||
'A12': '账号', 'B12': '旧账号', 'E2': '制单人', 'F2': '旧人', 'E8': '报销合计',
|
||
'A14': '要清空的旧值', 'A15': '旧签字', 'F15': '旧岗位', 'A16': '保留的自定义说明'}
|
||
for reference, value in values.items():
|
||
set_cell(document, reference, value)
|
||
set_cell(document, 'F8', 'SUM(F5:F6)', formula=True)
|
||
merges = ET.SubElement(document, tag('mergeCells'), count='3')
|
||
for reference in ['B4:C4', 'B5:C5', 'B6:C6']:
|
||
ET.SubElement(merges, tag('mergeCell'), ref=reference)
|
||
ET.SubElement(document, tag('pageMargins'), left='0.2', right='0.2', top='0.3', bottom='0.3', header='0', footer='0')
|
||
ET.SubElement(document, tag('pageSetup'), paperSize='9', orientation='landscape', fitToWidth='1')
|
||
template.parts[path] = encoded(document)
|
||
names = template.workbook.find(tag('definedNames'))
|
||
for item in list(names):
|
||
names.remove(item)
|
||
ET.SubElement(names, tag('definedName'), name='_xlnm.Print_Area', localSheetId='0').text = "'组 B 报销'!$A$1:$G$16"
|
||
sheet_list = template.workbook.find(tag('sheets'))
|
||
ET.SubElement(sheet_list, tag('sheet'), name='保留说明', sheetId='2', attrib={'{' + REL + '}id': 'rIdNotes'})
|
||
ET.SubElement(template.relations, '{' + PACKAGE + '}Relationship', Id='rIdNotes', Type=REL + '/worksheet',
|
||
Target='worksheets/notes.xml')
|
||
ET.SubElement(template.types, '{' + CONTENT + '}Override', PartName='/xl/worksheets/notes.xml',
|
||
ContentType='application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml')
|
||
notes = ET.Element(tag('worksheet'), nsmap={None: MAIN})
|
||
ET.SubElement(notes, tag('sheetData'))
|
||
set_cell(notes, 'A1', '保持这个工作表不变')
|
||
template.parts['xl/worksheets/notes.xml'] = encoded(notes)
|
||
template.parts['xl/workbook.xml'] = encoded(template.workbook)
|
||
template.parts['xl/_rels/workbook.xml.rels'] = encoded(template.relations)
|
||
template.parts['[Content_Types].xml'] = encoded(template.types)
|
||
target = self.root / 'different-layout.xlsx'
|
||
self.rewrite(target, template.parts)
|
||
mapping = inspect_template(target)['sheets'][0]['mapping']
|
||
mapping['signatureCells'] = 'A15,F15'
|
||
mapping['clearCells'] = 'A14'
|
||
return target, mapping
|
||
|
||
def test_auto_detect_uploaded_style_and_merged_signature_slots(self):
|
||
self.assertEqual(self.mapping['sheetName'], '个人报销单')
|
||
self.assertEqual((self.mapping['startRow'], self.mapping['endRow']), (9, 21))
|
||
self.assertEqual(self.mapping['columns'], dict(purpose='C', amount='H', count='G', invoiceType='B', sequence='A', remarks='I'))
|
||
self.assertEqual(self.mapping['cells'], dict(recipient='C23', bankName='C24', accountNumber='C25', preparer='H6', total='F27'))
|
||
self.assertNotIn('D32', self.mapping['signatureCells'])
|
||
validation = validate_template(self.template, self.mapping)
|
||
self.assertEqual(validation['mapping'], self.mapping)
|
||
self.assertEqual(len(validation['fingerprint']), 64)
|
||
|
||
def test_replaces_old_seven_rows_with_two_aggregated_categories(self):
|
||
original = self.template.read_bytes()
|
||
output, metadata = self.export()
|
||
self.assertEqual(metadata['expenseRowCount'], 2)
|
||
self.assertEqual(metadata['expensePageCount'], 1)
|
||
workbook = load_workbook(output)
|
||
sheet = workbook.active
|
||
self.assertEqual(sheet['C9'].value, '交通')
|
||
self.assertEqual(sheet['H9'].value, 100.30)
|
||
self.assertEqual(sheet['G9'].value, 2)
|
||
self.assertEqual(sheet['C10'].value, '=办公用品')
|
||
self.assertEqual(sheet['C10'].data_type, 's')
|
||
self.assertEqual(sheet['H10'].value, 444)
|
||
for row in range(11, 22):
|
||
for column in ['A', 'B', 'C', 'G', 'H', 'I']:
|
||
self.assertIn(sheet[f'{column}{row}'].value, ('', None))
|
||
for reference in ['C23', 'C24', 'C25', 'H6']:
|
||
self.assertIn(sheet[reference].value, ('', None))
|
||
self.assertEqual(sheet['F27'].value, '=SUM(H9:H21)')
|
||
self.assertEqual(sheet['A30'].value, '经办人:')
|
||
self.assertEqual(sheet['D30'].value, '财务:')
|
||
self.assertEqual(self.template.read_bytes(), original)
|
||
workbook.close()
|
||
|
||
def test_preserves_styles_merges_print_settings_and_media(self):
|
||
output, _ = self.export()
|
||
with zipfile.ZipFile(self.template) as original, zipfile.ZipFile(output) as generated:
|
||
for name in original.namelist():
|
||
if name == 'xl/styles.xml' or name.startswith('xl/media/') or name.startswith('xl/drawings/'):
|
||
self.assertEqual(original.read(name), generated.read(name), name)
|
||
old, new = load_workbook(self.template), load_workbook(output)
|
||
for reference in ['A1', 'A3', 'A4', 'C9', 'H9', 'C25']:
|
||
self.assertEqual(old.active[reference].style_id, new.active[reference].style_id)
|
||
self.assertEqual(str(old.active.merged_cells), str(new.active.merged_cells))
|
||
self.assertEqual(str(old.active.print_area), str(new.active.print_area))
|
||
self.assertEqual(old.active.page_setup, new.active.page_setup)
|
||
self.assertEqual(old.active['A3'].value, new.active['A3'].value)
|
||
self.assertEqual(old.active['A4'].value, new.active['A4'].value)
|
||
self.assertEqual(old.active['D29'].value, new.active['D29'].value)
|
||
old.close()
|
||
new.close()
|
||
|
||
def test_alternate_layout_preserves_other_sheet_and_paginates(self):
|
||
template, mapping = self.alternate()
|
||
self.assertEqual(mapping['columns']['purpose'], 'B')
|
||
self.assertEqual((mapping['startRow'], mapping['endRow']), (5, 6))
|
||
self.assertEqual(mapping['cells']['total'], 'F8')
|
||
state = dict(matches=self.state['matches'] + [self.match('hotel', '住宿', '9.99')])
|
||
output, result = self.export(mapping, template, state,
|
||
dict(recipient='新收款人', bankName='=新银行', accountNumber='0001234567890123456789'))
|
||
self.assertEqual(result['expensePageCount'], 2)
|
||
workbook = load_workbook(output)
|
||
self.assertEqual(workbook.sheetnames, ['组 B 报销', '保留说明', '组 B 报销-续2'])
|
||
self.assertEqual(workbook['保留说明']['A1'].value, '保持这个工作表不变')
|
||
for sheet in [workbook['组 B 报销'], workbook['组 B 报销-续2']]:
|
||
self.assertEqual(sheet['B10'].value, '新收款人')
|
||
self.assertEqual(sheet['F2'].value, '新收款人')
|
||
self.assertEqual(sheet['B11'].data_type, 's')
|
||
self.assertEqual(sheet['B12'].value, '0001234567890123456789')
|
||
self.assertEqual(sheet['B12'].data_type, 's')
|
||
self.assertEqual(sheet['F8'].value, '=SUM(F5:F6)')
|
||
self.assertEqual(sheet['A16'].value, '保留的自定义说明')
|
||
self.assertIn(sheet['A14'].value, ('', None))
|
||
self.assertEqual(sheet['A15'].value, '经办人:')
|
||
self.assertEqual(sheet.page_setup.orientation, 'landscape')
|
||
self.assertTrue(str(sheet.print_area).endswith('!$A$1:$G$16'))
|
||
self.assertEqual(workbook['组 B 报销-续2']['B5'].value, '住宿')
|
||
self.assertEqual(workbook['组 B 报销-续2']['A5'].value, 3)
|
||
self.assertIn(workbook['组 B 报销-续2']['B6'].value, ('', None))
|
||
workbook.close()
|
||
|
||
def test_unmapped_signature_cells_preserve_template(self):
|
||
mapping = copy.deepcopy(self.mapping)
|
||
mapping['signatureCells'] = ''
|
||
output, _ = self.export(mapping)
|
||
book = load_workbook(output)
|
||
self.assertEqual(book.active['A30'].value, '旧岗位:')
|
||
book.close()
|
||
|
||
def test_mapping_normalizes_lowercase(self):
|
||
mapping = copy.deepcopy(self.mapping)
|
||
mapping['columns']['purpose'] = ' c '
|
||
mapping['cells']['recipient'] = ' c23 '
|
||
mapping['signatureCells'] = 'a30, d30'
|
||
result = validate_template(self.template, mapping)
|
||
self.assertEqual(result['mapping']['columns']['purpose'], 'C')
|
||
self.assertEqual(result['mapping']['cells']['recipient'], 'C23')
|
||
self.assertEqual(result['mapping']['signatureCells'], 'A30,D30')
|
||
|
||
def test_invalid_mappings_do_not_write_output(self):
|
||
mutations = [
|
||
lambda mapping: mapping.update(startRow=22, endRow=9),
|
||
lambda mapping: mapping.update(startRow=0),
|
||
lambda mapping: mapping.update(endRow=300),
|
||
lambda mapping: mapping.update(sheetName='不存在'),
|
||
lambda mapping: mapping['columns'].update(purpose='D'),
|
||
lambda mapping: mapping['columns'].update(count='H'),
|
||
lambda mapping: mapping['columns'].update(amount=''),
|
||
lambda mapping: mapping['columns'].update(amount='A1'),
|
||
lambda mapping: mapping['cells'].update(recipient='C9'),
|
||
lambda mapping: mapping['cells'].update(bankName='C23'),
|
||
lambda mapping: mapping.update(signatureCells='D32'),
|
||
lambda mapping: mapping.update(clearCells='D23'),
|
||
lambda mapping: mapping.update(clearCells='C9'),
|
||
]
|
||
for mutate in mutations:
|
||
mapping = copy.deepcopy(self.mapping)
|
||
mutate(mapping)
|
||
with self.subTest(mapping=mapping), self.assertRaises(ValueError):
|
||
self.export(mapping)
|
||
self.assertFalse((self.root / 'output.xlsx').exists())
|
||
|
||
def test_corrupt_and_non_xlsx_rejected(self):
|
||
for name in ['bad.xlsx', 'old.xls', 'macro.xlsm']:
|
||
path = self.root / name
|
||
path.write_bytes(b'not an xlsx')
|
||
with self.assertRaises(ValueError):
|
||
inspect_template(path)
|
||
|
||
def test_live_external_formula_rejected_but_orphans_removed(self):
|
||
template = Template(self.template)
|
||
_, path, document = template.sheet('个人报销单')
|
||
set_cell(document, 'J1', "'[1]Sheet1'!A1", formula=True)
|
||
template.parts[path] = encoded(document)
|
||
live = self.root / 'external.xlsx'
|
||
self.rewrite(live, template.parts)
|
||
with self.assertRaisesRegex(ValueError, '外部'):
|
||
inspect_template(live)
|
||
output, _ = self.export()
|
||
with zipfile.ZipFile(output) as archive:
|
||
self.assertFalse(any(name.startswith('xl/externalLinks/') for name in archive.namelist()))
|
||
|
||
def test_formula_caches_are_invalidated(self):
|
||
output, _ = self.export()
|
||
template = Template(output)
|
||
_, _, document = template.sheet('个人报销单')
|
||
self.assertTrue(list(document.iter(tag('f'))))
|
||
for cell in document.iter(tag('c')):
|
||
if cell.find(tag('f')) is not None:
|
||
self.assertIsNone(cell.find(tag('v')))
|
||
self.assertEqual(template.workbook.find(tag('calcPr')).get('calcMode'), 'auto')
|
||
self.assertNotIn('xl/calcChain.xml', template.parts)
|
||
|
||
def engine(self, request):
|
||
binary = os.environ.get('RECEIPT_ENGINE_BINARY')
|
||
command = [binary] if binary else [sys.executable, str(Path(__file__).resolve().parents[1] / 'engine.py')]
|
||
process = subprocess.run(command, input=json.dumps(request), capture_output=True, text=True, timeout=60)
|
||
return process, json.loads(process.stdout.splitlines()[-1])
|
||
|
||
def test_engine_process_inspects_validates_and_uses_custom_template(self):
|
||
process, inspected = self.engine(dict(operation='inspect-expense-template', templatePath=str(self.template)))
|
||
self.assertEqual(process.returncode, 0, process.stderr)
|
||
self.assertEqual(inspected['result']['sheets'][0]['mapping'], self.mapping)
|
||
process, validated = self.engine(dict(operation='validate-expense-template', templatePath=str(self.template), templateMapping=self.mapping))
|
||
self.assertEqual(process.returncode, 0, process.stderr)
|
||
output = self.root / 'protocol.xlsx'
|
||
process, result = self.engine(dict(operation='expense', state=self.state, destination=str(output),
|
||
templatePath=str(self.template), templateMapping=self.mapping,
|
||
payee=dict(recipient='新用户', accountNumber='0009876543210987654321')))
|
||
self.assertEqual(process.returncode, 0, process.stderr)
|
||
self.assertEqual(result['result']['templateFingerprint'], hashlib.sha256(self.template.read_bytes()).hexdigest())
|
||
self.assertEqual(result['result']['templateMappingDigest'], validated['result']['mappingDigest'])
|
||
self.assertEqual(result['result']['expenseRowCount'], 2)
|
||
book = load_workbook(output)
|
||
self.assertEqual(book.active['C23'].value, '新用户')
|
||
self.assertEqual(book.active['H9'].value, 100.30)
|
||
book.close()
|
||
|
||
def test_engine_process_missing_custom_template_never_falls_back(self):
|
||
output = self.root / 'existing.xlsx'
|
||
output.write_bytes(b'keep existing output')
|
||
process, result = self.engine(dict(operation='expense', state=self.state, destination=str(output),
|
||
templatePath=str(self.root / 'missing.xlsx'), templateMapping=self.mapping))
|
||
self.assertNotEqual(process.returncode, 0)
|
||
self.assertEqual(result['event'], 'error')
|
||
self.assertEqual(output.read_bytes(), b'keep existing output')
|
||
self.assertFalse(list(self.root.glob('.*.xlsx')))
|
||
|
||
|
||
if __name__ == '__main__':
|
||
unittest.main()
|