Files
clientflow_backend/scripts/audit_work_center_forensics.py

1714 lines
85 KiB
Python
Executable File

#!/usr/bin/env python3
"""Forensic READ-ONLY audit of ClientFlow Work Center and associations.
Target: ClientFlow v4928.1.5.130+
This auditor explains *why* a Work Center action is or is not coherent. It
separates proven defects from policy ambiguity and expected behaviour.
It compares:
1. the normalized item shown in /operations;
2. the stored pending task;
3. the central opportunity next-action decision;
4. explicit blockers (association, fiscal conflict, reconstructed review);
5. active operation links and open reconciliation candidates;
6. the task materialization and visibility policies.
No INSERT/UPDATE/DELETE is executed. There is deliberately no --apply option.
All SQL owned by this script runs in a PostgreSQL READ ONLY transaction.
Examples:
python scripts/audit_work_center_forensics.py --limit 500 --opportunity-limit 1000
python scripts/audit_work_center_forensics.py --focus NOLTIA --focus S00323
python scripts/audit_work_center_forensics.py --fail-on confirmed_high
python scripts/audit_work_center_forensics.py --self-test
"""
from __future__ import annotations
import argparse
import csv
import json
import re
import sys
from collections import Counter, defaultdict
from dataclasses import asdict, dataclass, field
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
def _detect_project_root() -> Path:
script_path = Path(__file__).resolve()
candidates = [script_path.parent.parent, Path.cwd().resolve(), *script_path.parents]
seen: set[str] = 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))
from sqlalchemy import text # noqa: E402
OPEN_RECONCILIATION_STATUSES = {"open", "needs_review", "conflict"}
TERMINAL_STAGES = {"WON", "LOST", "NO_INTEREST", "CLOSED"}
DONE_TASK_STATUSES = {"done", "completed", "concluida", "concluída", "ignored", "ignorada"}
ASSOCIATION_ACTIONS = {
"ASSOCIATE_OPPORTUNITY", "REVIEW_ASSOCIATION", "LINK_DOCUMENT", "RECONCILE_DOCUMENTS",
}
RECONSTRUCTED_REVIEW_ACTIONS = {"REVIEW_RECONSTRUCTED_PROCESS", "REVIEW_MANUALLY"}
UI_RECONSTRUCTED_SENSITIVE_ACTIONS = {
# Exact allow-list used by app.operations_service v130.
"SEND_PROFORMA", "SEND_INVOICE", "CONFIRM_PAYMENT",
"CONFIRM_PAYMENT_AND_PREPARE_SHIPMENT", "PREPARE_ORDER", "CREATE_SHIPMENT",
}
OPERATIONAL_SENSITIVE_ACTIONS = UI_RECONSTRUCTED_SENSITIVE_ACTIONS | {
"VALIDATE_PHYSICAL_ORDER", "SHIP_ORDER", "CLOSE_OPPORTUNITY",
}
ACTION_ALIASES = {
"SHIP_ORDER": "CREATE_SHIPMENT",
"RECONCILE_DOCUMENTS": "ASSOCIATE_OPPORTUNITY",
"REVIEW_ASSOCIATION": "ASSOCIATE_OPPORTUNITY",
"LINK_DOCUMENT": "ASSOCIATE_OPPORTUNITY",
"REVIEW": "REVIEW_MANUALLY",
}
NON_TASK_ACTIONS = {"", "NO_ACTION", "WAIT_PRODUCTION", "NOT_FOUND", "AUDIT_ERROR"}
CATEGORY_ORDER = {"confirmed": 0, "needs_review": 1, "policy": 2, "expected": 3}
SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
CONFIDENCE_ORDER = {"high": 0, "medium": 1, "low": 2}
@dataclass
class Finding:
category: str
severity: str
confidence: str
actionable: bool
code: str
title: str
explanation: str
recommendation: str
root_cause: str = ""
opportunity_id: str = ""
opportunity_title: str = ""
customer_name: str = ""
work_item_id: str = ""
task_id: str = ""
association_item_id: str = ""
external_ref: str = ""
displayed_action: str = ""
stored_action: str = ""
central_action: str = ""
effective_action: str = ""
evidence: Dict[str, Any] = field(default_factory=dict)
proposed_next_step: Dict[str, Any] = field(default_factory=dict)
@dataclass
class AssociationAssessment:
item_id: str
source_system: str
external_type: str
external_ref: str
status: str
assigned_opportunity_id: str
exact_link_opportunity_ids: List[str]
match_kind: str
classification: str
blocks_opportunity: bool
confidence: str
identity: Dict[str, Any] = field(default_factory=dict)
candidate: Dict[str, Any] = field(default_factory=dict)
matching_links: List[Dict[str, Any]] = field(default_factory=list)
@dataclass
class ReconstructedAssessment:
record_mode: str
is_legacy_record: bool
explicit_required: bool
explicit_cleared: bool
pending_review_task_ids: List[str]
completed_review_task_ids: List[str]
state: str
evidence: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ActionChain:
opportunity_id: str
central_action: str
central_can_execute: Optional[bool]
explicit_blocker: str
effective_action: str
blocker_reason: str
association_blocker_ids: List[str]
reconstructed_state: str
materializable: bool
visible_actions: List[str]
pending_actions: List[str]
def _s(value: Any) -> str:
return str(value or "").strip()
def _upper(value: Any) -> str:
return _s(value).upper()
def _lower(value: Any) -> str:
return _s(value).lower()
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 _json_default(value: Any) -> str:
if isinstance(value, (datetime, date)):
return value.isoformat()
return str(value)
def _canonical_action(value: Any) -> str:
code = _upper(value)
return ACTION_ALIASES.get(code, code)
def _actions_match(left: Any, right: Any) -> bool:
a, b = _canonical_action(left), _canonical_action(right)
if not a or not b:
return False
if a == b:
return True
if {a, b} <= {"REVIEW_MANUALLY", "REVIEW_RECONSTRUCTED_PROCESS"}:
return True
if a == "FOLLOW_UP" and b.startswith("FOLLOW_UP_"):
return True
if b == "FOLLOW_UP" and a.startswith("FOLLOW_UP_"):
return True
return False
def _norm_text(value: Any) -> str:
value = _s(value).casefold()
value = re.sub(r"[^0-9a-zà-ÿ]+", " ", value)
return " ".join(value.split())
def _norm_email(value: Any) -> str:
return _s(value).casefold()
def _norm_tax_id(value: Any) -> str:
return "".join(ch for ch in _s(value) if ch.isdigit())
def _record_mode(metadata: Any) -> str:
return _lower(_as_dict(metadata).get("clientflow_record_mode"))
def _is_legacy_record(metadata: Any) -> bool:
return _record_mode(metadata) in {
"reconstructed_invoice_review", "historical_reconstructed", "legacy_review",
}
def _review_type(task: Mapping[str, Any]) -> str:
metadata = _as_dict(task.get("metadata"))
return _lower(metadata.get("review_type") or metadata.get("task_type"))
def _link_system(source_system: Any) -> str:
source = _lower(source_system)
if source.startswith("odoo"):
return "odoo"
if source.startswith("jasmin"):
return "jasmin"
if source.startswith("packlink"):
return "packlink"
return source
def _reference_variants(value: Any, *, system: str = "", external_type: str = "") -> set[str]:
raw = _s(value)
if not raw:
return set()
variants = {raw.casefold()}
compact = re.sub(r"\s+", "", raw).casefold()
variants.add(compact)
system = _lower(system)
external_type = _lower(external_type)
# Odoo sale orders are often represented both by numeric database id and
# human name S00325. Never infer one from the other. Only normalize zeros in
# an existing S-number, which is a presentation-equivalent reference.
if system == "odoo" or "sale" in external_type:
match = re.fullmatch(r"s0*(\d+)", compact)
if match:
number = int(match.group(1))
variants.add(f"s{number}")
variants.add(f"s{number:05d}")
return {variant for variant in variants if variant}
def _candidate_refs(item: Mapping[str, Any]) -> set[str]:
system = _link_system(item.get("source_system"))
external_type = _s(item.get("external_type"))
values = [item.get("external_id"), item.get("document_number")]
payload = _as_dict(item.get("payload"))
record = payload.get("record") if isinstance(payload.get("record"), dict) else payload
for key in ("id", "external_id", "name", "number", "document_number", "order_name"):
values.append(record.get(key))
refs: set[str] = set()
for value in values:
refs.update(_reference_variants(value, system=system, external_type=external_type))
return refs
def _link_refs(link: Mapping[str, Any]) -> set[str]:
system = _lower(link.get("system"))
external_type = _s(link.get("external_type"))
refs: set[str] = set()
for value in (link.get("external_id"), link.get("external_name")):
refs.update(_reference_variants(value, system=system, external_type=external_type))
return refs
def _display_ref(item: Mapping[str, Any]) -> str:
return _s(item.get("document_number") or item.get("external_id"))
def _truthy_metadata(metadata: Mapping[str, Any], keys: Iterable[str]) -> bool:
for key in keys:
value = metadata.get(key)
if value is True:
return True
if isinstance(value, str) and value.strip().casefold() in {"true", "1", "yes", "sim", "required"}:
return True
return False
def _falsey_metadata(metadata: Mapping[str, Any], keys: Iterable[str]) -> bool:
for key in keys:
value = metadata.get(key)
if value is False:
return True
if isinstance(value, str) and value.strip().casefold() in {"false", "0", "no", "não", "nao", "cleared", "validated"}:
return True
return False
def _focus_match(focus: Sequence[str], *values: Any) -> bool:
if not focus:
return True
haystack = " ".join(_s(value) for value in values).casefold()
return any(token.casefold() in haystack for token in focus if token)
def _severity_key(category: str, severity: str) -> str:
if category == "confirmed" and severity in {"critical", "high"}:
return "confirmed_high"
return severity
class ForensicAuditor:
def __init__(
self,
*,
limit: int,
opportunity_limit: int,
focus: Sequence[str],
include_clean: bool,
) -> None:
self.limit = min(max(int(limit), 1), 5000)
self.opportunity_limit = min(max(int(opportunity_limit), 1), 10000)
self.focus = [_s(value) for value in focus if _s(value)]
self.include_clean = bool(include_clean)
self.work_items: List[Dict[str, Any]] = []
self.operations_counts: Dict[str, Any] = {}
self.opportunities: Dict[str, Dict[str, Any]] = {}
self.tasks_by_opportunity: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
self.pending_tasks_by_opportunity: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
self.all_pending_tasks: List[Dict[str, Any]] = []
self.reconciliation_items: List[Dict[str, Any]] = []
self.operation_links: List[Dict[str, Any]] = []
self.documents_by_opportunity: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
self.task_events_by_task: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
self.next_actions: Dict[str, Dict[str, Any]] = {}
self.operation_snapshots: Dict[str, Dict[str, Any]] = {}
self.materialized_actions: set[str] = set()
self.association_assessments: List[AssociationAssessment] = []
self.associations_by_opportunity: Dict[str, List[AssociationAssessment]] = defaultdict(list)
self.reconstructed: Dict[str, ReconstructedAssessment] = {}
self.action_chains: Dict[str, ActionChain] = {}
self.visibility: Dict[str, Dict[str, Any]] = {}
self.findings: List[Finding] = []
self.dossiers: Dict[str, Dict[str, Any]] = {}
self.summary: Dict[str, Any] = {}
def add(self, finding: Finding) -> None:
if self.focus and not _focus_match(
self.focus,
finding.opportunity_id,
finding.opportunity_title,
finding.customer_name,
finding.external_ref,
finding.code,
):
return
self.findings.append(finding)
def load(self) -> None:
try:
from app.db import engine
from app.operations_service import get_operations_summary
from app.opportunity_next_action_service import get_opportunity_next_action
from app.operation_service import get_operation_snapshot
from app.opportunity_action_task_materializer import MATERIALIZED_ACTIONS, ACTION_ALIASES as MATERIALIZER_ALIASES
except ModuleNotFoundError as exc:
raise SystemExit(
"Não foi possível importar o backend ClientFlow. "
f"Raiz detetada: {PROJECT_ROOT}. Instale em <backend>/scripts/."
) from exc
operations = get_operations_summary(limit=self.limit)
self.work_items = [dict(item) for item in operations.get("work_items", [])]
self.operations_counts = dict(operations.get("counts", {}))
self.materialized_actions = {
_canonical_action(MATERIALIZER_ALIASES.get(_upper(code), _upper(code)))
for code in MATERIALIZED_ACTIONS
}
with engine.connect() as conn:
tx = conn.begin()
try:
conn.execute(text("SET TRANSACTION READ ONLY"))
required = {
"opportunities", "tasks", "reconciliation_items", "operation_links",
"commercial_documents", "customers", "task_events",
}
existing = set(conn.execute(text("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = ANY(:tables)
"""), {"tables": sorted(required)}).scalars().all())
missing = sorted(required - existing)
if missing:
raise RuntimeError("Tabelas necessárias em falta: " + ", ".join(missing))
opportunity_rows = conn.execute(text("""
SELECT
o.id::text, o.title, o.customer_name, o.customer_email,
o.stage, o.status, o.value_amount, o.currency,
o.local_customer_id::text, o.lifecycle_state,
o.next_follow_up_at, o.follow_up_attempts,
o.last_customer_activity_at, o.last_operator_activity_at,
o.last_commercial_activity_at, o.metadata,
o.created_at, o.updated_at,
c.name AS fiscal_customer_name,
c.email AS fiscal_customer_email,
c.tax_id AS fiscal_customer_tax_id,
c.street_name AS fiscal_customer_street,
c.postal_zone AS fiscal_customer_postal_zone,
c.city_name AS fiscal_customer_city
FROM opportunities o
LEFT JOIN customers c ON c.id = o.local_customer_id
WHERE COALESCE(o.status, 'open') = 'open'
AND COALESCE(o.stage, '') <> ALL(:terminal_stages)
ORDER BY o.updated_at DESC, o.created_at DESC
LIMIT :opportunity_limit
"""), {
"terminal_stages": sorted(TERMINAL_STAGES),
"opportunity_limit": self.opportunity_limit,
}).mappings().all()
self.opportunities = {str(row["id"]): dict(row) for row in opportunity_rows}
relevant_ids = set(self.opportunities)
relevant_ids.update(
_s(item.get("opportunity_id")) for item in self.work_items
if _s(item.get("opportunity_id"))
)
ids = sorted(relevant_ids)
task_rows = conn.execute(text("""
SELECT
t.id::text, t.opportunity_id::text, t.action_code,
t.action, t.note, t.route, t.priority, t.status,
t.due_at, t.source_system, t.metadata,
t.conversation_id, t.contact_id, t.customer_id,
t.message_id::text, t.raw_event_id::text,
t.idempotency_key, t.action_required, t.safe_to_post,
t.created_at, t.updated_at, t.done_at,
o.title AS opportunity_title,
o.stage AS opportunity_stage,
o.metadata AS opportunity_metadata,
COALESCE(c.name, o.customer_name, '') AS fiscal_customer_name,
COALESCE(c.email, o.customer_email, '') AS fiscal_customer_email,
COALESCE(c.tax_id, '') AS fiscal_customer_tax_id,
COALESCE(m.clean_body, m.raw_body, re.payload->>'content', '') AS request_text
FROM tasks t
LEFT JOIN opportunities o ON o.id = t.opportunity_id
LEFT JOIN customers c ON c.id = o.local_customer_id
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::text = ANY(:ids)
OR t.status = 'pending'
ORDER BY t.created_at DESC
"""), {"ids": ids or ["00000000-0000-0000-0000-000000000000"]}).mappings().all()
for row in task_rows:
task = dict(row)
oid = _s(task.get("opportunity_id"))
if oid:
self.tasks_by_opportunity[oid].append(task)
if _lower(task.get("status")) == "pending":
self.pending_tasks_by_opportunity[oid].append(task)
if _lower(task.get("status")) == "pending":
self.all_pending_tasks.append(task)
task_ids = [_s(row.get("id")) for row in task_rows if _s(row.get("id"))]
if task_ids:
event_rows = conn.execute(text("""
SELECT id::text, task_id::text, event_type, payload, created_by, created_at
FROM task_events
WHERE task_id::text = ANY(:task_ids)
ORDER BY created_at DESC
"""), {"task_ids": task_ids}).mappings().all()
for row in event_rows:
event = dict(row)
self.task_events_by_task[_s(event.get("task_id"))].append(event)
recon_rows = conn.execute(text("""
SELECT
ri.id::text, ri.opportunity_id::text, ri.customer_id::text,
ri.source_system, ri.external_type, ri.external_id,
ri.document_number, ri.title, ri.customer_name,
ri.customer_email, ri.customer_tax_id, ri.amount, ri.currency,
NULL::text AS suggested_stage, ri.suggested_action, ri.confidence,
ri.status, ri.payload, ri.resolution_note,
ri.created_at, ri.updated_at
FROM reconciliation_items ri
WHERE ri.status IN ('open','needs_review','conflict')
OR ri.opportunity_id::text = ANY(:ids)
ORDER BY ri.updated_at DESC, ri.created_at DESC
"""), {"ids": ids or ["00000000-0000-0000-0000-000000000000"]}).mappings().all()
self.reconciliation_items = [dict(row) for row in recon_rows]
link_rows = 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.created_at, ol.updated_at
FROM operation_links ol
WHERE ol.opportunity_id::text = ANY(:ids)
OR COALESCE(ol.status, '') NOT IN ('ignored','deleted')
"""), {"ids": ids or ["00000000-0000-0000-0000-000000000000"]}).mappings().all()
self.operation_links = [dict(row) for row in link_rows]
document_rows = conn.execute(text("""
SELECT
cd.id::text, cd.opportunity_id::text, cd.system,
cd.document_kind, cd.external_id, cd.document_number,
cd.status, cd.total_amount, cd.currency, cd.role,
cd.is_active, cd.is_primary, cd.document_date,
cd.created_at, cd.updated_at
FROM commercial_documents cd
WHERE cd.opportunity_id::text = ANY(:ids)
ORDER BY cd.created_at DESC
"""), {"ids": ids or ["00000000-0000-0000-0000-000000000000"]}).mappings().all()
for row in document_rows:
doc = dict(row)
self.documents_by_opportunity[_s(doc.get("opportunity_id"))].append(doc)
finally:
tx.rollback()
for opportunity_id in sorted(self.opportunities):
try:
self.next_actions[opportunity_id] = dict(get_opportunity_next_action(opportunity_id))
except Exception as exc:
self.next_actions[opportunity_id] = {
"action_code": "AUDIT_ERROR",
"label": "Erro ao calcular próxima ação",
"can_execute": False,
"reason_if_blocked": str(exc),
}
try:
snapshot = get_operation_snapshot(opportunity_id)
self.operation_snapshots[opportunity_id] = dict(snapshot or {})
except Exception as exc:
self.operation_snapshots[opportunity_id] = {"error": str(exc), "cards": [], "links": []}
def _identity_assessment(self, item: Mapping[str, Any], opportunity_id: str) -> Dict[str, Any]:
opportunity = self.opportunities.get(opportunity_id, {})
candidate_nif = _norm_tax_id(item.get("customer_tax_id"))
opportunity_nif = _norm_tax_id(opportunity.get("fiscal_customer_tax_id"))
candidate_email = _norm_email(item.get("customer_email"))
opportunity_emails = {
_norm_email(opportunity.get("fiscal_customer_email")),
_norm_email(opportunity.get("customer_email")),
} - {""}
candidate_name = _norm_text(item.get("customer_name"))
opportunity_names = {
_norm_text(opportunity.get("fiscal_customer_name")),
_norm_text(opportunity.get("customer_name")),
} - {""}
nif_state = "unknown"
if candidate_nif and opportunity_nif:
nif_state = "match" if candidate_nif == opportunity_nif else "mismatch"
email_state = "unknown"
if candidate_email and opportunity_emails:
email_state = "match" if candidate_email in opportunity_emails else "mismatch"
name_state = "unknown"
if candidate_name and opportunity_names:
exact = candidate_name in opportunity_names
token_overlap = max(
(len(set(candidate_name.split()) & set(name.split())) for name in opportunity_names),
default=0,
)
name_state = "match" if exact or token_overlap >= 2 else "mismatch"
return {
"nif": nif_state,
"email": email_state,
"name": name_state,
"candidate": {
"nif": candidate_nif,
"email": candidate_email,
"name": _s(item.get("customer_name")),
},
"opportunity": {
"nif": opportunity_nif,
"emails": sorted(opportunity_emails),
"names": sorted(opportunity_names),
},
}
def assess_associations(self) -> None:
active_links = [
link for link in self.operation_links
if _lower(link.get("status")) not in {"ignored", "deleted"}
]
for item in self.reconciliation_items:
if _lower(item.get("status")) not in OPEN_RECONCILIATION_STATUSES:
continue
system = _link_system(item.get("source_system"))
refs = _candidate_refs(item)
matches: List[Dict[str, Any]] = []
for link in active_links:
if _lower(link.get("system")) != system:
continue
if refs & _link_refs(link):
matches.append(link)
linked_ids = sorted({_s(link.get("opportunity_id")) for link in matches if _s(link.get("opportunity_id"))})
assigned = _s(item.get("opportunity_id"))
classification = "unresolved_unassigned"
match_kind = "none"
blocks = False
confidence = "medium"
identity: Dict[str, Any] = {}
if len(linked_ids) > 1:
classification = "conflict_multiple_exact_links"
match_kind = "exact_reference"
blocks = True
confidence = "high"
elif len(linked_ids) == 1:
exact = linked_ids[0]
match_kind = "exact_reference"
identity = self._identity_assessment(item, exact)
if assigned and assigned != exact:
classification = "wrong_assigned_opportunity"
blocks = True
confidence = "high"
else:
classification = "stale_already_linked"
blocks = False
confidence = "high"
elif assigned:
identity = self._identity_assessment(item, assigned)
if identity.get("nif") == "mismatch":
classification = "assigned_identity_conflict"
blocks = True
confidence = "high"
else:
classification = "assigned_unresolved"
blocks = True
confidence = "high" if identity.get("nif") == "match" else "medium"
else:
classification = "unresolved_unassigned"
blocks = False
confidence = "medium"
assessment = AssociationAssessment(
item_id=_s(item.get("id")),
source_system=system,
external_type=_s(item.get("external_type")),
external_ref=_display_ref(item),
status=_lower(item.get("status")),
assigned_opportunity_id=assigned,
exact_link_opportunity_ids=linked_ids,
match_kind=match_kind,
classification=classification,
blocks_opportunity=blocks,
confidence=confidence,
identity=identity,
candidate=dict(item),
matching_links=matches,
)
self.association_assessments.append(assessment)
target_ids = set(linked_ids)
if assigned:
target_ids.add(assigned)
for oid in target_ids:
self.associations_by_opportunity[oid].append(assessment)
common = self._common(assigned or (linked_ids[0] if len(linked_ids) == 1 else ""))
external_ref = assessment.external_ref
if classification == "conflict_multiple_exact_links":
self.add(Finding(
category="confirmed", severity="critical", confidence="high", actionable=True,
code="EXTERNAL_REFERENCE_LINKED_TO_MULTIPLE_OPPORTUNITIES",
title="A mesma evidência externa está ligada a várias oportunidades",
explanation="A referência externa tem mais de uma ligação ativa; nenhuma ação posterior é segura até ficar uma ligação única.",
recommendation="Rever as ligações e preservar apenas a oportunidade correta.",
root_cause="association_integrity",
association_item_id=assessment.item_id, external_ref=external_ref,
effective_action="ASSOCIATE_OPPORTUNITY",
evidence={"opportunity_ids": linked_ids, "matching_links": matches, "candidate_refs": sorted(refs)},
proposed_next_step={"action": "manual_resolve_duplicate_external_link", "automatic": False},
**common,
))
elif classification == "wrong_assigned_opportunity":
self.add(Finding(
category="confirmed", severity="critical", confidence="high", actionable=True,
code="ASSOCIATION_ASSIGNED_TO_WRONG_OPPORTUNITY",
title="O candidato aponta para uma oportunidade diferente da ligação externa existente",
explanation="A referência externa já tem uma ligação exata, mas o candidato está atribuído a outra oportunidade.",
recommendation="Bloquear a ação e corrigir manualmente a associação.",
root_cause="association_integrity",
association_item_id=assessment.item_id, external_ref=external_ref,
effective_action="ASSOCIATE_OPPORTUNITY",
evidence={"assigned_opportunity_id": assigned, "exact_link_opportunity_id": linked_ids[0], "identity": identity},
proposed_next_step={"action": "review_wrong_association", "automatic": False},
**common,
))
elif classification == "stale_already_linked":
exact = linked_ids[0]
self.add(Finding(
category="confirmed", severity="high", confidence="high", actionable=True,
code="STALE_OPEN_CANDIDATE_ALREADY_EXACTLY_LINKED",
title="Candidato aberto apesar de a evidência já estar ligada exatamente uma vez",
explanation="A ligação externa já existe numa única oportunidade. O candidato aberto é redundante e não deve bloquear o processo.",
recommendation="Resolver como já ligado, sem alterar fase, documentos ou tasks.",
root_cause="reconciliation_stale_candidate",
association_item_id=assessment.item_id, external_ref=external_ref,
effective_action="RESOLVE_AS_EXISTING_LINK",
evidence={"exact_opportunity_id": exact, "matching_link": matches[0], "identity": identity},
proposed_next_step={"action": "resolve_as_existing_link", "automatic": True, "guard": "exactly_one_active_link"},
**self._common(exact),
))
elif classification == "assigned_identity_conflict":
self.add(Finding(
category="confirmed", severity="critical", confidence="high", actionable=True,
code="ASSOCIATION_IDENTITY_CONFLICT",
title="O candidato atribuído tem NIF diferente do cliente fiscal",
explanation="Existe evidência fiscal contraditória; a associação não pode ser aceite automaticamente.",
recommendation="Validar cliente e documento antes de qualquer ação financeira ou logística.",
root_cause="identity_conflict",
association_item_id=assessment.item_id, external_ref=external_ref,
effective_action="ASSOCIATE_OPPORTUNITY",
evidence={"identity": identity},
proposed_next_step={"action": "manual_identity_review", "automatic": False},
**common,
))
elif classification == "assigned_unresolved":
self.add(Finding(
category="needs_review", severity="high", confidence=confidence, actionable=True,
code="ASSIGNED_ASSOCIATION_REQUIRES_DECISION",
title="Existe candidato atribuído sem ligação externa confirmada",
explanation="O candidato está associado à oportunidade no staging, mas ainda não existe uma ligação externa exata.",
recommendation="Confirmar a compra/processo e decidir ligar, criar nova oportunidade ou ignorar.",
root_cause="association_pending_decision",
association_item_id=assessment.item_id, external_ref=external_ref,
effective_action="ASSOCIATE_OPPORTUNITY",
evidence={"identity": identity, "candidate_refs": sorted(refs)},
proposed_next_step={"action": "operator_association_decision", "automatic": False},
**common,
))
def assess_reconstructed(self) -> None:
required_keys = {
"historical_evidence_review_required",
"reconstructed_process_review_required",
"reconstructed_review_required",
}
cleared_keys = {
"historical_evidence_review_required",
"reconstructed_process_review_required",
"reconstructed_review_required",
}
validated_keys = {
"reconstructed_process_validated",
"historical_evidence_validated",
"reconstructed_review_validated",
}
for oid, opportunity in self.opportunities.items():
metadata = _as_dict(opportunity.get("metadata"))
tasks = self.tasks_by_opportunity.get(oid, [])
pending_reviews: List[str] = []
completed_reviews: List[str] = []
for task in tasks:
code = _upper(task.get("action_code"))
review_type = _review_type(task)
is_review = code == "REVIEW_RECONSTRUCTED_PROCESS" or (
code == "REVIEW_MANUALLY" and review_type in {
"reconstructed_process", "historical_evidence_review",
"historical_evidence_review_required",
}
)
if not is_review:
continue
if _lower(task.get("status")) == "pending":
pending_reviews.append(_s(task.get("id")))
elif _lower(task.get("status")) in DONE_TASK_STATUSES:
completed_reviews.append(_s(task.get("id")))
explicit_required = _truthy_metadata(metadata, required_keys) or bool(pending_reviews)
explicit_cleared = _truthy_metadata(metadata, validated_keys) or _falsey_metadata(metadata, cleared_keys) or bool(completed_reviews)
legacy = _is_legacy_record(metadata)
if explicit_required and not explicit_cleared:
state = "required"
elif explicit_cleared:
state = "cleared"
elif legacy:
state = "legacy_without_explicit_state"
else:
state = "not_applicable"
assessment = ReconstructedAssessment(
record_mode=_record_mode(metadata),
is_legacy_record=legacy,
explicit_required=explicit_required,
explicit_cleared=explicit_cleared,
pending_review_task_ids=pending_reviews,
completed_review_task_ids=completed_reviews,
state=state,
evidence={
"required_metadata": {key: metadata.get(key) for key in sorted(required_keys) if key in metadata},
"validated_metadata": {key: metadata.get(key) for key in sorted(validated_keys) if key in metadata},
},
)
self.reconstructed[oid] = assessment
central_code = _upper(self.next_actions.get(oid, {}).get("action_code"))
if state == "legacy_without_explicit_state" and central_code in OPERATIONAL_SENSITIVE_ACTIONS:
self.add(Finding(
category="policy", severity="medium", confidence="high", actionable=False,
code="LEGACY_RECORD_WITHOUT_EXPLICIT_REVIEW_STATE",
title="Registo reconstruído sem estado explícito de revisão",
explanation=(
"O metadata identifica um processo reconstruído, mas não diz se a revisão é obrigatória ou já foi concluída. "
"O sistema usa apenas uma lista parcial de ações para decidir quando mostrar a revisão."
),
recommendation="Adicionar um estado persistido required/validated e deixar de inferir apenas por clientflow_record_mode.",
root_cause="reconstructed_review_policy",
central_action=central_code,
effective_action=central_code,
evidence={"assessment": asdict(assessment), "ui_sensitive_allowlist": sorted(UI_RECONSTRUCTED_SENSITIVE_ACTIONS)},
proposed_next_step={"action": "define_explicit_reconstructed_review_state", "automatic": False},
**self._common(oid),
))
if central_code in OPERATIONAL_SENSITIVE_ACTIONS and central_code not in UI_RECONSTRUCTED_SENSITIVE_ACTIONS:
self.add(Finding(
category="policy", severity="high", confidence="high", actionable=True,
code="RECONSTRUCTED_UI_GATE_ALLOWLIST_GAP",
title="A política de revisão reconstruída trata ações sensíveis de forma diferente",
explanation=(
f"A ação {central_code} é operacionalmente sensível, mas não pertence à lista usada pela UI para substituir a ação por revisão. "
"Outras ações sensíveis são bloqueadas apenas por estarem em clientflow_record_mode reconstruído."
),
recommendation="Decidir uma política única e aplicá-la no motor central, não numa lista local da página.",
root_cause="duplicated_action_policy",
central_action=central_code,
effective_action=central_code,
evidence={"assessment": asdict(assessment), "missing_from_ui_allowlist": central_code},
proposed_next_step={"action": "centralize_reconstructed_gate", "automatic": False},
**self._common(oid),
))
def _explicit_association_blockers(self, oid: str) -> List[AssociationAssessment]:
return [
assessment for assessment in self.associations_by_opportunity.get(oid, [])
if assessment.blocks_opportunity and assessment.classification in {
"wrong_assigned_opportunity", "assigned_identity_conflict", "assigned_unresolved",
"conflict_multiple_exact_links",
}
]
def build_action_chains(self) -> None:
visible_by_opportunity: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
for item in self.work_items:
oid = _s(item.get("opportunity_id"))
if oid:
visible_by_opportunity[oid].append(item)
for oid, opportunity in self.opportunities.items():
central = self.next_actions.get(oid, {})
central_code = _canonical_action(central.get("action_code"))
assoc_blockers = self._explicit_association_blockers(oid)
reconstructed = self.reconstructed.get(oid) or ReconstructedAssessment("", False, False, False, [], [], "not_applicable")
metadata = _as_dict(opportunity.get("metadata"))
fiscal_conflict = bool(
opportunity.get("has_nif_conflict") or metadata.get("has_nif_conflict")
or metadata.get("fiscal_conflict")
)
blocker = ""
reason = "central_decision"
effective = central_code
if assoc_blockers:
blocker = "association"
reason = "open_association_requires_decision"
effective = "ASSOCIATE_OPPORTUNITY"
elif reconstructed.state == "required":
blocker = "reconstructed_review"
reason = "explicit_reconstructed_review_required"
effective = "REVIEW_RECONSTRUCTED_PROCESS"
elif fiscal_conflict:
blocker = "fiscal_conflict"
reason = "fiscal_or_nif_conflict"
effective = "REVIEW_MANUALLY"
pending = self.pending_tasks_by_opportunity.get(oid, [])
visible = visible_by_opportunity.get(oid, [])
chain = ActionChain(
opportunity_id=oid,
central_action=central_code,
central_can_execute=central.get("can_execute"),
explicit_blocker=blocker,
effective_action=effective,
blocker_reason=reason,
association_blocker_ids=[assessment.item_id for assessment in assoc_blockers],
reconstructed_state=reconstructed.state,
materializable=_canonical_action(effective) in self.materialized_actions,
visible_actions=[_canonical_action(item.get("action_code")) for item in visible],
pending_actions=[_canonical_action(task.get("action_code")) for task in pending],
)
self.action_chains[oid] = chain
def _common(self, opportunity_id: str) -> Dict[str, Any]:
opportunity = self.opportunities.get(opportunity_id, {})
return {
"opportunity_id": opportunity_id,
"opportunity_title": _s(opportunity.get("title")),
"customer_name": _s(
opportunity.get("fiscal_customer_name") or opportunity.get("customer_name")
),
}
def _visible_item_for_task(self, task_id: str) -> Optional[Dict[str, Any]]:
return next(
(item for item in self.work_items if _lower(item.get("source")) == "task" and _s(item.get("id")) == task_id),
None,
)
def assess_visibility(self) -> None:
now = datetime.now(timezone.utc)
for task in self.all_pending_tasks:
task_id = _s(task.get("id"))
visible = self._visible_item_for_task(task_id)
code = _upper(task.get("action_code"))
due_at = task.get("due_at")
reason = "visible" if visible else "eligible_but_not_visible"
expected_hidden = False
if not visible and code.startswith("FOLLOW_UP_") and isinstance(due_at, datetime) and due_at > now:
reason = "scheduled_future_followup"
expected_hidden = True
elif not visible and _lower(task.get("status")) != "pending":
reason = "not_pending"
expected_hidden = True
self.visibility[task_id] = {
"visible": bool(visible),
"reason": reason,
"expected_hidden": expected_hidden,
"task": task,
"visible_item": visible,
}
if visible or expected_hidden:
continue
oid = _s(task.get("opportunity_id"))
chain = self.action_chains.get(oid)
severity = "high" if chain and _actions_match(code, chain.effective_action) else "medium"
self.add(Finding(
category="confirmed" if severity == "high" else "needs_review",
severity=severity,
confidence="high" if severity == "high" else "medium",
actionable=True,
code="PENDING_TASK_ELIGIBLE_BUT_NOT_VISIBLE",
title="Task pendente e vencida/atual não aparece no Centro de Trabalho",
explanation=(
"A task cumpre o filtro SQL base (pending e não é follow-up futuro), mas não foi devolvida na fila normalizada. "
"Pode estar a ser removida por regra de ruído ou ficar fora do limite/ordenação."
),
recommendation="Executar o diagnóstico de visibilidade e identificar a regra exata antes de recriar a task.",
root_cause="work_center_visibility",
task_id=task_id,
stored_action=code,
central_action=chain.central_action if chain else "",
effective_action=chain.effective_action if chain else "",
evidence={
"due_at": due_at,
"route": task.get("route"),
"priority": task.get("priority"),
"source_system": task.get("source_system"),
"operations_limit": self.limit,
"visible_queue_size": len(self.work_items),
},
proposed_next_step={"action": "trace_work_center_filter", "automatic": False},
**self._common(oid),
))
def audit_visible_actions(self) -> None:
for item in self.work_items:
if _lower(item.get("source")) != "task":
continue
task_id = _s(item.get("id"))
oid = _s(item.get("opportunity_id"))
if not oid or oid not in self.opportunities:
continue
chain = self.action_chains.get(oid)
if not chain:
continue
displayed = _canonical_action(item.get("action_code"))
stored = _canonical_action(item.get("original_action_code") or item.get("action_code"))
central = chain.central_action
effective = chain.effective_action
ui_override = displayed != stored
if not _actions_match(displayed, effective):
confirmed_blocker = bool(chain.explicit_blocker)
downstream_sensitive = stored in OPERATIONAL_SENSITIVE_ACTIONS
category = "confirmed" if confirmed_blocker else "needs_review"
severity = "critical" if confirmed_blocker and downstream_sensitive else ("high" if confirmed_blocker else "medium")
confidence = "high" if confirmed_blocker else "medium"
self.add(Finding(
category=category, severity=severity, confidence=confidence, actionable=True,
code="VISIBLE_ACTION_DIFFERS_FROM_FIRST_EXPLICITLY_SAFE_ACTION",
title="A ação visível não corresponde à primeira ação suportada pelas evidências",
explanation=(
f"O Centro mostra {displayed}, enquanto a cadeia de decisão indica {effective}. "
+ ("Existe um bloqueio explícito comprovado." if confirmed_blocker else "Não existe bloqueio explícito; a diferença pode ser uma política ou task antiga.")
),
recommendation="Preservar apenas uma primeira ação e explicar o bloqueio/razão no cartão.",
root_cause="action_precedence",
work_item_id=task_id, task_id=task_id,
displayed_action=displayed, stored_action=stored,
central_action=central, effective_action=effective,
evidence={"action_chain": asdict(chain), "ui_override": ui_override, "item": item},
proposed_next_step={"action": "review_or_reclassify_task", "automatic": False},
**self._common(oid),
))
if ui_override:
supported = False
if displayed == "ASSOCIATE_OPPORTUNITY" and chain.explicit_blocker == "association":
supported = True
if displayed == "REVIEW_RECONSTRUCTED_PROCESS" and chain.explicit_blocker == "reconstructed_review":
supported = True
if not supported:
self.add(Finding(
category="policy", severity="medium", confidence="high", actionable=True,
code="UI_ACTION_OVERRIDE_WITHOUT_MATCHING_EXPLICIT_BLOCKER",
title="A UI substituiu a ação persistida sem existir o bloqueio explícito correspondente",
explanation=f"A task guarda {stored}, mas a fila mostra {displayed}; a regra de apresentação não está alinhada com o estado persistido.",
recommendation="Mover a precedência para o motor central e persistir o estado que justifica o override.",
root_cause="ui_only_action_override",
work_item_id=task_id, task_id=task_id,
displayed_action=displayed, stored_action=stored,
central_action=central, effective_action=effective,
evidence={"action_chain": asdict(chain), "item": item},
proposed_next_step={"action": "centralize_ui_override_policy", "automatic": False},
**self._common(oid),
))
central_payload = self.next_actions.get(oid, {})
if central_payload.get("can_execute") is False and _actions_match(displayed, central):
self.add(Finding(
category="confirmed", severity="high", confidence="high", actionable=True,
code="CENTRAL_BLOCKED_ACTION_VISIBLE",
title="Ação que o motor central declara bloqueada aparece como trabalho executável",
explanation="O mesmo action_code é mostrado, mas can_execute=false no motor central.",
recommendation="Mostrar o motivo e impedir conclusão até resolver o bloqueio.",
root_cause="central_block_not_enforced",
work_item_id=task_id, task_id=task_id,
displayed_action=displayed, stored_action=stored,
central_action=central, effective_action=effective,
evidence={"reason_if_blocked": central_payload.get("reason_if_blocked"), "central": central_payload},
proposed_next_step={"action": "enforce_central_can_execute", "automatic": False},
**self._common(oid),
))
def audit_task_coherence(self) -> None:
for oid, opportunity in self.opportunities.items():
pending = self.pending_tasks_by_opportunity.get(oid, [])
chain = self.action_chains.get(oid)
if not chain:
continue
pending_codes = [_canonical_action(task.get("action_code")) for task in pending]
effective = chain.effective_action
if len(pending) > 1:
competing = [code for code in pending_codes if not _actions_match(code, effective)]
severity = "high" if competing else "medium"
self.add(Finding(
category="confirmed" if competing else "needs_review",
severity=severity, confidence="high", actionable=True,
code="MULTIPLE_PENDING_TASKS_REQUIRE_ORDERING",
title="A oportunidade tem várias tasks pendentes",
explanation=(
"Existem várias ações humanas em paralelo. "
+ ("Pelo menos uma não corresponde à primeira ação segura." if competing else "As tasks parecem equivalentes/duplicadas.")
),
recommendation="Definir a primeira ação e resolver/adiar as restantes após revisão.",
root_cause="task_competition",
central_action=chain.central_action, effective_action=effective,
evidence={"task_ids": [_s(task.get("id")) for task in pending], "pending_codes": pending_codes, "competing": competing},
proposed_next_step={"action": "review_competing_tasks", "automatic": False},
**self._common(oid),
))
matching_pending = [task for task in pending if _actions_match(task.get("action_code"), effective)]
matching_visible = [
item for item in self.work_items
if _s(item.get("opportunity_id")) == oid and _actions_match(item.get("action_code"), effective)
]
if effective in NON_TASK_ACTIONS:
for task in pending:
code = _canonical_action(task.get("action_code"))
if code.startswith("FOLLOW_UP_") and self.visibility.get(_s(task.get("id")), {}).get("reason") == "scheduled_future_followup":
continue
self.add(Finding(
category="needs_review", severity="medium", confidence="medium", actionable=True,
code="PENDING_TASK_DURING_NON_ACTION_STATE",
title="Existe task pendente enquanto o motor indica espera/sem ação",
explanation=f"A decisão efetiva é {effective}, mas existe a task {code}.",
recommendation="Confirmar se a task é futura, obsoleta ou uma exceção de negócio.",
root_cause="stale_or_exception_task",
task_id=_s(task.get("id")), stored_action=code,
central_action=chain.central_action, effective_action=effective,
evidence={"task": task, "action_chain": asdict(chain)},
proposed_next_step={"action": "review_task_during_wait_state", "automatic": False},
**self._common(oid),
))
continue
if chain.materializable and chain.central_can_execute is not False and not matching_pending and not matching_visible:
self.add(Finding(
category="confirmed", severity="high", confidence="high", actionable=True,
code="MATERIALIZABLE_EFFECTIVE_ACTION_HAS_NO_TASK",
title="A primeira ação segura é materializável, mas não existe task",
explanation=f"{effective} pertence ao conjunto real MATERIALIZED_ACTIONS e está executável, mas não existe task pendente nem item visível.",
recommendation="Materializar uma única task idempotente após confirmar que não existe bloqueio anterior.",
root_cause="task_materialization_gap",
central_action=chain.central_action, effective_action=effective,
evidence={"materialized_actions": sorted(self.materialized_actions), "action_chain": asdict(chain)},
proposed_next_step={"action": "materialize_effective_action", "action_code": effective, "automatic": False},
**self._common(oid),
))
elif not chain.materializable and effective not in NON_TASK_ACTIONS and not matching_pending and not matching_visible:
# This is a policy fact, not a bug: several central actions are
# intentionally not materialized automatically.
self.add(Finding(
category="policy", severity="info", confidence="high", actionable=False,
code="CENTRAL_ACTION_NOT_COVERED_BY_TASK_MATERIALIZER",
title="Ação central sem task porque não pertence à política de materialização",
explanation=f"A decisão é {effective}, mas o materializador atual só cobre {', '.join(sorted(self.materialized_actions))}.",
recommendation="Decidir se a ação deve permanecer apenas no pipeline ou passar a gerar task.",
root_cause="task_materialization_policy",
central_action=chain.central_action, effective_action=effective,
evidence={"materialized_actions": sorted(self.materialized_actions), "action_chain": asdict(chain)},
proposed_next_step={"action": "review_materialization_policy", "automatic": False},
**self._common(oid),
))
def audit_association_surface(self) -> None:
visible_by_oid: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
for item in self.work_items:
oid = _s(item.get("opportunity_id"))
if oid:
visible_by_oid[oid].append(item)
for oid, assessments in self.associations_by_opportunity.items():
blockers = [assessment for assessment in assessments if assessment.blocks_opportunity]
if not blockers:
continue
pending = self.pending_tasks_by_opportunity.get(oid, [])
visible = visible_by_oid.get(oid, [])
has_association_task = any(_canonical_action(task.get("action_code")) == "ASSOCIATE_OPPORTUNITY" for task in pending)
has_association_visible = any(_canonical_action(item.get("action_code")) == "ASSOCIATE_OPPORTUNITY" for item in visible)
if not has_association_task and not has_association_visible:
self.add(Finding(
category="confirmed", severity="high", confidence="high", actionable=True,
code="EXPLICIT_ASSOCIATION_BLOCKER_NOT_SURFACED",
title="Associação atribuída bloqueia a oportunidade mas não aparece no Centro de Trabalho",
explanation="Existe um candidato atribuído/conflituoso que exige decisão, sem task nem cartão de associação.",
recommendation="Materializar uma task de associação ou mostrar o candidato diretamente na fila de revisão.",
root_cause="association_not_materialized",
association_item_id=blockers[0].item_id,
external_ref=blockers[0].external_ref,
central_action=self.action_chains.get(oid).central_action if oid in self.action_chains else "",
effective_action="ASSOCIATE_OPPORTUNITY",
evidence={"blockers": [asdict(assessment) for assessment in blockers]},
proposed_next_step={"action": "surface_association_decision", "automatic": False},
**self._common(oid),
))
for oid, pending in self.pending_tasks_by_opportunity.items():
assoc_tasks = [task for task in pending if _canonical_action(task.get("action_code")) == "ASSOCIATE_OPPORTUNITY"]
if not assoc_tasks:
continue
blockers = self._explicit_association_blockers(oid)
stale = [a for a in self.associations_by_opportunity.get(oid, []) if a.classification == "stale_already_linked"]
if not blockers:
category = "confirmed" if stale else "needs_review"
severity = "high" if stale else "medium"
self.add(Finding(
category=category, severity=severity, confidence="high" if stale else "medium", actionable=True,
code="ASSOCIATION_TASK_WITHOUT_UNRESOLVED_BLOCKER",
title="Existe task de associação sem candidato bloqueante por resolver",
explanation=(
"Os candidatos relacionados já estão exatamente ligados." if stale
else "Não foi encontrado candidato atribuído/conflituoso que justifique a task."
),
recommendation="Resolver candidatos obsoletos e depois rever/ignorar a task de associação.",
root_cause="stale_association_task",
task_id=_s(assoc_tasks[0].get("id")), stored_action="ASSOCIATE_OPPORTUNITY",
effective_action=self.action_chains.get(oid).effective_action if oid in self.action_chains else "",
evidence={"task_ids": [_s(task.get("id")) for task in assoc_tasks], "stale_candidates": [asdict(a) for a in stale]},
proposed_next_step={"action": "review_stale_association_task", "automatic": False},
**self._common(oid),
))
def build_dossiers(self) -> None:
finding_oids = {finding.opportunity_id for finding in self.findings if finding.opportunity_id}
visible_oids = {_s(item.get("opportunity_id")) for item in self.work_items if _s(item.get("opportunity_id"))}
candidate_oids = set(self.associations_by_opportunity)
selected = set(self.opportunities) if self.include_clean else finding_oids | visible_oids | candidate_oids
if self.focus:
selected = {
oid for oid in selected
if _focus_match(
self.focus,
oid,
self.opportunities.get(oid, {}).get("title"),
self.opportunities.get(oid, {}).get("customer_name"),
self.opportunities.get(oid, {}).get("fiscal_customer_name"),
*[a.external_ref for a in self.associations_by_opportunity.get(oid, [])],
)
}
for oid in sorted(selected):
opportunity = self.opportunities.get(oid, {})
snapshot = self.operation_snapshots.get(oid, {})
cards = []
for card in snapshot.get("cards", []) or []:
cards.append({
"system": card.get("system"),
"external_type": card.get("external_type"),
"external_id": card.get("external_id"),
"status": card.get("status"),
"title": card.get("title"),
"subtitle": card.get("subtitle"),
"payload": card.get("payload"),
})
self.dossiers[oid] = {
"opportunity": opportunity,
"action_chain": asdict(self.action_chains.get(oid)) if oid in self.action_chains else {},
"central_decision": self.next_actions.get(oid, {}),
"reconstructed_review": asdict(self.reconstructed.get(oid)) if oid in self.reconstructed else {},
"visible_work_items": [item for item in self.work_items if _s(item.get("opportunity_id")) == oid],
"pending_tasks": self.pending_tasks_by_opportunity.get(oid, []),
"recent_tasks": self.tasks_by_opportunity.get(oid, [])[:20],
"task_visibility": [
self.visibility.get(_s(task.get("id")), {})
for task in self.pending_tasks_by_opportunity.get(oid, [])
],
"association_assessments": [asdict(a) for a in self.associations_by_opportunity.get(oid, [])],
"documents": self.documents_by_opportunity.get(oid, []),
"operation_snapshot": {"cards": cards, "links": snapshot.get("links", []), "error": snapshot.get("error")},
"findings": [asdict(f) for f in self.findings if f.opportunity_id == oid],
}
def audit(self) -> None:
self.assess_associations()
self.assess_reconstructed()
self.build_action_chains()
self.assess_visibility()
self.audit_visible_actions()
self.audit_task_coherence()
self.audit_association_surface()
unique: Dict[Tuple[str, str, str, str, str], Finding] = {}
for finding in self.findings:
key = (
finding.code, finding.opportunity_id, finding.task_id,
finding.association_item_id, finding.effective_action,
)
unique.setdefault(key, finding)
self.findings = sorted(
unique.values(),
key=lambda finding: (
CATEGORY_ORDER.get(finding.category, 99),
SEVERITY_ORDER.get(finding.severity, 99),
CONFIDENCE_ORDER.get(finding.confidence, 99),
finding.customer_name.casefold(), finding.code,
),
)
self.build_dossiers()
categories = Counter(f.category for f in self.findings)
severities = Counter(f.severity for f in self.findings)
actionable = Counter("actionable" if f.actionable else "informational" for f in self.findings)
root_causes = Counter(f.root_cause or "other" for f in self.findings)
self.summary = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"project_root": str(PROJECT_ROOT),
"read_only": True,
"work_items_visible": len(self.work_items),
"operations_counts": self.operations_counts,
"open_opportunities_audited": len(self.opportunities),
"pending_tasks_audited": len(self.all_pending_tasks),
"open_reconciliation_candidates": len(self.association_assessments),
"dossiers": len(self.dossiers),
"findings": len(self.findings),
"categories": dict(categories),
"severity": dict(severities),
"actionability": dict(actionable),
"root_causes": dict(root_causes),
"confirmed_actionable": sum(1 for f in self.findings if f.category == "confirmed" and f.actionable),
"policy_only": sum(1 for f in self.findings if f.category == "policy"),
}
def _write_csvs(self, output_dir: Path) -> Dict[str, str]:
findings_path = output_dir / "forensic_findings.csv"
association_path = output_dir / "association_matrix.csv"
action_path = output_dir / "action_chain_matrix.csv"
visibility_path = output_dir / "task_visibility_matrix.csv"
fields = list(Finding.__dataclass_fields__.keys())
with findings_path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fields)
writer.writeheader()
for finding in self.findings:
row = asdict(finding)
row["evidence"] = json.dumps(row["evidence"], ensure_ascii=False, default=_json_default)
row["proposed_next_step"] = json.dumps(row["proposed_next_step"], ensure_ascii=False, default=_json_default)
writer.writerow(row)
association_fields = [
"item_id", "source_system", "external_type", "external_ref", "status",
"assigned_opportunity_id", "exact_link_opportunity_ids", "match_kind",
"classification", "blocks_opportunity", "confidence", "identity",
]
with association_path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=association_fields)
writer.writeheader()
for assessment in self.association_assessments:
row = asdict(assessment)
row["exact_link_opportunity_ids"] = ",".join(row["exact_link_opportunity_ids"])
row["identity"] = json.dumps(row["identity"], ensure_ascii=False, default=_json_default)
writer.writerow({key: row.get(key) for key in association_fields})
action_fields = list(ActionChain.__dataclass_fields__.keys())
with action_path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=action_fields)
writer.writeheader()
for chain in self.action_chains.values():
row = asdict(chain)
for key in ("association_blocker_ids", "visible_actions", "pending_actions"):
row[key] = ",".join(row[key])
writer.writerow(row)
visibility_fields = [
"task_id", "opportunity_id", "customer_name", "action_code", "due_at",
"route", "priority", "visible", "reason", "expected_hidden",
]
with visibility_path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=visibility_fields)
writer.writeheader()
for task_id, assessment in self.visibility.items():
task = assessment.get("task") or {}
oid = _s(task.get("opportunity_id"))
row = {
"task_id": task_id,
"opportunity_id": oid,
"customer_name": self._common(oid).get("customer_name"),
"action_code": _upper(task.get("action_code")),
"due_at": task.get("due_at"),
"route": task.get("route"),
"priority": task.get("priority"),
"visible": assessment.get("visible"),
"reason": assessment.get("reason"),
"expected_hidden": assessment.get("expected_hidden"),
}
writer.writerow(row)
return {
"findings_csv": str(findings_path),
"association_csv": str(association_path),
"action_chain_csv": str(action_path),
"visibility_csv": str(visibility_path),
}
def report(self, output_dir: Path) -> Dict[str, str]:
output_dir.mkdir(parents=True, exist_ok=True)
json_path = output_dir / "work_center_forensic_audit.json"
markdown_path = output_dir / "WORK_CENTER_FORENSIC_AUDIT.md"
dossiers_path = output_dir / "CASE_DOSSIERS.md"
plan_path = output_dir / "review_plan.json"
sql_path = output_dir / "diagnostic_queries.sql"
payload = {
"summary": self.summary,
"findings": [asdict(f) for f in self.findings],
"association_assessments": [asdict(a) for a in self.association_assessments],
"reconstructed_assessments": {oid: asdict(value) for oid, value in self.reconstructed.items()},
"action_chains": {oid: asdict(value) for oid, value in self.action_chains.items()},
"task_visibility": self.visibility,
"dossiers": self.dossiers,
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, default=_json_default), encoding="utf-8")
confirmed = [f for f in self.findings if f.category == "confirmed"]
needs_review = [f for f in self.findings if f.category == "needs_review"]
policy = [f for f in self.findings if f.category == "policy"]
lines = [
"# Auditoria forense do Centro de Trabalho e associações",
"",
f"Gerada em: `{self.summary.get('generated_at')}`",
f"Itens visíveis: **{self.summary.get('work_items_visible', 0)}**",
f"Oportunidades abertas: **{self.summary.get('open_opportunities_audited', 0)}**",
f"Tasks pendentes: **{self.summary.get('pending_tasks_audited', 0)}**",
f"Candidatos abertos: **{self.summary.get('open_reconciliation_candidates', 0)}**",
f"Achados confirmados: **{len(confirmed)}**",
f"Casos a rever: **{len(needs_review)}**",
f"Lacunas/políticas: **{len(policy)}**",
"",
"## Como interpretar",
"",
"- **Confirmado:** a base de dados contém evidência suficiente de incoerência.",
"- **A rever:** existe uma diferença, mas falta contexto para chamar bug.",
"- **Política:** comportamento explicado pelo desenho atual; requer decisão de produto, não correção de dados em massa.",
"",
]
for heading, group in (("Problemas confirmados", confirmed), ("Casos a rever", needs_review), ("Lacunas de política", policy)):
lines.extend([f"## {heading}", ""])
if not group:
lines.append("Nenhum.")
lines.append("")
continue
for index, finding in enumerate(group, 1):
lines.extend([
f"### {index}. [{finding.severity.upper()}] {finding.title}",
"",
f"- Código: `{finding.code}`",
f"- Confiança: **{finding.confidence}** · Acionável: **{'sim' if finding.actionable else 'não'}**",
f"- Cliente: {finding.customer_name or ''}",
f"- Oportunidade: `{finding.opportunity_id or ''}` {finding.opportunity_title}",
f"- Task: `{finding.task_id or finding.work_item_id or ''}`",
f"- Candidato: `{finding.association_item_id or ''}` · `{finding.external_ref or ''}`",
f"- Ação visível: `{finding.displayed_action or ''}`",
f"- Ação persistida: `{finding.stored_action or ''}`",
f"- Ação central: `{finding.central_action or ''}`",
f"- Primeira ação suportada: `{finding.effective_action or ''}`",
f"- Causa provável: `{finding.root_cause or ''}`",
f"- Explicação: {finding.explanation}",
f"- Recomendação: {finding.recommendation}",
f"- Evidência: `{json.dumps(finding.evidence, ensure_ascii=False, default=_json_default)}`",
"",
])
lines.extend([
"## Garantia de segurança",
"",
"O script não possui `--apply`. As consultas próprias usam uma transação PostgreSQL `READ ONLY`; os serviços chamados são de leitura.",
"",
])
markdown_path.write_text("\n".join(lines), encoding="utf-8")
dossier_lines = ["# Dossiers por oportunidade", ""]
for oid, dossier in self.dossiers.items():
opp = dossier.get("opportunity") or {}
chain = dossier.get("action_chain") or {}
dossier_lines.extend([
f"## {dossier.get('opportunity', {}).get('fiscal_customer_name') or opp.get('customer_name') or oid}",
"",
f"- ID: `{oid}`",
f"- Título: {opp.get('title') or ''}",
f"- Fase: `{opp.get('stage') or ''}`",
f"- Ação central: `{chain.get('central_action') or ''}`",
f"- Bloqueio explícito: `{chain.get('explicit_blocker') or 'nenhum'}`",
f"- Primeira ação suportada: `{chain.get('effective_action') or ''}`",
f"- Estado reconstruído: `{chain.get('reconstructed_state') or ''}`",
f"- Tasks pendentes: `{', '.join(chain.get('pending_actions') or []) or 'nenhuma'}`",
f"- Ações visíveis: `{', '.join(chain.get('visible_actions') or []) or 'nenhuma'}`",
"",
"### Associações",
"",
])
associations = dossier.get("association_assessments") or []
if not associations:
dossier_lines.append("Sem candidatos relacionados.")
for assessment in associations:
dossier_lines.append(
f"- `{assessment.get('external_ref') or assessment.get('item_id')}`: "
f"{assessment.get('classification')} · bloqueia={assessment.get('blocks_opportunity')} · "
f"links={assessment.get('exact_link_opportunity_ids')}"
)
dossier_lines.extend(["", "### Tasks pendentes", ""])
pending = dossier.get("pending_tasks") or []
if not pending:
dossier_lines.append("Sem tasks pendentes.")
for task in pending:
visibility = self.visibility.get(_s(task.get("id")), {})
dossier_lines.append(
f"- `{task.get('id')}` `{task.get('action_code')}` · rota={task.get('route')} · "
f"due={task.get('due_at')} · visível={visibility.get('visible')} ({visibility.get('reason')})"
)
dossier_lines.extend(["", "### Documentos", ""])
docs = dossier.get("documents") or []
if not docs:
dossier_lines.append("Sem documentos ligados.")
for doc in docs:
dossier_lines.append(
f"- `{doc.get('document_kind')}` `{doc.get('document_number') or doc.get('external_id')}` "
f"· {doc.get('total_amount')} {doc.get('currency')} · role={doc.get('role')} · active={doc.get('is_active')}"
)
dossier_lines.extend(["", "### Estado operacional", ""])
cards = (dossier.get("operation_snapshot") or {}).get("cards") or []
if not cards:
dossier_lines.append("Sem cards operacionais.")
for card in cards:
dossier_lines.append(
f"- `{card.get('system')}/{card.get('external_type')}` `{card.get('external_id')}` · "
f"status={card.get('status')} · {card.get('title') or ''} {card.get('subtitle') or ''}"
)
dossier_lines.append("")
dossiers_path.write_text("\n".join(dossier_lines), encoding="utf-8")
review_plan = {
"generated_at": self.summary.get("generated_at"),
"read_only": True,
"warning": "Não aplicar automaticamente. Usar os guardas e dossiers para preparar correções específicas.",
"confirmed_actionable": [
{
"code": f.code,
"opportunity_id": f.opportunity_id,
"task_id": f.task_id,
"association_item_id": f.association_item_id,
"external_ref": f.external_ref,
"effective_action": f.effective_action,
"next_step": f.proposed_next_step,
"evidence": f.evidence,
}
for f in confirmed if f.actionable
],
"needs_policy_decision": [
{
"code": f.code,
"opportunity_id": f.opportunity_id,
"central_action": f.central_action,
"next_step": f.proposed_next_step,
}
for f in policy
],
}
plan_path.write_text(json.dumps(review_plan, ensure_ascii=False, indent=2, default=_json_default), encoding="utf-8")
sql_path.write_text("""-- Diagnóstico forense complementar (executar manualmente em READ ONLY)
-- 1. Cadeia de tasks por oportunidade, incluindo metadata e eventos
SELECT o.customer_name, o.id AS opportunity_id, o.stage,
t.id AS task_id, t.action_code, t.status, t.route, t.priority,
t.due_at, t.created_at, t.done_at, t.metadata,
COALESCE(json_agg(json_build_object(
'event_type', te.event_type, 'created_at', te.created_at,
'created_by', te.created_by, 'payload', te.payload
) ORDER BY te.created_at) FILTER (WHERE te.id IS NOT NULL), '[]') AS events
FROM opportunities o
LEFT JOIN tasks t ON t.opportunity_id = o.id
LEFT JOIN task_events te ON te.task_id = t.id
WHERE o.status = 'open'
GROUP BY o.customer_name, o.id, o.stage, t.id
ORDER BY o.customer_name, t.created_at;
-- 2. Candidatos abertos e ligações exatas potenciais
SELECT ri.id AS candidate_id, ri.source_system, ri.external_type,
ri.external_id, ri.document_number, ri.opportunity_id AS assigned_opportunity,
ri.customer_name, ri.customer_tax_id, ri.status,
ol.id AS link_id, ol.opportunity_id AS linked_opportunity,
ol.external_id AS link_external_id, ol.external_name AS link_external_name,
ol.status AS link_status
FROM reconciliation_items ri
LEFT JOIN operation_links ol
ON lower(ol.system) = lower(ri.source_system)
AND COALESCE(ol.status, '') NOT IN ('ignored','deleted')
AND (
lower(COALESCE(ol.external_id,'')) IN (lower(COALESCE(ri.external_id,'')), lower(COALESCE(ri.document_number,'')))
OR lower(COALESCE(ol.external_name,'')) IN (lower(COALESCE(ri.external_id,'')), lower(COALESCE(ri.document_number,'')))
)
WHERE ri.status IN ('open','needs_review','conflict')
ORDER BY ri.updated_at DESC;
-- 3. Registos reconstruídos sem estado persistido de revisão
SELECT id, customer_name, title, stage,
metadata->>'clientflow_record_mode' AS record_mode,
metadata->>'historical_evidence_review_required' AS review_required,
metadata->>'reconstructed_process_validated' AS review_validated
FROM opportunities
WHERE status = 'open'
AND metadata->>'clientflow_record_mode' IN (
'reconstructed_invoice_review','historical_reconstructed','legacy_review'
)
ORDER BY updated_at DESC;
-- 4. Tasks pendentes que deveriam ser elegíveis para o Centro de Trabalho
SELECT t.id, t.opportunity_id, o.customer_name, t.action_code,
t.route, t.priority, t.due_at, t.source_system, t.metadata
FROM tasks t
LEFT JOIN opportunities o ON o.id = t.opportunity_id
WHERE t.status = 'pending'
AND NOT (t.action_code LIKE 'FOLLOW_UP_%' AND t.due_at IS NOT NULL AND t.due_at > now())
ORDER BY
CASE lower(COALESCE(t.priority,'normal'))
WHEN 'urgente' THEN 0 WHEN 'alta' THEN 1 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3
END,
t.created_at DESC;
""", encoding="utf-8")
paths = {
"json": str(json_path),
"markdown": str(markdown_path),
"dossiers": str(dossiers_path),
"review_plan": str(plan_path),
"diagnostic_sql": str(sql_path),
}
paths.update(self._write_csvs(output_dir))
return paths
def _self_test() -> None:
assert _canonical_action("SHIP_ORDER") == "CREATE_SHIPMENT"
assert _actions_match("SHIP_ORDER", "CREATE_SHIPMENT")
assert _actions_match("RECONCILE_DOCUMENTS", "ASSOCIATE_OPPORTUNITY")
assert not _actions_match("SEND_INVOICE", "CONFIRM_PAYMENT")
assert _reference_variants("S00325", system="odoo", external_type="sale_order") == {"s00325", "s325"}
assert "323" not in _reference_variants("S00323", system="odoo", external_type="sale_order")
assert _norm_tax_id("PT 510 177 441") == "510177441"
assert _is_legacy_record({"clientflow_record_mode": "historical_reconstructed"})
assert not _is_legacy_record({"clientflow_record_mode": "normal"})
assert _truthy_metadata({"historical_evidence_review_required": True}, {"historical_evidence_review_required"})
assert _falsey_metadata({"historical_evidence_review_required": False}, {"historical_evidence_review_required"})
print("Self-test OK")
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--limit", type=int, default=500, help="Máximo de itens normalizados do Centro de Trabalho.")
parser.add_argument("--opportunity-limit", type=int, default=1000, help="Máximo de oportunidades abertas.")
parser.add_argument("--focus", action="append", default=[], help="Filtrar relatório por cliente, UUID, referência ou código; repetível.")
parser.add_argument("--include-clean", action="store_true", help="Incluir dossiers de oportunidades sem achados.")
parser.add_argument("--output-dir", default="", help="Diretório de saída.")
parser.add_argument(
"--fail-on",
choices=["confirmed_high", "critical", "high", "medium", "low", "info", "never"],
default="confirmed_high",
help="Código 2 quando existe achado nesse limiar. confirmed_high ignora policy/needs_review.",
)
parser.add_argument("--terminal-limit", type=int, default=80, help="Máximo de achados mostrados no terminal.")
parser.add_argument("--self-test", action="store_true")
return parser
def main() -> int:
args = _parser().parse_args()
if args.self_test:
_self_test()
return 0
auditor = ForensicAuditor(
limit=args.limit,
opportunity_limit=args.opportunity_limit,
focus=args.focus,
include_clean=args.include_clean,
)
auditor.load()
auditor.audit()
if args.output_dir:
output_dir = Path(args.output_dir)
else:
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_dir = PROJECT_ROOT / "audit_reports" / f"work_center_forensic_{stamp}"
paths = auditor.report(output_dir)
summary = auditor.summary
print("Auditoria forense do Centro de Trabalho em modo READ ONLY")
print(f"Itens visíveis: {summary.get('work_items_visible', 0)}")
print(f"Oportunidades abertas: {summary.get('open_opportunities_audited', 0)}")
print(f"Tasks pendentes: {summary.get('pending_tasks_audited', 0)}")
print(f"Candidatos abertos: {summary.get('open_reconciliation_candidates', 0)}")
print(f"Achados confirmados: {summary.get('categories', {}).get('confirmed', 0)}")
print(f"A rever: {summary.get('categories', {}).get('needs_review', 0)}")
print(f"Política: {summary.get('categories', {}).get('policy', 0)}")
terminal_limit = max(1, int(args.terminal_limit))
for finding in auditor.findings[:terminal_limit]:
left = f"{finding.category.upper():12} | {finding.severity.upper():8} | {finding.code:52}"
action = f"{finding.displayed_action or finding.stored_action or '-'} -> {finding.effective_action or '-'}"
print(f"{left} | {action:42} | {finding.customer_name or finding.external_ref or '-'}")
if len(auditor.findings) > terminal_limit:
print(f"... {len(auditor.findings) - terminal_limit} achado(s) adicionais no relatório.")
print("Relatórios:")
for key, value in paths.items():
print(f" {key}: {value}")
if args.fail_on == "never":
return 0
if args.fail_on == "confirmed_high":
if any(
finding.category == "confirmed"
and finding.severity in {"critical", "high"}
for finding in auditor.findings
):
return 2
return 0
threshold = args.fail_on
if any(SEVERITY_ORDER.get(f.severity, 99) <= SEVERITY_ORDER.get(threshold, -1) for f in auditor.findings):
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())