269 lines
8.3 KiB
Python
269 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
||
"""KOCR v4 — 后处理模块
|
||
|
||
包含:字符纠错、大写金额转数字、大小写交叉校验、借贷平衡验证
|
||
"""
|
||
|
||
import os
|
||
import re
|
||
|
||
|
||
# ======== 财务字符纠错字典 ========
|
||
FINANCE_CHAR_FIX = {
|
||
"染": "柒", "壶": "壹", "式": "贰", "参": "叁", "伍": "伍",
|
||
"O": "0", "o": "0", "l": "1", "I": "1", "Z": "2", "z": "2",
|
||
"S": "5", "s": "5", "B": "8", "b": "8", "〇": "零"
|
||
}
|
||
|
||
|
||
def char_correct(text: str) -> str:
|
||
"""财务字符纠错"""
|
||
for wrong, right in FINANCE_CHAR_FIX.items():
|
||
text = text.replace(wrong, right)
|
||
return text
|
||
|
||
|
||
# ======== 大写金额转数字 ========
|
||
|
||
CN_NUM = {
|
||
'零': 0, '壹': 1, '贰': 2, '叁': 3, '肆': 4,
|
||
'伍': 5, '陆': 6, '柒': 7, '捌': 8, '玖': 9,
|
||
'无': 0, '正': 0, # 易误读的常见替换
|
||
}
|
||
|
||
CN_UNIT = {
|
||
'拾': 10, '佰': 100, '仟': 1000,
|
||
'万': 10000, '亿': 100000000,
|
||
'元': 1, '角': 0.1, '分': 0.01,
|
||
'整': 0,
|
||
}
|
||
|
||
|
||
def chinese_amount_to_number(cn_str: str) -> float:
|
||
"""大写金额转阿拉伯数字
|
||
|
||
支持格式:
|
||
- 壹万贰仟叁佰肆拾伍元陆角柒分 → 12345.67
|
||
- 壹仟贰佰叁拾肆元整 → 1234.00
|
||
- 拾元整 → 10.00
|
||
- 壹佰贰拾叁元零角零分 → 123.00
|
||
|
||
Args:
|
||
cn_str: 中文大写金额字符串
|
||
|
||
Returns:
|
||
转换后的数字,失败返回 0.0
|
||
"""
|
||
if not cn_str:
|
||
return 0.0
|
||
|
||
# 清理字符串
|
||
cn_str = cn_str.strip().replace(' ', '').replace('(', '').replace(')', '')
|
||
|
||
# 去掉前缀(如 "合计:")
|
||
for prefix in ['合计', '合计大写', '人民币', 'RMB', '¥', '¥']:
|
||
if cn_str.startswith(prefix):
|
||
cn_str = cn_str[len(prefix):]
|
||
break
|
||
|
||
total = 0.0
|
||
temp = 0.0 # 当前节的值(万以下)
|
||
|
||
i = 0
|
||
while i < len(cn_str):
|
||
char = cn_str[i]
|
||
|
||
if char in CN_NUM:
|
||
temp = CN_NUM[char] # 暂存数字
|
||
elif char in CN_UNIT:
|
||
unit = CN_UNIT[char]
|
||
|
||
if char in ('万', '亿'):
|
||
# 万/亿:整节结算
|
||
total = (total + temp) * unit if temp > 0 else total * unit
|
||
temp = 0.0
|
||
elif char == '元':
|
||
# 元:加整数部分
|
||
total += temp if temp > 0 else 0
|
||
temp = 0.0
|
||
elif char in ('角', '分'):
|
||
# 角/分:加小数部分
|
||
total += temp * unit
|
||
temp = 0.0
|
||
else:
|
||
# 拾/佰/仟:节内进位
|
||
if temp == 0:
|
||
temp = 1 # "拾元" → 10元
|
||
total += temp * unit
|
||
temp = 0.0
|
||
elif char == '零':
|
||
# 零:跳过,继续
|
||
if i + 1 < len(cn_str) and cn_str[i + 1] in CN_UNIT:
|
||
# 零后面有单位 → 如"壹万零伍佰"
|
||
pass
|
||
temp = 0
|
||
elif char == '整':
|
||
# 整:结束
|
||
break
|
||
|
||
i += 1
|
||
|
||
# 处理末尾剩余
|
||
if temp > 0:
|
||
total += temp
|
||
|
||
return round(total, 2)
|
||
|
||
|
||
def amount_check(amount_num: float, amount_cn: str) -> bool:
|
||
"""大小写金额交叉验证
|
||
|
||
检查 OCR 识别的阿拉伯数字合计与中文大写合计是否匹配
|
||
|
||
Args:
|
||
amount_num: 阿拉伯数字合计(从 OCR 金额列解析)
|
||
amount_cn: 中文大写合计(从 OCR 文本栏解析)
|
||
|
||
Returns:
|
||
True if 差值 ≤ 0.01
|
||
"""
|
||
if not amount_cn or amount_num <= 0:
|
||
return True # 没有大写则跳过校验
|
||
|
||
cn_num = chinese_amount_to_number(amount_cn)
|
||
if cn_num <= 0:
|
||
return True # 大写解析失败,跳过
|
||
|
||
diff = abs(amount_num - cn_num)
|
||
return diff <= 0.01
|
||
|
||
|
||
# ======== 数据验证 ========
|
||
|
||
def validate_voucher(voucher_data: dict, balance_tolerance: float = 0.01) -> tuple:
|
||
"""验证 OCR 识别结果的完整性和正确性
|
||
|
||
Args:
|
||
voucher_data: K3VoucherParser.parse() 的输出
|
||
balance_tolerance: 借贷平衡允许误差(元)
|
||
|
||
Returns:
|
||
(is_clean, issues, summary_text)
|
||
"""
|
||
issues = []
|
||
warnings = []
|
||
entries = voucher_data.get('entries', [])
|
||
|
||
if not entries:
|
||
return False, ['❌ 未识别到任何分录'], '无分录'
|
||
|
||
# 1. 借贷平衡检查
|
||
total_dr = sum(e.get('debit', 0) or 0 for e in entries)
|
||
total_cr = sum(e.get('credit', 0) or 0 for e in entries)
|
||
balance_diff = abs(total_dr - total_cr)
|
||
|
||
if balance_diff > balance_tolerance:
|
||
issues.append(f'❌ 借贷不平衡: 借 {total_dr:.2f} ≠ 贷 {total_cr:.2f} (差 {balance_diff:.2f})')
|
||
|
||
# 2. 大小写金额交叉验证(如有可能)
|
||
total_str = voucher_data.get('total_str', '')
|
||
if total_str and total_dr > 0:
|
||
# 从合计字符串中提取大写金额
|
||
cn_part = re.sub(r'[\d,.\s¥¥]', '', total_str)
|
||
if cn_part:
|
||
cn_ok = amount_check(total_dr, cn_part)
|
||
if not cn_ok:
|
||
cn_val = chinese_amount_to_number(cn_part)
|
||
issues.append(f'❌ 大小写金额不匹配: 小写 {total_dr:.2f} ≠ 大写 {cn_part}(={cn_val:.2f})')
|
||
else:
|
||
warnings.append(f'✅ 大小写金额验证通过')
|
||
|
||
# 3. 金额合理性检查
|
||
for e in entries:
|
||
for side in ['debit', 'credit']:
|
||
val = e.get(side, 0) or 0
|
||
if val <= 0:
|
||
continue
|
||
if val > 99999999:
|
||
issues.append(f'⚠️ 金额异常过大: {e["account_code"]} {side}={val:.2f}')
|
||
if val < 0.01 and val > 0:
|
||
warnings.append(f' 金额异常小: {e["account_code"]} {side}={val:.2f}')
|
||
|
||
# 4. 科目代码检查
|
||
for e in entries:
|
||
code = e.get('account_code', '')
|
||
if not code or len(code) < 4:
|
||
warnings.append(f' 科目代码异常: {code} {e.get("account_name","")}')
|
||
|
||
# 构建摘要
|
||
is_clean = len(issues) == 0
|
||
summary_lines = []
|
||
if is_clean and not warnings:
|
||
summary_lines.append('✅ 验证通过(借贷平衡 + 金额合理)')
|
||
else:
|
||
summary_lines.extend(issues)
|
||
summary_lines.extend(warnings)
|
||
|
||
return is_clean, issues + warnings, '\n'.join(summary_lines)
|
||
|
||
|
||
def generate_review_file(results: list, output_dir: str) -> str:
|
||
"""生成批量处理的校验报告"""
|
||
from datetime import datetime
|
||
|
||
review_path = os.path.join(output_dir, '校验报告.txt')
|
||
lines = []
|
||
lines.append('=' * 50)
|
||
lines.append('KOCR v4 数据校验报告')
|
||
lines.append(f'生成时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
|
||
lines.append(f'处理张数: {len(results)}')
|
||
lines.append('=' * 50)
|
||
lines.append('')
|
||
|
||
all_clean = True
|
||
for fname, voucher, out_path, quality in results:
|
||
is_clean, issues, summary = validate_voucher(voucher)
|
||
if not is_clean:
|
||
all_clean = False
|
||
date_str = voucher['date'].strftime('%Y-%m-%d') if voucher.get('date') else '?'
|
||
vno = voucher.get('voucher_no', '?')
|
||
lines.append(f'📷 {fname}')
|
||
lines.append(f' 凭证: 记-{vno} 日期: {date_str}')
|
||
if quality:
|
||
lines.append(f' 类型: {quality.photo_type} 亮度:{quality.brightness} 模糊:{quality.blur_score}')
|
||
lines.append(f' {summary}')
|
||
lines.append('')
|
||
|
||
if all_clean:
|
||
lines.append('🎉 全部凭证验证通过')
|
||
else:
|
||
lines.append('⚠️ 存在需要人工复核的数据,请对照原始凭证核对')
|
||
|
||
lines.append('=' * 50)
|
||
|
||
with open(review_path, 'w', encoding='utf-8') as f:
|
||
f.write('\n'.join(lines))
|
||
|
||
return review_path
|
||
|
||
|
||
|
||
def validate_merged_vouchers(merged_list, balance_tolerance=0.01):
|
||
"""对合并后的凭证列表做完整校验
|
||
|
||
每笔凭证(多页已合并)统一做借贷平衡 + 大小写金额验证。
|
||
这才是最终的业务校验结果。
|
||
|
||
Args:
|
||
merged_list: merge_voucher_data() 的输出
|
||
balance_tolerance: 借贷平衡容差
|
||
|
||
Returns:
|
||
[(voucher, is_clean, issues, summary_text), ...]
|
||
"""
|
||
results = []
|
||
for voucher in merged_list:
|
||
is_clean, issues, summary = validate_voucher(voucher, balance_tolerance)
|
||
results.append((voucher, is_clean, issues, summary))
|
||
return results
|