#!/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()