"""Shared invoice-sent evidence rules. Keep the central next-action engine, the workflow guard and audits aligned when legacy SEND_INVOICE tasks were created from a quotation and only later enriched with the actual invoice in ``metadata.invoice_delivery_context``. """ from __future__ import annotations import json from typing import Any, Iterable _COMPLETED_STATUSES = { "done", "completed", "complete", "closed", "resolved", "concluida", "concluido", "concluída", "concluído", } def _dict(value: Any) -> dict[str, Any]: if isinstance(value, dict): return value if isinstance(value, str) and value: try: parsed = json.loads(value) return parsed if isinstance(parsed, dict) else {} except Exception: return {} return {} def _norm(value: Any) -> str: return str(value or "").strip() def _upper(value: Any) -> str: return _norm(value).upper() def task_is_completed(task: dict[str, Any]) -> bool: return _norm(task.get("status")).lower() in _COMPLETED_STATUSES or bool( task.get("completed_at") ) def task_looks_like_invoice_sent(task: dict[str, Any]) -> bool: code = _upper(task.get("action_code")) if code in {"SEND_INVOICE", "SEND_FISCAL_INVOICE", "INVOICE_SENT"} or code.startswith("SEND_INVOICE"): return True searchable = " ".join( _norm(task.get(key)) for key in ("action", "note", "description", "subject") ).upper() compact = searchable.translate( str.maketrans( { "Á": "A", "À": "A", "Ã": "A", "Â": "A", "É": "E", "Ê": "E", "Í": "I", "Ó": "O", "Õ": "O", "Ô": "O", "Ú": "U", "Ç": "C", } ) ) return bool( "SEND INVOICE" in compact or "INVOICE SENT" in compact or "ENVIAR FATURA" in compact or "FATURA ENVIADA" in compact or ("FATURA" in compact and ("ENVIAD" in compact or "ANEX" in compact or "PDF" in compact)) ) def _context_references(context: dict[str, Any]) -> tuple[set[str], set[str]]: ids = { _norm(context.get(key)) for key in ("document_id", "invoice_id", "external_id") if _norm(context.get(key)) } numbers = { _upper(context.get(key)) for key in ("document_number", "invoice_number", "number") if _upper(context.get(key)) } return ids, numbers def task_invoice_references(task: dict[str, Any]) -> tuple[set[str], set[str]]: """Return authoritative invoice identifiers declared by a task. ``invoice_delivery_context`` is populated after the real invoice exists and therefore supersedes the original next-action context, which may still name the quotation used to create the task. We intentionally do not merge both contexts because a stale quotation identifier must not contradict the later invoice-specific evidence. """ metadata = _dict(task.get("metadata")) delivery_context = _dict(metadata.get("invoice_delivery_context")) delivery_ids, delivery_numbers = _context_references(delivery_context) if delivery_ids or delivery_numbers: return delivery_ids, delivery_numbers direct_ids, direct_numbers = _context_references(metadata) next_action = _dict(metadata.get("next_action")) next_ids, next_numbers = _context_references(next_action) return direct_ids | next_ids, direct_numbers | next_numbers def invoice_references(invoices: Iterable[dict[str, Any]]) -> tuple[set[str], set[str]]: ids: set[str] = set() numbers: set[str] = set() for invoice in invoices or []: ids.update( _norm(invoice.get(key)) for key in ("id", "external_id") if _norm(invoice.get(key)) ) numbers.update( _upper(invoice.get(key)) for key in ("document_number", "number") if _upper(invoice.get(key)) ) return ids, numbers def completed_send_invoice_task_evidence( tasks: Iterable[dict[str, Any]], invoices: Iterable[dict[str, Any]], ) -> bool: """True when a completed invoice-send task matches a current invoice. Declared invoice identifiers are conservative: when present they must match the current invoice set. Legacy tasks with no identifiers can still count when their text clearly represents sending an invoice for the same opportunity. """ invoice_rows = list(invoices or []) invoice_ids, invoice_numbers = invoice_references(invoice_rows) for raw_task in tasks or []: task = dict(raw_task) if not task_looks_like_invoice_sent(task) or not task_is_completed(task): continue task_ids, task_numbers = task_invoice_references(task) if task_ids and invoice_ids and task_ids.isdisjoint(invoice_ids): continue if task_numbers and invoice_numbers and task_numbers.isdisjoint(invoice_numbers): continue if not task_ids and not task_numbers and invoice_numbers: searchable = " ".join( _norm(task.get(key)) for key in ("action", "note", "description", "subject") ).upper() # Exact invoice mention is strongest. Preserve compatibility for # old same-opportunity tasks that only say "Enviar fatura". if searchable and not any(number in searchable for number in invoice_numbers): if not task_looks_like_invoice_sent(task): continue return True return False