#!/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: m = re.search(r'[\u8bb0\u51ed\u8bc1]-\s*(\d+)', text) if m: result['voucher_no'] = int(m.group(1)) m2 = re.search(r'(\d+)(\d+/\d+)', text) # 多页凭证:123(1/3) if m2: result['voucher_no'] = int(m2.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