"""发票与支付记录匹配模块单元测试 覆盖范围: - 辅助函数:_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