agent 模式补充文件功能已经实现
This commit is contained in:
@@ -17,6 +17,7 @@ from src.doc.matcher import (
|
||||
_build_payment_records,
|
||||
_invoices_to_records,
|
||||
_match,
|
||||
_match_by_filename,
|
||||
_match_one_to_many,
|
||||
_match_one_to_one,
|
||||
_relative_tolerance,
|
||||
@@ -562,3 +563,304 @@ class TestMatchInvoicesToCards:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user