From 47f21888f906f3667dcb4984361ccb1cb6522d71 Mon Sep 17 00:00:00 2001 From: plx Date: Sat, 15 Aug 2026 09:47:19 +0000 Subject: [PATCH] fix: align opportunity UI with canonical current action --- app/admin_ui/pages/opportunities.py | 21 +- app/opportunity_service.py | 3 +- scripts/audit_operational_consistency.py | 292 ++++++++++++++++++ ..._opportunity_current_action_consistency.py | 58 ++++ ...est_v4928_1_5_132_operational_coherence.py | 4 +- 5 files changed, 366 insertions(+), 12 deletions(-) create mode 100644 scripts/audit_operational_consistency.py create mode 100644 tests/test_opportunity_current_action_consistency.py diff --git a/app/admin_ui/pages/opportunities.py b/app/admin_ui/pages/opportunities.py index 6f93f52..32609f2 100644 --- a/app/admin_ui/pages/opportunities.py +++ b/app/admin_ui/pages/opportunities.py @@ -1724,13 +1724,12 @@ def _opportunity_card_next_action(opp: dict) -> str: if reconstructed_review_required(metadata): return "Validar processo reconstruído" - pending_code = canonical_action_code(opp.get("pending_primary_action_code")) - if pending_code: - return str(opp.get("pending_primary_action") or primary_action_label(pending_code, fallback="Ver tarefa pendente")) - central = _central_next_action_for_card(opp) if central.get("label"): return str(central.get("label") or "") + pending_code = canonical_action_code(opp.get("pending_primary_action_code")) + if pending_code: + return str(opp.get("pending_primary_action") or primary_action_label(pending_code, fallback="Ver tarefa pendente")) if int(opp.get("pending_task_count") or 0) > 0: action_code = str(opp.get("last_action_code") or "").strip() return primary_action_label(action_code, fallback="Ver tarefa pendente") @@ -1765,9 +1764,9 @@ def _opportunity_board_column_for_opportunity(opp: dict) -> str: if reconstructed_review_required(metadata): return "requests" - pending_code = canonical_action_code(opp.get("pending_primary_action_code")) central_code = canonical_action_code(_central_next_action_for_card(opp).get("action_code")) - effective_code = pending_code or central_code + pending_code = canonical_action_code(opp.get("pending_primary_action_code")) + effective_code = central_code or pending_code if effective_code in {"SEND_INVOICE", "SEND_PROFORMA", "CONFIRM_PAYMENT", "FOLLOW_UP_PAYMENT"}: return "payment" @@ -1776,7 +1775,11 @@ def _opportunity_board_column_for_opportunity(opp: dict) -> str: "CLOSE_OPPORTUNITY", "VALIDATE_PHYSICAL_ORDER", }: return "operations" - if effective_code in {"ASSOCIATE_OPPORTUNITY", "REVIEW_ASSOCIATION", "LINK_DOCUMENT", "REVIEW_RECONSTRUCTED_PROCESS"}: + if effective_code in { + "ASSOCIATE_OPPORTUNITY", "REVIEW_ASSOCIATION", "LINK_DOCUMENT", + "REVIEW_RECONSTRUCTED_PROCESS", "RECONCILE_DOCUMENTS", + "VALIDATE_FISCAL_CUSTOMER", + }: return "requests" return _opportunity_board_column_for_stage(opp.get("stage")) @@ -2081,7 +2084,7 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N "target_url": f"/tasks/{review_task.get('id')}" if review_task else f"/opportunities/{opportunity_id}", "source": "explicit_reconstructed_review", } - elif not lifecycle_override and next_task: + elif not lifecycle_override and not next_action and next_task: next_action = { "action_code": str(next_task.get("action_code") or "REVIEW_MANUALLY"), "label": str(next_task.get("action") or primary_action_label(next_task.get("action_code"))), @@ -2103,7 +2106,7 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N primary_action = next_action.get("label") or action_label(next_action.get("action_code")) primary_note = _safe_opportunity_task_text(next_action.get("description") or "Continuar a próxima ação recomendada.") action_code_upper = str(next_action.get("action_code") or "").upper() - target_url = next_action.get("target_url") or (f"/tasks/{next_task.get('id')}" if next_task else "/tasks?status=pending") + target_url = next_action.get("target_url") or f"/opportunities/{opportunity_id}" if str(target_url).startswith("/tasks/") and "return_to=" not in str(target_url): sep = "&" if "?" in str(target_url) else "?" target_url = f"{target_url}{sep}return_to={quote(opportunity_return_to, safe='')}" diff --git a/app/opportunity_service.py b/app/opportunity_service.py index 960783f..a70ca9d 100644 --- a/app/opportunity_service.py +++ b/app/opportunity_service.py @@ -1512,7 +1512,8 @@ def list_opportunities( status: Optional[str] = None, limit: int = 300, ) -> List[Dict[str, Any]]: - ensure_opportunity_schema() + # Read paths must not attempt startup/migration DDL. The application + # schema is established explicitly before serving requests. # A oportunidade mantém dois conceitos diferentes: # - o.customer_*: contacto/origem captado da conversa/tarefa; # - o.local_customer_id -> customers: ficha fiscal usada em Jasmin/documentos. diff --git a/scripts/audit_operational_consistency.py b/scripts/audit_operational_consistency.py new file mode 100644 index 0000000..329e718 --- /dev/null +++ b/scripts/audit_operational_consistency.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +"""Read-only audit of operator-action consistency. + +The script deliberately refuses any database that is not transaction read-only. +It is intended to be run against the isolated production snapshot before and +after read-model/UI changes, with identical detection rules. +""" +from __future__ import annotations + +import argparse +import json +from collections import Counter, defaultdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from sqlalchemy import text + +from app.db import engine +from app.operations_service import get_operations_summary +from app.opportunity_next_action_service import get_opportunity_next_actions +from app.opportunity_service import list_opportunities +from app.work_center_action_policy import canonical_action_code, reconstructed_review_required + + +CLOSED_STATUSES = {"closed", "lost", "won", "completed", "cancelled", "canceled", "spam", "archived"} +FOLLOWUP_CODES = { + "CALL_CUSTOMER", "CONFIRM_DELIVERY", "FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA", + "FOLLOW_UP_PAYMENT", "FOLLOW_UP_CUSTOMER_REVIEW", "FOLLOW_UP_GENERIC", + "RECOVER_OPPORTUNITY", "REVIEW_NURTURE", +} +RESPONSE_CODES = {"SEND_INFO", "SUPPORT", "SEND_QUOTE", "SEND_PROFORMA", "SEND_INVOICE"} +FINANCIAL_CODES = {"SEND_INVOICE", "CREATE_JASMIN_INVOICE", "CONFIRM_PAYMENT", "SEND_PROFORMA"} +SEVERITY = { + "ACTION_SOURCE_DIVERGENCE": "CRITICAL", + "DETAIL_ACTION_DIVERGENCE": "CRITICAL", + "STALE_PENDING_TASK": "HIGH", + "MULTIPLE_COMPETING_PENDING_TASKS": "HIGH", + "FOLLOWUP_SATISFIED_BY_CUSTOMER_INBOUND": "HIGH", + "RESPONSE_TASK_ALREADY_SATISFIED": "HIGH", + "CLOSED_PROCESS_WITH_PENDING_TASK": "HIGH", + "ACTION_BLOCKER_CONTRADICTION": "CRITICAL", + "DOCUMENT_ACTION_WITHOUT_REQUIRED_DOCUMENT": "CRITICAL", + "SCHEDULED_FOLLOWUP_SOURCE_DIVERGENCE": "HIGH", + "OPERATIONS_FALSE_POSITIVE": "HIGH", + "OPERATIONS_FALSE_NEGATIVE": "HIGH", + "STAGE_ACTION_CONTRADICTION": "MEDIUM", + "DUPLICATE_CURRENT_OBLIGATION": "HIGH", + "TASK_STATUS_TIMELINE_CONTRADICTION": "HIGH", +} + + +def _s(value: Any) -> str: + return str(value or "").strip() + + +def _dt(value: Any): + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + if not value: + return None + try: + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + except ValueError: + return None + + +def _jsonable(value: Any): + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, dict): + return {k: _jsonable(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_jsonable(v) for v in value] + return value + + +def _detail_primary_action(opp: dict, decision: dict, pending: list[dict]) -> tuple[str, str]: + """Mirror the current detail-page precedence without rendering HTML.""" + metadata = opp.get("metadata") if isinstance(opp.get("metadata"), dict) else {} + if reconstructed_review_required(metadata): + return "REVIEW_RECONSTRUCTED_PROCESS", "explicit_reconstructed_review" + lifecycle = _s(opp.get("lifecycle_state")).lower() + call = next((t for t in pending if canonical_action_code(t.get("action_code")) == "CALL_CUSTOMER"), None) + if call: + due = _dt(call.get("due_at")) + lifecycle = "follow_up_due" if due and due <= datetime.now(timezone.utc) else "scheduled_follow_up" + elif lifecycle == "scheduled_follow_up": + lifecycle = "active" + lifecycle_task = next((t for t in pending if canonical_action_code(t.get("action_code")) in FOLLOWUP_CODES), None) + if lifecycle_task and lifecycle in {"follow_up_due", "scheduled_follow_up", "recovery", "nurture"}: + return canonical_action_code(lifecycle_task.get("action_code")), "lifecycle_task" + decision_code = canonical_action_code(decision.get("action_code")) + if decision_code: + return decision_code, "opportunity_decision" + if pending: + return canonical_action_code(pending[0].get("action_code")), "pending_task" + return "", "none" + + +def collect() -> dict: + with engine.connect() as conn: + identity = conn.execute(text( + "SELECT current_database(), current_user, current_setting('transaction_read_only')" + )).one() + if identity[2] != "on": + raise RuntimeError(f"audit requires transaction_read_only=on, got {identity!r}") + task_rows = conn.execute(text(""" + SELECT t.id::text, t.opportunity_id::text, t.action_code, t.action, + t.status, t.due_at, t.created_at, t.updated_at, t.done_at, + t.source_system, COALESCE(t.metadata, '{}'::jsonb) AS metadata + FROM tasks t WHERE t.opportunity_id IS NOT NULL + ORDER BY t.opportunity_id, t.created_at, t.id + """)).mappings().all() + timeline_rows = conn.execute(text(""" + SELECT o.id::text AS opportunity_id, + max(m.created_at) FILTER (WHERE m.direction='inbound') AS latest_inbound, + max(m.created_at) FILTER (WHERE m.direction='outbound') AS latest_outbound + FROM opportunities o + LEFT JOIN messages m ON m.conversation_id=o.conversation_id + AND m.source_system IN ('chatwoot','chatwoot_backfill') + GROUP BY o.id + """)).mappings().all() + recon_rows = conn.execute(text(""" + SELECT opportunity_id::text, status, suggested_action, source_system, + created_at, COALESCE(payload, '{}'::jsonb) AS payload + FROM reconciliation_items WHERE opportunity_id IS NOT NULL + ORDER BY opportunity_id, created_at DESC + """)).mappings().all() + document_rows = conn.execute(text(""" + SELECT opportunity_id::text, to_jsonb(commercial_documents) AS document + FROM commercial_documents WHERE opportunity_id IS NOT NULL + ORDER BY opportunity_id, created_at DESC + """)).mappings().all() + + opportunities = list_opportunities(status="all", limit=1000) + ids = [_s(o.get("id")) for o in opportunities] + decisions = get_opportunity_next_actions(ids) + operations = get_operations_summary(limit=2000) + canonical = {} + all_operation_items = [] + for key in ("work_items", "waiting_items", "backlog_items", "not_current_items"): + for item in operations.get(key, []): + all_operation_items.append(item) + if item.get("opportunity_id"): + canonical[_s(item.get("opportunity_id"))] = item + + tasks_by_opp: dict[str, list[dict]] = defaultdict(list) + for row in task_rows: + tasks_by_opp[_s(row["opportunity_id"])].append(dict(row)) + timelines = {_s(r["opportunity_id"]): dict(r) for r in timeline_rows} + recon_by_opp: dict[str, list[dict]] = defaultdict(list) + for row in recon_rows: + recon_by_opp[_s(row["opportunity_id"])].append(dict(row)) + docs_by_opp: dict[str, list[dict]] = defaultdict(list) + for row in document_rows: + docs_by_opp[_s(row["opportunity_id"])].append(dict(row["document"] or {})) + + findings = [] + records = [] + now = datetime.now(timezone.utc) + + def add(family: str, oid: str, detail: str, **evidence): + findings.append({ + "family": family, "severity": SEVERITY[family], "opportunity_id": oid, + "detail": detail, "evidence": _jsonable(evidence), + }) + + for opp in opportunities: + oid = _s(opp.get("id")) + decision = decisions.get(oid) or {} + decision_code = canonical_action_code(decision.get("action_code")) + item = canonical.get(oid) + operation_code = canonical_action_code(item.get("current_action_code")) if item else "" + all_tasks = tasks_by_opp.get(oid, []) + pending = [t for t in all_tasks if _s(t.get("status")).lower() == "pending"] + detail_code, detail_source = _detail_primary_action(opp, decision, pending) + timeline = timelines.get(oid, {}) + latest_in = _dt(timeline.get("latest_inbound")) + latest_out = _dt(timeline.get("latest_outbound")) + stale_ids = {_s(r.get("task_id") or r.get("id")) for r in (item or {}).get("stale_task_refs", [])} + docs = docs_by_opp.get(oid, []) + recons = recon_by_opp.get(oid, []) + active_call = next((t for t in pending if canonical_action_code(t.get("action_code")) == "CALL_CUSTOMER"), None) + + if item and operation_code != decision_code and operation_code != "CALL_CUSTOMER": + add("ACTION_SOURCE_DIVERGENCE", oid, "Operations action differs from OpportunityDecision", decision=decision_code, operations=operation_code) + if detail_code and decision_code and detail_code != decision_code and detail_source == "pending_task": + add("DETAIL_ACTION_DIVERGENCE", oid, "Detail primary action is overridden by a pending task", decision=decision_code, detail_action=detail_code) + for task in pending: + if _s(task.get("id")) in stale_ids: + add("STALE_PENDING_TASK", oid, "Canonical projection marks pending task as stale", task_id=task["id"], task_action=task["action_code"], current_action=operation_code or decision_code) + codes = [canonical_action_code(t.get("action_code")) for t in pending] + if len(set(codes)) > 1: + add("MULTIPLE_COMPETING_PENDING_TASKS", oid, "Opportunity has multiple distinct pending action codes", task_codes=codes, current_action=operation_code or decision_code) + duplicates = {code: count for code, count in Counter(codes).items() if code and count > 1} + if duplicates: + add("DUPLICATE_CURRENT_OBLIGATION", oid, "Duplicate pending action codes", duplicates=duplicates) + for task in pending: + code = canonical_action_code(task.get("action_code")) + created = _dt(task.get("created_at")) + if code.startswith("FOLLOW_UP_") and latest_in and created and latest_in > created: + add("FOLLOWUP_SATISFIED_BY_CUSTOMER_INBOUND", oid, "Customer inbound is later than pending follow-up", task_id=task["id"], task_action=code, task_created=created, latest_inbound=latest_in) + add("TASK_STATUS_TIMELINE_CONTRADICTION", oid, "Pending follow-up predates later customer inbound", task_id=task["id"], event="inbound") + if code in RESPONSE_CODES and latest_out and created and latest_out > created: + add("RESPONSE_TASK_ALREADY_SATISFIED", oid, "Public outbound is later than pending response task", task_id=task["id"], task_action=code, task_created=created, latest_outbound=latest_out) + add("TASK_STATUS_TIMELINE_CONTRADICTION", oid, "Pending response task predates later public outbound", task_id=task["id"], event="outbound") + status = _s(opp.get("status")).lower() + stage = _s(opp.get("stage")).lower() + lifecycle = _s(opp.get("lifecycle_state")).lower() + if pending and ({status, stage, lifecycle} & CLOSED_STATUSES): + add("CLOSED_PROCESS_WITH_PENDING_TASK", oid, "Closed/inactive process has pending tasks", status=status, stage=stage, lifecycle=lifecycle, task_codes=codes) + if decision_code in {"RECONCILE_DOCUMENTS", "VALIDATE_FISCAL_CUSTOMER"}: + contradicted = [c for c in codes if c in FINANCIAL_CODES or c in {"CREATE_JASMIN_QUOTE", "SEND_QUOTE"}] + if contradicted: + add("ACTION_BLOCKER_CONTRADICTION", oid, "Pending task proposes downstream action while decision requires prerequisite", blocker=decision_code, downstream=contradicted, detail_action=detail_code) + if detail_code in FINANCIAL_CODES and decision_code in {"RECONCILE_DOCUMENTS", "VALIDATE_FISCAL_CUSTOMER"}: + add("DOCUMENT_ACTION_WITHOUT_REQUIRED_DOCUMENT", oid, "Detail exposes document action despite structured prerequisite", detail_action=detail_code, prerequisite=decision_code) + scheduled_mirror = lifecycle == "scheduled_follow_up" or bool(opp.get("next_follow_up_at")) + if scheduled_mirror != bool(active_call): + add("SCHEDULED_FOLLOWUP_SOURCE_DIVERGENCE", oid, "Compatibility schedule fields disagree with active CALL_CUSTOMER task", lifecycle=lifecycle, next_follow_up_at=opp.get("next_follow_up_at"), active_call=bool(active_call)) + if item and _s(item.get("operational_queue")) in {"do_now", "review", "exception"}: + refs = item.get("eligibility", {}).get("obligation_source_refs", []) + if not refs and item.get("source") != "outbox" and decision_code in {"NO_ACTION", "NOT_FOUND", "FOLLOW_UP"}: + add("OPERATIONS_FALSE_POSITIVE", oid, "Actionable Operations item lacks a strong explicit obligation", action=operation_code, queue=item.get("operational_queue")) + if item and _s(item.get("operational_queue")) in {"waiting", "backlog", "not_current"} and pending: + non_stale = [t for t in pending if _s(t.get("id")) not in stale_ids] + if non_stale and not (operation_code == "CALL_CUSTOMER" and _dt(active_call.get("due_at") if active_call else None) and _dt(active_call.get("due_at")) > now): + add("OPERATIONS_FALSE_NEGATIVE", oid, "Demoted Operations item retains explicit non-stale pending obligation", action=operation_code, queue=item.get("operational_queue"), task_codes=[t["action_code"] for t in non_stale]) + if detail_code != decision_code and _s(opp.get("stage")).upper() in {"INVOICE_REQUESTED", "PROFORMA_SENT", "QUOTE_SENT", "PAYMENT_CONFIRMED"}: + add("STAGE_ACTION_CONTRADICTION", oid, "Presentation follows stage/task rather than structured decision", stage=opp.get("stage"), detail_action=detail_code, decision=decision_code) + + records.append(_jsonable({ + "opportunity_id": oid, "title": opp.get("title"), "customer": opp.get("linked_customer_name") or opp.get("customer_name"), + "status": opp.get("status"), "commercial_stage": opp.get("stage"), "lifecycle_state": opp.get("lifecycle_state"), + "next_follow_up_at": opp.get("next_follow_up_at"), "decision_action": decision_code, + "decision_reason": decision.get("reason"), "decision_can_execute": decision.get("can_execute"), + "operations_action": operation_code or None, "operational_queue": (item or {}).get("operational_queue"), + "eligibility_reason": (item or {}).get("eligibility_reason_code"), "pending_tasks": pending, + "stale_task_refs": (item or {}).get("stale_task_refs", []), "latest_public_inbound": latest_in, + "latest_public_outbound": latest_out, "active_call_customer_task": active_call, + "financial_state": decision.get("financial_state"), "document_state": [dict(d) for d in docs], + "payment_state": decision.get("financial_state"), "reconciliation_state": recons, + "primary_detail_action": detail_code, "primary_detail_source": detail_source, + })) + + counts = Counter(f["family"] for f in findings) + severity_counts = Counter(f["severity"] for f in findings) + ranked = sorted(findings, key=lambda f: ({"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}[f["severity"]], f["family"], f["opportunity_id"])) + return _jsonable({ + "generated_at": datetime.now(timezone.utc), + "database": {"name": identity[0], "user": identity[1], "transaction_read_only": identity[2]}, + "summary": { + "opportunities": len(opportunities), "tasks": len(task_rows), + "canonical_total": operations.get("canonical_count"), "work_queue_total": operations.get("counts", {}).get("work_queue_total"), + "families": dict(sorted(counts.items())), "severities": dict(severity_counts), + }, + "top_20": ranked[:20], "findings": ranked, "opportunities": records, + "operations_diagnostics": operations.get("projection_metrics", {}), + }) + + +def render_text(report: dict) -> str: + lines = [ + "ClientFlow operational consistency audit", + f"Generated: {report['generated_at']}", + f"Database: {report['database']}", + "", + "Summary", + json.dumps(report["summary"], ensure_ascii=False, indent=2), + "", + "Top 20 findings", + ] + for finding in report["top_20"]: + lines.append(f"[{finding['severity']}] {finding['family']} {finding['opportunity_id']}: {finding['detail']} | {json.dumps(finding['evidence'], ensure_ascii=False)}") + return "\n".join(lines) + "\n" + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--json", required=True) + parser.add_argument("--text", required=True) + args = parser.parse_args() + report = collect() + Path(args.json).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + Path(args.text).write_text(render_text(report), encoding="utf-8") + print(json.dumps(report["summary"], ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_opportunity_current_action_consistency.py b/tests/test_opportunity_current_action_consistency.py new file mode 100644 index 0000000..df6125c --- /dev/null +++ b/tests/test_opportunity_current_action_consistency.py @@ -0,0 +1,58 @@ +from pathlib import Path +from inspect import getsource + +from app.admin_ui.pages.opportunities import ( + _opportunity_board_column_for_opportunity, + _opportunity_card_next_action, +) + + +def opportunity(**overrides): + item = { + "id": "opp-1", + "stage": "INVOICE_REQUESTED", + "status": "open", + "lifecycle_state": "active", + "pending_task_count": 2, + "pending_primary_action_code": "SEND_INVOICE", + "pending_primary_action": "Enviar fatura ao cliente", + "clientflow_next_action": { + "action_code": "RECONCILE_DOCUMENTS", + "label": "Associar orçamento enviado", + "description": "Documento enviado ainda não associado.", + }, + "metadata": {}, + } + item.update(overrides) + return item + + +def test_board_primary_action_uses_structured_decision_not_stale_pending_task(): + assert _opportunity_card_next_action(opportunity()) == "Associar orçamento enviado" + + +def test_board_column_uses_structured_decision_not_stale_financial_task(): + assert _opportunity_board_column_for_opportunity(opportunity()) != "payment" + + +def test_detail_does_not_replace_structured_decision_with_arbitrary_pending_task(): + source = Path("app/admin_ui/pages/opportunities.py").read_text(encoding="utf-8") + detail = source[source.index("async def opportunity_detail_page"):] + assert "elif not lifecycle_override and next_task:" not in detail + + +def test_explicit_scheduled_call_remains_a_valid_primary_override(): + opp = opportunity( + lifecycle_state="active", + pending_follow_up_action_code="CALL_CUSTOMER", + pending_follow_up_action="Ligar ao cliente", + pending_follow_up_due_at="2099-08-21T09:00:00+00:00", + ) + assert _opportunity_card_next_action(opp).startswith("Ligar ao cliente em") + + +def test_opportunity_list_read_path_does_not_attempt_schema_writes(): + from app.opportunity_service import list_opportunities + + source = getsource(list_opportunities) + assert "ensure_opportunity_schema()" not in source diff --git a/tests/test_v4928_1_5_132_operational_coherence.py b/tests/test_v4928_1_5_132_operational_coherence.py index 26961f5..5e7a594 100644 --- a/tests/test_v4928_1_5_132_operational_coherence.py +++ b/tests/test_v4928_1_5_132_operational_coherence.py @@ -92,11 +92,11 @@ def test_validated_reconstructed_review_does_not_reopen_from_title(): assert reconstructed_review_status(metadata) == "validated" -def test_board_prefers_pending_task_and_explicit_review(): +def test_board_prefers_structured_decision_and_explicit_review(): source = (ROOT / "app" / "admin_ui" / "pages" / "opportunities.py").read_text(encoding="utf-8") function = source[source.index("def _opportunity_card_next_action"):source.index("def _is_noise_opportunity")] assert function.index("reconstructed_review_required(metadata)") < function.index("central = _central_next_action_for_card") - assert function.index("pending_primary_action_code") < function.index("central = _central_next_action_for_card") + assert function.index("central = _central_next_action_for_card") < function.index("pending_primary_action_code") assert 'return "Validar processo reconstruído"' in function