Initial commit: macOS receipt workspace

This commit is contained in:
于晓婷
2026-09-11 12:58:09 +08:00
commit bbacd1d6f3
40 changed files with 2790 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.build-tools/
.derived-data/
.validation/
native-engine/build/
native-engine/dist/
native-engine/*.spec
native-engine/__pycache__/
native-engine/tests/__pycache__/
*.xcuserstate
.DS_Store
+87
View File
@@ -0,0 +1,87 @@
# 贴票台 · macOS 原生版
SwiftUI 原生报账材料工作台。无需浏览器、Java、Spring Boot、HTTP 服务或用户单独安装 Python。RapidOCR 模型、解释器及 Office 导出依赖随应用内的处理引擎打包,扫描和导出仅通过本地子进程的标准输入/输出交换数据,不监听网络端口。
## 开发环境
- Xcode 26.3macOS 15.7 或更高版本。
- 当前构建为 Apple Siliconarm64);Intel Mac 需要另行构建对应架构的处理引擎。
- 本机 Xcode`/Users/yuxiaoting/Downloads/Xcode.app`。无需修改系统默认开发工具目录。
- 原工程 `../fapiao` 不会被修改,也不是运行依赖。
## 在 Xcode 中运行
打开 `reimburse.xcodeproj`,选择 `reimburse` scheme 和 My Mac,运行即可。构建阶段会将 `native-engine/dist/receipt-engine-helper.app` 放进应用的 `Contents/Helpers`,并为辅助进程添加沙盒继承权限。
当前开发目录已经准备了本地依赖和引擎。重新下载源码、切换架构或修改 Python 代码后,需先重建引擎:
```sh
python3.12 -m venv .build-tools
.build-tools/bin/python -m pip install -r native-engine/requirements.txt
bash native-engine/build-engine.sh
```
依赖安装只发生在开发打包时,最终用户不需要上述工具。`dist`、依赖环境及构建产物不提交到版本控制;发布流程需先生成引擎,再构建应用。
```sh
DEVELOPER_DIR=/Users/yuxiaoting/Downloads/Xcode.app/Contents/Developer \
xcodebuild -project reimburse.xcodeproj -scheme reimburse \
-configuration Debug -destination 'platform=macOS' \
-derivedDataPath .derived-data build
```
## 使用流程
1. 选择包含 `发票``付款截图``实物照片` 子目录的材料根目录。
2. 材料复制到应用自己的本地工作区,原始文件不会修改。再次导入会替换当前工作区。
3. 本地 OCR 提取信息,按旧项目规则自动匹配并分类。扫描显示真实处理进度。
4. 在“人工配对”两侧多选材料,选择分类后确认;识别失败的材料仍可人工配对。
5. “已核对”中可查看依据、改分类、撤销及导出。自动核对结果不代表已人工复核。
6. 工作区自动持久化到应用沙盒内的 Application Support/ReceiptDesk;清空只删除工作区副本,不删除原始材料。
## 功能对应
| 原项目 | 原生实现 |
|---|---|
| App.vue、三个页面 | ContentView、ScanPage、PairPage、MatchedPage |
| WorkspaceState / FileItem / MatchPair | Models.swift |
| 前端请求、状态操作 | WorkspaceStore.swift |
| OcrService / worker.py | native-engine/engine.py + 打包 RapidOCR |
| OcrExtractor / WorkspaceService | native-engine/domain.py |
| PptExportService | native-engine/exports.py / export_ppt |
| TravelExcelExportService | native-engine/exports.py / export_travel |
| PersonalExpenseExportService | native-engine/exports.py / export_expense |
保留一对一评分、多发票合计、多付款合计、同程多人归组、歧义判断、七类关键词分类、人工多对多、撤销、预览、金额统计和三类 Office 文件导出。
### 特意保留的业务口径
- 文件名不参与匹配;PDF 只识别、预览和导出首页。
- 一对一分数达到 90 时可通过歧义检查;其他候选需要至少领先 8 分。
- 一张发票对应多笔付款的唯一合计组合不另设商户/日期门槛。
- 页面发票汇总、OCR 首金额和个人报销单最大金额的不同计算口径沿用旧代码,未擅自合并。
- 订单截图与同金额支付凭证去重沿用旧规则,因此仍需人工检查不同订单同金额的情况。
- “按类型分类”PPT 沿用先日期、后类别排序,并非重新设计分类封面或类别分区。
- 实物照片不自动填入 PPT;除交通、住宿外保留手动粘贴占位页。
- 个人报销单沿用原始 XLSX 模板,直接修改工作表 XML,保留样式、打印设置、公式和关联资源;每页 13 条明细,最多六个签字岗位。删除全部岗位时按旧规则回退默认岗位。
### 必须说明的环境差异
RapidOCR 主版本、模型及关键推理依赖与旧环境对齐,但 Windows x64 与 macOS arm64 的底层运行库不同。PDF 首页由本地 PDFium 栅格化,替代原来的 PDFBox。因此不能保证所有文件的 OCR 文本逐字或浮点结果完全一致,仍需用实际报账材料做回归验收。应用不会悄悄换用苹果 Vision 或云端 OCR。
## 验证
```sh
.build-tools/bin/python -m unittest discover -s native-engine/tests -v
.build-tools/bin/python native-engine/tests/smoke_engine.py \
--binary "$PWD/native-engine/dist/receipt-engine-helper.app/Contents/MacOS/receipt-engine-helper" \
--output "$PWD/.validation/packaged"
```
覆盖原铁路多人凭证案例、火车与航班提取、金额歧义、分类、去重、PPT 页面安排、个人报销单分页与公式保留、行程表等。端到端测试使用 `../fapiao/demo-materials` 作为开发测试数据,运行中的应用本身不依赖该目录。
Debug 构建还提供应用沙盒内自检:用 `--verify-local-engine` 参数启动应用,生成独立测试图片,经过真正的 Swift → 本地引擎链路验证 OCR 和三种导出。结果写入沙盒 `Application Support/ReceiptDesk/diagnostics/report.json`,不替换用户工作区;自检后该应用进程自动退出。Release 不包含此入口。
## 发布说明
当前是本机开发运行构建,并非已经完成 Developer ID 签名和苹果公证的公开安装包。正式分发时需用同一 Developer ID 对辅助引擎的所有嵌套 Mach-O 库、辅助程序和主应用按由内到外顺序签名,再进行公证。请同时随发布包保留第三方依赖许可声明。
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PYTHON="$ROOT/.build-tools/bin/python"
if [ ! -x "$PYTHON" ]; then
echo "请先使用 Python 3.12 创建 .build-tools 虚拟环境并安装 native-engine/requirements.txt。" >&2
exit 1
fi
export PYINSTALLER_CONFIG_DIR="$ROOT/.build-tools/pyinstaller-cache"
cd "$ROOT/native-engine"
"$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
echo "本地处理引擎已打包,可以在 Xcode 中运行 reimburse。"
+358
View File
@@ -0,0 +1,358 @@
import re
import uuid
from collections import OrderedDict
from datetime import date
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
CATEGORIES = ['餐饮', '住宿', '交通', '办公用品', '招待', '采购', '其他']
HAN = r'\u3400-\u9fff'
DATE = r'(20\d{2})[年./-](1[0-2]|0?[1-9])[月./-](3[01]|[12]\d|0?[1-9])日?'
AMOUNT_PATTERNS = [
r'(?:支付总额|订单总额|应付总额|总金额|总计|价税合计|小写|实付金额|支付金额|付款金额|订单金额|合计|金额)[^0-9]{0,10}([0-9]{1,8}(?:[.,][0-9]{1,2})?)',
r'(?:¥|¥|RMB|CNY)\s*([0-9]{1,8}(?:[.,][0-9]{1,2})?)',
r'(?<![0-9])([0-9]{1,7}[.,][0-9]{2})(?![0-9%])',
]
MERCHANT_LABEL = r'销售方名称|销方名称|收款方|收款单位|商户全称|商户名称|商户|付款给|收款人|对方户名|交易对象'
KEYWORDS = [
'餐饮 餐厅 饭店 酒楼 外卖 咖啡 奶茶 茶饮 饮品 火锅 烧烤 快餐 小吃 食品 美团外卖 饿了么 tims 星巴克',
'酒店 宾馆 旅馆 住宿 房费 客房 民宿 公寓 入住 退房',
'铁路 火车 12306 车次 机票 航空 航班 登机 滴滴 网约车 出租车 打车 货运 运输 加油站 汽油 柴油 停车 高速 地铁 公交',
'办公用品 办公 文具 打印 复印 纸张 墨盒 硒鼓 耗材 电脑 键盘 鼠标 显示器 软件 会员订阅 桌椅 文件夹 饮用水 桶装水',
'招待 接待 宴请 商务宴请 客户用餐 会议餐',
'采购 商品 材料 设备 配件 家具 日用品 百货 商城 京东 淘宝 天猫 超市 便利店 购物',
]
def unique(values):
return list(dict.fromkeys(values))
def money(value):
return Decimal(str(value).replace(',', '.')).quantize(Decimal('.01'), rounding=ROUND_HALF_UP)
def first(pattern, text):
match = re.search(pattern, text, re.I)
return match.group(1).strip() if match else ''
def extract(lines):
text = '\n'.join(lines)
amounts = unique(format(money(value), '.2f') for pattern in AMOUNT_PATTERNS
for value in re.findall(pattern, text, re.I) if money(value) >= 0)
dates = []
for match in re.finditer(DATE, text):
try:
dates.append(date(*map(int, match.groups())).isoformat())
except ValueError:
pass
merchants = re.findall(r'(?:' + MERCHANT_LABEL + r')[:]?\s*([^\n]{2,40})', text)
def clean(value):
return re.sub(r'[|:]+$', '', re.sub(r'^.*?(?:' + MERCHANT_LABEL.replace('|商户|', '|') + r')[:]?', '', value)).strip()
def valid(value):
return (2 <= len(value) <= 45 and re.search('[' + HAN + 'A-Za-z]', value)
and not re.search('支付成功|交易成功|电子发票|增值税发票|付款方式|订单详情|账单详情|交易详情|商品说明|开票日期|价税合计', value))
for index, line in enumerate(lines):
line = clean(line)
if re.search('销售方|销方|收款方|收款单位|商户|付款给|收款人|对方户名|交易对象', line) and index + 1 < len(lines):
following = clean(lines[index + 1])
if valid(following):
merchants.append(following)
if re.search('公司|餐厅|饭店|酒店|宾馆|旅馆|商店|店|超市|便利店|中心|服务部|经营部|商行|科技|网络|运输|航空|铁路|医院|药房|加油站|停车场|物业|平台', line) and valid(line):
merchants.append(line)
return dict(status='success', rawText=text, lines=lines, amounts=amounts,
dates=unique(dates), merchants=unique(value.strip().rstrip(':|') for value in merchants),
orderNumbers=unique(re.findall(r'(?:交易单号|商户单号|订单号|流水号|交易号)[::]?\s*([A-Za-z0-9-]{8,40})', text)),
travel=extract_travel(text, unique(dates)), message='')
def extract_travel(text, dates):
travel = dict(type='', travelerName='', departure='', destination='', departureTime='', transportNumber='')
train = re.search('火车票|铁路电子客票|铁路|车次|检票口|12306', text)
flight = re.search('航空|航班|登机牌|电子客票行程单|乘机|起飞|客票号', text)
if not train and not flight:
return travel
travel['type'] = 'flight' if flight and not train else 'train'
for pattern in [r'(?:旅客姓名|乘车人|乘机人|旅客|姓名)[::]?\s*([' + HAN + r'·]{2,15})',
r'(?:\d{6,}\*{2,}\d{2,})\s*([' + HAN + r'·]{2,8})']:
names = [name for name in re.findall(pattern, text) if len(name) <= 8 and not re.search('税务|日期|发票|铁路|车站|公司|名称|代码|号码', name)]
if names:
travel['travelerName'] = names[0]
break
travel['departure'] = first(r'(?:出发地|出发站|始发站|起点|起飞机场)[::]?\s*([' + HAN + r'A-Za-z0-9]{2,24})', text)
travel['destination'] = first(r'(?:目的地|到达站|终点站|终点|到达机场)[::]?\s*([' + HAN + r'A-Za-z0-9]{2,24})', text)
route = re.search(r'([' + HAN + r'A-Za-z]{2,20})(?:站|机场)?\s*(?:→|->|—|至)\s*([' + HAN + r'A-Za-z]{2,20})(?:站|机场)?', text)
if route:
for index, field in enumerate(['departure', 'destination'], 1):
travel[field] = travel[field] or re.sub(r'(站|机场)$', '', route.group(index))
if train:
stations = unique(re.findall(r'([' + HAN + r']{2,12})站', text))
if len(stations) >= 2:
travel['departure'] = travel['departure'] or stations[0]
travel['destination'] = travel['destination'] or stations[1]
clock_pattern = r'([01]?\d|2[0-3]):([0-5]\d)'
for match in re.finditer(DATE + r'[^\n]{0,24}?' + clock_pattern, text):
if not re.search('开票日期|发票日期', text[max(0, match.start() - 16):match.start()]):
year, month, day, hour, minute = map(int, match.groups())
travel['departureTime'] = f'{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}'
break
if not travel['departureTime']:
departure_date = ''
for match in re.finditer(DATE, text):
if not re.search('开票日期|发票日期', text[max(0, match.start() - 16):match.start()]):
year, month, day = map(int, match.groups())
departure_date = f'{year:04}-{month:02}-{day:02}'
break
clock = re.search(clock_pattern, text)
travel['departureTime'] = ((departure_date or next(iter(dates), '')) + (' %02d:%02d' % tuple(map(int, clock.groups())) if clock else '')).strip()
pattern = r'(?<![A-Z0-9])([GDCZTKSLY]\d{1,5})(?![A-Z0-9])' if train else r'(?<![A-Z0-9])([A-Z0-9]{2}\d{3,4})(?![A-Z0-9])'
travel['transportNumber'] = first(pattern, text)
return travel
def amounts(item):
return [money(value) for value in item['ocr']['amounts'] if money(value) > 0] if item['ocr']['status'] == 'success' else []
def primary(item):
values = item['ocr']['amounts']
return money(values[0]) if values and item['ocr']['status'] == 'success' else None
def text(item):
return item['ocr']['rawText']
def common(left, right, field):
return [value for value in left['ocr'][field] if value in right['ocr'][field]]
def date_distance(invoices, payments):
distances = [abs((date.fromisoformat(left) - date.fromisoformat(right)).days)
for invoice in invoices for payment in payments
for left in invoice['ocr']['dates'] for right in payment['ocr']['dates']]
return min(distances, default=999999)
def similarity(left, right):
def normalize(value):
return re.sub('[^' + HAN + 'a-z0-9]', '', re.sub('有限责任公司|股份有限公司|有限公司|公司|商户|门店|收款方', '', value.lower()))
def grams(value):
return {value[index:index + 2] for index in range(len(value) - 1)} if len(value) > 1 else {value}
left, right = normalize(left), normalize(right)
if not left or not right:
return 0
if left in right or right in left:
return 1
return len(grams(left) & grams(right)) / len(grams(left) | grams(right))
def merchant_similarity(invoice, payment):
return max((similarity(left, right) for left in invoice['ocr']['merchants'] for right in payment['ocr']['merchants']), default=0)
def category(items):
content = ' '.join(text(item) + ' ' + ' '.join(item['ocr']['merchants']) for item in items).lower()
scores = [sum(3 if len(word) >= 4 else 2 if len(word) >= 3 else 1 for word in words.split() if word in content) for words in KEYWORDS]
return CATEGORIES[scores.index(max(scores))] if max(scores) else '其他'
def add_match(state, invoices, payments, score, reasons):
selected_category = category(invoices + payments)
for item in invoices + payments:
item['matched'] = True
state['matches'].append(dict(id=str(uuid.uuid4()), invoices=invoices, payments=payments,
category=selected_category, matchType='auto', score=score,
reasons=reasons + ['自动分类:' + selected_category]))
def combinations(values, target):
sums = {Decimal('0.00'): [[]]}
for item, amount in values:
snapshot = [(subtotal, [list(group) for group in groups]) for subtotal, groups in sums.items()]
for subtotal, groups in snapshot:
total = subtotal + amount
if total > target:
continue
destination = sums.setdefault(total, [])
for group in list(groups):
candidate = group + [item]
if candidate not in destination:
destination.append(candidate)
del destination[2:]
return [group for group in sums.get(target, []) if len(group) >= 2][:2]
def railway(item):
return bool(re.search('12306|铁路|火车|车次|中铁', text(item)))
def total_label(item):
return bool(re.search('支付总额|订单总额|应付总额|总金额|总计|合计', text(item)))
def travel_key(item):
travel = item['ocr']['travel']
return tuple(travel[field].lower() for field in ['type', 'departure', 'destination', 'departureTime', 'transportNumber'])
def auto_match(state):
invoices, payments = state['invoices'], state['payments']
groups = OrderedDict()
for invoice in invoices:
if invoice['ocr']['travel']['type']:
groups.setdefault(travel_key(invoice), []).append(invoice)
for group in groups.values():
if len(group) < 2 or any(primary(item) is None for item in group):
continue
total = sum(primary(item) for item in group)
evidence = [item for item in payments if not item['matched'] and railway(item) and total in amounts(item)]
if evidence and any(re.search('支付成功|付款成功|交易成功|支付方式|交易单号|商户单号', text(item)) for item in evidence) and date_distance(group, evidence) <= 14:
reasons = [f'同一行程多人发票合计一致 ¥{total:.2f}', ' + '.join(str(primary(item)) for item in group)]
if len(evidence) >= 2:
reasons.append('付款凭证与订单截图已共同归入本次行程')
add_match(state, group, evidence, 100 if len(evidence) >= 2 else 95, reasons)
for payment in payments:
total = primary(payment)
if payment['matched'] or total is None:
continue
values = [(item, primary(item)) for item in invoices if not item['matched'] and primary(item) is not None and 0 < primary(item) < total]
options = combinations(values, total)
if len(options) != 1:
continue
group, evidence = options[0], [payment]
for candidate in payments:
if candidate is payment or candidate['matched']:
continue
same_total = primary(candidate) == total or (total in amounts(candidate) and total_label(candidate))
related = (common(payment, candidate, 'orderNumbers') or merchant_similarity(payment, candidate) >= .35
or (railway(payment) and railway(candidate) and (date_distance([payment], [candidate]) <= 7 or total_label(payment) or total_label(candidate))))
if same_total and related:
evidence.append(candidate)
score = 70
reasons = [f'多张发票合计一致 ¥{total:.2f}', ' + '.join(str(primary(item)) for item in group)]
for condition, reason in [(len(evidence) >= 2, '订单与付款凭证相互印证'),
(date_distance(group, evidence) <= 7, '行程与付款日期接近'),
(all(item['ocr']['travel']['type'] for item in group) and len({tuple(item['ocr']['travel'][field] for field in ['departure', 'destination', 'departureTime', 'transportNumber']) for item in group}) == 1, '车次、路线及行程日期一致')]:
if condition:
score += 10
reasons.append(reason)
if score >= 90:
add_match(state, group, evidence, score, reasons)
candidates = []
for invoice in invoices:
for payment in payments:
shared = common(invoice, payment, 'amounts')
if not shared or invoice['ocr']['status'] != 'success' or payment['ocr']['status'] != 'success':
continue
score, reasons = 55, ['金额一致 ¥' + shared[0]]
merchant = merchant_similarity(invoice, payment)
if merchant >= .35:
score += int(25 * merchant + .5)
reasons.append(f'商户相似 {int(merchant * 100 + .5)}%')
distance = date_distance([invoice], [payment])
if distance <= 7:
score += 15 if distance <= 3 else 8
reasons.append('日期一致' if distance == 0 else f'日期相差 {distance}')
if common(invoice, payment, 'orderNumbers'):
score += 20
reasons.append('交易单号一致')
for amount in shared:
if sum(amount in item['ocr']['amounts'] for item in invoices) == 1 and sum(amount in item['ocr']['amounts'] for item in payments) == 1:
score += 20
reasons.append(f'金额 ¥{amount} 在本批材料中唯一')
break
if score >= 70:
candidates.append((invoice, payment, min(100, score), reasons))
candidates.sort(key=lambda candidate: -candidate[2])
for candidate in candidates:
invoice, payment, score, reasons = candidate
if invoice['matched'] or payment['matched']:
continue
second = max((other[2] for other in candidates if other is not candidate and (other[0] is invoice or other[1] is payment)
and not other[0]['matched'] and not other[1]['matched']), default=0)
if score >= 90 or score - second >= 8:
add_match(state, [invoice], [payment], score, reasons)
for invoice in invoices:
if invoice['matched']:
continue
available = sorted([(item, primary(item)) for item in payments if not item['matched'] and primary(item) is not None and primary(item) > 0], key=lambda entry: entry[1])
options = []
for total in amounts(invoice):
options.extend((total, group) for group in combinations([(item, amount) for item, amount in available if amount < total], total))
if len(options) > 1:
break
if len(options) == 1:
total, evidence = options[0]
add_match(state, [invoice], evidence, 95, [f'多笔付款合计一致 ¥{total:.2f}', ' + '.join(str(primary(item)) for item in evidence)])
return state
def material_date(item, issue=False):
content = text(item)
if issue:
match = re.search(r'(?:申请开票(?:日期|时间)?|开票日期|发票开具日期|开具日期)\s*[:]?\s*' + DATE, content)
if match:
try:
return date(*map(int, match.groups())).isoformat()
except ValueError:
pass
if item['ocr']['dates']:
return item['ocr']['dates'][0]
match = re.search(r'(?:^|\D)(\d{1,2})月(\d{1,2})日', content)
if match:
try:
return date(date.today().year, *map(int, match.groups())).isoformat()
except ValueError:
pass
return '9999-12-31'
def invoice_amount(item):
content = text(item)
patterns = [r'价税合计[^\n]{0,35}?(?:小写)?[^0-9]{0,12}([0-9][0-9,]*(?:\.\d{1,2})?)',
r'(?:小写|票价|发票金额|合计金额|总金额|应付金额)\s*[(]?[人民币RMB]*[)]?\s*[:]?\s*[¥¥]?\s*([0-9][0-9,]*(?:\.\d{1,2})?)']
for pattern in patterns:
values = re.findall(pattern, content, re.I)
if values:
return Decimal(values[-1].replace(',', ''))
return max([Decimal(value) for value in item['ocr']['amounts']], default=Decimal(0))
def payment_amount(item):
for pattern in [r'(?:支付总额|实付金额|实际支付金额|付款金额|实付款|实付价|合计)\s*[:]?\s*[¥¥]?\s*([0-9][0-9,]*(?:\.\d{1,2})?)',
r'已优惠[^\n]{0,24}?[¥¥]\s*([0-9][0-9,]*(?:\.\d{1,2})?)']:
values = re.findall(pattern, text(item), re.I)
if values:
return abs(Decimal(values[-1].replace(',', '')))
return abs(primary(item) or Decimal(0))
def payment_total(items):
result = Decimal(0)
for index, item in enumerate(items):
amount = payment_amount(item)
duplicate = re.search('支付总额|查看订单支付明细|购票成功|订单详情', text(item)) and any(
other_index != index and re.search('支付成功|付款成功|交易成功|支付方式|付款方式|交易单号|商户单号|转账成功|收款方', text(other))
and abs(payment_amount(other) - amount) < Decimal('.005') for other_index, other in enumerate(items))
if not duplicate:
result += amount
return result
def enrich(state):
for item in state['invoices'] + state['payments'] + state['photos']:
item['displayAmount'] = float(invoice_amount(item) if item['type'] == 'invoice' else payment_amount(item))
item['sortDate'] = material_date(item)
item['issueDate'] = material_date(item, True)
lookup = {item['id']: item for item in state['invoices'] + state['payments']}
for match in state['matches']:
match['invoices'] = [lookup[item['id']] for item in match['invoices']]
match['payments'] = [lookup[item['id']] for item in match['payments']]
match['paymentTotal'] = float(payment_total(match['payments']))
match['expenseAmount'] = float(sum(max((Decimal(value) for value in item['ocr']['amounts']), default=Decimal(0)) for item in match['invoices']))
state['directoryPaymentTotal'] = float(payment_total(state['payments']))
return state
+118
View File
@@ -0,0 +1,118 @@
import contextlib
import json
import os
import shutil
import sys
import traceback
import uuid
from pathlib import Path
from domain import auto_match, enrich, extract
from exports import export_expense, export_ppt, export_travel
PROTOCOL = sys.stdout
SUPPORTED = {'.jpg', '.jpeg', '.png', '.webp', '.bmp', '.gif', '.pdf'}
def emit(value):
PROTOCOL.write(json.dumps(value, ensure_ascii=False) + '\n')
PROTOCOL.flush()
def scan(request):
import onnxruntime
import pypdfium2 as pdfium
from PIL import Image
from rapidocr_onnxruntime import RapidOCR
root = Path(request['path'])
destination = Path(request['workspacePath'])
if not root.is_dir():
raise ValueError('请选择有效的报账材料文件夹')
destination.mkdir(parents=True, exist_ok=True)
state = dict(rootPath=request.get('folderName', root.name), invoices=[], payments=[], photos=[], matches=[], warnings=[], directoryPaymentTotal=0)
files = []
for source in sorted(root.rglob('*')):
if source.is_file() and source.suffix.lower() in SUPPORTED:
parts = source.relative_to(root).parts[:-1]
kind = next((kind for label, kind in [('发票', 'invoice'), ('付款截图', 'payment'), ('实物照片', 'photo')] if label in parts), None)
if kind:
files.append((source, kind))
if not files:
raise ValueError('目录中没有可处理材料,请确认包含“发票”“付款截图”子文件夹')
emit(dict(event='progress', progress=0, message='正在加载本地 RapidOCR 模型'))
onnxruntime.disable_telemetry_events()
recognizer = RapidOCR()
for index, (source, kind) in enumerate(files):
identifier = str(uuid.uuid4())
stored = destination / (identifier + source.suffix.lower())
shutil.copyfile(source, stored)
item = dict(id=identifier, name=source.name, path=str(stored), type=kind, size=stored.stat().st_size, matched=False,
previewPath='', ocr=extract([]))
item['ocr']['status'] = 'pending'
try:
if source.suffix.lower() == '.pdf':
with contextlib.closing(pdfium.PdfDocument(str(stored))) as document:
if not len(document):
raise ValueError('PDF 没有可预览页面')
with contextlib.closing(document[0]) as page:
with contextlib.closing(page.render(scale=140 / 72)) as bitmap:
image = bitmap.to_pil().convert('RGB')
else:
with Image.open(stored) as original:
image = original.convert('RGB')
preview = destination / (identifier + '-preview.png')
image.save(preview, format='PNG')
item['previewPath'] = str(preview)
if kind != 'photo':
result, _ = recognizer(str(preview if source.suffix.lower() == '.pdf' else stored))
item['ocr'] = extract([str(entry[1]).strip() for entry in result or [] if len(entry) >= 3 and str(entry[1]).strip()])
except Exception as error:
item['ocr']['status'] = 'failed'
item['ocr']['message'] = str(error)
state['warnings'].append(source.name + ':识别或预览失败,可人工配对')
state[{'invoice': 'invoices', 'payment': 'payments', 'photo': 'photos'}[kind]].append(item)
emit(dict(event='progress', progress=(index + 1) / len(files) * .95, message=f'已处理 {index + 1}/{len(files)} · {source.name}'))
for field, label in [('invoices', '发票'), ('payments', '付款截图'), ('photos', '实物照片')]:
if not state[field]:
state['warnings'].append('未找到“' + label + '”文件夹或支持的文件')
emit(dict(event='progress', progress=.97, message='正在执行自动配对与分类'))
return enrich(auto_match(state))
def dispatch(request):
operation = request['operation']
if operation == 'scan':
return scan(request)
state = request['state']
if operation == 'refresh':
return enrich(state)
if not state['matches']:
raise ValueError('至少完成一组核对后才能导出')
destination = Path(request['destination'])
temporary = destination.with_name('.' + str(uuid.uuid4()) + destination.suffix)
try:
if operation == 'ppt':
export_ppt(state, temporary, request.get('classified', True))
elif operation == 'travel':
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', []))
else:
raise ValueError('未知操作:' + operation)
os.replace(temporary, destination)
finally:
temporary.unlink(missing_ok=True)
return dict(destination=str(destination))
if __name__ == '__main__':
try:
request = json.loads(sys.stdin.readline())
with contextlib.redirect_stdout(sys.stderr):
result = dispatch(request)
emit(dict(event='result', result=result))
except Exception as error:
traceback.print_exc(file=sys.stderr)
emit(dict(event='error', message=str(error)))
sys.exit(1)
+232
View File
@@ -0,0 +1,232 @@
import copy
import io
import math
import posixpath
import zipfile
from decimal import Decimal
from pathlib import Path
from xml.etree import ElementTree as XML
from PIL import Image
from domain import material_date, text
DEFAULT_SIGNATURES = ['部门长', '剧组出纳', '制片主任', '剧组会计', '执行制片人', '制片人']
def image_for(item):
return Image.open(item.get('previewPath') or item['path']).convert('RGB')
def export_ppt(state, destination, classified):
from pptx import Presentation
from pptx.util import Pt
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import PP_ALIGN
presentation = Presentation()
presentation.slide_width, presentation.slide_height = Pt(1280), Pt(720)
def slide():
return presentation.slides.add_slide(presentation.slide_layouts[6])
def picture(page, item, box):
image = image_for(item)
left, top, width, height = box
scale = min(width / image.width, height / image.height)
actual_width, actual_height = int(image.width * scale), int(image.height * scale)
content = io.BytesIO()
image.save(content, format='PNG')
content.seek(0)
page.shapes.add_picture(content, Pt(left + (width - actual_width) // 2), Pt(top + (height - actual_height) // 2), Pt(actual_width), Pt(actual_height))
def payment_page(payments):
page = slide()
if len(payments) == 1:
picture(page, payments[0], (300, 20, 680, 680))
else:
width = (1200 - 24 * (len(payments) - 1)) // len(payments)
for index, item in enumerate(payments):
picture(page, item, (40 + index * (width + 24), 20, width, 680))
def label(page, box, value, size, title=False):
shape = page.shapes.add_shape(MSO_SHAPE.RECTANGLE, *(Pt(value) for value in box))
shape.fill.solid()
shape.fill.fore_color.rgb = RGBColor(245, 248, 247) if title else RGBColor(255, 255, 255)
shape.line.color.rgb = RGBColor(216, 226, 222) if title else RGBColor(170, 188, 182)
shape.line.width = Pt(1 if title else 1.5)
shape.text = value
for paragraph in shape.text_frame.paragraphs:
paragraph.alignment = PP_ALIGN.CENTER
for run in paragraph.runs:
run.font.name = 'PingFang SC'
run.font.size = Pt(size)
run.font.color.rgb = RGBColor(39, 91, 78) if title else RGBColor(112, 132, 126)
def photos(count):
for offset in range(0, count, 4):
slots = min(4, count - offset)
page = slide()
label(page, (55, 28, 1170, 52), f'实物照片粘贴区(请手动粘贴) {offset + 1}-{min(count, offset + 4)} / {count}', 18, True)
for index in range(slots):
box = (250, 105, 780, 555) if slots == 1 else (55 + index * 610, 105, 560, 555) if slots == 2 else (55 + index % 2 * 610, 105 + index // 2 * 285, 560, 255)
label(page, box, f'实物照片 {offset + index + 1}\n请在此处粘贴', 16)
def requires_photo(match):
return match['category'].strip() not in ['交通', '住宿']
def simple_batch(batch):
if not batch:
return
for match in batch:
picture(slide(), match['invoices'][0], (40, 25, 1200, 670))
payment_page([match['payments'][0] for match in batch])
photos(sum(len(match['payments']) for match in batch if requires_photo(match)))
matches = sorted(state['matches'], key=lambda match: (min(material_date(item, True) for item in match['invoices']), match['category'] if classified else ''))
batch = []
for match in matches:
if len(match['invoices']) != 1 or len(match['payments']) != 1:
simple_batch(batch)
batch = []
for item in sorted(match['invoices'], key=material_date):
picture(slide(), item, (40, 25, 1200, 670))
payments = sorted(match['payments'], key=material_date)
for index in range(0, len(payments), 4):
payment_page(payments[index:index + 4])
if requires_photo(match):
photos(len(payments))
else:
batch.append(match)
if len(batch) == 2:
simple_batch(batch)
batch = []
simple_batch(batch)
presentation.save(destination)
def export_travel(state, destination):
import mimetypes
mimetypes.knownfiles = []
mimetypes.init()
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
from openpyxl.utils import get_column_letter
invoices = [item for match in state['matches'] for item in match['invoices'] if item['ocr']['travel']['type']]
if not invoices:
raise ValueError('已核对材料中没有识别到火车票或航班行程')
workbook = Workbook()
sheet = workbook.active
sheet.title = '火车及航班行程'
sheet.append(['序号', '交通类型', '姓名', '出发地', '目的地', '出发时间', '车次/航班号', '金额', '发票文件'])
for cell in sheet[1]:
cell.font = Font(bold=True, color='FFFFFF')
cell.fill = PatternFill('solid', fgColor='006633')
cell.alignment = Alignment(horizontal='center')
for index, item in enumerate(invoices, 1):
travel = item['ocr']['travel']
values = [index, '火车' if travel['type'] == 'train' else '航班', travel['travelerName'], travel['departure'], travel['destination'], travel['departureTime'], travel['transportNumber'], next(iter(item['ocr']['amounts']), ''), item['name']]
sheet.append(values)
for cell in sheet[sheet.max_row]:
if isinstance(cell.value, str):
cell.data_type = 's'
for index, width in enumerate([8, 12, 16, 18, 18, 22, 18, 14, 38], 1):
sheet.column_dimensions[get_column_letter(index)].width = width
sheet.freeze_panes = 'A2'
workbook.save(destination)
def export_expense(state, destination, template, purposes, signatures):
from lxml import etree as ET
namespace = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
relationships = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
package = 'http://schemas.openxmlformats.org/package/2006/relationships'
content_namespace = 'http://schemas.openxmlformats.org/package/2006/content-types'
tag = lambda name: '{' + namespace + '}' + name
with zipfile.ZipFile(template) as source:
parts = {name: source.read(name) for name in source.namelist()}
workbook = ET.fromstring(parts['xl/workbook.xml'])
relations = ET.fromstring(parts['xl/_rels/workbook.xml.rels'])
content_types = ET.fromstring(parts['[Content_Types].xml'])
sheets = workbook.find(tag('sheets'))
original = next(sheet for sheet in sheets if sheet.get('name') == '1个人报销单')
original_index = list(sheets).index(original)
relation = next(item for item in relations if item.get('Id') == original.get('{' + relationships + '}id'))
sheet_path = posixpath.normpath(posixpath.join('xl', relation.get('Target'))) if not relation.get('Target').startswith('/') else relation.get('Target').lstrip('/')
sheet_bytes = parts[sheet_path]
names = workbook.find(tag('definedNames'))
retained_names = [copy.deepcopy(item) for item in names if item.get('localSheetId') == str(original_index)] if names is not None else []
if names is not None:
names.clear()
sheets.clear()
signatures = signatures or DEFAULT_SIGNATURES
page_count = math.ceil(len(state['matches']) / 13)
def set_cell(document, reference, value):
cell = document.find('.//' + tag('c') + '[@r="' + reference + '"]')
if cell is None:
row_number = ''.join(character for character in reference if character.isdigit())
row = document.find('.//' + tag('row') + '[@r="' + row_number + '"]')
if row is None:
raise ValueError('报销模板缺少明细行:' + row_number)
cell = ET.SubElement(row, tag('c'), r=reference)
for child in list(cell):
cell.remove(child)
if isinstance(value, (int, float, Decimal)):
cell.set('t', 'n')
ET.SubElement(cell, tag('v')).text = str(value)
else:
cell.set('t', 'inlineStr')
inline = ET.SubElement(cell, tag('is'))
ET.SubElement(inline, tag('t')).text = str(value)
for page in range(page_count):
document = ET.fromstring(sheet_bytes)
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)]:
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 ''
set_cell(document, cell, value + '' if value else '')
name = '个人报销单' if page_count == 1 else f'个人报销单-{page + 1}'
output_path = f'xl/worksheets/native-expense-{page + 1}.xml'
relation_id = f'rIdNativeExpense{page + 1}'
ET.SubElement(sheets, tag('sheet'), name=name, sheetId=str(page + 1), attrib={'{' + relationships + '}id': relation_id})
ET.SubElement(relations, '{' + package + '}Relationship', Id=relation_id, Type=relationships + '/worksheet', Target=output_path[3:])
ET.SubElement(content_types, '{' + content_namespace + '}Override', PartName='/' + output_path, ContentType='application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml')
parts[output_path] = ET.tostring(document, xml_declaration=True, encoding='UTF-8', standalone=True)
rel_path = posixpath.dirname(sheet_path) + '/_rels/' + posixpath.basename(sheet_path) + '.rels'
if rel_path in parts:
parts['xl/worksheets/_rels/' + posixpath.basename(output_path) + '.rels'] = parts[rel_path]
for definition in retained_names:
cloned = copy.deepcopy(definition)
cloned.set('localSheetId', str(page))
cloned.text = (cloned.text or '').replace("'1个人报销单'!", "'" + name + "'!").replace('1个人报销单!', "'" + name + "'!")
names.append(cloned)
calc = workbook.find(tag('calcPr'))
if calc is None:
calc = ET.SubElement(workbook, tag('calcPr'))
calc.set('fullCalcOnLoad', '1')
calc.set('forceFullCalc', '1')
for key, document in [('xl/workbook.xml', workbook), ('xl/_rels/workbook.xml.rels', relations), ('[Content_Types].xml', content_types)]:
parts[key] = ET.tostring(document, xml_declaration=True, encoding='UTF-8', standalone=True)
with zipfile.ZipFile(destination, 'w', zipfile.ZIP_DEFLATED) as output:
for name, content in parts.items():
output.writestr(name, content)
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>com.apple.security.app-sandbox</key><true/>
<key>com.apple.security.inherit</key><true/>
</dict></plist>
+27
View File
@@ -0,0 +1,27 @@
import json
from pathlib import Path
from PIL import Image, ImageDraw
root = Path(__file__).resolve().parents[1] / 'reimburse/Assets.xcassets/AppIcon.appiconset'
image = Image.new('RGBA', (1024, 1024))
draw = ImageDraw.Draw(image)
draw.rounded_rectangle((44, 44, 980, 980), radius=218, fill='#145b54')
draw.rounded_rectangle((72, 70, 952, 955), radius=194, fill='#1b7368')
draw.rounded_rectangle((284, 174, 743, 800), radius=42, fill='#f4f0e6')
draw.rounded_rectangle((350, 268, 665, 294), radius=13, fill='#90b2a8')
draw.rounded_rectangle((350, 341, 580, 363), radius=11, fill='#b1c7bd')
draw.rounded_rectangle((350, 405, 634, 427), radius=11, fill='#b1c7bd')
draw.rounded_rectangle((198, 488, 826, 817), radius=70, fill='#103e39')
draw.rounded_rectangle((218, 511, 806, 796), radius=54, fill='#224e46')
draw.rounded_rectangle((617, 582, 854, 726), radius=42, fill='#d6bd82')
draw.ellipse((662, 630, 709, 677), fill='#4c6351')
draw.line([(342, 645), (411, 707), (523, 584)], fill='#f4f0e6', width=29, joint='curve')
entries = []
for size in [16, 32, 128, 256, 512]:
for scale in [1, 2]:
filename = f'icon-{size}@{scale}x.png'
image.resize((size * scale, size * scale), Image.Resampling.LANCZOS).save(root / filename)
entries.append(dict(idiom='mac', size=f'{size}x{size}', scale=f'{scale}x', filename=filename))
content = json.dumps(dict(images=entries, info=dict(author='xcode', version=1)), indent=2)
import subprocess
subprocess.run(['apply_patch', '*** Begin Patch\n*** Delete File: ' + str(root / 'Contents.json') + '\n*** Add File: ' + str(root / 'Contents.json') + '\n' + '\n'.join('+' + line for line in content.splitlines()) + '\n*** End Patch'], check=True)
Binary file not shown.
+25
View File
@@ -0,0 +1,25 @@
altgraph==0.17.5
et_xmlfile==2.0.0
flatbuffers==25.12.19
lxml==6.1.3
macholib==1.16.4
numpy==2.5.1
onnxruntime==1.28.0
opencv-python==5.0.0.93
openpyxl==3.1.5
packaging==26.3
pillow==11.3.0
protobuf==7.35.1
pyclipper==1.4.0
pyinstaller==6.16.0
pyinstaller-hooks-contrib==2026.7
pypdfium2==4.30.0
python-pptx==1.0.2
PyYAML==6.0.3
rapidocr-onnxruntime==1.4.4
setuptools==84.0.0
shapely==2.1.2
six==1.17.0
tqdm==4.70.0
typing_extensions==4.16.0
xlsxwriter==3.2.9
+11
View File
@@ -0,0 +1,11 @@
-c requirements.lock.txt
rapidocr-onnxruntime==1.4.4
numpy==2.5.1
onnxruntime==1.28.0
opencv-python==5.0.0.93
protobuf==7.35.1
Pillow==11.3.0
python-pptx==1.0.2
openpyxl==3.1.5
pypdfium2==4.30.0
pyinstaller==6.16.0
+63
View File
@@ -0,0 +1,63 @@
import argparse
import json
import subprocess
import sys
import shutil
from pathlib import Path
from PIL import Image
def invoke(command, request):
process = subprocess.run(command, input=json.dumps(request, ensure_ascii=False) + '\n', capture_output=True, text=True, timeout=180)
events = [json.loads(line) for line in process.stdout.splitlines() if line.strip()]
errors = [event for event in events if event['event'] == 'error']
if process.returncode or errors:
raise RuntimeError(str(errors) + '\n' + process.stderr)
results = [event['result'] for event in events if event['event'] == 'result']
if len(results) != 1:
raise AssertionError('处理引擎没有返回唯一结果')
return results[0]
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--binary')
parser.add_argument('--output', required=True)
arguments = parser.parse_args()
root = Path(__file__).resolve().parents[3]
output = Path(arguments.output).resolve()
output.mkdir(parents=True, exist_ok=True)
engine = Path(__file__).resolve().parents[1] / 'engine.py'
command = [arguments.binary] if arguments.binary else [sys.executable, str(engine)]
state = invoke(command, dict(operation='scan', path=str(root / 'fapiao/demo-materials'), workspacePath=str(output / 'materials'), folderName='演示报账材料'))
assert len(state['invoices']) == 2
assert len(state['payments']) == 2
assert len(state['photos']) == 1
assert len(state['matches']) == 2
assert {match['category'] for match in state['matches']} == {'餐饮', '住宿'}
assert state['directoryPaymentTotal'] == 596.5
assert sum(match['expenseAmount'] for match in state['matches']) == 596.5
assert not state['warnings']
(output / 'state.json').write_text(json.dumps(state, ensure_ascii=False, indent=2))
for operation, extension in [('ppt', 'pptx'), ('expense', 'xlsx')]:
destination = output / ('演示报账材料.' + extension)
invoke(command, dict(operation=operation, state=state, destination=str(destination), classified=True, purposes={match['id']: match['category'] + '费用' for match in state['matches']}, signatures=['经办人', '财务']))
assert destination.stat().st_size > 1000
pdf_input = output / 'pdf-input'
(pdf_input / '发票').mkdir(parents=True, exist_ok=True)
(pdf_input / '付款截图').mkdir(parents=True, exist_ok=True)
with Image.open(root / 'fapiao/demo-materials/发票/住宿_差旅发票_A001.png') as original:
original.convert('RGB').save(pdf_input / '发票/两页发票.pdf', save_all=True, append_images=[Image.new('RGB', (800, 500), 'white')])
(pdf_input / '发票/损坏文件.pdf').write_bytes(b'not a pdf')
shutil.copyfile(root / 'fapiao/demo-materials/付款截图/酒店支付记录_B009.png', pdf_input / '付款截图/付款.png')
pdf_state = invoke(command, dict(operation='scan', path=str(pdf_input), workspacePath=str(output / 'pdf-materials')))
assert len(pdf_state['invoices']) == 2
assert len(pdf_state['matches']) == 1
assert pdf_state['matches'][0]['expenseAmount'] == 468
assert sum(item['ocr']['status'] == 'failed' for item in pdf_state['invoices']) == 1
invoke(command, dict(operation='ppt', state=pdf_state, destination=str(output / 'PDF首页贴票.pptx')))
print('完整链路通过:5 份材料、2 组自动核对、总额 596.50;PPT、个人报销单、PDF 首页与损坏文件降级均通过。')
if __name__ == '__main__':
main()
+109
View File
@@ -0,0 +1,109 @@
import copy
import sys
import unittest
from decimal import Decimal
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from domain import auto_match, category, combinations, enrich, extract, invoice_amount, payment_total
def material(identifier, kind, amount, content='', dates=None, merchants=None):
ocr = extract(content.splitlines())
ocr.update(amounts=[amount] if isinstance(amount, str) else amount, dates=dates or [], merchants=merchants or [])
return dict(id=identifier, name=identifier + '.png', path=identifier, previewPath='', type=kind,
size=1, matched=False, ocr=ocr)
def workspace(invoices, payments):
return dict(rootPath='测试', invoices=invoices, payments=payments, photos=[], matches=[], warnings=[])
def train(identifier, traveler):
return material(identifier, 'invoice', '1058.00', f'铁路电子客票\n乘车人:{traveler}\n北京南 → 上海虹桥\n2026年06月24日 15:00\nG21次', ['2026-06-24'], ['中国铁路'])
class ExtractionTests(unittest.TestCase):
def test_train(self):
data = extract(['铁路电子客票', '乘车人:张三', '北京南 → 上海虹桥', '2026年08月12日 09:30', 'G101次', '票价 ¥553.00'])
self.assertEqual(data['travel'], dict(type='train', travelerName='张三', departure='北京南', destination='上海虹桥', departureTime='2026-08-12 09:30', transportNumber='G101'))
def test_flight(self):
travel = extract(['航空电子客票行程单', '旅客姓名:李四', '出发地:广州', '目的地:成都', '2026-08-15 14:20', '航班号 CZ3401'])['travel']
self.assertEqual((travel['type'], travel['travelerName'], travel['departure'], travel['destination'], travel['transportNumber']), ('flight', '李四', '广州', '成都', 'CZ3401'))
def test_invoice_date_is_not_departure(self):
travel = extract(['电子发票(铁路电子客票) 北京市税务局', '发票号码:26119110010005751560 开票日期:2026年06月26日', '北京南站 G21 上海虹桥站', '2026年06月24日 15:00开 17车04C号', '2107021990****0234 王天行', '票价:¥1058.00'])['travel']
self.assertEqual(travel['travelerName'], '王天行')
self.assertEqual(travel['departureTime'], '2026-06-24 15:00')
def test_invalid_date_does_not_abort_material(self):
self.assertEqual(extract(['2026年02月31日 金额 20'])['dates'], [])
def test_amount_order(self):
self.assertEqual(extract(['票价 ¥1058.00', '支付总额 2116'])['amounts'][0], '2116.00')
class MatchingTests(unittest.TestCase):
def test_original_many_to_many_fixture(self):
state = workspace([train('i1', '王天行'), train('i2', '王超文')], [
material('p1', 'payment', ['1058.00', '2116.00'], '12306订单 中国铁路 支付总额 2116', ['2026-06-24']),
material('p2', 'payment', '2116.00', '12306消费 中国铁路', ['2026-06-22'])])
auto_match(state)
self.assertEqual(len(state['matches']), 1)
match = state['matches'][0]
self.assertEqual((len(match['invoices']), len(match['payments']), match['score'], match['category']), (2, 2, 100, '交通'))
def test_shared_travel_payment_evidence(self):
state = workspace([train('i1', '张三'), train('i2', '李四')], [material('p1', 'payment', '2116.00', '12306 支付成功', ['2026-06-24'])])
auto_match(state)
self.assertEqual(state['matches'][0]['score'], 95)
def test_unique_amount(self):
state = workspace([material('i', 'invoice', '100.00')], [material('p', 'payment', '100.00')])
auto_match(state)
self.assertEqual(state['matches'][0]['score'], 75)
def test_ambiguous_stays_unmatched(self):
state = workspace([material('i', 'invoice', '100.00', dates=['2026-08-01'])], [material('p1', 'payment', '100.00', dates=['2026-08-01']), material('p2', 'payment', '100.00', dates=['2026-08-01'])])
self.assertFalse(auto_match(state)['matches'])
def test_high_score_keeps_original_tie_rule(self):
state = workspace([material('i', 'invoice', '100.00', dates=['2026-08-01'], merchants=['测试公司'])], [material('p1', 'payment', '100.00', dates=['2026-08-01'], merchants=['测试公司']), material('p2', 'payment', '100.00', dates=['2026-08-01'], merchants=['测试公司'])])
self.assertEqual(len(auto_match(state)['matches']), 1)
def test_split_payment(self):
state = workspace([material('i', 'invoice', '100.00')], [material('p1', 'payment', '30.00'), material('p2', 'payment', '70.00')])
self.assertEqual(len(auto_match(state)['matches'][0]['payments']), 2)
def test_ambiguous_split_payment(self):
state = workspace([material('i', 'invoice', '100.00')], [material('p1', 'payment', '30.00'), material('p2', 'payment', '70.00'), material('p3', 'payment', '40.00'), material('p4', 'payment', '60.00')])
self.assertFalse(auto_match(state)['matches'])
def test_filename_does_not_match(self):
state = workspace([material('same', 'invoice', '100.00')], [material('same', 'payment', '90.00')])
self.assertFalse(auto_match(state)['matches'])
def test_decimal_combinations(self):
options = combinations([('first', Decimal('.10')), ('second', Decimal('.20'))], Decimal('.30'))
self.assertEqual(options, [['first', 'second']])
def test_original_categories(self):
for content, expected in [('海棠酒店 住宿 房费 客房', '住宿'), ('餐厅 美团外卖 咖啡 饮品', '餐饮'), ('办公用品 打印纸 墨盒 文具', '办公用品'), ('京东商城 采购家具设备', '采购'), ('客户招待 商务宴请', '招待')]:
self.assertEqual(category([material('i', 'invoice', '100.00', content)]), expected)
def test_summary_duplicate(self):
self.assertEqual(payment_total([material('order', 'payment', '100.00', '订单详情 支付总额 100.00'), material('proof', 'payment', '100.00', '支付成功 实付金额 100.00')]), Decimal('100.00'))
def test_invoice_total_labeled(self):
self.assertEqual(invoice_amount(material('i', 'invoice', ['200.00', '100.00'], '发票金额 100.00')), Decimal('100.00'))
def test_enrich_preserves_group_identity(self):
state = workspace([material('i', 'invoice', '100.00')], [material('p', 'payment', '100.00')])
enrich(auto_match(state))
self.assertTrue(state['matches'][0]['invoices'][0]['matched'])
self.assertEqual(state['matches'][0]['expenseAmount'], 100)
if __name__ == '__main__':
unittest.main()
+95
View File
@@ -0,0 +1,95 @@
import copy
import tempfile
import unittest
import zipfile
from pathlib import Path
from test_domain import material, workspace
from domain import auto_match, enrich
from exports import export_expense, export_ppt, export_travel
from PIL import Image
from openpyxl import load_workbook
from pptx import Presentation
class ExportTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
image = self.root / 'receipt.png'
Image.new('RGB', (400, 200), 'white').save(image)
self.state = enrich(auto_match(workspace([material('i', 'invoice', '100.00')], [material('p', 'payment', '100.00')])))
for item in self.state['invoices'] + self.state['payments']:
item['previewPath'] = str(image)
self.template = Path(__file__).parents[1] / 'personal-expense-template.xlsx'
def tearDown(self):
self.temporary.cleanup()
def test_simple_ppt_with_photo_placeholder(self):
destination = self.root / 'test.pptx'
export_ppt(self.state, destination, True)
deck = Presentation(destination)
self.assertEqual(len(deck.slides), 3)
self.assertEqual(deck.slide_width, 1280 * 12700)
self.assertTrue(any('实物照片' in shape.text for shape in deck.slides[2].shapes if shape.has_text_frame))
def test_travel_category_excludes_photo(self):
self.state['matches'][0]['category'] = '交通'
destination = self.root / 'test.pptx'
export_ppt(self.state, destination, False)
self.assertEqual(len(Presentation(destination).slides), 2)
def test_complex_payment_pagination(self):
self.state['matches'][0]['payments'] *= 5
destination = self.root / 'test.pptx'
export_ppt(self.state, destination, True)
self.assertEqual(len(Presentation(destination).slides), 5)
def test_expense_template_values_and_formulas(self):
destination = self.root / 'expense.xlsx'
match = self.state['matches'][0]
export_expense(self.state, destination, self.template, {match['id']: '=不是公式'}, ['经办人'])
workbook = load_workbook(destination)
self.assertEqual(workbook.sheetnames, ['个人报销单'])
sheet = workbook.active
self.assertEqual(sheet['C9'].value, '=不是公式')
self.assertEqual(sheet['C9'].data_type, 's')
self.assertEqual(sheet['H9'].value, 100)
self.assertEqual(sheet['G9'].value, 1)
self.assertEqual(sheet['A30'].value, '经办人:')
self.assertEqual(sheet['D30'].value, '')
original = load_workbook(self.template)['1个人报销单']
formulas = {cell.coordinate: cell.value for row in original for cell in row if cell.data_type == 'f'}
self.assertTrue(formulas)
for coordinate, value in formulas.items():
self.assertEqual(sheet[coordinate].value, value)
self.assertEqual(str(sheet.print_area).split('!')[-1], str(original.print_area).split('!')[-1])
def test_expense_pagination(self):
self.state['matches'] = [copy.deepcopy(self.state['matches'][0]) for _ in range(14)]
destination = self.root / 'expense.xlsx'
export_expense(self.state, destination, self.template, {}, [])
workbook = load_workbook(destination)
self.assertEqual(workbook.sheetnames, ['个人报销单-1', '个人报销单-2'])
self.assertEqual(workbook.worksheets[1]['H9'].value, 100)
self.assertEqual(workbook.worksheets[1]['H10'].value, '')
self.assertEqual(workbook.worksheets[1]['A30'].value, '部门长:')
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')
destination = self.root / 'travel.xlsx'
export_travel(self.state, destination)
sheet = load_workbook(destination).active
self.assertEqual(sheet.max_row, 2)
self.assertEqual(sheet['C2'].value, '张三')
self.assertEqual(sheet['G2'].value, 'G21')
def test_no_travel_raises(self):
with self.assertRaises(ValueError):
export_travel(self.state, self.root / 'travel.xlsx')
if __name__ == '__main__':
unittest.main()
+356
View File
@@ -0,0 +1,356 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXFileReference section */
973CFEFA305278FE00671FCA /* reimburse.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = reimburse.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
973CFEFC305278FE00671FCA /* reimburse */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = reimburse;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
973CFEF7305278FE00671FCA /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
973CFEF1305278FE00671FCA = {
isa = PBXGroup;
children = (
973CFEFC305278FE00671FCA /* reimburse */,
973CFEFB305278FE00671FCA /* Products */,
);
sourceTree = "<group>";
};
973CFEFB305278FE00671FCA /* Products */ = {
isa = PBXGroup;
children = (
973CFEFA305278FE00671FCA /* reimburse.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
973CFEF9305278FE00671FCA /* reimburse */ = {
isa = PBXNativeTarget;
buildConfigurationList = 973CFF05305278FF00671FCA /* Build configuration list for PBXNativeTarget "reimburse" */;
buildPhases = (
973CFEF6305278FE00671FCA /* Sources */,
973CFEF7305278FE00671FCA /* Frameworks */,
973CFEF8305278FE00671FCA /* Resources */,
AD18B14504DF4518BA480001 /* Embed Local Engine */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
973CFEFC305278FE00671FCA /* reimburse */,
);
name = reimburse;
packageProductDependencies = (
);
productName = reimburse;
productReference = 973CFEFA305278FE00671FCA /* reimburse.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
973CFEF2305278FE00671FCA /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2630;
LastUpgradeCheck = 2630;
TargetAttributes = {
973CFEF9305278FE00671FCA = {
CreatedOnToolsVersion = 26.3;
};
};
};
buildConfigurationList = 973CFEF5305278FE00671FCA /* Build configuration list for PBXProject "reimburse" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 973CFEF1305278FE00671FCA;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 973CFEFB305278FE00671FCA /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
973CFEF9305278FE00671FCA /* reimburse */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
973CFEF8305278FE00671FCA /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
AD18B14504DF4518BA480001 /* Embed Local Engine */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Embed Local Engine";
outputPaths = (
"$(TARGET_BUILD_DIR)/$(CONTENTS_FOLDER_PATH)/Helpers/receipt-engine-helper.app",
);
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";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
973CFEF6305278FE00671FCA /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
973CFF03305278FF00671FCA /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 15.7;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
973CFF04305278FF00671FCA /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 15.7;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
973CFF06305278FF00671FCA /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
ENABLE_USER_SELECTED_FILES = readwrite;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "贴票台";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = test.reimburse;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
973CFF07305278FF00671FCA /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
ENABLE_USER_SELECTED_FILES = readwrite;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "贴票台";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = test.reimburse;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
973CFEF5305278FE00671FCA /* Build configuration list for PBXProject "reimburse" */ = {
isa = XCConfigurationList;
buildConfigurations = (
973CFF03305278FF00671FCA /* Debug */,
973CFF04305278FF00671FCA /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
973CFF05305278FF00671FCA /* Build configuration list for PBXNativeTarget "reimburse" */ = {
isa = XCConfigurationList;
buildConfigurations = (
973CFF06305278FF00671FCA /* Debug */,
973CFF07305278FF00671FCA /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 973CFEF2305278FE00671FCA /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>reimburse.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,68 @@
{
"images": [
{
"idiom": "mac",
"size": "16x16",
"scale": "1x",
"filename": "icon-16@1x.png"
},
{
"idiom": "mac",
"size": "16x16",
"scale": "2x",
"filename": "icon-16@2x.png"
},
{
"idiom": "mac",
"size": "32x32",
"scale": "1x",
"filename": "icon-32@1x.png"
},
{
"idiom": "mac",
"size": "32x32",
"scale": "2x",
"filename": "icon-32@2x.png"
},
{
"idiom": "mac",
"size": "128x128",
"scale": "1x",
"filename": "icon-128@1x.png"
},
{
"idiom": "mac",
"size": "128x128",
"scale": "2x",
"filename": "icon-128@2x.png"
},
{
"idiom": "mac",
"size": "256x256",
"scale": "1x",
"filename": "icon-256@1x.png"
},
{
"idiom": "mac",
"size": "256x256",
"scale": "2x",
"filename": "icon-256@2x.png"
},
{
"idiom": "mac",
"size": "512x512",
"scale": "1x",
"filename": "icon-512@1x.png"
},
{
"idiom": "mac",
"size": "512x512",
"scale": "2x",
"filename": "icon-512@2x.png"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 776 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+131
View File
@@ -0,0 +1,131 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject var store: WorkspaceStore
@State private var confirmClear = false
var body: some View {
NavigationSplitView {
VStack(alignment: .leading, spacing: 24) {
HStack(spacing: 12) {
Image(systemName: "wallet.bifold.fill")
.font(.title2).foregroundStyle(.white)
.frame(width: 42, height: 42).background(.teal.gradient, in: RoundedRectangle(cornerRadius: 12))
VStack(alignment: .leading, spacing: 3) {
Text("贴票台").font(.title2.bold())
Text("本地报账工作台").font(.caption).foregroundStyle(.secondary)
}
}.padding(.horizontal, 16).padding(.top, 24)
List(WorkspacePage.allCases, selection: $store.page) { page in
Label {
HStack {
Text(page.rawValue)
Spacer()
if page == .pair { count(store.state.unmatchedInvoices.count + store.state.unmatchedPayments.count) }
if page == .matched { count(store.state.matches.count) }
}
} icon: { Image(systemName: page.symbol) }
.padding(.vertical, 7).tag(page)
}.listStyle(.sidebar)
VStack(alignment: .leading, spacing: 10) {
Label("RapidOCR · 本地识别", systemImage: "lock.shield")
Text("材料仅在本机处理\n工作区自动保存在此 Mac")
.font(.caption).foregroundStyle(.secondary)
}.font(.caption.weight(.medium)).padding(20)
}.navigationSplitViewColumnWidth(min: 210, ideal: 230, max: 270)
} detail: {
VStack(spacing: 0) {
topbar
Divider()
if store.busy {
VStack(alignment: .leading, spacing: 9) {
HStack {
ProgressView().controlSize(.small)
Text(store.status).font(.callout)
Spacer()
Button("取消", action: store.cancel).buttonStyle(.borderless)
}
ProgressView(value: store.progress).tint(.teal)
}.padding().background(.teal.opacity(0.06))
}
if let notice = store.notice {
HStack {
Label(notice, systemImage: "checkmark.circle.fill").foregroundStyle(.teal)
Spacer()
Button { store.notice = nil } label: { Image(systemName: "xmark") }.buttonStyle(.plain)
}.font(.callout).padding(12).background(.teal.opacity(0.06))
}
Group {
switch store.page ?? .scan {
case .scan: ScanPage(confirmClear: $confirmClear)
case .pair: PairPage()
case .matched: MatchedPage()
}
}.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor))
}
}
.tint(.teal)
.frame(minWidth: 1050, minHeight: 700)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: store.chooseFolder) { Label("导入文件夹", systemImage: "folder.badge.plus") }
.disabled(store.busy).keyboardShortcut("o")
}
}
.alert("处理未完成", isPresented: Binding(get: { store.errorMessage != nil }, set: { if !$0 { store.errorMessage = nil } })) {
Button("知道了") { store.errorMessage = nil }
} message: { Text(store.errorMessage ?? "") }
.confirmationDialog("清空当前工作区?", isPresented: $confirmClear) {
Button("清空材料与核对结果", role: .destructive, action: store.clear)
Button("取消", role: .cancel) {}
} message: { Text("当前工作区的副本和核对结果将被移除,您选择的原始文件夹不会被修改。") }
.sheet(item: $store.preview) { item in MaterialPreview(item: item) }
.sheet(isPresented: $store.showExpense) { ExpenseSheet() }
}
private var topbar: some View {
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 5) {
Text("RECEIPT DESK").font(.caption2.weight(.semibold)).tracking(2).foregroundStyle(.secondary)
Text((store.page ?? .scan).rawValue).font(.title.bold())
}
Spacer()
VStack(alignment: .trailing, spacing: 5) {
Text("\(store.state.invoices.count) 张发票 · \(store.state.payments.count) 张付款 · \(store.state.matches.count) 组已核对")
.font(.caption).foregroundStyle(.secondary)
HStack(spacing: 18) {
Text("发票 \(currency(store.state.invoiceTotal))")
Text("付款 \(currency(store.state.paymentTotal))").foregroundStyle(.teal)
}.font(.callout.weight(.semibold)).monospacedDigit()
}
}.padding(.horizontal, 28).padding(.vertical, 20)
}
private func count(_ number: Int) -> some View {
Text("\(number)").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
.padding(.horizontal, 7).padding(.vertical, 2).background(.quaternary, in: Capsule())
}
}
struct Panel<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
content.padding(20).frame(maxWidth: .infinity, alignment: .leading)
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 16))
.overlay(RoundedRectangle(cornerRadius: 16).stroke(.primary.opacity(0.06)))
}
}
struct EmptyPanel: View {
var symbol: String
var title: String
var message: String
var body: some View {
VStack(spacing: 16) {
Image(systemName: symbol).font(.system(size: 42, weight: .light)).foregroundStyle(.teal)
Text(title).font(.title3.bold())
Text(message).font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
}.frame(maxWidth: .infinity, maxHeight: .infinity).padding(40)
}
}
+123
View File
@@ -0,0 +1,123 @@
import Foundation
enum EngineFailure: LocalizedError {
case message(String)
var errorDescription: String? {
switch self { case .message(let message): message }
}
}
final class EngineBridge: @unchecked Sendable {
private let lock = NSLock()
private var process: Process?
private var cancelled = false
func cancel() {
lock.lock()
cancelled = true
if let process, process.isRunning { process.terminate() }
lock.unlock()
}
func run(request: Data, progress: @escaping @Sendable (Double, String) -> Void) async throws -> Data {
prepare()
return try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
do {
continuation.resume(returning: try self.execute(request: request, progress: progress))
} catch {
continuation.resume(throwing: error)
}
}
}
}
private func prepare() {
lock.lock()
cancelled = false
lock.unlock()
}
private func execute(request: Data, progress: @escaping @Sendable (Double, String) -> Void) throws -> Data {
let executable = Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/receipt-engine-helper.app/Contents/MacOS/receipt-engine-helper")
guard FileManager.default.isExecutableFile(atPath: executable.path) else {
throw EngineFailure.message("应用缺少本地处理引擎,请先运行 native-engine/build-engine.sh 后重新构建应用。")
}
let worker = Process()
let input = Pipe()
let output = Pipe()
let errorURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".log")
FileManager.default.createFile(atPath: errorURL.path, contents: nil)
let errorHandle = try FileHandle(forWritingTo: errorURL)
defer {
try? errorHandle.close()
try? FileManager.default.removeItem(at: errorURL)
}
worker.executableURL = executable
worker.standardInput = input
worker.standardOutput = output
worker.standardError = errorHandle
var environment = ProcessInfo.processInfo.environment
environment["PYTHONUTF8"] = "1"
environment["PYTHONUNBUFFERED"] = "1"
environment["OMP_NUM_THREADS"] = "2"
worker.environment = environment
lock.lock()
if cancelled {
lock.unlock()
throw CancellationError()
}
do {
try worker.run()
process = worker
} catch {
lock.unlock()
throw error
}
lock.unlock()
defer {
lock.lock()
process = nil
lock.unlock()
if worker.isRunning { worker.terminate() }
}
let deadline = DispatchWorkItem { if worker.isRunning { worker.terminate() } }
DispatchQueue.global().asyncAfter(deadline: .now() + 3600, execute: deadline)
defer { deadline.cancel() }
DispatchQueue.global().async {
try? input.fileHandleForWriting.write(contentsOf: request + Data([10]))
try? input.fileHandleForWriting.close()
}
var buffer = Data()
var result: Data?
var failure: String?
while true {
let chunk = output.fileHandleForReading.availableData
if chunk.isEmpty { break }
buffer.append(chunk)
while let newline = buffer.firstIndex(of: 10) {
let line = buffer.prefix(upTo: newline)
buffer.removeSubrange(...newline)
guard let object = try JSONSerialization.jsonObject(with: line) as? [String: Any] else { continue }
switch object["event"] as? String {
case "progress": progress(object["progress"] as? Double ?? 0, object["message"] as? String ?? "处理中")
case "result":
if let value = object["result"] { result = try JSONSerialization.data(withJSONObject: value) }
case "error": failure = object["message"] as? String
default: break
}
}
}
worker.waitUntilExit()
lock.lock()
let wasCancelled = cancelled
lock.unlock()
if wasCancelled { throw CancellationError() }
if let failure { throw EngineFailure.message(failure) }
guard worker.terminationStatus == 0, let result else {
let detail = (try? String(contentsOf: errorURL, encoding: .utf8)) ?? ""
throw EngineFailure.message("本地引擎未完成处理。\n" + String(detail.suffix(1800)))
}
return result
}
}
+138
View File
@@ -0,0 +1,138 @@
import SwiftUI
struct MatchedPage: View {
@EnvironmentObject var store: WorkspaceStore
var body: some View {
VStack(spacing: 18) {
HStack {
Picker("排版", selection: $store.classified) {
Text("按类型分类").tag(true)
Text("不分类").tag(false)
}.pickerStyle(.segmented).frame(width: 200)
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.export(.ppt) } label: { Label("导出 PPT", systemImage: "square.and.arrow.up") }
.buttonStyle(.borderedProminent).disabled(store.state.matches.isEmpty)
}
if store.state.matches.isEmpty {
EmptyPanel(symbol: "checkmark.seal", title: "还没有已核对材料", message: "扫描目录或完成人工配对后,结果会出现在这里。")
} else {
HStack {
Text("按发票开票日期排列 · 自动匹配结果也计入已核对").font(.caption).foregroundStyle(.secondary)
Spacer()
Text("\(store.state.matches.count)").font(.caption.monospacedDigit())
}
ScrollView {
LazyVStack(spacing: 18) {
ForEach(Array(store.state.sortedMatches.enumerated()), id: \.element.id) { index, match in
MatchRow(match: match, index: index + 1)
}
}.padding(2)
}
}
}.padding(28).disabled(store.busy)
}
}
struct MatchRow: View {
@EnvironmentObject var store: WorkspaceStore
let match: MatchGroup
let index: Int
var body: some View {
Panel {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text(String(format: "%02d", index)).font(.title2.monospacedDigit().bold()).foregroundStyle(.teal)
VStack(alignment: .leading, spacing: 4) {
Text("\(match.invoices.count) 张发票 · \(match.payments.count) 张付款").font(.headline)
Text("发票 \(currency(match.invoiceTotal)) / 付款 \(currency(match.paymentTotal))").font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text(match.matchType == "auto" ? "自动 \(match.score)%" : "人工确认")
.font(.caption.weight(.medium)).padding(.horizontal, 10).padding(.vertical, 5)
.background((match.matchType == "auto" ? Color.teal : Color.blue).opacity(0.1), in: Capsule())
if store.classified {
Picker("分类", selection: Binding(get: { match.category }, set: { store.updateCategory(match, category: $0) })) {
ForEach(expenseCategories, id: \.self) { Text($0) }
}.labelsHidden().frame(width: 110)
}
Button { store.undo(match) } label: { Image(systemName: "arrow.uturn.backward") }.help("撤销本组配对")
}
HStack(alignment: .top, spacing: 18) {
thumbnails(match.invoices.sorted { $0.issueDate < $1.issueDate })
Image(systemName: "link").foregroundStyle(.teal).padding(.top, 50)
thumbnails(match.payments.sorted { $0.sortDate < $1.sortDate })
}
Text(match.reasons.joined(separator: " · ")).font(.caption).foregroundStyle(.secondary).textSelection(.enabled)
}
}
}
private func thumbnails(_ items: [Material]) -> some View {
ScrollView(.horizontal) {
HStack(alignment: .top, spacing: 12) {
ForEach(items) { item in
Button { store.preview = item } label: {
VStack(alignment: .leading, spacing: 6) {
MaterialThumbnail(item: item, height: 115)
Text(item.name).font(.caption).lineLimit(2).frame(height: 32, alignment: .topLeading)
}.frame(width: 155)
}.buttonStyle(.plain).help("点击放大 \(item.name)")
}
}
}.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct ExpenseSheet: View {
@EnvironmentObject var store: WorkspaceStore
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
VStack(alignment: .leading, spacing: 6) {
Text("个人报销单").font(.title2.bold())
Text("使用原报销模板 · 每页 13 条明细 · 金额按已核对发票计算").font(.caption).foregroundStyle(.secondary)
}
Spacer()
}
ScrollView {
VStack(spacing: 12) {
ForEach(Array(store.state.matches.enumerated()), id: \.element.id) { index, match 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 }))
}.padding(10).background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8))
}
}
}
Divider()
HStack {
Text("签字岗位").font(.headline)
Text("最多 6 项;全部删除时沿用原模板默认岗位").font(.caption).foregroundStyle(.secondary)
Spacer()
Button { store.signatures.append("") } label: { Label("添加", systemImage: "plus") }.disabled(store.signatures.count >= 6)
}
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
ForEach(store.signatures.indices, id: \.self) { index in
HStack {
TextField("岗位名称", text: Binding(get: { store.signatures.indices.contains(index) ? store.signatures[index] : "" }, set: { if store.signatures.indices.contains(index) { store.signatures[index] = $0 } }))
Button { store.signatures.remove(at: index) } label: { Image(systemName: "minus.circle") }.buttonStyle(.borderless)
}
}
}
Divider()
HStack {
Text("保留模板中的公司、项目、收款信息和计算公式。").font(.caption).foregroundStyle(.secondary)
Spacer()
Button("取消") { dismiss() }.keyboardShortcut(.cancelAction)
Button("生成报销单") { store.export(.expense) }.buttonStyle(.borderedProminent)
}
}.padding(26).frame(width: 870, height: 620).textFieldStyle(.roundedBorder).disabled(store.busy)
.interactiveDismissDisabled(store.busy)
}
}
+120
View File
@@ -0,0 +1,120 @@
import SwiftUI
import ImageIO
struct MaterialThumbnail: View {
let item: Material
var height: CGFloat = 155
@State private var image: NSImage?
var body: some View {
ZStack {
Color.white
if let image {
Image(nsImage: image).resizable().scaledToFit().padding(6)
} else {
Image(systemName: "doc.richtext").font(.largeTitle).foregroundStyle(.gray)
}
}.frame(height: height).clipShape(RoundedRectangle(cornerRadius: 9))
.task(id: item.previewURL) {
let url = item.previewURL
image = await Task.detached(priority: .utility) {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
let bitmap = CGImageSourceCreateThumbnailAtIndex(source, 0, [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: 700,
kCGImageSourceCreateThumbnailWithTransform: true
] as CFDictionary) else { return NSImage(contentsOf: url) }
return NSImage(cgImage: bitmap, size: .zero)
}.value
}
}
}
struct MaterialCard: View {
@EnvironmentObject var store: WorkspaceStore
let item: Material
var selected = false
var selectable = true
var body: some View {
VStack(alignment: .leading, spacing: 9) {
MaterialThumbnail(item: item)
.overlay(alignment: .topTrailing) {
Button { store.preview = item } label: {
Image(systemName: "arrow.up.left.and.arrow.down.right").padding(7)
}.buttonStyle(.plain).background(.regularMaterial, in: Circle()).padding(7).help("放大预览")
}
HStack(alignment: .top) {
Text(item.name).font(.callout.weight(.medium)).lineLimit(2).help(item.name)
Spacer(minLength: 2)
if selected { Image(systemName: "checkmark.circle.fill").foregroundStyle(.teal) }
}
Text(item.amountLabel).font(.headline).monospacedDigit().foregroundStyle(.teal)
Text(item.ocr.merchants.first ?? "未识别商户").font(.caption).foregroundStyle(.secondary).lineLimit(1)
HStack {
Text(item.dateLabel)
Spacer()
if item.ocr.status == "failed" { Label("识别失败", systemImage: "exclamationmark.triangle").foregroundStyle(.orange) }
}.font(.caption2).foregroundStyle(.secondary)
}.padding(11)
.background(selected ? Color.teal.opacity(0.08) : Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 13))
.overlay(RoundedRectangle(cornerRadius: 13).stroke(selected ? .teal : .primary.opacity(0.08), lineWidth: selected ? 2 : 1))
.contentShape(Rectangle())
.focusable(selectable)
.onKeyPress(.return) {
if selectable { store.toggle(item) } else { store.preview = item }
return .handled
}
.onTapGesture { if selectable { store.toggle(item) } else { store.preview = item } }
.accessibilityElement(children: .contain)
.accessibilityLabel("\(item.name)\(item.amountLabel)\(selected ? "已选择" : "未选择")")
.accessibilityAction { if selectable { store.toggle(item) } else { store.preview = item } }
}
}
struct MaterialPreview: View {
let item: Material
@Environment(\.dismiss) private var dismiss
@State private var zoom: CGFloat = 1
@State private var showText = false
var body: some View {
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(item.name).font(.headline).lineLimit(1)
Text(item.amountLabel + " · " + item.dateLabel).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Toggle("识别文本", isOn: $showText).toggleStyle(.button)
Button { zoom = max(0.5, zoom - 0.25) } label: { Image(systemName: "minus.magnifyingglass") }
Text("\(Int(zoom * 100))%").font(.caption.monospacedDigit()).frame(width: 44)
Button { zoom = min(4, zoom + 0.25) } label: { Image(systemName: "plus.magnifyingglass") }
Button("完成") { dismiss() }.keyboardShortcut(.cancelAction)
}.padding(18)
Divider()
HStack(spacing: 0) {
GeometryReader { geometry in
ScrollView([.horizontal, .vertical]) {
if let image = NSImage(contentsOf: item.previewURL) {
Image(nsImage: image).resizable().scaledToFit()
.frame(width: max(1, geometry.size.width - 30) * zoom, height: max(1, geometry.size.height - 30) * zoom)
.padding(15)
} else {
EmptyPanel(symbol: "exclamationmark.triangle", title: "无法预览此文件", message: item.ocr.message)
}
}
}.background(.black.opacity(0.04))
if showText {
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 15) {
Text("OCR 原始文本").font(.headline)
if !item.ocr.message.isEmpty { Text(item.ocr.message).foregroundStyle(.orange) }
Text(item.ocr.rawText.isEmpty ? "没有识别到文本" : item.ocr.rawText).font(.callout).textSelection(.enabled)
}.padding(18).frame(maxWidth: .infinity, alignment: .leading)
}.frame(width: 280)
}
}
}.frame(width: 1000, height: 700)
}
}
+111
View File
@@ -0,0 +1,111 @@
import Foundation
let expenseCategories = ["餐饮", "住宿", "交通", "办公用品", "招待", "采购", "其他"]
let defaultSignatures = ["部门长", "剧组出纳", "制片主任", "剧组会计", "执行制片人", "制片人"]
struct TravelInfo: Codable, Hashable {
var type = ""
var travelerName = ""
var departure = ""
var destination = ""
var departureTime = ""
var transportNumber = ""
}
struct OCRData: Codable, Hashable {
var status = "pending"
var rawText = ""
var lines: [String] = []
var amounts: [String] = []
var dates: [String] = []
var merchants: [String] = []
var orderNumbers: [String] = []
var travel = TravelInfo()
var message = ""
}
struct Material: Codable, Identifiable, Hashable {
var id: String
var name: String
var path: String
var type: String
var size: Int64
var matched: Bool
var previewPath: String
var ocr: OCRData
var displayAmount: Double
var sortDate: String
var issueDate: String
var previewURL: URL { URL(fileURLWithPath: previewPath.isEmpty ? path : previewPath) }
var amountLabel: String { ocr.amounts.first.map { "¥\($0)" } ?? "未识别金额" }
var dateLabel: String { ocr.dates.first ?? "未识别日期" }
}
struct MatchGroup: Codable, Identifiable {
var id = UUID().uuidString
var invoices: [Material]
var payments: [Material]
var category: String
var matchType: String
var score: Int
var reasons: [String]
var paymentTotal: Double = 0
var expenseAmount: Double = 0
var date: String { invoices.map(\.issueDate).min() ?? "9999-12-31" }
var invoiceTotal: Double { invoices.reduce(0) { $0 + $1.displayAmount } }
}
struct Workspace: Codable {
var rootPath = ""
var invoices: [Material] = []
var payments: [Material] = []
var photos: [Material] = []
var matches: [MatchGroup] = []
var warnings: [String] = []
var directoryPaymentTotal: Double = 0
var unmatchedInvoices: [Material] { invoices.filter { !$0.matched }.sorted { $0.sortDate < $1.sortDate } }
var unmatchedPayments: [Material] { payments.filter { !$0.matched }.sorted { $0.sortDate < $1.sortDate } }
var sortedMatches: [MatchGroup] { matches.enumerated().sorted { left, right in
left.element.date == right.element.date ? left.offset < right.offset : left.element.date < right.element.date
}.map(\.element) }
var invoiceTotal: Double { matches.reduce(0) { $0 + $1.invoiceTotal } }
var paymentTotal: Double { matches.reduce(0) { $0 + $1.paymentTotal } }
var directoryInvoiceTotal: Double { invoices.reduce(0) { $0 + $1.displayAmount } }
var travelCount: Int { matches.flatMap(\.invoices).filter { !$0.ocr.travel.type.isEmpty }.count }
var autoCount: Int { matches.filter { $0.matchType == "auto" }.count }
var manualCount: Int { matches.filter { $0.matchType == "manual" }.count }
var recognizedCount: Int { (invoices + payments).filter { $0.ocr.status == "success" }.count }
}
func currency(_ value: Double) -> String {
value.formatted(.currency(code: "CNY").locale(Locale(identifier: "zh_CN")))
}
enum WorkspacePage: String, CaseIterable, Identifiable {
case scan = "材料扫描"
case pair = "人工配对"
case matched = "已核对"
var id: String { rawValue }
var symbol: String {
switch self {
case .scan: "doc.viewfinder"
case .pair: "point.3.connected.trianglepath.dotted"
case .matched: "checkmark.seal"
}
}
}
enum ExportKind: String {
case ppt, travel, expense
var fileExtension: String { self == .ppt ? "pptx" : "xlsx" }
var title: String {
switch self {
case .ppt: "报账贴票"
case .travel: "火车航班行程"
case .expense: "个人报销单"
}
}
}
+78
View File
@@ -0,0 +1,78 @@
#if DEBUG
import AppKit
import Foundation
@MainActor
enum NativeDiagnostics {
static func run() async {
let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("ReceiptDesk/diagnostics", isDirectory: true)
var report: [String: String] = [:]
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let root = directory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let input = root.appendingPathComponent("input", isDirectory: true)
for folder in ["发票", "付款截图"] {
let destination = input.appendingPathComponent(folder, isDirectory: true)
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
let image = NSImage(size: NSSize(width: 1000, height: 650))
image.lockFocus()
NSColor.white.setFill()
NSRect(x: 0, y: 0, width: 1000, height: 650).fill()
let lines = [folder == "发票" ? "电子发票" : "支付成功", "海棠餐厅", "金额 ¥100.00", "2026年08月05日", "订单号 TEST12345678"]
for (index, line) in lines.enumerated() {
(line as NSString).draw(at: NSPoint(x: 55, y: 520 - index * 85), withAttributes: [.font: NSFont.systemFont(ofSize: 42), .foregroundColor: NSColor.black])
}
image.unlockFocus()
guard let tiff = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff), let png = bitmap.representation(using: .png, properties: [:]) else {
throw EngineFailure.message("无法生成测试材料")
}
if folder == "发票" {
var box = CGRect(x: 0, y: 0, width: 1000, height: 650)
guard let consumer = CGDataConsumer(url: destination.appendingPathComponent("测试.pdf") as CFURL),
let context = CGContext(consumer: consumer, mediaBox: &box, nil), let bitmapImage = bitmap.cgImage else {
throw EngineFailure.message("无法生成 PDF 测试材料")
}
context.beginPDFPage(nil)
context.draw(bitmapImage, in: box)
context.endPDFPage()
context.beginPDFPage(nil)
context.endPDFPage()
context.closePDF()
} else {
try png.write(to: destination.appendingPathComponent("测试.png"))
}
}
let bridge = EngineBridge()
let request: [String: Any] = ["operation": "scan", "path": input.path, "workspacePath": root.appendingPathComponent("materials").path]
let data = try await bridge.run(request: JSONSerialization.data(withJSONObject: request)) { _, _ in }
var state = try JSONDecoder().decode(Workspace.self, from: data)
guard state.matches.count == 1, state.invoiceTotal == 100, state.paymentTotal == 100, state.warnings.count == 1 else {
throw EngineFailure.message("沙盒识别结果异常:\(state.matches.count) 组 / \(state.invoiceTotal) 元 / \(state.warnings)")
}
report["sandboxOCR"] = "passed"
report["pdfFirstPage"] = "passed"
state.invoices[0].ocr.travel = TravelInfo(type: "train", travelerName: "张三", departure: "北京南", destination: "上海虹桥", departureTime: "2026-08-05 09:30", transportNumber: "G101")
state.matches[0].invoices[0] = state.invoices[0]
for kind in [ExportKind.ppt, .expense, .travel] {
let destination = root.appendingPathComponent("sandbox-test." + (kind == .travel ? "travel.xlsx" : kind.fileExtension))
let stateObject = try JSONSerialization.jsonObject(with: JSONEncoder().encode(state))
let exportRequest: [String: Any] = ["operation": kind.rawValue, "state": stateObject, "destination": destination.path, "classified": true, "purposes": [:], "signatures": ["经办人", "财务"]]
_ = try await bridge.run(request: JSONSerialization.data(withJSONObject: exportRequest)) { _, _ in }
guard (try Data(contentsOf: destination)).count > 1000 else { throw EngineFailure.message("导出文件为空") }
report[kind.rawValue] = "passed"
}
try JSONEncoder().encode(state).write(to: root.appendingPathComponent("state.json"))
report["result"] = "passed"
report["directory"] = root.path
} catch {
report["result"] = "failed"
report["error"] = error.localizedDescription
}
if let data = try? JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]) {
try? data.write(to: directory.appendingPathComponent("report.json"), options: .atomic)
}
NSApplication.shared.terminate(nil)
}
}
#endif
+79
View File
@@ -0,0 +1,79 @@
import SwiftUI
struct MaterialAnchorKey: PreferenceKey {
static var defaultValue: [String: Anchor<CGRect>] = [:]
static func reduce(value: inout [String: Anchor<CGRect>], nextValue: () -> [String: Anchor<CGRect>]) {
value.merge(nextValue(), uniquingKeysWith: { _, next in next })
}
}
struct PairPage: View {
@EnvironmentObject var store: WorkspaceStore
var body: some View {
VStack(alignment: .leading, spacing: 18) {
HStack {
VStack(alignment: .leading, spacing: 6) {
Text("人工连连对").font(.title3.bold())
Text("左右均可多选,确认后作为一个核对组处理。人工确认不限制金额相等。")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Picker("分类", selection: $store.category) {
ForEach(expenseCategories, id: \.self) { Text($0) }
}.frame(width: 160)
Button("确认配对", action: store.confirmPair).buttonStyle(.borderedProminent).disabled(!store.canPair)
}
HStack {
Label("已选 \(store.selectedInvoices.count) 张发票", systemImage: "doc.text")
Image(systemName: "link").foregroundStyle(.teal)
Label("已选 \(store.selectedPayments.count) 张付款", systemImage: "creditcard")
Spacer()
if !store.selectedInvoices.isEmpty || !store.selectedPayments.isEmpty {
Button("取消选择") { store.selectedInvoices = []; store.selectedPayments = [] }.buttonStyle(.borderless)
}
}.font(.callout).foregroundStyle(.secondary)
HStack(alignment: .top, spacing: 40) {
column(title: "待配对发票", items: store.state.unmatchedInvoices, selected: store.selectedInvoices)
column(title: "待配对付款", items: store.state.unmatchedPayments, selected: store.selectedPayments)
}
.overlayPreferenceValue(MaterialAnchorKey.self) { anchors in
GeometryReader { geometry in
Canvas { context, _ in
for invoice in store.selectedInvoices {
for payment in store.selectedPayments {
if let left = anchors[invoice], let right = anchors[payment] {
let start = CGPoint(x: geometry[left].maxX, y: geometry[left].midY)
let end = CGPoint(x: geometry[right].minX, y: geometry[right].midY)
var line = Path()
line.move(to: start)
let bend = max(30, (end.x - start.x) * 0.42)
line.addCurve(to: end, control1: CGPoint(x: start.x + bend, y: start.y), control2: CGPoint(x: end.x - bend, y: end.y))
context.stroke(line, with: .color(.teal.opacity(0.6)), style: StrokeStyle(lineWidth: 2, dash: [6, 4]))
}
}
}
}
}.allowsHitTesting(false).clipped()
}
}.padding(28).disabled(store.busy)
}
private func column(title: String, items: [Material], selected: Set<String>) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack { Text(title).font(.headline); Spacer(); Text("\(items.count)").foregroundStyle(.secondary) }
if items.isEmpty {
EmptyPanel(symbol: "checkmark.circle", title: "材料已全部处理", message: "导入材料或撤销核对组后,会显示在这里。")
} else {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 180), spacing: 12)], spacing: 12) {
ForEach(items) { item in
MaterialCard(item: item, selected: selected.contains(item.id))
.anchorPreference(key: MaterialAnchorKey.self, value: .bounds) { selected.contains(item.id) ? [item.id: $0] : [:] }
}
}.padding(3)
}
}
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
+99
View File
@@ -0,0 +1,99 @@
import SwiftUI
struct ScanPage: View {
@EnvironmentObject var store: WorkspaceStore
@Binding var confirmClear: Bool
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 22) {
Panel {
HStack(spacing: 20) {
Image(systemName: "folder.fill").font(.system(size: 44)).foregroundStyle(.teal.gradient)
VStack(alignment: .leading, spacing: 7) {
Text(store.state.rootPath.isEmpty ? "从一个材料文件夹开始" : store.state.rootPath).font(.title3.bold())
Text("文件夹内请分别放置:发票 / 付款截图 / 实物照片")
.font(.callout).foregroundStyle(.secondary)
Text("支持图片和 PDF · PDF 处理首页 · 再次导入将替换当前工作区")
.font(.caption).foregroundStyle(.tertiary)
}
Spacer()
Button("选择文件夹", action: store.chooseFolder).buttonStyle(.borderedProminent).controlSize(.large).disabled(store.busy)
}
}
if !store.state.warnings.isEmpty {
Panel {
VStack(alignment: .leading, spacing: 7) {
Label("需要留意", systemImage: "exclamationmark.triangle").foregroundStyle(.orange).font(.headline)
ForEach(store.state.warnings, id: \.self) { Text($0).font(.caption).foregroundStyle(.secondary) }
}
}
}
HStack(spacing: 16) {
summary("发票", symbol: "doc.text", count: store.state.invoices.count, subtitle: "\(store.state.unmatchedInvoices.count) 份待配对")
summary("付款截图", symbol: "creditcard", count: store.state.payments.count, subtitle: "\(store.state.unmatchedPayments.count) 份待配对")
summary("实物照片", symbol: "photo.on.rectangle", count: store.state.photos.count, subtitle: "供 PPT 手工贴入")
}
Text("金额概览").font(.headline)
HStack(alignment: .top, spacing: 16) {
amountPanel("本次导入材料", subtitle: "包含已核对和待核对材料", invoice: store.state.directoryInvoiceTotal, payment: store.state.directoryPaymentTotal)
amountPanel("已核对材料", subtitle: "自动匹配与人工确认均计入", invoice: store.state.invoiceTotal, payment: store.state.paymentTotal)
}
if !store.state.matches.isEmpty {
Panel {
VStack(alignment: .leading, spacing: 16) {
Text("已核对付款 · 分类汇总").font(.headline)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 130))], alignment: .leading, spacing: 18) {
ForEach(expenseCategories, id: \.self) { category in
let total = store.state.matches.filter { $0.category == category }.reduce(0) { $0 + $1.paymentTotal }
if total > 0 {
VStack(alignment: .leading, spacing: 7) {
Text(category).font(.caption).foregroundStyle(.secondary)
Text(currency(total)).font(.headline).monospacedDigit()
}
}
}
}
}
}
}
HStack(spacing: 24) {
Label("自动核对 \(store.state.autoCount)", systemImage: "sparkles")
Label("人工核对 \(store.state.manualCount)", systemImage: "hand.draw")
Label("识别完成 \(store.state.recognizedCount)", systemImage: "text.viewfinder")
Spacer()
Button("前往人工配对") { store.page = .pair }.disabled(store.busy)
}.font(.callout).foregroundStyle(.secondary)
Divider()
HStack {
Text("工作区自动保存在本机,不修改原始材料。").font(.caption).foregroundStyle(.secondary)
Spacer()
Button("清空重置", role: .destructive) { confirmClear = true }.disabled(store.busy)
}
}.padding(28)
}
}
private func summary(_ title: String, symbol: String, count: Int, subtitle: String) -> some View {
Panel {
VStack(alignment: .leading, spacing: 12) {
Label(title, systemImage: symbol).font(.callout).foregroundStyle(.teal)
Text("\(count)").font(.system(size: 34, weight: .semibold, design: .rounded))
Text(subtitle).font(.caption).foregroundStyle(.secondary)
}
}
}
private func amountPanel(_ title: String, subtitle: String, invoice: Double, payment: Double) -> some View {
Panel {
VStack(alignment: .leading, spacing: 17) {
VStack(alignment: .leading, spacing: 5) {
Text(title).font(.headline)
Text(subtitle).font(.caption).foregroundStyle(.secondary)
}
HStack { Text("发票总额").foregroundStyle(.secondary); Spacer(); Text(currency(invoice)).fontWeight(.semibold) }
HStack { Text("付款总额").foregroundStyle(.secondary); Spacer(); Text(currency(payment)).fontWeight(.semibold).foregroundStyle(.teal) }
}.monospacedDigit()
}
}
}
+257
View File
@@ -0,0 +1,257 @@
import AppKit
import Combine
import UniformTypeIdentifiers
@MainActor
final class WorkspaceStore: ObservableObject {
@Published var state = Workspace()
@Published var page: WorkspacePage? = .scan
@Published var selectedInvoices: Set<String> = []
@Published var selectedPayments: Set<String> = []
@Published var category = "其他"
@Published var classified = true
@Published var busy = false
@Published var progress: Double = 0
@Published var status = ""
@Published var errorMessage: String?
@Published var notice: String?
@Published var preview: Material?
@Published var showExpense = false
@Published var purposes: [String: String] = [:]
@Published var signatures = defaultSignatures
private let engine = EngineBridge()
private let storage: URL
private var cancelRequested = false
init() {
storage = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("ReceiptDesk", isDirectory: true)
do {
try FileManager.default.createDirectory(at: storage, withIntermediateDirectories: true)
let session = storage.appendingPathComponent("workspace.json")
if FileManager.default.fileExists(atPath: session.path) {
state = try JSONDecoder().decode(Workspace.self, from: Data(contentsOf: session))
}
} catch {
errorMessage = "无法恢复上次工作区:\(error.localizedDescription)"
}
}
var canPair: Bool { !busy && !selectedInvoices.isEmpty && !selectedPayments.isEmpty }
func chooseFolder() {
guard !busy else { return }
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
panel.message = "选择包含“发票”“付款截图”“实物照片”的报账材料根目录"
panel.prompt = "导入材料"
guard panel.runModal() == .OK, let source = panel.url else { return }
let scoped = source.startAccessingSecurityScopedResource()
busy = true
cancelRequested = false
progress = 0
status = "正在复制材料到本地工作区"
notice = nil
let importRoot = storage.appendingPathComponent(UUID().uuidString, isDirectory: true)
let staging = importRoot.appendingPathComponent("input", isDirectory: true)
let output = importRoot.appendingPathComponent("materials", isDirectory: true)
Task {
defer {
if scoped { source.stopAccessingSecurityScopedResource() }
busy = false
try? FileManager.default.removeItem(at: staging)
}
do {
try await Task.detached(priority: .userInitiated) {
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
let supported = Set(["jpg", "jpeg", "png", "webp", "bmp", "gif", "pdf"])
guard let files = FileManager.default.enumerator(at: source, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], options: [.skipsHiddenFiles]) else {
throw EngineFailure.message("无法读取所选文件夹")
}
while let file = files.nextObject() as? URL {
let attributes = try file.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
guard attributes.isRegularFile == true, attributes.isSymbolicLink != true, supported.contains(file.pathExtension.lowercased()) else { continue }
let relative = String(file.path.dropFirst(source.path.count + 1))
let components = relative.split(separator: "/").dropLast()
guard components.contains(where: { ["发票", "付款截图", "实物照片"].contains(String($0)) }) else { continue }
let target = staging.appendingPathComponent(relative)
try FileManager.default.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
try FileManager.default.copyItem(at: file, to: target)
}
}.value
if cancelRequested { throw CancellationError() }
let request: [String: Any] = ["operation": "scan", "path": staging.path, "workspacePath": output.path, "folderName": source.lastPathComponent]
let result = try await run(request)
let next = try JSONDecoder().decode(Workspace.self, from: result)
try persist(next)
let oldPaths = materialDirectories()
state = next
resetSelection()
oldPaths.forEach { try? FileManager.default.removeItem(at: $0) }
progress = 1
notice = "扫描完成,自动核对 \(next.autoCount) 组材料"
if !next.unmatchedInvoices.isEmpty || !next.unmatchedPayments.isEmpty { page = .pair }
} catch is CancellationError {
notice = "已取消扫描,原工作区保持不变"
try? FileManager.default.removeItem(at: importRoot)
} catch {
errorMessage = error.localizedDescription
try? FileManager.default.removeItem(at: importRoot)
}
}
}
func cancel() {
cancelRequested = true
engine.cancel()
}
func toggle(_ item: Material) {
guard !busy else { return }
if item.type == "invoice" {
if selectedInvoices.contains(item.id) { selectedInvoices.remove(item.id) } else { selectedInvoices.insert(item.id) }
} else {
if selectedPayments.contains(item.id) { selectedPayments.remove(item.id) } else { selectedPayments.insert(item.id) }
}
}
func confirmPair() {
guard canPair else { return }
var next = state
let invoices = state.invoices.filter { selectedInvoices.contains($0.id) && !$0.matched }
let payments = state.payments.filter { selectedPayments.contains($0.id) && !$0.matched }
guard invoices.count == selectedInvoices.count, payments.count == selectedPayments.count else {
errorMessage = "所选材料已发生变化,请重新选择"
return
}
let identifiers = selectedInvoices.union(selectedPayments)
for index in next.invoices.indices where identifiers.contains(next.invoices[index].id) { next.invoices[index].matched = true }
for index in next.payments.indices where identifiers.contains(next.payments[index].id) { next.payments[index].matched = true }
next.matches.append(MatchGroup(invoices: invoices, payments: payments, category: category, matchType: "manual", score: 100, reasons: ["人工确认"]))
busy = true
status = "正在确认配对"
Task {
defer { busy = false }
do {
let result = try await run(["operation": "refresh", "state": try jsonObject(next)])
let updated = try JSONDecoder().decode(Workspace.self, from: result)
try persist(updated)
state = updated
resetSelection()
notice = "配对已确认,材料已移入已核对"
} catch { errorMessage = error.localizedDescription }
}
}
func updateCategory(_ match: MatchGroup, category: String) {
guard !busy, let index = state.matches.firstIndex(where: { $0.id == match.id }) else { return }
var next = state
next.matches[index].category = category
commit(next)
}
func undo(_ match: MatchGroup) {
guard !busy else { return }
var next = state
let identifiers = Set((match.invoices + match.payments).map(\.id))
next.matches.removeAll { $0.id == match.id }
for index in next.invoices.indices where identifiers.contains(next.invoices[index].id) { next.invoices[index].matched = false }
for index in next.payments.indices where identifiers.contains(next.payments[index].id) { next.payments[index].matched = false }
if commit(next) {
page = .pair
notice = "已撤销配对,材料回到待匹配区"
}
}
func clear() {
guard !busy else { return }
let directories = materialDirectories()
if commit(Workspace()) {
resetSelection()
preview = nil
showExpense = false
purposes = [:]
directories.forEach { try? FileManager.default.removeItem(at: $0) }
notice = "工作区已清空,原始材料未删除"
}
}
func openExpense() {
purposes = Dictionary(uniqueKeysWithValues: state.matches.map { ($0.id, $0.category == "其他" ? "" : $0.category + "费用") })
showExpense = true
}
func export(_ kind: ExportKind) {
guard !busy, !state.matches.isEmpty else { return }
let panel = NSSavePanel()
panel.allowedContentTypes = [UTType(filenameExtension: kind.fileExtension) ?? .data]
panel.canCreateDirectories = true
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmm"
panel.nameFieldStringValue = "\(kind.title)-\(formatter.string(from: Date())).\(kind.fileExtension)"
guard panel.runModal() == .OK, let destination = panel.url else { return }
let scoped = destination.startAccessingSecurityScopedResource()
let temporary = storage.appendingPathComponent(UUID().uuidString + "." + kind.fileExtension)
busy = true
status = "正在生成\(kind.title)"
progress = 0
Task {
defer {
busy = false
if scoped { destination.stopAccessingSecurityScopedResource() }
try? FileManager.default.removeItem(at: temporary)
}
do {
_ = try await run(["operation": kind.rawValue, "state": try jsonObject(state), "destination": temporary.path, "classified": classified, "purposes": purposes, "signatures": signatures])
try Data(contentsOf: temporary).write(to: destination, options: .atomic)
if kind == .expense { showExpense = false }
notice = "\(kind.title)已导出到 \(destination.lastPathComponent)"
NSWorkspace.shared.activateFileViewerSelecting([destination])
} catch is CancellationError {
notice = "已取消导出"
} catch { errorMessage = error.localizedDescription }
}
}
private func run(_ request: [String: Any]) async throws -> Data {
let data = try JSONSerialization.data(withJSONObject: request)
return try await engine.run(request: data) { [self] value, message in
Task { @MainActor in
self.progress = value
self.status = message
}
}
}
private func jsonObject(_ value: Workspace) throws -> Any {
try JSONSerialization.jsonObject(with: JSONEncoder().encode(value))
}
private func persist(_ value: Workspace) throws {
try JSONEncoder().encode(value).write(to: storage.appendingPathComponent("workspace.json"), options: .atomic)
}
@discardableResult private func commit(_ value: Workspace) -> Bool {
do {
try persist(value)
state = value
return true
} catch {
errorMessage = "无法保存工作区:\(error.localizedDescription)"
return false
}
}
private func materialDirectories() -> Set<URL> {
Set((state.invoices + state.payments + state.photos).compactMap { item in
let directory = URL(fileURLWithPath: item.path).deletingLastPathComponent().deletingLastPathComponent()
return directory.deletingLastPathComponent().standardizedFileURL == storage.standardizedFileURL ? directory : nil
})
}
private func resetSelection() {
selectedInvoices = []
selectedPayments = []
}
}
+34
View File
@@ -0,0 +1,34 @@
//
// reimburseApp.swift
// reimburse
//
// Created by on 2026/9/10.
//
import SwiftUI
@main
struct reimburseApp: App {
@StateObject private var store = WorkspaceStore()
var body: some Scene {
Window("贴票台", id: "workspace") {
ContentView()
.environmentObject(store)
.task {
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--verify-local-engine") {
await NativeDiagnostics.run()
}
#endif
}
}
.defaultSize(width: 1280, height: 860)
.commands {
CommandGroup(replacing: .newItem) {
Button("导入报账材料…", action: store.chooseFolder)
.keyboardShortcut("o").disabled(store.busy)
}
}
}
}