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
+14 -2
View File
@@ -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 只识别、预览和导出首页。
+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')
+1 -1
View File
@@ -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 */
+7
View File
@@ -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 {
+47
View File
@@ -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
}
}
+25 -9
View File
@@ -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)
+26
View File
@@ -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
+134
View File
@@ -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 ?? [:]
}
}
+102
View File
@@ -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) }
}
}
+32 -4
View File
@@ -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<String> = []
@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 + "费用") })
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<String>) {
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<String>? = 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)"
+52
View File
@@ -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")
}
}
+84
View File
@@ -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")
}
}