1
This commit is contained in:
@@ -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 只识别、预览和导出首页。
|
||||
|
||||
@@ -8,10 +8,19 @@ if [ ! -x "$PYTHON" ]; then
|
||||
fi
|
||||
export PYINSTALLER_CONFIG_DIR="$ROOT/.build-tools/pyinstaller-cache"
|
||||
cd "$ROOT/native-engine"
|
||||
SOURCE_HASH="$(bash "$ROOT/native-engine/engine-fingerprint.sh")"
|
||||
"$PYTHON" -m PyInstaller --noconfirm --clean --onedir --windowed --name receipt-engine-helper \
|
||||
--osx-bundle-identifier test.reimburse.engine \
|
||||
--distpath "$ROOT/native-engine/dist" --workpath "$ROOT/native-engine/build" \
|
||||
--collect-all rapidocr_onnxruntime --collect-all onnxruntime \
|
||||
--collect-all pypdfium2 --collect-all pypdfium2_raw \
|
||||
--add-data "personal-expense-template.xlsx:." engine.py
|
||||
BINARY="$ROOT/native-engine/dist/receipt-engine-helper.app/Contents/MacOS/receipt-engine-helper"
|
||||
RECEIPT_ENGINE_BINARY="$BINARY" "$PYTHON" -m unittest discover -s tests -p test_exports.py -k engine_process -v
|
||||
if [ "$SOURCE_HASH" != "$(bash "$ROOT/native-engine/engine-fingerprint.sh")" ]; then
|
||||
echo "error: 打包期间引擎源码发生变化,请重新构建。" >&2
|
||||
exit 1
|
||||
fi
|
||||
BINARY_HASH="$(shasum -a 256 "$BINARY" | cut -d ' ' -f 1)"
|
||||
printf '%s\n%s\n' "$SOURCE_HASH" "$BINARY_HASH" > "$ROOT/native-engine/dist/receipt-engine-helper.source.sha256"
|
||||
echo "本地处理引擎已打包,可以在 Xcode 中运行 reimburse。"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
shasum -a 256 ./*.py ./*.sh requirements*.txt personal-expense-template.xlsx helper.entitlements |
|
||||
shasum -a 256 | cut -d ' ' -f 1
|
||||
@@ -90,6 +90,7 @@ def dispatch(request):
|
||||
raise ValueError('至少完成一组核对后才能导出')
|
||||
destination = Path(request['destination'])
|
||||
temporary = destination.with_name('.' + str(uuid.uuid4()) + destination.suffix)
|
||||
metadata = {}
|
||||
try:
|
||||
if operation in ('ppt', 'approved-ppt'):
|
||||
export_ppt(state, temporary, request.get('classified', True))
|
||||
@@ -97,13 +98,14 @@ def dispatch(request):
|
||||
export_travel(state, temporary)
|
||||
elif operation == 'expense':
|
||||
template = Path(__file__).parent / 'personal-expense-template.xlsx'
|
||||
export_expense(state, temporary, template, request.get('purposes', {}), request.get('signatures', []))
|
||||
metadata = export_expense(state, temporary, template, request.get('purposes', {}), request.get('signatures', []),
|
||||
request.get('payee', {}), request.get('categoryPurposes'))
|
||||
else:
|
||||
raise ValueError('未知操作:' + operation)
|
||||
os.replace(temporary, destination)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
return dict(destination=str(destination))
|
||||
return dict(destination=str(destination), **metadata)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
ENGINE="$(cd "$(dirname "$0")" && pwd)"
|
||||
BINARY="$ENGINE/dist/receipt-engine-helper.app/Contents/MacOS/receipt-engine-helper"
|
||||
STAMP="$ENGINE/dist/receipt-engine-helper.source.sha256"
|
||||
SOURCE_HASH="$(bash "$ENGINE/engine-fingerprint.sh")"
|
||||
if [ -x "$BINARY" ] && [ -f "$STAMP" ]; then
|
||||
BINARY_HASH="$(shasum -a 256 "$BINARY" | cut -d ' ' -f 1)"
|
||||
if [ "$(cat "$STAMP")" = "$(printf '%s\n%s' "$SOURCE_HASH" "$BINARY_HASH")" ]; then
|
||||
echo "本地引擎与当前源码一致。"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo "本地引擎缺失或已过期,正在重新打包并验证导出功能…"
|
||||
bash "$ENGINE/build-engine.sh"
|
||||
+44
-17
@@ -183,7 +183,37 @@ def export_travel(state, destination):
|
||||
workbook.save(destination)
|
||||
|
||||
|
||||
def export_expense(state, destination, template, purposes, signatures):
|
||||
def expense_rows(matches, purposes, category_purposes=None):
|
||||
grouped = {}
|
||||
for match in matches:
|
||||
category = match.get('category') or '其他'
|
||||
row = grouped.setdefault(category, dict(category=category, count=0, amount=Decimal(0), types=[], purposes=[]))
|
||||
row['count'] += len(match['invoices'])
|
||||
purpose = purposes.get(match['id'], '').strip()
|
||||
if purpose and purpose not in row['purposes']:
|
||||
row['purposes'].append(purpose)
|
||||
for invoice in match['invoices']:
|
||||
content = text(invoice)
|
||||
if '专用发票' in content:
|
||||
invoice_type = '专票'
|
||||
elif 'invoice' in content.lower():
|
||||
invoice_type = 'Invoice'
|
||||
elif '押金' in content and '收据' in content:
|
||||
invoice_type = '押金收据'
|
||||
else:
|
||||
invoice_type = '普票'
|
||||
if invoice_type not in row['types']:
|
||||
row['types'].append(invoice_type)
|
||||
row['amount'] += max((abs(Decimal(value.replace(',', ''))) for value in invoice['ocr']['amounts']), default=Decimal(0))
|
||||
for row in grouped.values():
|
||||
if category_purposes is not None:
|
||||
row['purpose'] = category_purposes.get(row['category'], '').strip() or row['category']
|
||||
else:
|
||||
row['purpose'] = ';'.join(row['purposes']) or row['category']
|
||||
return list(grouped.values())
|
||||
|
||||
|
||||
def export_expense(state, destination, template, purposes, signatures, payee=None, category_purposes=None):
|
||||
from lxml import etree as ET
|
||||
namespace = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
|
||||
relationships = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
|
||||
@@ -207,7 +237,10 @@ def export_expense(state, destination, template, purposes, signatures):
|
||||
names.clear()
|
||||
sheets.clear()
|
||||
signatures = signatures or DEFAULT_SIGNATURES
|
||||
page_count = math.ceil(len(state['matches']) / 13)
|
||||
rows = expense_rows(state['matches'], purposes, category_purposes)
|
||||
if not rows:
|
||||
raise ValueError('请先勾选要导出的已核对材料')
|
||||
page_count = math.ceil(len(rows) / 13)
|
||||
|
||||
def set_cell(document, reference, value):
|
||||
cell = document.find('.//' + tag('c') + '[@r="' + reference + '"]')
|
||||
@@ -229,25 +262,18 @@ def export_expense(state, destination, template, purposes, signatures):
|
||||
|
||||
for page in range(page_count):
|
||||
document = ET.fromstring(sheet_bytes)
|
||||
profile = payee or {}
|
||||
for reference, field in [('C23', 'recipient'), ('C24', 'bankName'), ('C25', 'accountNumber'), ('H6', 'preparer')]:
|
||||
value = str(profile.get(field, '') or '').strip()
|
||||
if field == 'preparer' and not value:
|
||||
value = str(profile.get('recipient', '') or '').strip()
|
||||
set_cell(document, reference, value)
|
||||
for row_number in range(9, 22):
|
||||
for column in ['B', 'C', 'G', 'H', 'I']:
|
||||
set_cell(document, column + str(row_number), '')
|
||||
set_cell(document, 'A' + str(row_number), row_number - 8)
|
||||
for index, match in enumerate(state['matches'][page * 13:(page + 1) * 13]):
|
||||
invoice_type = '普票'
|
||||
for invoice in match['invoices']:
|
||||
content = text(invoice)
|
||||
if '专用发票' in content:
|
||||
invoice_type = '专票'
|
||||
break
|
||||
if 'invoice' in content.lower():
|
||||
invoice_type = 'Invoice'
|
||||
break
|
||||
if '押金' in content and '收据' in content:
|
||||
invoice_type = '押金收据'
|
||||
break
|
||||
total = sum(max((abs(Decimal(value.replace(',', ''))) for value in item['ocr']['amounts']), default=Decimal(0)) for item in match['invoices'])
|
||||
for column, value in [('B', invoice_type), ('C', purposes.get(match['id'], '').strip()), ('G', len(match['invoices'])), ('H', total)]:
|
||||
for index, row in enumerate(rows[page * 13:(page + 1) * 13]):
|
||||
for column, value in [('B', '/'.join(row['types'])), ('C', row['purpose']), ('G', row['count']), ('H', row['amount'])]:
|
||||
set_cell(document, column + str(index + 9), value)
|
||||
for index, cell in enumerate(['A30', 'D30', 'A31', 'D31', 'A32', 'D32']):
|
||||
value = signatures[index].strip() if index < len(signatures) else ''
|
||||
@@ -277,3 +303,4 @@ def export_expense(state, destination, template, purposes, signatures):
|
||||
with zipfile.ZipFile(destination, 'w', zipfile.ZIP_DEFLATED) as output:
|
||||
for name, content in parts.items():
|
||||
output.writestr(name, content)
|
||||
return dict(expenseGrouping='category-v1', expenseRowCount=len(rows))
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class EngineBuildTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temporary.name)
|
||||
engine = Path(__file__).resolve().parents[1]
|
||||
for name in ['engine-fingerprint.sh', 'ensure-engine.sh']:
|
||||
shutil.copyfile(engine / name, self.root / name)
|
||||
for name in ['engine.py', 'exports.py', 'requirements.txt', 'requirements.lock.txt',
|
||||
'personal-expense-template.xlsx', 'helper.entitlements']:
|
||||
(self.root / name).write_text('fixture\n')
|
||||
self.binary = self.root / 'dist/receipt-engine-helper.app/Contents/MacOS/receipt-engine-helper'
|
||||
(self.root / 'build-engine.sh').write_text('''#!/bin/bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
echo build >> builds.log
|
||||
mkdir -p dist/receipt-engine-helper.app/Contents/MacOS
|
||||
printf 'fixture engine\\n' > dist/receipt-engine-helper.app/Contents/MacOS/receipt-engine-helper
|
||||
chmod +x dist/receipt-engine-helper.app/Contents/MacOS/receipt-engine-helper
|
||||
SOURCE_HASH="$(bash engine-fingerprint.sh)"
|
||||
BINARY_HASH="$(shasum -a 256 dist/receipt-engine-helper.app/Contents/MacOS/receipt-engine-helper | cut -d ' ' -f 1)"
|
||||
printf '%s\\n%s\\n' "$SOURCE_HASH" "$BINARY_HASH" > dist/receipt-engine-helper.source.sha256
|
||||
''')
|
||||
|
||||
def tearDown(self):
|
||||
self.temporary.cleanup()
|
||||
|
||||
def ensure(self):
|
||||
return subprocess.run(['bash', str(self.root / 'ensure-engine.sh')],
|
||||
capture_output=True, text=True, timeout=20)
|
||||
|
||||
def build_count(self):
|
||||
return len((self.root / 'builds.log').read_text().splitlines())
|
||||
|
||||
def test_missing_engine_builds_and_unchanged_engine_is_reused(self):
|
||||
self.assertEqual(self.ensure().returncode, 0)
|
||||
self.assertEqual(self.build_count(), 1)
|
||||
self.assertEqual(self.ensure().returncode, 0)
|
||||
self.assertEqual(self.build_count(), 1)
|
||||
|
||||
def test_changed_sources_rebuild_even_when_timestamp_is_unchanged(self):
|
||||
self.assertEqual(self.ensure().returncode, 0)
|
||||
source = self.root / 'exports.py'
|
||||
original = source.stat()
|
||||
source.write_text('new grouping implementation\n')
|
||||
os.utime(source, ns=(original.st_atime_ns, original.st_mtime_ns))
|
||||
self.assertEqual(self.ensure().returncode, 0)
|
||||
self.assertEqual(self.build_count(), 2)
|
||||
|
||||
def test_replaced_binary_rebuilds(self):
|
||||
self.assertEqual(self.ensure().returncode, 0)
|
||||
self.binary.write_text('old engine\n')
|
||||
self.assertEqual(self.ensure().returncode, 0)
|
||||
self.assertEqual(self.build_count(), 2)
|
||||
|
||||
def test_missing_stamp_rebuilds(self):
|
||||
self.assertEqual(self.ensure().returncode, 0)
|
||||
(self.root / 'dist/receipt-engine-helper.source.sha256').unlink()
|
||||
self.assertEqual(self.ensure().returncode, 0)
|
||||
self.assertEqual(self.build_count(), 2)
|
||||
|
||||
def test_failed_rebuild_does_not_accept_old_engine(self):
|
||||
self.assertEqual(self.ensure().returncode, 0)
|
||||
(self.root / 'build-engine.sh').write_text('exit 17\n')
|
||||
self.assertEqual(self.ensure().returncode, 17)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,4 +1,8 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
@@ -68,6 +72,8 @@ class ExportTests(unittest.TestCase):
|
||||
|
||||
def test_expense_pagination(self):
|
||||
self.state['matches'] = [copy.deepcopy(self.state['matches'][0]) for _ in range(14)]
|
||||
for index, match in enumerate(self.state['matches']):
|
||||
match['category'] = f'类别{index}'
|
||||
destination = self.root / 'expense.xlsx'
|
||||
export_expense(self.state, destination, self.template, {}, [])
|
||||
workbook = load_workbook(destination)
|
||||
@@ -76,6 +82,137 @@ class ExportTests(unittest.TestCase):
|
||||
self.assertEqual(workbook.worksheets[1]['H10'].value, '')
|
||||
self.assertEqual(workbook.worksheets[1]['A30'].value, '部门长:')
|
||||
|
||||
def test_expense_payee_is_text_on_every_page(self):
|
||||
self.state['matches'] = [copy.deepcopy(self.state['matches'][0]) for _ in range(14)]
|
||||
for index, match in enumerate(self.state['matches']):
|
||||
match['category'] = f'类别{index}'
|
||||
destination = self.root / 'payee.xlsx'
|
||||
profile = dict(recipient='测试收款人', bankName='=测试银行支行',
|
||||
accountNumber='0012345678901234567890', preparer='测试制单人')
|
||||
export_expense(self.state, destination, self.template, {}, [], profile)
|
||||
for sheet in load_workbook(destination).worksheets:
|
||||
for reference, value in [('C23', profile['recipient']), ('C24', profile['bankName']),
|
||||
('C25', profile['accountNumber']), ('H6', profile['preparer'])]:
|
||||
self.assertEqual(sheet[reference].value, value)
|
||||
self.assertEqual(sheet[reference].data_type, 's')
|
||||
self.assertEqual(sheet['C25'].style_id, load_workbook(self.template)['1个人报销单']['C25'].style_id)
|
||||
|
||||
def test_expense_without_profile_clears_template_identity(self):
|
||||
destination = self.root / 'empty-payee.xlsx'
|
||||
export_expense(self.state, destination, self.template, {}, [])
|
||||
sheet = load_workbook(destination).active
|
||||
for reference in ['C23', 'C24', 'C25', 'H6']:
|
||||
self.assertEqual(sheet[reference].value, '')
|
||||
|
||||
def test_expense_preparer_defaults_to_recipient(self):
|
||||
destination = self.root / 'default-preparer.xlsx'
|
||||
export_expense(self.state, destination, self.template, {}, [], {'recipient': '测试收款人'})
|
||||
self.assertEqual(load_workbook(destination).active['H6'].value, '测试收款人')
|
||||
|
||||
def test_expense_profile_through_engine_process(self):
|
||||
binary = os.environ.get('RECEIPT_ENGINE_BINARY')
|
||||
command = [binary] if binary else [sys.executable, str(Path(__file__).resolve().parents[1] / 'engine.py')]
|
||||
destination = self.root / 'process-expense.xlsx'
|
||||
self.state['matches'] = [copy.deepcopy(self.state['matches'][0]) for _ in range(6)]
|
||||
for match in self.state['matches']:
|
||||
match['category'] = '交通'
|
||||
request = dict(operation='expense', state=self.state, destination=str(destination),
|
||||
categoryPurposes={'交通': '交通'},
|
||||
payee=dict(recipient='测试收款人', bankName='测试支行', accountNumber='0001234567890123456789'))
|
||||
process = subprocess.run(command, input=json.dumps(request) + '\n', capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(process.returncode, 0, process.stdout + process.stderr)
|
||||
events = [json.loads(line) for line in process.stdout.splitlines()]
|
||||
self.assertEqual(events[-1]['event'], 'result')
|
||||
sheet = load_workbook(destination).active
|
||||
self.assertEqual(sheet['C23'].value, '测试收款人')
|
||||
self.assertEqual(sheet['C24'].value, '测试支行')
|
||||
self.assertEqual(sheet['C25'].value, '0001234567890123456789')
|
||||
self.assertEqual(sheet['C25'].data_type, 's')
|
||||
self.assertEqual(sheet['H6'].value, '测试收款人')
|
||||
self.assertEqual(sheet['C9'].value, '交通')
|
||||
self.assertEqual(sheet['G9'].value, 6)
|
||||
self.assertEqual(sheet['H9'].value, 600)
|
||||
self.assertEqual(sheet['H10'].value, '')
|
||||
|
||||
def test_expense_engine_process_groups_selected_traffic_and_office(self):
|
||||
binary = os.environ.get('RECEIPT_ENGINE_BINARY')
|
||||
command = [binary] if binary else [sys.executable, str(Path(__file__).resolve().parents[1] / 'engine.py')]
|
||||
amounts = [['1058.00', '1058.00'], ['140.00'], ['300.00'], ['163.92'], ['253.45'], ['74.49'], ['444.00']]
|
||||
selected = []
|
||||
for index, invoice_amounts in enumerate(amounts):
|
||||
match = copy.deepcopy(self.state['matches'][0])
|
||||
match['id'] = f'selected-{index}'
|
||||
match['category'] = '办公用品' if index == 6 else '交通'
|
||||
match['invoices'] = []
|
||||
for invoice_index, amount in enumerate(invoice_amounts):
|
||||
invoice = copy.deepcopy(self.state['matches'][0]['invoices'][0])
|
||||
invoice['id'] = f'invoice-{index}-{invoice_index}'
|
||||
invoice['ocr']['amounts'] = [amount]
|
||||
match['invoices'].append(invoice)
|
||||
selected.append(match)
|
||||
self.state['matches'] = selected
|
||||
destination = self.root / 'two-categories.xlsx'
|
||||
request = dict(operation='expense', state=self.state, destination=str(destination),
|
||||
categoryPurposes={'交通': '交通', '办公用品': '办公用品'})
|
||||
process = subprocess.run(command, input=json.dumps(request) + '\n', capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(process.returncode, 0, process.stdout + process.stderr)
|
||||
events = [json.loads(line) for line in process.stdout.splitlines()]
|
||||
self.assertEqual(events[-1]['result']['expenseGrouping'], 'category-v1')
|
||||
self.assertEqual(events[-1]['result']['expenseRowCount'], 2)
|
||||
workbook = load_workbook(destination)
|
||||
self.assertEqual(workbook.sheetnames, ['个人报销单'])
|
||||
sheet = workbook.active
|
||||
rows = [(sheet[f'C{row}'].value, sheet[f'G{row}'].value, sheet[f'H{row}'].value)
|
||||
for row in range(9, 22) if sheet[f'H{row}'].value not in ('', None)]
|
||||
self.assertEqual(rows, [('交通', 7, 3047.86), ('办公用品', 1, 444)])
|
||||
self.assertEqual(round(sum(row[2] for row in rows), 2), 3491.86)
|
||||
self.assertEqual(sheet['F27'].value, '=SUM(H9:H21)')
|
||||
|
||||
def test_expense_same_category_collapses_before_pagination(self):
|
||||
self.state['matches'] = [copy.deepcopy(self.state['matches'][0]) for _ in range(14)]
|
||||
for match in self.state['matches']:
|
||||
match['category'] = '交通'
|
||||
destination = self.root / 'collapsed.xlsx'
|
||||
export_expense(self.state, destination, self.template, {}, [])
|
||||
workbook = load_workbook(destination)
|
||||
self.assertEqual(workbook.sheetnames, ['个人报销单'])
|
||||
sheet = workbook.active
|
||||
self.assertEqual(sheet['C9'].value, '交通')
|
||||
self.assertEqual(sheet['G9'].value, 14)
|
||||
self.assertEqual(sheet['H9'].value, 1400)
|
||||
self.assertEqual(sheet['H10'].value, '')
|
||||
|
||||
def test_expense_category_totals_counts_and_mixed_invoice_types(self):
|
||||
traffic = [copy.deepcopy(self.state['matches'][0]) for _ in range(6)]
|
||||
for match in traffic:
|
||||
match['category'] = '交通'
|
||||
match['invoices'][0]['ocr']['amounts'] = ['100.10', '2']
|
||||
traffic[0]['invoices'][0]['ocr']['rawText'] = '专用发票'
|
||||
hotel = copy.deepcopy(traffic[0])
|
||||
hotel['category'] = '住宿'
|
||||
hotel['invoices'] *= 2
|
||||
hotel['invoices'][0]['ocr']['amounts'] = ['-1,200.30', '2']
|
||||
self.state['matches'] = [traffic[0], hotel] + traffic[1:]
|
||||
original = copy.deepcopy(self.state)
|
||||
destination = self.root / 'grouped.xlsx'
|
||||
export_expense(self.state, destination, self.template, {}, [], category_purposes={'交通': '=交通用途'})
|
||||
sheet = load_workbook(destination).active
|
||||
self.assertEqual(sheet['C9'].value, '=交通用途')
|
||||
self.assertEqual(sheet['C9'].data_type, 's')
|
||||
self.assertEqual(sheet['B9'].value, '专票/普票')
|
||||
self.assertEqual(sheet['G9'].value, 6)
|
||||
self.assertEqual(sheet['H9'].value, 600.6)
|
||||
self.assertEqual(sheet['C10'].value, '住宿')
|
||||
self.assertEqual(sheet['G10'].value, 2)
|
||||
self.assertEqual(sheet['H10'].value, 2400.6)
|
||||
self.assertEqual(sheet['H11'].value, '')
|
||||
self.assertEqual(self.state, original)
|
||||
|
||||
def test_expense_empty_selection_is_rejected(self):
|
||||
self.state['matches'] = []
|
||||
with self.assertRaisesRegex(ValueError, '勾选'):
|
||||
export_expense(self.state, self.root / 'empty.xlsx', self.template, {}, [])
|
||||
|
||||
def test_travel_only_verified_invoices(self):
|
||||
travel = self.state['invoices'][0]['ocr']['travel']
|
||||
travel.update(type='train', travelerName='张三', departure='北京南', destination='上海虹桥', departureTime='2026-06-24 15:00', transportNumber='G21')
|
||||
|
||||
@@ -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 */
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? [:]
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user