Files
Auto-Finance/tests/test_matcher.py
2026-06-11 19:22:34 +08:00

565 lines
18 KiB
Python
Raw Permalink 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.
"""发票与支付记录匹配模块单元测试
覆盖范围:
- 辅助函数_safe_float, _relative_tolerance, _build_invoice_summary
- 一对一匹配:精确匹配、容差内匹配、容差外不匹配
- 一对多匹配:精确匹配阶段、贪心匹配阶段、回滚逻辑
- 记录构建_build_payment_records, _invoices_to_records
- 端到端match_invoices_to_cards直接传入分类好的支付记录
"""
from __future__ import annotations
from typing import Any
from src.doc.matcher import (
_build_invoice_summary,
_build_payment_records,
_invoices_to_records,
_match,
_match_one_to_many,
_match_one_to_one,
_relative_tolerance,
_safe_float,
match_invoices_to_cards,
)
# 字段键名(与源码中的字符串字面量保持一致)
K_INVOICE_TYPE = "invoice_type"
K_INVOICE_NUMBER = "invoice_number"
K_TOTAL_AMOUNT = "total_amount"
K_ITEM_NAME = "item_name"
K_PERSON_NAME = "person_name"
K_CARD_DATE = "card_date"
K_CARD_NO = "card_no"
K_CARD_AMOUNT = "card_amount"
K_MATCHED_INVOICES = "_matched_invoices"
K_RELATIVE_INVOICE_COUNT = "relative_invoice_count"
K_INVOICE_DETAIL = "invoice_detail"
K_REMARK = "remark"
K_SOURCE_FILE = "source_file"
INVOICE_TYPE_TRAIN = "train"
INVOICE_TYPE_HOTEL = "hotel"
INVOICE_TYPE_GENERAL = "general"
# ------------------------------------------------------------------
# Fixture helpers
# ------------------------------------------------------------------
def _make_invoice(number: str, amount: float, inv_type: str = INVOICE_TYPE_GENERAL) -> dict[str, Any]:
inv: dict[str, Any] = {
K_INVOICE_NUMBER: number,
K_INVOICE_TYPE: inv_type,
K_TOTAL_AMOUNT: str(amount),
}
if inv_type == INVOICE_TYPE_TRAIN:
inv[K_PERSON_NAME] = f"person{number}"
elif inv_type == INVOICE_TYPE_GENERAL:
inv[K_ITEM_NAME] = f"item{number}"
return inv
def _make_card(date: str, amount: float, card_no: str = "6228480000000000") -> dict[str, Any]:
return {
K_CARD_DATE: date,
K_CARD_NO: card_no,
K_CARD_AMOUNT: str(amount),
K_SOURCE_FILE: "card.png",
}
# ------------------------------------------------------------------
# 辅助函数
# ------------------------------------------------------------------
class TestSafeFloat:
"""安全浮点转换"""
def test_normal_string(self):
assert _safe_float("123.45") == 123.45
def test_with_comma(self):
assert _safe_float("1,234.56") == 1234.56
def test_none_returns_default(self):
assert _safe_float(None) == 0.0
def test_empty_string_returns_default(self):
assert _safe_float("") == 0.0
def test_whitespace_returns_default(self):
assert _safe_float(" ") == 0.0
def test_invalid_string_returns_default(self):
assert _safe_float("abc") == 0.0
def test_custom_default(self):
assert _safe_float(None, default=-1.0) == -1.0
def test_int_input(self):
assert _safe_float(42) == 42.0
class TestRelativeTolerance:
"""相对容差计算"""
def test_default_rate(self):
assert _relative_tolerance(1000) == 30.0
def test_custom_rate(self):
assert _relative_tolerance(1000, 0.03) == 30.0
def test_negative_base(self):
assert _relative_tolerance(-200, 0.03) == 6.0
def test_zero_base(self):
assert _relative_tolerance(0, 0.03) == 0.0
class TestBuildInvoiceSummary:
"""发票汇总字符串"""
def test_single_invoice(self):
invoices = [_make_invoice("INV001", 100.0)]
result = _build_invoice_summary(invoices)
assert "INV001" in result
assert "100.0" in result
def test_multiple_invoices(self):
invoices = [
_make_invoice("INV001", 100.0),
_make_invoice("INV002", 200.0),
]
result = _build_invoice_summary(invoices)
assert " | " in result
assert "INV001" in result
assert "INV002" in result
def test_train_uses_person_name(self):
invoices = [_make_invoice("TRAIN001", 500.0, INVOICE_TYPE_TRAIN)]
result = _build_invoice_summary(invoices)
assert "personTRAIN001" in result
assert INVOICE_TYPE_TRAIN in result
def test_hotel_uses_fixed_label(self):
invoices = [_make_invoice("HOTEL001", 800.0, INVOICE_TYPE_HOTEL)]
result = _build_invoice_summary(invoices)
assert "hotel[hotel]" in result
def test_general_uses_project_name(self):
invoices = [_make_invoice("INV001", 100.0, INVOICE_TYPE_GENERAL)]
result = _build_invoice_summary(invoices)
assert "itemINV001" in result
def test_missing_fields_falls_back_to_number(self):
inv = {K_INVOICE_NUMBER: "INV001", K_TOTAL_AMOUNT: "50.0"}
result = _build_invoice_summary([inv])
assert "INV001" in result
def test_empty_invoices_returns_empty(self):
result = _build_invoice_summary([])
assert result == ""
# ------------------------------------------------------------------
# 一对一匹配
# ------------------------------------------------------------------
class TestMatchOneToOne:
"""一对一匹配"""
def test_exact_match(self):
invoices = [_make_invoice("A", 500), _make_invoice("B", 300)]
cards = [_make_card("2026-01-01", 500), _make_card("2026-01-02", 300)]
for inv in invoices:
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
for card in cards:
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
assigned: set[int] = set()
result: dict[int, list[int]] = {}
_match_one_to_one(invoices, cards, 0.03, assigned, result)
assert 0 in result
assert 1 in result
assert result[0] == [0]
assert result[1] == [1]
def test_within_tolerance(self):
invoices = [_make_invoice("A", 500)]
cards = [_make_card("2026-01-01", 490)]
for inv in invoices:
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
for card in cards:
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
assigned: set[int] = set()
result: dict[int, list[int]] = {}
_match_one_to_one(invoices, cards, 0.03, assigned, result)
assert 0 in result
def test_outside_tolerance_no_match(self):
invoices = [_make_invoice("A", 500)]
cards = [_make_card("2026-01-01", 400)]
for inv in invoices:
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
for card in cards:
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
assigned: set[int] = set()
result: dict[int, list[int]] = {}
_match_one_to_one(invoices, cards, 0.03, assigned, result)
assert 0 not in result
def test_sorted_by_amount_desc(self):
invoices = [_make_invoice("A", 100), _make_invoice("B", 500)]
cards = [_make_card("2026-01-01", 100), _make_card("2026-01-02", 500)]
for inv in invoices:
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
for card in cards:
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
invoices.sort(key=lambda i: i["_amount"], reverse=True)
cards.sort(key=lambda c: c["_amount"], reverse=True)
assigned: set[int] = set()
result: dict[int, list[int]] = {}
_match_one_to_one(invoices, cards, 0.03, assigned, result)
assert len(result) == 2
# ------------------------------------------------------------------
# 一对多匹配
# ------------------------------------------------------------------
class TestMatchOneToMany:
"""一对多匹配"""
def _prepare(self, invoices, cards):
for inv in invoices:
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
for card in cards:
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
invoices.sort(key=lambda i: i["_amount"], reverse=True)
cards.sort(key=lambda c: c["_amount"], reverse=True)
def test_exact_match_phase(self):
invoices = [
_make_invoice("A", 500),
_make_invoice("B", 300),
_make_invoice("C", 200),
]
cards = [_make_card("2026-01-01", 500)]
self._prepare(invoices, cards)
assigned: set[int] = set()
result: dict[int, list[int]] = {}
_match_one_to_many(invoices, cards, 0.03, assigned, result)
assert 0 in result
matched_inv = invoices[result[0][0]]
assert matched_inv["_amount"] == 500.0
def test_greedy_match_multiple_invoices(self):
invoices = [
_make_invoice("A", 300),
_make_invoice("B", 200),
_make_invoice("C", 100),
]
cards = [_make_card("2026-01-01", 500)]
self._prepare(invoices, cards)
assigned: set[int] = set()
result: dict[int, list[int]] = {}
_match_one_to_many(invoices, cards, 0.03, assigned, result)
assert 0 in result
assert len(result[0]) == 2
def test_greedy_with_remaining_invoice(self):
invoices = [
_make_invoice("A", 300),
_make_invoice("B", 200),
_make_invoice("C", 100),
]
cards = [_make_card("2026-01-01", 500)]
self._prepare(invoices, cards)
assigned: set[int] = set()
result: dict[int, list[int]] = {}
_match_one_to_many(invoices, cards, 0.03, assigned, result)
assert 0 in result
assert len(assigned) == 2
def test_rollback_when_over_shooting(self):
invoices = [
_make_invoice("A", 600),
_make_invoice("B", 500),
]
cards = [_make_card("2026-01-01", 1000)]
self._prepare(invoices, cards)
assigned: set[int] = set()
result: dict[int, list[int]] = {}
_match_one_to_many(invoices, cards, 0.03, assigned, result)
assert 0 in result
assert len(result[0]) == 1
def test_zero_amount_card_skipped(self):
invoices = [_make_invoice("A", 100)]
cards = [_make_card("2026-01-01", 0)]
self._prepare(invoices, cards)
assigned: set[int] = set()
result: dict[int, list[int]] = {}
_match_one_to_many(invoices, cards, 0.03, assigned, result)
assert 0 not in result
def test_zero_amount_invoice_skipped(self):
invoices = [
_make_invoice("A", 100),
_make_invoice("B", 0),
]
cards = [_make_card("2026-01-01", 100)]
self._prepare(invoices, cards)
assigned: set[int] = set()
result: dict[int, list[int]] = {}
_match_one_to_many(invoices, cards, 0.03, assigned, result)
assert 0 in result
matched_inv = invoices[result[0][0]]
assert matched_inv["_amount"] == 100.0
def test_multiple_cards_greedy(self):
invoices = [
_make_invoice("A", 300),
_make_invoice("B", 200),
_make_invoice("C", 150),
_make_invoice("D", 100),
]
cards = [
_make_card("2026-01-01", 500),
_make_card("2026-01-02", 150),
]
self._prepare(invoices, cards)
assigned: set[int] = set()
result: dict[int, list[int]] = {}
_match_one_to_many(invoices, cards, 0.03, assigned, result)
assert 0 in result
assert 1 in result
# ------------------------------------------------------------------
# _match 路由
# ------------------------------------------------------------------
class TestMatchRouter:
"""_match 根据数量选择策略"""
def _prepare(self, invoices, cards):
for inv in invoices:
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
for card in cards:
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
invoices.sort(key=lambda i: i["_amount"], reverse=True)
cards.sort(key=lambda c: c["_amount"], reverse=True)
def test_equal_count_routes_to_one_to_one(self):
invoices = [_make_invoice("A", 500)]
cards = [_make_card("2026-01-01", 500)]
self._prepare(invoices, cards)
result = _match(cards, invoices, 0.03)
assert 0 in result
def test_more_invoices_routes_to_one_to_many(self):
invoices = [_make_invoice("A", 300), _make_invoice("B", 200)]
cards = [_make_card("2026-01-01", 500)]
self._prepare(invoices, cards)
result = _match(cards, invoices, 0.03)
assert 0 in result
# ------------------------------------------------------------------
# 记录构建
# ------------------------------------------------------------------
class TestBuildPaymentRecords:
"""构建支付记录列表"""
def _prepare(self, invoices, cards):
for inv in invoices:
inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
for card in cards:
card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
def test_matched_records_have_card_info(self):
invoices = [_make_invoice("A", 500)]
cards = [_make_card("2026-01-01", 500)]
self._prepare(invoices, cards)
card_to_invoices = {0: [0]}
records = _build_payment_records(cards, invoices, card_to_invoices)
assert len(records) == 1
assert records[0][K_CARD_DATE] == "2026-01-01"
assert records[0][K_RELATIVE_INVOICE_COUNT] == "1"
assert "unmatched" not in records[0][K_REMARK]
def test_unmatched_invoices_become_separate_records(self):
invoices = [_make_invoice("A", 500), _make_invoice("B", 300)]
cards = [_make_card("2026-01-01", 500)]
self._prepare(invoices, cards)
card_to_invoices = {0: [0]}
records = _build_payment_records(cards, invoices, card_to_invoices)
assert len(records) == 2
unmatched = [r for r in records if r[K_REMARK] == "unmatched"]
assert len(unmatched) == 1
def test_empty_mapping_returns_no_records(self):
invoices = []
cards = []
records = _build_payment_records(cards, invoices, {})
assert records == []
class TestInvoicesToRecords:
"""无刷卡记录时将发票转为独立记录"""
def test_single_invoice(self):
invoices = [_make_invoice("A", 100)]
records = _invoices_to_records(invoices)
assert len(records) == 1
assert records[0][K_RELATIVE_INVOICE_COUNT] == "1"
def test_multiple_invoices(self):
invoices = [_make_invoice("A", 100), _make_invoice("B", 200)]
records = _invoices_to_records(invoices)
assert len(records) == 2
def test_empty_invoices(self):
records = _invoices_to_records([])
assert records == []
# ------------------------------------------------------------------
# 端到端集成
# ------------------------------------------------------------------
class TestMatchInvoicesToCards:
"""match_invoices_to_cards 端到端测试"""
def test_no_cards_returns_invoice_records(self):
invoices = [_make_invoice("A", 100), _make_invoice("B", 200)]
result = match_invoices_to_cards(invoices, cards=None)
assert len(result) == 2
for rec in result:
assert rec[K_CARD_DATE] == ""
assert rec[K_CARD_NO] == ""
def test_one_to_one_end_to_end(self):
cards = [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "500.00",
}
]
invoices = [_make_invoice("A", 500)]
result = match_invoices_to_cards(invoices, cards=cards)
assert len(result) == 1
assert result[0][K_CARD_DATE] == "2026-01-01"
assert result[0][K_RELATIVE_INVOICE_COUNT] == "1"
assert "_amount" not in result[0]
def test_one_to_many_end_to_end(self):
cards = [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "500.00",
}
]
invoices = [
_make_invoice("A", 300),
_make_invoice("B", 200),
]
result = match_invoices_to_cards(invoices, cards=cards)
assert len(result) == 1
assert result[0][K_RELATIVE_INVOICE_COUNT] == "2"
def test_unmatched_invoices_included(self):
cards = [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "500.00",
}
]
invoices = [
_make_invoice("A", 500),
_make_invoice("B", 100),
]
result = match_invoices_to_cards(invoices, cards=cards)
assert len(result) == 2
unmatched = [r for r in result if r[K_REMARK] == "unmatched"]
assert len(unmatched) == 1
def test_internal_fields_cleaned(self):
cards = [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "500.00",
}
]
invoices = [_make_invoice("A", 500)]
result = match_invoices_to_cards(invoices, cards=cards)
for inv in result[0][K_MATCHED_INVOICES]:
assert "_amount" not in inv
def test_empty_cards_list(self):
invoices = [_make_invoice("A", 100)]
result = match_invoices_to_cards(invoices, cards=[])
assert len(result) == 1
assert result[0][K_CARD_DATE] == ""
def test_multiple_cards(self):
cards = [
{K_CARD_DATE: "2026-01-01", K_CARD_NO: "6228480000000001", K_CARD_AMOUNT: "500.00"},
{K_CARD_DATE: "2026-01-02", K_CARD_NO: "6228480000000002", K_CARD_AMOUNT: "300.00"},
]
invoices = [
_make_invoice("A", 500),
_make_invoice("B", 300),
]
result = match_invoices_to_cards(invoices, cards=cards)
assert len(result) == 2