Initial commit: macOS receipt workspace
@@ -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
|
||||||
@@ -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 库、辅助程序和主应用按由内到外顺序签名,再进行公证。请同时随发布包保留第三方依赖许可声明。
|
||||||
@@ -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。"
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from collections import OrderedDict
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||||
|
|
||||||
|
CATEGORIES = ['餐饮', '住宿', '交通', '办公用品', '招待', '采购', '其他']
|
||||||
|
HAN = r'\u3400-\u9fff'
|
||||||
|
DATE = r'(20\d{2})[年./-](1[0-2]|0?[1-9])[月./-](3[01]|[12]\d|0?[1-9])日?'
|
||||||
|
AMOUNT_PATTERNS = [
|
||||||
|
r'(?:支付总额|订单总额|应付总额|总金额|总计|价税合计|小写|实付金额|支付金额|付款金额|订单金额|合计|金额)[^0-9]{0,10}([0-9]{1,8}(?:[.,][0-9]{1,2})?)',
|
||||||
|
r'(?:¥|¥|RMB|CNY)\s*([0-9]{1,8}(?:[.,][0-9]{1,2})?)',
|
||||||
|
r'(?<![0-9])([0-9]{1,7}[.,][0-9]{2})(?![0-9%])',
|
||||||
|
]
|
||||||
|
MERCHANT_LABEL = r'销售方名称|销方名称|收款方|收款单位|商户全称|商户名称|商户|付款给|收款人|对方户名|交易对象'
|
||||||
|
KEYWORDS = [
|
||||||
|
'餐饮 餐厅 饭店 酒楼 外卖 咖啡 奶茶 茶饮 饮品 火锅 烧烤 快餐 小吃 食品 美团外卖 饿了么 tims 星巴克',
|
||||||
|
'酒店 宾馆 旅馆 住宿 房费 客房 民宿 公寓 入住 退房',
|
||||||
|
'铁路 火车 12306 车次 机票 航空 航班 登机 滴滴 网约车 出租车 打车 货运 运输 加油站 汽油 柴油 停车 高速 地铁 公交',
|
||||||
|
'办公用品 办公 文具 打印 复印 纸张 墨盒 硒鼓 耗材 电脑 键盘 鼠标 显示器 软件 会员订阅 桌椅 文件夹 饮用水 桶装水',
|
||||||
|
'招待 接待 宴请 商务宴请 客户用餐 会议餐',
|
||||||
|
'采购 商品 材料 设备 配件 家具 日用品 百货 商城 京东 淘宝 天猫 超市 便利店 购物',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def unique(values):
|
||||||
|
return list(dict.fromkeys(values))
|
||||||
|
|
||||||
|
|
||||||
|
def money(value):
|
||||||
|
return Decimal(str(value).replace(',', '.')).quantize(Decimal('.01'), rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
|
||||||
|
def first(pattern, text):
|
||||||
|
match = re.search(pattern, text, re.I)
|
||||||
|
return match.group(1).strip() if match else ''
|
||||||
|
|
||||||
|
|
||||||
|
def extract(lines):
|
||||||
|
text = '\n'.join(lines)
|
||||||
|
amounts = unique(format(money(value), '.2f') for pattern in AMOUNT_PATTERNS
|
||||||
|
for value in re.findall(pattern, text, re.I) if money(value) >= 0)
|
||||||
|
dates = []
|
||||||
|
for match in re.finditer(DATE, text):
|
||||||
|
try:
|
||||||
|
dates.append(date(*map(int, match.groups())).isoformat())
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
merchants = re.findall(r'(?:' + MERCHANT_LABEL + r')[::]?\s*([^\n]{2,40})', text)
|
||||||
|
|
||||||
|
def clean(value):
|
||||||
|
return re.sub(r'[|::]+$', '', re.sub(r'^.*?(?:' + MERCHANT_LABEL.replace('|商户|', '|') + r')[::]?', '', value)).strip()
|
||||||
|
|
||||||
|
def valid(value):
|
||||||
|
return (2 <= len(value) <= 45 and re.search('[' + HAN + 'A-Za-z]', value)
|
||||||
|
and not re.search('支付成功|交易成功|电子发票|增值税发票|付款方式|订单详情|账单详情|交易详情|商品说明|开票日期|价税合计', value))
|
||||||
|
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
line = clean(line)
|
||||||
|
if re.search('销售方|销方|收款方|收款单位|商户|付款给|收款人|对方户名|交易对象', line) and index + 1 < len(lines):
|
||||||
|
following = clean(lines[index + 1])
|
||||||
|
if valid(following):
|
||||||
|
merchants.append(following)
|
||||||
|
if re.search('公司|餐厅|饭店|酒店|宾馆|旅馆|商店|店|超市|便利店|中心|服务部|经营部|商行|科技|网络|运输|航空|铁路|医院|药房|加油站|停车场|物业|平台', line) and valid(line):
|
||||||
|
merchants.append(line)
|
||||||
|
return dict(status='success', rawText=text, lines=lines, amounts=amounts,
|
||||||
|
dates=unique(dates), merchants=unique(value.strip().rstrip('::|') for value in merchants),
|
||||||
|
orderNumbers=unique(re.findall(r'(?:交易单号|商户单号|订单号|流水号|交易号)[::]?\s*([A-Za-z0-9-]{8,40})', text)),
|
||||||
|
travel=extract_travel(text, unique(dates)), message='')
|
||||||
|
|
||||||
|
|
||||||
|
def extract_travel(text, dates):
|
||||||
|
travel = dict(type='', travelerName='', departure='', destination='', departureTime='', transportNumber='')
|
||||||
|
train = re.search('火车票|铁路电子客票|铁路|车次|检票口|12306', text)
|
||||||
|
flight = re.search('航空|航班|登机牌|电子客票行程单|乘机|起飞|客票号', text)
|
||||||
|
if not train and not flight:
|
||||||
|
return travel
|
||||||
|
travel['type'] = 'flight' if flight and not train else 'train'
|
||||||
|
for pattern in [r'(?:旅客姓名|乘车人|乘机人|旅客|姓名)[::]?\s*([' + HAN + r'·]{2,15})',
|
||||||
|
r'(?:\d{6,}\*{2,}\d{2,})\s*([' + HAN + r'·]{2,8})']:
|
||||||
|
names = [name for name in re.findall(pattern, text) if len(name) <= 8 and not re.search('税务|日期|发票|铁路|车站|公司|名称|代码|号码', name)]
|
||||||
|
if names:
|
||||||
|
travel['travelerName'] = names[0]
|
||||||
|
break
|
||||||
|
travel['departure'] = first(r'(?:出发地|出发站|始发站|起点|起飞机场)[::]?\s*([' + HAN + r'A-Za-z0-9]{2,24})', text)
|
||||||
|
travel['destination'] = first(r'(?:目的地|到达站|终点站|终点|到达机场)[::]?\s*([' + HAN + r'A-Za-z0-9]{2,24})', text)
|
||||||
|
route = re.search(r'([' + HAN + r'A-Za-z]{2,20})(?:站|机场)?\s*(?:→|->|—|至)\s*([' + HAN + r'A-Za-z]{2,20})(?:站|机场)?', text)
|
||||||
|
if route:
|
||||||
|
for index, field in enumerate(['departure', 'destination'], 1):
|
||||||
|
travel[field] = travel[field] or re.sub(r'(站|机场)$', '', route.group(index))
|
||||||
|
if train:
|
||||||
|
stations = unique(re.findall(r'([' + HAN + r']{2,12})站', text))
|
||||||
|
if len(stations) >= 2:
|
||||||
|
travel['departure'] = travel['departure'] or stations[0]
|
||||||
|
travel['destination'] = travel['destination'] or stations[1]
|
||||||
|
clock_pattern = r'([01]?\d|2[0-3]):([0-5]\d)'
|
||||||
|
for match in re.finditer(DATE + r'[^\n]{0,24}?' + clock_pattern, text):
|
||||||
|
if not re.search('开票日期|发票日期', text[max(0, match.start() - 16):match.start()]):
|
||||||
|
year, month, day, hour, minute = map(int, match.groups())
|
||||||
|
travel['departureTime'] = f'{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}'
|
||||||
|
break
|
||||||
|
if not travel['departureTime']:
|
||||||
|
departure_date = ''
|
||||||
|
for match in re.finditer(DATE, text):
|
||||||
|
if not re.search('开票日期|发票日期', text[max(0, match.start() - 16):match.start()]):
|
||||||
|
year, month, day = map(int, match.groups())
|
||||||
|
departure_date = f'{year:04}-{month:02}-{day:02}'
|
||||||
|
break
|
||||||
|
clock = re.search(clock_pattern, text)
|
||||||
|
travel['departureTime'] = ((departure_date or next(iter(dates), '')) + (' %02d:%02d' % tuple(map(int, clock.groups())) if clock else '')).strip()
|
||||||
|
pattern = r'(?<![A-Z0-9])([GDCZTKSLY]\d{1,5})(?![A-Z0-9])' if train else r'(?<![A-Z0-9])([A-Z0-9]{2}\d{3,4})(?![A-Z0-9])'
|
||||||
|
travel['transportNumber'] = first(pattern, text)
|
||||||
|
return travel
|
||||||
|
|
||||||
|
|
||||||
|
def amounts(item):
|
||||||
|
return [money(value) for value in item['ocr']['amounts'] if money(value) > 0] if item['ocr']['status'] == 'success' else []
|
||||||
|
|
||||||
|
|
||||||
|
def primary(item):
|
||||||
|
values = item['ocr']['amounts']
|
||||||
|
return money(values[0]) if values and item['ocr']['status'] == 'success' else None
|
||||||
|
|
||||||
|
|
||||||
|
def text(item):
|
||||||
|
return item['ocr']['rawText']
|
||||||
|
|
||||||
|
|
||||||
|
def common(left, right, field):
|
||||||
|
return [value for value in left['ocr'][field] if value in right['ocr'][field]]
|
||||||
|
|
||||||
|
|
||||||
|
def date_distance(invoices, payments):
|
||||||
|
distances = [abs((date.fromisoformat(left) - date.fromisoformat(right)).days)
|
||||||
|
for invoice in invoices for payment in payments
|
||||||
|
for left in invoice['ocr']['dates'] for right in payment['ocr']['dates']]
|
||||||
|
return min(distances, default=999999)
|
||||||
|
|
||||||
|
|
||||||
|
def similarity(left, right):
|
||||||
|
def normalize(value):
|
||||||
|
return re.sub('[^' + HAN + 'a-z0-9]', '', re.sub('有限责任公司|股份有限公司|有限公司|公司|商户|门店|收款方', '', value.lower()))
|
||||||
|
def grams(value):
|
||||||
|
return {value[index:index + 2] for index in range(len(value) - 1)} if len(value) > 1 else {value}
|
||||||
|
left, right = normalize(left), normalize(right)
|
||||||
|
if not left or not right:
|
||||||
|
return 0
|
||||||
|
if left in right or right in left:
|
||||||
|
return 1
|
||||||
|
return len(grams(left) & grams(right)) / len(grams(left) | grams(right))
|
||||||
|
|
||||||
|
|
||||||
|
def merchant_similarity(invoice, payment):
|
||||||
|
return max((similarity(left, right) for left in invoice['ocr']['merchants'] for right in payment['ocr']['merchants']), default=0)
|
||||||
|
|
||||||
|
|
||||||
|
def category(items):
|
||||||
|
content = ' '.join(text(item) + ' ' + ' '.join(item['ocr']['merchants']) for item in items).lower()
|
||||||
|
scores = [sum(3 if len(word) >= 4 else 2 if len(word) >= 3 else 1 for word in words.split() if word in content) for words in KEYWORDS]
|
||||||
|
return CATEGORIES[scores.index(max(scores))] if max(scores) else '其他'
|
||||||
|
|
||||||
|
|
||||||
|
def add_match(state, invoices, payments, score, reasons):
|
||||||
|
selected_category = category(invoices + payments)
|
||||||
|
for item in invoices + payments:
|
||||||
|
item['matched'] = True
|
||||||
|
state['matches'].append(dict(id=str(uuid.uuid4()), invoices=invoices, payments=payments,
|
||||||
|
category=selected_category, matchType='auto', score=score,
|
||||||
|
reasons=reasons + ['自动分类:' + selected_category]))
|
||||||
|
|
||||||
|
|
||||||
|
def combinations(values, target):
|
||||||
|
sums = {Decimal('0.00'): [[]]}
|
||||||
|
for item, amount in values:
|
||||||
|
snapshot = [(subtotal, [list(group) for group in groups]) for subtotal, groups in sums.items()]
|
||||||
|
for subtotal, groups in snapshot:
|
||||||
|
total = subtotal + amount
|
||||||
|
if total > target:
|
||||||
|
continue
|
||||||
|
destination = sums.setdefault(total, [])
|
||||||
|
for group in list(groups):
|
||||||
|
candidate = group + [item]
|
||||||
|
if candidate not in destination:
|
||||||
|
destination.append(candidate)
|
||||||
|
del destination[2:]
|
||||||
|
return [group for group in sums.get(target, []) if len(group) >= 2][:2]
|
||||||
|
|
||||||
|
|
||||||
|
def railway(item):
|
||||||
|
return bool(re.search('12306|铁路|火车|车次|中铁', text(item)))
|
||||||
|
|
||||||
|
|
||||||
|
def total_label(item):
|
||||||
|
return bool(re.search('支付总额|订单总额|应付总额|总金额|总计|合计', text(item)))
|
||||||
|
|
||||||
|
|
||||||
|
def travel_key(item):
|
||||||
|
travel = item['ocr']['travel']
|
||||||
|
return tuple(travel[field].lower() for field in ['type', 'departure', 'destination', 'departureTime', 'transportNumber'])
|
||||||
|
|
||||||
|
|
||||||
|
def auto_match(state):
|
||||||
|
invoices, payments = state['invoices'], state['payments']
|
||||||
|
groups = OrderedDict()
|
||||||
|
for invoice in invoices:
|
||||||
|
if invoice['ocr']['travel']['type']:
|
||||||
|
groups.setdefault(travel_key(invoice), []).append(invoice)
|
||||||
|
for group in groups.values():
|
||||||
|
if len(group) < 2 or any(primary(item) is None for item in group):
|
||||||
|
continue
|
||||||
|
total = sum(primary(item) for item in group)
|
||||||
|
evidence = [item for item in payments if not item['matched'] and railway(item) and total in amounts(item)]
|
||||||
|
if evidence and any(re.search('支付成功|付款成功|交易成功|支付方式|交易单号|商户单号', text(item)) for item in evidence) and date_distance(group, evidence) <= 14:
|
||||||
|
reasons = [f'同一行程多人发票合计一致 ¥{total:.2f}', ' + '.join(str(primary(item)) for item in group)]
|
||||||
|
if len(evidence) >= 2:
|
||||||
|
reasons.append('付款凭证与订单截图已共同归入本次行程')
|
||||||
|
add_match(state, group, evidence, 100 if len(evidence) >= 2 else 95, reasons)
|
||||||
|
for payment in payments:
|
||||||
|
total = primary(payment)
|
||||||
|
if payment['matched'] or total is None:
|
||||||
|
continue
|
||||||
|
values = [(item, primary(item)) for item in invoices if not item['matched'] and primary(item) is not None and 0 < primary(item) < total]
|
||||||
|
options = combinations(values, total)
|
||||||
|
if len(options) != 1:
|
||||||
|
continue
|
||||||
|
group, evidence = options[0], [payment]
|
||||||
|
for candidate in payments:
|
||||||
|
if candidate is payment or candidate['matched']:
|
||||||
|
continue
|
||||||
|
same_total = primary(candidate) == total or (total in amounts(candidate) and total_label(candidate))
|
||||||
|
related = (common(payment, candidate, 'orderNumbers') or merchant_similarity(payment, candidate) >= .35
|
||||||
|
or (railway(payment) and railway(candidate) and (date_distance([payment], [candidate]) <= 7 or total_label(payment) or total_label(candidate))))
|
||||||
|
if same_total and related:
|
||||||
|
evidence.append(candidate)
|
||||||
|
score = 70
|
||||||
|
reasons = [f'多张发票合计一致 ¥{total:.2f}', ' + '.join(str(primary(item)) for item in group)]
|
||||||
|
for condition, reason in [(len(evidence) >= 2, '订单与付款凭证相互印证'),
|
||||||
|
(date_distance(group, evidence) <= 7, '行程与付款日期接近'),
|
||||||
|
(all(item['ocr']['travel']['type'] for item in group) and len({tuple(item['ocr']['travel'][field] for field in ['departure', 'destination', 'departureTime', 'transportNumber']) for item in group}) == 1, '车次、路线及行程日期一致')]:
|
||||||
|
if condition:
|
||||||
|
score += 10
|
||||||
|
reasons.append(reason)
|
||||||
|
if score >= 90:
|
||||||
|
add_match(state, group, evidence, score, reasons)
|
||||||
|
candidates = []
|
||||||
|
for invoice in invoices:
|
||||||
|
for payment in payments:
|
||||||
|
shared = common(invoice, payment, 'amounts')
|
||||||
|
if not shared or invoice['ocr']['status'] != 'success' or payment['ocr']['status'] != 'success':
|
||||||
|
continue
|
||||||
|
score, reasons = 55, ['金额一致 ¥' + shared[0]]
|
||||||
|
merchant = merchant_similarity(invoice, payment)
|
||||||
|
if merchant >= .35:
|
||||||
|
score += int(25 * merchant + .5)
|
||||||
|
reasons.append(f'商户相似 {int(merchant * 100 + .5)}%')
|
||||||
|
distance = date_distance([invoice], [payment])
|
||||||
|
if distance <= 7:
|
||||||
|
score += 15 if distance <= 3 else 8
|
||||||
|
reasons.append('日期一致' if distance == 0 else f'日期相差 {distance} 天')
|
||||||
|
if common(invoice, payment, 'orderNumbers'):
|
||||||
|
score += 20
|
||||||
|
reasons.append('交易单号一致')
|
||||||
|
for amount in shared:
|
||||||
|
if sum(amount in item['ocr']['amounts'] for item in invoices) == 1 and sum(amount in item['ocr']['amounts'] for item in payments) == 1:
|
||||||
|
score += 20
|
||||||
|
reasons.append(f'金额 ¥{amount} 在本批材料中唯一')
|
||||||
|
break
|
||||||
|
if score >= 70:
|
||||||
|
candidates.append((invoice, payment, min(100, score), reasons))
|
||||||
|
candidates.sort(key=lambda candidate: -candidate[2])
|
||||||
|
for candidate in candidates:
|
||||||
|
invoice, payment, score, reasons = candidate
|
||||||
|
if invoice['matched'] or payment['matched']:
|
||||||
|
continue
|
||||||
|
second = max((other[2] for other in candidates if other is not candidate and (other[0] is invoice or other[1] is payment)
|
||||||
|
and not other[0]['matched'] and not other[1]['matched']), default=0)
|
||||||
|
if score >= 90 or score - second >= 8:
|
||||||
|
add_match(state, [invoice], [payment], score, reasons)
|
||||||
|
for invoice in invoices:
|
||||||
|
if invoice['matched']:
|
||||||
|
continue
|
||||||
|
available = sorted([(item, primary(item)) for item in payments if not item['matched'] and primary(item) is not None and primary(item) > 0], key=lambda entry: entry[1])
|
||||||
|
options = []
|
||||||
|
for total in amounts(invoice):
|
||||||
|
options.extend((total, group) for group in combinations([(item, amount) for item, amount in available if amount < total], total))
|
||||||
|
if len(options) > 1:
|
||||||
|
break
|
||||||
|
if len(options) == 1:
|
||||||
|
total, evidence = options[0]
|
||||||
|
add_match(state, [invoice], evidence, 95, [f'多笔付款合计一致 ¥{total:.2f}', ' + '.join(str(primary(item)) for item in evidence)])
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def material_date(item, issue=False):
|
||||||
|
content = text(item)
|
||||||
|
if issue:
|
||||||
|
match = re.search(r'(?:申请开票(?:日期|时间)?|开票日期|发票开具日期|开具日期)\s*[::]?\s*' + DATE, content)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
return date(*map(int, match.groups())).isoformat()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if item['ocr']['dates']:
|
||||||
|
return item['ocr']['dates'][0]
|
||||||
|
match = re.search(r'(?:^|\D)(\d{1,2})月(\d{1,2})日', content)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
return date(date.today().year, *map(int, match.groups())).isoformat()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return '9999-12-31'
|
||||||
|
|
||||||
|
|
||||||
|
def invoice_amount(item):
|
||||||
|
content = text(item)
|
||||||
|
patterns = [r'价税合计[^\n]{0,35}?(?:小写)?[^0-9]{0,12}([0-9][0-9,]*(?:\.\d{1,2})?)',
|
||||||
|
r'(?:小写|票价|发票金额|合计金额|总金额|应付金额)\s*[((]?[人民币RMB]*[))]?\s*[::]?\s*[¥¥]?\s*([0-9][0-9,]*(?:\.\d{1,2})?)']
|
||||||
|
for pattern in patterns:
|
||||||
|
values = re.findall(pattern, content, re.I)
|
||||||
|
if values:
|
||||||
|
return Decimal(values[-1].replace(',', ''))
|
||||||
|
return max([Decimal(value) for value in item['ocr']['amounts']], default=Decimal(0))
|
||||||
|
|
||||||
|
|
||||||
|
def payment_amount(item):
|
||||||
|
for pattern in [r'(?:支付总额|实付金额|实际支付金额|付款金额|实付款|实付价|合计)\s*[::]?\s*[¥¥]?\s*([0-9][0-9,]*(?:\.\d{1,2})?)',
|
||||||
|
r'已优惠[^\n]{0,24}?[¥¥]\s*([0-9][0-9,]*(?:\.\d{1,2})?)']:
|
||||||
|
values = re.findall(pattern, text(item), re.I)
|
||||||
|
if values:
|
||||||
|
return abs(Decimal(values[-1].replace(',', '')))
|
||||||
|
return abs(primary(item) or Decimal(0))
|
||||||
|
|
||||||
|
|
||||||
|
def payment_total(items):
|
||||||
|
result = Decimal(0)
|
||||||
|
for index, item in enumerate(items):
|
||||||
|
amount = payment_amount(item)
|
||||||
|
duplicate = re.search('支付总额|查看订单支付明细|购票成功|订单详情', text(item)) and any(
|
||||||
|
other_index != index and re.search('支付成功|付款成功|交易成功|支付方式|付款方式|交易单号|商户单号|转账成功|收款方', text(other))
|
||||||
|
and abs(payment_amount(other) - amount) < Decimal('.005') for other_index, other in enumerate(items))
|
||||||
|
if not duplicate:
|
||||||
|
result += amount
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def enrich(state):
|
||||||
|
for item in state['invoices'] + state['payments'] + state['photos']:
|
||||||
|
item['displayAmount'] = float(invoice_amount(item) if item['type'] == 'invoice' else payment_amount(item))
|
||||||
|
item['sortDate'] = material_date(item)
|
||||||
|
item['issueDate'] = material_date(item, True)
|
||||||
|
lookup = {item['id']: item for item in state['invoices'] + state['payments']}
|
||||||
|
for match in state['matches']:
|
||||||
|
match['invoices'] = [lookup[item['id']] for item in match['invoices']]
|
||||||
|
match['payments'] = [lookup[item['id']] for item in match['payments']]
|
||||||
|
match['paymentTotal'] = float(payment_total(match['payments']))
|
||||||
|
match['expenseAmount'] = float(sum(max((Decimal(value) for value in item['ocr']['amounts']), default=Decimal(0)) for item in match['invoices']))
|
||||||
|
state['directoryPaymentTotal'] = float(payment_total(state['payments']))
|
||||||
|
return state
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0"><dict>
|
||||||
|
<key>com.apple.security.app-sandbox</key><true/>
|
||||||
|
<key>com.apple.security.inherit</key><true/>
|
||||||
|
</dict></plist>
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
973CFEFA305278FE00671FCA /* reimburse.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = reimburse.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
973CFEFC305278FE00671FCA /* reimburse */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = reimburse;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
973CFEF7305278FE00671FCA /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
973CFEF1305278FE00671FCA = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
973CFEFC305278FE00671FCA /* reimburse */,
|
||||||
|
973CFEFB305278FE00671FCA /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
973CFEFB305278FE00671FCA /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
973CFEFA305278FE00671FCA /* reimburse.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
973CFEF9305278FE00671FCA /* reimburse */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 973CFF05305278FF00671FCA /* Build configuration list for PBXNativeTarget "reimburse" */;
|
||||||
|
buildPhases = (
|
||||||
|
973CFEF6305278FE00671FCA /* Sources */,
|
||||||
|
973CFEF7305278FE00671FCA /* Frameworks */,
|
||||||
|
973CFEF8305278FE00671FCA /* Resources */,
|
||||||
|
AD18B14504DF4518BA480001 /* Embed Local Engine */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
973CFEFC305278FE00671FCA /* reimburse */,
|
||||||
|
);
|
||||||
|
name = reimburse;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = reimburse;
|
||||||
|
productReference = 973CFEFA305278FE00671FCA /* reimburse.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
973CFEF2305278FE00671FCA /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 2630;
|
||||||
|
LastUpgradeCheck = 2630;
|
||||||
|
TargetAttributes = {
|
||||||
|
973CFEF9305278FE00671FCA = {
|
||||||
|
CreatedOnToolsVersion = 26.3;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 973CFEF5305278FE00671FCA /* Build configuration list for PBXProject "reimburse" */;
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = 973CFEF1305278FE00671FCA;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
productRefGroup = 973CFEFB305278FE00671FCA /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
973CFEF9305278FE00671FCA /* reimburse */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
973CFEF8305278FE00671FCA /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
AD18B14504DF4518BA480001 /* Embed Local Engine */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "Embed Local Engine";
|
||||||
|
outputPaths = (
|
||||||
|
"$(TARGET_BUILD_DIR)/$(CONTENTS_FOLDER_PATH)/Helpers/receipt-engine-helper.app",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/bash;
|
||||||
|
shellScript = "set -euo pipefail\nSOURCE=\"$SRCROOT/native-engine/dist/receipt-engine-helper.app\"\nif [ ! -d \"$SOURCE\" ]; then\n echo 'error: 请先运行 bash native-engine/build-engine.sh 打包本地 OCR 引擎'\n exit 1\nfi\nDEST=\"$TARGET_BUILD_DIR/$CONTENTS_FOLDER_PATH/Helpers/receipt-engine-helper.app\"\nmkdir -p \"$(dirname \"$DEST\")\"\n/usr/bin/ditto \"$SOURCE\" \"$DEST\"\nIDENTITY=\"${EXPANDED_CODE_SIGN_IDENTITY:--}\"\nif [ -z \"$IDENTITY\" ]; then IDENTITY=-; fi\n/usr/bin/codesign --force --sign \"$IDENTITY\" --entitlements \"$SRCROOT/native-engine/helper.entitlements\" \"$DEST\"\n";
|
||||||
|
};
|
||||||
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
973CFEF6305278FE00671FCA /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
973CFF03305278FF00671FCA /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 15.7;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
973CFF04305278FF00671FCA /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 15.7;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
973CFF06305278FF00671FCA /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ARCHS = arm64;
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
ENABLE_APP_SANDBOX = YES;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
ENABLE_USER_SELECTED_FILES = readwrite;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "贴票台";
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = test.reimburse;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
973CFF07305278FF00671FCA /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ARCHS = arm64;
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
ENABLE_APP_SANDBOX = YES;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
ENABLE_USER_SELECTED_FILES = readwrite;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "贴票台";
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = test.reimburse;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
973CFEF5305278FE00671FCA /* Build configuration list for PBXProject "reimburse" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
973CFF03305278FF00671FCA /* Debug */,
|
||||||
|
973CFF04305278FF00671FCA /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
973CFF05305278FF00671FCA /* Build configuration list for PBXNativeTarget "reimburse" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
973CFF06305278FF00671FCA /* Debug */,
|
||||||
|
973CFF07305278FF00671FCA /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 973CFEF2305278FE00671FCA /* Project object */;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>SchemeUserState</key>
|
||||||
|
<dict>
|
||||||
|
<key>reimburse.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"size": "16x16",
|
||||||
|
"scale": "1x",
|
||||||
|
"filename": "icon-16@1x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"size": "16x16",
|
||||||
|
"scale": "2x",
|
||||||
|
"filename": "icon-16@2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"size": "32x32",
|
||||||
|
"scale": "1x",
|
||||||
|
"filename": "icon-32@1x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"size": "32x32",
|
||||||
|
"scale": "2x",
|
||||||
|
"filename": "icon-32@2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"size": "128x128",
|
||||||
|
"scale": "1x",
|
||||||
|
"filename": "icon-128@1x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"size": "128x128",
|
||||||
|
"scale": "2x",
|
||||||
|
"filename": "icon-128@2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"size": "256x256",
|
||||||
|
"scale": "1x",
|
||||||
|
"filename": "icon-256@1x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"size": "256x256",
|
||||||
|
"scale": "2x",
|
||||||
|
"filename": "icon-256@2x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"size": "512x512",
|
||||||
|
"scale": "1x",
|
||||||
|
"filename": "icon-512@1x.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"size": "512x512",
|
||||||
|
"scale": "2x",
|
||||||
|
"filename": "icon-512@2x.png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"author": "xcode",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 776 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@EnvironmentObject var store: WorkspaceStore
|
||||||
|
@State private var confirmClear = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationSplitView {
|
||||||
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "wallet.bifold.fill")
|
||||||
|
.font(.title2).foregroundStyle(.white)
|
||||||
|
.frame(width: 42, height: 42).background(.teal.gradient, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text("贴票台").font(.title2.bold())
|
||||||
|
Text("本地报账工作台").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}.padding(.horizontal, 16).padding(.top, 24)
|
||||||
|
List(WorkspacePage.allCases, selection: $store.page) { page in
|
||||||
|
Label {
|
||||||
|
HStack {
|
||||||
|
Text(page.rawValue)
|
||||||
|
Spacer()
|
||||||
|
if page == .pair { count(store.state.unmatchedInvoices.count + store.state.unmatchedPayments.count) }
|
||||||
|
if page == .matched { count(store.state.matches.count) }
|
||||||
|
}
|
||||||
|
} icon: { Image(systemName: page.symbol) }
|
||||||
|
.padding(.vertical, 7).tag(page)
|
||||||
|
}.listStyle(.sidebar)
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Label("RapidOCR · 本地识别", systemImage: "lock.shield")
|
||||||
|
Text("材料仅在本机处理\n工作区自动保存在此 Mac")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}.font(.caption.weight(.medium)).padding(20)
|
||||||
|
}.navigationSplitViewColumnWidth(min: 210, ideal: 230, max: 270)
|
||||||
|
} detail: {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
topbar
|
||||||
|
Divider()
|
||||||
|
if store.busy {
|
||||||
|
VStack(alignment: .leading, spacing: 9) {
|
||||||
|
HStack {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
Text(store.status).font(.callout)
|
||||||
|
Spacer()
|
||||||
|
Button("取消", action: store.cancel).buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
ProgressView(value: store.progress).tint(.teal)
|
||||||
|
}.padding().background(.teal.opacity(0.06))
|
||||||
|
}
|
||||||
|
if let notice = store.notice {
|
||||||
|
HStack {
|
||||||
|
Label(notice, systemImage: "checkmark.circle.fill").foregroundStyle(.teal)
|
||||||
|
Spacer()
|
||||||
|
Button { store.notice = nil } label: { Image(systemName: "xmark") }.buttonStyle(.plain)
|
||||||
|
}.font(.callout).padding(12).background(.teal.opacity(0.06))
|
||||||
|
}
|
||||||
|
Group {
|
||||||
|
switch store.page ?? .scan {
|
||||||
|
case .scan: ScanPage(confirmClear: $confirmClear)
|
||||||
|
case .pair: PairPage()
|
||||||
|
case .matched: MatchedPage()
|
||||||
|
}
|
||||||
|
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color(nsColor: .windowBackgroundColor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(.teal)
|
||||||
|
.frame(minWidth: 1050, minHeight: 700)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button(action: store.chooseFolder) { Label("导入文件夹", systemImage: "folder.badge.plus") }
|
||||||
|
.disabled(store.busy).keyboardShortcut("o")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("处理未完成", isPresented: Binding(get: { store.errorMessage != nil }, set: { if !$0 { store.errorMessage = nil } })) {
|
||||||
|
Button("知道了") { store.errorMessage = nil }
|
||||||
|
} message: { Text(store.errorMessage ?? "") }
|
||||||
|
.confirmationDialog("清空当前工作区?", isPresented: $confirmClear) {
|
||||||
|
Button("清空材料与核对结果", role: .destructive, action: store.clear)
|
||||||
|
Button("取消", role: .cancel) {}
|
||||||
|
} message: { Text("当前工作区的副本和核对结果将被移除,您选择的原始文件夹不会被修改。") }
|
||||||
|
.sheet(item: $store.preview) { item in MaterialPreview(item: item) }
|
||||||
|
.sheet(isPresented: $store.showExpense) { ExpenseSheet() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var topbar: some View {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
Text("RECEIPT DESK").font(.caption2.weight(.semibold)).tracking(2).foregroundStyle(.secondary)
|
||||||
|
Text((store.page ?? .scan).rawValue).font(.title.bold())
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(alignment: .trailing, spacing: 5) {
|
||||||
|
Text("\(store.state.invoices.count) 张发票 · \(store.state.payments.count) 张付款 · \(store.state.matches.count) 组已核对")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
HStack(spacing: 18) {
|
||||||
|
Text("发票 \(currency(store.state.invoiceTotal))")
|
||||||
|
Text("付款 \(currency(store.state.paymentTotal))").foregroundStyle(.teal)
|
||||||
|
}.font(.callout.weight(.semibold)).monospacedDigit()
|
||||||
|
}
|
||||||
|
}.padding(.horizontal, 28).padding(.vertical, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func count(_ number: Int) -> some View {
|
||||||
|
Text("\(number)").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 7).padding(.vertical, 2).background(.quaternary, in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Panel<Content: View>: View {
|
||||||
|
@ViewBuilder var content: Content
|
||||||
|
var body: some View {
|
||||||
|
content.padding(20).frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 16).stroke(.primary.opacity(0.06)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EmptyPanel: View {
|
||||||
|
var symbol: String
|
||||||
|
var title: String
|
||||||
|
var message: String
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: symbol).font(.system(size: 42, weight: .light)).foregroundStyle(.teal)
|
||||||
|
Text(title).font(.title3.bold())
|
||||||
|
Text(message).font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
|
||||||
|
}.frame(maxWidth: .infinity, maxHeight: .infinity).padding(40)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: "个人报销单"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MaterialAnchorKey: PreferenceKey {
|
||||||
|
static var defaultValue: [String: Anchor<CGRect>] = [:]
|
||||||
|
static func reduce(value: inout [String: Anchor<CGRect>], nextValue: () -> [String: Anchor<CGRect>]) {
|
||||||
|
value.merge(nextValue(), uniquingKeysWith: { _, next in next })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PairPage: View {
|
||||||
|
@EnvironmentObject var store: WorkspaceStore
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("人工连连对").font(.title3.bold())
|
||||||
|
Text("左右均可多选,确认后作为一个核对组处理。人工确认不限制金额相等。")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Picker("分类", selection: $store.category) {
|
||||||
|
ForEach(expenseCategories, id: \.self) { Text($0) }
|
||||||
|
}.frame(width: 160)
|
||||||
|
Button("确认配对", action: store.confirmPair).buttonStyle(.borderedProminent).disabled(!store.canPair)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Label("已选 \(store.selectedInvoices.count) 张发票", systemImage: "doc.text")
|
||||||
|
Image(systemName: "link").foregroundStyle(.teal)
|
||||||
|
Label("已选 \(store.selectedPayments.count) 张付款", systemImage: "creditcard")
|
||||||
|
Spacer()
|
||||||
|
if !store.selectedInvoices.isEmpty || !store.selectedPayments.isEmpty {
|
||||||
|
Button("取消选择") { store.selectedInvoices = []; store.selectedPayments = [] }.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}.font(.callout).foregroundStyle(.secondary)
|
||||||
|
HStack(alignment: .top, spacing: 40) {
|
||||||
|
column(title: "待配对发票", items: store.state.unmatchedInvoices, selected: store.selectedInvoices)
|
||||||
|
column(title: "待配对付款", items: store.state.unmatchedPayments, selected: store.selectedPayments)
|
||||||
|
}
|
||||||
|
.overlayPreferenceValue(MaterialAnchorKey.self) { anchors in
|
||||||
|
GeometryReader { geometry in
|
||||||
|
Canvas { context, _ in
|
||||||
|
for invoice in store.selectedInvoices {
|
||||||
|
for payment in store.selectedPayments {
|
||||||
|
if let left = anchors[invoice], let right = anchors[payment] {
|
||||||
|
let start = CGPoint(x: geometry[left].maxX, y: geometry[left].midY)
|
||||||
|
let end = CGPoint(x: geometry[right].minX, y: geometry[right].midY)
|
||||||
|
var line = Path()
|
||||||
|
line.move(to: start)
|
||||||
|
let bend = max(30, (end.x - start.x) * 0.42)
|
||||||
|
line.addCurve(to: end, control1: CGPoint(x: start.x + bend, y: start.y), control2: CGPoint(x: end.x - bend, y: end.y))
|
||||||
|
context.stroke(line, with: .color(.teal.opacity(0.6)), style: StrokeStyle(lineWidth: 2, dash: [6, 4]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.allowsHitTesting(false).clipped()
|
||||||
|
}
|
||||||
|
}.padding(28).disabled(store.busy)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func column(title: String, items: [Material], selected: Set<String>) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack { Text(title).font(.headline); Spacer(); Text("\(items.count)").foregroundStyle(.secondary) }
|
||||||
|
if items.isEmpty {
|
||||||
|
EmptyPanel(symbol: "checkmark.circle", title: "材料已全部处理", message: "导入材料或撤销核对组后,会显示在这里。")
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 180), spacing: 12)], spacing: 12) {
|
||||||
|
ForEach(items) { item in
|
||||||
|
MaterialCard(item: item, selected: selected.contains(item.id))
|
||||||
|
.anchorPreference(key: MaterialAnchorKey.self, value: .bounds) { selected.contains(item.id) ? [item.id: $0] : [:] }
|
||||||
|
}
|
||||||
|
}.padding(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import AppKit
|
||||||
|
import Combine
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class WorkspaceStore: ObservableObject {
|
||||||
|
@Published var state = Workspace()
|
||||||
|
@Published var page: WorkspacePage? = .scan
|
||||||
|
@Published var selectedInvoices: Set<String> = []
|
||||||
|
@Published var selectedPayments: Set<String> = []
|
||||||
|
@Published var category = "其他"
|
||||||
|
@Published var classified = true
|
||||||
|
@Published var busy = false
|
||||||
|
@Published var progress: Double = 0
|
||||||
|
@Published var status = ""
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
@Published var notice: String?
|
||||||
|
@Published var preview: Material?
|
||||||
|
@Published var showExpense = false
|
||||||
|
@Published var purposes: [String: String] = [:]
|
||||||
|
@Published var signatures = defaultSignatures
|
||||||
|
private let engine = EngineBridge()
|
||||||
|
private let storage: URL
|
||||||
|
private var cancelRequested = false
|
||||||
|
|
||||||
|
init() {
|
||||||
|
storage = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("ReceiptDesk", isDirectory: true)
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: storage, withIntermediateDirectories: true)
|
||||||
|
let session = storage.appendingPathComponent("workspace.json")
|
||||||
|
if FileManager.default.fileExists(atPath: session.path) {
|
||||||
|
state = try JSONDecoder().decode(Workspace.self, from: Data(contentsOf: session))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorMessage = "无法恢复上次工作区:\(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var canPair: Bool { !busy && !selectedInvoices.isEmpty && !selectedPayments.isEmpty }
|
||||||
|
|
||||||
|
func chooseFolder() {
|
||||||
|
guard !busy else { return }
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.canChooseDirectories = true
|
||||||
|
panel.canChooseFiles = false
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
panel.message = "选择包含“发票”“付款截图”“实物照片”的报账材料根目录"
|
||||||
|
panel.prompt = "导入材料"
|
||||||
|
guard panel.runModal() == .OK, let source = panel.url else { return }
|
||||||
|
let scoped = source.startAccessingSecurityScopedResource()
|
||||||
|
busy = true
|
||||||
|
cancelRequested = false
|
||||||
|
progress = 0
|
||||||
|
status = "正在复制材料到本地工作区"
|
||||||
|
notice = nil
|
||||||
|
let importRoot = storage.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
let staging = importRoot.appendingPathComponent("input", isDirectory: true)
|
||||||
|
let output = importRoot.appendingPathComponent("materials", isDirectory: true)
|
||||||
|
Task {
|
||||||
|
defer {
|
||||||
|
if scoped { source.stopAccessingSecurityScopedResource() }
|
||||||
|
busy = false
|
||||||
|
try? FileManager.default.removeItem(at: staging)
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try await Task.detached(priority: .userInitiated) {
|
||||||
|
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
|
||||||
|
let supported = Set(["jpg", "jpeg", "png", "webp", "bmp", "gif", "pdf"])
|
||||||
|
guard let files = FileManager.default.enumerator(at: source, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], options: [.skipsHiddenFiles]) else {
|
||||||
|
throw EngineFailure.message("无法读取所选文件夹")
|
||||||
|
}
|
||||||
|
while let file = files.nextObject() as? URL {
|
||||||
|
let attributes = try file.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
|
||||||
|
guard attributes.isRegularFile == true, attributes.isSymbolicLink != true, supported.contains(file.pathExtension.lowercased()) else { continue }
|
||||||
|
let relative = String(file.path.dropFirst(source.path.count + 1))
|
||||||
|
let components = relative.split(separator: "/").dropLast()
|
||||||
|
guard components.contains(where: { ["发票", "付款截图", "实物照片"].contains(String($0)) }) else { continue }
|
||||||
|
let target = staging.appendingPathComponent(relative)
|
||||||
|
try FileManager.default.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
try FileManager.default.copyItem(at: file, to: target)
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
if cancelRequested { throw CancellationError() }
|
||||||
|
let request: [String: Any] = ["operation": "scan", "path": staging.path, "workspacePath": output.path, "folderName": source.lastPathComponent]
|
||||||
|
let result = try await run(request)
|
||||||
|
let next = try JSONDecoder().decode(Workspace.self, from: result)
|
||||||
|
try persist(next)
|
||||||
|
let oldPaths = materialDirectories()
|
||||||
|
state = next
|
||||||
|
resetSelection()
|
||||||
|
oldPaths.forEach { try? FileManager.default.removeItem(at: $0) }
|
||||||
|
progress = 1
|
||||||
|
notice = "扫描完成,自动核对 \(next.autoCount) 组材料"
|
||||||
|
if !next.unmatchedInvoices.isEmpty || !next.unmatchedPayments.isEmpty { page = .pair }
|
||||||
|
} catch is CancellationError {
|
||||||
|
notice = "已取消扫描,原工作区保持不变"
|
||||||
|
try? FileManager.default.removeItem(at: importRoot)
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
try? FileManager.default.removeItem(at: importRoot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
cancelRequested = true
|
||||||
|
engine.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggle(_ item: Material) {
|
||||||
|
guard !busy else { return }
|
||||||
|
if item.type == "invoice" {
|
||||||
|
if selectedInvoices.contains(item.id) { selectedInvoices.remove(item.id) } else { selectedInvoices.insert(item.id) }
|
||||||
|
} else {
|
||||||
|
if selectedPayments.contains(item.id) { selectedPayments.remove(item.id) } else { selectedPayments.insert(item.id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirmPair() {
|
||||||
|
guard canPair else { return }
|
||||||
|
var next = state
|
||||||
|
let invoices = state.invoices.filter { selectedInvoices.contains($0.id) && !$0.matched }
|
||||||
|
let payments = state.payments.filter { selectedPayments.contains($0.id) && !$0.matched }
|
||||||
|
guard invoices.count == selectedInvoices.count, payments.count == selectedPayments.count else {
|
||||||
|
errorMessage = "所选材料已发生变化,请重新选择"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let identifiers = selectedInvoices.union(selectedPayments)
|
||||||
|
for index in next.invoices.indices where identifiers.contains(next.invoices[index].id) { next.invoices[index].matched = true }
|
||||||
|
for index in next.payments.indices where identifiers.contains(next.payments[index].id) { next.payments[index].matched = true }
|
||||||
|
next.matches.append(MatchGroup(invoices: invoices, payments: payments, category: category, matchType: "manual", score: 100, reasons: ["人工确认"]))
|
||||||
|
busy = true
|
||||||
|
status = "正在确认配对"
|
||||||
|
Task {
|
||||||
|
defer { busy = false }
|
||||||
|
do {
|
||||||
|
let result = try await run(["operation": "refresh", "state": try jsonObject(next)])
|
||||||
|
let updated = try JSONDecoder().decode(Workspace.self, from: result)
|
||||||
|
try persist(updated)
|
||||||
|
state = updated
|
||||||
|
resetSelection()
|
||||||
|
notice = "配对已确认,材料已移入已核对"
|
||||||
|
} catch { errorMessage = error.localizedDescription }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateCategory(_ match: MatchGroup, category: String) {
|
||||||
|
guard !busy, let index = state.matches.firstIndex(where: { $0.id == match.id }) else { return }
|
||||||
|
var next = state
|
||||||
|
next.matches[index].category = category
|
||||||
|
commit(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
func undo(_ match: MatchGroup) {
|
||||||
|
guard !busy else { return }
|
||||||
|
var next = state
|
||||||
|
let identifiers = Set((match.invoices + match.payments).map(\.id))
|
||||||
|
next.matches.removeAll { $0.id == match.id }
|
||||||
|
for index in next.invoices.indices where identifiers.contains(next.invoices[index].id) { next.invoices[index].matched = false }
|
||||||
|
for index in next.payments.indices where identifiers.contains(next.payments[index].id) { next.payments[index].matched = false }
|
||||||
|
if commit(next) {
|
||||||
|
page = .pair
|
||||||
|
notice = "已撤销配对,材料回到待匹配区"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
guard !busy else { return }
|
||||||
|
let directories = materialDirectories()
|
||||||
|
if commit(Workspace()) {
|
||||||
|
resetSelection()
|
||||||
|
preview = nil
|
||||||
|
showExpense = false
|
||||||
|
purposes = [:]
|
||||||
|
directories.forEach { try? FileManager.default.removeItem(at: $0) }
|
||||||
|
notice = "工作区已清空,原始材料未删除"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openExpense() {
|
||||||
|
purposes = Dictionary(uniqueKeysWithValues: state.matches.map { ($0.id, $0.category == "其他" ? "" : $0.category + "费用") })
|
||||||
|
showExpense = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func export(_ kind: ExportKind) {
|
||||||
|
guard !busy, !state.matches.isEmpty else { return }
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.allowedContentTypes = [UTType(filenameExtension: kind.fileExtension) ?? .data]
|
||||||
|
panel.canCreateDirectories = true
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyyMMdd-HHmm"
|
||||||
|
panel.nameFieldStringValue = "\(kind.title)-\(formatter.string(from: Date())).\(kind.fileExtension)"
|
||||||
|
guard panel.runModal() == .OK, let destination = panel.url else { return }
|
||||||
|
let scoped = destination.startAccessingSecurityScopedResource()
|
||||||
|
let temporary = storage.appendingPathComponent(UUID().uuidString + "." + kind.fileExtension)
|
||||||
|
busy = true
|
||||||
|
status = "正在生成\(kind.title)"
|
||||||
|
progress = 0
|
||||||
|
Task {
|
||||||
|
defer {
|
||||||
|
busy = false
|
||||||
|
if scoped { destination.stopAccessingSecurityScopedResource() }
|
||||||
|
try? FileManager.default.removeItem(at: temporary)
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
_ = try await run(["operation": kind.rawValue, "state": try jsonObject(state), "destination": temporary.path, "classified": classified, "purposes": purposes, "signatures": signatures])
|
||||||
|
try Data(contentsOf: temporary).write(to: destination, options: .atomic)
|
||||||
|
if kind == .expense { showExpense = false }
|
||||||
|
notice = "\(kind.title)已导出到 \(destination.lastPathComponent)"
|
||||||
|
NSWorkspace.shared.activateFileViewerSelecting([destination])
|
||||||
|
} catch is CancellationError {
|
||||||
|
notice = "已取消导出"
|
||||||
|
} catch { errorMessage = error.localizedDescription }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func run(_ request: [String: Any]) async throws -> Data {
|
||||||
|
let data = try JSONSerialization.data(withJSONObject: request)
|
||||||
|
return try await engine.run(request: data) { [self] value, message in
|
||||||
|
Task { @MainActor in
|
||||||
|
self.progress = value
|
||||||
|
self.status = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func jsonObject(_ value: Workspace) throws -> Any {
|
||||||
|
try JSONSerialization.jsonObject(with: JSONEncoder().encode(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persist(_ value: Workspace) throws {
|
||||||
|
try JSONEncoder().encode(value).write(to: storage.appendingPathComponent("workspace.json"), options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult private func commit(_ value: Workspace) -> Bool {
|
||||||
|
do {
|
||||||
|
try persist(value)
|
||||||
|
state = value
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
errorMessage = "无法保存工作区:\(error.localizedDescription)"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func materialDirectories() -> Set<URL> {
|
||||||
|
Set((state.invoices + state.payments + state.photos).compactMap { item in
|
||||||
|
let directory = URL(fileURLWithPath: item.path).deletingLastPathComponent().deletingLastPathComponent()
|
||||||
|
return directory.deletingLastPathComponent().standardizedFileURL == storage.standardizedFileURL ? directory : nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetSelection() {
|
||||||
|
selectedInvoices = []
|
||||||
|
selectedPayments = []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||