Initial commit: macOS receipt workspace
This commit is contained in:
@@ -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)
|
||||
Binary file not shown.
@@ -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()
|
||||
Reference in New Issue
Block a user