Files
Auto-Finance/tests/test_matcher.py
wandering 1b35f07fd7 refactor: 架构重组 — doc/bot → core/infra,新增 Agent 调度模块
- src/doc/ 拆分为 src/core/extraction/, matching/, validation/(核心业务逻辑)
- src/bot/ 重命名为 src/infra/browser/(浏览器自动化基础设施)
- fill_consumable_doc.py → src/infra/documents/consumable.py
- 新增 Agent 调度模块:coordinator.py, events.py, session.py,重构 orchestrator.py
- 更新 AGENTS.md、README.md 及所有子目录 README
2026-07-02 18:36:19 +08:00

867 lines
29 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.core.matching 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