1
This commit is contained in:
@@ -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。"
|
||||
|
||||
@@ -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
|
||||
@@ -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__':
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user