import re import uuid from collections import OrderedDict from datetime import date from decimal import Decimal, InvalidOperation, ROUND_HALF_UP CATEGORIES = ['餐饮', '住宿', '交通', '办公用品', '招待', '采购', '其他'] HAN = r'\u3400-\u9fff' DATE = r'(20\d{2})[年./-](1[0-2]|0?[1-9])[月./-](3[01]|[12]\d|0?[1-9])日?' AMOUNT_PATTERNS = [ r'(?:支付总额|订单总额|应付总额|总金额|总计|价税合计|小写|实付金额|支付金额|付款金额|订单金额|合计|金额)[^0-9]{0,10}([0-9]{1,8}(?:[.,][0-9]{1,2})?)', r'(?:¥|¥|RMB|CNY)\s*([0-9]{1,8}(?:[.,][0-9]{1,2})?)', r'(?= 0) dates = [] for match in re.finditer(DATE, text): try: dates.append(date(*map(int, match.groups())).isoformat()) except ValueError: pass merchants = re.findall(r'(?:' + MERCHANT_LABEL + r')[::]?\s*([^\n]{2,40})', text) def clean(value): return re.sub(r'[|::]+$', '', re.sub(r'^.*?(?:' + MERCHANT_LABEL.replace('|商户|', '|') + r')[::]?', '', value)).strip() def valid(value): return (2 <= len(value) <= 45 and re.search('[' + HAN + 'A-Za-z]', value) and not re.search('支付成功|交易成功|电子发票|增值税发票|付款方式|订单详情|账单详情|交易详情|商品说明|开票日期|价税合计', value)) for index, line in enumerate(lines): line = clean(line) if re.search('销售方|销方|收款方|收款单位|商户|付款给|收款人|对方户名|交易对象', line) and index + 1 < len(lines): following = clean(lines[index + 1]) if valid(following): merchants.append(following) if re.search('公司|餐厅|饭店|酒店|宾馆|旅馆|商店|店|超市|便利店|中心|服务部|经营部|商行|科技|网络|运输|航空|铁路|医院|药房|加油站|停车场|物业|平台', line) and valid(line): merchants.append(line) return dict(status='success', rawText=text, lines=lines, amounts=amounts, dates=unique(dates), merchants=unique(value.strip().rstrip('::|') for value in merchants), orderNumbers=unique(re.findall(r'(?:交易单号|商户单号|订单号|流水号|交易号)[::]?\s*([A-Za-z0-9-]{8,40})', text)), travel=extract_travel(text, unique(dates)), message='') def extract_travel(text, dates): travel = dict(type='', travelerName='', departure='', destination='', departureTime='', transportNumber='') train = re.search('火车票|铁路电子客票|铁路|车次|检票口|12306', text) flight = re.search('航空|航班|登机牌|电子客票行程单|乘机|起飞|客票号', text) if not train and not flight: return travel travel['type'] = 'flight' if flight and not train else 'train' for pattern in [r'(?:旅客姓名|乘车人|乘机人|旅客|姓名)[::]?\s*([' + HAN + r'·]{2,15})', r'(?:\d{6,}\*{2,}\d{2,})\s*([' + HAN + r'·]{2,8})']: names = [name for name in re.findall(pattern, text) if len(name) <= 8 and not re.search('税务|日期|发票|铁路|车站|公司|名称|代码|号码', name)] if names: travel['travelerName'] = names[0] break travel['departure'] = first(r'(?:出发地|出发站|始发站|起点|起飞机场)[::]?\s*([' + HAN + r'A-Za-z0-9]{2,24})', text) travel['destination'] = first(r'(?:目的地|到达站|终点站|终点|到达机场)[::]?\s*([' + HAN + r'A-Za-z0-9]{2,24})', text) route = re.search(r'([' + HAN + r'A-Za-z]{2,20})(?:站|机场)?\s*(?:→|->|—|至)\s*([' + HAN + r'A-Za-z]{2,20})(?:站|机场)?', text) if route: for index, field in enumerate(['departure', 'destination'], 1): travel[field] = travel[field] or re.sub(r'(站|机场)$', '', route.group(index)) if train: stations = unique(re.findall(r'([' + HAN + r']{2,12})站', text)) if len(stations) >= 2: travel['departure'] = travel['departure'] or stations[0] travel['destination'] = travel['destination'] or stations[1] clock_pattern = r'([01]?\d|2[0-3]):([0-5]\d)' for match in re.finditer(DATE + r'[^\n]{0,24}?' + clock_pattern, text): if not re.search('开票日期|发票日期', text[max(0, match.start() - 16):match.start()]): year, month, day, hour, minute = map(int, match.groups()) travel['departureTime'] = f'{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}' break if not travel['departureTime']: departure_date = '' for match in re.finditer(DATE, text): if not re.search('开票日期|发票日期', text[max(0, match.start() - 16):match.start()]): year, month, day = map(int, match.groups()) departure_date = f'{year:04}-{month:02}-{day:02}' break clock = re.search(clock_pattern, text) travel['departureTime'] = ((departure_date or next(iter(dates), '')) + (' %02d:%02d' % tuple(map(int, clock.groups())) if clock else '')).strip() pattern = r'(? 0] if item['ocr']['status'] == 'success' else [] def primary(item): values = item['ocr']['amounts'] return money(values[0]) if values and item['ocr']['status'] == 'success' else None def text(item): return item['ocr']['rawText'] def common(left, right, field): return [value for value in left['ocr'][field] if value in right['ocr'][field]] def date_distance(invoices, payments): distances = [abs((date.fromisoformat(left) - date.fromisoformat(right)).days) for invoice in invoices for payment in payments for left in invoice['ocr']['dates'] for right in payment['ocr']['dates']] return min(distances, default=999999) def similarity(left, right): def normalize(value): return re.sub('[^' + HAN + 'a-z0-9]', '', re.sub('有限责任公司|股份有限公司|有限公司|公司|商户|门店|收款方', '', value.lower())) def grams(value): return {value[index:index + 2] for index in range(len(value) - 1)} if len(value) > 1 else {value} left, right = normalize(left), normalize(right) if not left or not right: return 0 if left in right or right in left: return 1 return len(grams(left) & grams(right)) / len(grams(left) | grams(right)) def merchant_similarity(invoice, payment): return max((similarity(left, right) for left in invoice['ocr']['merchants'] for right in payment['ocr']['merchants']), default=0) def category(items): content = ' '.join(text(item) + ' ' + ' '.join(item['ocr']['merchants']) for item in items).lower() scores = [sum(3 if len(word) >= 4 else 2 if len(word) >= 3 else 1 for word in words.split() if word in content) for words in KEYWORDS] return CATEGORIES[scores.index(max(scores))] if max(scores) else '其他' def add_match(state, invoices, payments, score, reasons): selected_category = category(invoices + payments) for item in invoices + payments: item['matched'] = True state['matches'].append(dict(id=str(uuid.uuid4()), invoices=invoices, payments=payments, category=selected_category, matchType='auto', score=score, reasons=reasons + ['自动分类:' + selected_category])) def combinations(values, target): sums = {Decimal('0.00'): [[]]} for item, amount in values: snapshot = [(subtotal, [list(group) for group in groups]) for subtotal, groups in sums.items()] for subtotal, groups in snapshot: total = subtotal + amount if total > target: continue destination = sums.setdefault(total, []) for group in list(groups): candidate = group + [item] if candidate not in destination: destination.append(candidate) del destination[2:] return [group for group in sums.get(target, []) if len(group) >= 2][:2] def railway(item): return bool(re.search('12306|铁路|火车|车次|中铁', text(item))) def total_label(item): return bool(re.search('支付总额|订单总额|应付总额|总金额|总计|合计', text(item))) def travel_key(item): travel = item['ocr']['travel'] return tuple(travel[field].lower() for field in ['type', 'departure', 'destination', 'departureTime', 'transportNumber']) def auto_match(state): invoices, payments = state['invoices'], state['payments'] groups = OrderedDict() for invoice in invoices: if invoice['ocr']['travel']['type']: groups.setdefault(travel_key(invoice), []).append(invoice) for group in groups.values(): if len(group) < 2 or any(primary(item) is None for item in group): continue total = sum(primary(item) for item in group) evidence = [item for item in payments if not item['matched'] and railway(item) and total in amounts(item)] if evidence and any(re.search('支付成功|付款成功|交易成功|支付方式|交易单号|商户单号', text(item)) for item in evidence) and date_distance(group, evidence) <= 14: reasons = [f'同一行程多人发票合计一致 ¥{total:.2f}', ' + '.join(str(primary(item)) for item in group)] if len(evidence) >= 2: reasons.append('付款凭证与订单截图已共同归入本次行程') add_match(state, group, evidence, 100 if len(evidence) >= 2 else 95, reasons) for payment in payments: total = primary(payment) if payment['matched'] or total is None: continue values = [(item, primary(item)) for item in invoices if not item['matched'] and primary(item) is not None and 0 < primary(item) < total] options = combinations(values, total) if len(options) != 1: continue group, evidence = options[0], [payment] for candidate in payments: if candidate is payment or candidate['matched']: continue same_total = primary(candidate) == total or (total in amounts(candidate) and total_label(candidate)) related = (common(payment, candidate, 'orderNumbers') or merchant_similarity(payment, candidate) >= .35 or (railway(payment) and railway(candidate) and (date_distance([payment], [candidate]) <= 7 or total_label(payment) or total_label(candidate)))) if same_total and related: evidence.append(candidate) score = 70 reasons = [f'多张发票合计一致 ¥{total:.2f}', ' + '.join(str(primary(item)) for item in group)] for condition, reason in [(len(evidence) >= 2, '订单与付款凭证相互印证'), (date_distance(group, evidence) <= 7, '行程与付款日期接近'), (all(item['ocr']['travel']['type'] for item in group) and len({tuple(item['ocr']['travel'][field] for field in ['departure', 'destination', 'departureTime', 'transportNumber']) for item in group}) == 1, '车次、路线及行程日期一致')]: if condition: score += 10 reasons.append(reason) if score >= 90: add_match(state, group, evidence, score, reasons) candidates = [] for invoice in invoices: for payment in payments: shared = common(invoice, payment, 'amounts') if not shared or invoice['ocr']['status'] != 'success' or payment['ocr']['status'] != 'success': continue score, reasons = 55, ['金额一致 ¥' + shared[0]] merchant = merchant_similarity(invoice, payment) if merchant >= .35: score += int(25 * merchant + .5) reasons.append(f'商户相似 {int(merchant * 100 + .5)}%') distance = date_distance([invoice], [payment]) if distance <= 7: score += 15 if distance <= 3 else 8 reasons.append('日期一致' if distance == 0 else f'日期相差 {distance} 天') if common(invoice, payment, 'orderNumbers'): score += 20 reasons.append('交易单号一致') for amount in shared: if sum(amount in item['ocr']['amounts'] for item in invoices) == 1 and sum(amount in item['ocr']['amounts'] for item in payments) == 1: score += 20 reasons.append(f'金额 ¥{amount} 在本批材料中唯一') break if score >= 70: candidates.append((invoice, payment, min(100, score), reasons)) candidates.sort(key=lambda candidate: -candidate[2]) for candidate in candidates: invoice, payment, score, reasons = candidate if invoice['matched'] or payment['matched']: continue second = max((other[2] for other in candidates if other is not candidate and (other[0] is invoice or other[1] is payment) and not other[0]['matched'] and not other[1]['matched']), default=0) if score >= 90 or score - second >= 8: add_match(state, [invoice], [payment], score, reasons) for invoice in invoices: if invoice['matched']: continue available = sorted([(item, primary(item)) for item in payments if not item['matched'] and primary(item) is not None and primary(item) > 0], key=lambda entry: entry[1]) options = [] for total in amounts(invoice): options.extend((total, group) for group in combinations([(item, amount) for item, amount in available if amount < total], total)) if len(options) > 1: break if len(options) == 1: total, evidence = options[0] add_match(state, [invoice], evidence, 95, [f'多笔付款合计一致 ¥{total:.2f}', ' + '.join(str(primary(item)) for item in evidence)]) return state def material_date(item, issue=False): content = text(item) if issue: match = re.search(r'(?:申请开票(?:日期|时间)?|开票日期|发票开具日期|开具日期)\s*[::]?\s*' + DATE, content) if match: try: return date(*map(int, match.groups())).isoformat() except ValueError: pass if item['ocr']['dates']: return item['ocr']['dates'][0] match = re.search(r'(?:^|\D)(\d{1,2})月(\d{1,2})日', content) if match: try: return date(date.today().year, *map(int, match.groups())).isoformat() except ValueError: pass return '9999-12-31' def invoice_amount(item): content = text(item) patterns = [r'价税合计[^\n]{0,35}?(?:小写)?[^0-9]{0,12}([0-9][0-9,]*(?:\.\d{1,2})?)', r'(?:小写|票价|发票金额|合计金额|总金额|应付金额)\s*[((]?[人民币RMB]*[))]?\s*[::]?\s*[¥¥]?\s*([0-9][0-9,]*(?:\.\d{1,2})?)'] for pattern in patterns: values = re.findall(pattern, content, re.I) if values: return Decimal(values[-1].replace(',', '')) return max([Decimal(value) for value in item['ocr']['amounts']], default=Decimal(0)) def payment_amount(item): for pattern in [r'(?:支付总额|实付金额|实际支付金额|付款金额|实付款|实付价|合计)\s*[::]?\s*[¥¥]?\s*([0-9][0-9,]*(?:\.\d{1,2})?)', r'已优惠[^\n]{0,24}?[¥¥]\s*([0-9][0-9,]*(?:\.\d{1,2})?)']: values = re.findall(pattern, text(item), re.I) if values: return abs(Decimal(values[-1].replace(',', ''))) return abs(primary(item) or Decimal(0)) def payment_total(items): result = Decimal(0) for index, item in enumerate(items): amount = payment_amount(item) duplicate = re.search('支付总额|查看订单支付明细|购票成功|订单详情', text(item)) and any( other_index != index and re.search('支付成功|付款成功|交易成功|支付方式|付款方式|交易单号|商户单号|转账成功|收款方', text(other)) and abs(payment_amount(other) - amount) < Decimal('.005') for other_index, other in enumerate(items)) if not duplicate: result += amount return result def enrich(state): from match_explanation import explain_match for item in state['invoices'] + state['payments'] + state['photos']: item['displayAmount'] = float(invoice_amount(item) if item['type'] == 'invoice' else payment_amount(item)) item['sortDate'] = material_date(item) item['issueDate'] = material_date(item, True) lookup = {item['id']: item for item in state['invoices'] + state['payments']} for match in state['matches']: match['invoices'] = [lookup[item['id']] for item in match['invoices']] match['payments'] = [lookup[item['id']] for item in match['payments']] match['paymentTotal'] = float(payment_total(match['payments'])) match['expenseAmount'] = float(sum(max((Decimal(value) for value in item['ocr']['amounts']), default=Decimal(0)) for item in match['invoices'])) match['explanation'] = explain_match(match) state['directoryPaymentTotal'] = float(payment_total(state['payments'])) return state