Files
clientflow_backend/scripts/audit_operator_page_consistency.py

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())