Initial commit: macOS receipt workspace

This commit is contained in:
于晓婷
2026-09-11 12:58:09 +08:00
commit bbacd1d6f3
40 changed files with 2790 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PYTHON="$ROOT/.build-tools/bin/python"
if [ ! -x "$PYTHON" ]; then
echo "请先使用 Python 3.12 创建 .build-tools 虚拟环境并安装 native-engine/requirements.txt。" >&2
exit 1
fi
export PYINSTALLER_CONFIG_DIR="$ROOT/.build-tools/pyinstaller-cache"
cd "$ROOT/native-engine"
"$PYTHON" -m PyInstaller --noconfirm --clean --onedir --windowed --name receipt-engine-helper \
--osx-bundle-identifier test.reimburse.engine \
--distpath "$ROOT/native-engine/dist" --workpath "$ROOT/native-engine/build" \
--collect-all rapidocr_onnxruntime --collect-all onnxruntime \
--collect-all pypdfium2 --collect-all pypdfium2_raw \
--add-data "personal-expense-template.xlsx:." engine.py
echo "本地处理引擎已打包,可以在 Xcode 中运行 reimburse。"
+358
View File
@@ -0,0 +1,358 @@
import re
import uuid
from collections import OrderedDict
from datetime import date
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
CATEGORIES = ['餐饮', '住宿', '交通', '办公用品', '招待', '采购', '其他']
HAN = r'\u3400-\u9fff'
DATE = r'(20\d{2})[年./-](1[0-2]|0?[1-9])[月./-](3[01]|[12]\d|0?[1-9])日?'
AMOUNT_PATTERNS = [
r'(?:支付总额|订单总额|应付总额|总金额|总计|价税合计|小写|实付金额|支付金额|付款金额|订单金额|合计|金额)[^0-9]{0,10}([0-9]{1,8}(?:[.,][0-9]{1,2})?)',
r'(?:¥|¥|RMB|CNY)\s*([0-9]{1,8}(?:[.,][0-9]{1,2})?)',
r'(?<![0-9])([0-9]{1,7}[.,][0-9]{2})(?![0-9%])',
]
MERCHANT_LABEL = r'销售方名称|销方名称|收款方|收款单位|商户全称|商户名称|商户|付款给|收款人|对方户名|交易对象'
KEYWORDS = [
'餐饮 餐厅 饭店 酒楼 外卖 咖啡 奶茶 茶饮 饮品 火锅 烧烤 快餐 小吃 食品 美团外卖 饿了么 tims 星巴克',
'酒店 宾馆 旅馆 住宿 房费 客房 民宿 公寓 入住 退房',
'铁路 火车 12306 车次 机票 航空 航班 登机 滴滴 网约车 出租车 打车 货运 运输 加油站 汽油 柴油 停车 高速 地铁 公交',
'办公用品 办公 文具 打印 复印 纸张 墨盒 硒鼓 耗材 电脑 键盘 鼠标 显示器 软件 会员订阅 桌椅 文件夹 饮用水 桶装水',
'招待 接待 宴请 商务宴请 客户用餐 会议餐',
'采购 商品 材料 设备 配件 家具 日用品 百货 商城 京东 淘宝 天猫 超市 便利店 购物',
]
def unique(values):
return list(dict.fromkeys(values))
def money(value):
return Decimal(str(value).replace(',', '.')).quantize(Decimal('.01'), rounding=ROUND_HALF_UP)
def first(pattern, text):
match = re.search(pattern, text, re.I)
return match.group(1).strip() if match else ''
def extract(lines):
text = '\n'.join(lines)
amounts = unique(format(money(value), '.2f') for pattern in AMOUNT_PATTERNS
for value in re.findall(pattern, text, re.I) if money(value) >= 0)
dates = []
for match in re.finditer(DATE, text):
try:
dates.append(date(*map(int, match.groups())).isoformat())
except ValueError:
pass
merchants = re.findall(r'(?:' + MERCHANT_LABEL + r')[:]?\s*([^\n]{2,40})', text)
def clean(value):
return re.sub(r'[|:]+$', '', re.sub(r'^.*?(?:' + MERCHANT_LABEL.replace('|商户|', '|') + r')[:]?', '', value)).strip()
def valid(value):
return (2 <= len(value) <= 45 and re.search('[' + HAN + 'A-Za-z]', value)
and not re.search('支付成功|交易成功|电子发票|增值税发票|付款方式|订单详情|账单详情|交易详情|商品说明|开票日期|价税合计', value))
for index, line in enumerate(lines):
line = clean(line)
if re.search('销售方|销方|收款方|收款单位|商户|付款给|收款人|对方户名|交易对象', line) and index + 1 < len(lines):
following = clean(lines[index + 1])
if valid(following):
merchants.append(following)
if re.search('公司|餐厅|饭店|酒店|宾馆|旅馆|商店|店|超市|便利店|中心|服务部|经营部|商行|科技|网络|运输|航空|铁路|医院|药房|加油站|停车场|物业|平台', line) and valid(line):
merchants.append(line)
return dict(status='success', rawText=text, lines=lines, amounts=amounts,
dates=unique(dates), merchants=unique(value.strip().rstrip(':|') for value in merchants),
orderNumbers=unique(re.findall(r'(?:交易单号|商户单号|订单号|流水号|交易号)[::]?\s*([A-Za-z0-9-]{8,40})', text)),
travel=extract_travel(text, unique(dates)), message='')
def extract_travel(text, dates):
travel = dict(type='', travelerName='', departure='', destination='', departureTime='', transportNumber='')
train = re.search('火车票|铁路电子客票|铁路|车次|检票口|12306', text)
flight = re.search('航空|航班|登机牌|电子客票行程单|乘机|起飞|客票号', text)
if not train and not flight:
return travel
travel['type'] = 'flight' if flight and not train else 'train'
for pattern in [r'(?:旅客姓名|乘车人|乘机人|旅客|姓名)[::]?\s*([' + HAN + r'·]{2,15})',
r'(?:\d{6,}\*{2,}\d{2,})\s*([' + HAN + r'·]{2,8})']:
names = [name for name in re.findall(pattern, text) if len(name) <= 8 and not re.search('税务|日期|发票|铁路|车站|公司|名称|代码|号码', name)]
if names:
travel['travelerName'] = names[0]
break
travel['departure'] = first(r'(?:出发地|出发站|始发站|起点|起飞机场)[::]?\s*([' + HAN + r'A-Za-z0-9]{2,24})', text)
travel['destination'] = first(r'(?:目的地|到达站|终点站|终点|到达机场)[::]?\s*([' + HAN + r'A-Za-z0-9]{2,24})', text)
route = re.search(r'([' + HAN + r'A-Za-z]{2,20})(?:站|机场)?\s*(?:→|->|—|至)\s*([' + HAN + r'A-Za-z]{2,20})(?:站|机场)?', text)
if route:
for index, field in enumerate(['departure', 'destination'], 1):
travel[field] = travel[field] or re.sub(r'(站|机场)$', '', route.group(index))
if train:
stations = unique(re.findall(r'([' + HAN + r']{2,12})站', text))
if len(stations) >= 2:
travel['departure'] = travel['departure'] or stations[0]
travel['destination'] = travel['destination'] or stations[1]
clock_pattern = r'([01]?\d|2[0-3]):([0-5]\d)'
for match in re.finditer(DATE + r'[^\n]{0,24}?' + clock_pattern, text):
if not re.search('开票日期|发票日期', text[max(0, match.start() - 16):match.start()]):
year, month, day, hour, minute = map(int, match.groups())
travel['departureTime'] = f'{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}'
break
if not travel['departureTime']:
departure_date = ''
for match in re.finditer(DATE, text):
if not re.search('开票日期|发票日期', text[max(0, match.start() - 16):match.start()]):
year, month, day = map(int, match.groups())
departure_date = f'{year:04}-{month:02}-{day:02}'
break
clock = re.search(clock_pattern, text)
travel['departureTime'] = ((departure_date or next(iter(dates), '')) + (' %02d:%02d' % tuple(map(int, clock.groups())) if clock else '')).strip()
pattern = r'(?<![A-Z0-9])([GDCZTKSLY]\d{1,5})(?![A-Z0-9])' if train else r'(?<![A-Z0-9])([A-Z0-9]{2}\d{3,4})(?![A-Z0-9])'
travel['transportNumber'] = first(pattern, text)
return travel
def amounts(item):
return [money(value) for value in item['ocr']['amounts'] if money(value) > 0] if item['ocr']['status'] == 'success' else []
def primary(item):
values = item['ocr']['amounts']
return money(values[0]) if values and item['ocr']['status'] == 'success' else None
def text(item):
return item['ocr']['rawText']
def common(left, right, field):
return [value for value in left['ocr'][field] if value in right['ocr'][field]]
def date_distance(invoices, payments):
distances = [abs((date.fromisoformat(left) - date.fromisoformat(right)).days)
for invoice in invoices for payment in payments
for left in invoice['ocr']['dates'] for right in payment['ocr']['dates']]
return min(distances, default=999999)
def similarity(left, right):
def normalize(value):
return re.sub('[^' + HAN + 'a-z0-9]', '', re.sub('有限责任公司|股份有限公司|有限公司|公司|商户|门店|收款方', '', value.lower()))
def grams(value):
return {value[index:index + 2] for index in range(len(value) - 1)} if len(value) > 1 else {value}
left, right = normalize(left), normalize(right)
if not left or not right:
return 0
if left in right or right in left:
return 1
return len(grams(left) & grams(right)) / len(grams(left) | grams(right))
def merchant_similarity(invoice, payment):
return max((similarity(left, right) for left in invoice['ocr']['merchants'] for right in payment['ocr']['merchants']), default=0)
def category(items):
content = ' '.join(text(item) + ' ' + ' '.join(item['ocr']['merchants']) for item in items).lower()
scores = [sum(3 if len(word) >= 4 else 2 if len(word) >= 3 else 1 for word in words.split() if word in content) for words in KEYWORDS]
return CATEGORIES[scores.index(max(scores))] if max(scores) else '其他'
def add_match(state, invoices, payments, score, reasons):
selected_category = category(invoices + payments)
for item in invoices + payments:
item['matched'] = True
state['matches'].append(dict(id=str(uuid.uuid4()), invoices=invoices, payments=payments,
category=selected_category, matchType='auto', score=score,
reasons=reasons + ['自动分类:' + selected_category]))
def combinations(values, target):
sums = {Decimal('0.00'): [[]]}
for item, amount in values:
snapshot = [(subtotal, [list(group) for group in groups]) for subtotal, groups in sums.items()]
for subtotal, groups in snapshot:
total = subtotal + amount
if total > target:
continue
destination = sums.setdefault(total, [])
for group in list(groups):
candidate = group + [item]
if candidate not in destination:
destination.append(candidate)
del destination[2:]
return [group for group in sums.get(target, []) if len(group) >= 2][:2]
def railway(item):
return bool(re.search('12306|铁路|火车|车次|中铁', text(item)))
def total_label(item):
return bool(re.search('支付总额|订单总额|应付总额|总金额|总计|合计', text(item)))
def travel_key(item):
travel = item['ocr']['travel']
return tuple(travel[field].lower() for field in ['type', 'departure', 'destination', 'departureTime', 'transportNumber'])
def auto_match(state):
invoices, payments = state['invoices'], state['payments']
groups = OrderedDict()
for invoice in invoices:
if invoice['ocr']['travel']['type']:
groups.setdefault(travel_key(invoice), []).append(invoice)
for group in groups.values():
if len(group) < 2 or any(primary(item) is None for item in group):
continue
total = sum(primary(item) for item in group)
evidence = [item for item in payments if not item['matched'] and railway(item) and total in amounts(item)]
if evidence and any(re.search('支付成功|付款成功|交易成功|支付方式|交易单号|商户单号', text(item)) for item in evidence) and date_distance(group, evidence) <= 14:
reasons = [f'同一行程多人发票合计一致 ¥{total:.2f}', ' + '.join(str(primary(item)) for item in group)]
if len(evidence) >= 2:
reasons.append('付款凭证与订单截图已共同归入本次行程')
add_match(state, group, evidence, 100 if len(evidence) >= 2 else 95, reasons)
for payment in payments:
total = primary(payment)
if payment['matched'] or total is None:
continue
values = [(item, primary(item)) for item in invoices if not item['matched'] and primary(item) is not None and 0 < primary(item) < total]
options = combinations(values, total)
if len(options) != 1:
continue
group, evidence = options[0], [payment]
for candidate in payments:
if candidate is payment or candidate['matched']:
continue
same_total = primary(candidate) == total or (total in amounts(candidate) and total_label(candidate))
related = (common(payment, candidate, 'orderNumbers') or merchant_similarity(payment, candidate) >= .35
or (railway(payment) and railway(candidate) and (date_distance([payment], [candidate]) <= 7 or total_label(payment) or total_label(candidate))))
if same_total and related:
evidence.append(candidate)
score = 70
reasons = [f'多张发票合计一致 ¥{total:.2f}', ' + '.join(str(primary(item)) for item in group)]
for condition, reason in [(len(evidence) >= 2, '订单与付款凭证相互印证'),
(date_distance(group, evidence) <= 7, '行程与付款日期接近'),
(all(item['ocr']['travel']['type'] for item in group) and len({tuple(item['ocr']['travel'][field] for field in ['departure', 'destination', 'departureTime', 'transportNumber']) for item in group}) == 1, '车次、路线及行程日期一致')]:
if condition:
score += 10
reasons.append(reason)
if score >= 90:
add_match(state, group, evidence, score, reasons)
candidates = []
for invoice in invoices:
for payment in payments:
shared = common(invoice, payment, 'amounts')
if not shared or invoice['ocr']['status'] != 'success' or payment['ocr']['status'] != 'success':
continue
score, reasons = 55, ['金额一致 ¥' + shared[0]]
merchant = merchant_similarity(invoice, payment)
if merchant >= .35:
score += int(25 * merchant + .5)
reasons.append(f'商户相似 {int(merchant * 100 + .5)}%')
distance = date_distance([invoice], [payment])
if distance <= 7:
score += 15 if distance <= 3 else 8
reasons.append('日期一致' if distance == 0 else f'日期相差 {distance}')
if common(invoice, payment, 'orderNumbers'):
score += 20
reasons.append('交易单号一致')
for amount in shared:
if sum(amount in item['ocr']['amounts'] for item in invoices) == 1 and sum(amount in item['ocr']['amounts'] for item in payments) == 1:
score += 20
reasons.append(f'金额 ¥{amount} 在本批材料中唯一')
break
if score >= 70:
candidates.append((invoice, payment, min(100, score), reasons))
candidates.sort(key=lambda candidate: -candidate[2])
for candidate in candidates:
invoice, payment, score, reasons = candidate
if invoice['matched'] or payment['matched']:
continue
second = max((other[2] for other in candidates if other is not candidate and (other[0] is invoice or other[1] is payment)
and not other[0]['matched'] and not other[1]['matched']), default=0)
if score >= 90 or score - second >= 8:
add_match(state, [invoice], [payment], score, reasons)
for invoice in invoices:
if invoice['matched']:
continue
available = sorted([(item, primary(item)) for item in payments if not item['matched'] and primary(item) is not None and primary(item) > 0], key=lambda entry: entry[1])
options = []
for total in amounts(invoice):
options.extend((total, group) for group in combinations([(item, amount) for item, amount in available if amount < total], total))
if len(options) > 1:
break
if len(options) == 1:
total, evidence = options[0]
add_match(state, [invoice], evidence, 95, [f'多笔付款合计一致 ¥{total:.2f}', ' + '.join(str(primary(item)) for item in evidence)])
return state
def material_date(item, issue=False):
content = text(item)
if issue:
match = re.search(r'(?:申请开票(?:日期|时间)?|开票日期|发票开具日期|开具日期)\s*[:]?\s*' + DATE, content)
if match:
try:
return date(*map(int, match.groups())).isoformat()
except ValueError:
pass
if item['ocr']['dates']:
return item['ocr']['dates'][0]
match = re.search(r'(?:^|\D)(\d{1,2})月(\d{1,2})日', content)
if match:
try:
return date(date.today().year, *map(int, match.groups())).isoformat()
except ValueError:
pass
return '9999-12-31'
def invoice_amount(item):
content = text(item)
patterns = [r'价税合计[^\n]{0,35}?(?:小写)?[^0-9]{0,12}([0-9][0-9,]*(?:\.\d{1,2})?)',
r'(?:小写|票价|发票金额|合计金额|总金额|应付金额)\s*[(]?[人民币RMB]*[)]?\s*[:]?\s*[¥¥]?\s*([0-9][0-9,]*(?:\.\d{1,2})?)']
for pattern in patterns:
values = re.findall(pattern, content, re.I)
if values:
return Decimal(values[-1].replace(',', ''))
return max([Decimal(value) for value in item['ocr']['amounts']], default=Decimal(0))
def payment_amount(item):
for pattern in [r'(?:支付总额|实付金额|实际支付金额|付款金额|实付款|实付价|合计)\s*[:]?\s*[¥¥]?\s*([0-9][0-9,]*(?:\.\d{1,2})?)',
r'已优惠[^\n]{0,24}?[¥¥]\s*([0-9][0-9,]*(?:\.\d{1,2})?)']:
values = re.findall(pattern, text(item), re.I)
if values:
return abs(Decimal(values[-1].replace(',', '')))
return abs(primary(item) or Decimal(0))
def payment_total(items):
result = Decimal(0)
for index, item in enumerate(items):
amount = payment_amount(item)
duplicate = re.search('支付总额|查看订单支付明细|购票成功|订单详情', text(item)) and any(
other_index != index and re.search('支付成功|付款成功|交易成功|支付方式|付款方式|交易单号|商户单号|转账成功|收款方', text(other))
and abs(payment_amount(other) - amount) < Decimal('.005') for other_index, other in enumerate(items))
if not duplicate:
result += amount
return result
def enrich(state):
for item in state['invoices'] + state['payments'] + state['photos']:
item['displayAmount'] = float(invoice_amount(item) if item['type'] == 'invoice' else payment_amount(item))
item['sortDate'] = material_date(item)
item['issueDate'] = material_date(item, True)
lookup = {item['id']: item for item in state['invoices'] + state['payments']}
for match in state['matches']:
match['invoices'] = [lookup[item['id']] for item in match['invoices']]
match['payments'] = [lookup[item['id']] for item in match['payments']]
match['paymentTotal'] = float(payment_total(match['payments']))
match['expenseAmount'] = float(sum(max((Decimal(value) for value in item['ocr']['amounts']), default=Decimal(0)) for item in match['invoices']))
state['directoryPaymentTotal'] = float(payment_total(state['payments']))
return state
+118
View File
@@ -0,0 +1,118 @@
import contextlib
import json
import os
import shutil
import sys
import traceback
import uuid
from pathlib import Path
from domain import auto_match, enrich, extract
from exports import export_expense, export_ppt, export_travel
PROTOCOL = sys.stdout
SUPPORTED = {'.jpg', '.jpeg', '.png', '.webp', '.bmp', '.gif', '.pdf'}
def emit(value):
PROTOCOL.write(json.dumps(value, ensure_ascii=False) + '\n')
PROTOCOL.flush()
def scan(request):
import onnxruntime
import pypdfium2 as pdfium
from PIL import Image
from rapidocr_onnxruntime import RapidOCR
root = Path(request['path'])
destination = Path(request['workspacePath'])
if not root.is_dir():
raise ValueError('请选择有效的报账材料文件夹')
destination.mkdir(parents=True, exist_ok=True)
state = dict(rootPath=request.get('folderName', root.name), invoices=[], payments=[], photos=[], matches=[], warnings=[], directoryPaymentTotal=0)
files = []
for source in sorted(root.rglob('*')):
if source.is_file() and source.suffix.lower() in SUPPORTED:
parts = source.relative_to(root).parts[:-1]
kind = next((kind for label, kind in [('发票', 'invoice'), ('付款截图', 'payment'), ('实物照片', 'photo')] if label in parts), None)
if kind:
files.append((source, kind))
if not files:
raise ValueError('目录中没有可处理材料,请确认包含“发票”“付款截图”子文件夹')
emit(dict(event='progress', progress=0, message='正在加载本地 RapidOCR 模型'))
onnxruntime.disable_telemetry_events()
recognizer = RapidOCR()
for index, (source, kind) in enumerate(files):
identifier = str(uuid.uuid4())
stored = destination / (identifier + source.suffix.lower())
shutil.copyfile(source, stored)
item = dict(id=identifier, name=source.name, path=str(stored), type=kind, size=stored.stat().st_size, matched=False,
previewPath='', ocr=extract([]))
item['ocr']['status'] = 'pending'
try:
if source.suffix.lower() == '.pdf':
with contextlib.closing(pdfium.PdfDocument(str(stored))) as document:
if not len(document):
raise ValueError('PDF 没有可预览页面')
with contextlib.closing(document[0]) as page:
with contextlib.closing(page.render(scale=140 / 72)) as bitmap:
image = bitmap.to_pil().convert('RGB')
else:
with Image.open(stored) as original:
image = original.convert('RGB')
preview = destination / (identifier + '-preview.png')
image.save(preview, format='PNG')
item['previewPath'] = str(preview)
if kind != 'photo':
result, _ = recognizer(str(preview if source.suffix.lower() == '.pdf' else stored))
item['ocr'] = extract([str(entry[1]).strip() for entry in result or [] if len(entry) >= 3 and str(entry[1]).strip()])
except Exception as error:
item['ocr']['status'] = 'failed'
item['ocr']['message'] = str(error)
state['warnings'].append(source.name + ':识别或预览失败,可人工配对')
state[{'invoice': 'invoices', 'payment': 'payments', 'photo': 'photos'}[kind]].append(item)
emit(dict(event='progress', progress=(index + 1) / len(files) * .95, message=f'已处理 {index + 1}/{len(files)} · {source.name}'))
for field, label in [('invoices', '发票'), ('payments', '付款截图'), ('photos', '实物照片')]:
if not state[field]:
state['warnings'].append('未找到“' + label + '”文件夹或支持的文件')
emit(dict(event='progress', progress=.97, message='正在执行自动配对与分类'))
return enrich(auto_match(state))
def dispatch(request):
operation = request['operation']
if operation == 'scan':
return scan(request)
state = request['state']
if operation == 'refresh':
return enrich(state)
if not state['matches']:
raise ValueError('至少完成一组核对后才能导出')
destination = Path(request['destination'])
temporary = destination.with_name('.' + str(uuid.uuid4()) + destination.suffix)
try:
if operation == 'ppt':
export_ppt(state, temporary, request.get('classified', True))
elif operation == 'travel':
export_travel(state, temporary)
elif operation == 'expense':
template = Path(__file__).parent / 'personal-expense-template.xlsx'
export_expense(state, temporary, template, request.get('purposes', {}), request.get('signatures', []))
else:
raise ValueError('未知操作:' + operation)
os.replace(temporary, destination)
finally:
temporary.unlink(missing_ok=True)
return dict(destination=str(destination))
if __name__ == '__main__':
try:
request = json.loads(sys.stdin.readline())
with contextlib.redirect_stdout(sys.stderr):
result = dispatch(request)
emit(dict(event='result', result=result))
except Exception as error:
traceback.print_exc(file=sys.stderr)
emit(dict(event='error', message=str(error)))
sys.exit(1)
+232
View File
@@ -0,0 +1,232 @@
import copy
import io
import math
import posixpath
import zipfile
from decimal import Decimal
from pathlib import Path
from xml.etree import ElementTree as XML
from PIL import Image
from domain import material_date, text
DEFAULT_SIGNATURES = ['部门长', '剧组出纳', '制片主任', '剧组会计', '执行制片人', '制片人']
def image_for(item):
return Image.open(item.get('previewPath') or item['path']).convert('RGB')
def export_ppt(state, destination, classified):
from pptx import Presentation
from pptx.util import Pt
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import PP_ALIGN
presentation = Presentation()
presentation.slide_width, presentation.slide_height = Pt(1280), Pt(720)
def slide():
return presentation.slides.add_slide(presentation.slide_layouts[6])
def picture(page, item, box):
image = image_for(item)
left, top, width, height = box
scale = min(width / image.width, height / image.height)
actual_width, actual_height = int(image.width * scale), int(image.height * scale)
content = io.BytesIO()
image.save(content, format='PNG')
content.seek(0)
page.shapes.add_picture(content, Pt(left + (width - actual_width) // 2), Pt(top + (height - actual_height) // 2), Pt(actual_width), Pt(actual_height))
def payment_page(payments):
page = slide()
if len(payments) == 1:
picture(page, payments[0], (300, 20, 680, 680))
else:
width = (1200 - 24 * (len(payments) - 1)) // len(payments)
for index, item in enumerate(payments):
picture(page, item, (40 + index * (width + 24), 20, width, 680))
def label(page, box, value, size, title=False):
shape = page.shapes.add_shape(MSO_SHAPE.RECTANGLE, *(Pt(value) for value in box))
shape.fill.solid()
shape.fill.fore_color.rgb = RGBColor(245, 248, 247) if title else RGBColor(255, 255, 255)
shape.line.color.rgb = RGBColor(216, 226, 222) if title else RGBColor(170, 188, 182)
shape.line.width = Pt(1 if title else 1.5)
shape.text = value
for paragraph in shape.text_frame.paragraphs:
paragraph.alignment = PP_ALIGN.CENTER
for run in paragraph.runs:
run.font.name = 'PingFang SC'
run.font.size = Pt(size)
run.font.color.rgb = RGBColor(39, 91, 78) if title else RGBColor(112, 132, 126)
def photos(count):
for offset in range(0, count, 4):
slots = min(4, count - offset)
page = slide()
label(page, (55, 28, 1170, 52), f'实物照片粘贴区(请手动粘贴) {offset + 1}-{min(count, offset + 4)} / {count}', 18, True)
for index in range(slots):
box = (250, 105, 780, 555) if slots == 1 else (55 + index * 610, 105, 560, 555) if slots == 2 else (55 + index % 2 * 610, 105 + index // 2 * 285, 560, 255)
label(page, box, f'实物照片 {offset + index + 1}\n请在此处粘贴', 16)
def requires_photo(match):
return match['category'].strip() not in ['交通', '住宿']
def simple_batch(batch):
if not batch:
return
for match in batch:
picture(slide(), match['invoices'][0], (40, 25, 1200, 670))
payment_page([match['payments'][0] for match in batch])
photos(sum(len(match['payments']) for match in batch if requires_photo(match)))
matches = sorted(state['matches'], key=lambda match: (min(material_date(item, True) for item in match['invoices']), match['category'] if classified else ''))
batch = []
for match in matches:
if len(match['invoices']) != 1 or len(match['payments']) != 1:
simple_batch(batch)
batch = []
for item in sorted(match['invoices'], key=material_date):
picture(slide(), item, (40, 25, 1200, 670))
payments = sorted(match['payments'], key=material_date)
for index in range(0, len(payments), 4):
payment_page(payments[index:index + 4])
if requires_photo(match):
photos(len(payments))
else:
batch.append(match)
if len(batch) == 2:
simple_batch(batch)
batch = []
simple_batch(batch)
presentation.save(destination)
def export_travel(state, destination):
import mimetypes
mimetypes.knownfiles = []
mimetypes.init()
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
from openpyxl.utils import get_column_letter
invoices = [item for match in state['matches'] for item in match['invoices'] if item['ocr']['travel']['type']]
if not invoices:
raise ValueError('已核对材料中没有识别到火车票或航班行程')
workbook = Workbook()
sheet = workbook.active
sheet.title = '火车及航班行程'
sheet.append(['序号', '交通类型', '姓名', '出发地', '目的地', '出发时间', '车次/航班号', '金额', '发票文件'])
for cell in sheet[1]:
cell.font = Font(bold=True, color='FFFFFF')
cell.fill = PatternFill('solid', fgColor='006633')
cell.alignment = Alignment(horizontal='center')
for index, item in enumerate(invoices, 1):
travel = item['ocr']['travel']
values = [index, '火车' if travel['type'] == 'train' else '航班', travel['travelerName'], travel['departure'], travel['destination'], travel['departureTime'], travel['transportNumber'], next(iter(item['ocr']['amounts']), ''), item['name']]
sheet.append(values)
for cell in sheet[sheet.max_row]:
if isinstance(cell.value, str):
cell.data_type = 's'
for index, width in enumerate([8, 12, 16, 18, 18, 22, 18, 14, 38], 1):
sheet.column_dimensions[get_column_letter(index)].width = width
sheet.freeze_panes = 'A2'
workbook.save(destination)
def export_expense(state, destination, template, purposes, signatures):
from lxml import etree as ET
namespace = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
relationships = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
package = 'http://schemas.openxmlformats.org/package/2006/relationships'
content_namespace = 'http://schemas.openxmlformats.org/package/2006/content-types'
tag = lambda name: '{' + namespace + '}' + name
with zipfile.ZipFile(template) as source:
parts = {name: source.read(name) for name in source.namelist()}
workbook = ET.fromstring(parts['xl/workbook.xml'])
relations = ET.fromstring(parts['xl/_rels/workbook.xml.rels'])
content_types = ET.fromstring(parts['[Content_Types].xml'])
sheets = workbook.find(tag('sheets'))
original = next(sheet for sheet in sheets if sheet.get('name') == '1个人报销单')
original_index = list(sheets).index(original)
relation = next(item for item in relations if item.get('Id') == original.get('{' + relationships + '}id'))
sheet_path = posixpath.normpath(posixpath.join('xl', relation.get('Target'))) if not relation.get('Target').startswith('/') else relation.get('Target').lstrip('/')
sheet_bytes = parts[sheet_path]
names = workbook.find(tag('definedNames'))
retained_names = [copy.deepcopy(item) for item in names if item.get('localSheetId') == str(original_index)] if names is not None else []
if names is not None:
names.clear()
sheets.clear()
signatures = signatures or DEFAULT_SIGNATURES
page_count = math.ceil(len(state['matches']) / 13)
def set_cell(document, reference, value):
cell = document.find('.//' + tag('c') + '[@r="' + reference + '"]')
if cell is None:
row_number = ''.join(character for character in reference if character.isdigit())
row = document.find('.//' + tag('row') + '[@r="' + row_number + '"]')
if row is None:
raise ValueError('报销模板缺少明细行:' + row_number)
cell = ET.SubElement(row, tag('c'), r=reference)
for child in list(cell):
cell.remove(child)
if isinstance(value, (int, float, Decimal)):
cell.set('t', 'n')
ET.SubElement(cell, tag('v')).text = str(value)
else:
cell.set('t', 'inlineStr')
inline = ET.SubElement(cell, tag('is'))
ET.SubElement(inline, tag('t')).text = str(value)
for page in range(page_count):
document = ET.fromstring(sheet_bytes)
for row_number in range(9, 22):
for column in ['B', 'C', 'G', 'H', 'I']:
set_cell(document, column + str(row_number), '')
set_cell(document, 'A' + str(row_number), row_number - 8)
for index, match in enumerate(state['matches'][page * 13:(page + 1) * 13]):
invoice_type = '普票'
for invoice in match['invoices']:
content = text(invoice)
if '专用发票' in content:
invoice_type = '专票'
break
if 'invoice' in content.lower():
invoice_type = 'Invoice'
break
if '押金' in content and '收据' in content:
invoice_type = '押金收据'
break
total = sum(max((abs(Decimal(value.replace(',', ''))) for value in item['ocr']['amounts']), default=Decimal(0)) for item in match['invoices'])
for column, value in [('B', invoice_type), ('C', purposes.get(match['id'], '').strip()), ('G', len(match['invoices'])), ('H', total)]:
set_cell(document, column + str(index + 9), value)
for index, cell in enumerate(['A30', 'D30', 'A31', 'D31', 'A32', 'D32']):
value = signatures[index].strip() if index < len(signatures) else ''
set_cell(document, cell, value + '' if value else '')
name = '个人报销单' if page_count == 1 else f'个人报销单-{page + 1}'
output_path = f'xl/worksheets/native-expense-{page + 1}.xml'
relation_id = f'rIdNativeExpense{page + 1}'
ET.SubElement(sheets, tag('sheet'), name=name, sheetId=str(page + 1), attrib={'{' + relationships + '}id': relation_id})
ET.SubElement(relations, '{' + package + '}Relationship', Id=relation_id, Type=relationships + '/worksheet', Target=output_path[3:])
ET.SubElement(content_types, '{' + content_namespace + '}Override', PartName='/' + output_path, ContentType='application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml')
parts[output_path] = ET.tostring(document, xml_declaration=True, encoding='UTF-8', standalone=True)
rel_path = posixpath.dirname(sheet_path) + '/_rels/' + posixpath.basename(sheet_path) + '.rels'
if rel_path in parts:
parts['xl/worksheets/_rels/' + posixpath.basename(output_path) + '.rels'] = parts[rel_path]
for definition in retained_names:
cloned = copy.deepcopy(definition)
cloned.set('localSheetId', str(page))
cloned.text = (cloned.text or '').replace("'1个人报销单'!", "'" + name + "'!").replace('1个人报销单!', "'" + name + "'!")
names.append(cloned)
calc = workbook.find(tag('calcPr'))
if calc is None:
calc = ET.SubElement(workbook, tag('calcPr'))
calc.set('fullCalcOnLoad', '1')
calc.set('forceFullCalc', '1')
for key, document in [('xl/workbook.xml', workbook), ('xl/_rels/workbook.xml.rels', relations), ('[Content_Types].xml', content_types)]:
parts[key] = ET.tostring(document, xml_declaration=True, encoding='UTF-8', standalone=True)
with zipfile.ZipFile(destination, 'w', zipfile.ZIP_DEFLATED) as output:
for name, content in parts.items():
output.writestr(name, content)
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>com.apple.security.app-sandbox</key><true/>
<key>com.apple.security.inherit</key><true/>
</dict></plist>
+27
View File
@@ -0,0 +1,27 @@
import json
from pathlib import Path
from PIL import Image, ImageDraw
root = Path(__file__).resolve().parents[1] / 'reimburse/Assets.xcassets/AppIcon.appiconset'
image = Image.new('RGBA', (1024, 1024))
draw = ImageDraw.Draw(image)
draw.rounded_rectangle((44, 44, 980, 980), radius=218, fill='#145b54')
draw.rounded_rectangle((72, 70, 952, 955), radius=194, fill='#1b7368')
draw.rounded_rectangle((284, 174, 743, 800), radius=42, fill='#f4f0e6')
draw.rounded_rectangle((350, 268, 665, 294), radius=13, fill='#90b2a8')
draw.rounded_rectangle((350, 341, 580, 363), radius=11, fill='#b1c7bd')
draw.rounded_rectangle((350, 405, 634, 427), radius=11, fill='#b1c7bd')
draw.rounded_rectangle((198, 488, 826, 817), radius=70, fill='#103e39')
draw.rounded_rectangle((218, 511, 806, 796), radius=54, fill='#224e46')
draw.rounded_rectangle((617, 582, 854, 726), radius=42, fill='#d6bd82')
draw.ellipse((662, 630, 709, 677), fill='#4c6351')
draw.line([(342, 645), (411, 707), (523, 584)], fill='#f4f0e6', width=29, joint='curve')
entries = []
for size in [16, 32, 128, 256, 512]:
for scale in [1, 2]:
filename = f'icon-{size}@{scale}x.png'
image.resize((size * scale, size * scale), Image.Resampling.LANCZOS).save(root / filename)
entries.append(dict(idiom='mac', size=f'{size}x{size}', scale=f'{scale}x', filename=filename))
content = json.dumps(dict(images=entries, info=dict(author='xcode', version=1)), indent=2)
import subprocess
subprocess.run(['apply_patch', '*** Begin Patch\n*** Delete File: ' + str(root / 'Contents.json') + '\n*** Add File: ' + str(root / 'Contents.json') + '\n' + '\n'.join('+' + line for line in content.splitlines()) + '\n*** End Patch'], check=True)
Binary file not shown.
+25
View File
@@ -0,0 +1,25 @@
altgraph==0.17.5
et_xmlfile==2.0.0
flatbuffers==25.12.19
lxml==6.1.3
macholib==1.16.4
numpy==2.5.1
onnxruntime==1.28.0
opencv-python==5.0.0.93
openpyxl==3.1.5
packaging==26.3
pillow==11.3.0
protobuf==7.35.1
pyclipper==1.4.0
pyinstaller==6.16.0
pyinstaller-hooks-contrib==2026.7
pypdfium2==4.30.0
python-pptx==1.0.2
PyYAML==6.0.3
rapidocr-onnxruntime==1.4.4
setuptools==84.0.0
shapely==2.1.2
six==1.17.0
tqdm==4.70.0
typing_extensions==4.16.0
xlsxwriter==3.2.9
+11
View File
@@ -0,0 +1,11 @@
-c requirements.lock.txt
rapidocr-onnxruntime==1.4.4
numpy==2.5.1
onnxruntime==1.28.0
opencv-python==5.0.0.93
protobuf==7.35.1
Pillow==11.3.0
python-pptx==1.0.2
openpyxl==3.1.5
pypdfium2==4.30.0
pyinstaller==6.16.0
+63
View File
@@ -0,0 +1,63 @@
import argparse
import json
import subprocess
import sys
import shutil
from pathlib import Path
from PIL import Image
def invoke(command, request):
process = subprocess.run(command, input=json.dumps(request, ensure_ascii=False) + '\n', capture_output=True, text=True, timeout=180)
events = [json.loads(line) for line in process.stdout.splitlines() if line.strip()]
errors = [event for event in events if event['event'] == 'error']
if process.returncode or errors:
raise RuntimeError(str(errors) + '\n' + process.stderr)
results = [event['result'] for event in events if event['event'] == 'result']
if len(results) != 1:
raise AssertionError('处理引擎没有返回唯一结果')
return results[0]
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--binary')
parser.add_argument('--output', required=True)
arguments = parser.parse_args()
root = Path(__file__).resolve().parents[3]
output = Path(arguments.output).resolve()
output.mkdir(parents=True, exist_ok=True)
engine = Path(__file__).resolve().parents[1] / 'engine.py'
command = [arguments.binary] if arguments.binary else [sys.executable, str(engine)]
state = invoke(command, dict(operation='scan', path=str(root / 'fapiao/demo-materials'), workspacePath=str(output / 'materials'), folderName='演示报账材料'))
assert len(state['invoices']) == 2
assert len(state['payments']) == 2
assert len(state['photos']) == 1
assert len(state['matches']) == 2
assert {match['category'] for match in state['matches']} == {'餐饮', '住宿'}
assert state['directoryPaymentTotal'] == 596.5
assert sum(match['expenseAmount'] for match in state['matches']) == 596.5
assert not state['warnings']
(output / 'state.json').write_text(json.dumps(state, ensure_ascii=False, indent=2))
for operation, extension in [('ppt', 'pptx'), ('expense', 'xlsx')]:
destination = output / ('演示报账材料.' + extension)
invoke(command, dict(operation=operation, state=state, destination=str(destination), classified=True, purposes={match['id']: match['category'] + '费用' for match in state['matches']}, signatures=['经办人', '财务']))
assert destination.stat().st_size > 1000
pdf_input = output / 'pdf-input'
(pdf_input / '发票').mkdir(parents=True, exist_ok=True)
(pdf_input / '付款截图').mkdir(parents=True, exist_ok=True)
with Image.open(root / 'fapiao/demo-materials/发票/住宿_差旅发票_A001.png') as original:
original.convert('RGB').save(pdf_input / '发票/两页发票.pdf', save_all=True, append_images=[Image.new('RGB', (800, 500), 'white')])
(pdf_input / '发票/损坏文件.pdf').write_bytes(b'not a pdf')
shutil.copyfile(root / 'fapiao/demo-materials/付款截图/酒店支付记录_B009.png', pdf_input / '付款截图/付款.png')
pdf_state = invoke(command, dict(operation='scan', path=str(pdf_input), workspacePath=str(output / 'pdf-materials')))
assert len(pdf_state['invoices']) == 2
assert len(pdf_state['matches']) == 1
assert pdf_state['matches'][0]['expenseAmount'] == 468
assert sum(item['ocr']['status'] == 'failed' for item in pdf_state['invoices']) == 1
invoke(command, dict(operation='ppt', state=pdf_state, destination=str(output / 'PDF首页贴票.pptx')))
print('完整链路通过:5 份材料、2 组自动核对、总额 596.50;PPT、个人报销单、PDF 首页与损坏文件降级均通过。')
if __name__ == '__main__':
main()
+109
View File
@@ -0,0 +1,109 @@
import copy
import sys
import unittest
from decimal import Decimal
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from domain import auto_match, category, combinations, enrich, extract, invoice_amount, payment_total
def material(identifier, kind, amount, content='', dates=None, merchants=None):
ocr = extract(content.splitlines())
ocr.update(amounts=[amount] if isinstance(amount, str) else amount, dates=dates or [], merchants=merchants or [])
return dict(id=identifier, name=identifier + '.png', path=identifier, previewPath='', type=kind,
size=1, matched=False, ocr=ocr)
def workspace(invoices, payments):
return dict(rootPath='测试', invoices=invoices, payments=payments, photos=[], matches=[], warnings=[])
def train(identifier, traveler):
return material(identifier, 'invoice', '1058.00', f'铁路电子客票\n乘车人:{traveler}\n北京南 → 上海虹桥\n2026年06月24日 15:00\nG21次', ['2026-06-24'], ['中国铁路'])
class ExtractionTests(unittest.TestCase):
def test_train(self):
data = extract(['铁路电子客票', '乘车人:张三', '北京南 → 上海虹桥', '2026年08月12日 09:30', 'G101次', '票价 ¥553.00'])
self.assertEqual(data['travel'], dict(type='train', travelerName='张三', departure='北京南', destination='上海虹桥', departureTime='2026-08-12 09:30', transportNumber='G101'))
def test_flight(self):
travel = extract(['航空电子客票行程单', '旅客姓名:李四', '出发地:广州', '目的地:成都', '2026-08-15 14:20', '航班号 CZ3401'])['travel']
self.assertEqual((travel['type'], travel['travelerName'], travel['departure'], travel['destination'], travel['transportNumber']), ('flight', '李四', '广州', '成都', 'CZ3401'))
def test_invoice_date_is_not_departure(self):
travel = extract(['电子发票(铁路电子客票) 北京市税务局', '发票号码:26119110010005751560 开票日期:2026年06月26日', '北京南站 G21 上海虹桥站', '2026年06月24日 15:00开 17车04C号', '2107021990****0234 王天行', '票价:¥1058.00'])['travel']
self.assertEqual(travel['travelerName'], '王天行')
self.assertEqual(travel['departureTime'], '2026-06-24 15:00')
def test_invalid_date_does_not_abort_material(self):
self.assertEqual(extract(['2026年02月31日 金额 20'])['dates'], [])
def test_amount_order(self):
self.assertEqual(extract(['票价 ¥1058.00', '支付总额 2116'])['amounts'][0], '2116.00')
class MatchingTests(unittest.TestCase):
def test_original_many_to_many_fixture(self):
state = workspace([train('i1', '王天行'), train('i2', '王超文')], [
material('p1', 'payment', ['1058.00', '2116.00'], '12306订单 中国铁路 支付总额 2116', ['2026-06-24']),
material('p2', 'payment', '2116.00', '12306消费 中国铁路', ['2026-06-22'])])
auto_match(state)
self.assertEqual(len(state['matches']), 1)
match = state['matches'][0]
self.assertEqual((len(match['invoices']), len(match['payments']), match['score'], match['category']), (2, 2, 100, '交通'))
def test_shared_travel_payment_evidence(self):
state = workspace([train('i1', '张三'), train('i2', '李四')], [material('p1', 'payment', '2116.00', '12306 支付成功', ['2026-06-24'])])
auto_match(state)
self.assertEqual(state['matches'][0]['score'], 95)
def test_unique_amount(self):
state = workspace([material('i', 'invoice', '100.00')], [material('p', 'payment', '100.00')])
auto_match(state)
self.assertEqual(state['matches'][0]['score'], 75)
def test_ambiguous_stays_unmatched(self):
state = workspace([material('i', 'invoice', '100.00', dates=['2026-08-01'])], [material('p1', 'payment', '100.00', dates=['2026-08-01']), material('p2', 'payment', '100.00', dates=['2026-08-01'])])
self.assertFalse(auto_match(state)['matches'])
def test_high_score_keeps_original_tie_rule(self):
state = workspace([material('i', 'invoice', '100.00', dates=['2026-08-01'], merchants=['测试公司'])], [material('p1', 'payment', '100.00', dates=['2026-08-01'], merchants=['测试公司']), material('p2', 'payment', '100.00', dates=['2026-08-01'], merchants=['测试公司'])])
self.assertEqual(len(auto_match(state)['matches']), 1)
def test_split_payment(self):
state = workspace([material('i', 'invoice', '100.00')], [material('p1', 'payment', '30.00'), material('p2', 'payment', '70.00')])
self.assertEqual(len(auto_match(state)['matches'][0]['payments']), 2)
def test_ambiguous_split_payment(self):
state = workspace([material('i', 'invoice', '100.00')], [material('p1', 'payment', '30.00'), material('p2', 'payment', '70.00'), material('p3', 'payment', '40.00'), material('p4', 'payment', '60.00')])
self.assertFalse(auto_match(state)['matches'])
def test_filename_does_not_match(self):
state = workspace([material('same', 'invoice', '100.00')], [material('same', 'payment', '90.00')])
self.assertFalse(auto_match(state)['matches'])
def test_decimal_combinations(self):
options = combinations([('first', Decimal('.10')), ('second', Decimal('.20'))], Decimal('.30'))
self.assertEqual(options, [['first', 'second']])
def test_original_categories(self):
for content, expected in [('海棠酒店 住宿 房费 客房', '住宿'), ('餐厅 美团外卖 咖啡 饮品', '餐饮'), ('办公用品 打印纸 墨盒 文具', '办公用品'), ('京东商城 采购家具设备', '采购'), ('客户招待 商务宴请', '招待')]:
self.assertEqual(category([material('i', 'invoice', '100.00', content)]), expected)
def test_summary_duplicate(self):
self.assertEqual(payment_total([material('order', 'payment', '100.00', '订单详情 支付总额 100.00'), material('proof', 'payment', '100.00', '支付成功 实付金额 100.00')]), Decimal('100.00'))
def test_invoice_total_labeled(self):
self.assertEqual(invoice_amount(material('i', 'invoice', ['200.00', '100.00'], '发票金额 100.00')), Decimal('100.00'))
def test_enrich_preserves_group_identity(self):
state = workspace([material('i', 'invoice', '100.00')], [material('p', 'payment', '100.00')])
enrich(auto_match(state))
self.assertTrue(state['matches'][0]['invoices'][0]['matched'])
self.assertEqual(state['matches'][0]['expenseAmount'], 100)
if __name__ == '__main__':
unittest.main()
+95
View File
@@ -0,0 +1,95 @@
import copy
import tempfile
import unittest
import zipfile
from pathlib import Path
from test_domain import material, workspace
from domain import auto_match, enrich
from exports import export_expense, export_ppt, export_travel
from PIL import Image
from openpyxl import load_workbook
from pptx import Presentation
class ExportTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
image = self.root / 'receipt.png'
Image.new('RGB', (400, 200), 'white').save(image)
self.state = enrich(auto_match(workspace([material('i', 'invoice', '100.00')], [material('p', 'payment', '100.00')])))
for item in self.state['invoices'] + self.state['payments']:
item['previewPath'] = str(image)
self.template = Path(__file__).parents[1] / 'personal-expense-template.xlsx'
def tearDown(self):
self.temporary.cleanup()
def test_simple_ppt_with_photo_placeholder(self):
destination = self.root / 'test.pptx'
export_ppt(self.state, destination, True)
deck = Presentation(destination)
self.assertEqual(len(deck.slides), 3)
self.assertEqual(deck.slide_width, 1280 * 12700)
self.assertTrue(any('实物照片' in shape.text for shape in deck.slides[2].shapes if shape.has_text_frame))
def test_travel_category_excludes_photo(self):
self.state['matches'][0]['category'] = '交通'
destination = self.root / 'test.pptx'
export_ppt(self.state, destination, False)
self.assertEqual(len(Presentation(destination).slides), 2)
def test_complex_payment_pagination(self):
self.state['matches'][0]['payments'] *= 5
destination = self.root / 'test.pptx'
export_ppt(self.state, destination, True)
self.assertEqual(len(Presentation(destination).slides), 5)
def test_expense_template_values_and_formulas(self):
destination = self.root / 'expense.xlsx'
match = self.state['matches'][0]
export_expense(self.state, destination, self.template, {match['id']: '=不是公式'}, ['经办人'])
workbook = load_workbook(destination)
self.assertEqual(workbook.sheetnames, ['个人报销单'])
sheet = workbook.active
self.assertEqual(sheet['C9'].value, '=不是公式')
self.assertEqual(sheet['C9'].data_type, 's')
self.assertEqual(sheet['H9'].value, 100)
self.assertEqual(sheet['G9'].value, 1)
self.assertEqual(sheet['A30'].value, '经办人:')
self.assertEqual(sheet['D30'].value, '')
original = load_workbook(self.template)['1个人报销单']
formulas = {cell.coordinate: cell.value for row in original for cell in row if cell.data_type == 'f'}
self.assertTrue(formulas)
for coordinate, value in formulas.items():
self.assertEqual(sheet[coordinate].value, value)
self.assertEqual(str(sheet.print_area).split('!')[-1], str(original.print_area).split('!')[-1])
def test_expense_pagination(self):
self.state['matches'] = [copy.deepcopy(self.state['matches'][0]) for _ in range(14)]
destination = self.root / 'expense.xlsx'
export_expense(self.state, destination, self.template, {}, [])
workbook = load_workbook(destination)
self.assertEqual(workbook.sheetnames, ['个人报销单-1', '个人报销单-2'])
self.assertEqual(workbook.worksheets[1]['H9'].value, 100)
self.assertEqual(workbook.worksheets[1]['H10'].value, '')
self.assertEqual(workbook.worksheets[1]['A30'].value, '部门长:')
def test_travel_only_verified_invoices(self):
travel = self.state['invoices'][0]['ocr']['travel']
travel.update(type='train', travelerName='张三', departure='北京南', destination='上海虹桥', departureTime='2026-06-24 15:00', transportNumber='G21')
destination = self.root / 'travel.xlsx'
export_travel(self.state, destination)
sheet = load_workbook(destination).active
self.assertEqual(sheet.max_row, 2)
self.assertEqual(sheet['C2'].value, '张三')
self.assertEqual(sheet['G2'].value, 'G21')
def test_no_travel_raises(self):
with self.assertRaises(ValueError):
export_travel(self.state, self.root / 'travel.xlsx')
if __name__ == '__main__':
unittest.main()