867 lines
29 KiB
Python
867 lines
29 KiB
Python
"""发票与支付记录匹配模块单元测试
|
||
|
||
覆盖范围:
|
||
- 辅助函数:_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_by_filename,
|
||
_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
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# 文件名匹配
|
||
# ------------------------------------------------------------------
|
||
|
||
|
||
class TestMatchByFilename:
|
||
"""文件名匹配:发票和刷卡记录的文件名(不含后缀)一致时直接匹配"""
|
||
|
||
def _make_invoice_with_file(self, number: str, amount: float, filename: str) -> dict[str, Any]:
|
||
inv = _make_invoice(number, amount)
|
||
inv["_source_file"] = filename
|
||
return inv
|
||
|
||
def _make_card_with_file(self, date: str, amount: float, filename: str) -> dict[str, Any]:
|
||
card = _make_card(date, amount)
|
||
card["_source_file"] = filename
|
||
return card
|
||
|
||
def test_exact_filename_match(self):
|
||
invoices = [
|
||
self._make_invoice_with_file("A", 500, "receipt_001.pdf"),
|
||
self._make_invoice_with_file("B", 300, "receipt_002.pdf"),
|
||
]
|
||
cards = [
|
||
self._make_card_with_file("2026-01-01", 500, "receipt_001.png"),
|
||
self._make_card_with_file("2026-01-02", 300, "receipt_002.png"),
|
||
]
|
||
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_by_filename(invoices, cards, assigned, result)
|
||
|
||
assert 0 in result
|
||
assert 1 in result
|
||
assert result[0] == [0]
|
||
assert result[1] == [1]
|
||
assert 0 in assigned
|
||
assert 1 in assigned
|
||
|
||
def test_filename_match_ignores_extension(self):
|
||
invoices = [self._make_invoice_with_file("A", 500, "data.pdf")]
|
||
cards = [self._make_card_with_file("2026-01-01", 500, "data.png")]
|
||
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_by_filename(invoices, cards, assigned, result)
|
||
|
||
assert 0 in result
|
||
assert result[0] == [0]
|
||
|
||
def test_filename_no_match_when_stems_differ(self):
|
||
invoices = [self._make_invoice_with_file("A", 500, "invoice_A.pdf")]
|
||
cards = [self._make_card_with_file("2026-01-01", 500, "card_001.png")]
|
||
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_by_filename(invoices, cards, assigned, result)
|
||
|
||
assert 0 not in result
|
||
assert len(assigned) == 0
|
||
|
||
def test_skips_invoice_without_source_file(self):
|
||
invoices = [_make_invoice("A", 500)]
|
||
cards = [self._make_card_with_file("2026-01-01", 500, "card.png")]
|
||
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_by_filename(invoices, cards, assigned, result)
|
||
|
||
assert 0 not in result
|
||
|
||
def test_skips_card_without_source_file(self):
|
||
invoices = [self._make_invoice_with_file("A", 500, "inv.pdf")]
|
||
cards = [_make_card("2026-01-01", 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])
|
||
|
||
assigned: set[int] = set()
|
||
result: dict[int, list[int]] = {}
|
||
_match_by_filename(invoices, cards, assigned, result)
|
||
|
||
assert 0 not in result
|
||
|
||
def test_skips_empty_source_file(self):
|
||
inv = _make_invoice("A", 500)
|
||
inv["_source_file"] = ""
|
||
cards = [self._make_card_with_file("2026-01-01", 500, "card.png")]
|
||
for _inv in [inv]:
|
||
_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_by_filename([inv], cards, assigned, result)
|
||
|
||
assert 0 not in result
|
||
|
||
def test_only_first_unassigned_invoice_matches(self):
|
||
invoices = [
|
||
self._make_invoice_with_file("A", 500, "same.pdf"),
|
||
self._make_invoice_with_file("B", 300, "same.pdf"),
|
||
]
|
||
cards = [self._make_card_with_file("2026-01-01", 500, "same.png")]
|
||
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_by_filename(invoices, cards, assigned, result)
|
||
|
||
assert 0 in result
|
||
assert result[0] == [0]
|
||
assert 0 in assigned
|
||
assert 1 not in assigned
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# 文件名匹配与金额匹配的交互
|
||
# ------------------------------------------------------------------
|
||
|
||
|
||
class TestFilenameAndAmountInteraction:
|
||
"""文件名预匹配后,已分配的发票不会被后续金额匹配重复处理"""
|
||
|
||
def _prepare_with_files(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 _make_invoice_with_file(self, number: str, amount: float, filename: str) -> dict[str, Any]:
|
||
inv = _make_invoice(number, amount)
|
||
inv["_source_file"] = filename
|
||
return inv
|
||
|
||
def _make_card_with_file(self, date: str, amount: float, filename: str) -> dict[str, Any]:
|
||
card = _make_card(date, amount)
|
||
card["_source_file"] = filename
|
||
return card
|
||
|
||
def test_filename_matched_invoice_skipped_in_one_to_one(self):
|
||
"""文件名预匹配后,_match_one_to_one 应跳过已分配的发票"""
|
||
invoices = [
|
||
self._make_invoice_with_file("A", 500, "match.pdf"),
|
||
self._make_invoice_with_file("B", 300, "other.pdf"),
|
||
]
|
||
cards = [
|
||
self._make_card_with_file("2026-01-01", 500, "match.png"),
|
||
self._make_card_with_file("2026-01-02", 300, "other.png"),
|
||
]
|
||
self._prepare_with_files(invoices, cards)
|
||
|
||
assigned: set[int] = set()
|
||
result: dict[int, list[int]] = {}
|
||
_match_by_filename(invoices, cards, assigned, result)
|
||
_match_one_to_one(invoices, cards, 0.03, assigned, result)
|
||
|
||
assert 0 in result
|
||
assert result[0] == [0]
|
||
assert len(assigned) == 2
|
||
|
||
def test_filename_match_takes_priority_over_amount(self):
|
||
"""即使金额不匹配,文件名匹配仍优先"""
|
||
invoices = [self._make_invoice_with_file("A", 1000, "same.pdf")]
|
||
cards = [self._make_card_with_file("2026-01-01", 500, "same.png")]
|
||
self._prepare_with_files(invoices, cards)
|
||
|
||
assigned: set[int] = set()
|
||
result: dict[int, list[int]] = {}
|
||
_match_by_filename(invoices, cards, assigned, result)
|
||
|
||
assert 0 in result
|
||
assert result[0] == [0]
|
||
assert 0 in assigned
|
||
|
||
def test_partial_filename_match_falls_back_to_amount(self):
|
||
"""部分文件名匹配后,剩余发票走金额匹配"""
|
||
invoices = [
|
||
self._make_invoice_with_file("A", 500, "match.pdf"),
|
||
self._make_invoice_with_file("B", 300, "no_match.pdf"),
|
||
]
|
||
cards = [
|
||
self._make_card_with_file("2026-01-01", 500, "match.png"),
|
||
self._make_card_with_file("2026-01-02", 300, "different.png"),
|
||
]
|
||
self._prepare_with_files(invoices, cards)
|
||
|
||
result = _match(cards, invoices, 0.03)
|
||
|
||
assert 0 in result
|
||
assert 1 in result
|
||
assert len(result) == 2
|
||
|
||
def test_filename_match_in_one_to_many_prevents_reuse(self):
|
||
"""一对多场景下,文件名匹配的发票不会被贪心匹配复用"""
|
||
invoices = [
|
||
self._make_invoice_with_file("A", 500, "same.pdf"),
|
||
self._make_invoice_with_file("B", 200, "other.pdf"),
|
||
]
|
||
cards = [self._make_card_with_file("2026-01-01", 500, "same.png")]
|
||
self._prepare_with_files(invoices, cards)
|
||
|
||
result = _match(cards, invoices, 0.03)
|
||
|
||
assert 0 in result
|
||
assert result[0] == [0]
|
||
assert len(result[0]) == 1
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# 端到端:文件名匹配集成
|
||
# ------------------------------------------------------------------
|
||
|
||
|
||
class TestEndToEndFilenameMatching:
|
||
"""match_invoices_to_cards 端到端测试(含文件名匹配)"""
|
||
|
||
def _make_invoice_with_file(self, number: str, amount: float, filename: str) -> dict[str, Any]:
|
||
inv = _make_invoice(number, amount)
|
||
inv["_source_file"] = filename
|
||
return inv
|
||
|
||
def _make_card_with_file(self, date: str, amount: float, filename: str) -> dict[str, Any]:
|
||
card = _make_card(date, amount)
|
||
card["_source_file"] = filename
|
||
return card
|
||
|
||
def test_full_filename_match(self):
|
||
invoices = [
|
||
self._make_invoice_with_file("A", 500, "receipt_001.pdf"),
|
||
self._make_invoice_with_file("B", 300, "receipt_002.pdf"),
|
||
]
|
||
cards = [
|
||
self._make_card_with_file("2026-01-01", 500, "receipt_001.png"),
|
||
self._make_card_with_file("2026-01-02", 300, "receipt_002.png"),
|
||
]
|
||
result = match_invoices_to_cards(invoices, cards=cards)
|
||
|
||
assert len(result) == 2
|
||
for rec in result:
|
||
assert rec[K_RELATIVE_INVOICE_COUNT] == "1"
|
||
|
||
def test_mixed_filename_and_amount_match(self):
|
||
"""部分文件名匹配 + 部分金额匹配"""
|
||
invoices = [
|
||
self._make_invoice_with_file("A", 500, "match.pdf"),
|
||
self._make_invoice_with_file("B", 300, "no_match.pdf"),
|
||
]
|
||
cards = [
|
||
self._make_card_with_file("2026-01-01", 500, "match.png"),
|
||
self._make_card_with_file("2026-01-02", 300, "different.png"),
|
||
]
|
||
result = match_invoices_to_cards(invoices, cards=cards)
|
||
|
||
assert len(result) == 2
|
||
|
||
def test_filename_match_with_no_cards_has_files(self):
|
||
"""无支付记录时,文件名信息不影响结果"""
|
||
invoices = [
|
||
self._make_invoice_with_file("A", 100, "inv.pdf"),
|
||
self._make_invoice_with_file("B", 200, "inv2.pdf"),
|
||
]
|
||
result = match_invoices_to_cards(invoices, cards=None)
|
||
|
||
assert len(result) == 2
|
||
|
||
def test_internal_fields_cleaned_after_filename_match(self):
|
||
"""文件名匹配后,内部字段仍被清理"""
|
||
invoices = [self._make_invoice_with_file("A", 500, "same.pdf")]
|
||
cards = [self._make_card_with_file("2026-01-01", 500, "same.png")]
|
||
result = match_invoices_to_cards(invoices, cards=cards)
|
||
|
||
for inv in result[0][K_MATCHED_INVOICES]:
|
||
assert "_amount" not in inv
|
||
for card in cards:
|
||
assert "_amount" not in card
|