691 lines
27 KiB
Python
691 lines
27 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
KOCR v4 — 金蝶 K3 凭证 OCR 填表工具
|
||
小唯 A06 出品 | 自动调参版
|
||
|
||
用法:
|
||
python3 kocr.py # 批量处理所有新照片
|
||
python3 kocr.py --new # 只处理未识别过的照片
|
||
python3 kocr.py --status # 查看处理进度
|
||
python3 kocr.py IMG_001.jpg # 单张处理
|
||
python3 kocr.py --merge # 合并输出排序好的汇总 Excel
|
||
python3 kocr.py --config my.yaml # 指定配置文件
|
||
|
||
特性:
|
||
- 自动图像分析 + 自动调参(按照片亮度/阴影/模糊度选参数)
|
||
- 大小写金额交叉验证(大写转数字比对)
|
||
- PPStructure TableMaster 表格识别(失败回退 DB+SVTR)
|
||
- 交错匹配(dr1, cr1, dr2, cr2...)
|
||
- 多页凭证自动合并
|
||
- 外置配置文件(部署时只需调整 config.yaml)
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import json
|
||
import argparse
|
||
import yaml
|
||
import cv2
|
||
|
||
# 项目根目录
|
||
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.insert(0, PROJECT_DIR)
|
||
|
||
from core.recognizer import run_ocr
|
||
from core.preprocessor import analyze_image, VoucherPreprocessor
|
||
from core.parser import K3VoucherParser
|
||
from core.postprocessor import validate_voucher, validate_merged_vouchers, generate_review_file
|
||
from core.excel_writer import fill_template, merge_output, merge_voucher_data
|
||
|
||
# ======== 科目代码标准映射表 ========
|
||
CODE_MAP_PATH = os.path.join(PROJECT_DIR, 'code_map.json')
|
||
_code_map_cache = None
|
||
|
||
def get_code_map():
|
||
global _code_map_cache
|
||
if _code_map_cache is not None:
|
||
return _code_map_cache
|
||
if os.path.exists(CODE_MAP_PATH):
|
||
with open(CODE_MAP_PATH, 'r', encoding='utf-8') as f:
|
||
_code_map_cache = json.load(f)
|
||
else:
|
||
_code_map_cache = {}
|
||
return _code_map_cache
|
||
|
||
def update_code_map(new_entries: dict):
|
||
"""更新映射表(用于后续完善)"""
|
||
current = get_code_map()
|
||
current.update(new_entries)
|
||
with open(CODE_MAP_PATH, 'w', encoding='utf-8') as f:
|
||
json.dump(current, f, ensure_ascii=False, indent=2)
|
||
global _code_map_cache
|
||
_code_map_cache = current
|
||
|
||
def get_reverse_code_map():
|
||
"""名称 → 代码 反向映射(精确匹配)"""
|
||
code_map = get_code_map()
|
||
return {v: k for k, v in code_map.items()}
|
||
|
||
def fuzzy_find_code(name_or_fragment: str):
|
||
"""根据名称部分字段模糊查找代码
|
||
|
||
匹配规则:名称包含该字段,或字段包含该名称
|
||
返回最长的匹配项(最具体)
|
||
"""
|
||
code_map = get_code_map()
|
||
name_or_fragment = name_or_fragment.strip()
|
||
if not name_or_fragment or len(name_or_fragment) < 2:
|
||
return None
|
||
|
||
best_code = None
|
||
best_len = 0
|
||
for code, name in code_map.items():
|
||
if name_or_fragment in name or name in name_or_fragment:
|
||
if len(name) > best_len:
|
||
best_code = code
|
||
best_len = len(name)
|
||
return best_code
|
||
|
||
|
||
# ======== 路径常量 ========
|
||
DEFAULT_CONFIG = os.path.join(PROJECT_DIR, 'config.yaml')
|
||
BATCH_INPUT_DIR = os.path.join(PROJECT_DIR, 'input')
|
||
BATCH_INPUT_DONE = os.path.join(PROJECT_DIR, 'input/done')
|
||
BATCH_OUTPUT_DIR = os.path.join(PROJECT_DIR, 'output')
|
||
INDEX_FILE = os.path.join(PROJECT_DIR, '.kocr_index.json')
|
||
|
||
|
||
# ======== 配置加载 ========
|
||
def load_config(config_path=None):
|
||
"""加载 YAML 配置,支持命令行覆盖"""
|
||
path = config_path or DEFAULT_CONFIG
|
||
if not os.path.exists(path):
|
||
print(f"⚠️ 配置文件不存在: {path},使用默认配置")
|
||
return _default_config()
|
||
|
||
with open(path, 'r', encoding='utf-8') as f:
|
||
config = yaml.safe_load(f)
|
||
return config
|
||
|
||
|
||
def _default_config():
|
||
"""默认配置(当 config.yaml 不存在时使用)"""
|
||
return {
|
||
'preprocessing': {
|
||
'max_side': 1600,
|
||
'denoise': True,
|
||
'deskew': True,
|
||
'remove_shadow': True,
|
||
'sharpen': True,
|
||
},
|
||
'ocr': {
|
||
'use_gpu': False,
|
||
'lang': 'ch',
|
||
'show_log': False,
|
||
'use_angle_cls': True,
|
||
'det_db_thresh': 0.3,
|
||
'det_db_box_thresh': 0.5,
|
||
'det_db_unclip_ratio': 2.2,
|
||
'max_side_len': 1600,
|
||
'det_db_score_mode': 'slow',
|
||
'min_size': 8,
|
||
'rec_drop_score': 0.5,
|
||
},
|
||
'postprocessing': {
|
||
'amount_balance_tolerance': 0.01,
|
||
},
|
||
'paths': {
|
||
'input_dir': 'input',
|
||
'output_dir': 'output',
|
||
'done_dir': 'input/done',
|
||
'template': '~/.hermes/cache/documents/doc_704eac18442d_凭证模板.xls',
|
||
},
|
||
'photo_analysis': {
|
||
'brightness_thresholds': {'flash': 200, 'dark': 60},
|
||
'blur_threshold': 100,
|
||
'shadow_threshold': 0.3,
|
||
},
|
||
}
|
||
|
||
|
||
# ======== 索引管理 ========
|
||
def load_index():
|
||
if os.path.exists(INDEX_FILE):
|
||
try:
|
||
with open(INDEX_FILE, 'r', encoding='utf-8') as f:
|
||
return yaml.safe_load(f) or {}
|
||
except:
|
||
pass
|
||
return {}
|
||
|
||
|
||
def save_index(idx):
|
||
os.makedirs(os.path.dirname(INDEX_FILE), exist_ok=True)
|
||
with open(INDEX_FILE, 'w', encoding='utf-8') as f:
|
||
yaml.dump(idx, f, allow_unicode=True, default_flow_style=False)
|
||
|
||
|
||
def mark_processed(filename, voucher_info, output_path):
|
||
idx = load_index()
|
||
idx[filename] = {
|
||
'voucher_word': voucher_info.get('voucher_word', '记'),
|
||
'voucher_no': voucher_info.get('voucher_no'),
|
||
'date': voucher_info.get('date').strftime('%Y-%m-%d') if voucher_info.get('date') else '',
|
||
'entries': len(voucher_info.get('entries', [])),
|
||
'total_debit': round(sum(e.get('debit', 0) or 0 for e in voucher_info.get('entries', [])), 2),
|
||
'total_credit': round(sum(e.get('credit', 0) or 0 for e in voucher_info.get('entries', [])), 2),
|
||
'output': os.path.basename(output_path) if output_path else '',
|
||
'processed_at': __import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||
}
|
||
save_index(idx)
|
||
|
||
|
||
def is_processed(filename):
|
||
idx = load_index()
|
||
return filename in idx
|
||
|
||
|
||
# ======== PDF 页面提取 ========
|
||
def extract_pdf_pages(pdf_path, output_dir):
|
||
"""将 PDF 每页转换为 JPG 图片
|
||
|
||
直接输出到 output_dir,命名格式:_{pdf_name}_p{n}.jpg
|
||
返回 [(page_path, page_label), ...]
|
||
"""
|
||
import subprocess
|
||
import re
|
||
|
||
pdf_name = os.path.splitext(os.path.basename(pdf_path))[0]
|
||
|
||
# pdftoppm: 每页生成 {prefix}-{页码}.jpg
|
||
result = subprocess.run(
|
||
['pdftoppm', '-jpeg', '-r', '300', pdf_path, os.path.join(output_dir, pdf_name)],
|
||
capture_output=True, text=True, timeout=120
|
||
)
|
||
if result.returncode != 0:
|
||
raise RuntimeError(f"pdftoppm 失败: {result.stderr}")
|
||
|
||
# 收集生成的图片 → 重命名为 _{pdf_name}_p{n}.jpg
|
||
pages = sorted([
|
||
f for f in os.listdir(output_dir)
|
||
if f.startswith(pdf_name) and f.lower().endswith('.jpg')
|
||
])
|
||
|
||
result_list = []
|
||
for p in pages:
|
||
old_path = os.path.join(output_dir, p)
|
||
# 从文件名提取页码,如 test-01.jpg → _test_p1.jpg
|
||
m = re.search(r'-(\d+)\.jpg$', p)
|
||
page_num = int(m.group(1)) if m else 0
|
||
new_name = f"_{pdf_name}_p{page_num}.jpg"
|
||
new_path = os.path.join(output_dir, new_name)
|
||
os.rename(old_path, new_path)
|
||
|
||
# 统一图片尺寸:缩放+白边到 3072×4096(对齐照片坐标)
|
||
# 解析器硬编码了 x/y 坐标阈值(适配 3072×4096 的照片)
|
||
# PDF 图片尺寸和比例不同,必须统一到照片尺寸才不走样
|
||
try:
|
||
import cv2
|
||
img = cv2.imread(new_path)
|
||
if img is not None and (img.shape[1] != 3072 or img.shape[0] != 4096):
|
||
h, w = img.shape[:2]
|
||
# 计算缩放比例(保持宽高比,适配目标尺寸)
|
||
scale = min(3072 / w, 4096 / h)
|
||
new_w = int(w * scale)
|
||
new_h = int(h * scale)
|
||
# 缩放
|
||
resized = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_CUBIC)
|
||
# 中心对齐,加白边补齐到 3072×4096
|
||
pad_top = (4096 - new_h) // 2
|
||
pad_bottom = 4096 - new_h - pad_top
|
||
pad_left = (3072 - new_w) // 2
|
||
pad_right = 3072 - new_w - pad_left
|
||
padded = cv2.copyMakeBorder(resized, pad_top, pad_bottom,
|
||
pad_left, pad_right,
|
||
cv2.BORDER_CONSTANT, value=[255, 255, 255])
|
||
cv2.imwrite(new_path, padded)
|
||
except Exception as e:
|
||
pass # 缩放失败不影响主流程
|
||
|
||
page_label = f"第{page_num}页" if m else os.path.basename(pdf_path)
|
||
result_list.append((new_path, page_label))
|
||
|
||
return result_list
|
||
|
||
|
||
def cleanup_pdf_temp_files(input_path):
|
||
"""清理 PDF 提取的临时图片(以下划线开头的 JPG)"""
|
||
for f in os.listdir(input_path):
|
||
if f.startswith('_') and f.lower().endswith('.jpg'):
|
||
try:
|
||
os.remove(os.path.join(input_path, f))
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
# ======== 单张凭证处理 ========
|
||
def process_single(image_path, config, output_path=None, verbose=False, mark_done=True):
|
||
"""处理单张凭证照片
|
||
|
||
流程: 原图 → PaddleOCR → 解析 → 填Excel → 验证
|
||
|
||
Returns:
|
||
(voucher_data, quality)
|
||
"""
|
||
filename = os.path.basename(image_path)
|
||
|
||
if verbose:
|
||
print(f"\n📷 {filename}")
|
||
|
||
# 1. 读取图像
|
||
img = cv2.imread(image_path)
|
||
if img is None:
|
||
raise ValueError(f"无法读取图片: {image_path}")
|
||
|
||
ocr_config = config.get('ocr', {})
|
||
|
||
# 2. 双管线 OCR:原图 + 预处理图,合并结果
|
||
if verbose:
|
||
print(f" 📊 分析图片质量...")
|
||
quality = analyze_image(img)
|
||
|
||
if verbose:
|
||
print(f" 📊 照片类型: {quality.photo_type} (亮度={quality.brightness:.0f}, 阴影比={quality.shadow_ratio:.2f})")
|
||
|
||
# 管线1: 原图OCR(保留横杠标记/续行结构)
|
||
if verbose:
|
||
print(f" 📋 管线1: 原图OCR...")
|
||
lines1 = run_ocr(img, ocr_config, photo_type=quality.photo_type)
|
||
|
||
# 管线2: 预处理后OCR(去阴影/纠偏/锐化,提高文字识别率)
|
||
preprocess_config = config.get('preprocessing', {})
|
||
preprocessor = VoucherPreprocessor.from_config(preprocess_config, quality.photo_type)
|
||
img_processed = preprocessor.process(img)
|
||
|
||
if verbose:
|
||
print(f" 📋 管线2: 预处理图OCR...")
|
||
lines2 = run_ocr(img_processed, ocr_config, photo_type=quality.photo_type)
|
||
|
||
# 合并双管线结果:按 y 坐标邻近去重
|
||
all_lines = lines1 + lines2
|
||
# 按 y 排序后,合并相近位置的重复文本
|
||
all_lines.sort(key=lambda l: (l[3], l[2]))
|
||
merged_lines = []
|
||
for line in all_lines:
|
||
is_dup = False
|
||
for existing in merged_lines:
|
||
if abs(line[3] - existing[3]) < 20 and abs(line[2] - existing[2]) < 50:
|
||
# 同位置,保留文本更长的
|
||
if len(line[0].strip()) > len(existing[0].strip()):
|
||
existing = line
|
||
is_dup = True
|
||
break
|
||
if not is_dup:
|
||
merged_lines.append(line)
|
||
|
||
if verbose:
|
||
print(f" 📋 双管线合并: {len(lines1)}+{len(lines2)} → {len(merged_lines)} 行去重")
|
||
|
||
# 3. 解析(使用双管线合并后的数据)
|
||
parser = K3VoucherParser(merged_lines)
|
||
voucher = parser.parse()
|
||
|
||
# 3.5 双向对照表校正(代码→名称 + 名称→代码反查)
|
||
CODE_MAP = get_code_map()
|
||
REVERSE_MAP = get_reverse_code_map()
|
||
corrections = 0
|
||
for e in voucher['entries']:
|
||
code = e.get('account_code', '')
|
||
name = e.get('account_name', '')
|
||
|
||
# 正向:代码在映射表中 → 用标准名称
|
||
if code in CODE_MAP:
|
||
std_name = CODE_MAP[code]
|
||
if name != std_name:
|
||
if verbose:
|
||
print(f" 🔄 名称修正: {code} [{name}] → [{std_name}]")
|
||
e['account_name'] = std_name
|
||
corrections += 1
|
||
|
||
# 反向:名称精确匹配 → 用标准代码
|
||
if name in REVERSE_MAP:
|
||
std_code = REVERSE_MAP[name]
|
||
if code != std_code:
|
||
if verbose:
|
||
print(f" 🔄 代码修正: [{code}] → [{std_code}] (名称={name})")
|
||
e['account_code'] = std_code
|
||
corrections += 1
|
||
|
||
# 模糊反向:代码不在映射表但名称部分匹配 → 反查
|
||
if code not in CODE_MAP and name:
|
||
fuzzy_code = fuzzy_find_code(name)
|
||
if fuzzy_code:
|
||
std_name = CODE_MAP[fuzzy_code]
|
||
if verbose:
|
||
print(f" 🔄 模糊反查: [{code},{name}] → [{fuzzy_code},{std_name}]")
|
||
e['account_code'] = fuzzy_code
|
||
e['account_name'] = std_name
|
||
corrections += 1
|
||
|
||
if verbose and corrections > 0:
|
||
print(f" ✅ 对照表双向修正: {corrections} 处")
|
||
|
||
if verbose:
|
||
print(f" 公司: {voucher['company']}")
|
||
print(f" 日期: {voucher['date'].strftime('%Y-%m-%d') if voucher['date'] else '未识别'}")
|
||
print(f" 凭证: {voucher['voucher_word']}-{voucher['voucher_no']}")
|
||
print(f" 分录: {len(voucher['entries'])} 行")
|
||
for e in voucher['entries']:
|
||
dr = f"借 {e['debit']:.2f}" if e['debit'] else ""
|
||
cr = f"贷 {e['credit']:.2f}" if e['credit'] else ""
|
||
print(f" {e['account_code']} {e['account_name']:20s} {dr:>12s} {cr:>12s}")
|
||
|
||
# 4. 大小写金额交叉验证
|
||
if voucher['total_str'] and voucher['total_amount'] > 0:
|
||
from core.postprocessor import amount_check
|
||
total_dr = sum(e.get('debit', 0) or 0 for e in voucher['entries'])
|
||
total_cr = sum(e.get('credit', 0) or 0 for e in voucher['entries'])
|
||
total_max = max(total_dr, total_cr)
|
||
if total_max > 0:
|
||
cn_part = __import__('re').sub(r'[\\d,.\\s¥¥]', '', voucher['total_str'])
|
||
if cn_part:
|
||
ok = amount_check(total_max, cn_part)
|
||
if verbose:
|
||
status = '✅' if ok else '❌'
|
||
print(f" 大小写校验: {status} 小写={total_max:.2f} 大写={cn_part}")
|
||
|
||
# 5. 填 Excel
|
||
if output_path is None:
|
||
basename = os.path.splitext(filename)[0]
|
||
output_path = os.path.join(BATCH_OUTPUT_DIR, f'{basename}_K3.xls')
|
||
|
||
paths_config = config.get('paths', {})
|
||
template = os.path.expanduser(paths_config.get('template', '~/.hermes/cache/documents/doc_704eac18442d_凭证模板.xls'))
|
||
|
||
fill_template(voucher, template, output_path, verbose=verbose)
|
||
|
||
# 6. 标记已处理
|
||
if mark_done:
|
||
mark_processed(filename, voucher, output_path)
|
||
|
||
return voucher, None
|
||
|
||
|
||
# ======== 状态报告 ========
|
||
def show_status(config):
|
||
paths_config = config.get('paths', {})
|
||
input_dir = os.path.expanduser(paths_config.get('input_dir', 'input'))
|
||
done_dir = os.path.expanduser(paths_config.get('done_dir', 'input/done'))
|
||
|
||
idx = load_index()
|
||
|
||
exts = ('.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif', '.webp')
|
||
pending_files = sorted([f for f in os.listdir(input_dir) if f.lower().endswith(exts)]) if os.path.exists(input_dir) else []
|
||
pdf_files = sorted([f for f in os.listdir(input_dir) if f.lower().endswith('.pdf')]) if os.path.exists(input_dir) else []
|
||
done_files = sorted([f for f in os.listdir(done_dir) if f.lower().endswith(exts)]) if os.path.exists(done_dir) else []
|
||
|
||
processed = set(idx.keys())
|
||
|
||
print("📊 KOCR v4 处理状态")
|
||
print(f" {'='*40}")
|
||
print(f" 索引记录: {len(processed)} 张已处理")
|
||
print(f" input/ 待处理: {len(pending_files)} 张图片")
|
||
if pdf_files:
|
||
print(f" input/ 待转换 PDF: {len(pdf_files)} 个")
|
||
print(f" done/ 已归档: {len(done_files)} 张")
|
||
print()
|
||
|
||
if idx:
|
||
print(" 最近处理的:")
|
||
sorted_items = sorted(
|
||
idx.items(),
|
||
key=lambda x: (x[1].get('date', ''), x[1].get('voucher_no', 0)),
|
||
reverse=True
|
||
)[:10]
|
||
for fname, info in sorted_items:
|
||
v = f"{info.get('voucher_word', '记')}-{info.get('voucher_no', '?')}"
|
||
d = info.get('date', '')
|
||
e = info.get('entries', 0)
|
||
t = info.get('processed_at', '')
|
||
print(f" {fname:30s} → {v:>8s} {d} {e}笔分录 {t}")
|
||
|
||
if pending_files:
|
||
new_pending = [f for f in pending_files if f not in processed]
|
||
if new_pending:
|
||
print(f"\n 📋 未处理的新照片 ({len(new_pending)} 张):")
|
||
for f in new_pending[:10]:
|
||
print(f" {f}")
|
||
if len(new_pending) > 10:
|
||
print(f" ... 还有 {len(new_pending)-10} 张")
|
||
|
||
print()
|
||
|
||
|
||
# ======== 主入口 ========
|
||
def main():
|
||
parser = argparse.ArgumentParser(description='KOCR v4 — 金蝶 K3 凭证 OCR 填表工具')
|
||
parser.add_argument('input', nargs='?', default=None,
|
||
help='凭证照片路径(留空则批量处理 input 目录)')
|
||
parser.add_argument('--output', '-o', default=None, help='输出 Excel 路径')
|
||
parser.add_argument('--config', '-c', default=DEFAULT_CONFIG, help='配置文件路径')
|
||
parser.add_argument('--verbose', '-v', action='store_true', help='显示详细结果')
|
||
parser.add_argument('--status', '-s', action='store_true', help='显示处理进度')
|
||
parser.add_argument('--new', '-n', action='store_true', help='只处理未识别过的新照片')
|
||
parser.add_argument('--archive', '-a', action='store_true', help='归档已处理的照片到 done/')
|
||
parser.add_argument('--review', '-r', action='store_true', help='生成数据校验报告')
|
||
parser.add_argument('--merge', '-m', action='store_true', help='合并输出汇总 Excel')
|
||
parser.add_argument('--force', '-f', action='store_true', help='强制重新处理所有图片')
|
||
args = parser.parse_args()
|
||
|
||
# 加载配置
|
||
config = load_config(args.config)
|
||
|
||
# 路径
|
||
paths_config = config.get('paths', {})
|
||
input_dir = os.path.expanduser(paths_config.get('input_dir', BATCH_INPUT_DIR))
|
||
output_dir = os.path.expanduser(paths_config.get('output_dir', BATCH_OUTPUT_DIR))
|
||
done_dir = os.path.expanduser(paths_config.get('done_dir', BATCH_INPUT_DONE))
|
||
template_path = os.path.expanduser(paths_config.get('template', '~/.hermes/cache/documents/doc_704eac18442d_凭证模板.xls'))
|
||
|
||
# 确保目录存在
|
||
for d in [input_dir, done_dir, output_dir]:
|
||
os.makedirs(d, exist_ok=True)
|
||
|
||
if not os.path.exists(template_path):
|
||
print(f"❌ 找不到模板: {template_path}")
|
||
sys.exit(1)
|
||
|
||
# ---- 状态查看 ----
|
||
if args.status:
|
||
show_status(config)
|
||
return
|
||
|
||
# ---- 归档 ----
|
||
if args.archive:
|
||
idx = load_index()
|
||
if not os.path.exists(input_dir):
|
||
print("📭 input 目录不存在")
|
||
return
|
||
moved = 0
|
||
for fname in os.listdir(input_dir):
|
||
if fname in idx:
|
||
src = os.path.join(input_dir, fname)
|
||
dst = os.path.join(done_dir, fname)
|
||
os.rename(src, dst)
|
||
moved += 1
|
||
print(f"📦 已归档 {moved} 张照片到 input/done/")
|
||
return
|
||
|
||
# ---- 确定文件列表 ----
|
||
input_path = args.input or input_dir
|
||
|
||
if os.path.isdir(input_path):
|
||
# 批量模式
|
||
img_exts = ('.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif', '.webp')
|
||
files = sorted([f for f in os.listdir(input_path) if f.lower().endswith(img_exts)])
|
||
|
||
# PDF 文件检测与提取
|
||
pdf_files = sorted([f for f in os.listdir(input_path) if f.lower().endswith('.pdf')])
|
||
if pdf_files:
|
||
print(f"📄 发现 {len(pdf_files)} 个 PDF 文件,正在提取页面...")
|
||
for pdf_file in pdf_files:
|
||
pdf_path = os.path.join(input_path, pdf_file)
|
||
try:
|
||
pages = extract_pdf_pages(pdf_path, input_path)
|
||
# 将页面图片加入待处理列表
|
||
page_fnames = [os.path.basename(p) for p, _ in pages]
|
||
files.extend(page_fnames)
|
||
print(f" 📄 {pdf_file} → {len(pages)} 页")
|
||
except Exception as e:
|
||
print(f" ❌ {pdf_file} 提取失败: {e}")
|
||
|
||
if not files:
|
||
print(f"📭 {input_path} 中没有图片或 PDF 文件")
|
||
sys.exit(0)
|
||
|
||
# --new 模式过滤(PDF 临时页面以下划线开头,始终视为新文件)
|
||
idx = load_index()
|
||
if args.new or not args.force:
|
||
new_files = [f for f in files if f not in idx or f.startswith('_')]
|
||
skipped = len(files) - len(new_files)
|
||
files = new_files
|
||
if skipped > 0:
|
||
print(f"⏭️ 跳过 {skipped} 张已处理过的照片")
|
||
if not files:
|
||
print("✅ 全部照片都已处理过了,无需重复识别")
|
||
return
|
||
|
||
print(f"📂 KOCR v4 — 批量处理: {len(files)} 张凭证")
|
||
print(f" 输入: {input_path}")
|
||
print(f" 输出: {output_dir}")
|
||
print(f" {'='*40}")
|
||
|
||
results = []
|
||
for i, fname in enumerate(files):
|
||
fpath = os.path.join(input_path, fname)
|
||
basename = os.path.splitext(fname)[0]
|
||
out_path = os.path.join(output_dir, f'{basename}_K3.xls')
|
||
|
||
print(f" [{i+1}/{len(files)}] {fname}")
|
||
try:
|
||
voucher, quality = process_single(fpath, config, out_path, verbose=args.verbose)
|
||
vno = voucher.get('voucher_no', '?')
|
||
vdate = voucher['date'].strftime('%Y-%m-%d') if voucher.get('date') else '?'
|
||
results.append((fname, voucher, out_path, quality))
|
||
print(f" ✅ ['raw'] {vdate} 记-{vno} → {os.path.basename(out_path)}")
|
||
except Exception as e:
|
||
print(f" ❌ 失败: {e}")
|
||
print()
|
||
|
||
# ---- 合并凭证数据(多页合并)+ 统一校验 ----
|
||
merged_vouchers = merge_voucher_data(results)
|
||
|
||
# ---- 科目名称归一化:同一代码取最高频名称 ---- #
|
||
from collections import Counter
|
||
code_name_counter = Counter()
|
||
code_name_votes = {}
|
||
for v in merged_vouchers:
|
||
for e in v['entries']:
|
||
c = e['account_code']
|
||
n = e['account_name']
|
||
if n and len(n) >= 2:
|
||
code_name_counter[(c, n)] += 1
|
||
# 每个代码选最高频名称
|
||
code_best_name = {}
|
||
for (c, n), cnt in code_name_counter.items():
|
||
if c not in code_best_name or cnt > code_best_name[c][1]:
|
||
code_best_name[c] = (n, cnt)
|
||
# 输出差异
|
||
for v in merged_vouchers:
|
||
for e in v['entries']:
|
||
c = e['account_code']
|
||
if c in code_best_name:
|
||
best_n, _ = code_best_name[c]
|
||
if e['account_name'] != best_n:
|
||
e['account_name'] = best_n
|
||
|
||
# 写回流到 results(merge_output 用 results 写文件)
|
||
for fname, voucher, out_path, quality in results:
|
||
if voucher.get('entries'):
|
||
for e in voucher['entries']:
|
||
c = e['account_code']
|
||
if c in code_best_name:
|
||
e['account_name'] = code_best_name[c][0]
|
||
|
||
validation_results = validate_merged_vouchers(
|
||
merged_vouchers,
|
||
config.get('postprocessing', {}).get('amount_balance_tolerance', 0.01)
|
||
)
|
||
|
||
clean_count = sum(1 for _, is_clean, _, _ in validation_results if is_clean)
|
||
multi_count = sum(1 for v in merged_vouchers if v.get('is_multi_page'))
|
||
|
||
print(f" {'='*40}")
|
||
print(f" 📋 合并后校验结果({len(merged_vouchers)} 笔凭证,含 {multi_count} 笔多页):")
|
||
for voucher, is_clean, issues, summary in validation_results:
|
||
date_str = voucher['date'].strftime('%m-%d') if voucher.get('date') else '??'
|
||
vno = voucher.get('voucher_no', '?')
|
||
multi = '📑' if voucher.get('is_multi_page') else ' '
|
||
entries = len(voucher['entries'])
|
||
status = '✅' if is_clean else '⚠️'
|
||
print(f" {status} {multi} {date_str} 记-{vno} ({entries}条分录)")
|
||
if not is_clean:
|
||
for iss in issues[:3]:
|
||
print(f" {iss}")
|
||
print(f" {'='*40}")
|
||
print(f" ✅ 校验通过: {clean_count} 笔")
|
||
print(f" ⚠️ 需人工复核: {len(validation_results) - clean_count} 笔")
|
||
|
||
# ---- 合并输出 ----
|
||
if args.merge and len(results) > 1:
|
||
merge_path = merge_output(results, template_path, output_dir)
|
||
print(f"\n 📊 已生成: {merge_path}")
|
||
|
||
# ---- 校验报告(基于合并后的数据) ----
|
||
if args.review and results:
|
||
review_results = []
|
||
for voucher, is_clean, issues, summary in validation_results:
|
||
fname = ' + '.join(voucher.get('pages', ['unknown']))
|
||
review_results.append((fname, voucher, '', None))
|
||
review_path = generate_review_file(review_results, output_dir)
|
||
print(f" 📋 校验报告: {os.path.basename(review_path)}")
|
||
|
||
# ---- 自动归档 ----
|
||
archived = 0
|
||
for fname, voucher, out_path, quality in results:
|
||
src = os.path.join(input_path, fname)
|
||
dst = os.path.join(done_dir, fname)
|
||
if os.path.exists(src) and not fname.startswith('_'):
|
||
os.rename(src, dst)
|
||
archived += 1
|
||
if archived > 0:
|
||
print(f" 📦 已归档 {archived} 张照片到 done/")
|
||
|
||
# ---- 清理 PDF 临时页面图片 ----
|
||
cleanup_pdf_temp_files(input_path)
|
||
|
||
print(f"🎉 完成! {len(results)}/{len(files)} 成功")
|
||
|
||
else:
|
||
# 单文件模式
|
||
if not os.path.exists(input_path):
|
||
print(f"❌ 找不到图片: {input_path}")
|
||
sys.exit(1)
|
||
|
||
if args.output:
|
||
out_path = args.output
|
||
else:
|
||
basename = os.path.splitext(os.path.basename(input_path))[0]
|
||
out_path = os.path.join(output_dir, f'{basename}_K3.xls')
|
||
|
||
voucher, quality = process_single(input_path, config, out_path, verbose=args.verbose)
|
||
results = [(os.path.basename(input_path), voucher, out_path, quality)]
|
||
|
||
if args.review:
|
||
review_path = generate_review_file(results, output_dir)
|
||
print(f" 📋 校验报告: {os.path.basename(review_path)}")
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|