From 7fea2063aa9bca118124a02e02db4b2c0716abe2 Mon Sep 17 00:00:00 2001 From: csj <2073278411@qq.com> Date: Wed, 16 Sep 2026 16:06:21 +0800 Subject: [PATCH] 1 --- README.md | 16 ++- native-engine/build-engine.sh | 9 ++ native-engine/engine-fingerprint.sh | 5 + native-engine/engine.py | 6 +- native-engine/ensure-engine.sh | 15 +++ native-engine/exports.py | 61 +++++++--- native-engine/tests/test_engine_build.py | 76 +++++++++++++ native-engine/tests/test_exports.py | 137 +++++++++++++++++++++++ reimburse.xcodeproj/project.pbxproj | 2 +- reimburse/ContentView.swift | 7 ++ reimburse/ExpenseSummary.swift | 47 ++++++++ reimburse/MatchedPage.swift | 34 ++++-- reimburse/NativeDiagnostics.swift | 26 +++++ reimburse/PayeeProfile.swift | 134 ++++++++++++++++++++++ reimburse/PayeeProfileSheet.swift | 102 +++++++++++++++++ reimburse/WorkspaceStore.swift | 38 ++++++- tests/ExpenseSummaryTests.swift | 52 +++++++++ tests/PayeeProfileTests.swift | 84 ++++++++++++++ 18 files changed, 815 insertions(+), 36 deletions(-) create mode 100644 native-engine/engine-fingerprint.sh create mode 100644 native-engine/ensure-engine.sh create mode 100644 native-engine/tests/test_engine_build.py create mode 100644 reimburse/ExpenseSummary.swift create mode 100644 reimburse/PayeeProfile.swift create mode 100644 reimburse/PayeeProfileSheet.swift create mode 100644 tests/ExpenseSummaryTests.swift create mode 100644 tests/PayeeProfileTests.swift diff --git a/README.md b/README.md index 18ad6f7..b2f1bd6 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ SwiftUI 原生报账材料工作台。无需浏览器、Java、Spring Boot、HTT ## 在 Xcode 中运行 -打开 `reimburse.xcodeproj`,选择 `reimburse` scheme 和 My Mac,运行即可。构建阶段会将 `native-engine/dist/receipt-engine-helper.app` 放进应用的 `Contents/Helpers`,并为辅助进程添加沙盒继承权限。 +打开 `reimburse.xcodeproj`,选择 `reimburse` scheme 和 My Mac,运行即可。构建阶段先核对引擎源码、打包脚本、模板、依赖声明与已打包二进制的指纹;缺失或不一致时自动重新打包,并运行打包引擎的报销单导出回归测试。验证成功才将 `native-engine/dist/receipt-engine-helper.app` 放进应用的 `Contents/Helpers`,并为辅助进程添加沙盒继承权限。没有本地打包依赖时构建会明确失败,不会继续使用旧引擎。 当前开发目录已经准备了本地依赖和引擎。重新下载源码、切换架构或修改 Python 代码后,需先重建引擎: @@ -36,9 +36,17 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \ 2. 材料复制到应用自己的本地工作区,原始文件不会修改。再次导入会先确认是否替换当前工作区;取消确认保留当前材料和核对结果。 3. 本地 OCR 提取信息,按旧项目规则自动匹配并分类。扫描显示真实处理进度。 4. 在“人工配对”两侧多选材料,选择分类后确认;识别失败的材料仍可人工配对。 -5. “已核对”中可改分类、预览材料、撤销及导出。首列勾选一组或多组材料,或使用表格上方“全选当前分组”,再点击“导出所选 N 组 PPT”;切换分组保留勾选,取消当前分组全选不影响其他分组,未勾选时不能导出 PPT。“全部”页可全选所有已核对组。行程 Excel 和个人报销单仍使用全部已核对材料。自动核对结果不代表已人工复核。 +5. “已核对”中可改分类、预览材料、撤销及导出。首列勾选一组或多组材料,或使用表格上方“全选当前分组”,再点击“导出所选 N 组 PPT”或“所选报销单”;切换分组保留勾选,取消当前分组全选不影响其他分组,未勾选时不能导出这两类文件。“全部”页可全选所有已核对组。行程 Excel 仍使用全部已核对材料。自动核对结果不代表已人工复核。 6. 工作区自动持久化到应用沙盒内的 Application Support/ReceiptDesk;清空只删除工作区副本,不删除原始材料。 +## 收款信息 + +在本地贴票工具栏点击“收款信息”,填写收款人、开户行(建议包含支行)、账号,以及可选的制单人。保存后重启仍可使用,也可在个人报销单弹窗内修改或删除。账号按文本保存,保留前导零和长账号;界面默认隐藏完整账号。 + +资料独立保存于当前 macOS 用户的本机钥匙串,不写入工作区 JSON,不上传、不云同步;导入新材料和清空工作区不会删除资料。它不随项目管理的登录账号切换,共用同一 macOS 用户时需自行核对收款人。 + +个人报销单每一页自动填写已保存资料;制单人未填写时使用收款人。没有保存资料时,相应单元格留空,不沿用模板的示例姓名、开户行及账号。PPT 和行程 Excel 不携带这些资料。导出的报销单包含完整收款账号,请妥善保管。删除本机资料不会修改已导出的文件。 + ## 功能对应 | 原项目 | 原生实现 | @@ -54,6 +62,10 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \ 保留一对一评分、多发票合计、多付款合计、同程多人归组、歧义判断、七类关键词分类、人工多对多、撤销、预览、金额统计和三类 Office 文件导出。 +个人报销单只使用打开弹窗时勾选的材料,按费用类型合并明细。例如勾选 6 组交通,只生成一条“交通”,金额和发票数量累计;未勾选材料不计入。弹窗显示合并后的类型、组数、单据数和金额,可按类型填写用途。金额沿用原报销单口径(每张发票 OCR 金额的绝对值最大值),不改为付款金额;混合票据类型会一并列明。按首次出现的类型顺序输出,每页最多 13 个类型,收款信息与模板公式保持不变。 + +保存个人报销单前还会核对引擎返回的分类合并版本和明细行数;旧引擎或行数与预览不一致时停止保存,不覆盖用户选定的输出文件。打包引擎通过 `RECEIPT_ENGINE_BINARY` 运行 `test_exports.py` 的 `engine_process` 测试。最终应用的引擎带沙盒继承签名,不能直接从普通终端启动;应使用 Debug 应用的 `--verify-local-engine` 自检入口,从真实应用内验证两条汇总并检查生成的 `grouped-expense.xlsx`,而不能仅验证 Python 源码。 + ### 特意保留的业务口径 - 文件名不参与匹配;PDF 只识别、预览和导出首页。 diff --git a/native-engine/build-engine.sh b/native-engine/build-engine.sh index f831e94..4c314ca 100644 --- a/native-engine/build-engine.sh +++ b/native-engine/build-engine.sh @@ -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。" diff --git a/native-engine/engine-fingerprint.sh b/native-engine/engine-fingerprint.sh new file mode 100644 index 0000000..8f398b6 --- /dev/null +++ b/native-engine/engine-fingerprint.sh @@ -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 diff --git a/native-engine/engine.py b/native-engine/engine.py index 1ecdc0c..2f5262d 100644 --- a/native-engine/engine.py +++ b/native-engine/engine.py @@ -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__': diff --git a/native-engine/ensure-engine.sh b/native-engine/ensure-engine.sh new file mode 100644 index 0000000..6392c9b --- /dev/null +++ b/native-engine/ensure-engine.sh @@ -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" diff --git a/native-engine/exports.py b/native-engine/exports.py index 09d0209..69c4271 100644 --- a/native-engine/exports.py +++ b/native-engine/exports.py @@ -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)) diff --git a/native-engine/tests/test_engine_build.py b/native-engine/tests/test_engine_build.py new file mode 100644 index 0000000..240044e --- /dev/null +++ b/native-engine/tests/test_engine_build.py @@ -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() diff --git a/native-engine/tests/test_exports.py b/native-engine/tests/test_exports.py index 69d6277..fbfcbd2 100644 --- a/native-engine/tests/test_exports.py +++ b/native-engine/tests/test_exports.py @@ -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') diff --git a/reimburse.xcodeproj/project.pbxproj b/reimburse.xcodeproj/project.pbxproj index 530469e..39bd161 100644 --- a/reimburse.xcodeproj/project.pbxproj +++ b/reimburse.xcodeproj/project.pbxproj @@ -130,7 +130,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/bash; - shellScript = "set -euo pipefail\nSOURCE=\"$SRCROOT/native-engine/dist/receipt-engine-helper.app\"\nif [ ! -d \"$SOURCE\" ]; then\n echo 'error: 请先运行 bash native-engine/build-engine.sh 打包本地 OCR 引擎'\n exit 1\nfi\nDEST=\"$TARGET_BUILD_DIR/$CONTENTS_FOLDER_PATH/Helpers/receipt-engine-helper.app\"\nmkdir -p \"$(dirname \"$DEST\")\"\n/usr/bin/ditto \"$SOURCE\" \"$DEST\"\nIDENTITY=\"${EXPANDED_CODE_SIGN_IDENTITY:--}\"\nif [ -z \"$IDENTITY\" ]; then IDENTITY=-; fi\n/usr/bin/codesign --force --sign \"$IDENTITY\" --entitlements \"$SRCROOT/native-engine/helper.entitlements\" \"$DEST\"\n"; + shellScript = "set -euo pipefail\nbash \"$SRCROOT/native-engine/ensure-engine.sh\"\nSOURCE=\"$SRCROOT/native-engine/dist/receipt-engine-helper.app\"\nDEST=\"$TARGET_BUILD_DIR/$CONTENTS_FOLDER_PATH/Helpers/receipt-engine-helper.app\"\nmkdir -p \"$(dirname \"$DEST\")\"\n/usr/bin/ditto \"$SOURCE\" \"$DEST\"\nIDENTITY=\"${EXPANDED_CODE_SIGN_IDENTITY:--}\"\nif [ -z \"$IDENTITY\" ]; then IDENTITY=-; fi\n/usr/bin/codesign --force --sign \"$IDENTITY\" --entitlements \"$SRCROOT/native-engine/helper.entitlements\" \"$DEST\"\n"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/reimburse/ContentView.swift b/reimburse/ContentView.swift index 20da363..6ddb753 100644 --- a/reimburse/ContentView.swift +++ b/reimburse/ContentView.swift @@ -3,6 +3,7 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var store: WorkspaceStore @State private var confirmClear = false + @State private var showPayeeProfile = false var body: some View { NavigationSplitView { @@ -68,6 +69,11 @@ struct ContentView: View { .tint(.teal) .frame(minWidth: 1050, minHeight: 700) .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { showPayeeProfile = true } label: { + Label("收款信息", systemImage: "person.crop.rectangle") + }.disabled(store.busy) + } ToolbarItem(placement: .primaryAction) { Button(action: store.chooseFolder) { Label("导入文件夹", systemImage: "folder.badge.plus") } .disabled(store.busy).keyboardShortcut("o") @@ -82,6 +88,7 @@ struct ContentView: View { } message: { Text("当前工作区的副本和核对结果将被移除,您选择的原始文件夹不会被修改。") } .sheet(item: $store.preview) { item in MaterialPreview(item: item) } .sheet(isPresented: $store.showExpense) { ExpenseSheet() } + .sheet(isPresented: $showPayeeProfile) { PayeeProfileSheet(profileStore: store.payeeProfile) } } private var topbar: some View { diff --git a/reimburse/ExpenseSummary.swift b/reimburse/ExpenseSummary.swift new file mode 100644 index 0000000..ca5b6a5 --- /dev/null +++ b/reimburse/ExpenseSummary.swift @@ -0,0 +1,47 @@ +import Foundation + +struct ExpenseSummary: Identifiable { + let category: String + var groupCount = 0 + var invoiceCount = 0 + var amount = Decimal.zero + var id: String { category } + var amountValue: Double { NSDecimalNumber(decimal: amount).doubleValue } + + static func validateExportResult(_ data: Data, matches: [MatchGroup]) throws { + let result = try JSONSerialization.jsonObject(with: data) as? [String: Any] + guard result?["expenseGrouping"] as? String == "category-v1", + result?["expenseRowCount"] as? Int == rows(for: matches).count else { + throw ExportMismatch() + } + } + + private struct ExportMismatch: LocalizedError { + var errorDescription: String? { + "导出引擎未按费用类型合并,已停止保存文件。请重新构建并运行最新版应用后再导出。" + } + } + + static func rows(for matches: [MatchGroup]) -> [ExpenseSummary] { + var result: [ExpenseSummary] = [] + for match in matches { + let category = match.category.isEmpty ? "其他" : match.category + let index: Int + if let existing = result.firstIndex(where: { $0.category == category }) { + index = existing + } else { + index = result.count + result.append(ExpenseSummary(category: category)) + } + result[index].groupCount += 1 + result[index].invoiceCount += match.invoices.count + for invoice in match.invoices { + let amounts = invoice.ocr.amounts.compactMap { + Decimal(string: $0.replacingOccurrences(of: ",", with: ""), locale: Locale(identifier: "en_US_POSIX")) + }.map { $0 < 0 ? -$0 : $0 } + result[index].amount += amounts.max() ?? .zero + } + } + return result + } +} diff --git a/reimburse/MatchedPage.swift b/reimburse/MatchedPage.swift index 8c2fc82..6a1adf5 100644 --- a/reimburse/MatchedPage.swift +++ b/reimburse/MatchedPage.swift @@ -22,7 +22,9 @@ struct MatchedPage: View { } Spacer() Button { store.export(.travel) } label: { Label("行程 Excel", systemImage: "tram") }.disabled(store.state.travelCount == 0) - Button(action: store.openExpense) { Label("个人报销单", systemImage: "tablecells") }.disabled(store.state.matches.isEmpty) + Button { store.openExpense(selectedMatchIDs: pptSelection.ids) } label: { + Label("所选报销单", systemImage: "tablecells") + }.disabled(selectedMatches.isEmpty) Button { store.export(.ppt, selectedMatchIDs: pptSelection.ids) } label: { Label("导出所选 \(selectedMatches.count) 组 PPT", systemImage: "square.and.arrow.up") } @@ -56,7 +58,7 @@ struct MatchedPage: View { .foregroundStyle(.secondary) Button("清空选择") { pptSelection.ids.removeAll() }.disabled(pptSelection.ids.isEmpty) }.font(.callout) - Text("切换分组保留勾选;PPT 仅导出所选组的完整发票与付款材料。行程 Excel 和个人报销单仍使用全部已核对材料。") + Text("切换分组保留勾选;PPT 和个人报销单仅导出所选材料,报销单按费用类型合并。行程 Excel 仍使用全部已核对材料。") .font(.caption).foregroundStyle(.secondary) } }.padding(28).disabled(store.busy) @@ -187,22 +189,35 @@ struct ExpenseSheet: View { HStack { VStack(alignment: .leading, spacing: 6) { Text("个人报销单").font(.title2.bold()) - Text("使用原报销模板 · 每页 13 条明细 · 金额按已核对发票计算").font(.caption).foregroundStyle(.secondary) + Text("已选 \(store.expenseMatchIDs.count) 组 → 合并为 \(store.expenseRows.count) 条费用 · 同类型金额和单据数量汇总") + .font(.caption).foregroundStyle(.secondary) } Spacer() } + PayeeProfileSummary(profileStore: store.payeeProfile) ScrollView { VStack(spacing: 12) { - ForEach(Array(store.state.matches.enumerated()), id: \.element.id) { index, match in + ForEach(Array(store.expenseRows.enumerated()), id: \.element.id) { index, summary in HStack(spacing: 16) { Text(String(format: "%02d", index + 1)).font(.callout.monospacedDigit()).foregroundStyle(.secondary).frame(width: 30) - Text("\(match.category) · \(match.invoices.count) 张").frame(width: 120, alignment: .leading) - Text(currency(match.expenseAmount)).fontWeight(.medium).monospacedDigit().frame(width: 120, alignment: .trailing) - TextField("支出项目 / 用途", text: Binding(get: { store.purposes[match.id] ?? "" }, set: { store.purposes[match.id] = $0 })) + VStack(alignment: .leading, spacing: 3) { + Text(summary.category) + Text("\(summary.groupCount) 组 · \(summary.invoiceCount) 张单据").font(.caption).foregroundStyle(.secondary) + }.frame(width: 120, alignment: .leading) + Text(currency(summary.amountValue)).fontWeight(.medium).monospacedDigit().frame(width: 120, alignment: .trailing) + TextField("支出项目 / 用途", text: Binding(get: { store.purposes[summary.category] ?? summary.category }, set: { store.purposes[summary.category] = $0 })) }.padding(10).background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8)) } } } + HStack { + Text("合计 \(currency(NSDecimalNumber(decimal: store.expenseRows.reduce(.zero) { $0 + $1.amount }).doubleValue))").fontWeight(.semibold).monospacedDigit() + Spacer() + Text("金额沿用报销单的发票计算口径,不使用付款截图金额。").font(.caption).foregroundStyle(.secondary) + } + if !store.expenseSelectionValid { + Text("勾选材料已变化,请关闭弹窗后重新选择。").foregroundStyle(.red) + } Divider() HStack { Text("签字岗位").font(.headline) @@ -220,10 +235,11 @@ struct ExpenseSheet: View { } Divider() HStack { - Text("保留模板中的公司、项目、收款信息和计算公式。").font(.caption).foregroundStyle(.secondary) + Text("保留公司、项目和公式;收款信息使用本机已保存资料。").font(.caption).foregroundStyle(.secondary) Spacer() Button("取消") { dismiss() }.keyboardShortcut(.cancelAction) - Button("生成报销单") { store.export(.expense) }.buttonStyle(.borderedProminent) + Button("生成所选报销单") { store.export(.expense) }.buttonStyle(.borderedProminent) + .disabled(!store.expenseSelectionValid) } }.padding(26).frame(width: 870, height: 620).textFieldStyle(.roundedBorder).disabled(store.busy) .interactiveDismissDisabled(store.busy) diff --git a/reimburse/NativeDiagnostics.swift b/reimburse/NativeDiagnostics.swift index 4e9ff39..d259568 100644 --- a/reimburse/NativeDiagnostics.swift +++ b/reimburse/NativeDiagnostics.swift @@ -62,6 +62,32 @@ enum NativeDiagnostics { guard (try Data(contentsOf: destination)).count > 1000 else { throw EngineFailure.message("导出文件为空") } report[kind.rawValue] = "passed" } + var groupedState = Workspace() + let groupedAmounts = [["1058.00", "1058.00"], ["140.00"], ["300.00"], ["163.92"], + ["253.45"], ["74.49"], ["444.00"]] + for (index, amounts) in groupedAmounts.enumerated() { + let invoices = amounts.enumerated().map { invoiceIndex, amount in + var invoice = state.invoices[0] + invoice.id = "grouped-\(index)-\(invoiceIndex)" + invoice.ocr.amounts = [amount] + return invoice + } + groupedState.matches.append(MatchGroup( + id: "grouped-\(index)", invoices: invoices, payments: [], + category: index == 6 ? "办公用品" : "交通", matchType: "manual", score: 100, reasons: [] + )) + } + let groupedDestination = root.appendingPathComponent("grouped-expense.xlsx") + let groupedRequest: [String: Any] = [ + "operation": "expense", + "state": try JSONSerialization.jsonObject(with: JSONEncoder().encode(groupedState)), + "destination": groupedDestination.path, + "categoryPurposes": ["交通": "交通", "办公用品": "办公用品"] + ] + let groupedResult = try await bridge.run(request: JSONSerialization.data(withJSONObject: groupedRequest)) { _, _ in } + try ExpenseSummary.validateExportResult(groupedResult, matches: groupedState.matches) + report["expenseCategoryGrouping"] = "passed" + report["groupedExpensePath"] = groupedDestination.path try JSONEncoder().encode(state).write(to: root.appendingPathComponent("state.json")) report["result"] = "passed" report["directory"] = root.path diff --git a/reimburse/PayeeProfile.swift b/reimburse/PayeeProfile.swift new file mode 100644 index 0000000..063209a --- /dev/null +++ b/reimburse/PayeeProfile.swift @@ -0,0 +1,134 @@ +import Foundation +import Security +import Combine + +struct PayeeProfile: Codable, Equatable { + var recipient = "" + var bankName = "" + var accountNumber = "" + var preparer = "" + + func validated() throws -> PayeeProfile { + var result = self + result.recipient = recipient.trimmingCharacters(in: .whitespacesAndNewlines) + result.bankName = bankName.trimmingCharacters(in: .whitespacesAndNewlines) + result.accountNumber = accountNumber.filter { !$0.isWhitespace } + result.preparer = preparer.trimmingCharacters(in: .whitespacesAndNewlines) + guard !result.recipient.isEmpty, !result.bankName.isEmpty, !result.accountNumber.isEmpty else { + throw PayeeFailure(message: "请填写收款人、开户行和账号。") + } + guard result.recipient.count <= 100, result.bankName.count <= 200, + result.accountNumber.count <= 64, result.preparer.count <= 100 else { + throw PayeeFailure(message: "收款人和制单人最多 100 字,开户行最多 200 字,账号最多 64 位。") + } + guard [result.recipient, result.bankName, result.accountNumber, result.preparer].allSatisfy({ + $0.unicodeScalars.allSatisfy { !CharacterSet.controlCharacters.contains($0) } + }) else { + throw PayeeFailure(message: "资料中不能包含换行或控制字符。") + } + return result + } + + var exportFields: [String: String] { + ["recipient": recipient, "bankName": bankName, "accountNumber": accountNumber, + "preparer": preparer.isEmpty ? recipient : preparer] + } +} + +struct PayeeFailure: LocalizedError { + let message: String + var errorDescription: String? { message } +} + +protocol PayeeStorage { + func read() throws -> Data? + func write(_ data: Data) throws + func delete() throws +} + +struct KeychainPayeeStorage: PayeeStorage { + private var query: [String: Any] { + [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "Scoopex.Reimburse.LocalPayee", + kSecAttrAccount as String: "local-mac-user", + kSecAttrSynchronizable as String: false] + } + + func read() throws -> Data? { + var request = query + request[kSecReturnData as String] = true + request[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(request as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + throw PayeeFailure(message: "无法读取收款信息,请解锁钥匙串并重试。") + } + return data + } + + func write(_ data: Data) throws { + let status = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) + if status == errSecItemNotFound { + var request = query + request[kSecValueData as String] = data + request[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + guard SecItemAdd(request as CFDictionary, nil) == errSecSuccess else { + throw PayeeFailure(message: "无法保存收款信息,请解锁钥匙串并重试。") + } + } else if status != errSecSuccess { + throw PayeeFailure(message: "无法更新收款信息,原资料未被替换。") + } + } + + func delete() throws { + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw PayeeFailure(message: "无法删除收款信息,请解锁钥匙串并重试。") + } + } +} + +@MainActor +final class PayeeProfileStore: ObservableObject { + @Published private(set) var profile: PayeeProfile? + @Published private(set) var loadError: String? + private let storage: any PayeeStorage + + init(storage: any PayeeStorage = KeychainPayeeStorage()) { + self.storage = storage + reload() + } + + func reload() { + do { + if let data = try storage.read() { + profile = try JSONDecoder().decode(PayeeProfile.self, from: data).validated() + } else { + profile = nil + } + loadError = nil + } catch { + profile = nil + loadError = "收款信息读取失败,请重试;不会使用模板中的示例账号。" + } + } + + func save(_ draft: PayeeProfile) throws { + guard loadError == nil else { throw PayeeFailure(message: "请先重新读取收款信息,避免覆盖未读取的资料。") } + let value = try draft.validated() + try storage.write(JSONEncoder().encode(value)) + profile = value + } + + func clear() throws { + try storage.delete() + profile = nil + loadError = nil + } + + func exportFields() throws -> [String: String] { + guard loadError == nil else { throw PayeeFailure(message: "收款信息读取失败,请打开“收款信息”重试后再导出。") } + return profile?.exportFields ?? [:] + } +} diff --git a/reimburse/PayeeProfileSheet.swift b/reimburse/PayeeProfileSheet.swift new file mode 100644 index 0000000..d831fb9 --- /dev/null +++ b/reimburse/PayeeProfileSheet.swift @@ -0,0 +1,102 @@ +import SwiftUI + +struct PayeeProfileSheet: View { + @ObservedObject var profileStore: PayeeProfileStore + @Environment(\.dismiss) private var dismiss + @State private var draft = PayeeProfile() + @State private var showAccount = false + @State private var confirmDelete = false + @State private var errorMessage: String? + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Label("收款信息", systemImage: "person.crop.rectangle").font(.title2.bold()) + Text("保存后自动填入个人报销单;导出的文件会包含完整账号,请妥善保管。") + .font(.callout).foregroundStyle(.secondary) + if let loadError = profileStore.loadError { + Text(loadError).foregroundStyle(.red) + Button("重新读取") { + profileStore.reload() + draft = profileStore.profile ?? PayeeProfile() + } + } + Form { + TextField("收款人(必填)", text: $draft.recipient, prompt: Text("填写账户户名")) + TextField("开户行(必填)", text: $draft.bankName, prompt: Text("填写银行及开户支行")) + LabeledContent("账号(必填)") { + HStack { + if showAccount { + TextField("银行卡号或收款账号", text: $draft.accountNumber) + } else { + SecureField("银行卡号或收款账号", text: $draft.accountNumber) + } + Button { showAccount.toggle() } label: { + Image(systemName: showAccount ? "eye.slash" : "eye") + } + .buttonStyle(.borderless) + .help(showAccount ? "隐藏账号" : "显示账号") + .accessibilityLabel(showAccount ? "隐藏账号" : "显示账号") + } + } + TextField("制单人(选填)", text: $draft.preparer, prompt: Text("不填时使用收款人姓名")) + }.textFieldStyle(.roundedBorder).disabled(profileStore.loadError != nil) + if let errorMessage { + Text(errorMessage).font(.callout).foregroundStyle(.red) + } + Label("仅保存在当前 macOS 用户的本机钥匙串中,不上传、不云同步。更换材料或清空工作区不会删除资料。与项目管理登录账号无关,共用此 Mac 用户时请核对收款人。", systemImage: "lock.shield") + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + Divider() + HStack { + Button("删除已保存资料", role: .destructive) { confirmDelete = true } + .disabled(profileStore.profile == nil && profileStore.loadError == nil) + Spacer() + Button("取消") { dismiss() }.keyboardShortcut(.cancelAction) + Button("保存") { + do { + try profileStore.save(draft) + dismiss() + } catch { errorMessage = error.localizedDescription } + } + .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) + .disabled(profileStore.loadError != nil) + } + } + .padding(26).frame(width: 580) + .onAppear { draft = profileStore.profile ?? PayeeProfile() } + .confirmationDialog("删除本机保存的收款信息?", isPresented: $confirmDelete) { + Button("删除收款信息", role: .destructive) { + do { + try profileStore.clear() + draft = PayeeProfile() + errorMessage = nil + } catch { errorMessage = error.localizedDescription } + } + Button("取消", role: .cancel) {} + } message: { + Text("不会删除报销材料;已导出的文件仍保留原来的收款信息。") + } + } +} + +struct PayeeProfileSummary: View { + @ObservedObject var profileStore: PayeeProfileStore + @State private var editing = false + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("本次收款信息").font(.headline) + if let profile = profileStore.profile { + Text("\(profile.recipient) · \(profile.bankName) · 尾号 \(profile.accountNumber.suffix(4))") + .font(.caption).textSelection(.enabled) + } else { + Text(profileStore.loadError ?? "尚未填写,导出的收款信息将留空,不使用模板示例账号。") + .font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + Button("设置收款信息") { editing = true } + } + .sheet(isPresented: $editing) { PayeeProfileSheet(profileStore: profileStore) } + } +} diff --git a/reimburse/WorkspaceStore.swift b/reimburse/WorkspaceStore.swift index 181dc2b..2fdddd6 100644 --- a/reimburse/WorkspaceStore.swift +++ b/reimburse/WorkspaceStore.swift @@ -17,8 +17,10 @@ final class WorkspaceStore: ObservableObject { @Published var notice: String? @Published var preview: Material? @Published var showExpense = false + @Published private(set) var expenseMatchIDs: Set = [] @Published var purposes: [String: String] = [:] @Published var signatures = defaultSignatures + let payeeProfile = PayeeProfileStore() private let engine = EngineBridge() private let storage: URL private var cancelRequested = false @@ -204,22 +206,40 @@ final class WorkspaceStore: ObservableObject { resetSelection() preview = nil showExpense = false + expenseMatchIDs = [] purposes = [:] directories.forEach { try? FileManager.default.removeItem(at: $0) } notice = "工作区已清空,原始材料未删除" } } - func openExpense() { - purposes = Dictionary(uniqueKeysWithValues: state.matches.map { ($0.id, $0.category == "其他" ? "" : $0.category + "费用") }) - showExpense = true + var expenseRows: [ExpenseSummary] { + ExpenseSummary.rows(for: state.matches.filter { expenseMatchIDs.contains($0.id) }) + } + + var expenseSelectionValid: Bool { + !expenseMatchIDs.isEmpty && expenseMatchIDs.isSubset(of: Set(state.matches.map(\.id))) + } + + func openExpense(selectedMatchIDs: Set) { + guard !busy else { return } + do { + _ = try MatchedPPTSelection(ids: selectedMatchIDs).exportWorkspace(from: state) + expenseMatchIDs = selectedMatchIDs + purposes = Dictionary(uniqueKeysWithValues: expenseRows.map { ($0.category, $0.category) }) + showExpense = true + } catch { errorMessage = error.localizedDescription } } func export(_ kind: ExportKind, selectedMatchIDs: Set? = nil) { guard !busy, !state.matches.isEmpty else { return } let exportState: Workspace + let payeeFields: [String: String] do { - if kind == .ppt, let selectedMatchIDs { + payeeFields = kind == .expense ? try payeeProfile.exportFields() : [:] + if kind == .expense { + exportState = try MatchedPPTSelection(ids: expenseMatchIDs).exportWorkspace(from: state) + } else if kind == .ppt, let selectedMatchIDs { exportState = try MatchedPPTSelection(ids: selectedMatchIDs).exportWorkspace(from: state) } else { exportState = state @@ -247,7 +267,15 @@ final class WorkspaceStore: ObservableObject { try? FileManager.default.removeItem(at: temporary) } do { - _ = try await run(["operation": kind.rawValue, "state": try jsonObject(exportState), "destination": temporary.path, "classified": classified, "purposes": purposes, "signatures": signatures]) + var request: [String: Any] = ["operation": kind.rawValue, "state": try jsonObject(exportState), "destination": temporary.path, "classified": classified, "purposes": purposes, "signatures": signatures] + if kind == .expense { + request["payee"] = payeeFields + request["categoryPurposes"] = purposes + } + let result = try await run(request) + if kind == .expense { + try ExpenseSummary.validateExportResult(result, matches: exportState.matches) + } try Data(contentsOf: temporary).write(to: destination, options: .atomic) if kind == .expense { showExpense = false } notice = "\(kind.title)已导出到 \(destination.lastPathComponent)" diff --git a/tests/ExpenseSummaryTests.swift b/tests/ExpenseSummaryTests.swift new file mode 100644 index 0000000..ea960a8 --- /dev/null +++ b/tests/ExpenseSummaryTests.swift @@ -0,0 +1,52 @@ +import Foundation + +@main +struct ExpenseSummaryTests { + static func match(_ id: String, category: String, amounts: [String]) -> MatchGroup { + var ocr = OCRData() + ocr.amounts = amounts + let invoice = Material(id: id, name: id, path: "", type: "invoice", size: 0, matched: true, + previewPath: "", ocr: ocr, displayAmount: 9999, sortDate: "", issueDate: "") + return MatchGroup(id: id, invoices: [invoice], payments: [], category: category, + matchType: "manual", score: 100, reasons: [], paymentTotal: 8888, expenseAmount: 7777) + } + + static func main() throws { + let traffic = (1...6).map { match("traffic-\($0)", category: "交通", amounts: ["100.10"]) } + let hotel = match("hotel", category: "住宿", amounts: ["250.20"]) + let excluded = match("excluded", category: "交通", amounts: ["999"]) + var workspace = Workspace() + workspace.matches = traffic + [hotel, excluded] + let selection = MatchedPPTSelection(ids: Set((traffic + [hotel]).map(\.id))) + let selected = try selection.exportWorkspace(from: workspace) + let rows = ExpenseSummary.rows(for: selected.matches) + precondition(rows.count == 2) + precondition(rows.map(\.category) == ["交通", "住宿"]) + precondition(rows[0].groupCount == 6 && rows[0].invoiceCount == 6) + precondition(rows[0].amount == Decimal(string: "600.60")) + precondition(rows[1].amount == Decimal(string: "250.20")) + precondition(workspace.matches.count == 8) + var multiple = match("multi", category: "交通", amounts: ["100", "-1,200.30", "5"]) + multiple.invoices += [hotel.invoices[0]] + let combined = ExpenseSummary.rows(for: [multiple]) + precondition(combined[0].invoiceCount == 2) + precondition(combined[0].amount == Decimal(string: "1450.50")) + let emptyAmounts = ExpenseSummary.rows(for: [match("empty", category: "", amounts: [])]) + precondition(emptyAmounts[0].category == "其他" && emptyAmounts[0].amount == .zero) + precondition(ExpenseSummary.rows(for: []).isEmpty) + let response = try JSONSerialization.data(withJSONObject: ["expenseGrouping": "category-v1", "expenseRowCount": 2]) + try ExpenseSummary.validateExportResult(response, matches: selected.matches) + for result: [String: Any] in [ + ["destination": "/tmp/old-engine.xlsx"], + ["expenseGrouping": "category-v1", "expenseRowCount": 7], + ["expenseGrouping": "per-match", "expenseRowCount": 2] + ] { + let data = try JSONSerialization.data(withJSONObject: result) + do { + try ExpenseSummary.validateExportResult(data, matches: selected.matches) + preconditionFailure("Outdated or inconsistent exports must be rejected") + } catch {} + } + print("Selected expense grouping, counts and decimal totals tests passed") + } +} diff --git a/tests/PayeeProfileTests.swift b/tests/PayeeProfileTests.swift new file mode 100644 index 0000000..b0a5901 --- /dev/null +++ b/tests/PayeeProfileTests.swift @@ -0,0 +1,84 @@ +import Foundation + +final class MemoryPayeeStorage: PayeeStorage { + var data: Data? + var failRead = false + var failWrite = false + var failDelete = false + + func read() throws -> Data? { + if failRead { throw PayeeFailure(message: "Read failed") } + return data + } + + func write(_ value: Data) throws { + if failWrite { throw PayeeFailure(message: "Write failed") } + data = value + } + + func delete() throws { + if failDelete { throw PayeeFailure(message: "Delete failed") } + data = nil + } +} + +@main +struct PayeeProfileTests { + @MainActor static func main() throws { + let storage = MemoryPayeeStorage() + let store = PayeeProfileStore(storage: storage) + precondition(store.profile == nil && store.loadError == nil) + let empty = try store.exportFields() + precondition(empty.isEmpty) + precondition((try? PayeeProfile().validated()) == nil) + let draft = PayeeProfile(recipient: " 测试收款人 ", bankName: " 测试银行支行 ", + accountNumber: " 0012 3456 7890 ", preparer: "") + try store.save(draft) + precondition(store.profile?.accountNumber == "001234567890") + precondition(store.profile?.recipient == "测试收款人") + precondition(store.profile?.exportFields["preparer"] == "测试收款人") + let restored = PayeeProfileStore(storage: storage) + precondition(restored.profile == store.profile) + var changed = draft + changed.preparer = "测试制单人" + try store.save(changed) + precondition(store.profile?.exportFields["preparer"] == "测试制单人") + let saved = store.profile + let savedData = storage.data + storage.failWrite = true + do { + try store.save(draft) + preconditionFailure("Failed storage must not report success") + } catch {} + precondition(store.profile == saved && storage.data == savedData) + storage.failWrite = false + storage.failDelete = true + do { + try store.clear() + preconditionFailure("Failed delete must retain profile") + } catch {} + precondition(store.profile == saved) + storage.failDelete = false + try store.clear() + precondition(storage.data == nil && store.profile == nil) + try store.save(draft) + storage.failRead = true + store.reload() + precondition(store.profile == nil && store.loadError != nil) + precondition((try? store.exportFields()) == nil) + precondition((try? store.save(draft)) == nil) + storage.failRead = false + store.reload() + precondition(store.profile != nil && store.loadError == nil) + storage.data = Data("corrupt".utf8) + store.reload() + precondition(store.loadError != nil && store.profile == nil) + var invalid = draft + invalid.bankName = String(repeating: "行", count: 201) + precondition((try? invalid.validated()) == nil) + invalid = draft + invalid.recipient = "测试\n收款人" + precondition((try? invalid.validated()) == nil) + print("Payee profile validation, persistence, update, delete and failure tests passed") + } +}