1079 lines
52 KiB
Python
Executable File
1079 lines
52 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Audit ClientFlow operator pages for data/flow coherence.
|
|
|
|
Read-only production helper. It does not call external APIs and it does not
|
|
modify rows. The goal is to catch cases where the opportunity/task page would
|
|
show contradictory information, for example:
|
|
|
|
- document says invoice exists but the flow still marks invoice as missing;
|
|
- Odoo picking is done while the next action is still "wait production";
|
|
- SEND_INVOICE task/action is active although invoice was already sent;
|
|
- public email domains are used as weak identity evidence;
|
|
- old invoices from the same customer are shown as if they were same-process candidates;
|
|
- Chatwoot conversation is linked but no local message is visible.
|
|
|
|
Typical usage:
|
|
PYTHONPATH=. python scripts/audit_operator_page_consistency.py --limit 200
|
|
PYTHONPATH=. python scripts/audit_operator_page_consistency.py --opportunity-id <uuid>
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
from collections import Counter, defaultdict
|
|
from dataclasses import dataclass, asdict
|
|
from datetime import date, datetime, timezone
|
|
from decimal import Decimal, InvalidOperation
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
|
|
from sqlalchemy import text
|
|
|
|
from app.db import engine
|
|
|
|
try: # Optional: lets us compare DB evidence with the central workflow engine.
|
|
from app.opportunity_next_action_service import get_opportunity_next_action
|
|
except Exception: # pragma: no cover - production resilience
|
|
get_opportunity_next_action = None # type: ignore[assignment]
|
|
|
|
|
|
PUBLIC_EMAIL_DOMAINS = {
|
|
# Portugal / common ISP/public mailboxes
|
|
"sapo.pt", "netcabo.pt", "clix.pt", "iol.pt", "mail.telepac.pt", "vodafone.pt",
|
|
# Global public mailbox providers
|
|
"gmail.com", "googlemail.com", "hotmail.com", "hotmail.pt", "outlook.com", "outlook.pt",
|
|
"live.com", "live.pt", "msn.com", "yahoo.com", "yahoo.pt", "icloud.com", "me.com",
|
|
"aol.com", "proton.me", "protonmail.com", "mail.com", "gmx.com", "gmx.net",
|
|
"uol.com.br", "bol.com.br", "terra.com.br", "wanadoo.fr", "orange.fr",
|
|
}
|
|
|
|
# Odoo often carries logistics-only lines that should not be compared against
|
|
# Jasmin commercial/fiscal lines. Keep these configurable, but protect the
|
|
# common BLIF defaults that generated false MEDIUM findings in v1.5.84.
|
|
DEFAULT_NON_BILLABLE_DELIVERY_PATTERNS = (
|
|
"delivery_007",
|
|
"standard delivery",
|
|
"standard_delivery",
|
|
"shipping",
|
|
"transportadora",
|
|
)
|
|
|
|
SEVERITY_ORDER = {"critical": 1, "high": 2, "medium": 3, "low": 4, "info": 5}
|
|
|
|
DONE_STATUSES = {"done", "completed", "concluida", "concluída", "closed", "fechada", "ignored", "ignorada"}
|
|
PENDING_STATUSES = {"pending", "open", "new", "pendente"}
|
|
INVOICE_KINDS = {"invoice", "fatura", "fa", "ft", "jasmin_invoice"}
|
|
QUOTE_KINDS = {"quotation", "quote", "orc", "orcamento", "orçamento", "proforma", "jasmin_quotation", "jasmin_proforma"}
|
|
ACTIVE_DOC_ROLES = {"current", "accepted", "historical", "history", ""}
|
|
PRODUCTION_STATES = {"in_progress", "in_production", "waiting_stock", "confirmed", "created", "progress", "to_close"}
|
|
READY_STATES = {"ready_to_ship", "validated", "assigned", "ready"}
|
|
SHIPPED_STATES = {"shipped", "sent", "created"}
|
|
DELIVERED_STATES = {"done", "delivered", "validated"}
|
|
|
|
EXPLICIT_QUOTE_REQUEST_RE = re.compile(
|
|
r"\b(necessito|preciso|agradeco|agradeço|envie|enviar|mande|mandar|solicito|peço|peco|pretendo|queria|gostaria)\b.{0,80}\b(orcamento|orçamento|cotacao|cotação|proposta|quote|quotation)\b|\b(orcamento|orçamento|cotacao|cotação|proposta)\b.{0,80}\b(preco|preço|valor|valores|quantidade|equipamento|carregador)",
|
|
re.IGNORECASE | re.DOTALL,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Finding:
|
|
severity: str
|
|
rule: str
|
|
opportunity_id: str
|
|
opportunity_title: str = ""
|
|
customer: str = ""
|
|
document: str = ""
|
|
conversation_id: str = ""
|
|
expected: str = ""
|
|
actual: str = ""
|
|
details: str = ""
|
|
url: str = ""
|
|
|
|
|
|
def _json_default(value: Any) -> str:
|
|
if isinstance(value, (datetime, date)):
|
|
return value.isoformat()
|
|
if isinstance(value, Decimal):
|
|
return str(value)
|
|
return str(value)
|
|
|
|
|
|
def _as_dict(value: Any) -> Dict[str, Any]:
|
|
if isinstance(value, dict):
|
|
return value
|
|
if isinstance(value, str) and value.strip():
|
|
try:
|
|
parsed = json.loads(value)
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
except Exception:
|
|
return {}
|
|
return {}
|
|
|
|
|
|
def _as_list(value: Any) -> List[Any]:
|
|
if isinstance(value, list):
|
|
return value
|
|
if isinstance(value, str) and value.strip():
|
|
try:
|
|
parsed = json.loads(value)
|
|
return parsed if isinstance(parsed, list) else []
|
|
except Exception:
|
|
return []
|
|
return []
|
|
|
|
|
|
def _s(value: Any) -> str:
|
|
return str(value or "").strip()
|
|
|
|
|
|
def _lower(value: Any) -> str:
|
|
return _s(value).lower()
|
|
|
|
|
|
def _upper(value: Any) -> str:
|
|
return _s(value).upper()
|
|
|
|
|
|
def _money(value: Any) -> Optional[Decimal]:
|
|
if value in (None, ""):
|
|
return None
|
|
try:
|
|
if isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
|
|
return None
|
|
return Decimal(str(value)).quantize(Decimal("0.01"))
|
|
except (InvalidOperation, ValueError, TypeError):
|
|
return None
|
|
|
|
|
|
def _money_close(a: Any, b: Any, tolerance: Decimal = Decimal("0.05")) -> bool:
|
|
da, db = _money(a), _money(b)
|
|
if da is None or db is None:
|
|
return False
|
|
return abs(da - db) <= tolerance
|
|
|
|
|
|
def _date(value: Any) -> Optional[date]:
|
|
if isinstance(value, date) and not isinstance(value, datetime):
|
|
return value
|
|
if isinstance(value, datetime):
|
|
return value.date()
|
|
if isinstance(value, str) and value.strip():
|
|
try:
|
|
return datetime.fromisoformat(value.replace("Z", "+00:00")).date()
|
|
except Exception:
|
|
try:
|
|
return date.fromisoformat(value[:10])
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
|
|
def _days_between(a: Any, b: Any) -> Optional[int]:
|
|
da, db = _date(a), _date(b)
|
|
if not da or not db:
|
|
return None
|
|
return abs((da - db).days)
|
|
|
|
|
|
def _email_domain(email: Any) -> str:
|
|
text = _lower(email)
|
|
if "@" not in text:
|
|
return ""
|
|
return text.rsplit("@", 1)[-1]
|
|
|
|
|
|
def _is_public_domain(domain: str) -> bool:
|
|
return _lower(domain) in PUBLIC_EMAIL_DOMAINS
|
|
|
|
|
|
def _csv_patterns(value: str) -> List[str]:
|
|
return [_lower(part) for part in re.split(r"[,;\n]+", value or "") if _lower(part)]
|
|
|
|
|
|
def _non_billable_delivery_patterns(args: argparse.Namespace | None = None) -> List[str]:
|
|
patterns = list(DEFAULT_NON_BILLABLE_DELIVERY_PATTERNS)
|
|
patterns.extend(_csv_patterns(os.environ.get("CLIENTFLOW_NON_BILLABLE_ODOO_LINES", "")))
|
|
if args is not None:
|
|
for value in getattr(args, "non_billable_delivery_pattern", []) or []:
|
|
patterns.extend(_csv_patterns(value))
|
|
# Keep order stable while de-duplicating.
|
|
seen = set()
|
|
result: List[str] = []
|
|
for pattern in patterns:
|
|
normalized = _lower(pattern)
|
|
if normalized and normalized not in seen:
|
|
result.append(normalized)
|
|
seen.add(normalized)
|
|
return result
|
|
|
|
|
|
def _safe_uuid_filter(column: str, param: str) -> str:
|
|
return f"{column} = CAST(:{param} AS UUID)"
|
|
|
|
|
|
def _table_exists(conn: Any, table: str) -> bool:
|
|
return bool(conn.execute(text("""
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM information_schema.tables
|
|
WHERE table_schema = current_schema() AND table_name = :table
|
|
)
|
|
"""), {"table": table}).scalar())
|
|
|
|
|
|
def _column_exists(conn: Any, table: str, column: str) -> bool:
|
|
return bool(conn.execute(text("""
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema = current_schema() AND table_name = :table AND column_name = :column
|
|
)
|
|
"""), {"table": table, "column": column}).scalar())
|
|
|
|
|
|
def _rows(conn: Any, sql: str, params: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
return [dict(r) for r in conn.execute(text(sql), params).mappings().all()]
|
|
|
|
|
|
def _first(conn: Any, sql: str, params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
row = conn.execute(text(sql), params).mappings().first()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def _select_opportunities(conn: Any, args: argparse.Namespace) -> List[Dict[str, Any]]:
|
|
where = ["1=1"]
|
|
params: Dict[str, Any] = {}
|
|
if args.opportunity_id:
|
|
where.append("o.id = CAST(:opportunity_id AS UUID)")
|
|
params["opportunity_id"] = args.opportunity_id
|
|
if args.document_number:
|
|
where.append("EXISTS (SELECT 1 FROM commercial_documents d WHERE d.opportunity_id = o.id AND d.document_number = :document_number)")
|
|
params["document_number"] = args.document_number
|
|
if args.conversation_id:
|
|
where.append("o.conversation_id = :conversation_id")
|
|
params["conversation_id"] = str(args.conversation_id)
|
|
if not args.include_closed:
|
|
where.append("COALESCE(o.status, 'open') <> 'closed'")
|
|
where.append("COALESCE(o.stage, '') NOT IN ('WON','LOST','NO_INTEREST')")
|
|
if args.updated_since_days and int(args.updated_since_days) > 0:
|
|
where.append("COALESCE(o.updated_at, o.created_at) >= now() - (:updated_since_days || ' days')::interval")
|
|
params["updated_since_days"] = int(args.updated_since_days)
|
|
|
|
limit = ""
|
|
if args.limit and int(args.limit) > 0:
|
|
limit = "LIMIT :limit"
|
|
params["limit"] = int(args.limit)
|
|
|
|
return _rows(conn, f"""
|
|
SELECT
|
|
o.id::text,
|
|
o.title,
|
|
o.stage,
|
|
o.status,
|
|
o.conversation_id,
|
|
o.contact_id,
|
|
o.customer_name,
|
|
o.customer_email,
|
|
o.local_customer_id::text,
|
|
o.value_amount,
|
|
o.currency,
|
|
o.last_action_code,
|
|
o.last_task_id::text,
|
|
o.metadata,
|
|
o.created_at,
|
|
o.updated_at
|
|
FROM opportunities o
|
|
WHERE {' AND '.join(where)}
|
|
ORDER BY COALESCE(o.updated_at, o.created_at) DESC, o.created_at DESC
|
|
{limit}
|
|
""", params)
|
|
|
|
|
|
def _load_related(conn: Any, opportunity_id: str) -> Dict[str, Any]:
|
|
params = {"opportunity_id": opportunity_id}
|
|
docs = _rows(conn, """
|
|
SELECT
|
|
id::text,
|
|
customer_id::text,
|
|
system,
|
|
document_kind,
|
|
document_number,
|
|
external_id,
|
|
status,
|
|
amount,
|
|
tax_amount,
|
|
total_amount,
|
|
currency,
|
|
role,
|
|
is_primary,
|
|
is_active,
|
|
document_date,
|
|
created_at,
|
|
updated_at,
|
|
payload
|
|
FROM commercial_documents
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
ORDER BY
|
|
CASE WHEN COALESCE(is_primary, TRUE) THEN 0 ELSE 1 END,
|
|
CASE lower(COALESCE(document_kind,'')) WHEN 'invoice' THEN 1 WHEN 'quotation' THEN 2 WHEN 'proforma' THEN 3 ELSE 4 END,
|
|
COALESCE(document_date, created_at::date) DESC,
|
|
created_at DESC
|
|
""", params) if _table_exists(conn, "commercial_documents") else []
|
|
|
|
links = _rows(conn, """
|
|
SELECT id::text, system, external_type, external_id, external_name, status, payload, last_synced_at, updated_at, created_at
|
|
FROM operation_links
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
ORDER BY system, external_type, updated_at DESC
|
|
""", params) if _table_exists(conn, "operation_links") else []
|
|
|
|
tasks = _rows(conn, """
|
|
SELECT
|
|
t.id::text, t.action_code, t.action, t.note, t.route, t.priority, t.status,
|
|
t.due_at, t.done_at, t.created_at, t.updated_at, t.metadata,
|
|
t.message_id::text, t.raw_event_id::text, t.source_event_id,
|
|
COALESCE(
|
|
NULLIF(re.payload->'conversation'->'additional_attributes'->>'mail_subject', ''),
|
|
NULLIF(re.payload->'content_attributes'->'email'->>'subject', ''),
|
|
''
|
|
) AS message_subject,
|
|
COALESCE(NULLIF(m.clean_body, ''), NULLIF(m.raw_body, ''), NULLIF(re.payload->>'content', ''), '') AS request_text
|
|
FROM tasks t
|
|
LEFT JOIN messages m ON m.id = t.message_id
|
|
LEFT JOIN raw_events re ON re.id = t.raw_event_id
|
|
WHERE t.opportunity_id = CAST(:opportunity_id AS UUID)
|
|
ORDER BY
|
|
CASE lower(COALESCE(t.status,'')) WHEN 'pending' THEN 0 ELSE 1 END,
|
|
t.due_at NULLS LAST,
|
|
t.created_at DESC
|
|
""", params) if _table_exists(conn, "tasks") else []
|
|
|
|
items = _rows(conn, """
|
|
SELECT id::text, product_name, sku, jasmin_sales_item, quantity, unit_price, total_price, status, metadata, created_at, updated_at
|
|
FROM opportunity_items
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
ORDER BY created_at DESC
|
|
""", params) if _table_exists(conn, "opportunity_items") else []
|
|
|
|
customer = None
|
|
opp_customer = _first(conn, "SELECT local_customer_id::text FROM opportunities WHERE id = CAST(:opportunity_id AS UUID)", params)
|
|
customer_id = (opp_customer or {}).get("local_customer_id")
|
|
if customer_id and _table_exists(conn, "customers"):
|
|
customer = _first(conn, """
|
|
SELECT id::text, name, tax_id, email, phone, street_name, postal_zone, city_name, country, metadata, updated_at
|
|
FROM customers
|
|
WHERE id = CAST(:customer_id AS UUID)
|
|
""", {"customer_id": customer_id})
|
|
|
|
# Messages visible either by direct opportunity link or by the conversation id.
|
|
conv = _first(conn, "SELECT conversation_id FROM opportunities WHERE id = CAST(:opportunity_id AS UUID)", params)
|
|
conversation_id = _s((conv or {}).get("conversation_id"))
|
|
comms: List[Dict[str, Any]] = []
|
|
msgs: List[Dict[str, Any]] = []
|
|
raws: List[Dict[str, Any]] = []
|
|
if _table_exists(conn, "communications"):
|
|
comms = _rows(conn, """
|
|
SELECT id::text, source_message_id, conversation_id, sender_email, sender_name, subject, classification, status, opportunity_id::text, task_id::text, created_at
|
|
FROM communications
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
OR (:conversation_id <> '' AND conversation_id = :conversation_id)
|
|
ORDER BY created_at DESC
|
|
LIMIT 50
|
|
""", {"opportunity_id": opportunity_id, "conversation_id": conversation_id})
|
|
if _table_exists(conn, "messages"):
|
|
msgs = _rows(conn, """
|
|
SELECT id::text, raw_event_id::text, source_event_id, conversation_id, contact_id, direction, clean_body, raw_body, metadata, created_at
|
|
FROM messages
|
|
WHERE (:conversation_id <> '' AND conversation_id = :conversation_id)
|
|
OR id IN (SELECT message_id FROM tasks WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND message_id IS NOT NULL)
|
|
ORDER BY created_at DESC
|
|
LIMIT 50
|
|
""", {"opportunity_id": opportunity_id, "conversation_id": conversation_id})
|
|
if _table_exists(conn, "raw_events"):
|
|
raws = _rows(conn, """
|
|
SELECT id::text, source_system, event_type, source_event_id, conversation_id, contact_id, processed, ignored, processing_error, message_id::text, created_at, processed_at
|
|
FROM raw_events
|
|
WHERE (:conversation_id <> '' AND conversation_id = :conversation_id)
|
|
OR id IN (SELECT raw_event_id FROM tasks WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND raw_event_id IS NOT NULL)
|
|
ORDER BY created_at DESC
|
|
LIMIT 50
|
|
""", {"opportunity_id": opportunity_id, "conversation_id": conversation_id})
|
|
|
|
candidates: List[Dict[str, Any]] = []
|
|
if _table_exists(conn, "reconciliation_items"):
|
|
candidates = _rows(conn, """
|
|
SELECT id::text, source_system, external_type, document_number, title, customer_name, customer_tax_id, amount, currency, status, confidence, payload, created_at, updated_at
|
|
FROM reconciliation_items
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
ORDER BY COALESCE(updated_at, created_at) DESC
|
|
LIMIT 100
|
|
""", params)
|
|
|
|
return {"docs": docs, "links": links, "tasks": tasks, "items": items, "customer": customer, "communications": comms, "messages": msgs, "raw_events": raws, "candidates": candidates}
|
|
|
|
|
|
def _doc_kind(doc: Dict[str, Any]) -> str:
|
|
return _lower(doc.get("document_kind"))
|
|
|
|
|
|
def _doc_number(doc: Dict[str, Any] | None) -> str:
|
|
if not doc:
|
|
return ""
|
|
return _s(doc.get("document_number") or doc.get("external_id") or doc.get("id"))
|
|
|
|
|
|
def _doc_total(doc: Dict[str, Any] | None) -> Optional[Decimal]:
|
|
if not doc:
|
|
return None
|
|
return _money(doc.get("total_amount") if doc.get("total_amount") is not None else doc.get("amount"))
|
|
|
|
|
|
def _active_docs(docs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
result = []
|
|
for d in docs:
|
|
role = _lower(d.get("role") or "current")
|
|
if d.get("is_active") is False:
|
|
continue
|
|
if role not in ACTIVE_DOC_ROLES:
|
|
continue
|
|
result.append(d)
|
|
return result
|
|
|
|
|
|
def _find_invoice(docs: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
for d in _active_docs(docs):
|
|
if _doc_kind(d) in INVOICE_KINDS:
|
|
return d
|
|
return None
|
|
|
|
|
|
def _find_quote(docs: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
for d in _active_docs(docs):
|
|
if _doc_kind(d) in QUOTE_KINDS:
|
|
return d
|
|
return None
|
|
|
|
|
|
def _primary_doc(docs: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
active = _active_docs(docs)
|
|
primaries = [d for d in active if d.get("is_primary") is not False and _lower(d.get("role") or "current") in {"current", "accepted", ""}]
|
|
return (primaries or active or [None])[0]
|
|
|
|
|
|
def _link_by_type(links: List[Dict[str, Any]], system: str, external_type: str) -> Optional[Dict[str, Any]]:
|
|
for link in links:
|
|
if _lower(link.get("system")) == system and _lower(link.get("external_type")) == external_type:
|
|
return link
|
|
return None
|
|
|
|
|
|
def _payment_confirmed(links: List[Dict[str, Any]], stage: str) -> bool:
|
|
link = _link_by_type(links, "clientflow", "payment")
|
|
return _lower((link or {}).get("status")) == "confirmed" or _upper(stage) == "PAYMENT_CONFIRMED"
|
|
|
|
|
|
def _odoo_physical(links: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
physical = _link_by_type(links, "odoo", "physical_status") or {}
|
|
production = _link_by_type(links, "odoo", "production") or {}
|
|
validation = _link_by_type(links, "odoo", "physical_validation") or {}
|
|
sale = _link_by_type(links, "odoo", "sale_order") or {}
|
|
payload = _as_dict(physical.get("payload"))
|
|
prod_payload = _as_dict(production.get("payload"))
|
|
val_payload = _as_dict(validation.get("payload"))
|
|
pickings = _as_list(payload.get("outgoing_pickings") or payload.get("pickings") or val_payload.get("outgoing_pickings") or val_payload.get("pickings"))
|
|
productions = _as_list(payload.get("productions") or prod_payload.get("productions"))
|
|
picking_states = {_lower((p or {}).get("state")) for p in pickings if isinstance(p, dict)}
|
|
production_states = {_lower((p or {}).get("state")) for p in productions if isinstance(p, dict)}
|
|
status = _lower(physical.get("status"))
|
|
validation_status = _lower(validation.get("status"))
|
|
production_status = _lower(production.get("status"))
|
|
has_sale = bool(sale)
|
|
delivery_done = bool(payload.get("delivery_done")) or "done" in picking_states or status in {"shipped", "delivered", "done", "validated"}
|
|
delivery_ready = bool(payload.get("delivery_ready")) or "assigned" in picking_states or validation_status in {"ready_to_ship", "validated"} or status in READY_STATES
|
|
production_active = bool(production_states & PRODUCTION_STATES) or production_status in {"in_progress", "in_production", "confirmed"} or status in {"in_production", "waiting_stock"}
|
|
return {
|
|
"physical_link": physical,
|
|
"production_link": production,
|
|
"validation_link": validation,
|
|
"sale_link": sale,
|
|
"status": status,
|
|
"production_status": production_status,
|
|
"validation_status": validation_status,
|
|
"has_sale": has_sale,
|
|
"pickings": pickings,
|
|
"productions": productions,
|
|
"picking_states": sorted(s for s in picking_states if s),
|
|
"production_states": sorted(s for s in production_states if s),
|
|
"delivery_done": delivery_done,
|
|
"delivery_ready": delivery_ready,
|
|
"production_active": production_active,
|
|
"sale_name": _s(sale.get("external_name") or sale.get("external_id") or payload.get("sale_order")),
|
|
}
|
|
|
|
|
|
def _task_pending(tasks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
return [t for t in tasks if _lower(t.get("status")) in PENDING_STATUSES]
|
|
|
|
|
|
def _task_done(tasks: List[Dict[str, Any]], action_code: str) -> bool:
|
|
code = _upper(action_code)
|
|
return any(_upper(t.get("action_code")) == code and _lower(t.get("status")) in DONE_STATUSES for t in tasks)
|
|
|
|
|
|
def _task_pending_code(tasks: List[Dict[str, Any]], action_code: str) -> bool:
|
|
code = _upper(action_code)
|
|
return any(_upper(t.get("action_code")) == code and _lower(t.get("status")) in PENDING_STATUSES for t in tasks)
|
|
|
|
|
|
def _missing_fiscal_fields(customer: Optional[Dict[str, Any]]) -> List[str]:
|
|
if not customer:
|
|
return ["cliente fiscal"]
|
|
missing = []
|
|
if not _s(customer.get("tax_id")):
|
|
missing.append("NIF")
|
|
if not _s(customer.get("email")):
|
|
missing.append("email de faturação")
|
|
if not _s(customer.get("street_name")):
|
|
missing.append("morada fiscal")
|
|
if not _s(customer.get("postal_zone")):
|
|
missing.append("código postal")
|
|
if not _s(customer.get("city_name")):
|
|
missing.append("localidade")
|
|
return missing
|
|
|
|
|
|
def _document_ref(invoice: Optional[Dict[str, Any]], quote: Optional[Dict[str, Any]], primary: Optional[Dict[str, Any]]) -> str:
|
|
doc = primary or invoice or quote
|
|
if not doc:
|
|
return ""
|
|
kind = "Fatura" if _doc_kind(doc) in INVOICE_KINDS else "Orçamento" if _doc_kind(doc) in QUOTE_KINDS else _s(doc.get("document_kind"))
|
|
total = _doc_total(doc)
|
|
return f"{kind} {_doc_number(doc)}" + (f" · {total}" if total is not None else "")
|
|
|
|
|
|
def _current_candidate_like_old_invoice(candidates: List[Dict[str, Any]], docs: List[Dict[str, Any]], customer: Optional[Dict[str, Any]]) -> Iterable[Tuple[Dict[str, Any], str]]:
|
|
"""Flag same-customer invoices that are likely other purchases, not candidates."""
|
|
invoice = _find_invoice(docs)
|
|
quote = _find_quote(docs)
|
|
current_total = _doc_total(invoice) or _doc_total(quote)
|
|
current_date = (invoice or quote or {}).get("document_date") or (invoice or quote or {}).get("created_at")
|
|
current_numbers = {_doc_number(d) for d in docs if _doc_number(d)}
|
|
for c in candidates:
|
|
ext_type = _lower(c.get("external_type"))
|
|
number = _s(c.get("document_number"))
|
|
if number in current_numbers:
|
|
continue
|
|
if "invoice" not in ext_type and not number.upper().startswith(("FA", "FT")):
|
|
continue
|
|
status = _lower(c.get("status"))
|
|
if status in {"ignored", "rejected", "closed", "linked"}:
|
|
continue
|
|
c_amount = _money(c.get("amount"))
|
|
c_date = c.get("created_at") or c.get("updated_at")
|
|
payload = _as_dict(c.get("payload"))
|
|
# Some reconciliation payloads carry created/date and status.
|
|
c_date = payload.get("document_date") or payload.get("created_at") or payload.get("date") or c_date
|
|
amount_match = current_total is not None and c_amount is not None and _money_close(c_amount, current_total, Decimal("1.00"))
|
|
close_date = (_days_between(c_date, current_date) or 9999) <= 14
|
|
if not amount_match or not close_date:
|
|
reason = []
|
|
if not amount_match:
|
|
reason.append(f"valor candidato {c_amount} diferente do processo {current_total}")
|
|
if not close_date:
|
|
reason.append(f"data afastada do documento atual")
|
|
yield c, "; ".join(reason)
|
|
|
|
|
|
def _line_source(item: Dict[str, Any]) -> str:
|
|
meta = _as_dict(item.get("metadata"))
|
|
return _lower(meta.get("source_system")) or (
|
|
"odoo" if _upper(item.get("status")) == "ODOO_IMPORTED"
|
|
else "jasmin" if _upper(item.get("status")) == "JASMIN_IMPORTED"
|
|
else "manual"
|
|
)
|
|
|
|
|
|
def _line_totals(items: List[Dict[str, Any]]) -> Dict[str, Decimal]:
|
|
totals: Dict[str, Decimal] = defaultdict(lambda: Decimal("0.00"))
|
|
for item in items:
|
|
source = _line_source(item)
|
|
total = _money(item.get("total_price")) or Decimal("0.00")
|
|
totals[source] += total
|
|
return dict(totals)
|
|
|
|
|
|
def _normalized_doc_number(value: Any) -> str:
|
|
text = _upper(value)
|
|
# Keep document identifiers stable while ignoring small formatting differences.
|
|
return re.sub(r"[^A-Z0-9./_-]+", "", text)
|
|
|
|
|
|
def _line_document_tokens(item: Dict[str, Any]) -> set[str]:
|
|
meta = _as_dict(item.get("metadata"))
|
|
tokens: set[str] = set()
|
|
for key in (
|
|
"document_number", "document_no", "document", "doc_number", "doc_no",
|
|
"source_document_number", "source_doc_number", "origin_document_number",
|
|
"origin_doc_number", "jasmin_document_number", "jasmin_doc_number",
|
|
"invoice_number", "quotation_number", "proforma_number", "external_document_number",
|
|
"external_id", "source_external_id", "origin_external_id", "source_id",
|
|
):
|
|
value = meta.get(key)
|
|
if isinstance(value, (str, int)) and _s(value):
|
|
tokens.add(_normalized_doc_number(value))
|
|
# Some metadata stores nested source/origin payloads. Search shallow nested dicts only.
|
|
for nested_key in ("source", "origin", "jasmin", "document", "commercial_document"):
|
|
nested = _as_dict(meta.get(nested_key))
|
|
for key in ("number", "document_number", "external_id", "id"):
|
|
value = nested.get(key)
|
|
if isinstance(value, (str, int)) and _s(value):
|
|
tokens.add(_normalized_doc_number(value))
|
|
return {t for t in tokens if t}
|
|
|
|
|
|
def _line_total_for_document(items: List[Dict[str, Any]], doc: Optional[Dict[str, Any]], source: str = "jasmin") -> Tuple[Decimal, bool]:
|
|
"""Return imported line total for a specific document.
|
|
|
|
Older audits summed every Jasmin-imported line in the opportunity. When an
|
|
opportunity has both ORC and FA attached, that doubles the amount and creates
|
|
false positives. Prefer lines whose metadata references the active document
|
|
number. The boolean tells callers whether document-specific matching was
|
|
possible; if false, callers may fall back to broad source totals.
|
|
"""
|
|
if not doc:
|
|
return Decimal("0.00"), False
|
|
wanted = {_normalized_doc_number(_doc_number(doc)), _normalized_doc_number(doc.get("external_id"))}
|
|
wanted = {w for w in wanted if w}
|
|
if not wanted:
|
|
return Decimal("0.00"), False
|
|
total = Decimal("0.00")
|
|
matched = False
|
|
for item in items:
|
|
if _line_source(item) != source:
|
|
continue
|
|
tokens = _line_document_tokens(item)
|
|
if tokens and tokens.intersection(wanted):
|
|
matched = True
|
|
total += _money(item.get("total_price")) or Decimal("0.00")
|
|
return total, matched
|
|
|
|
|
|
def _odoo_delivery_lines(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
result = []
|
|
for item in items:
|
|
meta = _as_dict(item.get("metadata"))
|
|
source = _lower(meta.get("source_system"))
|
|
name = _lower(item.get("product_name") or item.get("description"))
|
|
sku = _lower(item.get("sku") or meta.get("default_code") or meta.get("sku"))
|
|
if source == "odoo" and ("delivery" in name or "shipping" in name or "transporte" in name or "delivery" in sku):
|
|
result.append(item)
|
|
return result
|
|
|
|
|
|
def _odoo_line_signature(item: Dict[str, Any]) -> str:
|
|
meta = _as_dict(item.get("metadata"))
|
|
parts = [
|
|
item.get("sku"),
|
|
item.get("product_name"),
|
|
item.get("jasmin_sales_item"),
|
|
meta.get("sku"),
|
|
meta.get("default_code"),
|
|
meta.get("product_name"),
|
|
meta.get("name"),
|
|
]
|
|
return " | ".join(_lower(part) for part in parts if _s(part))
|
|
|
|
|
|
def _is_non_billable_odoo_line(item: Dict[str, Any], patterns: Sequence[str]) -> bool:
|
|
signature = _odoo_line_signature(item)
|
|
return any(pattern and pattern in signature for pattern in patterns)
|
|
|
|
|
|
def _expected_next_action(data: Dict[str, Any]) -> Tuple[str, str]:
|
|
"""A conservative expected action from raw facts, independent from UI labels."""
|
|
opp, docs, links, tasks, customer = data["opp"], data["docs"], data["links"], data["tasks"], data["customer"]
|
|
metadata = _as_dict(opp.get("metadata"))
|
|
payment_terms = _lower(metadata.get("payment_terms")) or "before_shipping"
|
|
invoice = _find_invoice(docs)
|
|
quote = _find_quote(docs)
|
|
odoo = _odoo_physical(links)
|
|
payment_ok = _payment_confirmed(links, opp.get("stage"))
|
|
missing = _missing_fiscal_fields(customer)
|
|
pending = _task_pending(tasks)
|
|
|
|
if missing and (invoice or quote):
|
|
# If invoice already exists, fiscal incompleteness is still an admin warning, not necessarily next action.
|
|
if not invoice and payment_ok:
|
|
return "VALIDATE_FISCAL_CUSTOMER", "pagamento confirmado mas faltam dados fiscais antes da fatura"
|
|
if payment_terms == "after_delivery":
|
|
if odoo["delivery_done"]:
|
|
if invoice:
|
|
return "FOLLOW_UP_PAYMENT", "pagamento pós-entrega: entrega feita e fatura existe; acompanhar pagamento"
|
|
return "SEND_INVOICE", "pagamento pós-entrega: entrega/pronto, falta fatura"
|
|
if odoo["delivery_ready"]:
|
|
if invoice:
|
|
return "SHIP_ORDER", "fatura existe e encomenda pronta; avançar entrega/levantamento"
|
|
return "SEND_INVOICE", "encomenda pronta para entrega/levantamento; emitir/enviar fatura"
|
|
if odoo["production_active"] or odoo["has_sale"]:
|
|
return "WAIT_PRODUCTION", "pagamento pós-entrega: aguardar produção/preparação antes da fatura"
|
|
if invoice and payment_ok and odoo["delivery_done"]:
|
|
return "CONFIRM_DELIVERY", "fatura/pagamento OK e picking done; confirmar tracking/entrega"
|
|
if invoice and payment_ok and odoo["delivery_ready"]:
|
|
return "SHIP_ORDER", "fatura/pagamento OK e encomenda pronta"
|
|
if invoice and payment_ok and odoo["production_active"]:
|
|
return "WAIT_PRODUCTION", "fatura/pagamento OK; produção/preparação em curso"
|
|
if invoice and not payment_ok and payment_terms != "after_delivery":
|
|
return "CONFIRM_PAYMENT", "fatura existe; pagamento ainda não confirmado"
|
|
if payment_ok and not invoice:
|
|
if missing:
|
|
return "VALIDATE_FISCAL_CUSTOMER", "pagamento confirmado mas dados fiscais incompletos"
|
|
return "SEND_INVOICE", "pagamento confirmado; falta fatura"
|
|
if quote and not payment_ok and payment_terms != "after_delivery":
|
|
return "CONFIRM_PAYMENT", "orçamento associado; falta confirmação de pagamento"
|
|
if quote and payment_terms == "after_delivery" and not odoo["has_sale"]:
|
|
return "PREPARE_ORDER", "pagamento pós-entrega; falta venda/preparação Odoo"
|
|
if pending:
|
|
return _upper(pending[0].get("action_code") or "OPEN_TASK"), "existe tarefa pendente"
|
|
return "REVIEW", "sem regra final; rever manualmente"
|
|
|
|
|
|
def _audit_one(opp: Dict[str, Any], related: Dict[str, Any], base_url: str = "", args: argparse.Namespace | None = None) -> List[Finding]:
|
|
data = {"opp": opp, **related}
|
|
docs = related["docs"]
|
|
links = related["links"]
|
|
tasks = related["tasks"]
|
|
items = related["items"]
|
|
customer = related["customer"]
|
|
messages = related["messages"]
|
|
comms = related["communications"]
|
|
raws = related["raw_events"]
|
|
candidates = related["candidates"]
|
|
conversation_id = _s(opp.get("conversation_id"))
|
|
invoice = _find_invoice(docs)
|
|
quote = _find_quote(docs)
|
|
primary = _primary_doc(docs)
|
|
doc_ref = _document_ref(invoice, quote, primary)
|
|
customer_name = _s((customer or {}).get("name") or opp.get("customer_name"))
|
|
url = f"{base_url.rstrip('/')}/opportunities/{opp['id']}" if base_url else f"/opportunities/{opp['id']}"
|
|
findings: List[Finding] = []
|
|
|
|
def add(severity: str, rule: str, expected: str = "", actual: str = "", details: str = "") -> None:
|
|
findings.append(Finding(
|
|
severity=severity,
|
|
rule=rule,
|
|
opportunity_id=_s(opp.get("id")),
|
|
opportunity_title=_s(opp.get("title")),
|
|
customer=customer_name,
|
|
document=doc_ref,
|
|
conversation_id=conversation_id,
|
|
expected=expected,
|
|
actual=actual,
|
|
details=details,
|
|
url=url,
|
|
))
|
|
|
|
# 1) Next action comparison.
|
|
#
|
|
# v1.5.79: this auditor must not turn an optional service failure into one
|
|
# finding per opportunity. In production some databases/schema versions make
|
|
# app.opportunity_next_action_service fail while the rest of the read-only
|
|
# consistency checks are still useful. Therefore, by default we do not call
|
|
# the central service and we do not compare the conservative expected action
|
|
# with stale opportunities.last_action_code. Enable explicit flags when you
|
|
# want to debug the next-action service itself.
|
|
expected_code, expected_reason = _expected_next_action(data)
|
|
actual_next: Dict[str, Any] = {}
|
|
actual_code = ""
|
|
compare_service = bool(getattr(args, "compare_next_action_service", False))
|
|
debug_service_errors = bool(getattr(args, "debug_next_action_service_errors", False))
|
|
compare_stored = bool(getattr(args, "compare_stored_last_action", False))
|
|
|
|
if compare_service and get_opportunity_next_action is not None:
|
|
try:
|
|
actual_next = get_opportunity_next_action(str(opp["id"])) or {}
|
|
except Exception as exc: # do not break whole audit
|
|
if debug_service_errors:
|
|
add("low", "next_action_service_error", "serviço de próxima ação deve responder", "erro", str(exc)[:500])
|
|
actual_next = {}
|
|
if actual_next:
|
|
actual_code = _upper(actual_next.get("action_code") or actual_next.get("next_action", {}).get("code"))
|
|
if actual_code and expected_code and actual_code != expected_code:
|
|
# Tolerate REVIEW vs OPEN_TASK only if there is a real pending task.
|
|
if not (expected_code in {"REVIEW", "OPEN_TASK"} and actual_code in {"REVIEW", "OPEN_TASK"}):
|
|
add("high", "next_action_mismatch", expected_code, actual_code, expected_reason)
|
|
elif compare_stored:
|
|
stored_code = _upper(opp.get("last_action_code"))
|
|
if stored_code and expected_code and stored_code != expected_code:
|
|
if not (expected_code in {"REVIEW", "OPEN_TASK"} and stored_code in {"REVIEW", "OPEN_TASK"}):
|
|
add("medium", "stored_last_action_mismatch", expected_code, stored_code, expected_reason)
|
|
|
|
# 2) Fiscal/customer completeness and public domain identity.
|
|
missing_fiscal = _missing_fiscal_fields(customer)
|
|
if missing_fiscal and invoice is None and _payment_confirmed(links, opp.get("stage")):
|
|
add("high", "invoice_blocked_by_missing_fiscal_data", "validar/completar dados fiscais antes da fatura", ", ".join(missing_fiscal), "Pagamento confirmado sem fatura e ficha fiscal incompleta.")
|
|
|
|
fiscal_email = _s((customer or {}).get("email"))
|
|
chat_email = _s(opp.get("customer_email"))
|
|
# Prefer recent message sender if available.
|
|
for m in comms:
|
|
if _s(m.get("sender_email")):
|
|
chat_email = _s(m.get("sender_email"))
|
|
break
|
|
if fiscal_email and chat_email and fiscal_email.lower() != chat_email.lower():
|
|
fd, cd = _email_domain(fiscal_email), _email_domain(chat_email)
|
|
if fd and fd == cd and _is_public_domain(fd):
|
|
add("medium", "public_domain_identity_is_weak", "não usar domínio público como match de identidade", fd, f"fiscal={fiscal_email}; contacto={chat_email}")
|
|
|
|
for task in tasks:
|
|
if _upper(task.get("action_code")) == "SEND_INFO" and _lower(task.get("status")) in PENDING_STATUSES:
|
|
task_text = "\n".join(_s(task.get(k)) for k in ["message_subject", "request_text", "action", "note"])
|
|
if EXPLICIT_QUOTE_REQUEST_RE.search(task_text):
|
|
add("medium", "send_info_task_for_explicit_quote_request", "SEND_QUOTE", "SEND_INFO", "Pedido explícito de orçamento/cotação/proposta deve manter objetivo SEND_QUOTE; pode ser proposta textual sem anexo.")
|
|
break
|
|
|
|
# 3) Odoo/document/flow coherence.
|
|
odoo = _odoo_physical(links)
|
|
payment_ok = _payment_confirmed(links, opp.get("stage"))
|
|
if odoo["delivery_done"] and odoo["production_active"]:
|
|
add("high", "odoo_delivery_done_but_production_active", "priorizar picking done ou alertar inconsistência Odoo", f"pickings={odoo['picking_states']} produções={odoo['production_states']}", "Entrega concluída no Odoo mas produção ainda ativa/confirmada.")
|
|
if odoo["delivery_done"] and _upper(expected_code) == "WAIT_PRODUCTION":
|
|
add("high", "wait_production_after_delivery_done", "confirmar entrega/tracking", "aguardar produção", "Picking Odoo está done; não deve sugerir produção.")
|
|
if invoice and (_task_done(tasks, "SEND_INVOICE") or payment_ok) and actual_code == "SEND_INVOICE" and not _task_pending_code(tasks, "SEND_INVOICE"):
|
|
add("high", "send_invoice_action_after_invoice_done", "não sugerir SEND_INVOICE", "SEND_INVOICE", f"Fatura {_doc_number(invoice)} existe e task de envio não está pendente.")
|
|
if invoice and not any(_doc_kind(d) in INVOICE_KINDS and d.get("is_primary") is not False for d in docs):
|
|
add("medium", "invoice_exists_but_not_primary", "fatura ativa/principal quando já existe fatura atual", "fatura não primária", _doc_number(invoice))
|
|
invoice_payload = _as_dict(invoice.get("payload")) if invoice else {}
|
|
invoice_sent_evidence = bool(invoice_payload.get("clientflow_invoice_sent_evidence") or invoice_payload.get("invoice_sent_at"))
|
|
if invoice and _task_done(tasks, "SEND_INVOICE") and not invoice_sent_evidence and not _lower(invoice.get("status")) in {"sent", "issued_sent"}:
|
|
add("low", "invoice_sent_task_done_but_document_status_not_sent", "estado/documento deve indicar envio ou a UI deve usar a task como evidência", _s(invoice.get("status")), f"Task SEND_INVOICE concluída para {_doc_number(invoice)}")
|
|
|
|
# 4) Payment timing sanity.
|
|
payment_terms = _lower(_as_dict(opp.get("metadata")).get("payment_terms")) or "before_shipping"
|
|
if payment_terms == "after_delivery" and _task_pending_code(tasks, "SEND_INVOICE") and odoo["production_active"] and not odoo["delivery_ready"] and not odoo["delivery_done"]:
|
|
add("high", "after_delivery_invoice_task_too_early", "aguardar produção/preparação", "SEND_INVOICE pendente", "Pagamento após entrega: faturar quando pronto para entrega/levantamento.")
|
|
if payment_terms == "before_shipping" and odoo["delivery_done"] and not payment_ok:
|
|
add("critical", "before_shipping_delivered_without_payment_confirmed", "pagamento confirmado antes de envio", "picking done sem pagamento confirmado", "Fluxo antes do envio foi ultrapassado.")
|
|
|
|
# 5) Documents/candidates: avoid same-customer old docs as actionable.
|
|
for candidate, reason in _current_candidate_like_old_invoice(candidates, docs, customer):
|
|
add("medium", "old_invoice_candidate_same_customer_not_same_process", "auditoria/ignorar, não candidato acionável", _s(candidate.get("document_number")), reason)
|
|
|
|
# 6) Product/amount coherence.
|
|
totals = _line_totals(items)
|
|
broad_jasmin_total = totals.get("jasmin", Decimal("0.00"))
|
|
odoo_total = totals.get("odoo", Decimal("0.00"))
|
|
invoice_total = _doc_total(invoice)
|
|
quote_total = _doc_total(quote)
|
|
|
|
invoice_line_total, invoice_line_matched = _line_total_for_document(items, invoice, "jasmin")
|
|
quote_line_total, quote_line_matched = _line_total_for_document(items, quote, "jasmin")
|
|
# v1.5.84: compare document totals against lines from that same document,
|
|
# not against all Jasmin lines in the opportunity. Opportunities often keep
|
|
# both ORC and FA as context, so broad totals are expected to be 2x.
|
|
if invoice_total is not None:
|
|
if invoice_line_matched:
|
|
if invoice_line_total and abs(invoice_line_total - invoice_total) > Decimal("0.05"):
|
|
add("medium", "jasmin_invoice_lines_total_differs_from_invoice", f"{invoice_total}", f"{invoice_line_total}", "Soma das linhas da fatura Jasmin não coincide com a fatura principal.")
|
|
elif broad_jasmin_total and abs(broad_jasmin_total - invoice_total) > Decimal("0.05"):
|
|
add("low", "jasmin_imported_lines_total_ambiguous_for_invoice", f"{invoice_total}", f"{broad_jasmin_total}", "Não foi possível identificar linhas por documento; soma Jasmin global pode incluir orçamento + fatura.")
|
|
if quote_total is not None and not invoice:
|
|
if quote_line_matched:
|
|
if quote_line_total and abs(quote_line_total - quote_total) > Decimal("0.05"):
|
|
add("medium", "jasmin_quote_lines_total_differs_from_quote", f"{quote_total}", f"{quote_line_total}", "Soma das linhas do orçamento Jasmin não coincide com o orçamento principal.")
|
|
elif broad_jasmin_total and abs(broad_jasmin_total - quote_total) > Decimal("0.05"):
|
|
add("low", "jasmin_imported_lines_total_ambiguous_for_quote", f"{quote_total}", f"{broad_jasmin_total}", "Não foi possível identificar linhas por documento; soma Jasmin global pode incluir vários documentos.")
|
|
delivery_lines = _odoo_delivery_lines(items)
|
|
if delivery_lines and invoice_total is not None and odoo_total and not _money_close(odoo_total, invoice_total, Decimal("1.00")):
|
|
patterns = _non_billable_delivery_patterns(args)
|
|
billable_delivery_lines = [i for i in delivery_lines if not _is_non_billable_odoo_line(i, patterns)]
|
|
non_billable_delivery_lines = [i for i in delivery_lines if _is_non_billable_odoo_line(i, patterns)]
|
|
billable_delivery = sum((_money(i.get("total_price")) or Decimal("0.00")) for i in billable_delivery_lines)
|
|
non_billable_delivery = sum((_money(i.get("total_price")) or Decimal("0.00")) for i in non_billable_delivery_lines)
|
|
if billable_delivery:
|
|
add("medium", "odoo_delivery_line_not_in_jasmin_invoice", "confirmar se transporte é faturável", f"Odoo={odoo_total}; Fatura={invoice_total}; entrega_faturável={billable_delivery}", "Linha Delivery/Shipping no Odoo pode não estar faturada no Jasmin.")
|
|
elif non_billable_delivery and not bool(getattr(args, "suppress_non_billable_delivery_warnings", False)):
|
|
add("low", "odoo_non_billable_delivery_line_not_in_jasmin_invoice", "linha logística configurada como não faturável", f"Odoo={odoo_total}; Fatura={invoice_total}; logística={non_billable_delivery}", "Linha Odoo logística/não faturável não refletida na fatura Jasmin; aviso informativo.")
|
|
|
|
# 7) Messages/coherence.
|
|
if conversation_id and not (messages or comms or raws):
|
|
add("medium", "linked_chatwoot_conversation_without_local_messages", "mensagens visíveis/indexadas", "0 mensagens", f"Conversa #{conversation_id} ligada à oportunidade.")
|
|
if any(not r.get("processed") and not r.get("ignored") for r in raws):
|
|
pending = [r.get("source_event_id") for r in raws if not r.get("processed") and not r.get("ignored")]
|
|
add("high", "raw_events_pending_for_linked_conversation", "processar raw_events", ",".join(_s(x) for x in pending[:10]), f"Conversa #{conversation_id}")
|
|
unresolved_error_raws = [
|
|
r for r in raws
|
|
if _s(r.get("processing_error"))
|
|
and not r.get("ignored")
|
|
and (not r.get("processed") or not _s(r.get("message_id")))
|
|
]
|
|
if unresolved_error_raws:
|
|
errs = [f"{r.get('source_event_id')}: {_s(r.get('processing_error'))[:160]}" for r in unresolved_error_raws]
|
|
add("high", "raw_events_processing_error_for_linked_conversation", "reprocessar/corrigir erro", "; ".join(errs[:3]), f"Conversa #{conversation_id}")
|
|
|
|
# 8) Tasks temporal/status anomalies.
|
|
now = datetime.now(timezone.utc)
|
|
for task in tasks:
|
|
due = task.get("due_at")
|
|
due_dt: Optional[datetime] = None
|
|
if isinstance(due, datetime):
|
|
due_dt = due if due.tzinfo else due.replace(tzinfo=timezone.utc)
|
|
elif isinstance(due, str) and due:
|
|
try:
|
|
due_dt = datetime.fromisoformat(due.replace("Z", "+00:00"))
|
|
except Exception:
|
|
due_dt = None
|
|
task_metadata = _as_dict(task.get("metadata"))
|
|
future_done_ack = bool(task_metadata.get("future_done_acknowledged_by") or task_metadata.get("completed_before_due_acknowledged_at"))
|
|
if due_dt and due_dt > now and not future_done_ack and _lower(task.get("status")) in {"done", "completed", "concluida", "concluída"}:
|
|
add("low", "future_due_task_already_completed", "ignorada/cancelada ou nota clara", f"{task.get('action_code')} due={due_dt.isoformat()} status={task.get('status')}", "Task futura marcada como concluída pode confundir a timeline.")
|
|
if invoice and _upper(task.get("action_code")) in {"CONFIRM_PAYMENT", "FOLLOW_UP_PAYMENT"} and _lower(task.get("status")) in PENDING_STATUSES and payment_ok:
|
|
add("medium", "obsolete_payment_task_pending", "fechar/ignorar task de pagamento", f"{task.get('action_code')} pendente", "Pagamento já confirmado.")
|
|
|
|
return findings
|
|
|
|
|
|
def _write_csv(path: Path, findings: List[Finding]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fields = list(asdict(findings[0]).keys()) if findings else [
|
|
"severity", "rule", "opportunity_id", "opportunity_title", "customer", "document", "conversation_id", "expected", "actual", "details", "url"
|
|
]
|
|
with path.open("w", newline="", encoding="utf-8") as f:
|
|
writer = csv.DictWriter(f, fieldnames=fields)
|
|
writer.writeheader()
|
|
for item in findings:
|
|
writer.writerow(asdict(item))
|
|
|
|
|
|
def _write_json(path: Path, findings: List[Finding], summary: Dict[str, Any]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with path.open("w", encoding="utf-8") as f:
|
|
json.dump({"summary": summary, "findings": [asdict(x) for x in findings]}, f, ensure_ascii=False, indent=2, default=_json_default)
|
|
|
|
|
|
def _write_markdown(path: Path, findings: List[Finding], summary: Dict[str, Any], max_rows: int = 300) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
by_sev = summary["by_severity"]
|
|
by_rule = summary["by_rule"]
|
|
lines = [
|
|
"# Auditoria de coerência das páginas ClientFlow",
|
|
"",
|
|
f"Gerado em: {datetime.now(timezone.utc).isoformat(timespec='seconds')}",
|
|
f"Oportunidades analisadas: {summary['opportunities_scanned']}",
|
|
f"Findings: {summary['findings_total']}",
|
|
"",
|
|
"## Resumo por severidade",
|
|
"",
|
|
]
|
|
for sev in ["critical", "high", "medium", "low", "info"]:
|
|
lines.append(f"- **{sev}**: {by_sev.get(sev, 0)}")
|
|
lines += ["", "## Top regras", ""]
|
|
for rule, count in sorted(by_rule.items(), key=lambda kv: (-kv[1], kv[0]))[:30]:
|
|
lines.append(f"- `{rule}`: {count}")
|
|
lines += ["", "## Findings", ""]
|
|
if not findings:
|
|
lines.append("Sem inconsistências encontradas dentro das regras atuais.")
|
|
else:
|
|
lines.append("| Sev | Regra | Oportunidade | Cliente | Documento | Esperado | Atual | Detalhe |")
|
|
lines.append("|---|---|---|---|---|---|---|---|")
|
|
for f in findings[:max_rows]:
|
|
vals = [
|
|
f.severity,
|
|
f"`{f.rule}`",
|
|
f"[{f.opportunity_title or f.opportunity_id}]({f.url})" if f.url else (f.opportunity_title or f.opportunity_id),
|
|
f.customer,
|
|
f.document,
|
|
f.expected,
|
|
f.actual,
|
|
f.details,
|
|
]
|
|
lines.append("| " + " | ".join(_md_cell(v) for v in vals) + " |")
|
|
if len(findings) > max_rows:
|
|
lines.append("")
|
|
lines.append(f"Mostrados {max_rows} de {len(findings)} findings. Ver CSV/JSON para a lista completa.")
|
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
|
|
|
|
def _md_cell(value: Any) -> str:
|
|
text_value = _s(value).replace("\n", " ").replace("|", "\\|")
|
|
return text_value[:500]
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Audita coerência das páginas de operador ClientFlow sem alterar dados.")
|
|
parser.add_argument("--limit", type=int, default=0, help="Número máximo de oportunidades a analisar. 0 = todas as ativas.")
|
|
parser.add_argument("--include-closed", action="store_true", help="Inclui oportunidades fechadas/terminais.")
|
|
parser.add_argument("--updated-since-days", type=int, default=0, help="Filtra oportunidades atualizadas nos últimos N dias.")
|
|
parser.add_argument("--opportunity-id", help="Audita apenas uma oportunidade.")
|
|
parser.add_argument("--document-number", help="Audita oportunidades ligadas a um documento Jasmin específico.")
|
|
parser.add_argument("--conversation-id", help="Audita oportunidades ligadas a uma conversa Chatwoot específica.")
|
|
parser.add_argument("--output-dir", default="/tmp", help="Diretório dos relatórios.")
|
|
parser.add_argument("--base-url", default=os.environ.get("CLIENTFLOW_PUBLIC_URL", ""), help="URL base para links no relatório Markdown.")
|
|
parser.add_argument("--min-severity", choices=list(SEVERITY_ORDER), default="info", help="Filtra findings abaixo desta severidade.")
|
|
parser.add_argument("--max-md-rows", type=int, default=300, help="Máximo de linhas no relatório Markdown.")
|
|
parser.add_argument("--compare-next-action-service", action="store_true", help="Chama o serviço central de próxima ação e compara a decisão. Desligado por omissão para evitar ruído se o serviço/schema falhar.")
|
|
parser.add_argument("--debug-next-action-service-errors", action="store_true", help="Quando usado com --compare-next-action-service, escreve erros do serviço como findings LOW.")
|
|
parser.add_argument("--compare-stored-last-action", action="store_true", help="Compara a regra esperada com opportunities.last_action_code. Útil para procurar códigos obsoletos, mas pode gerar ruído.")
|
|
parser.add_argument("--non-billable-delivery-pattern", action="append", default=[], help="Padrão/SKU Odoo de linha logística não faturável. Pode repetir ou separar por vírgulas. Também lê CLIENTFLOW_NON_BILLABLE_ODOO_LINES.")
|
|
parser.add_argument("--suppress-non-billable-delivery-warnings", action="store_true", help="Não escreve findings LOW para linhas Odoo configuradas como logísticas/não faturáveis.")
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
out_dir = Path(args.output_dir)
|
|
min_rank = SEVERITY_ORDER[args.min_severity]
|
|
all_findings: List[Finding] = []
|
|
scanned = 0
|
|
|
|
with engine.begin() as conn:
|
|
opportunities = _select_opportunities(conn, args)
|
|
print(f"OPPORTUNITIES_TO_SCAN={len(opportunities)}")
|
|
for idx, opp in enumerate(opportunities, start=1):
|
|
scanned += 1
|
|
if idx == 1 or idx % 50 == 0:
|
|
print(f"scanning={idx}/{len(opportunities)} updated_at={opp.get('updated_at')} title={_s(opp.get('title'))[:80]}", flush=True)
|
|
related = _load_related(conn, _s(opp.get("id")))
|
|
findings = _audit_one(opp, related, base_url=args.base_url, args=args)
|
|
for f in findings:
|
|
if SEVERITY_ORDER.get(f.severity, 99) <= min_rank:
|
|
all_findings.append(f)
|
|
|
|
all_findings.sort(key=lambda f: (SEVERITY_ORDER.get(f.severity, 99), f.rule, f.customer, f.opportunity_title))
|
|
by_sev = Counter(f.severity for f in all_findings)
|
|
by_rule = Counter(f.rule for f in all_findings)
|
|
summary = {
|
|
"opportunities_scanned": scanned,
|
|
"findings_total": len(all_findings),
|
|
"by_severity": dict(by_sev),
|
|
"by_rule": dict(by_rule),
|
|
}
|
|
|
|
csv_path = out_dir / "clientflow_operator_page_consistency.csv"
|
|
md_path = out_dir / "clientflow_operator_page_consistency.md"
|
|
json_path = out_dir / "clientflow_operator_page_consistency.json"
|
|
_write_csv(csv_path, all_findings)
|
|
_write_markdown(md_path, all_findings, summary, max_rows=args.max_md_rows)
|
|
_write_json(json_path, all_findings, summary)
|
|
|
|
print("Auditoria de coerência concluída.")
|
|
print(f"Oportunidades analisadas: {scanned}")
|
|
print(f"Findings: {len(all_findings)}")
|
|
print("Resumo por severidade:")
|
|
for sev in ["critical", "high", "medium", "low", "info"]:
|
|
if by_sev.get(sev, 0):
|
|
print(f"{sev.upper()}: {by_sev[sev]}")
|
|
print("Top regras:")
|
|
for rule, count in by_rule.most_common(20):
|
|
print(f"{rule}: {count}")
|
|
print(f"CSV: {csv_path}")
|
|
print(f"Markdown: {md_path}")
|
|
print(f"JSON: {json_path}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|