Files
Auto-Finance/src/doc/matcher.py
T
2026-06-15 10:39:01 +08:00

410 lines
14 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.
"""发票与支付记录匹配
将提取到的发票数据与支付记录进行金额匹配,
输出以支付记录为主键的结果列表。
支付记录由统一提取模块(extractor)根据 LLM 返回的「invoice_type」字段分类而来,
不再按文件类型假设文档类型。
## 业务约束
- 发票数 >= 付款记录数(最少一张发票对应一张付款记录)
- 发票总金额 >= 付款总金额(发票只能比付款多,不能少)
- 若发票数 == 付款数,走一对一匹配,无需一对多
## 匹配流程
1. 接收分类好的发票和支付记录列表
2. 解析发票和刷卡记录的金额,进行总额校验
- 发票总额 < 刷卡总额时发出 warning
3. 按金额降序排序
4. 文件名匹配(最高优先级):发票和刷卡记录的文件名(不含后缀)一致时直接匹配
5. 根据数量关系选择匹配策略:
- 数量相等 → 一对一匹配:按金额从大到小依次配对,相对容差内即匹配
- 发票更多 → 一对多匹配:对每张刷卡记录贪心凑金额,相对容差内结束
6. 构建以支付记录为主键的结果列表
7. 未匹配的发票单独作为一条记录(无刷卡信息)
8. 清理内部字段,输出支付记录列表
## 容差计算
使用相对容差(默认 3%),以刷卡金额为基准:
- ¥2900 发票 vs ¥2850 刷卡 → 差 ¥50,容差 ¥85.5 → 匹配成功
- ¥100 发票 vs ¥95 刷卡 → 差 ¥5,容差 ¥3.0 → 不匹配(需精确匹配或调整)
## 一对多匹配细节
- 对每张刷卡记录,维护 remaining(剩余待匹配金额)
- 遍历未分配的发票(按金额降序):
- 若发票金额 + 容差 >= remaining,视为最后一张,匹配后退出
- 否则发票金额不超过 remaining + 容差即可匹配
- 匹配后 remaining 为负且超出容差时回滚最后一张发票
- 每张发票只会被分配一次
## 对外接口
match_invoices_to_cards(invoices, cards, tolerance) -> list[dict]
"""
from pathlib import Path
from typing import Any
from .. import get_logger
log = get_logger("matcher")
def _safe_float(value: str | None, default: float = 0.0) -> float:
"""安全转换为浮点数"""
if value is None or str(value).strip() == "":
return default
try:
return float(str(value).replace(",", ""))
except (ValueError, TypeError):
return default
def _build_invoice_summary(invoices: list[dict[str, Any]]) -> str:
"""将多张发票信息汇总为备注字符串"""
parts = []
for inv in invoices:
inv_type = inv.get("invoice_type", "unknown")
amount = inv.get("total_amount", "unknown")
if inv_type == "train":
label = inv.get("person_name") or inv.get("invoice_number", "unknown")
elif inv_type == "hotel":
label = "hotel"
else:
label = inv.get("item_name") or inv.get("invoice_number", "unknown")
parts.append(f"{inv_type}[{label}]¥{amount}")
return " | ".join(parts)
def _relative_tolerance(base: float, rate: float = 0.03) -> float:
"""根据基准金额计算相对容差(默认 3%)"""
return abs(base) * rate
def match_invoices_to_cards(
invoices: list[dict[str, Any]],
cards: list[dict[str, Any]] | None = None,
tolerance: float = 0.03,
) -> list[dict[str, Any]]:
"""将发票与支付记录按金额匹配,输出以支付记录为主键的结果列表
支付记录由统一提取模块(extractor)根据 LLM 返回的「invoice_type」字段分类而来。
业务约束:
- 发票数 >= 付款记录数
- 发票总金额 >= 付款总金额(发票只能比付款多,不能少)
- 若发票数 == 付款数,一对一匹配,无需一对多
Args:
invoices: 发票列表,需包含 "total_amount" 字段
cards: 支付记录列表,需包含 "card_amount" 字段(由 extractor 分类提供)
tolerance: 金额匹配容差比例(默认 0.03 = 3%)
Returns:
以支付记录为主键的结果列表,每条记录包含:
- card_date, card_no, card_amount(支付信息)
- 关联发票列表(_matched_invoices)
- 发票详情备注
- 未匹配发票单独作为一条无刷卡信息的记录
"""
if not cards:
log.warning("无支付记录可供匹配,发票将保持原状")
return _invoices_to_records(invoices)
# 解析金额
for card in cards:
card["_amount"] = _safe_float(card.get("card_amount"))
for inv in invoices:
inv["_amount"] = _safe_float(inv.get("total_amount"))
# 数据校验
total_invoices = sum(inv["_amount"] for inv in invoices)
total_cards = sum(card["_amount"] for card in cards)
log.info(
f"金额校验: 发票总额 ¥{total_invoices:.2f}, 刷卡总额 ¥{total_cards:.2f}, "
f"发票数 {len(invoices)}, 刷卡数 {len(cards)}"
)
total_tolerance = _relative_tolerance(max(total_invoices, total_cards), tolerance)
if total_invoices < total_cards - total_tolerance:
log.warning(
f"发票总额 (¥{total_invoices:.2f}) 小于刷卡总额 (¥{total_cards:.2f}), "
f"超出容差 {tolerance * 100:.0f}%, 匹配结果可能有偏差"
)
# 按金额降序排序
cards.sort(key=lambda c: c["_amount"], reverse=True)
invoices.sort(key=lambda i: i["_amount"], reverse=True)
# 执行匹配,返回 {card_index: [invoice_indices]} 的映射
card_to_invoices = _match(cards, invoices, tolerance)
# 构建支付记录列表
records = _build_payment_records(cards, invoices, card_to_invoices)
# 清理内部字段
for inv in invoices:
inv.pop("_amount", None)
for card in cards:
card.pop("_amount", None)
# 统计
matched_invoices = sum(len(inv_list) for inv_list in card_to_invoices.values())
unmatched_count = len(invoices) - matched_invoices
log.info(f"匹配完成: {len(records)} 条支付记录, {matched_invoices}/{len(invoices)} 张发票已关联")
if unmatched_count:
log.info(f"未匹配发票: {unmatched_count} 张(已单独列为记录)")
return records
def _match(
cards: list[dict[str, Any]],
invoices: list[dict[str, Any]],
tolerance: float,
) -> dict[int, list[int]]:
"""执行匹配,返回 {card_index: [invoice_indices]} 的映射
tolerance 为相对容差比例(如 0.03 表示 3%)
匹配优先级(从高到低):
1. 文件名匹配:发票和刷卡记录的文件名(不含后缀)一致时直接匹配
2. 精确匹配:金额差 <= 0.01 元
3. 一对一 / 一对多贪心匹配:按金额容差匹配
"""
result: dict[int, list[int]] = {}
assigned: set[int] = set()
# ---- 阶段 0:文件名匹配(最高优先级)----
_match_by_filename(invoices, cards, assigned, result)
if len(invoices) == len(cards):
_match_one_to_one(invoices, cards, tolerance, assigned, result)
else:
_match_one_to_many(invoices, cards, tolerance, assigned, result)
return result
def _match_by_filename(
invoices: list[dict[str, Any]],
cards: list[dict[str, Any]],
assigned: set[int],
result: dict[int, list[int]],
) -> None:
"""文件名匹配:发票和刷卡记录的文件名(不含后缀)一致时直接匹配"""
for card_idx, card in enumerate(cards):
card_name = card.get("_source_file", "")
if not card_name:
continue
card_stem = Path(card_name).stem
for idx, inv in enumerate(invoices):
if idx in assigned:
continue
inv_name = inv.get("_source_file", "")
if not inv_name:
continue
inv_stem = Path(inv_name).stem
if inv_stem == card_stem:
assigned.add(idx)
result[card_idx] = [idx]
log.info(f"[文件名匹配] {inv_name} ↔ {card_name}")
break
def _match_one_to_one(
invoices: list[dict[str, Any]],
cards: list[dict[str, Any]],
tolerance: float,
assigned: set[int],
result: dict[int, list[int]],
) -> None:
"""一对一匹配:发票数等于刷卡数,对每张刷卡记录寻找金额最接近的未分配发票"""
for card_idx, card in enumerate(cards):
if card_idx in result:
continue
card_amount = card["_amount"]
card_tol = _relative_tolerance(card_amount, tolerance)
# 在未分配的发票中找金额最接近的
best_idx = -1
best_diff = float("inf")
for idx, inv in enumerate(invoices):
if idx in assigned:
continue
diff = abs(inv["_amount"] - card_amount)
if diff < best_diff:
best_diff = diff
best_idx = idx
if best_idx >= 0 and best_diff <= card_tol:
inv = invoices[best_idx]
assigned.add(best_idx)
result[card_idx] = [best_idx]
log.info(
f"[一对一] {inv.get('invoice_number', 'unknown')} ¥{inv['_amount']:.2f} "
f"↔ {card.get('_source_file', 'unknown')} ¥{card_amount:.2f}"
)
elif best_idx >= 0:
inv = invoices[best_idx]
log.warning(
f"[一对一] 金额偏差超出容差: "
f"{inv.get('invoice_number', 'unknown')} ¥{inv['_amount']:.2f} "
f"vs ¥{card_amount:.2f} (差 ¥{best_diff:.2f}, 容差 ¥{card_tol:.2f})"
)
def _match_one_to_many(
invoices: list[dict[str, Any]],
cards: list[dict[str, Any]],
tolerance: float,
assigned: set[int],
result: dict[int, list[int]],
) -> None:
"""一对多匹配:一张刷卡可能对应多张发票,按金额从大到小贪心匹配"""
# ---- 阶段 1:精确匹配(金额差 <= 0.01 元视为相等)----
exact_tolerance = 0.01
for card_idx, card in enumerate(cards):
card_amount = card["_amount"]
if card_amount <= 0:
continue
for idx, inv in enumerate(invoices):
if idx in assigned:
continue
inv_amount = inv["_amount"]
if inv_amount <= 0:
continue
if abs(inv_amount - card_amount) <= exact_tolerance:
assigned.add(idx)
result[card_idx] = [idx]
log.info(
f"[一对多-精确] {inv.get('invoice_number', 'unknown')} ¥{inv_amount:.2f} "
f"↔ {card.get('_source_file', 'unknown')} ¥{card_amount:.2f}"
)
break
# ---- 阶段 2:贪心匹配(仅处理未精确匹配的刷卡记录)----
for card_idx, card in enumerate(cards):
if card_idx in result:
continue
card_amount = card["_amount"]
if card_amount <= 0:
continue
card_tol = _relative_tolerance(card_amount, tolerance)
remaining = card_amount
matched_indices: list[int] = []
for idx, inv in enumerate(invoices):
if idx in assigned:
continue
if remaining <= card_tol:
break
inv_amount = inv["_amount"]
if inv_amount <= 0:
continue
if inv_amount + card_tol >= remaining:
is_match = True
else:
is_match = inv_amount <= remaining + card_tol
if is_match:
assigned.add(idx)
matched_indices.append(idx)
remaining -= inv_amount
if remaining <= card_tol:
break
# 回滚:如果匹配后 remaining 为负且超出容差
if remaining < -card_tol and matched_indices:
last_idx = matched_indices.pop()
assigned.discard(last_idx)
remaining += invoices[last_idx]["_amount"]
# 记录匹配结果
if matched_indices:
result[card_idx] = matched_indices
for idx in matched_indices:
inv = invoices[idx]
log.info(
f"[一对多-贪心] {inv.get('invoice_number', 'unknown')} ¥{inv['_amount']:.2f} "
f"→ {card.get('_source_file', 'unknown')} ¥{card['_amount']:.2f}"
)
def _build_payment_records(
cards: list[dict[str, Any]],
invoices: list[dict[str, Any]],
card_to_invoices: dict[int, list[int]],
) -> list[dict[str, Any]]:
"""构建以支付记录为主键的结果列表"""
records: list[dict[str, Any]] = []
for card_idx, inv_indices in card_to_invoices.items():
card = cards[card_idx]
matched_invs = [invoices[idx] for idx in inv_indices]
record = {
"card_date": card.get("card_date", ""),
"card_no": card.get("card_no", ""),
"card_amount": str(card["_amount"]),
"relative_invoice_count": str(len(matched_invs)),
"invoice_detail": _build_invoice_summary(matched_invs),
"remark": "",
"_source_file": card.get("_source_file", ""),
"_matched_invoices": matched_invs,
}
records.append(record)
# 未匹配的发票,单独作为记录
matched_indices = set()
for inv_indices in card_to_invoices.values():
matched_indices.update(inv_indices)
unmatched = [inv for idx, inv in enumerate(invoices) if idx not in matched_indices]
for inv in unmatched:
record = {
"card_date": "",
"card_no": "",
"card_amount": "",
"relative_invoice_count": "1",
"invoice_detail": _build_invoice_summary([inv]),
"remark": "unmatched",
"_matched_invoices": [inv],
}
records.append(record)
return records
def _invoices_to_records(invoices: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""无刷卡记录时,将每张发票转为独立记录"""
records = []
for inv in invoices:
record = {
"card_date": "",
"card_no": "",
"card_amount": "",
"relative_invoice_count": "1",
"invoice_detail": _build_invoice_summary([inv]),
"remark": "",
"_matched_invoices": [inv],
}
records.append(record)
return records