Files
clientflow_backend/scripts/audit_reconciliation_coherence.py

1023 lines
50 KiB
Python

#!/usr/bin/env python3
"""Auditoria read-only da reconciliação ClientFlow.
Objetivos:
- detetar candidatos que já estão ligados a uma oportunidade;
- comparar NIF, email, nome fiscal, documentos, valores, fase e próxima ação;
- detetar risco de regressão ao ligar um candidato antigo/incompleto;
- opcionalmente confirmar a evidência com pedidos GET/search_read a Odoo,
Jasmin e Packlink;
- produzir um plano de correções sem alterar a base de dados.
O script abre a transação PostgreSQL como READ ONLY. Não cria, atualiza,
resolve nem liga qualquer item de reconciliação.
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import json
import re
import sys
import unicodedata
from collections import defaultdict
from dataclasses import asdict, dataclass, field
from datetime import date, datetime, timezone
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
from sqlalchemy import text
def _detect_project_root() -> Path:
"""Locate the ClientFlow backend root without depending on cwd/PYTHONPATH."""
script_path = Path(__file__).resolve()
candidates = [script_path.parent.parent, Path.cwd().resolve(), *script_path.parents]
seen = set()
for candidate in candidates:
key = str(candidate)
if key in seen:
continue
seen.add(key)
if (candidate / "app" / "db.py").is_file():
return candidate
return script_path.parent.parent
PROJECT_ROOT = _detect_project_root()
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
OPEN_STATUSES = ("open", "needs_review", "conflict")
STAGE_RANK = {
"REVIEW": 5,
"NEW_LEAD": 10,
"INFO_REQUESTED": 20,
"INFO_SENT": 30,
"QUOTE_REQUESTED": 40,
"QUOTE_SENT": 50,
"PROFORMA_REQUESTED": 55,
"PROFORMA_SENT": 60,
"INVOICE_REQUESTED": 62,
"INVOICE_SENT": 65,
"WAITING_PAYMENT": 70,
"PAYMENT_CONFIRMED": 80,
"ODOO_ORDER_CREATED": 85,
"IN_PRODUCTION": 88,
"ORDER_PREPARATION": 90,
"READY_TO_SHIP": 92,
"INVOICED": 94,
"SHIPMENT_CREATED": 98,
"SHIPPED": 100,
"TRACKING_SENT": 102,
"DELIVERED": 108,
"WON": 110,
"NO_INTEREST": 118,
"LOST": 120,
"ARCHIVED": 130,
}
SUGGESTED_STAGE_BY_TYPE = {
"jasmin_quotation": "QUOTE_SENT",
"jasmin_proforma": "WAITING_PAYMENT",
"jasmin_invoice": "INVOICE_SENT",
"odoo_sale_order": "ODOO_ORDER_CREATED",
"packlink_shipment": "SHIPMENT_CREATED",
"payment_proof": "WAITING_PAYMENT",
"payment_receipt": "WAITING_PAYMENT",
"manual_request": "QUOTE_REQUESTED",
}
HUMAN_ACTIONS_BY_STAGE = {
"QUOTE_SENT": {"FOLLOW_UP_QUOTE", "SEND_PROFORMA", "REVIEW_MANUALLY"},
"PROFORMA_SENT": {"FOLLOW_UP_PAYMENT", "CONFIRM_PAYMENT", "REVIEW_MANUALLY"},
"INVOICE_SENT": {"FOLLOW_UP_PAYMENT", "CONFIRM_PAYMENT", "REVIEW_MANUALLY"},
"WAITING_PAYMENT": {"FOLLOW_UP_PAYMENT", "CONFIRM_PAYMENT", "REVIEW_MANUALLY"},
"PAYMENT_CONFIRMED": {"PREPARE_ORDER", "REVIEW_MANUALLY"},
"ODOO_ORDER_CREATED": {"SEND_INVOICE", "PREPARE_ORDER", "REVIEW_MANUALLY"},
"IN_PRODUCTION": {"PREPARE_ORDER", "REVIEW_MANUALLY"},
"ORDER_PREPARATION": {"PREPARE_ORDER", "VALIDATE_PHYSICAL_ORDER", "CREATE_SHIPMENT", "SHIP_ORDER", "REVIEW_MANUALLY"},
"READY_TO_SHIP": {"CREATE_SHIPMENT", "SHIP_ORDER", "REVIEW_MANUALLY"},
"SHIPMENT_CREATED": {"SEND_TRACKING", "SHIP_ORDER", "REVIEW_MANUALLY"},
}
SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
def _clean(value: Any) -> str:
return str(value or "").strip()
def _jsonable(value: Any) -> Any:
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, Decimal):
return str(value)
if isinstance(value, Mapping):
return {str(k): _jsonable(v) for k, v in value.items()}
if isinstance(value, (list, tuple, set)):
return [_jsonable(v) for v in value]
return value
def _normalize_tax_id(value: Any) -> str:
raw = re.sub(r"[^A-Z0-9]", "", _clean(value).upper())
if raw.startswith("PT"):
raw = raw[2:]
return raw
def _normalize_email(value: Any) -> str:
return _clean(value).lower()
def _normalize_name(value: Any) -> str:
raw = unicodedata.normalize("NFKD", _clean(value).lower())
raw = "".join(ch for ch in raw if not unicodedata.combining(ch))
raw = re.sub(r"\b(lda|ltd|unipessoal|sa|s a|sociedade anonima|limitada)\b", " ", raw)
raw = re.sub(r"[^a-z0-9]+", " ", raw)
return " ".join(raw.split())
def _money(value: Any) -> Optional[Decimal]:
if value is None or value == "":
return None
raw = _clean(value).replace("", "").replace(" ", "").replace(",", ".")
try:
return Decimal(raw).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return None
def _stage_rank(stage: Any) -> int:
return STAGE_RANK.get(_clean(stage).upper(), 0)
def _external_refs(item: Mapping[str, Any]) -> List[str]:
values = [item.get("external_id"), item.get("document_number")]
payload = item.get("payload") if isinstance(item.get("payload"), dict) else {}
record = payload.get("record") if isinstance(payload.get("record"), dict) else {}
values.extend([
record.get("id"), record.get("name"), record.get("documentNumber"),
record.get("naturalKey"), record.get("reference"),
])
refs: List[str] = []
for value in values:
value = _clean(value)
if value and value.lower() not in {ref.lower() for ref in refs}:
refs.append(value)
return refs
def _system_for_link(source_system: Any) -> str:
value = _clean(source_system).lower()
if value in {"odoo", "jasmin", "packlink"}:
return value
return value
def _expected_link_types(external_type: Any) -> set[str]:
return {
"odoo_sale_order": {"sale_order", "odoo_sale_order"},
"packlink_shipment": {"shipment", "packlink_shipment"},
"jasmin_quotation": {"quotation", "jasmin_quotation"},
"jasmin_proforma": {"proforma", "jasmin_proforma"},
"jasmin_invoice": {"invoice", "jasmin_invoice"},
}.get(_clean(external_type), set())
def _document_kind(external_type: Any) -> str:
return {
"jasmin_quotation": "quotation",
"jasmin_proforma": "proforma",
"jasmin_invoice": "invoice",
}.get(_clean(external_type), "")
def _close_amount(left: Any, right: Any, *, absolute: Decimal = Decimal("0.02"), relative: Decimal = Decimal("0.02")) -> bool:
a, b = _money(left), _money(right)
if a is None or b is None:
return False
diff = abs(a - b)
if diff <= absolute:
return True
base = max(abs(a), abs(b), Decimal("1"))
return (diff / base) <= relative
def _looks_like_tax_difference(net: Any, gross: Any) -> bool:
a, b = _money(net), _money(gross)
if a is None or b is None or a <= 0 or b <= 0:
return False
ratio = max(a, b) / min(a, b)
return Decimal("1.20") <= ratio <= Decimal("1.27")
def _picking_state_from_payload(item: Mapping[str, Any]) -> Tuple[str, str]:
payload = item.get("payload") if isinstance(item.get("payload"), dict) else {}
record = payload.get("record") if isinstance(payload.get("record"), dict) else payload
fulfilment = record.get("fulfilment") if isinstance(record.get("fulfilment"), dict) else {}
pickings = fulfilment.get("outgoing_pickings") if isinstance(fulfilment.get("outgoing_pickings"), list) else []
if not pickings and isinstance(record.get("pickings"), list):
pickings = record.get("pickings")
if not pickings:
return "", ""
ordered = sorted(pickings, key=lambda p: _clean(p.get("date_done") or p.get("scheduled_date")), reverse=True)
state = _clean(ordered[0].get("state")).lower()
scheduled = _clean(ordered[0].get("scheduled_date") or ordered[0].get("date_done"))
return state, scheduled
@dataclass
class Finding:
severity: str
code: str
title: str
recommendation: str
item_id: str = ""
source_system: str = ""
external_type: str = ""
external_ref: str = ""
customer_name: str = ""
opportunity_id: str = ""
opportunity_title: str = ""
evidence: Dict[str, Any] = field(default_factory=dict)
proposed_correction: Dict[str, Any] = field(default_factory=dict)
class Auditor:
def __init__(self, *, days: int, limit: int, source: str = "", item_id: str = "") -> None:
self.days = max(int(days), 1)
self.limit = min(max(int(limit), 1), 5000)
self.source = _clean(source).lower()
self.item_id = _clean(item_id)
self.items: List[Dict[str, Any]] = []
self.opportunities: Dict[str, Dict[str, Any]] = {}
self.operation_links: List[Dict[str, Any]] = []
self.documents: List[Dict[str, Any]] = []
self.tasks: List[Dict[str, Any]] = []
self.findings: List[Finding] = []
self.live_results: Dict[str, Any] = {}
def load(self) -> None:
try:
from app.db import engine
except ModuleNotFoundError as exc:
raise SystemExit(
"Não foi possível importar o backend ClientFlow. "
f"Raiz detetada: {PROJECT_ROOT}. "
"Instale o script em <clientflow_backend>/scripts/ ou execute-o "
"a partir da raiz do backend."
) from exc
source_sql = ""
params: Dict[str, Any] = {"days": self.days, "limit": self.limit}
if self.source:
source_sql += " AND lower(ri.source_system) = :source"
params["source"] = self.source
if self.item_id:
source_sql += " AND ri.id = CAST(:item_id AS UUID)"
params["item_id"] = self.item_id
with engine.connect() as conn:
transaction = conn.begin()
try:
conn.execute(text("SET TRANSACTION READ ONLY"))
existing_tables = set(conn.execute(text("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name IN (
'reconciliation_items','opportunities','customers',
'operation_links','commercial_documents','tasks'
)
""")).scalars().all())
required = {"reconciliation_items", "opportunities", "operation_links", "commercial_documents", "tasks"}
missing = sorted(required - existing_tables)
if missing:
raise RuntimeError("Tabelas necessárias em falta: " + ", ".join(missing))
self.items = [dict(row) for row in conn.execute(text(f"""
SELECT
ri.id::text, ri.source_system, ri.external_type, ri.external_id,
ri.title, ri.description, ri.status, ri.priority,
ri.suggested_action, ri.confidence,
ri.opportunity_id::text, ri.customer_id::text,
ri.customer_name, ri.customer_email, ri.customer_tax_id,
ri.document_number, ri.document_date, ri.amount, ri.currency,
ri.payload, ri.resolution_note, ri.created_at, ri.updated_at,
o.title AS linked_opportunity_title,
o.stage AS linked_opportunity_stage,
o.status AS linked_opportunity_status
FROM reconciliation_items ri
LEFT JOIN opportunities o ON o.id = ri.opportunity_id
WHERE ri.status IN ('open','needs_review','conflict')
AND (ri.document_date IS NULL OR ri.document_date >= CURRENT_DATE - :days)
{source_sql}
ORDER BY ri.updated_at DESC, ri.created_at DESC
LIMIT :limit
"""), params).mappings().all()]
opportunity_rows = conn.execute(text("""
SELECT
o.id::text, o.title, o.stage, o.status,
o.customer_name, o.customer_email, o.value_amount,
o.local_customer_id::text, o.metadata, o.updated_at,
c.name AS fiscal_name, c.email AS fiscal_email, c.tax_id AS fiscal_tax_id
FROM opportunities o
LEFT JOIN customers c ON c.id = o.local_customer_id
WHERE o.status IN ('open','won','lost','no_interest','archived')
""")).mappings().all()
self.opportunities = {str(row["id"]): dict(row) for row in opportunity_rows}
self.operation_links = [dict(row) for row in conn.execute(text("""
SELECT
ol.id::text, ol.opportunity_id::text, ol.system, ol.external_type,
ol.external_id, ol.external_name, ol.status, ol.payload,
ol.last_synced_at, ol.updated_at,
o.title AS opportunity_title, o.stage AS opportunity_stage,
o.status AS opportunity_status, o.customer_name AS opportunity_customer_name
FROM operation_links ol
JOIN opportunities o ON o.id = ol.opportunity_id
""")).mappings().all()]
self.documents = [dict(row) for row in conn.execute(text("""
SELECT
cd.id::text, cd.opportunity_id::text, cd.customer_id::text,
cd.system, cd.document_kind, cd.external_id, cd.document_number,
cd.status, cd.amount, cd.tax_amount, cd.total_amount,
cd.currency, cd.role, cd.is_primary, cd.is_active,
cd.document_date, cd.payload, cd.updated_at,
o.title AS opportunity_title, o.stage AS opportunity_stage,
o.status AS opportunity_status
FROM commercial_documents cd
LEFT JOIN opportunities o ON o.id = cd.opportunity_id
""")).mappings().all()]
self.tasks = [dict(row) for row in conn.execute(text("""
SELECT
t.id::text, t.opportunity_id::text, t.action_code,
t.action, t.route, t.status, t.priority, t.due_at,
t.source_system, t.metadata, t.created_at
FROM tasks t
WHERE t.status = 'pending'
""")).mappings().all()]
finally:
transaction.rollback()
def add(self, finding: Finding) -> None:
self.findings.append(finding)
def _matching_links(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]:
system = _system_for_link(item.get("source_system"))
refs = {ref.lower() for ref in _external_refs(item)}
expected_types = _expected_link_types(item.get("external_type"))
matches = []
for link in self.operation_links:
if _clean(link.get("system")).lower() != system:
continue
values = {_clean(link.get("external_id")).lower(), _clean(link.get("external_name")).lower()}
if refs & {value for value in values if value}:
matches.append(link)
continue
if expected_types and _clean(link.get("external_type")) in expected_types:
# Same type alone is not a match; keep strict external identity.
continue
return matches
def _matching_documents(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]:
if _clean(item.get("source_system")).lower() != "jasmin":
return []
refs = {ref.lower() for ref in _external_refs(item)}
kind = _document_kind(item.get("external_type"))
matches = []
for doc in self.documents:
if _clean(doc.get("system")).lower() != "jasmin":
continue
if kind and _clean(doc.get("document_kind")) != kind:
continue
values = {_clean(doc.get("external_id")).lower(), _clean(doc.get("document_number")).lower()}
if refs & {value for value in values if value}:
matches.append(doc)
return matches
def _identity_candidates(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]:
tax_id = _normalize_tax_id(item.get("customer_tax_id"))
email = _normalize_email(item.get("customer_email"))
name = _normalize_name(item.get("customer_name"))
amount = _money(item.get("amount"))
scored: List[Tuple[int, Dict[str, Any], List[str]]] = []
for opp in self.opportunities.values():
if _clean(opp.get("status")).lower() != "open":
continue
score = 0
reasons: List[str] = []
opp_tax = _normalize_tax_id(opp.get("fiscal_tax_id"))
opp_email_values = {_normalize_email(opp.get("customer_email")), _normalize_email(opp.get("fiscal_email"))}
opp_names = {_normalize_name(opp.get("customer_name")), _normalize_name(opp.get("fiscal_name"))}
if tax_id and opp_tax and tax_id == opp_tax:
score += 120
reasons.append("NIF exato")
if email and email in {x for x in opp_email_values if x}:
score += 80
reasons.append("email exato")
if name and name in {x for x in opp_names if x}:
score += 60
reasons.append("nome fiscal exato")
elif name and any(name in x or x in name for x in opp_names if len(x) >= 5):
score += 35
reasons.append("nome fiscal aproximado")
if amount is not None and _close_amount(amount, opp.get("value_amount"), relative=Decimal("0.05")):
score += 10
reasons.append("valor próximo")
if score >= 35:
copy = dict(opp)
copy["match_score"] = score
copy["match_reasons"] = reasons
scored.append((score, copy, reasons))
scored.sort(key=lambda row: (-row[0], -_stage_rank(row[1].get("stage")), _clean(row[1].get("title"))))
return [row[1] for row in scored[:5]]
def _documents_for_opportunity(self, opportunity_id: str) -> List[Dict[str, Any]]:
return [doc for doc in self.documents if _clean(doc.get("opportunity_id")) == opportunity_id and bool(doc.get("is_active", True))]
def _tasks_for_opportunity(self, opportunity_id: str) -> List[Dict[str, Any]]:
return [task for task in self.tasks if _clean(task.get("opportunity_id")) == opportunity_id]
def _audit_item(self, item: Dict[str, Any]) -> None:
item_id = _clean(item.get("id"))
refs = _external_refs(item)
external_ref = refs[0] if refs else ""
common = {
"item_id": item_id,
"source_system": _clean(item.get("source_system")),
"external_type": _clean(item.get("external_type")),
"external_ref": external_ref,
"customer_name": _clean(item.get("customer_name")),
}
links = self._matching_links(item)
docs = self._matching_documents(item)
exact_opp_ids = {
_clean(link.get("opportunity_id")) for link in links if _clean(link.get("opportunity_id"))
} | {
_clean(doc.get("opportunity_id")) for doc in docs if _clean(doc.get("opportunity_id"))
}
if _clean(item.get("opportunity_id")):
exact_opp_ids.add(_clean(item.get("opportunity_id")))
if len(exact_opp_ids) > 1:
self.add(Finding(
severity="critical",
code="EXTERNAL_ID_LINKED_TO_MULTIPLE_OPPORTUNITIES",
title="A mesma evidência externa aparece ligada a várias oportunidades",
recommendation="Bloquear a ligação e rever manualmente as associações antes de qualquer correção de fase ou documento.",
evidence={"opportunity_ids": sorted(exact_opp_ids), "links": links, "documents": docs},
proposed_correction={"action": "block_and_review", "automatic": False},
**common,
))
return
exact_opp_id = next(iter(exact_opp_ids), "")
exact_opp = self.opportunities.get(exact_opp_id) if exact_opp_id else None
if exact_opp:
common.update({
"opportunity_id": exact_opp_id,
"opportunity_title": _clean(exact_opp.get("title")),
})
if _clean(item.get("status")) in OPEN_STATUSES:
self.add(Finding(
severity="high",
code="OPEN_ITEM_ALREADY_LINKED",
title="Candidato aberto apesar de a evidência já estar ligada",
recommendation="Resolver o item como já ligado, sem reaplicar a fase sugerida nem recriar tasks.",
evidence={
"item_status": item.get("status"),
"existing_links": links,
"existing_documents": docs,
"current_stage": exact_opp.get("stage"),
},
proposed_correction={
"action": "resolve_as_existing_link",
"automatic_candidate": True,
"guard": "confirm exactly one existing opportunity",
},
**common,
))
suggested_stage = SUGGESTED_STAGE_BY_TYPE.get(_clean(item.get("external_type")), "")
current_stage = _clean(exact_opp.get("stage")).upper()
if suggested_stage and _stage_rank(current_stage) > _stage_rank(suggested_stage):
self.add(Finding(
severity="high",
code="STAGE_REGRESSION_RISK",
title="A reconciliação sugere uma fase anterior à fase atual",
recommendation="Ao resolver o item, preservar a fase atual e usar a evidência apenas como contexto/documento.",
evidence={"current_stage": current_stage, "suggested_stage": suggested_stage, "suggested_action": item.get("suggested_action")},
proposed_correction={"action": "preserve_current_stage", "automatic_candidate": True},
**common,
))
opp_docs = self._documents_for_opportunity(exact_opp_id)
has_invoice = any(_clean(doc.get("document_kind")) == "invoice" and bool(doc.get("is_active", True)) for doc in opp_docs)
has_quotation = any(_clean(doc.get("document_kind")) == "quotation" and bool(doc.get("is_active", True)) for doc in opp_docs)
suggested_action = _clean(item.get("suggested_action")).upper()
if suggested_action == "SEND_INVOICE" and has_invoice:
self.add(Finding(
severity="high",
code="OBSOLETE_SEND_INVOICE",
title="A reconciliação pede envio de fatura, mas a oportunidade já tem fatura",
recommendation="Marcar o candidato como resolvido/obsoleto e recalcular a próxima ação a partir da fase operacional atual.",
evidence={"documents": opp_docs, "current_stage": current_stage},
proposed_correction={"action": "resolve_obsolete_action", "replacement_action": self._recommended_action(exact_opp, opp_docs)},
**common,
))
if suggested_action in {"SEND_QUOTE", "SEND_PROFORMA"} and has_quotation and _stage_rank(current_stage) >= _stage_rank("QUOTE_SENT"):
self.add(Finding(
severity="medium",
code="OBSOLETE_DOCUMENT_ACTION",
title="Ação documental sugerida já foi ultrapassada",
recommendation="Preservar os documentos atuais e recalcular a próxima ação sem repetir o envio histórico.",
evidence={"suggested_action": suggested_action, "current_stage": current_stage},
proposed_correction={"action": "recompute_next_action"},
**common,
))
item_amount = _money(item.get("amount"))
opp_amount = _money(exact_opp.get("value_amount"))
doc_amounts = [
_money(doc.get("total_amount")) or _money(doc.get("amount"))
for doc in opp_docs
if (_money(doc.get("total_amount")) or _money(doc.get("amount"))) is not None
]
if item_amount is not None:
comparable = [value for value in [opp_amount, *doc_amounts] if value is not None]
if comparable and not any(_close_amount(item_amount, value, relative=Decimal("0.03")) for value in comparable):
severity = "medium" if any(_looks_like_tax_difference(item_amount, value) for value in comparable) else "high"
self.add(Finding(
severity=severity,
code="AMOUNT_MISMATCH",
title="Valor da reconciliação diverge do valor da oportunidade/documentos",
recommendation="Validar se a diferença corresponde a IVA, transporte, desconto ou linhas em falta antes de aplicar a associação.",
evidence={
"reconciliation_amount": item_amount,
"opportunity_amount": opp_amount,
"document_amounts": comparable,
"possible_tax_difference": any(_looks_like_tax_difference(item_amount, value) for value in comparable),
},
proposed_correction={"action": "review_amount_and_lines", "automatic": False},
**common,
))
self._audit_missing_work_task(exact_opp, opp_docs, common)
else:
candidates = self._identity_candidates(item)
if candidates:
best = candidates[0]
common.update({"opportunity_id": _clean(best.get("id")), "opportunity_title": _clean(best.get("title"))})
severity = "medium" if int(best.get("match_score") or 0) >= 80 else "low"
self.add(Finding(
severity=severity,
code="EXISTING_OPPORTUNITY_CANDIDATE",
title="Existe uma oportunidade aberta potencialmente compatível",
recommendation="Comparar NIF, cliente fiscal, documentos e compra antes de criar uma nova oportunidade.",
evidence={"candidates": candidates},
proposed_correction={"action": "review_link_to_existing", "automatic": False},
**common,
))
else:
self.add(Finding(
severity="info",
code="NO_EXISTING_OPPORTUNITY_FOUND",
title="Não foi encontrada oportunidade aberta compatível",
recommendation="Confirmar identidade e decidir entre criar nova oportunidade, histórico ou ignorar.",
evidence={"identity": {"tax_id": item.get("customer_tax_id"), "email": item.get("customer_email"), "name": item.get("customer_name")}},
proposed_correction={"action": "manual_triage"},
**common,
))
picking_state, picking_date = _picking_state_from_payload(item)
if picking_state in {"assigned", "confirmed", "waiting"} and _clean(item.get("suggested_action")).upper() == "SEND_INVOICE":
self.add(Finding(
severity="medium",
code="ODOO_PHYSICAL_STATE_ACTION_MISMATCH",
title="O estado físico Odoo está avançado, mas a ação sugerida é apenas enviar fatura",
recommendation="Verificar primeiro se já existe fatura/pagamento e, se existir, orientar a próxima ação para expedição.",
evidence={"picking_state": picking_state, "scheduled_date": picking_date, "suggested_action": item.get("suggested_action")},
proposed_correction={"action": "recompute_from_full_operation_context"},
**common,
))
if picking_date:
try:
parsed = datetime.fromisoformat(picking_date.replace("Z", "+00:00")).date()
except Exception:
parsed = None
if parsed and parsed > datetime.now(timezone.utc).date():
self.add(Finding(
severity="low",
code="FUTURE_OPERATION_DATE_LABEL",
title="Data futura apresentada como evento já ocorrido",
recommendation="Apresentar como data prevista/programada e não como entrega concluída.",
evidence={"scheduled_date": picking_date, "picking_state": picking_state},
proposed_correction={"action": "fix_ui_label"},
**common,
))
def _recommended_action(self, opportunity: Mapping[str, Any], docs: Sequence[Mapping[str, Any]]) -> str:
stage = _clean(opportunity.get("stage")).upper()
has_invoice = any(_clean(doc.get("document_kind")) == "invoice" and bool(doc.get("is_active", True)) for doc in docs)
if stage == "ORDER_PREPARATION" and picking_state == "assigned":
return "VALIDATE_PHYSICAL_ORDER"
if stage == "READY_TO_SHIP":
return "CREATE_SHIPMENT"
if stage in {"ORDER_PREPARATION", "IN_PRODUCTION"}:
return "PREPARE_ORDER"
if stage in {"ODOO_ORDER_CREATED", "PAYMENT_CONFIRMED"}:
return "PREPARE_ORDER" if has_invoice else "SEND_INVOICE"
if stage in {"INVOICE_SENT", "WAITING_PAYMENT"}:
return "FOLLOW_UP_PAYMENT"
if stage == "QUOTE_SENT":
return "FOLLOW_UP_QUOTE"
return "REVIEW_MANUALLY"
def _audit_missing_work_task(self, opportunity: Mapping[str, Any], docs: Sequence[Mapping[str, Any]], common: Dict[str, Any]) -> None:
opportunity_id = _clean(opportunity.get("id"))
stage = _clean(opportunity.get("stage")).upper()
expected = HUMAN_ACTIONS_BY_STAGE.get(stage)
if not expected:
return
pending = self._tasks_for_opportunity(opportunity_id)
pending_codes = {_clean(task.get("action_code")).upper() for task in pending}
recommended = self._recommended_action(opportunity, docs)
if not pending and recommended in expected:
severity = "high" if stage in {"READY_TO_SHIP", "SHIPMENT_CREATED"} else "medium"
self.add(Finding(
severity=severity,
code="MISSING_HUMAN_TASK",
title="A oportunidade tem próxima ação humana, mas não tem task pendente",
recommendation="Materializar uma única task operacional coerente com a fase, sem regressar a oportunidade.",
evidence={"stage": stage, "recommended_action": recommended, "pending_tasks": []},
proposed_correction={"action": "materialize_single_task", "action_code": recommended, "automatic": False},
**common,
))
elif pending and recommended not in pending_codes and stage in {"READY_TO_SHIP", "SHIPMENT_CREATED"}:
self.add(Finding(
severity="medium",
code="PENDING_TASK_NOT_ALIGNED_WITH_STAGE",
title="A task pendente não corresponde à fase operacional mais avançada",
recommendation="Rever a task antes de executar; evitar ações financeiras/comerciais já ultrapassadas.",
evidence={"stage": stage, "recommended_action": recommended, "pending_tasks": pending},
proposed_correction={"action": "review_pending_task", "replacement_action": recommended},
**common,
))
def audit(self) -> None:
for item in self.items:
self._audit_item(item)
self.findings.sort(key=lambda f: (SEVERITY_ORDER.get(f.severity, 9), f.customer_name, f.external_ref, f.code))
async def _probe_jasmin(self, items: Sequence[Mapping[str, Any]], max_live: int) -> Dict[str, Any]:
from app.jasmin_client import JasminClient
client = JasminClient()
results: List[Dict[str, Any]] = []
selected = [item for item in items if _clean(item.get("source_system")).lower() == "jasmin"][:max_live]
for item in selected:
external_id = _clean(item.get("external_id"))
external_type = _clean(item.get("external_type"))
row = {"item_id": item.get("id"), "external_id": external_id, "external_type": external_type, "ok": False}
if not external_id:
row["error"] = "external_id em falta; GET ignorado"
row["skipped"] = True
results.append(row)
continue
try:
if external_type == "jasmin_quotation":
payload = await client.get_quotation(external_id)
elif external_type == "jasmin_invoice":
payload = await client.get_invoice(external_id)
else:
row["error"] = "tipo sem GET direto implementado"
row["skipped"] = True
results.append(row)
continue
row.update({"ok": True, "payload": payload})
except Exception as exc:
row["error"] = str(exc)
results.append(row)
return {"system": "jasmin", "count": len(results), "results": results}
def _probe_odoo(self, items: Sequence[Mapping[str, Any]], max_live: int) -> Dict[str, Any]:
from app.odoo_client import OdooClient
client = OdooClient()
results: List[Dict[str, Any]] = []
fields = ["id", "name", "state", "partner_id", "amount_total", "amount_untaxed", "currency_id", "date_order", "invoice_status", "write_date"]
selected = [item for item in items if _clean(item.get("source_system")).lower() == "odoo"][:max_live]
for item in selected:
external_id = _clean(item.get("external_id"))
order_name = _clean(item.get("document_number"))
row = {"item_id": item.get("id"), "external_id": external_id, "document_number": order_name, "ok": False}
try:
records: List[Dict[str, Any]] = []
if external_id.isdigit():
records = client.search_read("sale.order", [["id", "=", int(external_id)]], fields, limit=2)
if not records and order_name:
records = client.search_read("sale.order", [["name", "=", order_name]], fields, limit=2)
if not records:
row["error"] = "venda não encontrada no Odoo"
else:
sale = records[0]
pickings = client.search_read(
"stock.picking",
[["origin", "ilike", _clean(sale.get("name"))]],
["id", "name", "state", "origin", "picking_type_id", "scheduled_date", "date_done"],
limit=100,
order="id desc",
)
row.update({"ok": True, "sale": sale, "pickings": pickings})
except Exception as exc:
row["error"] = str(exc)
results.append(row)
return {"system": "odoo", "count": len(results), "results": results}
async def _probe_packlink(self, items: Sequence[Mapping[str, Any]], max_live: int) -> Dict[str, Any]:
from app.packlink_client import PacklinkClient
client = PacklinkClient()
results: List[Dict[str, Any]] = []
selected = [item for item in items if _clean(item.get("source_system")).lower() == "packlink"][:max_live]
for item in selected:
ref = _clean(item.get("external_id") or item.get("document_number"))
row = {"item_id": item.get("id"), "reference": ref, "ok": False}
if not ref:
row["error"] = "referência Packlink em falta"
row["skipped"] = True
results.append(row)
continue
try:
payload = await client.get_shipment(ref)
row.update({"ok": True, "payload": payload})
except Exception as exc:
row["error"] = str(exc)
results.append(row)
return {"system": "packlink", "count": len(results), "results": results}
def probe_live(self, systems: Sequence[str], max_live: int) -> None:
chosen = {system.strip().lower() for system in systems if system.strip()}
if "all" in chosen:
chosen = {"odoo", "jasmin", "packlink"}
if "odoo" in chosen:
try:
self.live_results["odoo"] = self._probe_odoo(self.items, max_live)
except Exception as exc:
self.live_results["odoo"] = {"system": "odoo", "error": str(exc), "results": []}
if "jasmin" in chosen:
try:
self.live_results["jasmin"] = asyncio.run(self._probe_jasmin(self.items, max_live))
except Exception as exc:
self.live_results["jasmin"] = {"system": "jasmin", "error": str(exc), "results": []}
if "packlink" in chosen:
try:
self.live_results["packlink"] = asyncio.run(self._probe_packlink(self.items, max_live))
except Exception as exc:
self.live_results["packlink"] = {"system": "packlink", "error": str(exc), "results": []}
self._audit_live_results()
def _audit_live_results(self) -> None:
item_by_id = {_clean(item.get("id")): item for item in self.items}
for system, payload in self.live_results.items():
if payload.get("error"):
self.add(Finding(
severity="high",
code=f"LIVE_{system.upper()}_UNAVAILABLE",
title=f"Integração {system} indisponível para auditoria live",
recommendation="Corrigir configuração/credenciais antes de confiar nos candidatos dessa origem.",
source_system=system,
evidence={"error": payload.get("error")},
proposed_correction={"action": "fix_integration_configuration", "automatic": False},
))
for result in payload.get("results") or []:
if result.get("skipped"):
continue
item = item_by_id.get(_clean(result.get("item_id")))
if not item:
continue
common = {
"item_id": _clean(item.get("id")),
"source_system": _clean(item.get("source_system")),
"external_type": _clean(item.get("external_type")),
"external_ref": (_external_refs(item) or [""])[0],
"customer_name": _clean(item.get("customer_name")),
}
if not result.get("ok"):
severity = "high" if "401" in _clean(result.get("error")) else "medium"
self.add(Finding(
severity=severity,
code=f"LIVE_{system.upper()}_GET_FAILED",
title=f"Falha na confirmação live em {system}",
recommendation="Corrigir credenciais/identificador ou tratar o candidato como não confirmado; não ligar automaticamente.",
evidence=result,
proposed_correction={"action": "fix_integration_or_review", "automatic": False},
**common,
))
elif system == "odoo":
sale = result.get("sale") or {}
live_amount = _money(sale.get("amount_total"))
staged_amount = _money(item.get("amount"))
if live_amount is not None and staged_amount is not None and not _close_amount(live_amount, staged_amount):
self.add(Finding(
severity="high",
code="LIVE_ODOO_AMOUNT_CHANGED",
title="O valor Odoo mudou desde a criação do candidato",
recommendation="Atualizar o staging e rever linhas antes de ligar o processo.",
evidence={"staged_amount": staged_amount, "live_amount": live_amount, "sale": sale},
proposed_correction={"action": "refresh_candidate_from_odoo"},
**common,
))
self.findings.sort(key=lambda f: (SEVERITY_ORDER.get(f.severity, 9), f.customer_name, f.external_ref, f.code))
def summary(self) -> Dict[str, Any]:
by_severity: Dict[str, int] = defaultdict(int)
by_code: Dict[str, int] = defaultdict(int)
for finding in self.findings:
by_severity[finding.severity] += 1
by_code[finding.code] += 1
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"days": self.days,
"items_audited": len(self.items),
"findings": len(self.findings),
"by_severity": dict(sorted(by_severity.items(), key=lambda x: SEVERITY_ORDER.get(x[0], 9))),
"by_code": dict(sorted(by_code.items())),
"live_systems": sorted(self.live_results),
"read_only": True,
}
def write_reports(self, output_dir: Path) -> Dict[str, str]:
output_dir.mkdir(parents=True, exist_ok=True)
summary = self.summary()
findings_data = [_jsonable(asdict(finding)) for finding in self.findings]
items_data = [_jsonable(item) for item in self.items]
full = {
"summary": summary,
"findings": findings_data,
"items": items_data,
"live_results": _jsonable(self.live_results),
}
json_path = output_dir / "reconciliation_audit.json"
json_path.write_text(json.dumps(full, ensure_ascii=False, indent=2), encoding="utf-8")
plan_path = output_dir / "correction_plan.json"
plan = {
"generated_at": summary["generated_at"],
"read_only": True,
"instructions": "Rever cada proposta antes de criar qualquer script --apply.",
"corrections": [
{
"item_id": finding.item_id,
"code": finding.code,
"severity": finding.severity,
"opportunity_id": finding.opportunity_id,
"external_ref": finding.external_ref,
"proposal": _jsonable(finding.proposed_correction),
"guard_evidence": _jsonable(finding.evidence),
}
for finding in self.findings
if finding.proposed_correction
],
}
plan_path.write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8")
csv_path = output_dir / "reconciliation_findings.csv"
columns = [
"severity", "code", "customer_name", "source_system", "external_type",
"external_ref", "item_id", "opportunity_id", "opportunity_title",
"title", "recommendation", "proposed_correction", "evidence",
]
with csv_path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=columns)
writer.writeheader()
for finding in self.findings:
row = asdict(finding)
row["proposed_correction"] = json.dumps(_jsonable(row["proposed_correction"]), ensure_ascii=False)
row["evidence"] = json.dumps(_jsonable(row["evidence"]), ensure_ascii=False)
writer.writerow({key: row.get(key, "") for key in columns})
md_path = output_dir / "RECONCILIATION_AUDIT.md"
lines = [
"# Auditoria de coerência da reconciliação",
"",
f"Gerada em: `{summary['generated_at']}`",
f"Janela: `{self.days}` dia(s)",
f"Itens auditados: **{summary['items_audited']}**",
f"Achados: **{summary['findings']}**",
"",
"## Resumo por severidade",
"",
]
for severity in ["critical", "high", "medium", "low", "info"]:
lines.append(f"- {severity}: **{summary['by_severity'].get(severity, 0)}**")
lines.extend(["", "## Achados", ""])
if not self.findings:
lines.append("Nenhuma incoerência encontrada dentro da janela auditada.")
for index, finding in enumerate(self.findings, 1):
lines.extend([
f"### {index}. [{finding.severity.upper()}] {finding.title}",
"",
f"- Código: `{finding.code}`",
f"- Cliente: {finding.customer_name or ''}",
f"- Origem: `{finding.source_system}` / `{finding.external_type}` / `{finding.external_ref or ''}`",
f"- Item: `{finding.item_id or ''}`",
f"- Oportunidade: `{finding.opportunity_id or ''}` {finding.opportunity_title or ''}",
f"- Recomendação: {finding.recommendation}",
f"- Correção proposta: `{json.dumps(_jsonable(finding.proposed_correction), ensure_ascii=False)}`",
"",
])
lines.extend([
"## Garantia de segurança",
"",
"Este relatório foi produzido numa transação PostgreSQL `READ ONLY`. O script não alterou itens, oportunidades, documentos, ligações ou tasks.",
])
md_path.write_text("\n".join(lines), encoding="utf-8")
sql_path = output_dir / "diagnostic_queries.sql"
sql_path.write_text("""-- Consultas apenas de diagnóstico; não contêm UPDATE/DELETE/INSERT.\n\nSELECT status, source_system, external_type, COUNT(*)\nFROM reconciliation_items\nWHERE status IN ('open','needs_review','conflict')\nGROUP BY status, source_system, external_type\nORDER BY status, source_system, external_type;\n\nSELECT ri.id, ri.source_system, ri.external_type, ri.external_id, ri.document_number,\n ri.customer_name, ri.opportunity_id, o.title, o.stage\nFROM reconciliation_items ri\nLEFT JOIN opportunities o ON o.id = ri.opportunity_id\nWHERE ri.status IN ('open','needs_review','conflict')\nORDER BY ri.updated_at DESC;\n""", encoding="utf-8")
return {
"json": str(json_path),
"markdown": str(md_path),
"csv": str(csv_path),
"correction_plan": str(plan_path),
"diagnostic_sql": str(sql_path),
}
def _self_test() -> None:
assert _normalize_tax_id("PT 510 177 441") == "510177441"
assert _normalize_email(" Test@Example.COM ") == "test@example.com"
assert _normalize_name("NOLTIA SYSTEM, LDA") == "noltia system"
assert _close_amount("269,37", Decimal("269.37"))
assert _looks_like_tax_difference("219.00", "269.37")
assert _stage_rank("READY_TO_SHIP") > _stage_rank("ODOO_ORDER_CREATED")
print("Self-test OK")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Auditoria read-only da reconciliação ClientFlow")
parser.add_argument("--days", type=int, default=3, help="Janela operacional em dias (default: 3)")
parser.add_argument("--limit", type=int, default=500, help="Máximo de itens a auditar")
parser.add_argument("--source", choices=["odoo", "jasmin", "packlink", "manual"], default=None, help="Filtrar origem")
parser.add_argument("--item-id", default="", help="Auditar apenas um reconciliation_item UUID")
parser.add_argument("--live", default="", help="GET live separados por vírgula: odoo,jasmin,packlink,all")
parser.add_argument("--max-live", type=int, default=30, help="Máximo de pedidos live por sistema")
parser.add_argument("--output-dir", default="", help="Diretório dos relatórios")
parser.add_argument("--no-files", action="store_true", help="Não escrever relatórios em disco")
parser.add_argument("--self-test", action="store_true", help="Executar testes internos e terminar")
return parser.parse_args()
def main() -> int:
args = parse_args()
if args.self_test:
_self_test()
return 0
auditor = Auditor(days=args.days, limit=args.limit, source=args.source, item_id=args.item_id)
auditor.load()
auditor.audit()
if args.live:
auditor.probe_live([value for value in args.live.split(",") if value.strip()], max(1, int(args.max_live)))
summary = auditor.summary()
print("Reconciliação auditada em modo READ ONLY")
print(f"Itens auditados: {summary['items_audited']}")
print(f"Achados: {summary['findings']}")
for severity in ["critical", "high", "medium", "low", "info"]:
print(f" {severity:8}: {summary['by_severity'].get(severity, 0)}")
for finding in auditor.findings[:50]:
print(
f"{finding.severity.upper():8} | {finding.code:42} | "
f"{finding.external_ref or '':18} | {finding.customer_name or ''}"
)
if len(auditor.findings) > 50:
print(f"... mais {len(auditor.findings) - 50} achado(s) no relatório")
if not args.no_files:
if args.output_dir:
output_dir = Path(args.output_dir)
else:
stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
output_dir = Path("audit_reports") / f"reconciliation_{stamp}"
paths = auditor.write_reports(output_dir)
print("Relatórios:")
for name, path in paths.items():
print(f" {name}: {path}")
return 2 if summary["by_severity"].get("critical", 0) else 0
if __name__ == "__main__":
raise SystemExit(main())