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