kocr/core/parser.py

560 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""KOCR v5 — 金蝶 K3 凭证解析器(基于 y 坐标区间的垂直布局解析)"""
import re
from datetime import datetime
class K3VoucherParser:
"""基于 y 坐标区间的 K3 凭证解析器
核心思路:不依赖行级匹配,而是按 y 坐标区间划分功能区域:
- 头部区y<1200公司名、日期、凭证号、摘要
- 科目区y≈1200-2400科目代码和名称
- 借方金额区(借方标题 ~ 贷方标题之间)
- 贷方金额区(贷方标题 ~ 图片底部)
- 底部区:合计、制单等固定信息
"""
def __init__(self, lines):
"""lines: [(text, conf, cx, cy, box), ...]"""
self.lines = sorted(lines, key=lambda l: (l[3], l[2]))
self._merge_continuation_lines()
def _merge_continuation_lines(self):
"""合并续行会计科目名称被OCR分成多行时合并回一行
策略:续行以横杠开头(如 -淇县支行1710020809200182975
找到上方最近的一个以横杠结尾的代码行进行合并。
如果找不到以横杠结尾的代码行,则找上方最近的不以横杠开头的代码行。
"""
# 先识别所有代码行
code_lines = [] # [(idx, text, ends_with_dash, cy, cx)]
for i, (text, conf, cx, cy, box) in enumerate(self.lines):
t = text.strip()
if re.match(r'\d{3,4}\.\d', t):
code_lines.append((i, t, t.endswith('-'), cy, cx))
if not code_lines:
return
merged = list(self.lines)
removed = set()
merged_codes = set() # 已接收续行的代码行,不再接收
for i, (text, conf, cx, cy, box) in enumerate(self.lines):
t = text.strip()
if not (t.startswith('-') or t.startswith('') or t.startswith('')):
continue
if i in removed:
continue
# 跳过包含日期/表头关键词的续行
stripped = t.lstrip('-—')
if any(k in stripped for k in ['日期', '记账凭证', '记账', '业务日期', '凭证号', '附件数']):
continue
# 匹配代码行
best_code = None
best_score = 99999
for idx, ct, ends_dash, ccy, ccx in code_lines:
if idx in merged_codes:
continue # 已合并过的代码行不再接收续行
if abs(cy - ccy) > 300:
continue
if abs(cx - ccx) > 300:
continue
dist_y = abs(cy - ccy)
dist_x = abs(cx - ccx)
if ends_dash:
score = dist_y * 2 + dist_x * 2
else:
# 不以横杠结尾的代码行x必须非常接近才接受
if dist_x > 100:
continue
score = 1500 + dist_y + dist_x * 2
if score < best_score:
best_score = score
best_code = idx
if best_code is not None and best_score < 2000:
orig_text, orig_conf, orig_cx, orig_cy, orig_box = merged[best_code]
merged[best_code] = (orig_text + text, min(orig_conf, conf), orig_cx, orig_cy, orig_box)
removed.add(i)
merged_codes.add(best_code) # 标记已合并
self.lines = [merged[i] for i in range(len(merged)) if i not in removed]
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': '',
}
# 1. 找到关键 y 坐标边界
bounds = self._find_bounds()
# 2. 提取头部信息(日期、凭证号等)
self._extract_header(result, bounds)
# 3. 提取科目代码和名称
codes = self._extract_codes(bounds)
# 4. 提取金额(按借方/贷方分类)
debit_amts, credit_amts = self._extract_amounts(bounds)
# 5. 提取摘要
summary = self._extract_summary(bounds)
# 6. 汇总金额并去重
debit_amts = self._deduplicate_amounts(debit_amts)
credit_amts = self._deduplicate_amounts(credit_amts)
# 7. 按行数匹配金额到科目
self._match_entries(result, codes, debit_amts, credit_amts, summary, bounds)
return result
def _find_bounds(self):
"""找到凭证各区域的 y 坐标边界"""
bounds = {
'y_summary_header': None, # "摘要" 标题
'y_subject_header': None, # "科目" 标题
'y_debit_header': None, # "借方" 标题
'y_credit_header': None, # "贷方" 标题
'y_date': None, # 日期行
'y_total': None, # 合计行
}
for text, conf, cx, cy, box in self.lines:
t = text.strip()
if t == '摘要':
bounds['y_summary_header'] = cy
elif t == '科目':
bounds['y_subject_header'] = cy
elif t == '借方':
bounds['y_debit_header'] = cy
elif t == '贷方':
bounds['y_credit_header'] = cy
elif '合计' in t and ('' in t or '' in t or '' in t or
'合计' in t and any(c in t for c in '零壹贰叁肆伍陆柒捌玖拾佰仟万亿')):
if bounds['y_total'] is None:
bounds['y_total'] = (cy, text)
elif cy > bounds['y_total'][0]:
bounds['y_total'] = (cy, text)
elif '日期' in t:
bounds['y_date'] = cy
return bounds
def _extract_header(self, result, bounds):
"""提取凭证头部信息:日期、凭证号、公司"""
for text, conf, cx, cy, box in self.lines:
t = text.strip()
if '物业' in t and '公司' in t and not result['company']:
result['company'] = t
elif '日期' in t:
m = re.search(r'(\d{4}[-年]\d{1,2}[-月]\d{1,2})', t)
if m:
ds = m.group(1).replace('', '-').replace('', '-').replace('', '')
try:
result['date'] = datetime.strptime(ds, '%Y-%m-%d')
result['year'] = result['date'].year
result['period'] = result['date'].month
except:
pass
elif '凭证' in t:
m = re.search(r'[记凭证号][^\d]*(\d+)', t)
if m:
result['voucher_no'] = int(m.group(1))
elif '附件' in t:
m = re.search(r'附件数[:]?(\d+)', t)
if m:
result['attachments'] = int(m.group(1))
elif '' in t and '' in t:
m = re.search(r'第(\d+)期', t)
if m:
result['period'] = int(m.group(1))
elif '合计' in t and any(c in t for c in '零壹贰叁肆伍陆柒捌玖拾佰仟万亿'):
result['total_str'] = t
m = re.search(r'(\d+\.\d{2})', re.sub(r'[^\d.]', '', t))
if m:
result['total_amount'] = float(m.group(1))
elif t.startswith('制单') or t.startswith('制单人'):
result['preparer'] = t.split('')[-1].split(':')[-1].strip()
elif t.startswith('审核'):
result['checker'] = t.split('')[-1].split(':')[-1].strip()
elif t.startswith('出纳'):
result['cashier'] = t.split('')[-1].split(':')[-1].strip()
elif t.startswith('经办'):
result['handler'] = t.split('')[-1].split(':')[-1].strip()
elif t.startswith('过账'):
result['poster'] = t.split('')[-1].split(':')[-1].strip()
def _extract_codes(self, bounds):
"""提取科目代码和名称"""
codes = []
code_map = {} # code_part → 在 codes 列表中的索引
# 安全获取边界值
y_subj = bounds.get('y_subject_header')
y_debit = bounds.get('y_debit_header')
y_summary = bounds.get('y_summary_header')
# 默认边界
y_subj_val = y_subj if y_subj is not None else 2000
y_debit_val = y_debit if y_debit is not None else 99999
y_lower = y_summary if y_summary is not None else 800
for text, conf, cx, cy, box in self.lines:
if conf < 0.5:
continue
# 科目代码区域:摘要标题上方 ~ 借方标题之间
if cy < y_lower - 100 or cy > y_debit_val:
continue
# x坐标科目代码通常在表格中间区域
# 注不设硬x过滤器正则 \d{3,4}\.\d 和首位数 1000-9999 校验已足够精准
# 设 x 过滤器会误杀 PDF 版本排版偏左和少部分照片x=1297 也有代码)
t = text.strip()
# 排除表头文字
if t in ('摘要', '科目', '借方', '贷方', '合计') or len(t) < 4:
continue
# 匹配科目代码模式3-4位数字开头后面跟 .数字
m = re.match(r'(\d{3,4}\.\d[\d.]*)', t)
if not m:
continue
code_part = m.group(1).rstrip('.')
# 验证是合法的科目代码1000~9999
first_num = int(code_part.split('.')[0])
if first_num < 1000 or first_num > 9999:
continue
# 提取名称 — 按用户规则:取最后一个横杠之后的文字
name = t[m.end():].lstrip('-—= ')
# 保留长数字尾(银行账号),仅清除短数字尾
if not re.search(r'\d{6,}', name):
name = re.sub(r'[\d.\-—=]+$', '', name).strip()
name = name.lstrip('-—-=')
if '\\' in name:
name = name.split('\\', 1)[0].strip()
else:
idx = max(name.rfind(''), name.rfind('-'), name.rfind(''))
if idx >= 0:
name = name[idx+1:].strip()
# 去重:优先保留带完整名称的行
if code_part in code_map:
existing = codes[code_map[code_part]]
# 已有名称为空,而新行有名称 → 更新
if len(name) >= 2 and (not existing['name'] or len(existing['name']) < 2 or len(name) > len(existing['name'])): # 优先长名
existing['name'] = name
existing['y_pos'] = cy
existing['x_pos'] = cx
continue
codes.append({
'code': code_part,
'name': name,
'desc': '',
'debit': 0.0,
'credit': 0.0,
'y_pos': cy,
'x_pos': cx,
})
code_map[code_part] = len(codes) - 1
# 按 y 排序
codes.sort(key=lambda c: c['y_pos'])
# ---- 名称补位:代码行没有名称时,从下方最近行补取 ----
# 收集科目区所有含中文的文本(用于名称补位)
name_candidates = []
for text, conf, cx, cy, box in self.lines:
if conf < 0.5:
continue
if cy < y_lower - 100 or cy > y_debit_val:
continue
t = text.strip()
if len(t) < 2 or not re.search(r'[\u4e00-\u9fff]', t):
continue
if t in ('摘要', '科目', '借方', '贷方', '合计', '日期', '会计'):
continue
# 排除明显的非科目名称文本
if any(k in t for k in ['过账', '经办', '审核', '出纳', '制单', 'Manager']):
continue
# 排除本身就是科目代码的行
if re.match(r'\d{3,4}\.\d', t):
continue
name_candidates.append((t, cy, cx))
name_candidates.sort(key=lambda x: x[1])
for code in codes:
if code['name'] and len(code['name']) >= 2:
continue # 已有名称,跳过
# 找代码行下方最近的含中文文本(原始逻辑:只找下方,不搜上方)
best = None
best_dist = 99999
for t, cy, cx in name_candidates:
if cy <= code['y_pos']:
continue # 只找下方的
if abs(cx - code['x_pos']) > 400:
continue
dist = cy - code['y_pos']
if dist < best_dist and dist < 400:
best = (t, cy, cx)
best_dist = dist
if best:
t, cy, cx = best
# 按用户规则提取名称(原始逻辑:先清洗后取最后横杠)
n = t.lstrip('-—= ')
# 保留长数字尾(银行账号)
if not re.search(r'\d{6,}', n):
n = re.sub(r'[\d.\-—=]+$', '', n).strip()
n = n.lstrip('-—-=')
if '\\' in n:
n = n.split('\\', 1)[0].strip()
else:
idx = max(n.rfind(''), n.rfind('-'), n.rfind(''))
if idx >= 0:
n = n[idx+1:].strip()
if n and len(n) >= 2:
# 名称质量检查:如果提取结果可疑,试下一个候选
bad_name = (len(n) < 3 or n.endswith('/')
or n in ('里C', '里A', '假台', '里C3', '里A3')
or re.search(r'[里景苑园]$', n)
or re.search(r'[A-Z]\d', n))
if bad_name:
# 移除当前候选,再找下一个
name_candidates = [(ct, ccy, ccx) for ct, ccy, ccx in name_candidates
if ct != t or ccy != cy or ccx != cx]
# 递归尝试下一个(限制深度)
for _ in range(10):
next_best = None
next_dist = 99999
for nt, ncy, ncx in name_candidates:
if ncy <= code['y_pos']:
continue
if abs(ncx - code['x_pos']) > 400:
continue
ndist = ncy - code['y_pos']
if ndist < next_dist and ndist < 400:
next_best = (nt, ncy, ncx)
next_dist = ndist
if not next_best:
break
nt, ncy, ncx = next_best
nn = nt.lstrip('-—= ')
# 保留长数字尾(银行账号)
if not re.search(r'\d{6,}', nn):
nn = re.sub(r'[\d.\-—=]+$', '', nn).strip()
nn = nn.lstrip('-—-=')
if '\\' in nn:
nn = nn.split('\\', 1)[0].strip()
else:
nidx = max(nn.rfind(''), nn.rfind('-'), nn.rfind(''))
if nidx >= 0:
nn = nn[nidx+1:].strip()
if nn and len(nn) >= 2 and not (
len(nn) < 3 or nn.endswith('/')
or nn in ('里C', '里A', '假台', '里C3', '里A3')
or re.search(r'[里景苑园]$', nn)
or re.search(r'[A-Z]\d', nn)
):
code['name'] = nn
break
# 这个也不行,继续下一个
name_candidates = [(ct, ccy, ccx) for ct, ccy, ccx in name_candidates
if ct != nt or ccy != ncy or ccx != ncx]
else:
code['name'] = n
return codes
def _extract_amounts(self, bounds):
"""提取金额,按借方/贷方分类"""
y_debit = bounds.get('y_debit_header')
y_credit = bounds.get('y_credit_header')
# 安全默认值
y_debit_val = y_debit if y_debit is not None else 3000
y_credit_val = y_credit if y_credit is not None else 3500
amounts = []
skip_texts = {'摘要', '科目', '借方', '贷方', '合计', '经办', '审核', '出纳', '制单'}
for text, conf, cx, cy, box in self.lines:
if conf < 0.5:
continue
if text.strip() in skip_texts:
continue
if any(k in text for k in ['Manager', 'HONOR', 'Magic6', 'RMB']):
continue
# 提取金额:去掉所有非数字字符(逗号/空格等),避免"11, 075.68"被解析为75.68
cleaned = re.sub(r'[^\d.]', '', text)
m = re.search(r'(\d+\.\d{2})', cleaned)
if not m:
continue
val = float(m.group(1))
# 排除年份
if 1900 < val < 2100:
continue
if val < 0.5:
continue
# 分类借贷:只取借方标题以下的文本作为金额
if cy < y_debit_val:
continue # 在借方标题上方,不是金额
elif cy <= y_credit_val:
zone = 'debit'
else:
zone = 'credit'
amounts.append({'val': val, 'x': cx, 'y': cy, 'zone': zone})
debit_amts = [a for a in amounts if a['zone'] == 'debit']
credit_amts = [a for a in amounts if a['zone'] == 'credit']
return debit_amts, credit_amts
def _deduplicate_amounts(self, amts):
"""对金额去重:同一位置 OCR 重复读取的只保留一个(按 y 坐标邻近去重)
不按值去重——同一区域完全可能有多个相同金额如借方3笔3000.00)。
只在同一位值y差<15像素且值相同时才认为是 OCR 重复。
"""
if not amts:
return []
# 按 y 排序
amts.sort(key=lambda a: a['y'])
deduped = []
for a in amts:
# 检查是否与上一条金额在同一位置附近y差<15像素且值相同
is_dup = False
if deduped:
last = deduped[-1]
if abs(a['y'] - last['y']) < 15 and abs(a['val'] - last['val']) < 0.01:
is_dup = True
if not is_dup:
deduped.append(a)
return deduped
def _extract_summary(self, bounds):
"""提取凭证摘要(通常在表格顶部区域)"""
y_subj = bounds.get('y_subject_header')
y_subj_val = y_subj if y_subj is not None else 2000
candidates = []
for text, conf, cx, cy, box in self.lines:
if conf < 0.6:
continue
if not re.search(r'[\u4e00-\u9fff]', text):
continue
if cy < 0 or cy > y_subj_val - 100:
continue
if len(text.strip()) < 6:
continue
if any(k in text for k in ['经办', '合计', '审核', '日期', '业务', '附件']):
continue
candidates.append((text, cy))
# 按 y 排序,取最靠上且有意义的候选
candidates.sort(key=lambda x: x[1])
# 过滤掉日期/期号类文本(如"J22年第9期"、"022年第9期"
real_summaries = [t for t, y in candidates
if not re.match(r'.*\d{1,4}年第?\d{1,2}期', t)
and not re.match(r'[:]\d+', t)
and '日期' not in t
and len(t) >= 6]
if real_summaries:
summary = real_summaries[0]
# P05/PO5/P0S → POS 纠错
summary = re.sub(r'P[O0][5S]', 'POS', summary)
return summary
return ''
def _match_entries(self, result, codes, debit_amts, credit_amts, summary, bounds):
"""将金额匹配到科目,构建分录
顺序分配 + 多余金额合并到最后一个科目:
- 第1个借方金额 → 第1个科目第2个借方 → 第2个科目……
- 借方金额多于科目数 → 多余金额合并到最后一个科目
- 贷方同理
- 不做平均分配(平均分配会产生不存在于原始凭证的金额)
"""
if not codes:
result['entries'] = []
return
n_codes = len(codes)
# 初始化
for code in codes:
code['debit'] = 0.0
code['credit'] = 0.0
# 借方顺序分配:多余的累加到最后一个科目
for i, amt in enumerate(debit_amts):
idx = min(i, n_codes - 1)
codes[idx]['debit'] += amt['val']
# 贷方顺序分配:多余的累加到最后一个科目
for i, amt in enumerate(credit_amts):
idx = min(i, n_codes - 1)
codes[idx]['credit'] += amt['val']
# 四舍五入
for code in codes:
code['debit'] = round(code['debit'], 2)
code['credit'] = round(code['credit'], 2)
# 设置摘要
for code in codes:
code['desc'] = summary or '会计凭证'
# 构建 entries
result['entries'] = []
for i, code in enumerate(codes):
result['entries'].append({
'id': i + 1,
'description': code['desc'],
'account_code': code['code'],
'account_name': code['name'] or '未知科目',
'debit': code['debit'],
'credit': code['credit'],
})
# 清洗科目名称
for e in result['entries']:
e['account_name'] = e['account_name'].replace('--', '-').strip()