171 lines
5.5 KiB
Python
171 lines
5.5 KiB
Python
"""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
|