"""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 app.db import engine from urllib.parse import quote import json import uuid 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.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", } DELIVERY_TERM_LABELS = { "carrier": "Transportadora", "pickup": "Levantamento", "install_partner": "Eletricista/instalador do cliente", "undefined": "A definir", } FISCAL_FIELD_LABELS = { "name": "nome fiscal", "tax_id": "NIF", "email": "email de faturação", "phone": "telefone", "street_name": "morada fiscal", "postal_zone": "código postal", "city_name": "localidade", "country": "país", } def _field_label_list(fields: list | tuple | set) -> str: return ", ".join(FISCAL_FIELD_LABELS.get(str(field), str(field)) for field in (fields or [])) # 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"), ("LOST", "Perdido"), ("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", } 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 _OBSOLETE_AFTER_PAYMENT_TASK_CODES = { "CONFIRM_PAYMENT", "FOLLOW_UP_PAYMENT", "FOLLOW_UP_PROFORMA", "FOLLOW_UP_QUOTE", } def _is_obsolete_after_payment_task(task: dict, payment_confirmed: bool) -> bool: if not payment_confirmed: return False return str(task.get("status") or "").lower() == "pending" and str(task.get("action_code") or "").upper() in _OBSOLETE_AFTER_PAYMENT_TASK_CODES 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 _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_sent = bool(invoice.get("sent_at") or invoice.get("sent") or str(invoice.get("status") or "").lower() in {"sent", "issued_sent"}) if invoice_sent: detail = "Fatura enviada. Próximo passo: acompanhar produção/preparação ou envio." else: detail = "Fatura criada/associada. Envia o PDF ao cliente; depois acompanha produção/preparação." 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.""" if not is_uuid_text(opportunity_id): return {} 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 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 "" 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 = "" if not sale: no_sale_warning = '
Sem venda Odoo ligada.
Usa candidatos abaixo ou a reconciliação para ligar a venda correta antes de confiar no fluxo físico.
' 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, preparação, produção e entrega física. A atualização é manual para evitar chamadas lentas ao abrir a oportunidade.
Última sincronização: {esc(fmt_dt(last_synced))}
{unlink_odoo_button_html}
{notice_html}{error_html}{no_sale_warning}
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
{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 _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 _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) visible_board_columns = [column for column in OPPORTUNITY_BOARD_COLUMNS if column[0] != "closed"] 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 == "closed": continue if scope and scope not in {"all", "open"}: if scope == "blocked": pending = int(opportunity.get("pending_task_count") or 0) if pending <= 0 and not opportunity_customer_mismatch(opportunity): continue elif key != 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: 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 a visual board column from stage plus next pending action. The stored stage remains unchanged. This only avoids showing opportunities with a financial/logistics next step under the initial "Pedidos" column. """ action_code = str(opp.get("last_action_code") or "").upper().strip() if int(opp.get("pending_task_count") or 0) > 0: if action_code in {"SEND_INVOICE", "SEND_PROFORMA", "CONFIRM_PAYMENT"}: return "payment" if action_code in {"PREPARE_ORDER", "CREATE_SHIPMENT"}: return "operations" 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) cta_label = next_action if pending else "Ver oportunidade" cta_class = "btn-primary" if pending 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(title)}
    {subtitle_html}
    {esc(subject)}
    {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] 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"), ("shipment", "Enviadas"), ("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"""
    Abertas
    {total_open}
    em acompanhamento
    Com tarefa
    {len(attention)}
    requerem ação
    Tarefas pendentes
    {total_pending}
    ligadas a vendas
    Valor estimado
    {money_html(total_value)}
    lista atual
    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.
    {len(visible_opportunities)} resultado(s)
    {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"} payment_confirmed_for_ui = _opportunity_payment_confirmed(opportunity_id) or stage == "PAYMENT_CONFIRMED" 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_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: legacy_notice_html = ( '
    ' 'Registo antigo/reconstruído.
    ' 'A oportunidade foi normalizada a partir de documentos já existentes. ' 'Valida pagamento, valor e linhas antes de executar novas ações.' '
    ' ) opportunity_return_to = f"/opportunities/{opportunity_id}" try: next_action = get_opportunity_next_action(opportunity_id) except Exception: 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.") 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 next_action.get("action_code") == "VALIDATE_FISCAL_CUSTOMER": 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_obsolete = _is_obsolete_after_payment_task(task, payment_confirmed_for_ui) task_note = compact_text(_safe_opportunity_task_text(task.get('note') or task.get('action') or ''), 70) if task_obsolete: task_note = compact_text((task_note + " · obsoleta: pagamento já confirmado").strip(), 95) task_status_html = status_badge("ignored") if task_obsolete else status_badge(task.get('status')) task_row_class = "table-light" if task_obsolete else "" task_rows += f''' {esc(action_label(task.get('action_code')))}
    {esc(task_note)}
    {route_badge(task.get('route'))} {task_status_html} {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) has_invoice_for_ui = any( str(doc.get("document_kind") or "") == "invoice" and str(doc.get("role") or "current") in {"current", "accepted"} and bool(doc.get("is_active", True)) is not False for doc in linked_documents ) fiscal_missing_for_action = fiscal_customer_missing_fields(fiscal_customer) if linked_customer else [] if payment_confirmed_for_ui and not has_invoice_for_ui and fiscal_missing_for_action: missing_text = ", ".join(fiscal_missing_for_action) primary_action = "Completar dados fiscais" primary_note = f"Pagamento confirmado, mas faltam {missing_text} antes de emitir/enviar a fatura." if isinstance(jasmin_fiscal_preview, dict) and jasmin_fiscal_preview.get("available") and not jasmin_fiscal_preview.get("conflict"): primary_button = f'
    ' else: primary_button = f'Abrir ficha fiscal' if isinstance(next_action, dict): next_action = {**next_action, "action_code": "VALIDATE_FISCAL_CUSTOMER", "label": primary_action, "description": primary_note} else: next_action = {"action_code": "VALIDATE_FISCAL_CUSTOMER", "label": primary_action, "description": primary_note} 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' 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(next_action.get('action_code') or (next_task.get('action_code') if next_task else None) or opportunity.get('last_action_code') or 'FOLLOW_UP')}
    Ações avançadas
    ''' manual_follow_up_html = f'''

    Criar follow-up

    Agenda uma tarefa de follow-up. O sistema sugere o contacto, mas não envia email automaticamente.
    ''' correction_state = _opportunity_manual_correction_state(opportunity_id) 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.
    """ 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" missing_labels = _field_label_list(jasmin_fiscal_preview.get("missing_fields") or []) fillable_labels = _field_label_list(fillable) if fillable: fillable_text = "Ação disponível: importar " + fillable_labels + "." fiscal_sync_status = 'pode completar' button_disabled = "" elif jasmin_fiscal_preview.get("missing_fields"): fillable_text = "Jasmin encontrado, mas não contém novos campos para preencher os dados em falta: " + missing_labels + "." fiscal_sync_status = 'sem novos campos' button_disabled = " disabled" else: fillable_text = "Cliente fiscal e documento Jasmin consistentes; sem campos em falta para importar." fiscal_sync_status = 'sem campos em falta' button_disabled = " disabled" jasmin_fiscal_sync_html = ( '
    ' '
    ' '
    Dados fiscais disponíveis no Jasmin
    ' f'
    {fiscal_sync_status}
    ' '
    ' 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}/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}/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) 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): 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) referer = request.headers.get("referer") or "/opportunities" return RedirectResponse(referer, 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/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)