完成差旅发票录入流程

This commit is contained in:
wandering
2026-06-11 19:22:34 +08:00
parent 263d542903
commit 87c7b2d5be
62 changed files with 3033 additions and 2756 deletions

23
tests/README.md Normal file
View File

@@ -0,0 +1,23 @@
---
last_reviewed: 2026-06-11
---
# tests — 单元测试目录
存放项目单元测试,使用 pytest 运行。
## 测试清单
| 文件 | 覆盖范围 |
|------|---------|
| `test_config.py` | 配置加载模块 |
| `test_extractor.py` | 发票提取编排:空目录、提取失败、正常流程、分类结果、申请单分离、支付匹配 |
| `test_invoice.py` | 发票分类、CSV 读写 |
| `test_llm_extractor.py` | LLM 信息提取 |
| `test_matcher.py` | 发票与支付记录匹配 |
## 运行方式
```bash
uv run pytest tests/
```

340
tests/test_extractor.py Normal file
View File

@@ -0,0 +1,340 @@
"""发票提取编排模块单元测试
覆盖范围:
- extract_invoices空目录、提取失败、正常流程、分类结果、申请单分离
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from src.doc.extractor import extract_invoices
# 字段键名(与源码中的字符串字面量保持一致)
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"
# 发票类型
INVOICE_TYPE_TRAIN = "train"
INVOICE_TYPE_HOTEL = "hotel"
INVOICE_TYPE_GENERAL = "general"
INVOICE_TYPE_PAYMENT = "payment"
DOCUMENT_TYPE_APPLICATION = "application"
# ------------------------------------------------------------------
# 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_application() -> dict[str, Any]:
return {
K_INVOICE_TYPE: DOCUMENT_TYPE_APPLICATION,
"applicant": "张三",
}
def _make_card(amount: float) -> dict[str, Any]:
return {
K_INVOICE_TYPE: INVOICE_TYPE_PAYMENT,
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: f"{amount:.2f}",
}
# ------------------------------------------------------------------
# extract_invoices
# ------------------------------------------------------------------
class TestExtractInvoices:
"""extract_invoices 编排函数"""
def test_empty_directory(self, tmp_path: Path, monkeypatch):
monkeypatch.setattr("src.doc.extractor._find_all_files", lambda d: [])
records, apps, groups = extract_invoices(str(tmp_path))
assert records == []
assert apps == []
assert groups == {"travel": [], "general": [], "application": []}
def test_extraction_fails(self, tmp_path: Path, monkeypatch):
pdf = tmp_path / "broken.pdf"
pdf.touch()
monkeypatch.setattr("src.doc.extractor._find_all_files", lambda d: [pdf])
monkeypatch.setattr("src.doc.extractor._extract_document", lambda p, c: None)
records, apps, groups = extract_invoices(str(tmp_path))
assert records == []
assert apps == []
assert groups == {"travel": [], "general": [], "application": []}
def test_normal_flow_general_invoices(self, tmp_path: Path, monkeypatch):
pdf1 = tmp_path / "inv1.pdf"
pdf2 = tmp_path / "inv2.pdf"
pdf1.touch()
pdf2.touch()
inv1 = _make_invoice("INV001", 300.0, INVOICE_TYPE_GENERAL)
inv2 = _make_invoice("INV002", 200.0, INVOICE_TYPE_GENERAL)
monkeypatch.setattr("src.doc.extractor._find_all_files", lambda d: [pdf1, pdf2])
call_index = [0]
def fake_extract(path, cache_dir):
idx = call_index[0]
call_index[0] += 1
return inv1 if idx == 0 else inv2
monkeypatch.setattr("src.doc.extractor._extract_document", fake_extract)
def fake_match(invoices, cards):
return [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "500.00",
K_RELATIVE_INVOICE_COUNT: "2",
K_INVOICE_DETAIL: "",
K_REMARK: "",
K_MATCHED_INVOICES: [inv1, inv2],
}
]
monkeypatch.setattr("src.doc.extractor.match_invoices_to_cards", fake_match)
records, apps, groups = extract_invoices(str(tmp_path))
assert len(records) == 1
assert records[0][K_RELATIVE_INVOICE_COUNT] == "2"
assert len(groups["travel"]) == 0
assert len(groups["general"]) == 2
def test_normal_flow_mixed_invoices(self, tmp_path: Path, monkeypatch):
pdf1 = tmp_path / "train.pdf"
pdf2 = tmp_path / "hotel.pdf"
pdf3 = tmp_path / "general.pdf"
pdf1.touch()
pdf2.touch()
pdf3.touch()
inv_train = _make_invoice("TRAIN001", 500.0, INVOICE_TYPE_TRAIN)
inv_hotel = _make_invoice("HOTEL001", 800.0, INVOICE_TYPE_HOTEL)
inv_general = _make_invoice("GEN001", 150.0, INVOICE_TYPE_GENERAL)
monkeypatch.setattr(
"src.doc.extractor._find_all_files",
lambda d: [pdf1, pdf2, pdf3],
)
invoices_list = [inv_train, inv_hotel, inv_general]
call_index = [0]
def fake_extract(path, cache_dir):
idx = call_index[0]
call_index[0] += 1
return invoices_list[idx]
monkeypatch.setattr("src.doc.extractor._extract_document", fake_extract)
def fake_match(invoices, cards):
return [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "500.00",
K_RELATIVE_INVOICE_COUNT: "1",
K_INVOICE_DETAIL: "",
K_REMARK: "",
K_MATCHED_INVOICES: [inv_train],
},
{
K_CARD_DATE: "2026-01-02",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "800.00",
K_RELATIVE_INVOICE_COUNT: "1",
K_INVOICE_DETAIL: "",
K_REMARK: "",
K_MATCHED_INVOICES: [inv_hotel],
},
{
K_CARD_DATE: "",
K_CARD_NO: "",
K_CARD_AMOUNT: "",
K_RELATIVE_INVOICE_COUNT: "1",
K_INVOICE_DETAIL: "",
K_REMARK: "unmatched",
K_MATCHED_INVOICES: [inv_general],
},
]
monkeypatch.setattr("src.doc.extractor.match_invoices_to_cards", fake_match)
records, apps, groups = extract_invoices(str(tmp_path))
assert len(records) == 3
assert len(groups["travel"]) == 2
assert len(groups["general"]) == 1
travel_numbers = {inv[K_INVOICE_NUMBER] for inv in groups["travel"]}
assert "TRAIN001" in travel_numbers
assert "HOTEL001" in travel_numbers
assert groups["general"][0][K_INVOICE_NUMBER] == "GEN001"
def test_application_documents_separated(self, tmp_path: Path, monkeypatch):
pdf1 = tmp_path / "invoice.pdf"
pdf2 = tmp_path / "application.pdf"
pdf1.touch()
pdf2.touch()
inv = _make_invoice("INV001", 300.0, INVOICE_TYPE_GENERAL)
app = _make_application()
monkeypatch.setattr(
"src.doc.extractor._find_all_files",
lambda d: [pdf1, pdf2],
)
results = [inv, app]
call_index = [0]
def fake_extract(path, cache_dir):
idx = call_index[0]
call_index[0] += 1
return results[idx]
monkeypatch.setattr("src.doc.extractor._extract_document", fake_extract)
def fake_match(invoices, cards):
return [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "300.00",
K_RELATIVE_INVOICE_COUNT: "1",
K_INVOICE_DETAIL: "",
K_REMARK: "",
K_MATCHED_INVOICES: [inv],
}
]
monkeypatch.setattr("src.doc.extractor.match_invoices_to_cards", fake_match)
records, apps, groups = extract_invoices(str(tmp_path))
assert len(apps) == 1
assert apps[0]["applicant"] == "张三"
assert len(groups["application"]) == 1
def test_payment_records_included(self, tmp_path: Path, monkeypatch):
pdf1 = tmp_path / "invoice.pdf"
pdf2 = tmp_path / "card.png"
pdf1.touch()
pdf2.touch()
inv = _make_invoice("INV001", 300.0, INVOICE_TYPE_GENERAL)
card = _make_card(300.0)
monkeypatch.setattr(
"src.doc.extractor._find_all_files",
lambda d: [pdf1, pdf2],
)
results = [inv, card]
call_index = [0]
def fake_extract(path, cache_dir):
idx = call_index[0]
call_index[0] += 1
return results[idx]
monkeypatch.setattr("src.doc.extractor._extract_document", fake_extract)
def fake_match(invoices, cards):
return [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "300.00",
K_RELATIVE_INVOICE_COUNT: "1",
K_INVOICE_DETAIL: "",
K_REMARK: "",
K_MATCHED_INVOICES: [inv],
}
]
monkeypatch.setattr("src.doc.extractor.match_invoices_to_cards", fake_match)
records, apps, groups = extract_invoices(str(tmp_path))
assert len(records) == 1
assert len(groups["general"]) == 1
def test_partial_extraction_failure(self, tmp_path: Path, monkeypatch):
pdf1 = tmp_path / "good.pdf"
pdf2 = tmp_path / "bad.pdf"
pdf1.touch()
pdf2.touch()
inv = _make_invoice("INV001", 300.0, INVOICE_TYPE_GENERAL)
monkeypatch.setattr(
"src.doc.extractor._find_all_files",
lambda d: [pdf1, pdf2],
)
results = [inv, None]
call_index = [0]
def fake_extract(path, cache_dir):
idx = call_index[0]
call_index[0] += 1
return results[idx]
monkeypatch.setattr("src.doc.extractor._extract_document", fake_extract)
def fake_match(invoices, cards):
return [
{
K_CARD_DATE: "2026-01-01",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "300.00",
K_RELATIVE_INVOICE_COUNT: "1",
K_INVOICE_DETAIL: "",
K_REMARK: "",
K_MATCHED_INVOICES: [inv],
}
]
monkeypatch.setattr("src.doc.extractor.match_invoices_to_cards", fake_match)
records, apps, groups = extract_invoices(str(tmp_path))
assert len(records) == 1
assert len(groups["general"]) == 1

View File

@@ -5,14 +5,36 @@
from src.doc.invoice import (
INVOICE_LEVEL_COLUMNS,
INVOICE_TYPE_GENERAL,
INVOICE_TYPE_HOTEL,
INVOICE_TYPE_TRAIN,
INVOICE_TYPE_TRAVEL,
PAYMENT_RECORD_COLUMNS,
classify_invoice_batch,
is_travel_invoice,
)
from src.doc.invoice import (
_classify_invoice_batch as classify_invoice_batch,
)
# 字段键名与发票类型(与源码中的字符串字面量保持一致)
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"
DOCUMENT_TYPE_APPLICATION = "application"
INVOICE_TYPE_TRAVEL = [INVOICE_TYPE_TRAIN, INVOICE_TYPE_HOTEL]
def is_travel_invoice(invoice_type: str) -> bool:
return invoice_type in INVOICE_TYPE_TRAVEL
class TestInvoiceConstants:
@@ -47,7 +69,7 @@ class TestIsTravelInvoice:
assert is_travel_invoice(INVOICE_TYPE_GENERAL) is False
def test_unknown_not_travel(self) -> None:
assert is_travel_invoice("未知类型") is False
assert is_travel_invoice("unknown") is False
class TestClassifyInvoiceBatch:
@@ -55,12 +77,12 @@ class TestClassifyInvoiceBatch:
def test_empty_list(self) -> None:
result = classify_invoice_batch([])
assert result == {"travel": [], "general": []}
assert result == {"travel": [], "general": [], "application": []}
def test_all_travel(self) -> None:
invoices = [
{"发票类型": INVOICE_TYPE_TRAIN, "发票号码": "001"},
{"发票类型": INVOICE_TYPE_HOTEL, "发票号码": "002"},
{K_INVOICE_TYPE: INVOICE_TYPE_TRAIN, K_INVOICE_NUMBER: "001"},
{K_INVOICE_TYPE: INVOICE_TYPE_HOTEL, K_INVOICE_NUMBER: "002"},
]
result = classify_invoice_batch(invoices)
assert len(result["travel"]) == 2
@@ -68,7 +90,7 @@ class TestClassifyInvoiceBatch:
def test_all_general(self) -> None:
invoices = [
{"发票类型": INVOICE_TYPE_GENERAL, "发票号码": "001"},
{K_INVOICE_TYPE: INVOICE_TYPE_GENERAL, K_INVOICE_NUMBER: "001"},
]
result = classify_invoice_batch(invoices)
assert len(result["travel"]) == 0
@@ -76,14 +98,14 @@ class TestClassifyInvoiceBatch:
def test_mixed(self) -> None:
invoices = [
{"发票类型": INVOICE_TYPE_TRAIN, "发票号码": "001"},
{"发票类型": INVOICE_TYPE_GENERAL, "发票号码": "002"},
{K_INVOICE_TYPE: INVOICE_TYPE_TRAIN, K_INVOICE_NUMBER: "001"},
{K_INVOICE_TYPE: INVOICE_TYPE_GENERAL, K_INVOICE_NUMBER: "002"},
]
result = classify_invoice_batch(invoices)
assert len(result["travel"]) == 1
assert len(result["general"]) == 1
def test_missing_type_defaults_to_general(self) -> None:
invoices = [{"发票号码": "001"}]
invoices = [{K_INVOICE_NUMBER: "001"}]
result = classify_invoice_batch(invoices)
assert len(result["general"]) == 1

202
tests/test_llm_extractor.py Normal file
View File

@@ -0,0 +1,202 @@
"""LLM 信息提取模块单元测试
覆盖范围:
- _parse_json_response纯 JSON、Markdown 包裹、带前缀、解析失败
- _image_to_base64图片转 base64
- extract_document成功提取、LLM 失败
"""
from __future__ import annotations
import base64
import json
from pathlib import Path
import pytest
from src.doc.llm_extractor import (
_image_to_base64,
_parse_json_response,
extract_document,
)
# 字段键名(与源码中的字符串字面量保持一致)
K_INVOICE_NUMBER = "invoice_number"
K_TOTAL_AMOUNT = "total_amount"
K_CARD_DATE = "card_date"
K_CARD_NO = "card_no"
K_CARD_AMOUNT = "card_amount"
# ------------------------------------------------------------------
# _parse_json_response
# ------------------------------------------------------------------
class TestParseJsonResponse:
"""JSON 响应解析"""
def test_pure_json(self):
raw = json.dumps({K_INVOICE_NUMBER: "123456", K_TOTAL_AMOUNT: "100.00"})
result = _parse_json_response(raw)
assert result[K_INVOICE_NUMBER] == "123456"
assert result[K_TOTAL_AMOUNT] == "100.00"
def test_markdown_json_block(self):
raw = f'```json\n{{"{K_INVOICE_NUMBER}": "789"}}\n```'
result = _parse_json_response(raw)
assert result[K_INVOICE_NUMBER] == "789"
def test_markdown_block_without_lang(self):
raw = '```\n{"key": "value"}\n```'
result = _parse_json_response(raw)
assert result["key"] == "value"
def test_json_prefix(self):
raw = f'json\n{{"{K_INVOICE_NUMBER}": "001"}}'
result = _parse_json_response(raw)
assert result[K_INVOICE_NUMBER] == "001"
def test_json_prefix_with_whitespace(self):
raw = ' json \n{"a": 1}'
result = _parse_json_response(raw)
assert result["a"] == 1
def test_nested_json(self):
raw = json.dumps({"outer": {"inner": [1, 2, 3]}})
result = _parse_json_response(raw)
assert result["outer"]["inner"] == [1, 2, 3]
def test_whitespace_around_json(self):
raw = ' \n {"x": 42} \n '
result = _parse_json_response(raw)
assert result["x"] == 42
def test_invalid_json_raises(self):
with pytest.raises(ValueError):
_parse_json_response("not json at all")
def test_empty_string_raises(self):
with pytest.raises(ValueError):
_parse_json_response("")
# ------------------------------------------------------------------
# _image_to_base64
# ------------------------------------------------------------------
class TestImageToBase64:
"""图片转 base64"""
def test_png_to_base64(self, tmp_path: Path):
content = b"\x89PNG\r\n\x1a\nfake_png_data"
img_path = tmp_path / "test.png"
img_path.write_bytes(content)
result = _image_to_base64(img_path)
assert isinstance(result, str)
assert base64.b64decode(result) == content
def test_jpg_to_base64(self, tmp_path: Path):
content = b"\xff\xd8\xff\xe0fake_jpg_data"
img_path = tmp_path / "test.jpg"
img_path.write_bytes(content)
result = _image_to_base64(img_path)
assert base64.b64decode(result) == content
def test_file_not_found_raises(self, tmp_path: Path):
img_path = tmp_path / "nonexistent.png"
with pytest.raises(FileNotFoundError):
_image_to_base64(img_path)
def test_returns_utf8_string(self, tmp_path: Path):
content = b"test_image_content"
img_path = tmp_path / "test.png"
img_path.write_bytes(content)
result = _image_to_base64(img_path)
assert type(result) is str
# ------------------------------------------------------------------
# extract_document (mock LLM)
# ------------------------------------------------------------------
class TestExtractDocument:
"""图片提取:通过 mock _llm_query_multimodal 避免真实 LLM 调用"""
def _mock_multimodal(self, monkeypatch, response_text: str):
def fake_query(system_prompt, text, image_b64, max_tokens=4096):
return response_text
monkeypatch.setattr("src.doc.llm_extractor._llm_query_multimodal", fake_query)
def test_success(self, tmp_path: Path, monkeypatch):
img_path = tmp_path / "card.png"
img_path.write_bytes(b"fake_image")
mock_result = {
K_CARD_DATE: "2026-01-10",
K_CARD_NO: "6228480000000000",
K_CARD_AMOUNT: "500.00",
}
self._mock_multimodal(monkeypatch, json.dumps(mock_result))
result = extract_document(img_path)
assert result[K_CARD_DATE] == "2026-01-10"
assert result[K_CARD_NO] == "6228480000000000"
assert result[K_CARD_AMOUNT] == "500.00"
def test_llm_failure_propagates(self, tmp_path: Path, monkeypatch):
img_path = tmp_path / "card.png"
img_path.write_bytes(b"fake_image")
def fake_query(system_prompt, text, image_b64, max_tokens=4096):
raise RuntimeError("模型不可用")
monkeypatch.setattr("src.doc.llm_extractor._llm_query_multimodal", fake_query)
with pytest.raises(RuntimeError, match="模型不可用"):
extract_document(img_path)
def test_file_not_found_propagates(self, tmp_path: Path, monkeypatch):
img_path = tmp_path / "missing.png"
self._mock_multimodal(monkeypatch, "{}")
with pytest.raises(FileNotFoundError):
extract_document(img_path)
def test_markdown_wrapped_json(self, tmp_path: Path, monkeypatch):
img_path = tmp_path / "card.png"
img_path.write_bytes(b"fake_image")
mock_result = {
K_CARD_DATE: "2026-02-01",
K_CARD_NO: "6228481111111111",
K_CARD_AMOUNT: "300.00",
}
wrapped = f"```json\n{json.dumps(mock_result)}\n```"
self._mock_multimodal(monkeypatch, wrapped)
result = extract_document(img_path)
assert result[K_CARD_DATE] == "2026-02-01"
def test_image_encoded_as_base64(self, tmp_path: Path, monkeypatch):
img_path = tmp_path / "card.png"
expected_content = b"test_image_data"
img_path.write_bytes(expected_content)
received_b64 = None
def capture_b64(system_prompt, text, image_b64s, max_tokens=4096):
nonlocal received_b64
received_b64 = image_b64s
return json.dumps({K_CARD_DATE: "2026-01-01", K_CARD_NO: "0000", K_CARD_AMOUNT: "100"})
monkeypatch.setattr("src.doc.llm_extractor._llm_query_multimodal", capture_b64)
extract_document(img_path)
assert received_b64 is not None
assert isinstance(received_b64, list)
assert len(received_b64) >= 1
assert base64.b64decode(received_b64[0]) == expected_content

564
tests/test_matcher.py Normal file
View File

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