commit bbacd1d6f352a134072f9b164c0ecdad31024343 Author: 于晓婷 Date: Fri Sep 11 12:58:09 2026 +0800 Initial commit: macOS receipt workspace diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3624348 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..635653e --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# 贴票台 · macOS 原生版 + +SwiftUI 原生报账材料工作台。无需浏览器、Java、Spring Boot、HTTP 服务或用户单独安装 Python。RapidOCR 模型、解释器及 Office 导出依赖随应用内的处理引擎打包,扫描和导出仅通过本地子进程的标准输入/输出交换数据,不监听网络端口。 + +## 开发环境 + +- Xcode 26.3,macOS 15.7 或更高版本。 +- 当前构建为 Apple Silicon(arm64);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 库、辅助程序和主应用按由内到外顺序签名,再进行公证。请同时随发布包保留第三方依赖许可声明。 diff --git a/native-engine/build-engine.sh b/native-engine/build-engine.sh new file mode 100644 index 0000000..f831e94 --- /dev/null +++ b/native-engine/build-engine.sh @@ -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。" diff --git a/native-engine/domain.py b/native-engine/domain.py new file mode 100644 index 0000000..e35037e --- /dev/null +++ b/native-engine/domain.py @@ -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) + 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'(? 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 diff --git a/native-engine/engine.py b/native-engine/engine.py new file mode 100644 index 0000000..a98335a --- /dev/null +++ b/native-engine/engine.py @@ -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) diff --git a/native-engine/exports.py b/native-engine/exports.py new file mode 100644 index 0000000..547eeaf --- /dev/null +++ b/native-engine/exports.py @@ -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) diff --git a/native-engine/helper.entitlements b/native-engine/helper.entitlements new file mode 100644 index 0000000..430a5cc --- /dev/null +++ b/native-engine/helper.entitlements @@ -0,0 +1,6 @@ + + + + com.apple.security.app-sandbox + com.apple.security.inherit + diff --git a/native-engine/make_icon.py b/native-engine/make_icon.py new file mode 100644 index 0000000..16bf63b --- /dev/null +++ b/native-engine/make_icon.py @@ -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) diff --git a/native-engine/personal-expense-template.xlsx b/native-engine/personal-expense-template.xlsx new file mode 100644 index 0000000..adaa1f7 Binary files /dev/null and b/native-engine/personal-expense-template.xlsx differ diff --git a/native-engine/requirements.lock.txt b/native-engine/requirements.lock.txt new file mode 100644 index 0000000..6745b62 --- /dev/null +++ b/native-engine/requirements.lock.txt @@ -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 diff --git a/native-engine/requirements.txt b/native-engine/requirements.txt new file mode 100644 index 0000000..de2cf1c --- /dev/null +++ b/native-engine/requirements.txt @@ -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 diff --git a/native-engine/tests/smoke_engine.py b/native-engine/tests/smoke_engine.py new file mode 100644 index 0000000..5e3eaa2 --- /dev/null +++ b/native-engine/tests/smoke_engine.py @@ -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() diff --git a/native-engine/tests/test_domain.py b/native-engine/tests/test_domain.py new file mode 100644 index 0000000..4440e80 --- /dev/null +++ b/native-engine/tests/test_domain.py @@ -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() diff --git a/native-engine/tests/test_exports.py b/native-engine/tests/test_exports.py new file mode 100644 index 0000000..69d6277 --- /dev/null +++ b/native-engine/tests/test_exports.py @@ -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() diff --git a/reimburse.xcodeproj/project.pbxproj b/reimburse.xcodeproj/project.pbxproj new file mode 100644 index 0000000..2395362 --- /dev/null +++ b/reimburse.xcodeproj/project.pbxproj @@ -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 = ""; + }; +/* 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 = ""; + }; + 973CFEFB305278FE00671FCA /* Products */ = { + isa = PBXGroup; + children = ( + 973CFEFA305278FE00671FCA /* reimburse.app */, + ); + name = Products; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/reimburse.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/reimburse.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/reimburse.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/reimburse.xcodeproj/xcuserdata/yuxiaoting.xcuserdatad/xcschemes/xcschememanagement.plist b/reimburse.xcodeproj/xcuserdata/yuxiaoting.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..9b9f5ab --- /dev/null +++ b/reimburse.xcodeproj/xcuserdata/yuxiaoting.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + reimburse.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/reimburse/Assets.xcassets/AccentColor.colorset/Contents.json b/reimburse/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/reimburse/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/reimburse/Assets.xcassets/AppIcon.appiconset/Contents.json b/reimburse/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..e6368b5 --- /dev/null +++ b/reimburse/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 + } +} diff --git a/reimburse/Assets.xcassets/AppIcon.appiconset/icon-128@1x.png b/reimburse/Assets.xcassets/AppIcon.appiconset/icon-128@1x.png new file mode 100644 index 0000000..fa770d5 Binary files /dev/null and b/reimburse/Assets.xcassets/AppIcon.appiconset/icon-128@1x.png differ diff --git a/reimburse/Assets.xcassets/AppIcon.appiconset/icon-128@2x.png b/reimburse/Assets.xcassets/AppIcon.appiconset/icon-128@2x.png new file mode 100644 index 0000000..3632781 Binary files /dev/null and b/reimburse/Assets.xcassets/AppIcon.appiconset/icon-128@2x.png differ diff --git a/reimburse/Assets.xcassets/AppIcon.appiconset/icon-16@1x.png b/reimburse/Assets.xcassets/AppIcon.appiconset/icon-16@1x.png new file mode 100644 index 0000000..0bba926 Binary files /dev/null and b/reimburse/Assets.xcassets/AppIcon.appiconset/icon-16@1x.png differ diff --git a/reimburse/Assets.xcassets/AppIcon.appiconset/icon-16@2x.png b/reimburse/Assets.xcassets/AppIcon.appiconset/icon-16@2x.png new file mode 100644 index 0000000..8227da9 Binary files /dev/null and b/reimburse/Assets.xcassets/AppIcon.appiconset/icon-16@2x.png differ diff --git a/reimburse/Assets.xcassets/AppIcon.appiconset/icon-256@1x.png b/reimburse/Assets.xcassets/AppIcon.appiconset/icon-256@1x.png new file mode 100644 index 0000000..3632781 Binary files /dev/null and b/reimburse/Assets.xcassets/AppIcon.appiconset/icon-256@1x.png differ diff --git a/reimburse/Assets.xcassets/AppIcon.appiconset/icon-256@2x.png b/reimburse/Assets.xcassets/AppIcon.appiconset/icon-256@2x.png new file mode 100644 index 0000000..7562535 Binary files /dev/null and b/reimburse/Assets.xcassets/AppIcon.appiconset/icon-256@2x.png differ diff --git a/reimburse/Assets.xcassets/AppIcon.appiconset/icon-32@1x.png b/reimburse/Assets.xcassets/AppIcon.appiconset/icon-32@1x.png new file mode 100644 index 0000000..8227da9 Binary files /dev/null and b/reimburse/Assets.xcassets/AppIcon.appiconset/icon-32@1x.png differ diff --git a/reimburse/Assets.xcassets/AppIcon.appiconset/icon-32@2x.png b/reimburse/Assets.xcassets/AppIcon.appiconset/icon-32@2x.png new file mode 100644 index 0000000..7d3f60b Binary files /dev/null and b/reimburse/Assets.xcassets/AppIcon.appiconset/icon-32@2x.png differ diff --git a/reimburse/Assets.xcassets/AppIcon.appiconset/icon-512@1x.png b/reimburse/Assets.xcassets/AppIcon.appiconset/icon-512@1x.png new file mode 100644 index 0000000..7562535 Binary files /dev/null and b/reimburse/Assets.xcassets/AppIcon.appiconset/icon-512@1x.png differ diff --git a/reimburse/Assets.xcassets/AppIcon.appiconset/icon-512@2x.png b/reimburse/Assets.xcassets/AppIcon.appiconset/icon-512@2x.png new file mode 100644 index 0000000..e6c83bf Binary files /dev/null and b/reimburse/Assets.xcassets/AppIcon.appiconset/icon-512@2x.png differ diff --git a/reimburse/Assets.xcassets/Contents.json b/reimburse/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/reimburse/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/reimburse/ContentView.swift b/reimburse/ContentView.swift new file mode 100644 index 0000000..20da363 --- /dev/null +++ b/reimburse/ContentView.swift @@ -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: 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) + } +} diff --git a/reimburse/EngineBridge.swift b/reimburse/EngineBridge.swift new file mode 100644 index 0000000..62054c9 --- /dev/null +++ b/reimburse/EngineBridge.swift @@ -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 + } +} diff --git a/reimburse/MatchedPage.swift b/reimburse/MatchedPage.swift new file mode 100644 index 0000000..8656951 --- /dev/null +++ b/reimburse/MatchedPage.swift @@ -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) + } +} diff --git a/reimburse/MaterialViews.swift b/reimburse/MaterialViews.swift new file mode 100644 index 0000000..bc9cec2 --- /dev/null +++ b/reimburse/MaterialViews.swift @@ -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) + } +} diff --git a/reimburse/Models.swift b/reimburse/Models.swift new file mode 100644 index 0000000..b060c76 --- /dev/null +++ b/reimburse/Models.swift @@ -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: "个人报销单" + } + } +} diff --git a/reimburse/NativeDiagnostics.swift b/reimburse/NativeDiagnostics.swift new file mode 100644 index 0000000..4e9ff39 --- /dev/null +++ b/reimburse/NativeDiagnostics.swift @@ -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 diff --git a/reimburse/PairPage.swift b/reimburse/PairPage.swift new file mode 100644 index 0000000..60d5329 --- /dev/null +++ b/reimburse/PairPage.swift @@ -0,0 +1,79 @@ +import SwiftUI + +struct MaterialAnchorKey: PreferenceKey { + static var defaultValue: [String: Anchor] = [:] + static func reduce(value: inout [String: Anchor], nextValue: () -> [String: Anchor]) { + 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) -> 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) + } +} diff --git a/reimburse/ScanPage.swift b/reimburse/ScanPage.swift new file mode 100644 index 0000000..8b34ba8 --- /dev/null +++ b/reimburse/ScanPage.swift @@ -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() + } + } +} diff --git a/reimburse/WorkspaceStore.swift b/reimburse/WorkspaceStore.swift new file mode 100644 index 0000000..509b5d9 --- /dev/null +++ b/reimburse/WorkspaceStore.swift @@ -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 = [] + @Published var selectedPayments: Set = [] + @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 { + 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 = [] + } +} diff --git a/reimburse/reimburseApp.swift b/reimburse/reimburseApp.swift new file mode 100644 index 0000000..23399b0 --- /dev/null +++ b/reimburse/reimburseApp.swift @@ -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) + } + } + } +}