424 lines
18 KiB
Python
424 lines
18 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
|
||
# 数据区上界:列头上方500px(兼容科目代码在列头上方的布局)
|
||
y_data_start = max(0, y_start - 500)
|
||
y_end = 999999
|
||
# 找数据区下方的"合计"行(排除顶部大写合计:带中文数字的)
|
||
import re as _re
|
||
heji_positions = [cy for text, conf, cx, cy, box in lines
|
||
if '合计' in text
|
||
and cy > y_data_start
|
||
and not _re.search(r'[壹贰叁肆伍陆柒捌玖拾佰仟万亿]', text)]
|
||
if heji_positions:
|
||
y_end = max(heji_positions) # 取最靠下的合计行
|
||
|
||
# === 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.3 科目名称补位:如果代码没有名称,从附近行找 ===
|
||
for code in codes:
|
||
if code['name']: # 已有名称则跳过
|
||
continue
|
||
# 在当前代码下方附近找包含中文的文本(排除含其他科目代码的行)
|
||
name_candidates = []
|
||
for text, conf, cx, cy, box in self.lines:
|
||
if conf < 0.5:
|
||
continue
|
||
if not re.search(r'[\u4e00-\u9fff]', 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 < 400 or cx > 1300: # 名称列通常在x=400-1300
|
||
continue
|
||
if re.search(r'(公司|有限|集团|企业)', text): # 排除公司名
|
||
continue
|
||
if text.strip() in ('未到账', '已到账', '未达', '已达'):
|
||
continue
|
||
# 排除包含其他科目代码的行(如 "6603.02-财务费用")
|
||
if re.search(r'\d{3,}\.\d', text):
|
||
continue
|
||
dist_y = cy - code['y_pos']
|
||
if 0 < dist_y < 200: # 代码下方200px以内
|
||
name_candidates.append((text, dist_y, cx, cy))
|
||
if name_candidates:
|
||
# 取y距离最近的
|
||
name_candidates.sort(key=lambda x: (x[1], -x[2]))
|
||
code['name'] = name_candidates[0][0]
|
||
code['y_pos'] = max(code['y_pos'], name_candidates[0][3]) # 更新y为名称位置
|
||
|
||
# === 4.5 摘要提取:从剩余 OCR 文字中找摘要 ===
|
||
code_y_min = min(c['y_pos'] for c in codes)
|
||
|
||
# 找到表格头的y坐标("科目"或"摘要"列头)
|
||
table_header_y = min(
|
||
(cy for text, conf, cx, cy, box in self.lines
|
||
if text.strip() in ('科目', '摘要', '借方', '贷方')),
|
||
default=0
|
||
)
|
||
# 摘要搜索上界:向上1000px的宽范围(摘要有时在列头上方)
|
||
desc_upper_bound = max(0, code_y_min - 1000)
|
||
|
||
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 not re.search(r'[\u4e00-\u9fff]', text): # 没有中文字,排除
|
||
continue
|
||
if len(text.strip()) < 4: # 太短
|
||
continue
|
||
if conf < 0.6: # 提高置信度要求
|
||
continue
|
||
# 过滤非摘要的日期/期号文本
|
||
if re.match(r'\d{3}年第\d+期', text.strip()):
|
||
continue
|
||
if re.match(r'^[::]?\d{1,3}$', text.strip()):
|
||
continue
|
||
if text.strip().startswith('日期') or text.strip().startswith('业务日期'):
|
||
continue
|
||
if re.match(r'[\d,]+\s*\.\s*\d{2}', text.strip()):
|
||
continue
|
||
# 摘要列通常在凭证中部(x=400~950),排除右侧的公司名和左侧的金额
|
||
if cx > 950 or cx < 400:
|
||
continue
|
||
# 过滤包含"公司"、"物业"、"有限"等公司相关词
|
||
if re.search(r'(公司|有限|物业|集团|企业)', text):
|
||
continue
|
||
# 过滤科目名称文本:以 -/- 开头的会计科目
|
||
if re.match(r'^[--][^\d]', text.strip()) and any(
|
||
kw in text for kw in ['银行', '货币', '账款', '费用', '收入', '成本',
|
||
'应收', '应付', '预付', '预收', '其他', '库存',
|
||
'管理', '财务', '手续费', '押金', '资本']
|
||
):
|
||
continue
|
||
# 过滤纯科目名称(在代码附近找到的科目名不应作为摘要)
|
||
if len(text.strip()) < 20: # 短文本更容易是科目名
|
||
is_near_name = False
|
||
for c in codes:
|
||
if c['name'] and c['name'] in text:
|
||
if abs(c['y_pos'] - cy) < 100:
|
||
is_near_name = True
|
||
break
|
||
if is_near_name:
|
||
continue
|
||
if cy < desc_upper_bound:
|
||
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'])
|
||
# 按y坐标最近邻匹配:对每个科目代码,找最近的摘要候选
|
||
codes.sort(key=lambda c: c['y_pos'])
|
||
used_indices = set()
|
||
for code in codes:
|
||
best_dist = float('inf')
|
||
best_idx = -1
|
||
for i, desc in enumerate(desc_candidates):
|
||
if i in used_indices:
|
||
continue
|
||
dist = abs(desc['y'] - code['y_pos'])
|
||
if dist < best_dist:
|
||
best_dist = dist
|
||
best_idx = i
|
||
if best_idx >= 0 and best_dist < 200: # 200px以内视为有效匹配
|
||
code['desc'] = desc_candidates[best_idx]['text']
|
||
used_indices.add(best_idx)
|
||
elif best_idx >= 0:
|
||
# 超过200px但只有这一个候选且是第一行,可能是共享摘要
|
||
pass
|
||
|
||
# 如果没有任何匹配到摘要,但候选列表有内容,用第一个候选作为共享摘要
|
||
none_found = all(not c.get('desc') for c in codes)
|
||
if none_found and desc_candidates:
|
||
for code in codes:
|
||
code['desc'] = desc_candidates[0]['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
|