"""Commercial opportunity routes and actions. Moved from app.admin_dashboard in v4.7.2. The handlers still reuse legacy helpers to keep this refactor behavior-preserving. """ from fastapi import APIRouter, Request from fastapi.responses import PlainTextResponse, RedirectResponse from sqlalchemy import text from sqlalchemy.exc import OperationalError from app.db import engine from urllib.parse import quote import json import time import uuid from datetime import datetime, timezone import app.admin_dashboard as legacy from app.admin_dashboard import * # noqa: F401,F403 from app.admin_ui.labels import primary_action_label from app.operation_noise import is_noise_operation_item from app.opportunity_next_action_service import get_opportunity_next_action from app.opportunity_action_task_materializer import ensure_pending_task_for_next_action from app.work_center_action_policy import ( canonical_action_code, reconstructed_review_required, reconstructed_review_status, ) from app.opportunity_service import ( LOSS_REASON_LABELS, OPPORTUNITY_LIFECYCLE_STATES, lifecycle_label, mark_opportunity_lost, set_opportunity_lifecycle, ) from app.admin_ui.guidance import ( blocker_alert_html, fiscal_contact_inline_html, fiscal_contact_panel_html, fiscal_customer_missing_fields, opportunity_blockers, opportunity_context_customer, readiness_checklist_html, shipment_missing_fields, stage_requires_fiscal_customer, ) _opportunity_board_column_for_stage = legacy._opportunity_board_column_for_stage router = APIRouter() PAYMENT_TERM_LABELS = { "before_shipping": "Antes do envio", "after_delivery": "Após entrega", "agreement": "Conforme acordo", "undefined": "A definir", } _OBSOLETE_AFTER_PAYMENT_TASK_CODES = { "CONFIRM_PAYMENT", "FOLLOW_UP_PAYMENT", "FOLLOW_UP_PROFORMA", "FOLLOW_UP_QUOTE", "CONFIRM_DELIVERY", "RECOVER_OPPORTUNITY", "REVIEW_NURTURE", } def _is_obsolete_after_payment_task(task: dict, payment_confirmed_for_ui: bool) -> bool: if not payment_confirmed_for_ui: return False code = str((task or {}).get("action_code") or "").upper().strip() if code in _OBSOLETE_AFTER_PAYMENT_TASK_CODES: metadata = (task or {}).get("metadata") or {} if isinstance(metadata, dict): metadata.setdefault("ui_reason", "obsoleta: pagamento já confirmado") return True return False DELIVERY_TERM_LABELS = { "carrier": "Transportadora", "pickup": "Levantamento", "install_partner": "Eletricista/instalador do cliente", "undefined": "A definir", } # UI simplification: commercial phases stay short; financial/Odoo/shipping details # remain visible as derived evidence cards instead of becoming dozens of manual phases. COMMERCIAL_STAGE_OPTIONS = [ ("NEW_LEAD", "Novo pedido"), ("INFO_SENT", "Informação enviada"), ("QUOTE_SENT", "Orçamento enviado"), ("WAITING_PAYMENT", "A aguardar pagamento"), ("PAYMENT_CONFIRMED", "Pagamento confirmado"), ("ODOO_ORDER_CREATED", "Encomenda confirmada / em execução"), ("WON", "Concluído"), ("REVIEW", "Rever"), ] _DETAILED_OPERATIONAL_STAGES = { "INFO_REQUESTED", "QUOTE_REQUESTED", "PROFORMA_REQUESTED", "INVOICE_REQUESTED", "INVOICE_SENT", "WAITING_PAYMENT", "PAYMENT_CONFIRMED", "IN_PRODUCTION", "READY_TO_SHIP", "INVOICED", "SHIPMENT_CREATED", "TRACKING_SENT", "DELIVERED", "ORDER_PREPARATION", "SHIPPED", "NO_INTEREST", "ARCHIVED", } def _opportunity_metadata(opportunity: dict) -> dict: raw = opportunity.get("metadata") if isinstance(opportunity, dict) else {} return raw if isinstance(raw, dict) else {} def _commercial_stage_options_html(current_stage: str) -> str: current_stage = str(current_stage or "NEW_LEAD").upper() option_values = {value for value, _label in COMMERCIAL_STAGE_OPTIONS} html = "" if current_stage not in option_values and current_stage in OPPORTUNITY_STAGE_LABELS: html += ( '' ) for value, label in COMMERCIAL_STAGE_OPTIONS: selected = "selected" if value == current_stage else "" html += f'' return html def _option_tags(options: dict, selected_value: str) -> str: selected_value = str(selected_value or "undefined") html = "" for value, label in options.items(): selected = "selected" if value == selected_value else "" html += f'' return html def _payment_terms_summary(metadata: dict) -> tuple[str, str]: # Default BLIF commercial terms: payment before shipping and carrier delivery. # Operators can still override to after-delivery/agreement/undefined per opportunity. payment_term = str(metadata.get("payment_terms") or "before_shipping") delivery_term = str(metadata.get("delivery_terms") or "carrier") payment_label = PAYMENT_TERM_LABELS.get(payment_term, PAYMENT_TERM_LABELS["undefined"]) delivery_label = DELIVERY_TERM_LABELS.get(delivery_term, DELIVERY_TERM_LABELS["undefined"]) return payment_label, delivery_label def _safe_opportunity_task_text(value: str) -> str: """Normalize legacy/stale task notes before showing them in opportunity UI.""" text_value = str(value or "") replacements = { "fatura por emitir": "fatura criada/associada; enviar PDF ao cliente", "Fatura por emitir": "Fatura criada/associada; enviar PDF ao cliente", "Processo Odoo reconstruído: encomenda/entrega encontrada e fatura por emitir.": "Processo reconstruído: fatura criada/associada; enviar PDF ao cliente.", "Preparar e enviar pró-forma para pagamento.": "Preparar e enviar orçamento para pagamento.", "Enviar pró-forma": "Enviar orçamento para pagamento", "pró-forma": "orçamento para pagamento", "Pró-forma": "Orçamento para pagamento", } for old, new in replacements.items(): text_value = text_value.replace(old, new) return text_value def _opportunity_payment_confirmed(opportunity_id: str) -> bool: if not is_uuid_text(opportunity_id): return False with engine.begin() as conn: return bool(conn.execute(text(''' SELECT 1 FROM operation_links WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND system = 'clientflow' AND external_type = 'payment' AND status = 'confirmed' LIMIT 1 '''), {"opportunity_id": opportunity_id}).scalar()) def _opportunity_invoice_sent_evidence(opportunity_id: str, invoice_number: str | None = None) -> bool: """Return local evidence that an invoice was sent to the customer. Commercial documents imported from Jasmin do not always carry a sent flag. Reconstructed opportunities often have the evidence only as a completed SEND_INVOICE task, so the UI must use the same evidence model as the central next-action engine. """ if not is_uuid_text(opportunity_id): return False normalized_invoice = str(invoice_number or "").strip().upper() with engine.begin() as conn: row = conn.execute(text(''' SELECT 1 FROM tasks WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND action_code = 'SEND_INVOICE' AND LOWER(COALESCE(status, '')) IN ('done','completed','complete','closed','resolved','concluida','concluído','concluída') AND ( :invoice_number = '' OR UPPER(COALESCE(action, '') || ' ' || COALESCE(note, '') || ' ' || COALESCE(metadata::text, '')) LIKE '%' || :invoice_number || '%' OR COALESCE(metadata->>'document_number', metadata->>'invoice_number', '') = '' ) LIMIT 1 '''), { "opportunity_id": opportunity_id, "invoice_number": normalized_invoice, }).scalar() return bool(row) def _document_display_number(doc: dict | None) -> str: if not doc: return "—" return str(doc.get("document_number") or doc.get("external_id") or doc.get("id") or "documento") def _finance_quick_card_html(opportunity_id: str, linked_documents: list[dict], payment_term: str, payment_term_label: str) -> str: # BLIF default flow: quotation -> payment -> invoice -> prepare/ship. quotation = next((d for d in linked_documents if str(d.get("document_kind") or "") in {"quotation", "proforma"} and str(d.get("role") or "current") in {"current", "accepted"}), None) invoice = next((d for d in linked_documents if str(d.get("document_kind") or "") == "invoice" and str(d.get("role") or "current") in {"current", "accepted"}), None) payment_confirmed = _opportunity_payment_confirmed(opportunity_id) base_doc = invoice or quotation base_doc_label = "Fatura" if invoice else ("Orçamento" if quotation else "Documento") amount = (base_doc.get("total_amount") or base_doc.get("amount")) if base_doc else None amount_html = money_html(float(amount or 0)) if amount else "—" payment_status = "Confirmado" if payment_confirmed else ("Pendente pós-entrega" if payment_term == "after_delivery" else "Por confirmar") if not base_doc: action_html = '
Bloqueado: cria/associa primeiro um orçamento ou fatura.
' elif payment_confirmed: if not invoice: action_html = f'''
Pagamento confirmado. Próximo passo do fluxo normal: emitir fatura.
''' else: invoice_payload = invoice.get("payload") if isinstance(invoice.get("payload"), dict) else {} invoice_sent = bool( invoice.get("sent_at") or invoice.get("sent") or str(invoice.get("status") or "").lower() in {"sent", "issued_sent"} or invoice_payload.get("clientflow_invoice_sent_evidence") or invoice_payload.get("invoice_sent_at") or _opportunity_invoice_sent_evidence(opportunity_id, _document_display_number(invoice)) ) if invoice_sent: detail = "Fatura enviada e pagamento confirmado. Continua pela próxima ação operacional indicada acima." else: detail = "Fatura criada/associada. Envia o PDF ao cliente; depois acompanha preparação/Odoo." action_html = f'
{esc(detail)}
' else: note = "Pagamento validado pelo operador no ClientFlow." button_label = "Confirmar pagamento" if payment_term == "after_delivery": note = "Registar pagamento recebido após entrega/acordo comercial." button_label = "Confirmar pagamento pós-entrega" elif quotation and not invoice and payment_term == "before_shipping": note = "Pagamento confirmado com base no orçamento. Emitir fatura de seguida." action_html = f'''
''' return f'''

Financeiro rápido

