agent 模式补充文件功能已经实现

This commit is contained in:
wandering
2026-06-15 10:39:01 +08:00
parent e252896de9
commit 7c137c5214
34 changed files with 1664 additions and 1651 deletions

View File

@@ -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