fix: deduplicate reconciliation loose items
This commit is contained in:
@@ -355,6 +355,19 @@ def render_reconciliation_table(items: list[dict]) -> str:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def loose_reconciliation_items(
|
||||||
|
items: list[dict], displayed_candidates: list[dict],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Exclude only exact evidence IDs represented by visible process cards."""
|
||||||
|
represented_ids = {
|
||||||
|
str(item_id)
|
||||||
|
for candidate in displayed_candidates
|
||||||
|
for item_id in (candidate.get("item_ids") or [])
|
||||||
|
if item_id is not None
|
||||||
|
}
|
||||||
|
return [item for item in items if str(item.get("id")) not in represented_ids]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/reconciliation", response_class=HTMLResponse)
|
@router.get("/reconciliation", response_class=HTMLResponse)
|
||||||
@router.get("/reconciliacao", response_class=HTMLResponse)
|
@router.get("/reconciliacao", response_class=HTMLResponse)
|
||||||
async def reconciliation_page(status: Optional[str] = "open", external_type: Optional[str] = "all", notice: Optional[str] = None, days: Optional[int] = 3):
|
async def reconciliation_page(status: Optional[str] = "open", external_type: Optional[str] = "all", notice: Optional[str] = None, days: Optional[int] = 3):
|
||||||
@@ -363,7 +376,10 @@ async def reconciliation_page(status: Optional[str] = "open", external_type: Opt
|
|||||||
item_type = None if external_type == "all" else external_type
|
item_type = None if external_type == "all" else external_type
|
||||||
recent_days = min(max(int(days or 3), 1), 90)
|
recent_days = min(max(int(days or 3), 1), 90)
|
||||||
items = sort_items_for_operator(list_reconciliation_items(status=status, external_type=item_type, limit=100, days=recent_days))
|
items = sort_items_for_operator(list_reconciliation_items(status=status, external_type=item_type, limit=100, days=recent_days))
|
||||||
process_candidates = list_reconciliation_process_candidates(status="open", days=recent_days, limit=8)
|
process_candidates = list_reconciliation_process_candidates(
|
||||||
|
status=status, external_type=item_type, days=recent_days, limit=8,
|
||||||
|
)
|
||||||
|
items = loose_reconciliation_items(items, process_candidates)
|
||||||
summary = reconciliation_summary(days=recent_days)
|
summary = reconciliation_summary(days=recent_days)
|
||||||
enrichment_summary = fiscal_enrichment_summary()
|
enrichment_summary = fiscal_enrichment_summary()
|
||||||
|
|
||||||
|
|||||||
@@ -2547,8 +2547,11 @@ def _record_reconciliation_decision(
|
|||||||
logger.warning("failed to record reconciliation decision %s: %s", decision_type, exc)
|
logger.warning("failed to record reconciliation decision %s: %s", decision_type, exc)
|
||||||
|
|
||||||
|
|
||||||
def list_reconciliation_process_candidates(*, status: str = "open", days: int = 3, limit: int = 20) -> List[Dict[str, Any]]:
|
def list_reconciliation_process_candidates(
|
||||||
"""Return grouped process candidates built from recent open items.
|
*, status: str = "open", external_type: Optional[str] = None,
|
||||||
|
days: int = 3, limit: int = 20,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Return process candidates built from items eligible in the active filter.
|
||||||
|
|
||||||
A candidate is shown when at least two pieces of evidence share the same
|
A candidate is shown when at least two pieces of evidence share the same
|
||||||
NIF/email/name, when one item already has a strong open-operation
|
NIF/email/name, when one item already has a strong open-operation
|
||||||
@@ -2556,7 +2559,10 @@ def list_reconciliation_process_candidates(*, status: str = "open", days: int =
|
|||||||
(for example an Odoo sale order without ClientFlow).
|
(for example an Odoo sale order without ClientFlow).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
items = list_reconciliation_items(status=status, limit=300, days=days)
|
item_filters: Dict[str, Any] = {"status": status, "limit": 300, "days": days}
|
||||||
|
if external_type:
|
||||||
|
item_filters["external_type"] = external_type
|
||||||
|
items = list_reconciliation_items(**item_filters)
|
||||||
except Exception as exc: # pragma: no cover - production safety guard
|
except Exception as exc: # pragma: no cover - production safety guard
|
||||||
logger.warning("failed to list reconciliation items for process candidates: %s", exc)
|
logger.warning("failed to list reconciliation items for process candidates: %s", exc)
|
||||||
return []
|
return []
|
||||||
|
|||||||
67
tests/test_reconciliation_loose_items_visibility.py
Normal file
67
tests/test_reconciliation_loose_items_visibility.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
from inspect import getsource
|
||||||
|
|
||||||
|
from app.admin_ui.pages.reconciliation import loose_reconciliation_items
|
||||||
|
from app import reconciliation_service as service
|
||||||
|
|
||||||
|
|
||||||
|
def _item(item_id: str, *, customer: str = "same", external_type: str = "jasmin_invoice") -> dict:
|
||||||
|
return {"id": item_id, "customer_name": customer, "external_type": external_type}
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_in_displayed_process_is_not_loose_and_unrepresented_item_stays_visible():
|
||||||
|
items = [_item("invoice-188"), _item("quote-258")]
|
||||||
|
candidates = [{"item_ids": ["invoice-188"]}]
|
||||||
|
|
||||||
|
assert loose_reconciliation_items(items, candidates) == [items[1]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_documents_for_same_customer_are_separate_by_exact_id():
|
||||||
|
invoice = _item("invoice-188", customer="ACME")
|
||||||
|
other_invoice = _item("invoice-112", customer="ACME")
|
||||||
|
|
||||||
|
assert loose_reconciliation_items(
|
||||||
|
[invoice, other_invoice], [{"item_ids": ["invoice-188"]}],
|
||||||
|
) == [other_invoice]
|
||||||
|
|
||||||
|
|
||||||
|
def test_exclusion_is_exact_and_does_not_use_approximate_document_identity():
|
||||||
|
exact = _item("doc-1")
|
||||||
|
similar = _item("doc-10")
|
||||||
|
same_number_different_id = {**_item("doc-2"), "document_number": "FA.FA2026.188"}
|
||||||
|
exact["document_number"] = "FA.FA2026.188"
|
||||||
|
|
||||||
|
assert loose_reconciliation_items(
|
||||||
|
[exact, similar, same_number_different_id], [{"item_ids": ["doc-1"]}],
|
||||||
|
) == [similar, same_number_different_id]
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_from_process_not_displayed_remains_loose():
|
||||||
|
item = _item("hidden-process-item")
|
||||||
|
|
||||||
|
assert loose_reconciliation_items([item], []) == [item]
|
||||||
|
assert loose_reconciliation_items([item], [{"item_ids": ["different-id"]}]) == [item]
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_candidate_query_preserves_status_external_type_and_days(monkeypatch):
|
||||||
|
received = {}
|
||||||
|
|
||||||
|
def fake_list(**kwargs):
|
||||||
|
received.update(kwargs)
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "list_reconciliation_items", fake_list)
|
||||||
|
assert service.list_reconciliation_process_candidates(
|
||||||
|
status="needs_review", external_type="jasmin_invoice", days=7, limit=8,
|
||||||
|
) == []
|
||||||
|
assert received == {
|
||||||
|
"status": "needs_review", "external_type": "jasmin_invoice",
|
||||||
|
"days": 7, "limit": 300,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_uses_current_filters_and_only_visible_candidate_ids():
|
||||||
|
from app.admin_ui.pages import reconciliation as page
|
||||||
|
|
||||||
|
source = getsource(page.reconciliation_page)
|
||||||
|
assert "status=status, external_type=item_type, days=recent_days" in source
|
||||||
|
assert "items = loose_reconciliation_items(items, process_candidates)" in source
|
||||||
Reference in New Issue
Block a user