Files
reimburse/native-engine/domain.py
T
2026-09-17 11:12:57 +08:00

362 lines
19 KiB
Python
Raw 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.
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-9])([0-9]{1,7}[.,][0-9]{2})(?![0-9%])',
]
MERCHANT_LABEL = r'销售方名称|销方名称|收款方|收款单位|商户全称|商户名称|商户|付款给|收款人|对方户名|交易对象'
KEYWORDS = [
'餐饮 餐厅 饭店 酒楼 外卖 咖啡 奶茶 茶饮 饮品 火锅 烧烤 快餐 小吃 食品 美团外卖 饿了么 tims 星巴克',
'酒店 宾馆 旅馆 住宿 房费 客房 民宿 公寓 入住 退房',
'铁路 火车 12306 车次 机票 航空 航班 登机 滴滴 网约车 出租车 打车 货运 运输 加油站 汽油 柴油 停车 高速 地铁 公交',
'办公用品 办公 文具 打印 复印 纸张 墨盒 硒鼓 耗材 电脑 键盘 鼠标 显示器 软件 会员订阅 桌椅 文件夹 饮用水 桶装水',
'招待 接待 宴请 商务宴请 客户用餐 会议餐',
'采购 商品 材料 设备 配件 家具 日用品 百货 商城 京东 淘宝 天猫 超市 便利店 购物',
]
def unique(values):
return list(dict.fromkeys(values))
def money(value):
return Decimal(str(value).replace(',', '.')).quantize(Decimal('.01'), rounding=ROUND_HALF_UP)
def first(pattern, text):
match = re.search(pattern, text, re.I)
return match.group(1).strip() if match else ''
def extract(lines):
text = '\n'.join(lines)
amounts = unique(format(money(value), '.2f') for pattern in AMOUNT_PATTERNS
for value in re.findall(pattern, text, re.I) if money(value) >= 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'(?<![A-Z0-9])([GDCZTKSLY]\d{1,5})(?![A-Z0-9])' if train else r'(?<![A-Z0-9])([A-Z0-9]{2}\d{3,4})(?![A-Z0-9])'
travel['transportNumber'] = first(pattern, text)
return travel
def amounts(item):
return [money(value) for value in item['ocr']['amounts'] if money(value) > 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