This commit is contained in:
csj
2026-09-16 16:06:21 +08:00
parent b2c7b28e22
commit 7fea2063aa
18 changed files with 815 additions and 36 deletions
+9
View File
@@ -8,10 +8,19 @@ if [ ! -x "$PYTHON" ]; then
fi
export PYINSTALLER_CONFIG_DIR="$ROOT/.build-tools/pyinstaller-cache"
cd "$ROOT/native-engine"
SOURCE_HASH="$(bash "$ROOT/native-engine/engine-fingerprint.sh")"
"$PYTHON" -m PyInstaller --noconfirm --clean --onedir --windowed --name receipt-engine-helper \
--osx-bundle-identifier test.reimburse.engine \
--distpath "$ROOT/native-engine/dist" --workpath "$ROOT/native-engine/build" \
--collect-all rapidocr_onnxruntime --collect-all onnxruntime \
--collect-all pypdfium2 --collect-all pypdfium2_raw \
--add-data "personal-expense-template.xlsx:." engine.py
BINARY="$ROOT/native-engine/dist/receipt-engine-helper.app/Contents/MacOS/receipt-engine-helper"
RECEIPT_ENGINE_BINARY="$BINARY" "$PYTHON" -m unittest discover -s tests -p test_exports.py -k engine_process -v
if [ "$SOURCE_HASH" != "$(bash "$ROOT/native-engine/engine-fingerprint.sh")" ]; then
echo "error: 打包期间引擎源码发生变化,请重新构建。" >&2
exit 1
fi
BINARY_HASH="$(shasum -a 256 "$BINARY" | cut -d ' ' -f 1)"
printf '%s\n%s\n' "$SOURCE_HASH" "$BINARY_HASH" > "$ROOT/native-engine/dist/receipt-engine-helper.source.sha256"
echo "本地处理引擎已打包,可以在 Xcode 中运行 reimburse。"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")"
shasum -a 256 ./*.py ./*.sh requirements*.txt personal-expense-template.xlsx helper.entitlements |
shasum -a 256 | cut -d ' ' -f 1
+4 -2
View File
@@ -90,6 +90,7 @@ def dispatch(request):
raise ValueError('至少完成一组核对后才能导出')
destination = Path(request['destination'])
temporary = destination.with_name('.' + str(uuid.uuid4()) + destination.suffix)
metadata = {}
try:
if operation in ('ppt', 'approved-ppt'):
export_ppt(state, temporary, request.get('classified', True))
@@ -97,13 +98,14 @@ def dispatch(request):
export_travel(state, temporary)
elif operation == 'expense':
template = Path(__file__).parent / 'personal-expense-template.xlsx'
export_expense(state, temporary, template, request.get('purposes', {}), request.get('signatures', []))
metadata = export_expense(state, temporary, template, request.get('purposes', {}), request.get('signatures', []),
request.get('payee', {}), request.get('categoryPurposes'))
else:
raise ValueError('未知操作:' + operation)
os.replace(temporary, destination)
finally:
temporary.unlink(missing_ok=True)
return dict(destination=str(destination))
return dict(destination=str(destination), **metadata)
if __name__ == '__main__':
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
set -euo pipefail
ENGINE="$(cd "$(dirname "$0")" && pwd)"
BINARY="$ENGINE/dist/receipt-engine-helper.app/Contents/MacOS/receipt-engine-helper"
STAMP="$ENGINE/dist/receipt-engine-helper.source.sha256"
SOURCE_HASH="$(bash "$ENGINE/engine-fingerprint.sh")"
if [ -x "$BINARY" ] && [ -f "$STAMP" ]; then
BINARY_HASH="$(shasum -a 256 "$BINARY" | cut -d ' ' -f 1)"
if [ "$(cat "$STAMP")" = "$(printf '%s\n%s' "$SOURCE_HASH" "$BINARY_HASH")" ]; then
echo "本地引擎与当前源码一致。"
exit 0
fi
fi
echo "本地引擎缺失或已过期,正在重新打包并验证导出功能…"
bash "$ENGINE/build-engine.sh"
+44 -17
View File
@@ -183,7 +183,37 @@ def export_travel(state, destination):
workbook.save(destination)
def export_expense(state, destination, template, purposes, signatures):
def expense_rows(matches, purposes, category_purposes=None):
grouped = {}
for match in matches:
category = match.get('category') or '其他'
row = grouped.setdefault(category, dict(category=category, count=0, amount=Decimal(0), types=[], purposes=[]))
row['count'] += len(match['invoices'])
purpose = purposes.get(match['id'], '').strip()
if purpose and purpose not in row['purposes']:
row['purposes'].append(purpose)
for invoice in match['invoices']:
content = text(invoice)
if '专用发票' in content:
invoice_type = '专票'
elif 'invoice' in content.lower():
invoice_type = 'Invoice'
elif '押金' in content and '收据' in content:
invoice_type = '押金收据'
else:
invoice_type = '普票'
if invoice_type not in row['types']:
row['types'].append(invoice_type)
row['amount'] += max((abs(Decimal(value.replace(',', ''))) for value in invoice['ocr']['amounts']), default=Decimal(0))
for row in grouped.values():
if category_purposes is not None:
row['purpose'] = category_purposes.get(row['category'], '').strip() or row['category']
else:
row['purpose'] = ''.join(row['purposes']) or row['category']
return list(grouped.values())
def export_expense(state, destination, template, purposes, signatures, payee=None, category_purposes=None):
from lxml import etree as ET
namespace = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
relationships = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
@@ -207,7 +237,10 @@ def export_expense(state, destination, template, purposes, signatures):
names.clear()
sheets.clear()
signatures = signatures or DEFAULT_SIGNATURES
page_count = math.ceil(len(state['matches']) / 13)
rows = expense_rows(state['matches'], purposes, category_purposes)
if not rows:
raise ValueError('请先勾选要导出的已核对材料')
page_count = math.ceil(len(rows) / 13)
def set_cell(document, reference, value):
cell = document.find('.//' + tag('c') + '[@r="' + reference + '"]')
@@ -229,25 +262,18 @@ def export_expense(state, destination, template, purposes, signatures):
for page in range(page_count):
document = ET.fromstring(sheet_bytes)
profile = payee or {}
for reference, field in [('C23', 'recipient'), ('C24', 'bankName'), ('C25', 'accountNumber'), ('H6', 'preparer')]:
value = str(profile.get(field, '') or '').strip()
if field == 'preparer' and not value:
value = str(profile.get('recipient', '') or '').strip()
set_cell(document, reference, value)
for row_number in range(9, 22):
for column in ['B', 'C', 'G', 'H', 'I']:
set_cell(document, column + str(row_number), '')
set_cell(document, 'A' + str(row_number), row_number - 8)
for index, match in enumerate(state['matches'][page * 13:(page + 1) * 13]):
invoice_type = '普票'
for invoice in match['invoices']:
content = text(invoice)
if '专用发票' in content:
invoice_type = '专票'
break
if 'invoice' in content.lower():
invoice_type = 'Invoice'
break
if '押金' in content and '收据' in content:
invoice_type = '押金收据'
break
total = sum(max((abs(Decimal(value.replace(',', ''))) for value in item['ocr']['amounts']), default=Decimal(0)) for item in match['invoices'])
for column, value in [('B', invoice_type), ('C', purposes.get(match['id'], '').strip()), ('G', len(match['invoices'])), ('H', total)]:
for index, row in enumerate(rows[page * 13:(page + 1) * 13]):
for column, value in [('B', '/'.join(row['types'])), ('C', row['purpose']), ('G', row['count']), ('H', row['amount'])]:
set_cell(document, column + str(index + 9), value)
for index, cell in enumerate(['A30', 'D30', 'A31', 'D31', 'A32', 'D32']):
value = signatures[index].strip() if index < len(signatures) else ''
@@ -277,3 +303,4 @@ def export_expense(state, destination, template, purposes, signatures):
with zipfile.ZipFile(destination, 'w', zipfile.ZIP_DEFLATED) as output:
for name, content in parts.items():
output.writestr(name, content)
return dict(expenseGrouping='category-v1', expenseRowCount=len(rows))
+76
View File
@@ -0,0 +1,76 @@
import os
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
class EngineBuildTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
engine = Path(__file__).resolve().parents[1]
for name in ['engine-fingerprint.sh', 'ensure-engine.sh']:
shutil.copyfile(engine / name, self.root / name)
for name in ['engine.py', 'exports.py', 'requirements.txt', 'requirements.lock.txt',
'personal-expense-template.xlsx', 'helper.entitlements']:
(self.root / name).write_text('fixture\n')
self.binary = self.root / 'dist/receipt-engine-helper.app/Contents/MacOS/receipt-engine-helper'
(self.root / 'build-engine.sh').write_text('''#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")"
echo build >> builds.log
mkdir -p dist/receipt-engine-helper.app/Contents/MacOS
printf 'fixture engine\\n' > dist/receipt-engine-helper.app/Contents/MacOS/receipt-engine-helper
chmod +x dist/receipt-engine-helper.app/Contents/MacOS/receipt-engine-helper
SOURCE_HASH="$(bash engine-fingerprint.sh)"
BINARY_HASH="$(shasum -a 256 dist/receipt-engine-helper.app/Contents/MacOS/receipt-engine-helper | cut -d ' ' -f 1)"
printf '%s\\n%s\\n' "$SOURCE_HASH" "$BINARY_HASH" > dist/receipt-engine-helper.source.sha256
''')
def tearDown(self):
self.temporary.cleanup()
def ensure(self):
return subprocess.run(['bash', str(self.root / 'ensure-engine.sh')],
capture_output=True, text=True, timeout=20)
def build_count(self):
return len((self.root / 'builds.log').read_text().splitlines())
def test_missing_engine_builds_and_unchanged_engine_is_reused(self):
self.assertEqual(self.ensure().returncode, 0)
self.assertEqual(self.build_count(), 1)
self.assertEqual(self.ensure().returncode, 0)
self.assertEqual(self.build_count(), 1)
def test_changed_sources_rebuild_even_when_timestamp_is_unchanged(self):
self.assertEqual(self.ensure().returncode, 0)
source = self.root / 'exports.py'
original = source.stat()
source.write_text('new grouping implementation\n')
os.utime(source, ns=(original.st_atime_ns, original.st_mtime_ns))
self.assertEqual(self.ensure().returncode, 0)
self.assertEqual(self.build_count(), 2)
def test_replaced_binary_rebuilds(self):
self.assertEqual(self.ensure().returncode, 0)
self.binary.write_text('old engine\n')
self.assertEqual(self.ensure().returncode, 0)
self.assertEqual(self.build_count(), 2)
def test_missing_stamp_rebuilds(self):
self.assertEqual(self.ensure().returncode, 0)
(self.root / 'dist/receipt-engine-helper.source.sha256').unlink()
self.assertEqual(self.ensure().returncode, 0)
self.assertEqual(self.build_count(), 2)
def test_failed_rebuild_does_not_accept_old_engine(self):
self.assertEqual(self.ensure().returncode, 0)
(self.root / 'build-engine.sh').write_text('exit 17\n')
self.assertEqual(self.ensure().returncode, 17)
if __name__ == '__main__':
unittest.main()
+137
View File
@@ -1,4 +1,8 @@
import copy
import json
import os
import subprocess
import sys
import tempfile
import unittest
import zipfile
@@ -68,6 +72,8 @@ class ExportTests(unittest.TestCase):
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)
@@ -76,6 +82,137 @@ class ExportTests(unittest.TestCase):
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')