diff --git a/app/admin_ui/pages/reconciliation.py b/app/admin_ui/pages/reconciliation.py index fb5abcb..9452cf7 100644 --- a/app/admin_ui/pages/reconciliation.py +++ b/app/admin_ui/pages/reconciliation.py @@ -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("/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): @@ -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 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)) - 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) enrichment_summary = fiscal_enrichment_summary() diff --git a/app/reconciliation_service.py b/app/reconciliation_service.py index 5c2a0a0..7a9f2dd 100644 --- a/app/reconciliation_service.py +++ b/app/reconciliation_service.py @@ -2547,8 +2547,11 @@ def _record_reconciliation_decision( 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]]: - """Return grouped process candidates built from recent open items. +def list_reconciliation_process_candidates( + *, 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 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). """ 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 logger.warning("failed to list reconciliation items for process candidates: %s", exc) return [] diff --git a/tests/test_reconciliation_loose_items_visibility.py b/tests/test_reconciliation_loose_items_visibility.py new file mode 100644 index 0000000..690c229 --- /dev/null +++ b/tests/test_reconciliation_loose_items_visibility.py @@ -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