#!/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 """ 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())