Ação independente da fase: usa orçamento/fatura associado e a condição comercial.
{esc(base_doc_label)}{esc(_document_display_number(base_doc))}
Valor esperado{amount_html}
Pagamento{esc(payment_status)}
Condição{esc(payment_term_label)}
{action_html}
''' def _json_payload(value: object) -> str: return json.dumps(value or {}, ensure_ascii=False, default=str) def _opportunity_manual_correction_state(opportunity_id: str) -> dict: """Return counts that help the operator understand external links before correction. This card is auxiliary/advanced UI only. It must never make the opportunity detail page fail if PostgreSQL detects a transient lock cycle while reconciliation/sync jobs are rebuilding evidence. Retry once and then return a safe degraded state instead of surfacing a 500. """ safe_empty = { "odoo_links": 0, "jasmin_links": 0, "jasmin_documents": 0, "imported_lines": 0, "reconciliation_items": 0, } if not is_uuid_text(opportunity_id): return safe_empty last_lock_error = None for attempt in range(2): try: with engine.begin() as conn: row = conn.execute(text(""" SELECT COUNT(*) FILTER (WHERE system = 'odoo')::int AS odoo_links, COUNT(*) FILTER (WHERE system = 'jasmin')::int AS jasmin_links FROM operation_links WHERE opportunity_id = CAST(:opportunity_id AS UUID) """), {"opportunity_id": opportunity_id}).mappings().first() or {} docs = conn.execute(text(""" SELECT COUNT(*)::int FROM commercial_documents WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND system = 'jasmin' """), {"opportunity_id": opportunity_id}).scalar() or 0 imported = conn.execute(text(""" SELECT COUNT(*)::int FROM opportunity_items WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND ( UPPER(COALESCE(status,'')) IN ('ODOO_IMPORTED','JASMIN_IMPORTED') OR UPPER(COALESCE(status,'')) LIKE '%\\_IMPORTED' ESCAPE '\\' OR COALESCE(metadata->>'source_system','') IN ('odoo','jasmin') ) """), {"opportunity_id": opportunity_id}).scalar() or 0 reconciliation = conn.execute(text(""" SELECT COUNT(*)::int FROM reconciliation_items WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND source_system IN ('odoo','jasmin') """), {"opportunity_id": opportunity_id}).scalar() or 0 data = dict(row) data["jasmin_documents"] = int(docs or 0) data["imported_lines"] = int(imported or 0) data["reconciliation_items"] = int(reconciliation or 0) return data except OperationalError as exc: msg = str(exc).lower() if "deadlock detected" in msg or "lock timeout" in msg or "could not obtain lock" in msg: last_lock_error = exc if attempt == 0: time.sleep(0.25) continue degraded = dict(safe_empty) degraded["unavailable"] = True degraded["unavailable_reason"] = "lock_timeout" return degraded raise if last_lock_error: degraded = dict(safe_empty) degraded["unavailable"] = True degraded["unavailable_reason"] = "lock_timeout" return degraded return safe_empty def _opportunity_archive_spam_state(opportunity_id: str) -> dict: """Best-effort state for the UI-only spam archive card. The POST action still performs the authoritative safety check. This helper must never break the opportunity page; deadlocks/reconciliation locks simply hide the archive card for this request. """ if not is_uuid_text(opportunity_id): return {"can_archive": False, "unavailable": True} try: with engine.begin() as conn: row = conn.execute(text(""" SELECT (SELECT COUNT(*) FROM commercial_documents WHERE opportunity_id = CAST(:opportunity_id AS UUID)) AS docs, (SELECT COUNT(*) FROM reconciliation_items WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND source_system IN ('jasmin','odoo')) AS linked_external, (SELECT COUNT(*) FROM operation_links WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND system IN ('jasmin','odoo','packlink')) AS operation_links, (SELECT COUNT(*) FROM tasks WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND action_code = 'IGNORE_SPAM') AS spam_tasks, (SELECT COUNT(*) FROM tasks WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND route = 'spam') AS spam_route_tasks, (SELECT COUNT(*) FROM communications WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND classification IN ('IGNORE_SPAM','SPAM')) AS spam_communications, (SELECT status FROM opportunities WHERE id = CAST(:opportunity_id AS UUID)) AS status """), {"opportunity_id": opportunity_id}).mappings().first() or {} except OperationalError: return {"can_archive": False, "unavailable": True} except Exception: return {"can_archive": False, "unavailable": True} docs = int(row.get("docs") or 0) linked_external = int(row.get("linked_external") or 0) operation_links = int(row.get("operation_links") or 0) spam_evidence = int(row.get("spam_tasks") or 0) + int(row.get("spam_route_tasks") or 0) + int(row.get("spam_communications") or 0) status = str(row.get("status") or "").lower() return { "can_archive": status != "archived" and docs == 0 and linked_external == 0 and operation_links == 0, "has_spam_evidence": spam_evidence > 0, "docs": docs, "linked_external": linked_external, "operation_links": operation_links, "spam_evidence": spam_evidence, "status": status, } def _recalculate_opportunity_value_after_manual_correction(conn, opportunity_id: str): manual_total = conn.execute(text(""" SELECT COALESCE(SUM(total_price), 0)::numeric FROM opportunity_items WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND UPPER(COALESCE(status,'')) NOT IN ('REJECTED','CANCELLED','DELIVERED','HISTORICAL','ODOO_IMPORTED','JASMIN_IMPORTED') AND UPPER(COALESCE(status,'')) NOT LIKE '%\\_IMPORTED' ESCAPE '\\' AND COALESCE(metadata->>'source_system','manual') NOT IN ('odoo','jasmin') """), {"opportunity_id": opportunity_id}).scalar() return manual_total or 0 def apply_manual_external_correction( opportunity_id: str, *, unlink_odoo: bool, unlink_jasmin: bool, remove_imported_lines: bool, new_stage: str, note: str, actor: str = "operator_manual_correction", ) -> dict: """Manual override for wrongly linked Odoo/Jasmin evidence. This is intentionally auditable and local-only: it never deletes data in Odoo, Jasmin or Chatwoot. It only detaches ClientFlow evidence from the opportunity. """ if not is_uuid_text(opportunity_id): raise ValueError("Identificador de oportunidade inválido.") new_stage = str(new_stage or "INFO_SENT").strip().upper() if new_stage not in OPPORTUNITY_STAGE_LABELS: raise ValueError(f"Fase inválida: {new_stage}") action_by_stage = { "INFO_SENT": "SEND_INFO", "INFO_REQUESTED": "SEND_INFO", "QUOTE_REQUESTED": "SEND_QUOTE", "QUOTE_SENT": "SEND_QUOTE", "PROFORMA_REQUESTED": "SEND_PROFORMA", "PROFORMA_SENT": "SEND_PROFORMA", "INVOICE_REQUESTED": "SEND_INVOICE", "INVOICE_SENT": "SEND_INVOICE", "WAITING_PAYMENT": "CONFIRM_PAYMENT", "REVIEW": "REVIEW_MANUALLY", "LOST": "MARK_NO_INTEREST", "NO_INTEREST": "MARK_NO_INTEREST", } new_action = action_by_stage.get(new_stage, "SEND_INFO") sources: list[str] = [] if unlink_odoo: sources.append("odoo") if unlink_jasmin: sources.append("jasmin") if not sources and not new_stage: return {"changed": 0} result = { "operation_links_deleted": 0, "jasmin_documents_deleted": 0, "imported_lines_deleted": 0, "reconciliation_items_unlinked": 0, "stage": new_stage, } note = (note or "Correção manual: associação externa errada removida pelo operador.").strip() metadata_patch = { "manual_external_correction": True, "manual_external_correction_sources": sources, "manual_external_correction_note": note, "manual_external_correction_actor": actor, } with engine.begin() as conn: current = conn.execute(text(""" SELECT stage, value_amount, last_action_code FROM opportunities WHERE id = CAST(:opportunity_id AS UUID) """), {"opportunity_id": opportunity_id}).mappings().first() if not current: raise ValueError("Oportunidade não encontrada.") old_stage = str(current.get("stage") or "NEW_LEAD") if unlink_odoo: result["operation_links_deleted"] += conn.execute(text(""" DELETE FROM operation_links WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND system = 'odoo' """), {"opportunity_id": opportunity_id}).rowcount or 0 if unlink_jasmin: # Commercial document lines are removed by ON DELETE CASCADE. result["jasmin_documents_deleted"] += conn.execute(text(""" DELETE FROM commercial_documents WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND system = 'jasmin' """), {"opportunity_id": opportunity_id}).rowcount or 0 result["operation_links_deleted"] += conn.execute(text(""" DELETE FROM operation_links WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND system = 'jasmin' """), {"opportunity_id": opportunity_id}).rowcount or 0 if remove_imported_lines and sources: result["imported_lines_deleted"] += conn.execute(text(""" DELETE FROM opportunity_items WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND ( (:unlink_odoo IS TRUE AND (COALESCE(metadata->>'source_system','') = 'odoo' OR UPPER(COALESCE(status,'')) = 'ODOO_IMPORTED')) OR (:unlink_jasmin IS TRUE AND (COALESCE(metadata->>'source_system','') = 'jasmin' OR UPPER(COALESCE(status,'')) = 'JASMIN_IMPORTED')) OR ((:unlink_odoo IS TRUE OR :unlink_jasmin IS TRUE) AND UPPER(COALESCE(status,'')) LIKE '%\\_IMPORTED' ESCAPE '\\') ) """), {"opportunity_id": opportunity_id, "unlink_odoo": bool(unlink_odoo), "unlink_jasmin": bool(unlink_jasmin)}).rowcount or 0 if sources: result["reconciliation_items_unlinked"] += conn.execute(text(""" UPDATE reconciliation_items SET opportunity_id = NULL, status = CASE WHEN status IN ('resolved','linked','applied','open','needs_review','conflict') THEN 'needs_review' ELSE status END, resolution_note = COALESCE(resolution_note || ' | ', '') || :note, resolved_at = NULL, payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB), updated_at = now() WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND ( (:unlink_odoo IS TRUE AND source_system = 'odoo') OR (:unlink_jasmin IS TRUE AND source_system = 'jasmin') ) """), { "opportunity_id": opportunity_id, "unlink_odoo": bool(unlink_odoo), "unlink_jasmin": bool(unlink_jasmin), "note": note, "payload": _json_payload({"manual_unlinked_from_opportunity_id": opportunity_id, "sources": sources, "actor": actor}), }).rowcount or 0 manual_total = _recalculate_opportunity_value_after_manual_correction(conn, opportunity_id) conn.execute(text(""" UPDATE opportunities SET stage = :stage, status = CASE WHEN :stage IN ('WON','LOST','NO_INTEREST','DELIVERED') THEN 'closed' ELSE 'open' END, last_action_code = :action_code, value_amount = CAST(:value_amount AS NUMERIC), metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB), closed_at = CASE WHEN :stage IN ('WON','LOST','NO_INTEREST','DELIVERED') THEN COALESCE(closed_at, now()) ELSE NULL END, updated_at = now() WHERE id = CAST(:opportunity_id AS UUID) """), { "opportunity_id": opportunity_id, "stage": new_stage, "action_code": new_action, "value_amount": manual_total, "metadata": _json_payload(metadata_patch), }) conn.execute(text(""" INSERT INTO opportunity_events ( id, opportunity_id, event_type, action_code, from_stage, to_stage, note, payload, created_by ) VALUES ( CAST(:id AS UUID), CAST(:opportunity_id AS UUID), 'manual_external_correction', :action_code, :from_stage, :to_stage, :note, CAST(:payload AS JSONB), :created_by ) """), { "id": str(uuid.uuid4()), "opportunity_id": opportunity_id, "action_code": new_action, "from_stage": old_stage, "to_stage": new_stage, "note": note, "payload": _json_payload(result), "created_by": actor, }) return result def ignore_external_candidate_for_opportunity(opportunity_id: str, item_id: str, *, actor: str = "operator_ui_ignore_candidate") -> int: if not is_uuid_text(opportunity_id) or not is_uuid_text(item_id): raise ValueError("Identificador inválido.") with engine.begin() as conn: count = conn.execute(text(""" UPDATE reconciliation_items SET status = 'ignored', opportunity_id = CASE WHEN opportunity_id = CAST(:opportunity_id AS UUID) THEN NULL ELSE opportunity_id END, resolution_note = COALESCE(resolution_note || ' | ', '') || 'Ignorado manualmente a partir da oportunidade.', resolved_at = now(), payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB), updated_at = now() WHERE id = CAST(:item_id AS UUID) AND source_system IN ('odoo','jasmin') """), { "opportunity_id": opportunity_id, "item_id": item_id, "payload": _json_payload({"ignored_from_opportunity_id": opportunity_id, "actor": actor}), }).rowcount or 0 if count: conn.execute(text(""" INSERT INTO opportunity_events ( id, opportunity_id, event_type, action_code, note, payload, created_by ) VALUES ( CAST(:id AS UUID), CAST(:opportunity_id AS UUID), 'external_candidate_ignored', 'REVIEW_RECONCILIATION', :note, CAST(:payload AS JSONB), :created_by ) """), { "id": str(uuid.uuid4()), "opportunity_id": opportunity_id, "note": "Candidato externo ignorado manualmente.", "payload": _json_payload({"item_id": item_id}), "created_by": actor, }) return int(count or 0) def unlink_commercial_document_from_opportunity( opportunity_id: str, document_id: str, *, remove_imported_lines: bool = True, note: str = "", actor: str = "operator_ui_document_unlink", ) -> dict: """Detach one local commercial document from an opportunity. This is the granular counterpart to the broad manual external correction. It does not delete anything in Jasmin/Odoo. It only removes the document from this ClientFlow opportunity and, when requested, removes imported opportunity lines that explicitly came from that document reference. """ if not is_uuid_text(opportunity_id) or not is_uuid_text(document_id): raise ValueError("Identificador inválido.") note = (note or "Documento desassociado manualmente desta oportunidade.").strip() result = {"document_unlinked": 0, "imported_lines_deleted": 0, "reconciliation_items_unlinked": 0} with engine.begin() as conn: doc = conn.execute(text(""" SELECT id::text, opportunity_id::text, system, document_kind, external_id, document_number, role, is_primary, total_amount, amount FROM commercial_documents WHERE id = CAST(:document_id AS UUID) AND opportunity_id = CAST(:opportunity_id AS UUID) LIMIT 1 """), {"document_id": document_id, "opportunity_id": opportunity_id}).mappings().first() if not doc: raise ValueError("Documento não encontrado nesta oportunidade.") refs = [str(doc.get("document_number") or "").strip(), str(doc.get("external_id") or "").strip()] refs = [r for r in refs if r] payload = _json_payload({ "manual_document_unlink": True, "opportunity_id": opportunity_id, "document_id": document_id, "document_number": doc.get("document_number"), "external_id": doc.get("external_id"), "actor": actor, "note": note, }) result["document_unlinked"] = conn.execute(text(""" UPDATE commercial_documents SET opportunity_id = NULL, role = 'detached', is_primary = FALSE, is_active = FALSE, payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB), updated_at = now() WHERE id = CAST(:document_id AS UUID) AND opportunity_id = CAST(:opportunity_id AS UUID) """), {"document_id": document_id, "opportunity_id": opportunity_id, "payload": payload}).rowcount or 0 if remove_imported_lines and refs: result["imported_lines_deleted"] = conn.execute(text(""" DELETE FROM opportunity_items WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND ( metadata->>'source_system' = CAST(:system AS TEXT) OR (:system = 'jasmin' AND status = 'JASMIN_IMPORTED') OR (:system = 'odoo' AND status = 'ODOO_IMPORTED') ) AND ( metadata->>'source_document' = ANY(:refs) OR metadata->>'source_external_id' = ANY(:refs) OR source_document = ANY(:refs) ) """), {"opportunity_id": opportunity_id, "system": doc.get("system") or "jasmin", "refs": refs}).rowcount or 0 if refs: result["reconciliation_items_unlinked"] = conn.execute(text(""" UPDATE reconciliation_items SET opportunity_id = NULL, status = CASE WHEN status IN ('resolved','linked','applied','open','needs_review','conflict') THEN 'needs_review' ELSE status END, resolution_note = COALESCE(resolution_note || ' | ', '') || :note, resolved_at = NULL, payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB), updated_at = now() WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND source_system = CAST(:system AS TEXT) AND ( document_number = ANY(:refs) OR external_id = ANY(:refs) OR payload::text ILIKE '%' || CAST(:document_id AS TEXT) || '%' ) """), { "opportunity_id": opportunity_id, "system": doc.get("system") or "jasmin", "refs": refs, "document_id": document_id, "note": note, "payload": payload, }).rowcount or 0 conn.execute(text(""" INSERT INTO opportunity_events (id, opportunity_id, event_type, action_code, note, payload, created_by) VALUES (CAST(:id AS UUID), CAST(:opportunity_id AS UUID), 'commercial_document_unlinked', 'REVIEW_RECONCILIATION', :note, CAST(:payload AS JSONB), :actor) """), { "id": str(uuid.uuid4()), "opportunity_id": opportunity_id, "note": note, "payload": payload, "actor": actor, }) return result def set_commercial_document_role_for_opportunity( opportunity_id: str, document_id: str, *, role: str = "current", make_primary: bool = True, actor: str = "operator_ui_document_role", ) -> dict: """Choose which document belongs to the current process without deleting evidence.""" if not is_uuid_text(opportunity_id) or not is_uuid_text(document_id): raise ValueError("Identificador inválido.") role = str(role or "current").strip().lower() if role not in {"current", "accepted", "related", "historical"}: raise ValueError("Papel de documento inválido.") with engine.begin() as conn: doc = conn.execute(text(""" SELECT id::text, system, document_kind, document_number FROM commercial_documents WHERE id = CAST(:document_id AS UUID) AND opportunity_id = CAST(:opportunity_id AS UUID) LIMIT 1 """), {"document_id": document_id, "opportunity_id": opportunity_id}).mappings().first() if not doc: raise ValueError("Documento não encontrado nesta oportunidade.") if make_primary and role in {"current", "accepted"}: conn.execute(text(""" UPDATE commercial_documents SET role = CASE WHEN COALESCE(role, 'current') = 'current' THEN 'historical' ELSE role END, is_primary = FALSE, is_active = CASE WHEN COALESCE(role, 'current') = 'current' THEN FALSE ELSE COALESCE(is_active, TRUE) END, updated_at = now() WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND system = CAST(:system AS TEXT) AND document_kind = CAST(:document_kind AS TEXT) AND id <> CAST(:document_id AS UUID) """), { "opportunity_id": opportunity_id, "system": doc.get("system"), "document_kind": doc.get("document_kind"), "document_id": document_id, }) conn.execute(text(""" UPDATE commercial_documents SET role = :role, is_primary = :is_primary, is_active = TRUE, updated_at = now(), payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB) WHERE id = CAST(:document_id AS UUID) AND opportunity_id = CAST(:opportunity_id AS UUID) """), { "document_id": document_id, "opportunity_id": opportunity_id, "role": role, "is_primary": bool(make_primary and role in {"current", "accepted"}), "payload": _json_payload({"manual_document_role": role, "manual_primary": bool(make_primary), "actor": actor}), }) conn.execute(text(""" INSERT INTO opportunity_events (id, opportunity_id, event_type, action_code, note, payload, created_by) VALUES (CAST(:id AS UUID), CAST(:opportunity_id AS UUID), 'commercial_document_role_changed', 'REVIEW_RECONCILIATION', :note, CAST(:payload AS JSONB), :actor) """), { "id": str(uuid.uuid4()), "opportunity_id": opportunity_id, "note": f"Documento {doc.get('document_number') or document_id} marcado como {role}.", "payload": _json_payload({"document_id": document_id, "role": role, "make_primary": bool(make_primary)}), "actor": actor, }) return {"changed": 1, "role": role, "is_primary": bool(make_primary and role in {"current", "accepted"})} def _odoo_m2o_label(value) -> str: if isinstance(value, (list, tuple)) and len(value) >= 2: return str(value[1] or "") if isinstance(value, dict): return str(value.get("name") or value.get("display_name") or value.get("id") or "") return str(value or "") def _odoo_status_badge(status: object) -> str: s = str(status or "").lower() cls = "text-bg-secondary" if s in {"done", "shipped", "delivered", "validated", "sale", "created", "order_created", "ready_to_ship"}: cls = "text-bg-success" elif s in {"assigned", "confirmed", "waiting", "in_production", "progress", "pending", "sent", "quote_only"}: cls = "text-bg-warning" elif s in {"cancel", "cancelled", "failed", "not_found", "blocked"}: cls = "text-bg-danger" return f'{esc(status or "—")}' def _opportunity_odoo_rows(opportunity_id: str) -> tuple[list[dict], list[dict]]: """Return linked Odoo operation links and recent reconciliation candidates. Read-only. The panel must not call Odoo on page load; the operator uses the explicit sync button to refresh live Odoo state. """ with engine.begin() as conn: links = conn.execute(text(""" SELECT id::text, system, external_type, external_id, external_name, external_url, status, payload, last_synced_at, updated_at FROM operation_links WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND system = 'odoo' ORDER BY CASE external_type WHEN 'sale_order' THEN 1 WHEN 'physical_status' THEN 2 WHEN 'production' THEN 3 WHEN 'physical_validation' THEN 4 ELSE 9 END, updated_at DESC """), {"opportunity_id": opportunity_id}).mappings().all() candidates = conn.execute(text(""" SELECT id::text, source_system, external_type, external_id, document_number, title, status, amount, currency, customer_name, customer_email, customer_tax_id, payload, opportunity_id::text AS linked_opportunity_id, created_at, updated_at, resolved_at FROM reconciliation_items WHERE source_system = 'odoo' AND external_type = 'odoo_sale_order' AND ( opportunity_id = CAST(:opportunity_id AS UUID) OR (status IN ('open','needs_review','conflict') AND payload::text ILIKE '%' || CAST(:opportunity_id AS TEXT) || '%') ) ORDER BY CASE WHEN opportunity_id = CAST(:opportunity_id AS UUID) THEN 0 ELSE 1 END, updated_at DESC LIMIT 20 """), {"opportunity_id": opportunity_id}).mappings().all() return [dict(r) for r in links], [dict(r) for r in candidates] def odoo_status_panel_html(opportunity_id: str, *, notice: str = "", error_notice: str = "") -> str: try: links, candidates = _opportunity_odoo_rows(opportunity_id) except Exception as exc: return f'
Erro ao carregar Odoo: {esc(exc)}
' by_type = {str(link.get("external_type") or ""): link for link in links} sale = by_type.get("sale_order") or {} physical = by_type.get("physical_status") or {} physical_payload = physical.get("payload") if isinstance(physical.get("payload"), dict) else {} sale_payload = sale.get("payload") if isinstance(sale.get("payload"), dict) else {} live_sale = physical_payload.get("sale_order") if isinstance(physical_payload.get("sale_order"), dict) else {} pickings = physical_payload.get("pickings") if isinstance(physical_payload.get("pickings"), list) else [] productions = physical_payload.get("productions") if isinstance(physical_payload.get("productions"), list) else [] sale_name = live_sale.get("name") or sale.get("external_name") or sale_payload.get("sale_order") or sale.get("external_id") or "—" sale_state = live_sale.get("state") or sale.get("status") or "—" sale_amount = live_sale.get("amount_total") or sale_payload.get("amount_total") or "" partner = _odoo_m2o_label(live_sale.get("partner") or live_sale.get("partner_id") or sale_payload.get("partner") or sale_payload.get("partner_id")) or "—" last_synced = physical.get("last_synced_at") or sale.get("last_synced_at") or "—" physical_label = physical_payload.get("label") or physical.get("status") or "Não sincronizado" physical_reason = physical_payload.get("reason") or "Usa o botão para consultar estado físico no Odoo." physical_next = physical_payload.get("next_action") or "" physical_status_value = str(physical.get("status") or physical_payload.get("physical_status") or physical_payload.get("status") or "").strip().lower() picking_states = { str(p.get("state") or "").strip().lower() for p in pickings if isinstance(p, dict) and str(p.get("state") or "").strip() } whout_done = ( bool(physical_payload.get("delivery_done")) or physical_status_value in {"done", "shipped", "delivered", "validated"} or (bool(picking_states) and picking_states <= {"done", "cancel"} and "done" in picking_states) ) whout_ready = ( bool(physical_payload.get("ready_to_ship") or physical_payload.get("delivery_ready")) or physical_status_value in {"ready_to_ship", "ready", "validated"} or "assigned" in picking_states ) if whout_done: physical_reason = "WH/OUT concluído no Odoo." physical_next = "Processo pronto para conclusão quando fatura enviada e pagamento confirmado." elif whout_ready: physical_label = "Picking reservado — validação física pendente" physical_reason = "Odoo assigned indica stock reservado; ainda falta confirmar fisicamente a preparação da encomenda." physical_next = "Validar encomenda física antes de criar envio/tracking." notice_html = f'
{esc(notice)}
' if notice else "" error_html = f'
Erro Odoo: {esc(error_notice)}
' if error_notice else "" sale_url = str(sale.get("external_url") or "").strip() sale_link = f'Abrir Odoo' if sale_url else "" no_sale_warning = "" manual_sale_link_html = "" if not sale: no_sale_warning = '
Sem venda Odoo ligada.
Regista o número da venda Odoo (ex.: S00308) ou usa candidatos abaixo para ligar a venda correta antes de confiar no fluxo físico.
' manual_sale_link_html = f'''
Associar venda Odoo manualmente
Não cria nada no Odoo; apenas liga a venda já criada ao processo e sincroniza WH/OUT.
''' unlink_odoo_button_html = "" if sale: unlink_odoo_button_html = f"""
""" picking_rows = "" for pck in pickings[:8]: picking_rows += f""" {esc(pck.get('name') or pck.get('id') or 'Entrega')}
{esc(_odoo_m2o_label(pck.get('type')) or pck.get('origin') or '')}
{_odoo_status_badge(pck.get('state'))} {esc(fmt_dt(pck.get('scheduled_date') or pck.get('date_done')))} """ if not picking_rows: picking_rows = 'Sem entregas/pickings sincronizados.' production_rows = "" for mo in productions[:8]: production_rows += f""" {esc(mo.get('name') or mo.get('id') or 'Produção')}
{esc(_odoo_m2o_label(mo.get('product')))}
{_odoo_status_badge(mo.get('state'))} {esc(mo.get('qty') or '')} """ if not production_rows: production_rows = 'Sem ordens de produção sincronizadas.' candidate_rows = "" for cand in candidates: linked_here = str(cand.get("linked_opportunity_id") or "") == str(opportunity_id) if linked_here: action_html = f'''
Ligada
''' else: action_html = f"""
""" candidate_rows += f""" {esc(cand.get('document_number') or cand.get('external_id') or 'Venda Odoo')}
{esc(cand.get('title') or '')}
{money_html(cand.get('amount') or 0)}
{esc(cand.get('currency') or 'EUR')}
{esc(cand.get('customer_name') or '—')}
{esc(cand.get('customer_email') or '')}
{_odoo_status_badge(cand.get('status'))} {action_html} """ if not candidate_rows: candidate_rows = 'Sem vendas Odoo candidatas ligadas a esta oportunidade.' amount_html = money_html(sale_amount) if sale_amount not in {"", None} else "—" details_open = "open" if candidates else "" physical_next_html = f'
{esc(physical_next)}
' if physical_next else "" return f"""

Estado Odoo

Venda Odoo e entrega/WH-OUT. Ordens de fabrico ficam em detalhe técnico e não conduzem o fluxo do operador.
Última sincronização: {esc(fmt_dt(last_synced))}
{unlink_odoo_button_html}
{notice_html}{error_html}{no_sale_warning}{manual_sale_link_html}
Venda Odoo
{esc(sale_name)}
{_odoo_status_badge(sale_state)}
{sale_link}
Cliente Odoo
{esc(partner)}
Valor Odoo
{amount_html}
Estado físico
{esc(physical_label)}
{esc(physical_reason)}
{physical_next_html}
{picking_rows}
Entrega / pickingEstadoData
Detalhes técnicos Odoo / fabrico
Informativo. O fluxo do ClientFlow usa a venda Odoo e o estado da entrega/WH-OUT; ordens WH/MO não bloqueiam o fecho comercial.
{production_rows}
Produção / preparaçãoEstadoQtd.
Vendas Odoo ligadas/candidatas
Associa candidatos apenas quando representam a mesma venda/processo.
{candidate_rows}
VendaValorClienteEstadoAção
""" def _task_href_with_return_to(task_id: str, return_to: str) -> str: href = f"/tasks/{task_id}" if return_to: href += f"?return_to={quote(return_to, safe='')}" return href def _render_email_identity_review(opportunity_id: str, linked_customer: dict | None) -> str: try: from app.fiscal_enrichment_service import email_identity_review_for_opportunity review = email_identity_review_for_opportunity(opportunity_id, refresh=False) except Exception as exc: return f"""
Identidade do email
Erro ao ler identidade extraída: {esc(exc)}
""" if not review.get("ok") or not review.get("identity"): return f"""
Identidade do email
Ainda não existe identidade extraída para esta oportunidade.
""" identity = review.get("identity") or {} companies = review.get("valid_company_mentions") or identity.get("company_mentions") or [] phones = identity.get("phones") or [] evidence = identity.get("evidence") or [] conflict = bool(review.get("conflict")) suggested = review.get("suggested_internal_customer") or {} model = (identity.get("raw_payload") or {}).get("llm_model") if isinstance(identity.get("raw_payload"), dict) else identity.get("llm_model") model = model or identity.get("llm_model") or "—" confidence = identity.get("confidence") try: confidence_value = float(confidence or 0) confidence_text = f"{confidence_value * 100:.0f}%" if confidence_value <= 1 else f"{confidence_value:.0f}%" except Exception: confidence_text = "—" company_html = "".join(f'{esc(c)}' for c in companies) or 'Sem empresa explícita válida' phone_html = ", ".join(esc(p) for p in phones) if phones else "—" evidence_html = "".join(f'
  • {esc(compact_text(e, 90))}
  • ' for e in evidence[:3]) conflict_html = "" if conflict: conflict_html = f"""
    Possível conflito fiscal.
    O email menciona {esc(', '.join(companies) or 'outra empresa')}, mas a oportunidade está ligada a {esc(review.get('linked_customer_name') or 'outro cliente')}.
    """ suggested_html = "" if suggested and companies: suggested_html = f"""
    Cliente interno compatível
    {esc(suggested.get('nome') or suggested.get('name') or 'Cliente')}
    NIF {esc(suggested.get('nif') or suggested.get('tax_id') or '—')}
    """ return f"""
    Identidade extraída do email
    {esc(identity.get('extraction_method') or identity.get('method') or '—')} · {esc(model)} · confiança {esc(confidence_text)}
    {status_badge('conflito') if conflict and 'status_badge' in globals() else ''}
    {conflict_html}
    Pessoa
    {esc(identity.get('person_name') or '—')}
    Empresa mencionada
    {company_html}
    Email / domínio
    {esc(identity.get('email') or '—')} · {esc(identity.get('domain') or '—')}
    Morada
    {esc(identity.get('address') or '—')}
    Telefones
    {phone_html}
    {suggested_html} {f'' if evidence_html else ''}
    """ def _local_normalize_fiscal_name(value: object) -> str: text = " ".join(str(value or "").strip().casefold().replace(",", " ").replace(".", " ").split()) legal = {"lda", "ltd", "sa", "s", "a", "unipessoal", "limitada", "sociedade", "portugal"} return " ".join(token for token in text.split() if token not in legal) def _render_fiscal_suggestions(opportunity_id: str, linked_customer: dict | None) -> str: try: from app.fiscal_enrichment_service import list_fiscal_suggestions_for_opportunity suggestions = list_fiscal_suggestions_for_opportunity(opportunity_id, limit=3) except Exception: suggestions = [] if linked_customer and not suggestions: return "" if not suggestions: return f"""
    Sem sugestão fiscal externa registada.
    """ rows = "" linked_name_norm = _local_normalize_fiscal_name(linked_customer.get("name") if linked_customer else "") linked_tax_id = str((linked_customer or {}).get("tax_id") or "").strip() linked_customer_id = str((linked_customer or {}).get("id") or "").strip() visible_suggestions = [] for suggestion in suggestions: status = str(suggestion.get("status") or "pending") lookup_value = str(suggestion.get("lookup_value") or "").strip().lower() suggested_nif = str(suggestion.get("suggested_nif") or "").strip() suggested_name_norm = _local_normalize_fiscal_name(suggestion.get("suggested_name")) suggested_customer_id = str(suggestion.get("suggested_customer_id") or "").strip() if lookup_value in {"pt", "com", "net", "org", "www", "http", "https", "mail", "email"}: continue # Do not show old accepted suggestions that merely confirm the current fiscal customer. # The fiscal card already shows the truth; repeating an accepted suggestion with stale # suggested_nif=NULL is confusing. same_current_customer = bool( linked_customer and status == "accepted" and ( (suggested_customer_id and linked_customer_id and suggested_customer_id == linked_customer_id) or (linked_name_norm and suggested_name_norm and linked_name_norm == suggested_name_norm) or (linked_tax_id and suggested_nif and linked_tax_id == suggested_nif) ) ) if same_current_customer: continue visible_suggestions.append(suggestion) for suggestion in visible_suggestions: sid = str(suggestion.get("id") or "") status = str(suggestion.get("status") or "pending") badge = status_badge(status) if "status_badge" in globals() else f"{esc(status)}" confidence = suggestion.get("confidence") if confidence is not None: try: confidence_value = float(confidence) confidence_text = f"{confidence_value * 100:.0f}%" if confidence_value <= 1 else f"{confidence_value:.0f}%" except Exception: confidence_text = "—" else: confidence_text = "—" actions = "" if status == "pending" and sid: actions = f"""
    """ rows += f"""
    {esc(suggestion.get('suggested_name') or 'Empresa sugerida')}{badge}
    Sugestão fiscal · NIF {esc(suggestion.get('suggested_nif') or '—')} · confiança {esc(confidence_text)}
    {esc(suggestion.get('match_type') or suggestion.get('lookup_type') or 'match')}
    {actions}
    """ if not rows.strip(): return "" return f"""
    Sugestões fiscais por validar
    Não é cliente fiscal confirmado. Associar apenas depois de validar nome/NIF.
    {rows}
    """ def _jasmin_candidate_tax_conflict_message(opportunity_id: str, item_id: str) -> str: """Return a blocking message when a Jasmin candidate belongs to another NIF.""" try: from app.commercial_service import get_customer_for_opportunity, normalize_tax_id from app.jasmin_backfill_service import find_jasmin_document_candidates_for_opportunity linked_customer = get_customer_for_opportunity(opportunity_id) linked_tax_id = normalize_tax_id((linked_customer or {}).get("tax_id")) if not linked_tax_id: return "" for item in find_jasmin_document_candidates_for_opportunity(opportunity_id, limit=50): if str(item.get("id") or "") != str(item_id): continue candidate_tax = normalize_tax_id(item.get("customer_tax_id")) if candidate_tax and candidate_tax != linked_tax_id: return ( "NIF divergente: o documento Jasmin pertence a outro cliente fiscal. " "Rever manualmente na reconciliação antes de associar/substituir." ) return "" except Exception: # Não bloquear quando não conseguimos confirmar conflito; o serviço de importação # continua responsável por validar a operação. return "" return "" def _opportunity_jasmin_state(opportunity_id: str) -> dict: # Small UI helper: summarize current Jasmin evidence imported in ClientFlow. try: from sqlalchemy import text from app.db import engine with engine.begin() as conn: row = conn.execute(text(""" SELECT COUNT(*) FILTER (WHERE system = 'jasmin')::int AS jasmin_documents, COUNT(*) FILTER (WHERE system = 'jasmin' AND document_kind = 'quotation')::int AS quotations, COUNT(*) FILTER (WHERE system = 'jasmin' AND document_kind = 'proforma')::int AS proformas, COUNT(*) FILTER (WHERE system = 'jasmin' AND document_kind = 'invoice')::int AS invoices, (ARRAY_AGG(document_number ORDER BY COALESCE(document_date, created_at::date) DESC, created_at DESC) FILTER (WHERE system = 'jasmin'))[1] AS current_document_number, (ARRAY_AGG(document_kind ORDER BY COALESCE(document_date, created_at::date) DESC, created_at DESC) FILTER (WHERE system = 'jasmin'))[1] AS current_document_kind, (ARRAY_AGG(total_amount ORDER BY COALESCE(document_date, created_at::date) DESC, created_at DESC) FILTER (WHERE system = 'jasmin'))[1] AS current_total_amount FROM commercial_documents WHERE opportunity_id = CAST(:opportunity_id AS UUID) """), {"opportunity_id": str(opportunity_id)}).mappings().first() item_count = conn.execute(text(""" SELECT COUNT(*)::int FROM opportunity_items WHERE opportunity_id = CAST(:opportunity_id AS UUID) """), {"opportunity_id": str(opportunity_id)}).scalar() or 0 data = dict(row or {}) data["item_count"] = int(item_count or 0) return data except Exception: return {"jasmin_documents": 0, "item_count": 0} def _opportunity_consistency_alert_html(opportunity: dict, tasks: list[dict], opportunity_items: list[dict], opportunity_id: str) -> str: # Surface soft inconsistencies without blocking the operator. state = _opportunity_jasmin_state(opportunity_id) stage = str(opportunity.get("stage") or "") pending_action_codes = {str(t.get("action_code") or "") for t in tasks if str(t.get("status") or "") == "pending"} has_payment_task = bool({"CONFIRM_PAYMENT", "CONFIRM_PAYMENT_AND_PREPARE_SHIPMENT"} & pending_action_codes) has_quote = int(state.get("quotations") or 0) > 0 has_proforma = int(state.get("proformas") or 0) > 0 has_invoice = int(state.get("invoices") or 0) > 0 has_items = bool(opportunity_items) or int(state.get("item_count") or 0) > 0 alerts = [] if has_payment_task and has_quote and not (has_proforma or has_invoice): alerts.append( "Existe tarefa de confirmar pagamento e o documento Jasmin atual é orçamento. " "Isto está correto no fluxo normal BLIF: confirma pagamento com base no orçamento antes de emitir fatura." ) if stage == "WAITING_PAYMENT" and has_quote and not (has_proforma or has_invoice): alerts.append( "A fase está em pagamento com apenas orçamento Jasmin importado. Isto pode estar correto: no fluxo normal, a fatura é emitida após confirmação do pagamento." ) if has_items and int(state.get("jasmin_documents") or 0) <= 0: alerts.append( "A oportunidade tem produtos, mas ainda não tem documento Jasmin importado. Usa Reimportar detalhes ou Criar orçamento." ) if not alerts: return "" items = "".join(f"
  • {esc(a)}
  • " for a in alerts[:3]) return f'''
    Verificação de consistência operacional
    ''' def _derived_timeline_html(opportunity_id: str) -> str: # Fallback timeline based on current documents/items/tasks when no audit events exist. try: from sqlalchemy import text from app.db import engine with engine.begin() as conn: docs = conn.execute(text(""" SELECT document_kind, document_number, total_amount, status, created_at FROM commercial_documents WHERE opportunity_id = CAST(:opportunity_id AS UUID) ORDER BY created_at DESC LIMIT 3 """), {"opportunity_id": str(opportunity_id)}).mappings().all() item_count = conn.execute(text(""" SELECT COUNT(*)::int FROM opportunity_items WHERE opportunity_id = CAST(:opportunity_id AS UUID) """), {"opportunity_id": str(opportunity_id)}).scalar() or 0 except Exception: docs, item_count = [], 0 items = "" for doc in docs: title = "Documento Jasmin importado" detail = f"{doc.get('document_number') or 'documento'} · {money_html(doc.get('total_amount') or 0)}" items += f'''
    {esc(fmt_dt(doc.get('created_at')))}
    derivado
    {esc(title)}{operation_status_badge(str(doc.get('status') or 'created'))}
    {esc(detail)}
    ''' if item_count and not docs: items += f'''
    derivado
    Produtos na oportunidade
    {esc(item_count)} linha(s) comerciais associadas.
    ''' return items def _parse_opportunity_dt(value: object): if not value: return None if isinstance(value, datetime): dt = value else: try: dt = datetime.fromisoformat(str(value).replace("Z", "+00:00")) except Exception: return None if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc) def _opportunity_lifecycle_state(opp: dict) -> str: state = str(opp.get("lifecycle_state") or "active").strip().lower() or "active" now = datetime.now(timezone.utc) nurture_until = _parse_opportunity_dt(opp.get("nurture_until")) next_follow_up = _parse_opportunity_dt(opp.get("next_follow_up_at")) if state == "nurture" and nurture_until and nurture_until <= now: return "follow_up_due" if state in {"awaiting_customer", "active"} and next_follow_up and next_follow_up <= now: return "follow_up_due" return state def _opportunity_last_commercial_activity(opp: dict): for key in ("last_customer_activity_at", "last_operator_activity_at", "last_commercial_activity_at", "last_message_at"): dt = _parse_opportunity_dt(opp.get(key)) if dt: return dt return None def _opportunity_inactive(opp: dict) -> bool: if _opportunity_lifecycle_state(opp) in {"recovery", "nurture"}: return False last_activity = _opportunity_last_commercial_activity(opp) attempts = int(opp.get("follow_up_attempts") or 0) if not last_activity: return attempts >= 2 days = (datetime.now(timezone.utc) - last_activity).days return days >= 10 and attempts >= 2 def _opportunity_query_string(q: Optional[str] = None, status: Optional[str] = "open", scope: Optional[str] = "all", limit: int = 300) -> str: parts = [] if q: parts.append(f"q={esc(q)}") if status and status != "open": parts.append(f"status={esc(status)}") if scope and scope != "all": parts.append(f"scope={esc(scope)}") if limit and int(limit) != 300: parts.append(f"limit={int(limit)}") return ("?" + "&".join(parts)) if parts else "" def _attach_central_next_actions(opportunities: list[dict]) -> None: """Enrich board rows with the same next-action engine used by details. The board used to derive labels from the stored legacy stage, which made closed-ready opportunities appear as "Enviar tracking" and WH/MO cases as "Acompanhar produção". Keep this read-only and best-effort: if the central engine fails for one card, the card falls back to the legacy text. """ for opp in opportunities: if isinstance(opp.get("clientflow_next_action"), dict): continue oid = str(opp.get("id") or "").strip() if not oid: continue try: decision = get_opportunity_next_action(oid) except Exception as exc: decision = { "action_code": "DECISION_ERROR", "label": opportunity_next_action_text(opp), "description": f"Falha ao calcular próxima ação central: {exc}", } if isinstance(decision, dict): opp["clientflow_next_action"] = decision def _central_next_action_for_card(opp: dict) -> dict: decision = opp.get("clientflow_next_action") return decision if isinstance(decision, dict) else {} def _opportunity_visible_set(q: Optional[str] = None, status: Optional[str] = "open", scope: Optional[str] = "all", limit: int = 300) -> tuple[list[dict], dict, list[tuple[str, str, object]]]: if (status or "open") == "closed": status = "open" opportunities = list_opportunities(q=q, status=status or "open", limit=limit) _attach_central_next_actions(opportunities) visible_board_columns = [column for column in OPPORTUNITY_BOARD_COLUMNS if column[0] not in {"closed", "archived"}] grouped = {key: [] for key, _label, _stages in visible_board_columns} visible = [] for opportunity in opportunities: if _is_noise_opportunity(opportunity): continue key = _opportunity_board_column_for_opportunity(opportunity) if key in {"closed", "archived"}: continue if scope and scope not in {"all", "open"}: lifecycle_state = _opportunity_lifecycle_state(opportunity) scope_to_column = {"new": "requests", "quote": "sent", "shipment": "operations"} if scope == "blocked": pending = int(opportunity.get("pending_task_count") or 0) if pending <= 0 and not opportunity_customer_mismatch(opportunity): continue elif scope == "active": if lifecycle_state not in {"active", "awaiting_customer"} or _opportunity_inactive(opportunity): continue elif scope == "awaiting_customer": if lifecycle_state != "awaiting_customer": continue elif scope == "follow_up_due": if lifecycle_state != "follow_up_due": continue elif scope == "recovery": if lifecycle_state != "recovery": continue elif scope == "nurture": if lifecycle_state != "nurture": continue elif scope == "inactive": if not _opportunity_inactive(opportunity): continue elif scope == "unvalued": if float(opportunity.get("value_amount") or 0) > 0: continue elif key != scope_to_column.get(scope, scope): continue visible.append(opportunity) grouped.setdefault(key, []).append(opportunity) return visible, grouped, visible_board_columns def _compact_identity(value: object) -> str: value = compact_text(str(value or "").strip(), 42) if value.casefold() in {"", "geral", "cliente", "contacto"} or value.isdigit(): return "" return value def _opportunity_card_identity(opp: dict) -> tuple[str, str]: fiscal = _compact_identity(opp.get("linked_customer_name")) contact_name = _compact_identity(opp.get("customer_name")) contact_email = _compact_identity(opp.get("customer_email")) if fiscal: subtitle = contact_email or contact_name return fiscal, (f"Contacto: {subtitle}" if subtitle and subtitle != fiscal else "") if contact_name: return contact_name, contact_email if contact_email and contact_email != contact_name else "" if contact_email: return contact_email, "" conversation = str(opp.get("conversation_id") or "").strip() return "Contacto sem identificação", (f"Conversa Chatwoot #{conversation}" if conversation else "") # Legacy regression context: cta_label = "Concluir tarefa pendente" if pending else "Ver oportunidade". # v4.8.5 replaces that generic CTA with a specific action label. def _opportunity_card_next_action(opp: dict) -> str: lifecycle_state = _opportunity_lifecycle_state(opp) pending_follow_up_action = str(opp.get("pending_follow_up_action") or "").strip() pending_follow_up_code = str(opp.get("pending_follow_up_action_code") or "").strip() if lifecycle_state == "recovery": return pending_follow_up_action or "Recuperar oportunidade sem resposta" if lifecycle_state == "follow_up_due": return pending_follow_up_action or primary_action_label(pending_follow_up_code, fallback="Executar follow-up vencido") if lifecycle_state == "nurture": return pending_follow_up_action or "Aguardar data para retomar contacto" if lifecycle_state == "awaiting_customer": due = _parse_opportunity_dt(opp.get("next_follow_up_at")) return f"Aguardar resposta até {due.strftime('%d/%m')}" if due else "Aguardar resposta do cliente" metadata = opp.get("metadata") if isinstance(opp.get("metadata"), dict) else {} 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 "") 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") return opportunity_next_action_text(opp) def _is_noise_opportunity(opp: dict) -> bool: """Hide old bounce/NDR opportunities from the commercial board. Operations already hides technical mailbox noise; the opportunity board must use the same guard so legacy Mail Delivery/postmaster opportunities do not keep appearing as commercial work. """ return is_noise_operation_item({ "customer_name": opp.get("customer_name"), "contact_display_name": opp.get("customer_name"), "fiscal_customer_name": opp.get("linked_customer_name"), "message_subject": opp.get("product_interest"), "title": opp.get("title"), "detail": opp.get("product_interest"), "request_text": (opp.get("metadata") or {}).get("request_text") if isinstance(opp.get("metadata"), dict) else "", "source_system": opp.get("source_system"), "action_code": opp.get("last_action_code"), "no_opportunity_reason": (opp.get("metadata") or {}).get("no_opportunity_reason") if isinstance(opp.get("metadata"), dict) else "", "status": opp.get("status"), }) def _opportunity_board_column_for_opportunity(opp: dict) -> str: """Choose the visual column from the same first-safe-action precedence.""" metadata = opp.get("metadata") if isinstance(opp.get("metadata"), dict) else {} 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 if effective_code in {"SEND_INVOICE", "SEND_PROFORMA", "CONFIRM_PAYMENT", "FOLLOW_UP_PAYMENT"}: return "payment" if effective_code in { "PREPARE_ORDER", "CREATE_SHIPMENT", "WAIT_PRODUCTION", "WAIT_ODOO", "CLOSE_OPPORTUNITY", "VALIDATE_PHYSICAL_ORDER", }: return "operations" if effective_code in {"ASSOCIATE_OPPORTUNITY", "REVIEW_ASSOCIATION", "LINK_DOCUMENT", "REVIEW_RECONSTRUCTED_PROCESS"}: return "requests" return _opportunity_board_column_for_stage(opp.get("stage")) def _render_opportunity_card(opp: dict) -> str: oid = str(opp.get("id") or "") title, subtitle = _opportunity_card_identity(opp) subject = compact_text(opp.get("product_interest") or opp.get("title") or "Pedido comercial", 64) next_action = compact_text(_opportunity_card_next_action(opp), 72) pending = int(opp.get("pending_task_count") or 0) blockers = opportunity_blockers(opp) lifecycle_state = _opportunity_lifecycle_state(opp) state_label = lifecycle_label(lifecycle_state) state_class = { "active": "text-bg-success", "awaiting_customer": "text-bg-info", "follow_up_due": "text-bg-warning", "recovery": "text-bg-danger", "nurture": "text-bg-secondary", }.get(lifecycle_state, "text-bg-light") last_customer = _parse_opportunity_dt(opp.get("last_customer_activity_at") or opp.get("last_message_at")) customer_age = "Sem atividade do cliente registada" if last_customer: days = max(0, (datetime.now(timezone.utc) - last_customer).days) customer_age = "Cliente respondeu hoje" if days == 0 else f"Sem resposta do cliente há {days} dia(s)" next_follow = _parse_opportunity_dt(opp.get("next_follow_up_at") or opp.get("nurture_until")) follow_text = f"Próximo contacto: {next_follow.strftime('%d/%m/%Y')}" if next_follow else "Sem próximo contacto agendado" attempts = int(opp.get("follow_up_attempts") or 0) value = float(opp.get("value_amount") or 0) value_text = money_html(value) if value > 0 else "Valor por definir" cta_label = next_action if pending else "Ver oportunidade" cta_class = "btn-primary" if pending or lifecycle_state in {"follow_up_due", "recovery"} else "btn-outline-primary" blocker_html = blocker_alert_html(blockers, empty_text="") if blockers else "" subtitle_html = f'
    {esc(subtitle)}
    ' if subtitle else "" blocker_class = " has-blocker" if blockers else "" return f"""
    {esc(state_label)}{value_text}
    {esc(title)}
    {subtitle_html}
    {esc(subject)}
    {esc(customer_age)} · {esc(follow_text)} · {attempts} tentativa(s)
    {blocker_html}
    Próxima ação {esc(next_action)}
    {esc(cta_label)}
    """ def render_opportunities_board_partial(q: Optional[str] = None, status: Optional[str] = "open", scope: Optional[str] = "all", limit: int = 300) -> str: visible_opportunities, grouped, visible_board_columns = _opportunity_visible_set(q=q, status=status, scope=scope, limit=limit) board_html = "" for key, label, _stages in visible_board_columns: cards = "".join(_render_opportunity_card(opp) for opp in grouped.get(key, [])) if not cards: cards = '
    Sem oportunidades nesta etapa.
    ' board_html += f"""
    {esc(label)} {len(grouped.get(key, []))}
    {cards}
    """ return f"""
    {len(visible_opportunities)} resultado(s) A atualizar…
    {board_html}
    """ @router.get("/opportunities/partials/board", response_class=HTMLResponse) async def opportunities_board_partial(q: Optional[str] = None, status: Optional[str] = "open", scope: Optional[str] = "all", limit: int = 300): return HTMLResponse(render_opportunities_board_partial(q=q, status=status, scope=scope, limit=limit)) @router.get("/opportunities", response_class=HTMLResponse) @router.get("/oportunidades", response_class=HTMLResponse) async def opportunities_page( request: Request, q: Optional[str] = None, status: Optional[str] = "open", scope: Optional[str] = "all", limit: int = 300, ): # Quadro operacional em Bootstrap 5. v4.7.4 adds an HTMX board partial # while preserving the same opportunity query and card semantics. if (status or "open") == "closed": status = "open" visible_opportunities, grouped, visible_board_columns = _opportunity_visible_set(q=q, status=status, scope=scope, limit=limit) total_open = sum(1 for opp in visible_opportunities if str(opp.get("status") or "") == "open") total_pending = sum(int(opp.get("pending_task_count") or 0) for opp in visible_opportunities) total_value = sum(float(opp.get("value_amount") or 0) for opp in visible_opportunities) attention = [opp for opp in visible_opportunities if int(opp.get("pending_task_count") or 0) > 0] active_count = sum(1 for opp in visible_opportunities if _opportunity_lifecycle_state(opp) in {"active", "awaiting_customer"} and not _opportunity_inactive(opp)) due_count = sum(1 for opp in visible_opportunities if _opportunity_lifecycle_state(opp) == "follow_up_due") recovery_count = sum(1 for opp in visible_opportunities if _opportunity_lifecycle_state(opp) == "recovery") unvalued_count = sum(1 for opp in visible_opportunities if float(opp.get("value_amount") or 0) <= 0) if is_htmx(request): return HTMLResponse(render_opportunities_board_partial(q=q, status=status, scope=scope, limit=limit)) status_options = "" for value, label in [("open", "Abertas"), ("all", "Todas")]: selected = "selected" if (status or "open") == value else "" status_options += f'' stage_tabs = "" filters = [ ("all", "Todas"), ("new", "Novas"), ("quote", "Orçamento enviado"), ("payment", "Pagamento pendente"), ("active", "Ativas"), ("awaiting_customer", "A aguardar cliente"), ("follow_up_due", "Follow-up vencido"), ("recovery", "Recuperação"), ("unvalued", "Por valorizar"), ("inactive", "Inativas"), ("nurture", "Acompanhamento futuro"), ("shipment", "Operação/entrega"), ("blocked", "Bloqueadas"), ] for key, label in filters: href = "/opportunities" + _opportunity_query_string(q=q, status=status, scope=key, limit=limit) partial_href = "/opportunities/partials/board" + _opportunity_query_string(q=q, status=status, scope=key, limit=limit) active = "btn-primary" if (scope or "all") == key else "btn-outline-secondary" stage_tabs += f'{esc(label)}' body = f"""
    Ativas
    {active_count}
    com contacto em curso
    Follow-up vencido
    {due_count}
    contactar agora
    Recuperação
    {recovery_count}
    decisão comercial necessária
    Por valorizar
    {unvalued_count}
    de {total_open} abertas · {money_html(total_value)}
    Limpar
    Filtros:{stage_tabs}

    Quadro de oportunidades

    Cards por etapa, com identificação clara, assunto, próxima ação e bloqueios relevantes. Filtros atualizam por HTMX.
    {render_opportunities_board_partial(q=q, status=status, scope=scope, limit=limit)}
    """ return layout("Oportunidades", "Pipeline comercial com foco na próxima ação", body, active="opportunities") @router.get("/opportunities/{opportunity_id}", response_class=HTMLResponse) async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = None): if not is_uuid_text(opportunity_id): return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) opportunity = get_opportunity(opportunity_id) if not opportunity: return layout("Oportunidade não encontrada", "Pipeline comercial", '
    Oportunidade não encontrada.
    ', "opportunities") tasks = list_opportunity_tasks(opportunity_id, limit=100) events = list_opportunity_events(opportunity_id, limit=100) stage = str(opportunity.get("stage") or "NEW_LEAD") terminal_stage = str(opportunity.get("status") or "").lower() == "closed" or stage in {"WON", "LOST", "NO_INTEREST", "DELIVERED"} all_pending_tasks = [t for t in tasks if str(t.get("status")) == "pending"] payment_confirmed_for_ui = stage == "PAYMENT_CONFIRMED" if terminal_stage: pending_tasks = [ t for t in all_pending_tasks if str(t.get("action_code") or "").upper() not in { "FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA", "FOLLOW_UP_PAYMENT", "FOLLOW_UP_CUSTOMER_REVIEW", "FOLLOW_UP_GENERIC", "CONFIRM_DELIVERY", "RECOVER_OPPORTUNITY", "REVIEW_NURTURE", } ] else: pending_tasks = [t for t in all_pending_tasks if not _is_obsolete_after_payment_task(t, payment_confirmed_for_ui)] next_task = pending_tasks[0] if pending_tasks else None opportunity_items = list_opportunity_items(opportunity_id) active_products = list_products(active="true", limit=200) try: from app.commercial_service import list_commercial_documents linked_documents = list_commercial_documents(opportunity_id=opportunity_id, limit=8) except Exception: linked_documents = [] primary_document = next( ( doc for doc in linked_documents if str(doc.get("document_kind") or "") == "invoice" and str(doc.get("role") or "current") in {"current", "accepted"} and bool(doc.get("is_primary", True)) ), next( ( doc for doc in linked_documents if str(doc.get("role") or "current") in {"current", "accepted"} and bool(doc.get("is_primary", True)) ), linked_documents[0] if linked_documents else None, ), ) opportunity_items_total = sum( float(item.get("total_price") or 0) for item in opportunity_items if str(item.get("status") or "").upper() not in {"REJECTED", "CANCELLED", "DELIVERED", "HISTORICAL"} ) document_value = float(primary_document.get("total_amount") or primary_document.get("amount") or 0) if primary_document else 0 estimated_value = document_value or opportunity_items_total or float(opportunity.get("value_amount") or 0) value_source = "documento principal" if document_value else ("linhas atuais" if opportunity_items_total else "oportunidade") operation_snapshot = get_operation_snapshot(opportunity_id) opportunity_for_cockpit = dict(opportunity) opportunity_for_cockpit["pending_task_count"] = len(pending_tasks) try: opportunity_communications = list_communications_for_opportunity(opportunity_id, limit=12) except Exception: opportunity_communications = [] notice_html = f'
    {esc(notice)}
    ' if notice else '' metadata = _opportunity_metadata(opportunity) payment_term = str(metadata.get("payment_terms") or "before_shipping") delivery_term = str(metadata.get("delivery_terms") or "carrier") commercial_terms_note = str(metadata.get("commercial_terms_note") or "") payment_term_label, delivery_term_label = _payment_terms_summary(metadata) record_mode = str(metadata.get("clientflow_record_mode") or "") legacy_mode = record_mode in {"reconstructed_invoice_review", "historical_reconstructed", "legacy_review"} legacy_notice_html = "" if legacy_mode: review_state = reconstructed_review_status(metadata) if review_state in {"validated", "waived"}: legacy_notice_html = ( '
    ' 'Registo reconstruído validado.
    ' 'A oportunidade foi normalizada a partir de documentos existentes e a revisão obrigatória já foi concluída.' '
    ' ) elif review_state == "required": legacy_notice_html = ( '
    ' 'Processo reconstruído por validar.
    ' 'Confirma cliente, documento principal, valor e evidência de pagamento antes de executar ações sensíveis.' '
    ' ) else: legacy_notice_html = ( '
    ' 'Registo antigo/reconstruído sem estado explícito de revisão.
    ' 'Executa a migração v132 para definir se a revisão está pendente ou já foi concluída.' '
    ' ) opportunity_return_to = f"/opportunities/{opportunity_id}" try: next_action = get_opportunity_next_action(opportunity_id) except Exception: next_action = {} lifecycle_state_for_detail = _opportunity_lifecycle_state(opportunity) lifecycle_task = next(( task for task in pending_tasks if str(task.get("action_code") or "").upper() in { "CONFIRM_DELIVERY", "FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA", "FOLLOW_UP_PAYMENT", "FOLLOW_UP_CUSTOMER_REVIEW", "FOLLOW_UP_GENERIC", "RECOVER_OPPORTUNITY", "REVIEW_NURTURE", } ), None) lifecycle_override = False if lifecycle_task and lifecycle_state_for_detail in {"follow_up_due", "recovery", "nurture"}: lifecycle_override = True next_action = { "action_code": str(lifecycle_task.get("action_code") or "FOLLOW_UP_GENERIC"), "label": str(lifecycle_task.get("action") or primary_action_label(lifecycle_task.get("action_code"))), "description": str(lifecycle_task.get("note") or "Continuar acompanhamento comercial."), "target_url": f"/tasks/{lifecycle_task.get('id')}", "source": "lifecycle_task", } if reconstructed_review_required(metadata): review_task = next(( task for task in pending_tasks if str(task.get("action_code") or "").upper() == "REVIEW_RECONSTRUCTED_PROCESS" ), None) next_action = { "action_code": "REVIEW_RECONSTRUCTED_PROCESS", "label": "Validar processo reconstruído", "description": "Confirmar cliente, documento principal, valor e evidências antes de executar a ação sensível seguinte.", "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: 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"))), "description": str(next_task.get("note") or "Executar tarefa pendente."), "target_url": f"/tasks/{next_task.get('id')}", "source": "pending_task", } # Materialize human-only central actions into actual pending tasks. # The top-level next action should not be an abstract label when the workbench # expects an operator to perform it. The helper is idempotent and currently # creates SEND_INVOICE/FOLLOW_UP_PAYMENT tasks when needed. try: materialized_task = ensure_pending_task_for_next_action( opportunity_id, next_action if isinstance(next_action, dict) else {}, source="opportunity_detail", actor="system", ) except Exception: materialized_task = {"created": False} if materialized_task.get("created"): tasks = list_opportunity_tasks(opportunity_id, limit=100) all_pending_tasks = [t for t in tasks if str(t.get("status")) == "pending"] if terminal_stage: pending_tasks = [ t for t in all_pending_tasks if str(t.get("action_code") or "").upper() not in { "FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA", "FOLLOW_UP_PAYMENT", "FOLLOW_UP_CUSTOMER_REVIEW", "FOLLOW_UP_GENERIC", } ] else: pending_tasks = [t for t in all_pending_tasks if not _is_obsolete_after_payment_task(t, payment_confirmed_for_ui)] next_task = pending_tasks[0] if pending_tasks else None opportunity_for_cockpit["pending_task_count"] = len(pending_tasks) try: next_action = get_opportunity_next_action(opportunity_id) except Exception: pass if isinstance(next_action, dict): # v1.5.107: keep the legacy operational cockpit aligned with the # central decision engine. Without this, CLOSE_OPPORTUNITY could show # at the top while the cockpit still suggested an old SEND_INVOICE # action from the legacy workflow plan. opportunity_for_cockpit["clientflow_next_action"] = dict(next_action) if next_action: 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") 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='')}" button_label = "Abrir tarefa" if str(target_url).startswith("/tasks/") else "Continuar" if action_code_upper == "VALIDATE_FISCAL_CUSTOMER": primary_button = f'
    ' elif action_code_upper == "CLOSE_OPPORTUNITY": primary_button = ( f'
    ' '' '' '' '
    ' ) else: primary_button = f'{esc(button_label)}' elif next_task: primary_action = action_label(next_task.get("action_code")) primary_note = _safe_opportunity_task_text(next_task.get("note") or next_task.get("action") or "Abrir tarefa pendente para continuar.") primary_button = f'Abrir tarefa' else: primary_action = opportunity_next_action_text(opportunity) primary_note = "Não existe tarefa pendente ligada. Atualiza o estado ou acompanha a oportunidade." primary_button = 'Ver tarefas' task_rows = "" for task in tasks[:8]: task_rows += f''' {esc(action_label(task.get('action_code')))}
    {esc(compact_text(_safe_opportunity_task_text(task.get('note') or task.get('action') or ''), 70))}
    {route_badge(task.get('route'))} {status_badge(task.get('status'))} {esc(fmt_dt(task.get('due_at') or task.get('created_at')))} ''' if not task_rows: task_rows = 'Sem tarefas associadas.' communication_rows = "" for communication in opportunity_communications: action = classification_action(communication.get("classification")) communication_rows += f''' {esc(communication.get('subject') or 'Sem assunto')}
    {esc(communication.get('sender_name') or communication.get('sender_email') or '—')}
    {esc(communication.get('classification') or 'por classificar')} {status_badge(communication.get('status'))} {esc(fmt_dt(communication.get('created_at')))} ''' if not communication_rows: conv = str(opportunity.get("conversation_id") or "").strip() if conv: communication_rows = f''' Conversa Chatwoot #{esc(conv)}
    Ainda não há mensagens indexadas/ligadas nesta oportunidade.
    por sincronizar sem ligação local — ''' else: communication_rows = 'Sem comunicações associadas à oportunidade.' timeline_items = "" try: unified_timeline = list_unified_opportunity_timeline(opportunity_id, limit=14) except Exception: unified_timeline = [] for event in unified_timeline: status = event.get("status") status_html = status_badge(status) if status else "" source = event.get("source") or "event" detail = compact_text(event.get("detail") or "", 140) timeline_items += f'''
    {esc(fmt_dt(event.get('created_at')))}
    {esc(source)}
    {esc(event.get('title') or 'Evento')}{status_html}
    {esc(detail or '—')}
    ''' if not timeline_items: timeline_items = _derived_timeline_html(opportunity_id) if not timeline_items: timeline_items = '
    Sem eventos registados.
    ' # Mostrar poucas fases comerciais. Estados financeiros/Odoo/envio continuam # visíveis como evidência derivada, mas deixam de dominar o dropdown. stage_options = _commercial_stage_options_html(stage) customer_name = opportunity_customer_name(opportunity) contact_name = opportunity_contact_name(opportunity) customer_email = opportunity.get("customer_email") or "" customer_phone = opportunity.get("customer_phone") or "" conversation = opportunity.get("conversation_id") or "—" # v4.6.2: não mostrar aviso por divergência de nome. Contacto pessoal e # cliente fiscal/empresa podem ser diferentes e ainda assim estar corretos. customer_mismatch_alert = "" linked_customer = None customer_options = '' try: from app.commercial_service import get_customer_for_opportunity, list_customers linked_customer = get_customer_for_opportunity(opportunity_id) for c in list_customers(limit=150): selected = "selected" if linked_customer and str(c.get("id")) == str(linked_customer.get("id")) else "" label = f"{c.get('name') or 'Cliente'} · {c.get('tax_id') or 'sem NIF'}" customer_options += f'' except Exception: linked_customer = None fiscal_suggestions_html = _render_fiscal_suggestions(opportunity_id, linked_customer) email_identity_html = _render_email_identity_review(opportunity_id, linked_customer) try: from app.jasmin_fiscal_sync_service import get_jasmin_fiscal_sync_preview jasmin_fiscal_preview = get_jasmin_fiscal_sync_preview(opportunity_id) except Exception: jasmin_fiscal_preview = {"available": False} fiscal_customer = opportunity_context_customer(opportunity, linked_customer) fiscal_customer_href = f"/customers/{esc(fiscal_customer.get('id'))}" if fiscal_customer and fiscal_customer.get("id") else "" fiscal_contact_html = fiscal_contact_panel_html( fiscal_customer=fiscal_customer, contact_name=contact_name, contact_email=customer_email, contact_phone=customer_phone, conversation_id=opportunity.get("conversation_id"), contact_id=opportunity.get("contact_id"), customer_href=fiscal_customer_href, ) next_action_code = (next_action.get("action_code") if isinstance(next_action, dict) else None) or opportunity.get("last_action_code") current_blockers = opportunity_blockers(opportunity, linked_customer, action_code=next_action_code) document_already_issued = bool( primary_document or linked_documents or stage in {"QUOTE_SENT", "PROFORMA_SENT", "INVOICE_SENT", "WAITING_PAYMENT", "PAYMENT_CONFIRMED", "WON"} ) blockers_html = ( '
    Avisos para revisão' + '
    Existe documento emitido/ligado; estes dados devem ser revistos para próximos documentos ou correção administrativa.
    ' if current_blockers and document_already_issued else blocker_alert_html(current_blockers) ) fiscal_readiness_html = readiness_checklist_html( title="Prontidão para documentos", missing=fiscal_customer_missing_fields(fiscal_customer), ok_text="Cliente fiscal pronto para orçamento ou fatura.", blocked_text=("Dados fiscais incompletos no ClientFlow; rever para próximos documentos." if document_already_issued else "Dados fiscais incompletos no ClientFlow; rever antes de emitir novo documento."), ) shipment_readiness_html = readiness_checklist_html( title="Prontidão para envio", missing=shipment_missing_fields(fiscal_customer, opportunity), ok_text="Dados mínimos de envio completos.", blocked_text="Envio deve aguardar correção destes dados.", ) consistency_alert_html = _opportunity_consistency_alert_html(opportunity, tasks, opportunity_items, opportunity_id) if primary_document: document_label = commercial_document_display_number(primary_document, fallback="número por atualizar") document_kind = { "quotation": "Orçamento", "proforma": "Orçamento legado", "invoice": "Fatura", }.get(str(primary_document.get("document_kind") or ""), "Documento") document_state = f"{document_kind} · {document_label}" document_chip = 'ligado' else: document_state = "Sem documento principal" document_chip = 'pendente' fiscal_state = (linked_customer.get("name") if linked_customer else "Por associar") fiscal_missing_for_chip = fiscal_customer_missing_fields(fiscal_customer) if linked_customer else [] if linked_customer and not fiscal_missing_for_chip: fiscal_chip = 'OK' elif linked_customer: fiscal_chip = 'associado · incompleto' else: fiscal_chip = 'sem cliente' task_state = f"{len(pending_tasks)} pendente(s)" if pending_tasks else "Sem tarefas pendentes" task_chip = 'requer ação' if pending_tasks else 'limpo' display_next_action_code = str((next_action.get('action_code') if isinstance(next_action, dict) else None) or (next_task.get('action_code') if next_task else None) or opportunity.get('last_action_code') or 'FOLLOW_UP').upper() if display_next_action_code in {'WAIT_PRODUCTION', 'WAIT_ODOO'} and stage in {'READY_TO_SHIP', 'SHIPMENT_CREATED'}: display_next_action_code = 'SHIP_ORDER' if isinstance(next_action, dict) and str(next_action.get('action_code') or '').upper() == 'SEND_INVOICE': display_next_action_code = 'SEND_INVOICE' operator_summary_html = f'''

    Mapa operacional

    Leitura rápida do processo: cliente fiscal, documento principal, task e próxima ação.
    Ver reconciliação
    Cliente fiscal{esc(fiscal_state)}{fiscal_chip}
    Documento principal{esc(document_state)}{document_chip}
    Tasks{esc(task_state)}{task_chip}
    Decisão seguinte{esc(primary_action)}{esc(display_next_action_code)}
    Ações avançadas
    ''' manual_follow_up_html = f'''

    Criar follow-up

    Agenda uma tarefa de follow-up. O sistema não cria confirmações de entrega automaticamente; usa “Verificar entrega” apenas quando o histórico sugere um problema real.
    ''' lifecycle_state = _opportunity_lifecycle_state(opportunity) lifecycle_state_label = lifecycle_label(lifecycle_state) last_customer_dt = _parse_opportunity_dt(opportunity.get("last_customer_activity_at") or opportunity.get("last_message_at")) last_operator_dt = _parse_opportunity_dt(opportunity.get("last_operator_activity_at")) next_follow_dt = _parse_opportunity_dt(opportunity.get("next_follow_up_at") or opportunity.get("nurture_until")) lifecycle_summary = [ f"Última atividade do cliente: {last_customer_dt.strftime('%d/%m/%Y %H:%M') if last_customer_dt else 'sem registo'}", f"Último contacto do operador: {last_operator_dt.strftime('%d/%m/%Y %H:%M') if last_operator_dt else 'sem registo'}", f"Próximo contacto: {next_follow_dt.strftime('%d/%m/%Y') if next_follow_dt else 'não agendado'}", f"Tentativas: {int(opportunity.get('follow_up_attempts') or 0)}", f"Entrega da última comunicação: {str(opportunity.get('last_delivery_status') or 'não verificada')}", ] loss_reason_options = ''.join( f'' for code, label in LOSS_REASON_LABELS.items() if code != "future_timing" ) lifecycle_management_html = f'''

    Atividade comercial

    Controla espera, recuperação, acompanhamento futuro e perda sem usar updated_at técnico como sinal de atividade.
    {esc(lifecycle_state_label)}
    ''' correction_state = _opportunity_manual_correction_state(opportunity_id) if correction_state.get("unavailable"): correction_badge_html = """
    Ligações atuais temporariamente indisponíveis por sincronização/reconciliação em curso. Reabre esta secção dentro de segundos se precisares de corrigir associações.
    """ else: correction_badge_html = f"""
    Ligações atuais: Odoo {esc(correction_state.get('odoo_links', 0))} · Jasmin docs {esc(correction_state.get('jasmin_documents', 0))} · linhas importadas {esc(correction_state.get('imported_lines', 0))} · candidatos ligados {esc(correction_state.get('reconciliation_items', 0))}
    """ correction_stage_options = "" for value in ["INFO_SENT", "INFO_REQUESTED", "QUOTE_REQUESTED", "QUOTE_SENT", "REVIEW", "NO_INTEREST", "LOST"]: label = OPPORTUNITY_STAGE_LABELS.get(value, value) selected = "selected" if value == "INFO_SENT" else "" correction_stage_options += f'' manual_correction_html = f"""
    Correção avançada de associação operacional Corrigir associação operacional
    Abrir apenas quando Odoo/Jasmin foram associados ao processo errado.
    {correction_badge_html}
    Zona sensível: não altera Odoo/Jasmin; só limpa a leitura local no ClientFlow e regista auditoria.
    """ archive_spam_state = _opportunity_archive_spam_state(opportunity_id) archive_spam_html = "" if archive_spam_state.get("can_archive"): archive_hint = "Existe evidência de spam nesta oportunidade." if archive_spam_state.get("has_spam_evidence") else "Usa apenas para falso positivo/spam sem documentos nem Odoo/Jasmin." archive_spam_html = f"""
    Arquivar spam/falso positivo
    Exclui esta oportunidade do funil sem contar como perdida. Mantém auditoria.
    {esc(archive_hint)} A ação é recusada se houver documentos Jasmin, Odoo, Packlink ou reconciliação externa ligada.
    """ technical_html = f'''
    ID
    {esc(opportunity_id)}
    Conversa
    {esc(conversation)}
    Última action
    {esc(opportunity.get('last_action_code') or '—')}
    Atualizada
    {esc(fmt_dt(opportunity.get('updated_at')))}
    ''' # Tarefa ativa folded into "O que fazer agora?" to avoid duplicate cards like # "Enviar orçamento" appearing twice in the Operation column. Legacy static # tests still look for the label "Tarefa ativa" to guard the old refresh flow. next_task_focus_html = "" payment_term_hint = "" if payment_term == "after_delivery": payment_term_hint = '
    Pagamento pós-entrega: preparação/envio podem avançar com encomenda confirmada; depois acompanhar fatura/pagamento.
    ' elif payment_term == "before_shipping": payment_term_hint = '
    Pagamento antes do envio: confirmar pagamento com base no orçamento; emitir fatura só depois do pagamento confirmado.
    ' finance_quick_card_html = _finance_quick_card_html(opportunity_id, linked_documents, payment_term, payment_term_label) commercial_terms_card_html = f'''

    Condições comerciais

    Define a regra do processo sem forçar um fluxo único. Fluxo normal BLIF: orçamento → pagamento → fatura → preparar/enviar encomenda.
    {payment_term_hint}
    ''' stage_control_html = f'''

    Alterar fase comercial

    Lista curta: detalhes como fatura, pagamento, Odoo, produção e envio devem ser lidos nos cards de contexto.
    ''' operation_action_html = f'''
    O que fazer agora?

    {esc(primary_action)}

    {esc(primary_note)}
    {primary_button}
    ''' jasmin_fiscal_sync_html = "" if isinstance(jasmin_fiscal_preview, dict) and jasmin_fiscal_preview.get("available"): candidate = jasmin_fiscal_preview.get("candidate") or {} document = jasmin_fiscal_preview.get("document") or {} candidate_line = f"{candidate.get('name') or 'Cliente Jasmin'} · NIF {candidate.get('tax_id') or '—'}" doc_line = " · ".join(str(x) for x in [document.get('document_number'), document.get('document_kind')] if x) fillable = jasmin_fiscal_preview.get("fillable_fields") or [] if jasmin_fiscal_preview.get("conflict"): jasmin_fiscal_sync_html = ( '
    ' 'Dados Jasmin encontrados, mas a importação está bloqueada por NIF divergente. ' 'Revê a associação fiscal antes de importar.
    ' ) else: jasmin_sync_button_label = "Completar com dados Jasmin" if linked_customer else "Associar e completar com Jasmin" fillable_text = ("Campos a preencher: " + ", ".join(str(x) for x in fillable)) if fillable else "Jasmin encontrado, mas não contém novos campos; associa cliente fiscal com base no documento Jasmin." jasmin_fiscal_sync_html = ( '
    ' '
    Dados fiscais disponíveis no Jasmin
    ' f'
    {esc(candidate_line)}
    ' f'
    {esc(doc_line or "documento Jasmin associado")}
    ' f'
    {esc(fillable_text)}
    ' f'
    ' f'' '
    ' ) if linked_customer: linked_customer_label = f"{linked_customer.get('name') or 'Cliente'} · {linked_customer.get('tax_id') or 'sem NIF'}" linked_customer_email = linked_customer.get("email") or "—" linked_customer_address_parts = [ linked_customer.get("street_name"), linked_customer.get("postal_zone"), linked_customer.get("city_name"), ] linked_customer_address = " · ".join(str(part) for part in linked_customer_address_parts if part) or "morada fiscal incompleta" fiscal_missing_inline = fiscal_customer_missing_fields(fiscal_customer) fiscal_status_badges = ( 'associadodados OK' if not fiscal_missing_inline else 'associadodados incompletos' ) fiscal_missing_note = "" if fiscal_missing_inline: fiscal_missing_note = '
    Faltam: ' + esc(", ".join(fiscal_missing_inline)) + '.
    ' fiscal_customer_url = f"/customers/{esc(linked_customer.get('id'))}" if linked_customer.get("id") else "/customers" fiscal_association_card_html = f'''

    Cliente fiscal

    Ficha fiscal associada à oportunidade. Associar cliente fiscal
    {fiscal_status_badges}
    {esc(linked_customer.get('name') or 'Cliente fiscal associado')}
    NIF {esc(linked_customer.get('tax_id') or '—')} · {esc(linked_customer_email)}
    {esc(linked_customer_address)}
    {fiscal_missing_note}
    {jasmin_fiscal_sync_html}
    Sugestões de identidade continuam disponíveis na secção de contexto quando houver candidatos por validar.
    ''' else: fiscal_association_card_html = f'''

    Associar cliente fiscal

    Resolve o bloqueio fiscal desta oportunidade. Escolhe uma ficha existente ou deixa vazio para desassociar.
    {jasmin_fiscal_sync_html}
    Sugestões e identidade extraída
    {email_identity_html}{fiscal_suggestions_html}
    ''' # "Bloqueios atuais" permanece como conceito de UI/teste, mas o layout agora separa operação e contexto. body = f''' ← Voltar a oportunidades {notice_html} {legacy_notice_html} {customer_mismatch_alert} {consistency_alert_html}
    Oportunidade

    {esc(opportunity.get('title') or 'Oportunidade')}

    {esc(customer_name)} · {esc(opportunity.get('product_interest') or 'Interesse por definir')}
    {opportunity_stage_badge(stage)}{opportunity_priority_chip(opportunity)}
    Próxima ação{esc(primary_action)}
    Cliente fiscal{esc(fiscal_state)}
    Documento{esc(document_state)}
    Valor{money_html(estimated_value)}
    Tasks{esc(task_state)}
    Contexto e evidência
    {fiscal_contact_html}
    {fiscal_readiness_html}
    {shipment_readiness_html}

    Resumo essencial

    {'Valor principal' if document_value else ('Valor reconstruído' if legacy_mode else 'Valor estimado')}{money_html(estimated_value)}
    {esc(value_source)}
    Tarefas pendentes{len(pending_tasks)}
    Atualizada{esc(fmt_dt(opportunity.get('updated_at')))}

    Pipeline

    {stage_progress_html(stage)}
    {operation_cockpit_html(opportunity_id, opportunity_for_cockpit, operation_snapshot)}
    {jasmin_documents_html(opportunity_id)}
    {opportunity_products_panel_html(opportunity_id)}
    {odoo_status_panel_html(opportunity_id)}
    {opportunity_integrations_panel_html(opportunity_id)}

    Tasks relacionadas

    Ações humanas já criadas para esta oportunidade.
    {task_rows}
    AçãoFilaEstadoData

    Mensagens Chatwoot

    Mensagens relevantes ligadas a esta oportunidade. A resposta continua no Chatwoot.
    {communication_rows}
    MensagemClassificaçãoEstadoRecebida

    Timeline recente

    {timeline_items}
    Ver detalhes técnicos e edição avançada
    {technical_html}
    ''' return layout(str(opportunity.get("title") or "Oportunidade"), "Detalhe comercial com informação essencial", body, "opportunities") @router.get("/opportunities/{opportunity_id}/partials/odoo-status", response_class=HTMLResponse) async def opportunity_odoo_status_partial(opportunity_id: str): if not is_uuid_text(opportunity_id): return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) return HTMLResponse(odoo_status_panel_html(opportunity_id)) @router.get("/opportunities/{opportunity_id}/partials/jasmin-documents", response_class=HTMLResponse) async def opportunity_jasmin_documents_partial(opportunity_id: str): if not is_uuid_text(opportunity_id): return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) return HTMLResponse(jasmin_documents_html(opportunity_id)) @router.get("/opportunities/{opportunity_id}/partials/products", response_class=HTMLResponse) async def opportunity_products_partial(opportunity_id: str): if not is_uuid_text(opportunity_id): return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) return HTMLResponse(opportunity_products_panel_html(opportunity_id)) @router.post("/commercial-documents/{document_id}/unlink-from-opportunity") async def commercial_document_unlink_from_opportunity_action(document_id: str, request: Request): form = await request.form() opportunity_id = str(form.get("opportunity_id") or "").strip() remove_lines = str(form.get("remove_imported_lines") or "1") == "1" note = str(form.get("note") or "").strip() or "Documento removido manualmente desta oportunidade; pertence a outra compra/processo." if not is_uuid_text(opportunity_id) or not is_uuid_text(document_id): return PlainTextResponse("Identificador inválido.", status_code=422) try: result = unlink_commercial_document_from_opportunity( opportunity_id, document_id, remove_imported_lines=remove_lines, note=note, actor="operator_ui_document_unlink", ) except Exception as exc: if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao desassociar documento: {exc}"), status_code=409) return PlainTextResponse(f"Erro ao desassociar documento: {exc}", status_code=500) notice = ( "Documento desassociado desta oportunidade. " f"Linhas importadas removidas: {result.get('imported_lines_deleted', 0)}." ) if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice)) return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303) @router.post("/commercial-documents/{document_id}/role") async def commercial_document_role_action(document_id: str, request: Request): form = await request.form() opportunity_id = str(form.get("opportunity_id") or "").strip() role = str(form.get("role") or "current").strip().lower() make_primary = str(form.get("make_primary") or "1") == "1" if not is_uuid_text(opportunity_id) or not is_uuid_text(document_id): return PlainTextResponse("Identificador inválido.", status_code=422) try: result = set_commercial_document_role_for_opportunity( opportunity_id, document_id, role=role, make_primary=make_primary, actor="operator_ui_document_role", ) except Exception as exc: if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao atualizar papel do documento: {exc}"), status_code=409) return PlainTextResponse(f"Erro ao atualizar papel do documento: {exc}", status_code=500) label = {"current": "atual", "accepted": "aceite", "related": "relacionado", "historical": "histórico"}.get(result.get("role"), role) notice = f"Documento marcado como {label}." if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice)) return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303) @router.post("/commercial-documents/{document_id}/refresh") async def commercial_document_refresh(document_id: str, request: Request): form = await request.form() opportunity_id = str(form.get("opportunity_id") or "").strip() try: from app.jasmin_service import refresh_commercial_document_from_jasmin await refresh_commercial_document_from_jasmin(document_id) except Exception as exc: if opportunity_id and is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao atualizar documento: {exc}"), status_code=409) return PlainTextResponse(f"Erro ao atualizar documento: {exc}", status_code=500) if opportunity_id and is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, notice="Documento atualizado a partir do Jasmin.")) return RedirectResponse(f"/opportunities/{opportunity_id}" if opportunity_id else "/outbox", status_code=303) @router.get("/commercial-documents/{document_id}/pdf") async def commercial_document_pdf(document_id: str): try: from app.jasmin_service import get_commercial_document_pdf doc, data, content_type = await get_commercial_document_pdf(document_id) except Exception as exc: return PlainTextResponse(f"Erro ao obter PDF Jasmin: {exc}", status_code=500) name = doc.get("document_number") or doc.get("external_id") or document_id safe_name = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in str(name))[:80] or "documento" headers = {"Content-Disposition": f'inline; filename="{safe_name}.pdf"'} return Response(content=data, media_type=content_type or "application/pdf", headers=headers) @router.post("/opportunities/{opportunity_id}/archive-spam") async def opportunity_archive_spam_action(opportunity_id: str, request: Request): if not is_uuid_text(opportunity_id): return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) form = await request.form() reason = str(form.get("reason") or "spam/falso positivo").strip() try: from app.opportunity_service import archive_spam_opportunity_if_safe result = archive_spam_opportunity_if_safe( opportunity_id, reason=reason or "spam/falso positivo", actor="operator_ui_archive_spam", ) except Exception as exc: notice = quote(f"Não foi possível arquivar spam: {exc}") return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303) if not result.get("ok"): msg = result.get("reason") or "não permitido" if msg == "has_commercial_or_external_evidence": msg = "A oportunidade tem documentos ou ligações externas; não foi arquivada automaticamente." notice = quote(f"Arquivar spam recusado: {msg}") return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303) notice = quote("Oportunidade arquivada como spam/falso positivo e excluída do funil.") return RedirectResponse(f"/opportunities?notice={notice}", status_code=303) @router.post("/opportunities/{opportunity_id}/manual-correction") async def opportunity_manual_correction_action(opportunity_id: str, request: Request): if not is_uuid_text(opportunity_id): return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) form = await request.form() unlink_odoo = str(form.get("unlink_odoo") or "") == "1" unlink_jasmin = str(form.get("unlink_jasmin") or "") == "1" remove_imported_lines = str(form.get("remove_imported_lines") or "") == "1" stage = str(form.get("stage") or "INFO_SENT").strip().upper() note = str(form.get("note") or "").strip() or "Correção manual: Odoo/Jasmin pertenciam a outro processo; classificado como informação enviada." try: result = apply_manual_external_correction( opportunity_id, unlink_odoo=unlink_odoo, unlink_jasmin=unlink_jasmin, remove_imported_lines=remove_imported_lines, new_stage=stage, note=note, actor="operator_ui_manual_correction", ) except Exception as exc: if is_htmx(request): return PlainTextResponse(f"Erro na correção manual: {exc}", status_code=409) return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote('Erro na correção manual: ' + str(exc))}", status_code=303) notice = ( "Correção aplicada: " f"Odoo/Jasmin desligados; {result.get('imported_lines_deleted', 0)} linha(s) importada(s) removida(s); " f"fase definida como {OPPORTUNITY_STAGE_LABELS.get(stage, stage)}." ) return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303) @router.post("/opportunities/{opportunity_id}/odoo/unlink") async def opportunity_odoo_unlink_action(opportunity_id: str, request: Request): try: result = apply_manual_external_correction( opportunity_id, unlink_odoo=True, unlink_jasmin=False, remove_imported_lines=True, new_stage="INFO_SENT", note="Correção manual: venda Odoo desassociada da oportunidade.", actor="operator_ui_odoo_unlink", ) if request.headers.get("hx-request"): return HTMLResponse(odoo_status_panel_html(opportunity_id, notice=f"Odoo desassociado. Linhas removidas: {result.get('imported_lines_deleted', 0)}")) except Exception as exc: if request.headers.get("hx-request"): return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=f"Erro ao desassociar Odoo: {exc}"), status_code=409) return PlainTextResponse(f"Erro ao desassociar Odoo: {exc}", status_code=500) return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Odoo%20desassociado", status_code=303) @router.post("/opportunities/{opportunity_id}/jasmin/unlink") async def opportunity_jasmin_unlink_action(opportunity_id: str, request: Request): try: result = apply_manual_external_correction( opportunity_id, unlink_odoo=False, unlink_jasmin=True, remove_imported_lines=True, new_stage="INFO_SENT", note="Correção manual: documentos/candidatos Jasmin desassociados da oportunidade.", actor="operator_ui_jasmin_unlink", ) if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, notice=f"Jasmin desassociado. Documentos removidos: {result.get('jasmin_documents_deleted', 0)} · linhas removidas: {result.get('imported_lines_deleted', 0)}")) except Exception as exc: if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao desassociar Jasmin: {exc}"), status_code=409) return PlainTextResponse(f"Erro ao desassociar Jasmin: {exc}", status_code=500) return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Jasmin%20desassociado", status_code=303) @router.post("/opportunities/{opportunity_id}/external-candidate/{item_id}/ignore") async def opportunity_ignore_external_candidate_action(opportunity_id: str, item_id: str, request: Request): source_system = "" try: with engine.begin() as conn: source_system = str(conn.execute(text(""" SELECT source_system FROM reconciliation_items WHERE id = CAST(:item_id AS UUID) """), {"item_id": item_id}).scalar() or "") count = ignore_external_candidate_for_opportunity(opportunity_id, item_id) except Exception as exc: if request.headers.get("hx-request"): if source_system == "odoo": return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=f"Erro ao ignorar candidato: {exc}"), status_code=409) return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao ignorar candidato: {exc}"), status_code=409) return PlainTextResponse(f"Erro ao ignorar candidato: {exc}", status_code=500) notice = "Candidato ignorado." if count else "Candidato não encontrado ou já ignorado." if request.headers.get("hx-request"): if source_system == "odoo": return HTMLResponse(odoo_status_panel_html(opportunity_id, notice=notice)) return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice)) return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303) @router.post("/opportunities/{opportunity_id}/follow-up") async def create_opportunity_follow_up_action(opportunity_id: str, request: Request): if not is_uuid_text(opportunity_id): return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) form = await request.form() follow_up_type = str(form.get("follow_up_type") or "generic").strip() note = str(form.get("note") or "").strip() try: delay_days = int(str(form.get("delay_days") or "3")) except Exception: delay_days = 3 try: from app.followup_service import create_manual_follow_up_for_opportunity result = create_manual_follow_up_for_opportunity( opportunity_id=opportunity_id, follow_up_type=follow_up_type, delay_days=delay_days, note=note, created_by="operator", ) notice = "Follow-up agendado." if result.get("ok") else "Não foi possível agendar follow-up." except Exception as exc: notice = f"Erro ao agendar follow-up: {exc}" return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303) @router.post("/opportunities/{opportunity_id}/commercial-terms") async def update_opportunity_commercial_terms_action(opportunity_id: str, request: Request): if not is_uuid_text(opportunity_id): return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) form = await request.form() payment_terms = str(form.get("payment_terms") or "before_shipping").strip() delivery_terms = str(form.get("delivery_terms") or "carrier").strip() note = str(form.get("note") or "").strip() if payment_terms not in PAYMENT_TERM_LABELS: return PlainTextResponse("Condição de pagamento inválida.", status_code=422) if delivery_terms not in DELIVERY_TERM_LABELS: return PlainTextResponse("Condição de entrega inválida.", status_code=422) payload = { "payment_terms": payment_terms, "delivery_terms": delivery_terms, "commercial_terms_note": note, "commercial_terms_updated_by": "operator", } try: with engine.begin() as conn: exists = conn.execute(text(""" SELECT 1 FROM opportunities WHERE id = CAST(:opportunity_id AS UUID) LIMIT 1 """), {"opportunity_id": opportunity_id}).scalar() if not exists: return PlainTextResponse("Oportunidade não encontrada.", status_code=404) conn.execute(text(""" UPDATE opportunities SET metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:payload AS JSONB), updated_at = now() WHERE id = CAST(:opportunity_id AS UUID) """), { "opportunity_id": opportunity_id, "payload": _json_payload(payload), }) except Exception as exc: notice = quote(f"Não foi possível guardar condições comerciais: {exc}") return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303) notice = quote("Condições comerciais guardadas.") return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303) @router.post("/opportunities/{opportunity_id}/lifecycle") async def update_opportunity_lifecycle_action(opportunity_id: str, request: Request): if not is_uuid_text(opportunity_id): return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) form = await request.form() state = str(form.get("state") or "active").strip().lower() nurture_until = str(form.get("nurture_until") or "").strip() reason = str(form.get("reason") or "").strip() if state == "nurture" and not nurture_until: return PlainTextResponse("Indica a data para retomar o contacto.", status_code=422) try: changed = set_opportunity_lifecycle( opportunity_id, state, nurture_until=nurture_until or None, reason=reason, created_by="operator", ) except ValueError as exc: return PlainTextResponse(str(exc), status_code=422) except Exception as exc: notice = quote(f"Não foi possível alterar o estado operacional: {exc}") return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303) if not changed: return PlainTextResponse("Oportunidade não encontrada.", status_code=404) try: from app.followup_service import cancel_pending_followups_for_opportunity, create_follow_up_task cancel_pending_followups_for_opportunity( opportunity_id=opportunity_id, reason=f"manual_lifecycle_change:{state}", created_by="operator", ) if state == "recovery": create_follow_up_task( opportunity_id=opportunity_id, action_code="RECOVER_OPPORTUNITY", route="vendas", action="Recuperar oportunidade sem resposta", note=reason or "Rever canal, abordagem, timing e decidir entre nova tentativa, acompanhamento futuro ou perda.", reason="MANUAL_RECOVERY", delay_days=1, created_by="operator", idempotency_suffix=f"manual-recovery:{time.time_ns()}", follow_up_family="generic", follow_up_stage=99, follow_up_max_stage=99, cascade=False, contact_purpose="recovery_review", ) elif state == "nurture": create_follow_up_task( opportunity_id=opportunity_id, action_code="REVIEW_NURTURE", route="vendas", action="Rever oportunidade em acompanhamento futuro", note=reason or "Retomar contacto na data acordada ou rever se o timing continua válido.", reason="NURTURE_REVIEW", delay_days=1, created_by="operator", idempotency_suffix=f"nurture:{nurture_until}:{time.time_ns()}", follow_up_family="generic", follow_up_stage=99, follow_up_max_stage=99, cascade=False, contact_purpose="nurture_review", due_at_override=_parse_opportunity_dt(nurture_until), ) except Exception: pass notice = quote(f"Estado operacional alterado para {lifecycle_label(state)}.") return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303) @router.post("/opportunities/{opportunity_id}/lost") async def mark_opportunity_lost_action(opportunity_id: str, request: Request): if not is_uuid_text(opportunity_id): return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) form = await request.form() reason_code = str(form.get("reason_code") or "").strip().lower() note = str(form.get("note") or "").strip() try: changed = mark_opportunity_lost( opportunity_id, reason_code=reason_code, note=note, created_by="operator", ) except ValueError as exc: return PlainTextResponse(str(exc), status_code=422) except Exception as exc: notice = quote(f"Não foi possível marcar como perdida: {exc}") return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303) if not changed: return PlainTextResponse("Oportunidade não encontrada.", status_code=404) return RedirectResponse("/opportunities?status=open&scope=recovery", status_code=303) @router.post("/opportunities/{opportunity_id}/stage") async def update_opportunity_stage_action(opportunity_id: str, request: Request): if not is_uuid_text(opportunity_id): return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) form = await request.form() stage = str(form.get("stage") or "").strip().upper() # Backwards-compatible alias used by older UI/tests. # The canonical ClientFlow stage is NEW_LEAD. stage_aliases = {"NEW": "NEW_LEAD"} stage = stage_aliases.get(stage, stage) note = str(form.get("note") or "").strip() if not stage or stage not in OPPORTUNITY_STAGE_LABELS: return PlainTextResponse("Fase de oportunidade inválida.", status_code=422) if stage in {"LOST", "NO_INTEREST"}: return PlainTextResponse("Usa a ação 'Fechar como perdida' e indica o motivo.", status_code=422) try: set_opportunity_stage(opportunity_id, stage, note=note, created_by="operator") except ValueError as exc: return PlainTextResponse(f"Transição de fase inválida: {exc}", status_code=409) except Exception as exc: notice = quote(f"Não foi possível alterar fase: {exc}") return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303) return RedirectResponse(f"/opportunities/{opportunity_id}", status_code=303) @router.post("/opportunities/{opportunity_id}/customer") async def opportunity_link_customer_action(opportunity_id: str, request: Request): form = await request.form() customer_id = str(form.get("customer_id") or "").strip() try: from app.commercial_service import link_customer_to_opportunity, unlink_customer_from_opportunity if customer_id: link_customer_to_opportunity(customer_id, opportunity_id) else: unlink_customer_from_opportunity(opportunity_id) except Exception as exc: return PlainTextResponse(f"Erro ao associar cliente: {exc}", status_code=500) return RedirectResponse(f"/opportunities/{opportunity_id}", status_code=303) @router.post("/opportunities/{opportunity_id}/email-identity/extract") async def opportunity_email_identity_extract_action(opportunity_id: str, request: Request): try: from app.email_identity_extraction_service import extract_identity_for_opportunity result = extract_identity_for_opportunity(opportunity_id, refresh=True, use_llm=True) except Exception as exc: return PlainTextResponse(f"Erro ao extrair identidade do email: {exc}", status_code=500) if not result: notice = "Sem mensagem associada para extrair identidade." else: companies = result.get("company_mentions") or [] notice = "Identidade extraída" + (f": {', '.join(companies[:2])}" if companies else ".") return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303) @router.post("/opportunities/{opportunity_id}/email-identity/assist") async def opportunity_email_identity_assist_action(opportunity_id: str, request: Request): try: from app.fiscal_enrichment_service import assist_email_identity_enrichment result = assist_email_identity_enrichment(opportunity_id, refresh=True, apply_safe=False) except Exception as exc: return PlainTextResponse(f"Erro ao procurar cliente fiscal por identidade: {exc}", status_code=500) if result.get("conflict"): notice = "Possível conflito fiscal detetado pela identidade extraída." elif result.get("status") == "email_identity_matches_current_fiscal_customer": notice = "Identidade extraída confirma o cliente fiscal atual." elif result.get("suggested"): notice = "Sugestão fiscal criada a partir da identidade extraída." else: notice = "Identidade extraída, mas sem cliente fiscal compatível encontrado." return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303) @router.post("/opportunities/{opportunity_id}/email-identity/cleanup-invalid") async def opportunity_email_identity_cleanup_invalid_action(opportunity_id: str, request: Request): try: from app.email_identity_cleanup_service import cleanup_invalid_email_identity_state result = cleanup_invalid_email_identity_state( opportunity_id=opportunity_id, include_accepted=True, fix_extractions=True, apply=True, ) except Exception as exc: return PlainTextResponse(f"Erro ao limpar identidade inválida: {exc}", status_code=500) notice = ( f"Limpeza de identidade: {result.get('rejected', 0)} sugestão(ões) rejeitada(s), " f"{result.get('fixed_extractions', 0)} extração(ões) corrigida(s)." ) return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303) @router.post("/opportunities/{opportunity_id}/fiscal-enrich") async def opportunity_fiscal_enrich_action(opportunity_id: str, request: Request): try: from app.fiscal_enrichment_service import enrich_opportunity result = enrich_opportunity(opportunity_id, apply_safe=True) except Exception as exc: return PlainTextResponse(f"Erro ao enriquecer cliente fiscal: {exc}", status_code=500) if result.get("auto_applied"): notice = "Cliente fiscal auto-associado por enriquecimento." elif result.get("suggested"): notice = "Sugestão fiscal criada para revisão." else: notice = f"Sem sugestão fiscal: {result.get('reason') or 'sem correspondência'}" return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303) @router.post("/fiscal-suggestions/{suggestion_id}/accept") async def fiscal_suggestion_accept_action(suggestion_id: str, request: Request): try: from app.fiscal_enrichment_service import apply_fiscal_suggestion result = apply_fiscal_suggestion(suggestion_id, actor="operator_ui") except Exception as exc: return PlainTextResponse(f"Erro ao aplicar sugestão fiscal: {exc}", status_code=500) opportunity_id = result.get("opportunity_id") or "" if not result.get("applied"): return PlainTextResponse(f"Sugestão não aplicada: {result.get('reason')}", status_code=409) return RedirectResponse(f"/opportunities/{esc(opportunity_id)}?notice=Sugest%C3%A3o%20fiscal%20aplicada", status_code=303) @router.post("/fiscal-suggestions/{suggestion_id}/reject") async def fiscal_suggestion_reject_action(suggestion_id: str, request: Request): from app.admin_auth import safe_local_redirect try: from app.fiscal_enrichment_service import reject_fiscal_suggestion reject_fiscal_suggestion(suggestion_id, actor="operator_ui") except Exception as exc: return PlainTextResponse(f"Erro ao rejeitar sugestão fiscal: {exc}", status_code=500) redirect_target = safe_local_redirect( request.headers.get("referer"), fallback="/opportunities", ) return RedirectResponse(redirect_target, status_code=303) @router.post("/opportunities/{opportunity_id}/jasmin/complete-fiscal") async def opportunity_jasmin_complete_fiscal_action(opportunity_id: str, request: Request): try: from app.jasmin_fiscal_sync_service import apply_jasmin_fiscal_sync result = apply_jasmin_fiscal_sync(opportunity_id, actor="operator_ui_jasmin_fiscal_sync") filled = result.get("filled_fields") or [] if filled: notice = "Dados fiscais completados com Jasmin: " + ", ".join(str(x) for x in filled) else: notice = "Cliente fiscal associado/completado com dados Jasmin." except Exception as exc: notice = "Erro ao completar dados fiscais com Jasmin: " + str(exc) return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303) @router.post("/opportunities/{opportunity_id}/jasmin/sync-candidates") async def opportunity_jasmin_sync_candidates_action(opportunity_id: str, request: Request): try: from app.external_reconciliation_sync import sync_jasmin_reconciliation_candidates result = await sync_jasmin_reconciliation_candidates(limit=100, days=30) except Exception as exc: if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao sincronizar Jasmin: {exc}"), status_code=409) return PlainTextResponse(f"Erro ao sincronizar Jasmin: {exc}", status_code=500) seen = result.get("seen", 0) created = result.get("created_or_updated", 0) notice = f"Jasmin sincronizado: {seen} documento(s) visto(s), {created} criado(s)/atualizado(s)." if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice)) return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Jasmin%20sincronizado", status_code=303) @router.post("/opportunities/{opportunity_id}/jasmin/reimport-details") async def opportunity_jasmin_reimport_details_action(opportunity_id: str, request: Request): try: from app.jasmin_backfill_service import backfill_jasmin_opportunity_details_async result = await backfill_jasmin_opportunity_details_async( opportunity_id=opportunity_id, fetch_detail=True, actor="operator_ui_reimport", dry_run=False, ) except Exception as exc: if is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao reimportar detalhes Jasmin: {exc}"), status_code=409) return PlainTextResponse(f"Erro ao reimportar detalhes Jasmin: {exc}", status_code=500) if not result.get("ok"): msg = result.get("error") or "sem itens Jasmin para reimportar" if is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Não foi possível reimportar: {msg}"), status_code=409) return RedirectResponse(f"/opportunities/{opportunity_id}?notice=N%C3%A3o%20foi%20poss%C3%ADvel%20reimportar%20Jasmin", status_code=303) import_result = result.get("import_result") or {} docs = int(import_result.get("documents") or 0) lines = int(import_result.get("lines") or 0) notice = f"Detalhes Jasmin reimportados: {docs} documento(s), {lines} linha(s). Recarregue a página para atualizar produtos/valor no topo." if is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice)) return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303) @router.post("/opportunities/{opportunity_id}/jasmin/link-candidate/{item_id}") async def opportunity_jasmin_link_candidate_action(opportunity_id: str, item_id: str, request: Request): conflict_msg = _jasmin_candidate_tax_conflict_message(opportunity_id, item_id) if conflict_msg: if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=conflict_msg), status_code=409) return PlainTextResponse(conflict_msg, status_code=409) try: from app.jasmin_backfill_service import link_and_import_jasmin_candidate_async result = await link_and_import_jasmin_candidate_async( opportunity_id=opportunity_id, item_id=item_id, actor="operator_ui_link_existing_jasmin", ) except Exception as exc: if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao associar documento Jasmin: {exc}"), status_code=409) return PlainTextResponse(f"Erro ao associar documento Jasmin: {exc}", status_code=500) if not result.get("ok"): msg = result.get("error") or "não foi possível associar documento Jasmin" if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Não foi possível associar: {msg}"), status_code=409) return RedirectResponse(f"/opportunities/{opportunity_id}?notice=N%C3%A3o%20foi%20poss%C3%ADvel%20associar%20Jasmin", status_code=303) import_result = result.get("import_result") or {} docs = import_result.get("documents", 0) lines = import_result.get("lines", 0) notice = f"Documento Jasmin associado e importado: {docs} documento(s), {lines} linha(s). Recarregue a página para atualizar valor/produtos no topo." if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice)) return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Documento%20Jasmin%20associado", status_code=303) @router.post("/opportunities/{opportunity_id}/jasmin/replace-candidate/{item_id}") async def opportunity_jasmin_replace_candidate_action(opportunity_id: str, item_id: str, request: Request): conflict_msg = _jasmin_candidate_tax_conflict_message(opportunity_id, item_id) if conflict_msg: if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=conflict_msg), status_code=409) return PlainTextResponse(conflict_msg, status_code=409) try: from app.jasmin_backfill_service import replace_jasmin_document_for_opportunity_async result = await replace_jasmin_document_for_opportunity_async( opportunity_id=opportunity_id, item_id=item_id, actor="operator_ui_replace_existing_jasmin", dry_run=False, ) except Exception as exc: if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao substituir documento Jasmin: {exc}"), status_code=409) return PlainTextResponse(f"Erro ao substituir documento Jasmin: {exc}", status_code=500) if not result.get("ok"): msg = result.get("error") or "não foi possível substituir documento Jasmin" if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Não foi possível substituir: {msg}"), status_code=409) return RedirectResponse(f"/opportunities/{opportunity_id}?notice=N%C3%A3o%20foi%20poss%C3%ADvel%20substituir%20Jasmin", status_code=303) import_result = result.get("import_result") or {} docs = import_result.get("documents", 0) lines = import_result.get("lines", 0) removed_docs = result.get("removed_documents", 0) notice = f"Documento Jasmin substituído: {removed_docs} anterior(es) removido(s), {docs} documento(s), {lines} linha(s) importada(s). Recarregue a página para atualizar valor/produtos no topo." if request.headers.get("hx-request"): return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice)) return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Documento%20Jasmin%20substitu%C3%ADdo", status_code=303) @router.post("/opportunities/{opportunity_id}/jasmin/create-quotation") async def opportunity_jasmin_create_quotation(opportunity_id: str, request: Request): try: if settings.jasmin_enabled: from app.jasmin_service import enqueue_create_quotation enqueue_create_quotation(opportunity_id, created_by="operator") else: return PlainTextResponse("JASMIN_ENABLED=false", status_code=409) except Exception as exc: print(f"ClientFlow Jasmin create quotation failed: {exc}", flush=True) if is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=str(exc)), status_code=409) notice = quote(f"Não foi possível criar orçamento Jasmin: {exc}") return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303) if is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, notice="Pedido de orçamento enviado para a outbox Jasmin.")) return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Pedido%20de%20or%C3%A7amento%20enviado%20para%20a%20outbox%20Jasmin", status_code=303) @router.post("/opportunities/{opportunity_id}/jasmin/convert-invoice") async def opportunity_jasmin_convert_invoice(opportunity_id: str, request: Request): try: if settings.jasmin_enabled: from app.jasmin_service import enqueue_convert_latest_to_invoice enqueue_convert_latest_to_invoice(opportunity_id, created_by="operator") else: return PlainTextResponse("JASMIN_ENABLED=false", status_code=409) except Exception as exc: print(f"ClientFlow Jasmin convert invoice failed: {exc}", flush=True) if is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=str(exc)), status_code=409) notice = quote(f"Não foi possível criar fatura Jasmin: {exc}") return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303) if is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, notice="Pedido de fatura enviado para a outbox Jasmin.")) return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Pedido%20de%20fatura%20enviado%20para%20a%20outbox%20Jasmin", status_code=303) @router.post("/opportunities/{opportunity_id}/operations/{action_key}") async def opportunity_operation_action(opportunity_id: str, action_key: str, request: Request): if not is_uuid_text(opportunity_id): return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) # Accept legacy/semantic action names used by older UI buttons and E2E audits. action_aliases = { "prepare_order": "odoo_sale_order", "prepare_shipping": "packlink_shipment", "send_followup": "tracking_sent", } action_key = action_aliases.get(str(action_key or ""), str(action_key or "")) form = await request.form() external_id = str(form.get("external_id") or "").strip() external_name = str(form.get("external_name") or form.get("external_ref") or form.get("title") or "").strip() external_url = str(form.get("external_url") or "").strip() note = str(form.get("note") or "").strip() try: # Jasmin e Packlink, sem referência manual, criam itens de outbox para a API real. # Se o operador preencher external_id/external_name, mantém o modo manual/fallback. if action_key == "jasmin_quotation" and not external_id and not external_name: if settings.jasmin_enabled: from app.jasmin_service import enqueue_create_quotation enqueue_create_quotation(opportunity_id, created_by="operator") else: register_operation_action(opportunity_id, action_key, external_id=external_id, external_name=external_name, external_url=external_url, note=note, created_by="operator") elif action_key == "packlink_shipment" and not external_id and not external_name: if settings.packlink_enabled: from app.packlink_service import enqueue_packlink_shipment enqueue_packlink_shipment(opportunity_id, created_by="operator") else: register_operation_action(opportunity_id, action_key, external_id=external_id, external_name=external_name, external_url=external_url, note=note, created_by="operator") else: register_operation_action(opportunity_id, action_key, external_id=external_id, external_name=external_name, external_url=external_url, note=note, created_by="operator") except OperationActionBlocked as exc: # Ações operacionais incompatíveis com o estado da oportunidade são bloqueios reais, # não sucesso silencioso. Devolve 409 para testes/API e HTMX; a UI mostra a razão. return PlainTextResponse(f"Ação bloqueada: {exc}", status_code=409) except Exception as exc: print(f"ClientFlow operation action failed: {exc}", flush=True) if is_htmx(request): return PlainTextResponse(f"Erro ao registar ação: {exc}", status_code=409) notice = quote(f"Não foi possível registar ação: {exc}") return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303) return RedirectResponse(f"/opportunities/{opportunity_id}", status_code=303) @router.post("/opportunities/{opportunity_id}/odoo/link-sale") async def opportunity_odoo_link_sale_number_action(opportunity_id: str, request: Request): if not is_uuid_text(opportunity_id): return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) form = await request.form() sale_ref = str(form.get("sale_ref") or form.get("external_name") or form.get("external_ref") or "").strip() if not sale_ref: msg = "Indica o nº da venda Odoo, por exemplo S00308." if request.headers.get("hx-request"): return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=msg), status_code=422) return PlainTextResponse(msg, status_code=422) sale_ref = sale_ref.upper() if sale_ref.lower().startswith("s") else sale_ref external_id = sale_ref if sale_ref.isdigit() else "" external_name = sale_ref notice = f"Venda Odoo {sale_ref} registada na oportunidade." try: from app.operation_service import register_operation_action register_operation_action( opportunity_id, "odoo_sale_order", external_id=external_id, external_name=external_name, note=f"Venda Odoo {sale_ref} associada manualmente pelo operador.", payload={"manual_odoo_sale_ref": sale_ref}, created_by="operator_ui_odoo_sale_ref", ) with engine.begin() as conn: conn.execute(text(""" UPDATE tasks SET status = 'done', note = COALESCE(note, '') || CAST(:note AS TEXT), updated_at = NOW() WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND action_code = 'PREPARE_ORDER' AND status = 'pending' """), { "opportunity_id": opportunity_id, "note": f"\n\nConcluída automaticamente: venda Odoo {sale_ref} associada manualmente.", }) try: sync_opportunity_odoo_status(opportunity_id) notice = f"Venda Odoo {sale_ref} associada e estado WH/OUT sincronizado." except Exception as sync_exc: notice = f"Venda Odoo {sale_ref} associada. Sincronização Odoo falhou: {sync_exc}" if request.headers.get("hx-request"): return HTMLResponse(odoo_status_panel_html(opportunity_id, notice=notice)) return RedirectResponse(url=f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303) except OperationActionBlocked as exc: msg = f"Ação bloqueada: {exc}" except Exception as exc: msg = f"Erro ao associar venda Odoo: {exc}" if request.headers.get("hx-request"): return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=msg), status_code=409) return RedirectResponse(url=f"/opportunities/{opportunity_id}?notice={quote(msg)}", status_code=303) @router.post("/opportunities/{opportunity_id}/odoo/sync-status") async def opportunity_odoo_sync_status_action(opportunity_id: str, request: Request): try: result = sync_opportunity_odoo_status(opportunity_id) label = result.get("label") or result.get("physical_status") or "estado Odoo atualizado" if request.headers.get("hx-request"): return HTMLResponse(odoo_status_panel_html(opportunity_id, notice=f"Odoo sincronizado: {label}")) except Exception as exc: if request.headers.get("hx-request"): return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=str(exc)), status_code=409) return RedirectResponse(url=f"/opportunities/{opportunity_id}?notice=Erro%20ao%20sincronizar%20Odoo", status_code=303) return RedirectResponse(url=f"/opportunities/{opportunity_id}?notice=Odoo%20sincronizado", status_code=303) @router.post("/opportunities/{opportunity_id}/odoo/link-candidate/{item_id}") async def opportunity_odoo_link_candidate_action(opportunity_id: str, item_id: str, request: Request): try: from app.reconciliation_service import link_reconciliation_to_opportunity link_reconciliation_to_opportunity(item_id, opportunity_id, actor="operator_ui_odoo_panel") try: sync_opportunity_odoo_status(opportunity_id) except Exception: pass if request.headers.get("hx-request"): return HTMLResponse(odoo_status_panel_html(opportunity_id, notice="Venda Odoo associada à oportunidade.")) except Exception as exc: if request.headers.get("hx-request"): return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=f"Erro ao associar venda Odoo: {exc}"), status_code=409) return PlainTextResponse(f"Erro ao associar venda Odoo: {exc}", status_code=500) return RedirectResponse(url=f"/opportunities/{opportunity_id}?notice=Venda%20Odoo%20associada", status_code=303)