312 lines
12 KiB
Python
312 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
"""KOCR v4 — K3 记账凭证解析器(基于坐标)"""
|
||
|
||
import re
|
||
from datetime import datetime
|
||
|
||
|
||
class K3VoucherParser:
|
||
"""基于坐标解析 K3 标准记账凭证"""
|
||
|
||
def __init__(self, lines):
|
||
self.lines = sorted(lines, key=lambda l: (l[3], l[2]))
|
||
|
||
def parse(self):
|
||
result = {
|
||
'company': '',
|
||
'date': None,
|
||
'year': None,
|
||
'period': None,
|
||
'voucher_word': '记',
|
||
'voucher_no': None,
|
||
'attachments': 0,
|
||
'entries': [],
|
||
'total_str': '',
|
||
'total_amount': 0,
|
||
'preparer': '',
|
||
'checker': '',
|
||
'cashier': '',
|
||
'handler': '',
|
||
'poster': '',
|
||
}
|
||
|
||
for text, conf, cx, cy, box in self.lines:
|
||
if cy < 480 and '物业' in text:
|
||
result['company'] = text
|
||
|
||
elif '业务日期' in text or '日期' in text:
|
||
m = re.search(r'(\d{4}[-\u5e74]\d{1,2}[-\u6708]\d{1,2})', text)
|
||
if m:
|
||
date_str = m.group(1).replace('年', '-').replace('月', '-').replace('日', '')
|
||
try:
|
||
result['date'] = datetime.strptime(date_str, '%Y-%m-%d')
|
||
except:
|
||
pass
|
||
|
||
elif '凭证' in text:
|
||
# 兼容各种格式:记-45、记 - 45、记-45(1/3)、45(1/3)
|
||
m = re.search(r'[记凭证]\s*[-—]\s*(\d+)', text)
|
||
if m:
|
||
result['voucher_no'] = int(m.group(1))
|
||
if not result['voucher_no']:
|
||
m2 = re.search(r'(?:记\s*)?[-—]?\s*(\d+)\s*[((]\d+[/]\d+[))]', text)
|
||
if m2:
|
||
result['voucher_no'] = int(m2.group(1))
|
||
# 兜底:直接找"凭证号"后面的数字
|
||
if not result['voucher_no']:
|
||
m3 = re.search(r'凭证号\s*[::]\s*记?\s*[-—]?\s*(\d+)', text)
|
||
if m3:
|
||
result['voucher_no'] = int(m3.group(1))
|
||
|
||
elif '附件数' in text:
|
||
m = re.search(r'附件数[::](\d+)', text)
|
||
if m:
|
||
result['attachments'] = int(m.group(1))
|
||
|
||
elif '第' in text and '期' in text:
|
||
m = re.search(r'第(\d+)期', text)
|
||
if m:
|
||
result['period'] = int(m.group(1))
|
||
|
||
elif '合计' in text or '壹' in text or '贰' in text:
|
||
result['total_str'] = text
|
||
m = re.search(r'([\d,]+\.[\d]{2})', text)
|
||
if m:
|
||
result['total_amount'] = float(m.group(1).replace(',', ''))
|
||
|
||
elif '制单' in text:
|
||
m = re.search(r'制单[::](.*)', text)
|
||
if m:
|
||
result['preparer'] = m.group(1).strip()
|
||
|
||
elif '审核' in text:
|
||
m = re.search(r'审核[::](.*)', text)
|
||
if m:
|
||
result['checker'] = m.group(1).strip()
|
||
|
||
elif '出纳' in text:
|
||
m = re.search(r'出纳[::](.*)', text)
|
||
if m:
|
||
result['cashier'] = m.group(1).strip()
|
||
|
||
elif '经办' in text:
|
||
m = re.search(r'经办[::](.*)', text)
|
||
if m:
|
||
result['handler'] = m.group(1).strip()
|
||
|
||
elif '过账' in text:
|
||
m = re.search(r'过账[::](.*)', text)
|
||
if m:
|
||
result['poster'] = m.group(1).strip()
|
||
|
||
if result['date']:
|
||
result['year'] = result['date'].year
|
||
|
||
result['entries'] = self._parse_entries()
|
||
return result
|
||
|
||
def _parse_entries(self):
|
||
"""基于坐标解析分录"""
|
||
if not self.lines:
|
||
return []
|
||
|
||
lines = sorted(self.lines, key=lambda l: (l[3], l[2]))
|
||
|
||
# === 1. 确定数据区域 ===
|
||
y_headers = []
|
||
for text, conf, cx, cy, box in lines:
|
||
if text.strip() in ('借方', '贷方'):
|
||
y_headers.append(cy)
|
||
y_start = min(y_headers) if y_headers else 0
|
||
y_end = 999999
|
||
for text, conf, cx, cy, box in lines:
|
||
if '合计' in text:
|
||
y_end = min(y_end, cy)
|
||
y_data_start = max(0, y_start - 150)
|
||
|
||
# === 2. 获取图像宽度 ===
|
||
all_xs = []
|
||
for _, _, _, _, box in lines:
|
||
if box:
|
||
for p in box:
|
||
all_xs.append(p[0])
|
||
img_w = max(all_xs) if all_xs else 3000
|
||
|
||
# === 3. 筛选数据区域 ===
|
||
data_lines = [(t, c, x, y, b) for t, c, x, y, b in lines
|
||
if y_data_start <= y <= y_end and c >= 0.4]
|
||
|
||
# === 4. 提取科目代码 ===
|
||
codes = []
|
||
seen_codes = set()
|
||
skip_keywords = ['科目', '摘要', '借方', '贷方', '出纳',
|
||
'制单', '审核', '经办', '过账', '附件',
|
||
'Manager', 'HONOR', 'Magic6', '合计',
|
||
'凭证', '号']
|
||
|
||
for text, conf, cx, cy, box in data_lines:
|
||
if any(k in text for k in skip_keywords):
|
||
continue
|
||
text_clean = text.strip().replace(':', '').replace(' ', '')
|
||
if text_clean in {'借', '贷', '科', '摘要', 'RMB', ''}:
|
||
continue
|
||
m = re.match(r'(\d[\d.]*)([--]?\s*.*)', text.strip())
|
||
if m and m.group(1).count('.') >= 1:
|
||
code_part = m.group(1).rstrip('.')
|
||
rest = m.group(2).lstrip('--—= ').strip()
|
||
if re.match(r'^\d{3,}\.', code_part):
|
||
first_dot = code_part.find('.')
|
||
code_num = int(code_part[:first_dot]) if first_dot > 0 else int(code_part)
|
||
if code_num >= 1000 and code_part not in seen_codes:
|
||
seen_codes.add(code_part)
|
||
codes.append({
|
||
'code': code_part,
|
||
'name': rest,
|
||
'desc': '',
|
||
'debit': 0.0,
|
||
'credit': 0.0,
|
||
'y_pos': cy,
|
||
'x_pos': cx,
|
||
})
|
||
|
||
if not codes:
|
||
return self._parse_entries_fallback()
|
||
|
||
# === 4.5 摘要提取:从剩余 OCR 文字中找摘要 ===
|
||
code_y_min = min(c['y_pos'] for c in codes)
|
||
desc_candidates = []
|
||
search_lines = self.lines
|
||
for text, conf, cx, cy, box in search_lines:
|
||
if conf < 0.5:
|
||
continue
|
||
is_code = False
|
||
for c in codes:
|
||
if abs(c['y_pos'] - cy) < 20:
|
||
is_code = True
|
||
break
|
||
if is_code:
|
||
continue
|
||
if re.search(r'\d+\.\d{2}', text):
|
||
continue
|
||
if any(k in text for k in skip_keywords + ['合计', '亿', '千', '百', '拾', '万']):
|
||
continue
|
||
if re.match(r'^[\d.\-,]+$', text.strip()):
|
||
continue
|
||
if len(text.strip()) < 3:
|
||
continue
|
||
if cx < 300:
|
||
continue
|
||
if text.strip().startswith('·') or '工商银行' in text:
|
||
continue
|
||
if '支行' in text or re.search(r'\d{15,}', text):
|
||
continue
|
||
if text.strip() in ('未到账', '已到账', '未达', '已达'):
|
||
continue
|
||
if cy < code_y_min - 50:
|
||
continue
|
||
# 去重:跳过 y 距离 < 30 的重复文本
|
||
if any(abs(d['y'] - cy) < 30 for d in desc_candidates):
|
||
continue
|
||
desc_candidates.append({'text': text, 'y': cy, 'x': cx})
|
||
|
||
desc_candidates.sort(key=lambda d: d['y'])
|
||
for i, desc in enumerate(desc_candidates):
|
||
if i < len(codes):
|
||
codes[i]['desc'] = desc['text']
|
||
|
||
# === 5. 提取金额 ===
|
||
amounts = []
|
||
skip_amount_keywords = ['Manager', 'HONOR', 'Magic6', 'RMB',
|
||
'日', '期', '凭证', '号', '附件',
|
||
'制单', '审核', '出纳', '经办',
|
||
'科目', '摘要']
|
||
|
||
for text, conf, cx, cy, box in data_lines:
|
||
if any(k in text for k in skip_amount_keywords):
|
||
continue
|
||
m = re.search(r'([\d,]+\.[\d]{2})', text.replace(',', ''))
|
||
if m:
|
||
val = float(m.group(1).replace(',', ''))
|
||
# 排除年份/月份误识别
|
||
if 1900 < val < 2100:
|
||
continue
|
||
if re.match(r'^\d{3,}\.', text.strip()):
|
||
continue
|
||
if val < 0.5:
|
||
continue
|
||
amounts.append({'val': val, 'x': cx, 'y': cy})
|
||
|
||
# === 6. 找到借贷分界线 ===
|
||
amount_xs = [a['x'] for a in amounts]
|
||
if amount_xs:
|
||
amount_xs.sort()
|
||
max_gap = 0
|
||
split_idx = len(amount_xs) // 2
|
||
for i in range(1, len(amount_xs)):
|
||
gap = amount_xs[i] - amount_xs[i-1]
|
||
if gap > max_gap:
|
||
max_gap = gap
|
||
split_idx = i
|
||
bound_x = (amount_xs[split_idx-1] + amount_xs[split_idx]) / 2 if split_idx > 0 else amount_xs[0]
|
||
else:
|
||
bound_x = img_w * 0.5
|
||
|
||
# === 7. 交错匹配金额到科目 ===
|
||
debit_amts = sorted([a for a in amounts if a['x'] < bound_x], key=lambda a: a['y'])
|
||
credit_amts = sorted([a for a in amounts if a['x'] >= bound_x], key=lambda a: a['y'])
|
||
|
||
interleaved = []
|
||
max_len = max(len(debit_amts), len(credit_amts))
|
||
for i in range(max_len):
|
||
if i < len(debit_amts):
|
||
interleaved.append(('dr', debit_amts[i]))
|
||
if i < len(credit_amts):
|
||
interleaved.append(('cr', credit_amts[i]))
|
||
|
||
codes.sort(key=lambda c: c['y_pos'])
|
||
|
||
for i, code in enumerate(codes):
|
||
if i < len(interleaved):
|
||
side, amt = interleaved[i]
|
||
if side == 'dr':
|
||
code['debit'] = amt['val']
|
||
else:
|
||
code['credit'] = amt['val']
|
||
|
||
# === 8. 构建结果 ===
|
||
entries = []
|
||
for code in codes:
|
||
entries.append({
|
||
'id': len(entries) + 1,
|
||
'description': code['desc'] or '会计凭证',
|
||
'account_code': code['code'],
|
||
'account_name': code['name'] or '未知科目',
|
||
'debit': code['debit'],
|
||
'credit': code['credit'],
|
||
})
|
||
|
||
for e in entries:
|
||
e['account_name'] = e['account_name'].replace('--', '-').strip()
|
||
|
||
return entries
|
||
|
||
def _parse_entries_fallback(self):
|
||
"""兜底:基础科目代码检测"""
|
||
entries = []
|
||
for text, conf, cx, cy, box in self.lines:
|
||
if conf < 0.5:
|
||
continue
|
||
m = re.match(r'(\d{3,}\.[\d.]+)', text.strip())
|
||
if m:
|
||
code = m.group(1).rstrip('.')
|
||
name = text[m.end():].lstrip('--—= ')
|
||
entries.append({
|
||
'id': len(entries) + 1,
|
||
'description': '',
|
||
'account_code': code,
|
||
'account_name': name or '未知科目',
|
||
'debit': 0.0,
|
||
'credit': 0.0,
|
||
})
|
||
return entries
|