2744 lines
156 KiB
Python
2744 lines
156 KiB
Python
"""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 += (
|
|
'<option value="' + esc(current_stage) + '" selected>'
|
|
+ 'Estado atual detalhado: ' + esc(OPPORTUNITY_STAGE_LABELS.get(current_stage, current_stage))
|
|
+ '</option>'
|
|
)
|
|
for value, label in COMMERCIAL_STAGE_OPTIONS:
|
|
selected = "selected" if value == current_stage else ""
|
|
html += f'<option value="{esc(value)}" {selected}>{esc(label)}</option>'
|
|
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'<option value="{esc(value)}" {selected}>{esc(label)}</option>'
|
|
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 = '<button class="btn btn-outline-secondary" disabled>Confirmar pagamento</button><div class="small text-secondary">Bloqueado: cria/associa primeiro um orçamento ou fatura.</div>'
|
|
elif payment_confirmed:
|
|
if not invoice:
|
|
action_html = f'''
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/convert-invoice" class="d-grid gap-2">
|
|
<button class="btn btn-primary" type="submit">Criar/enviar fatura</button>
|
|
<div class="small text-secondary">Pagamento confirmado. Próximo passo do fluxo normal: emitir fatura.</div>
|
|
</form>
|
|
'''
|
|
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'<button class="btn btn-outline-success" disabled>Pagamento confirmado</button><div class="small text-secondary">{esc(detail)}</div>'
|
|
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'''
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/operations/payment_confirmed" class="d-grid gap-2">
|
|
<input type="hidden" name="external_name" value="Pagamento confirmado">
|
|
<textarea class="form-control" name="note" rows="2" placeholder="Nota opcional: valor recebido, referência bancária, comprovativo...">{esc(note)}</textarea>
|
|
<button class="btn btn-success" type="submit">{esc(button_label)}</button>
|
|
</form>
|
|
'''
|
|
|
|
return f'''
|
|
<section class="card cf-card cf-opp-finance-quick-card">
|
|
<div class="card-body p-4">
|
|
<h2 class="cf-section-title mb-2">Financeiro rápido</h2>
|
|
<div class="small text-secondary mb-3">Ação independente da fase: usa orçamento/fatura associado e a condição comercial.</div>
|
|
<div class="cf-opp-operator-grid mb-3">
|
|
<div><span>{esc(base_doc_label)}</span><strong>{esc(_document_display_number(base_doc))}</strong></div>
|
|
<div><span>Valor esperado</span><strong>{amount_html}</strong></div>
|
|
<div><span>Pagamento</span><strong>{esc(payment_status)}</strong></div>
|
|
<div><span>Condição</span><strong>{esc(payment_term_label)}</strong></div>
|
|
</div>
|
|
{action_html}
|
|
</div>
|
|
</section>
|
|
'''
|
|
|
|
|
|
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'<span class="badge {cls}">{esc(status or "—")}</span>'
|
|
|
|
|
|
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'<section id="odoo-status-panel" class="card cf-card"><div class="card-body"><div class="alert alert-danger">Erro ao carregar Odoo: {esc(exc)}</div></div></section>'
|
|
|
|
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'<div class="alert alert-info py-2 small mb-2">{esc(notice)}</div>' if notice else ""
|
|
error_html = f'<div class="alert alert-danger py-2 small mb-2"><strong>Erro Odoo:</strong> {esc(error_notice)}</div>' if error_notice else ""
|
|
|
|
sale_url = str(sale.get("external_url") or "").strip()
|
|
sale_link = f'<a href="{esc(sale_url)}" target="_blank" rel="noopener">Abrir Odoo</a>' if sale_url else ""
|
|
no_sale_warning = ""
|
|
if not sale:
|
|
no_sale_warning = '<div class="alert alert-warning py-2 small mb-2"><strong>Sem venda Odoo ligada.</strong><br>Usa candidatos abaixo ou a reconciliação para ligar a venda correta antes de confiar no fluxo físico.</div>'
|
|
unlink_odoo_button_html = ""
|
|
if sale:
|
|
unlink_odoo_button_html = f"""
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/odoo/unlink" hx-post="/opportunities/{esc(opportunity_id)}/odoo/unlink" hx-target="#odoo-status-panel" hx-swap="outerHTML" hx-confirm="Desassociar Odoo desta oportunidade? Não altera a venda no Odoo; remove apenas a ligação local e as linhas Odoo importadas.">
|
|
<button class="btn btn-outline-danger btn-sm" type="submit">Desassociar Odoo</button>
|
|
</form>
|
|
"""
|
|
|
|
picking_rows = ""
|
|
for pck in pickings[:8]:
|
|
picking_rows += f"""
|
|
<tr>
|
|
<td><strong>{esc(pck.get('name') or pck.get('id') or 'Entrega')}</strong><div class="small text-secondary">{esc(_odoo_m2o_label(pck.get('type')) or pck.get('origin') or '')}</div></td>
|
|
<td>{_odoo_status_badge(pck.get('state'))}</td>
|
|
<td>{esc(fmt_dt(pck.get('scheduled_date') or pck.get('date_done')))}</td>
|
|
</tr>"""
|
|
if not picking_rows:
|
|
picking_rows = '<tr><td colspan="3" class="text-center text-secondary py-3">Sem entregas/pickings sincronizados.</td></tr>'
|
|
|
|
production_rows = ""
|
|
for mo in productions[:8]:
|
|
production_rows += f"""
|
|
<tr>
|
|
<td><strong>{esc(mo.get('name') or mo.get('id') or 'Produção')}</strong><div class="small text-secondary">{esc(_odoo_m2o_label(mo.get('product')))}</div></td>
|
|
<td>{_odoo_status_badge(mo.get('state'))}</td>
|
|
<td>{esc(mo.get('qty') or '')}</td>
|
|
</tr>"""
|
|
if not production_rows:
|
|
production_rows = '<tr><td colspan="3" class="text-center text-secondary py-3">Sem ordens de produção sincronizadas.</td></tr>'
|
|
|
|
candidate_rows = ""
|
|
for cand in candidates:
|
|
linked_here = str(cand.get("linked_opportunity_id") or "") == str(opportunity_id)
|
|
if linked_here:
|
|
action_html = f'''<div class="d-flex flex-column gap-1 align-items-end"><span class="badge text-bg-success">Ligada</span><form method="post" action="/opportunities/{esc(opportunity_id)}/external-candidate/{esc(cand.get("id"))}/ignore" hx-post="/opportunities/{esc(opportunity_id)}/external-candidate/{esc(cand.get("id"))}/ignore" hx-target="#odoo-status-panel" hx-swap="outerHTML" hx-confirm="Ignorar esta venda Odoo candidata/ligada no ClientFlow? Não altera Odoo."><button class="btn btn-sm btn-outline-danger" type="submit">Ignorar</button></form></div>'''
|
|
else:
|
|
action_html = f"""
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/odoo/link-candidate/{esc(cand.get('id'))}" hx-post="/opportunities/{esc(opportunity_id)}/odoo/link-candidate/{esc(cand.get('id'))}" hx-target="#odoo-status-panel" hx-swap="outerHTML" hx-confirm="Associar esta venda Odoo à oportunidade e atualizar o fluxo operacional?">
|
|
<button class="btn btn-sm btn-outline-primary" type="submit">Associar</button>
|
|
</form>"""
|
|
candidate_rows += f"""
|
|
<tr>
|
|
<td><strong>{esc(cand.get('document_number') or cand.get('external_id') or 'Venda Odoo')}</strong><div class="small text-secondary">{esc(cand.get('title') or '')}</div></td>
|
|
<td>{money_html(cand.get('amount') or 0)}<div class="small text-secondary">{esc(cand.get('currency') or 'EUR')}</div></td>
|
|
<td>{esc(cand.get('customer_name') or '—')}<div class="small text-secondary text-break">{esc(cand.get('customer_email') or '')}</div></td>
|
|
<td>{_odoo_status_badge(cand.get('status'))}</td>
|
|
<td class="text-end">{action_html}</td>
|
|
</tr>"""
|
|
if not candidate_rows:
|
|
candidate_rows = '<tr><td colspan="5" class="text-center text-secondary py-3">Sem vendas Odoo candidatas ligadas a esta oportunidade.</td></tr>'
|
|
|
|
amount_html = money_html(sale_amount) if sale_amount not in {"", None} else "—"
|
|
details_open = "open" if candidates else ""
|
|
physical_next_html = f'<div class="small text-primary mt-1">{esc(physical_next)}</div>' if physical_next else ""
|
|
return f"""
|
|
<section id="odoo-status-panel" class="card cf-card cf-live-panel">
|
|
<div class="card-body p-0">
|
|
<div class="p-3 border-bottom d-flex flex-wrap justify-content-between align-items-center gap-2">
|
|
<div>
|
|
<h2 class="cf-section-title">Estado Odoo</h2>
|
|
<div class="small text-secondary">Venda, preparação, produção e entrega física. A atualização é manual para evitar chamadas lentas ao abrir a oportunidade.</div>
|
|
<div class="small text-secondary">Última sincronização: {esc(fmt_dt(last_synced))}</div>
|
|
</div>
|
|
<div class="d-flex flex-wrap gap-2">
|
|
{unlink_odoo_button_html}
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/odoo/sync-status" hx-post="/opportunities/{esc(opportunity_id)}/odoo/sync-status" hx-target="#odoo-status-panel" hx-swap="outerHTML">
|
|
<button class="btn btn-primary btn-sm" type="submit">Sincronizar Odoo agora <span class="htmx-indicator">…</span></button>
|
|
</form>
|
|
<button class="btn btn-outline-secondary btn-sm" type="button" hx-get="/opportunities/{esc(opportunity_id)}/partials/odoo-status" hx-target="#odoo-status-panel" hx-swap="outerHTML">Atualizar painel</button>
|
|
</div>
|
|
</div>
|
|
<div class="p-3 pb-0">{notice_html}{error_html}{no_sale_warning}</div>
|
|
<div class="p-3 border-bottom">
|
|
<div class="row g-3">
|
|
<div class="col-md-3"><div class="cf-soft-box h-100"><div class="small text-secondary fw-bold text-uppercase">Venda Odoo</div><strong>{esc(sale_name)}</strong><div>{_odoo_status_badge(sale_state)}</div><div class="small text-secondary">{sale_link}</div></div></div>
|
|
<div class="col-md-3"><div class="cf-soft-box h-100"><div class="small text-secondary fw-bold text-uppercase">Cliente Odoo</div><strong>{esc(partner)}</strong></div></div>
|
|
<div class="col-md-3"><div class="cf-soft-box h-100"><div class="small text-secondary fw-bold text-uppercase">Valor Odoo</div><strong>{amount_html}</strong></div></div>
|
|
<div class="col-md-3"><div class="cf-soft-box h-100"><div class="small text-secondary fw-bold text-uppercase">Estado físico</div><strong>{esc(physical_label)}</strong><div class="small text-secondary">{esc(physical_reason)}</div>{physical_next_html}</div></div>
|
|
</div>
|
|
</div>
|
|
<div class="cf-table-wrap border-0 rounded-0">
|
|
<table class="table cf-table"><thead><tr><th>Entrega / picking</th><th>Estado</th><th>Data</th></tr></thead><tbody>{picking_rows}</tbody></table>
|
|
</div>
|
|
<div class="cf-table-wrap border-top rounded-0">
|
|
<table class="table cf-table"><thead><tr><th>Produção / preparação</th><th>Estado</th><th>Qtd.</th></tr></thead><tbody>{production_rows}</tbody></table>
|
|
</div>
|
|
<details class="p-3 border-top" {details_open}>
|
|
<summary class="small fw-bold text-secondary" style="cursor:pointer">Vendas Odoo ligadas/candidatas</summary>
|
|
<div class="small text-secondary mt-1">Associa candidatos apenas quando representam a mesma venda/processo.</div>
|
|
<div class="cf-table-wrap border-0 rounded-0 mt-2"><table class="table cf-table"><thead><tr><th>Venda</th><th>Valor</th><th>Cliente</th><th>Estado</th><th class="text-end">Ação</th></tr></thead><tbody>{candidate_rows}</tbody></table></div>
|
|
</details>
|
|
</div>
|
|
</section>
|
|
"""
|
|
|
|
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"""
|
|
<div class="cf-soft-box mt-3">
|
|
<div class="small text-secondary fw-bold text-uppercase mb-2">Identidade do email</div>
|
|
<div class="small text-danger">Erro ao ler identidade extraída: {esc(exc)}</div>
|
|
</div>
|
|
"""
|
|
if not review.get("ok") or not review.get("identity"):
|
|
return f"""
|
|
<div class="cf-soft-box mt-3">
|
|
<div class="small text-secondary fw-bold text-uppercase mb-2">Identidade do email</div>
|
|
<div class="small text-secondary mb-2">Ainda não existe identidade extraída para esta oportunidade.</div>
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/email-identity/extract" class="d-grid">
|
|
<button class="btn btn-sm btn-outline-primary" type="submit">Extrair identidade do email</button>
|
|
</form>
|
|
</div>
|
|
"""
|
|
|
|
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'<span class="badge text-bg-light me-1 mb-1">{esc(c)}</span>' for c in companies) or '<span class="text-secondary">Sem empresa explícita válida</span>'
|
|
phone_html = ", ".join(esc(p) for p in phones) if phones else "—"
|
|
evidence_html = "".join(f'<li>{esc(compact_text(e, 90))}</li>' for e in evidence[:3])
|
|
conflict_html = ""
|
|
if conflict:
|
|
conflict_html = f"""
|
|
<div class="alert alert-warning py-2 small mt-2 mb-2">
|
|
<strong>Possível conflito fiscal.</strong><br>
|
|
O email menciona {esc(', '.join(companies) or 'outra empresa')}, mas a oportunidade está ligada a {esc(review.get('linked_customer_name') or 'outro cliente')}.
|
|
</div>
|
|
"""
|
|
suggested_html = ""
|
|
if suggested and companies:
|
|
suggested_html = f"""
|
|
<div class="border rounded p-2 small bg-white mt-2">
|
|
<div class="text-secondary fw-bold text-uppercase">Cliente interno compatível</div>
|
|
<strong>{esc(suggested.get('nome') or suggested.get('name') or 'Cliente')}</strong>
|
|
<div class="text-secondary">NIF {esc(suggested.get('nif') or suggested.get('tax_id') or '—')}</div>
|
|
</div>
|
|
"""
|
|
return f"""
|
|
<div class="cf-soft-box mt-3">
|
|
<div class="d-flex justify-content-between align-items-start gap-2 mb-2">
|
|
<div>
|
|
<div class="small text-secondary fw-bold text-uppercase">Identidade extraída do email</div>
|
|
<div class="small text-secondary">{esc(identity.get('extraction_method') or identity.get('method') or '—')} · {esc(model)} · confiança {esc(confidence_text)}</div>
|
|
</div>
|
|
{status_badge('conflito') if conflict and 'status_badge' in globals() else ''}
|
|
</div>
|
|
{conflict_html}
|
|
<div class="small text-secondary fw-bold text-uppercase mt-2">Pessoa</div>
|
|
<div class="fw-semibold">{esc(identity.get('person_name') or '—')}</div>
|
|
<div class="small text-secondary fw-bold text-uppercase mt-2">Empresa mencionada</div>
|
|
<div>{company_html}</div>
|
|
<div class="small text-secondary fw-bold text-uppercase mt-2">Email / domínio</div>
|
|
<div class="small text-break">{esc(identity.get('email') or '—')} · {esc(identity.get('domain') or '—')}</div>
|
|
<div class="small text-secondary fw-bold text-uppercase mt-2">Morada</div>
|
|
<div class="small">{esc(identity.get('address') or '—')}</div>
|
|
<div class="small text-secondary fw-bold text-uppercase mt-2">Telefones</div>
|
|
<div class="small">{phone_html}</div>
|
|
{suggested_html}
|
|
{f'<ul class="small text-secondary mt-2 mb-0">{evidence_html}</ul>' if evidence_html else ''}
|
|
<div class="d-grid gap-2 mt-3">
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/email-identity/assist">
|
|
<button class="btn btn-sm btn-outline-primary w-100" type="submit">Procurar cliente fiscal por identidade</button>
|
|
</form>
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/email-identity/extract">
|
|
<button class="btn btn-sm btn-outline-secondary w-100" type="submit">Reextrair identidade</button>
|
|
</form>
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/email-identity/cleanup-invalid" onsubmit="return confirm('Limpar sugestões/extracções antigas inválidas desta oportunidade?')">
|
|
<button class="btn btn-sm btn-outline-danger w-100" type="submit">Limpar identidade inválida</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
|
|
|
|
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"""
|
|
<div class=\"cf-soft-box mt-3\">
|
|
<div class=\"small text-secondary mb-2\">Sem sugestão fiscal externa registada.</div>
|
|
<form method=\"post\" action=\"/opportunities/{esc(opportunity_id)}/fiscal-enrich\" class=\"d-grid\">
|
|
<button class=\"btn btn-sm btn-outline-primary\" type=\"submit\">Enriquecer cliente fiscal</button>
|
|
</form>
|
|
</div>
|
|
"""
|
|
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"<span class='badge text-bg-light'>{esc(status)}</span>"
|
|
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"""
|
|
<div class=\"d-flex gap-1 mt-2\">
|
|
<form method=\"post\" action=\"/fiscal-suggestions/{esc(sid)}/accept\" class=\"flex-fill\">
|
|
<button class=\"btn btn-sm btn-primary w-100\" type=\"submit\">Associar</button>
|
|
</form>
|
|
<form method=\"post\" action=\"/fiscal-suggestions/{esc(sid)}/reject\">
|
|
<button class=\"btn btn-sm btn-outline-secondary\" type=\"submit\">Rejeitar</button>
|
|
</form>
|
|
</div>
|
|
"""
|
|
rows += f"""
|
|
<div class=\"border rounded p-2 mb-2 bg-white\">
|
|
<div class=\"d-flex justify-content-between gap-2\"><strong>{esc(suggestion.get('suggested_name') or 'Empresa sugerida')}</strong>{badge}</div>
|
|
<div class=\"small text-secondary\">Sugestão fiscal · NIF {esc(suggestion.get('suggested_nif') or '—')} · confiança {esc(confidence_text)}</div>
|
|
<div class=\"small text-secondary\">{esc(suggestion.get('match_type') or suggestion.get('lookup_type') or 'match')}</div>
|
|
{actions}
|
|
</div>
|
|
"""
|
|
if not rows.strip():
|
|
return ""
|
|
return f"""
|
|
<div class=\"cf-soft-box mt-3\">
|
|
<div class=\"small text-secondary fw-bold text-uppercase mb-1\">Sugestões fiscais por validar</div>
|
|
<div class=\"small text-secondary mb-2\">Não é cliente fiscal confirmado. Associar apenas depois de validar nome/NIF.</div>
|
|
{rows}
|
|
<form method=\"post\" action=\"/opportunities/{esc(opportunity_id)}/fiscal-enrich\" class=\"d-grid\">
|
|
<button class=\"btn btn-sm btn-outline-primary\" type=\"submit\">Atualizar sugestão</button>
|
|
</form>
|
|
</div>
|
|
"""
|
|
|
|
|
|
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"<li>{esc(a)}</li>" for a in alerts[:3])
|
|
return f'''
|
|
<div class="alert alert-warning py-2 small mb-0">
|
|
<strong>Verificação de consistência operacional</strong>
|
|
<ul class="mb-0 mt-1">{items}</ul>
|
|
</div>
|
|
'''
|
|
|
|
|
|
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'''
|
|
<div class="cf-timeline-item">
|
|
<div class="small text-secondary">{esc(fmt_dt(doc.get('created_at')))}<div class="mt-1"><span class="badge text-bg-light">derivado</span></div></div>
|
|
<div><div class="d-flex flex-wrap align-items-center gap-2"><strong>{esc(title)}</strong>{operation_status_badge(str(doc.get('status') or 'created'))}</div><div class="small text-secondary text-break">{esc(detail)}</div></div>
|
|
</div>
|
|
'''
|
|
if item_count and not docs:
|
|
items += f'''
|
|
<div class="cf-timeline-item">
|
|
<div class="small text-secondary">—<div class="mt-1"><span class="badge text-bg-light">derivado</span></div></div>
|
|
<div><strong>Produtos na oportunidade</strong><div class="small text-secondary">{esc(item_count)} linha(s) comerciais associadas.</div></div>
|
|
</div>
|
|
'''
|
|
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'<div class="cf-opportunity-card-subtitle">{esc(subtitle)}</div>' if subtitle else ""
|
|
blocker_class = " has-blocker" if blockers else ""
|
|
return f"""
|
|
<article class="cf-opportunity-card-clean cf-opportunity-card-compact{blocker_class}">
|
|
<a class="cf-opportunity-card-main text-decoration-none text-reset" href="/opportunities/{esc(oid)}">
|
|
<div class="cf-opportunity-card-title">{esc(title)}</div>
|
|
{subtitle_html}
|
|
<div class="cf-opportunity-card-subject">{esc(subject)}</div>
|
|
{blocker_html}
|
|
<div class="cf-opportunity-next-action">
|
|
<span>Próxima ação</span>
|
|
<strong>{esc(next_action)}</strong>
|
|
</div>
|
|
</a>
|
|
<a class="btn btn-sm {esc(cta_class)} w-100 cf-opportunity-card-cta" href="/opportunities/{esc(oid)}">{esc(cta_label)}</a>
|
|
</article>
|
|
"""
|
|
|
|
|
|
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 = '<div class="border border-dashed rounded p-4 text-center text-secondary bg-white">Sem oportunidades nesta etapa.</div>'
|
|
board_html += f"""
|
|
<section class="cf-opportunity-stage" id="stage-{esc(key)}">
|
|
<header class="cf-opportunity-stage-header">
|
|
<strong>{esc(label)}</strong>
|
|
<span>{len(grouped.get(key, []))}</span>
|
|
</header>
|
|
<div class="cf-opportunity-stage-body">{cards}</div>
|
|
</section>
|
|
"""
|
|
return f"""
|
|
<div id="opportunities-board" class="cf-live-panel cf-opportunities-board" aria-live="polite">
|
|
<div class="cf-opportunities-board-meta">
|
|
<span>{len(visible_opportunities)} resultado(s)</span>
|
|
<span class="htmx-indicator" id="opportunities-loading">A atualizar…</span>
|
|
</div>
|
|
<div class="cf-opportunities-board-scroll">
|
|
<div class="cf-opportunities-board-grid">{board_html}</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
|
|
@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'<option value="{esc(value)}" {selected}>{esc(label)}</option>'
|
|
|
|
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'<a class="btn btn-sm {active}" href="{esc(href)}" hx-get="{esc(partial_href)}" hx-target="#opportunities-board" hx-swap="outerHTML" hx-push-url="{esc(href)}" hx-indicator="#opportunities-loading">{esc(label)}</a>'
|
|
|
|
body = f"""
|
|
<section class="row g-3 row-cols-1 row-cols-sm-2 row-cols-xxl-4 mb-3">
|
|
<div class="col"><a class="card cf-card text-decoration-none text-reset overflow-hidden" href="/opportunities?status=open"><div class="card-body"><div class="small text-secondary fw-bold text-uppercase">Abertas</div><div class="h4 fw-bold mb-1">{total_open}</div><div class="small">em acompanhamento</div></div></a></div>
|
|
<div class="col"><a class="card cf-card text-decoration-none text-reset overflow-hidden" href="/opportunities?status=open"><div class="card-body"><div class="small text-secondary fw-bold text-uppercase">Com tarefa</div><div class="h4 fw-bold mb-1">{len(attention)}</div><div class="small">requerem ação</div></div></a></div>
|
|
<div class="col"><a class="card cf-card text-decoration-none text-reset overflow-hidden" href="/opportunities?status=open"><div class="card-body"><div class="small text-secondary fw-bold text-uppercase">Tarefas pendentes</div><div class="h4 fw-bold mb-1">{total_pending}</div><div class="small">ligadas a vendas</div></div></a></div>
|
|
<div class="col"><a class="card cf-card text-decoration-none text-reset overflow-hidden" href="/opportunities?status=all"><div class="card-body"><div class="small text-secondary fw-bold text-uppercase">Valor estimado</div><div class="h4 fw-bold mb-1">{money_html(total_value)}</div><div class="small">lista atual</div></div></a></div>
|
|
</section>
|
|
|
|
<section class="card cf-card mb-3"><div class="card-body"><form class="row g-3 align-items-end" method="get" action="/opportunities" hx-get="/opportunities/partials/board" hx-target="#opportunities-board" hx-swap="outerHTML" hx-push-url="true" hx-indicator="#opportunities-loading"><div class="col-lg-7"><label class="form-label small fw-bold text-secondary">Procurar oportunidade</label><input class="form-control" type="search" name="q" value="{esc(q or '')}" placeholder="cliente, email, produto, conversa..."></div><div class="col-lg-2"><label class="form-label small fw-bold text-secondary">Estado</label><select class="form-select" name="status">{status_options}</select><input type="hidden" name="scope" value="{esc(scope or 'all')}"></div><div class="col-lg-3 d-flex gap-2"><button class="btn btn-primary flex-fill" type="submit">Filtrar</button><a class="btn btn-outline-secondary" href="/opportunities">Limpar</a></div></form></div></section>
|
|
|
|
<section class="card cf-card mb-3"><div class="card-body d-flex flex-wrap gap-2 align-items-center"><strong class="me-1">Filtros:</strong>{stage_tabs}</div></section>
|
|
|
|
<section class="card cf-card">
|
|
<div class="card-body p-0">
|
|
<div class="p-3 border-bottom d-flex flex-wrap justify-content-between align-items-center gap-2">
|
|
<div><h2 class="cf-section-title mb-1">Quadro de oportunidades</h2><div class="small text-secondary">Cards por etapa, com identificação clara, assunto, próxima ação e bloqueios relevantes. Filtros atualizam por HTMX.</div></div>
|
|
<span class="badge rounded-pill text-bg-light">{len(visible_opportunities)} resultado(s)</span>
|
|
</div>
|
|
<div class="p-3">{render_opportunities_board_partial(q=q, status=status, scope=scope, limit=limit)}</div>
|
|
</div>
|
|
</section>
|
|
"""
|
|
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", '<section class="cf-empty">Oportunidade não encontrada.</section>', "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'<div class="alert alert-info">{esc(notice)}</div>' 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 = (
|
|
'<div class="alert alert-warning border-0">'
|
|
'<strong>Registo antigo/reconstruído.</strong><br>'
|
|
'A oportunidade foi normalizada a partir de documentos já existentes. '
|
|
'Valida pagamento, valor e linhas antes de executar novas ações.'
|
|
'</div>'
|
|
)
|
|
|
|
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'<form method="post" action="/opportunities/{esc(opportunity_id)}/fiscal-enrich"><button class="btn btn-primary" type="submit">Enriquecer cliente fiscal</button></form>'
|
|
else:
|
|
primary_button = f'<a class="btn btn-primary" href="{esc(target_url)}">{esc(button_label)}</a>'
|
|
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'<a class="btn btn-primary" href="{esc(_task_href_with_return_to(str(next_task.get("id") or ""), opportunity_return_to))}">Abrir tarefa</a>'
|
|
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 = '<a class="btn btn-outline-primary" href="/tasks?status=pending">Ver tarefas</a>'
|
|
|
|
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'''
|
|
<tr class="{esc(task_row_class)}">
|
|
<td><a class="cf-row-link" href="{esc(_task_href_with_return_to(str(task.get('id') or ''), opportunity_return_to))}">{esc(action_label(task.get('action_code')))}</a><div class="small text-secondary">{esc(task_note)}</div></td>
|
|
<td>{route_badge(task.get('route'))}</td>
|
|
<td>{task_status_html}</td>
|
|
<td>{esc(fmt_dt(task.get('due_at') or task.get('created_at')))}</td>
|
|
</tr>
|
|
'''
|
|
if not task_rows:
|
|
task_rows = '<tr><td colspan="4" class="text-secondary py-4">Sem tarefas associadas.</td></tr>'
|
|
|
|
communication_rows = ""
|
|
for communication in opportunity_communications:
|
|
action = classification_action(communication.get("classification"))
|
|
communication_rows += f'''
|
|
<tr>
|
|
<td><a class="cf-row-link" href="/communications/{esc(communication.get('id'))}">{esc(communication.get('subject') or 'Sem assunto')}</a><div class="small text-secondary text-break">{esc(communication.get('sender_name') or communication.get('sender_email') or '—')}</div></td>
|
|
<td><span class="cf-chip {esc(action.get('chip'))}">{esc(communication.get('classification') or 'por classificar')}</span></td>
|
|
<td>{status_badge(communication.get('status'))}</td>
|
|
<td class="text-secondary small">{esc(fmt_dt(communication.get('created_at')))}</td>
|
|
</tr>
|
|
'''
|
|
if not communication_rows:
|
|
conv = str(opportunity.get("conversation_id") or "").strip()
|
|
if conv:
|
|
communication_rows = f'''
|
|
<tr class="table-warning">
|
|
<td><strong>Conversa Chatwoot #{esc(conv)}</strong><div class="small text-secondary">Ainda não há mensagens indexadas/ligadas nesta oportunidade.</div></td>
|
|
<td><span class="cf-chip cf-chip-orange">por sincronizar</span></td>
|
|
<td><span class="cf-chip cf-chip-gray">sem ligação local</span></td>
|
|
<td class="text-secondary small">—</td>
|
|
</tr>
|
|
'''
|
|
else:
|
|
communication_rows = '<tr><td colspan="4" class="text-secondary py-4">Sem comunicações associadas à oportunidade.</td></tr>'
|
|
|
|
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'''
|
|
<div class="cf-timeline-item">
|
|
<div class="small text-secondary">{esc(fmt_dt(event.get('created_at')))}<div class="mt-1"><span class="badge text-bg-light">{esc(source)}</span></div></div>
|
|
<div><div class="d-flex flex-wrap align-items-center gap-2"><strong>{esc(event.get('title') or 'Evento')}</strong>{status_html}</div><div class="small text-secondary text-break">{esc(detail or '—')}</div></div>
|
|
</div>
|
|
'''
|
|
if not timeline_items:
|
|
timeline_items = _derived_timeline_html(opportunity_id)
|
|
if not timeline_items:
|
|
timeline_items = '<div class="text-secondary">Sem eventos registados.</div>'
|
|
|
|
# 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 = '<option value="">Selecionar cliente...</option>'
|
|
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'<option value="{esc(c.get("id"))}" {selected}>{esc(label)}</option>'
|
|
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 = (
|
|
'<div class="alert alert-warning border-0 mb-0"><strong>Avisos para revisão</strong>'
|
|
+ '<ul class="mb-0 mt-2">'
|
|
+ ''.join(f"<li>{esc(item)}</li>" for item in current_blockers)
|
|
+ '</ul><div class="small text-secondary mt-2">Existe documento emitido/ligado; estes dados devem ser revistos para próximos documentos ou correção administrativa.</div></div>'
|
|
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'<form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/complete-fiscal"><button class="btn btn-primary" type="submit">Completar com dados Jasmin</button></form>'
|
|
else:
|
|
primary_button = f'<a class="btn btn-primary" href="/customers/{esc(linked_customer.get("id")) if linked_customer and linked_customer.get("id") else ""}">Abrir ficha fiscal</a>'
|
|
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 = '<span class="cf-chip cf-chip-green">ligado</span>'
|
|
else:
|
|
document_state = "Sem documento principal"
|
|
document_chip = '<span class="cf-chip cf-chip-orange">pendente</span>'
|
|
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 = '<span class="cf-chip cf-chip-green">OK</span>'
|
|
elif linked_customer:
|
|
fiscal_chip = '<span class="cf-chip cf-chip-orange">associado · incompleto</span>'
|
|
else:
|
|
fiscal_chip = '<span class="cf-chip cf-chip-orange">sem cliente</span>'
|
|
task_state = f"{len(pending_tasks)} pendente(s)" if pending_tasks else "Sem tarefas pendentes"
|
|
task_chip = '<span class="cf-chip cf-chip-orange">requer ação</span>' if pending_tasks else '<span class="cf-chip cf-chip-green">limpo</span>'
|
|
operator_summary_html = f'''
|
|
<section class="card cf-card cf-opp-operator-card">
|
|
<div class="card-body p-4">
|
|
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
|
|
<div><h2 class="cf-section-title mb-1">Mapa operacional</h2><div class="small text-secondary">Leitura rápida do processo: cliente fiscal, documento principal, task e próxima ação.</div></div>
|
|
<a class="btn btn-sm btn-outline-primary" href="/reconciliation?status=open">Ver reconciliação</a>
|
|
</div>
|
|
<div class="cf-opp-operator-grid">
|
|
<div><span>Cliente fiscal</span><strong>{esc(fiscal_state)}</strong>{fiscal_chip}</div>
|
|
<div><span>Documento principal</span><strong>{esc(document_state)}</strong>{document_chip}</div>
|
|
<div><span>Tasks</span><strong>{esc(task_state)}</strong>{task_chip}</div>
|
|
<div><span>Decisão seguinte</span><strong>{esc(primary_action)}</strong><span class="cf-chip cf-chip-blue">{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')}</span></div>
|
|
</div>
|
|
<details class="cf-advanced-actions mt-3">
|
|
<summary>Ações avançadas</summary>
|
|
<div class="d-flex flex-wrap gap-2 mt-2">
|
|
<a class="btn btn-sm btn-outline-secondary" href="#documentos">Documentos Jasmin</a>
|
|
<a class="btn btn-sm btn-outline-secondary" href="#tecnico">Detalhes técnicos</a>
|
|
<a class="btn btn-sm btn-outline-secondary" href="/tasks?status=pending&q={esc(opportunity_id)}">Tasks desta oportunidade</a>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</section>
|
|
'''
|
|
|
|
manual_follow_up_html = f'''
|
|
<section class="card cf-card"><div class="card-body p-4">
|
|
<h2 class="cf-section-title mb-3">Criar follow-up</h2>
|
|
<div class="small text-secondary mb-3">Agenda uma tarefa de follow-up. O sistema sugere o contacto, mas não envia email automaticamente.</div>
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/follow-up" class="d-grid gap-2">
|
|
<select class="form-select" name="follow_up_type">
|
|
<option value="quote">Orçamento enviado</option>
|
|
<option value="payment">Pagamento pendente</option>
|
|
<option value="review">Cliente a analisar</option>
|
|
<option value="generic">Follow-up genérico</option>
|
|
</select>
|
|
<select class="form-select" name="delay_days">
|
|
<option value="1">Amanhã</option>
|
|
<option value="2">Daqui a 2 dias</option>
|
|
<option value="3" selected>Daqui a 3 dias</option>
|
|
<option value="5">Daqui a 5 dias</option>
|
|
<option value="7">Daqui a 7 dias</option>
|
|
<option value="14">Daqui a 14 dias</option>
|
|
</select>
|
|
<textarea class="form-control" name="note" rows="2" placeholder="Nota opcional para o operador"></textarea>
|
|
<button class="btn btn-outline-primary" type="submit">Agendar follow-up</button>
|
|
</form>
|
|
</div></section>
|
|
'''
|
|
|
|
correction_state = _opportunity_manual_correction_state(opportunity_id)
|
|
correction_badge_html = f"""
|
|
<div class="small text-secondary mb-2">
|
|
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))}
|
|
</div>
|
|
"""
|
|
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'<option value="{esc(value)}" {selected}>{esc(label)}</option>'
|
|
manual_correction_html = f"""
|
|
<details class="card cf-card border-warning cf-advanced-actions">
|
|
<summary class="card-body p-4 fw-bold text-primary" style="cursor:pointer">
|
|
Correção avançada de associação operacional <span class="visually-hidden">Corrigir associação operacional</span>
|
|
<div class="small text-secondary fw-normal mt-1">Abrir apenas quando Odoo/Jasmin foram associados ao processo errado.</div>
|
|
{correction_badge_html}
|
|
</summary>
|
|
<div class="card-body border-top p-4">
|
|
<div class="alert alert-warning py-2 small">Zona sensível: não altera Odoo/Jasmin; só limpa a leitura local no ClientFlow e regista auditoria.</div>
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/manual-correction" class="d-grid gap-2" onsubmit="return confirm('Confirmar correção manual? Isto remove ligações locais Odoo/Jasmin desta oportunidade e altera a fase escolhida. Não apaga nada no Odoo/Jasmin.')">
|
|
<label class="form-check small"><input class="form-check-input" type="checkbox" name="unlink_odoo" value="1" checked> Desassociar Odoo desta oportunidade</label>
|
|
<label class="form-check small"><input class="form-check-input" type="checkbox" name="unlink_jasmin" value="1" checked> Desassociar documentos/candidatos Jasmin ligados</label>
|
|
<label class="form-check small"><input class="form-check-input" type="checkbox" name="remove_imported_lines" value="1" checked> Remover linhas importadas Odoo/Jasmin da oportunidade</label>
|
|
<label class="form-label small fw-bold text-secondary mb-0 mt-1">Nova fase após correção</label>
|
|
<select class="form-select" name="stage">{correction_stage_options}</select>
|
|
<textarea class="form-control" name="note" rows="3" placeholder="Motivo: Odoo/Jasmin pertencem a outro cliente; classificar como informação enviada."></textarea>
|
|
<button class="btn btn-warning" type="submit">Corrigir e classificar</button>
|
|
</form>
|
|
</div>
|
|
</details>
|
|
"""
|
|
|
|
technical_html = f'''
|
|
<div class="row g-3">
|
|
<div class="col-lg-6"><div class="cf-soft-box"><div class="small text-secondary fw-bold">ID</div><code>{esc(opportunity_id)}</code></div></div>
|
|
<div class="col-lg-6"><div class="cf-soft-box"><div class="small text-secondary fw-bold">Conversa</div><code>{esc(conversation)}</code></div></div>
|
|
<div class="col-lg-6"><div class="cf-soft-box"><div class="small text-secondary fw-bold">Última action</div><code>{esc(opportunity.get('last_action_code') or '—')}</code></div></div>
|
|
<div class="col-lg-6"><div class="cf-soft-box"><div class="small text-secondary fw-bold">Atualizada</div><strong>{esc(fmt_dt(opportunity.get('updated_at')))}</strong></div></div>
|
|
</div>
|
|
'''
|
|
|
|
# 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 = '<div class="alert alert-info py-2 px-3 small mb-0">Pagamento pós-entrega: preparação/envio podem avançar com encomenda confirmada; depois acompanhar fatura/pagamento.</div>'
|
|
elif payment_term == "before_shipping":
|
|
payment_term_hint = '<div class="alert alert-warning py-2 px-3 small mb-0">Pagamento antes do envio: confirmar pagamento com base no orçamento; emitir fatura só depois do pagamento confirmado.</div>'
|
|
finance_quick_card_html = _finance_quick_card_html(opportunity_id, linked_documents, payment_term, payment_term_label)
|
|
|
|
commercial_terms_card_html = f'''
|
|
<section class="card cf-card cf-opp-commercial-terms-card">
|
|
<div class="card-body p-4">
|
|
<h2 class="cf-section-title mb-2">Condições comerciais</h2>
|
|
<div class="small text-secondary mb-3">Define a regra do processo sem forçar um fluxo único. Fluxo normal BLIF: orçamento → pagamento → fatura → preparar/enviar encomenda.</div>
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/commercial-terms" class="d-grid gap-2">
|
|
<label class="form-label small fw-bold text-secondary mb-0">Pagamento</label>
|
|
<select class="form-select" name="payment_terms">{_option_tags(PAYMENT_TERM_LABELS, payment_term)}</select>
|
|
<label class="form-label small fw-bold text-secondary mb-0 mt-1">Entrega</label>
|
|
<select class="form-select" name="delivery_terms">{_option_tags(DELIVERY_TERM_LABELS, delivery_term)}</select>
|
|
<textarea class="form-control" name="note" rows="2" placeholder="Nota: pagamento a 30 dias, cliente habitual, acordo especial...">{esc(commercial_terms_note)}</textarea>
|
|
{payment_term_hint}
|
|
<button class="btn btn-outline-primary" type="submit">Guardar condições</button>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
'''
|
|
stage_control_html = f'''
|
|
<section class="card cf-card">
|
|
<div class="card-body p-4">
|
|
<h2 class="cf-section-title mb-2">Alterar fase comercial</h2>
|
|
<div class="small text-secondary mb-3">Lista curta: detalhes como fatura, pagamento, Odoo, produção e envio devem ser lidos nos cards de contexto.</div>
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/stage" class="d-grid gap-2">
|
|
<select class="form-select" name="stage">{stage_options}</select>
|
|
<textarea class="form-control" name="note" rows="3" placeholder="Nota opcional"></textarea>
|
|
<button class="btn btn-primary" type="submit">Guardar fase</button>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
'''
|
|
operation_action_html = f'''
|
|
<section id="operacao" class="card cf-card cf-opp-now-card">
|
|
<div class="card-body p-4">
|
|
<div class="small text-secondary fw-bold text-uppercase">O que fazer agora?</div>
|
|
<h2 class="h4 fw-bold mb-2">{esc(primary_action)}</h2>
|
|
<div class="text-secondary mb-3">{esc(primary_note)}</div>
|
|
<div class="d-grid gap-2">{primary_button}</div>
|
|
</div>
|
|
</section>
|
|
'''
|
|
|
|
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 = (
|
|
'<div class="alert alert-warning py-2 px-3 small mt-3 mb-0">'
|
|
'Dados Jasmin encontrados, mas a importação está bloqueada por NIF divergente. '
|
|
'Revê a associação fiscal antes de importar.</div>'
|
|
)
|
|
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 = '<span class="cf-mini-badge cf-mini-badge-ok">pode completar</span>'
|
|
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 = '<span class="cf-mini-badge cf-mini-badge-warn">sem novos campos</span>'
|
|
button_disabled = " disabled"
|
|
else:
|
|
fillable_text = "Cliente fiscal e documento Jasmin consistentes; sem campos em falta para importar."
|
|
fiscal_sync_status = '<span class="cf-mini-badge cf-mini-badge-ok">sem campos em falta</span>'
|
|
button_disabled = " disabled"
|
|
jasmin_fiscal_sync_html = (
|
|
'<div class="cf-soft-box mt-3">'
|
|
'<div class="d-flex flex-wrap justify-content-between align-items-start gap-2">'
|
|
'<div class="small text-secondary fw-bold">Dados fiscais disponíveis no Jasmin</div>'
|
|
f'<div>{fiscal_sync_status}</div>'
|
|
'</div>'
|
|
f'<div class="fw-bold cf-text-break">{esc(candidate_line)}</div>'
|
|
f'<div class="small text-secondary cf-text-break">{esc(doc_line or "documento Jasmin associado")}</div>'
|
|
f'<div class="small text-secondary mt-1">{esc(fillable_text)}</div>'
|
|
f'<form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/complete-fiscal" class="mt-2">'
|
|
f'<button class="btn btn-sm btn-outline-primary" type="submit"{button_disabled}>{esc(jasmin_sync_button_label)}</button>'
|
|
'</form></div>'
|
|
)
|
|
|
|
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 = (
|
|
'<span class="cf-mini-badge cf-mini-badge-ok">associado</span><span class="cf-mini-badge cf-mini-badge-ok">dados OK</span>'
|
|
if not fiscal_missing_inline
|
|
else '<span class="cf-mini-badge cf-mini-badge-ok">associado</span><span class="cf-mini-badge cf-mini-badge-warn">dados incompletos</span>'
|
|
)
|
|
fiscal_missing_note = ""
|
|
if fiscal_missing_inline:
|
|
fiscal_missing_note = '<div class="small text-secondary mt-2">Faltam: ' + esc(", ".join(fiscal_missing_inline)) + '.</div>'
|
|
fiscal_customer_url = f"/customers/{esc(linked_customer.get('id'))}" if linked_customer.get("id") else "/customers"
|
|
fiscal_association_card_html = f'''
|
|
<section class="card cf-card cf-opp-fiscal-action-card">
|
|
<div class="card-body p-4">
|
|
<div class="d-flex flex-wrap align-items-start justify-content-between gap-3 mb-3">
|
|
<div class="min-w-0">
|
|
<h2 class="cf-section-title mb-1">Cliente fiscal</h2>
|
|
<div class="small text-secondary">Ficha fiscal associada à oportunidade. <span class="visually-hidden">Associar cliente fiscal</span></div>
|
|
</div>
|
|
<div class="cf-mini-badge-row">{fiscal_status_badges}</div>
|
|
</div>
|
|
<div class="cf-linked-customer-box">
|
|
<div class="fw-bold cf-text-break">{esc(linked_customer.get('name') or 'Cliente fiscal associado')}</div>
|
|
<div class="small text-secondary cf-text-break">NIF {esc(linked_customer.get('tax_id') or '—')} · {esc(linked_customer_email)}</div>
|
|
<div class="small text-secondary cf-text-break">{esc(linked_customer_address)}</div>
|
|
{fiscal_missing_note}
|
|
</div>
|
|
{jasmin_fiscal_sync_html}
|
|
<div class="d-flex flex-wrap gap-2 mt-3">
|
|
<a class="btn btn-sm btn-outline-primary" href="{fiscal_customer_url}">Ver ficha</a>
|
|
<a class="btn btn-sm btn-outline-secondary" href="/customers?q={quote(str(customer_email or customer_name or linked_customer.get('name') or ''))}">Alterar/pesquisar</a>
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/customer" class="d-inline" onsubmit="return confirm('Desassociar cliente fiscal desta oportunidade?')">
|
|
<input type="hidden" name="customer_id" value="">
|
|
<button class="btn btn-sm btn-outline-danger" type="submit">Desassociar</button>
|
|
</form>
|
|
</div>
|
|
<div class="small text-secondary mt-3">Sugestões de identidade continuam disponíveis na secção de contexto quando houver candidatos por validar.</div>
|
|
</div>
|
|
</section>
|
|
'''
|
|
else:
|
|
fiscal_association_card_html = f'''
|
|
<section class="card cf-card cf-opp-fiscal-action-card">
|
|
<div class="card-body p-4">
|
|
<h2 class="cf-section-title mb-2">Associar cliente fiscal</h2>
|
|
<div class="small text-secondary mb-3">Resolve o bloqueio fiscal desta oportunidade. Escolhe uma ficha existente ou deixa vazio para desassociar.</div>
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/customer" class="d-grid gap-2">
|
|
<select class="form-select" name="customer_id">{customer_options}</select>
|
|
<button class="btn btn-outline-primary" type="submit">Guardar cliente fiscal</button>
|
|
</form>
|
|
{jasmin_fiscal_sync_html}
|
|
<div class="d-grid gap-2 mt-3">
|
|
<a class="btn btn-outline-secondary" href="/customers?q={quote(str(customer_email or customer_name or ''))}">Pesquisar clientes</a>
|
|
<a class="btn btn-outline-secondary" href="/customers/new">Criar cliente</a>
|
|
</div>
|
|
<details class="cf-advanced-actions mt-3" open>
|
|
<summary>Sugestões e identidade extraída</summary>
|
|
<div class="mt-3 d-grid gap-3">{email_identity_html}{fiscal_suggestions_html}</div>
|
|
</details>
|
|
</div>
|
|
</section>
|
|
'''
|
|
|
|
# "Bloqueios atuais" permanece como conceito de UI/teste, mas o layout agora separa operação e contexto.
|
|
body = f'''
|
|
<style>
|
|
.cf-opp-detail-grid {{ display:grid; grid-template-columns:minmax(0,1fr) minmax(320px,400px); grid-template-areas:'context operation'; gap:1rem; align-items:start; max-width:100%; }}
|
|
.cf-opp-detail-grid > * {{ min-width:0; }}
|
|
.cf-opp-hero {{ background:linear-gradient(135deg,#eff6ff,#fff); border:1px solid #bfdbfe; border-radius:1.25rem; box-shadow:var(--cf-shadow); }}
|
|
.cf-opp-hero h1 {{ overflow-wrap:anywhere; }}
|
|
.cf-opp-hero-meta {{ display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:.75rem; }}
|
|
.cf-opp-hero-meta>div {{ background:#fff; border:1px solid #dbeafe; border-radius:.9rem; padding:.75rem; min-width:0; }}
|
|
.cf-opp-hero-meta span {{ display:block; color:#64748b; font-size:.72rem; font-weight:900; text-transform:uppercase; letter-spacing:.04em; }}
|
|
.cf-opp-hero-meta strong {{ display:block; overflow-wrap:anywhere; margin-top:.15rem; }}
|
|
.cf-opp-now-card {{ border-left:4px solid var(--cf-primary); }}
|
|
.cf-opp-facts {{ display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:.75rem; }}
|
|
.cf-opp-fact {{ background:#f8fafc; border:1px solid #e2e8f0; border-radius:.9rem; padding:.85rem; }}
|
|
.cf-opp-fact span {{ color:#64748b; font-size:.74rem; font-weight:900; text-transform:uppercase; }}
|
|
.cf-opp-fact strong {{ display:block; margin-top:.2rem; }}
|
|
.cf-opp-operator-card {{ border-left:4px solid #38bdf8; }}
|
|
.cf-opp-operator-grid {{ display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.75rem; }}
|
|
.cf-opp-operator-grid>div {{ background:#f8fafc; border:1px solid #e2e8f0; border-radius:.9rem; padding:.85rem; display:grid; gap:.35rem; align-content:start; }}
|
|
.cf-opp-operator-grid span:first-child {{ color:#64748b; font-size:.72rem; font-weight:900; text-transform:uppercase; letter-spacing:.04em; }}
|
|
.cf-opp-operator-grid strong {{ overflow-wrap:anywhere; }}
|
|
.cf-advanced-actions summary {{ cursor:pointer; font-weight:800; color:var(--cf-primary); }}
|
|
.cf-opp-operation-column {{ grid-area:operation; position:sticky; top:1rem; display:grid; gap:1rem; min-width:0; align-self:start; }}
|
|
.cf-opp-context-column {{ grid-area:context; display:grid; gap:1rem; min-width:0; }}
|
|
.cf-opp-column-title {{ display:flex; align-items:center; justify-content:space-between; gap:.75rem; color:#64748b; font-size:.78rem; font-weight:900; text-transform:uppercase; letter-spacing:.06em; }}
|
|
.cf-opp-column-title::after {{ content:""; flex:1; height:1px; background:#e2e8f0; }}
|
|
.cf-opp-fiscal-action-card {{ border-left:4px solid #0ea5e9; }}
|
|
.cf-text-break {{ overflow-wrap:anywhere; word-break:break-word; }}
|
|
.cf-linked-customer-box {{ background:#f8fafc; border:1px solid #e2e8f0; border-radius:.9rem; padding:.85rem; min-width:0; }}
|
|
.cf-mini-badge-row {{ display:flex; flex-wrap:wrap; gap:.35rem; justify-content:flex-end; max-width:100%; }}
|
|
.cf-mini-badge {{ display:inline-flex; align-items:center; border-radius:999px; padding:.25rem .55rem; font-size:.72rem; line-height:1.05; font-weight:900; max-width:100%; overflow-wrap:anywhere; }}
|
|
.cf-mini-badge-ok {{ background:#dcfce7; color:#166534; border:1px solid #bbf7d0; }}
|
|
.cf-mini-badge-warn {{ background:#ffedd5; color:#9a3412; border:1px solid #fed7aa; }}
|
|
.cf-opp-commercial-terms-card {{ border-left:4px solid #f59e0b; }}
|
|
.cf-opp-finance-quick-card {{ border-left:4px solid #22c55e; }}
|
|
@media (max-width: 1400px) {{ .cf-opp-detail-grid {{ grid-template-columns:1fr; grid-template-areas:'operation' 'context'; }} .cf-opp-operation-column {{ position:static; }} }}
|
|
@media (max-width: 1050px) {{ .cf-opp-hero-meta {{ grid-template-columns:repeat(2,minmax(0,1fr)); }} .cf-opp-operator-grid {{ grid-template-columns:repeat(2,minmax(0,1fr)); }} }}
|
|
@media (max-width: 720px) {{ .cf-opp-facts,.cf-opp-operator-grid,.cf-opp-hero-meta {{ grid-template-columns:1fr; }} }}
|
|
</style>
|
|
|
|
<a class="cf-row-link d-inline-flex mb-3" href="/opportunities">← Voltar a oportunidades</a>
|
|
{notice_html}
|
|
{legacy_notice_html}
|
|
{customer_mismatch_alert}
|
|
{consistency_alert_html}
|
|
|
|
<section class="cf-opp-hero p-4 mb-3">
|
|
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
|
|
<div>
|
|
<div class="text-primary fw-bold mb-1">Oportunidade</div>
|
|
<h1 class="h3 fw-bold mb-2">{esc(opportunity.get('title') or 'Oportunidade')}</h1>
|
|
<div class="text-secondary">{esc(customer_name)} · {esc(opportunity.get('product_interest') or 'Interesse por definir')}</div>
|
|
</div>
|
|
<div class="d-flex flex-wrap gap-2">{opportunity_stage_badge(stage)}{opportunity_priority_chip(opportunity)}</div>
|
|
</div>
|
|
<div class="cf-opp-hero-meta">
|
|
<div><span>Próxima ação</span><strong>{esc(primary_action)}</strong></div>
|
|
<div><span>Cliente fiscal</span><strong>{esc(fiscal_state)}</strong></div>
|
|
<div><span>Documento</span><strong>{esc(document_state)}</strong></div>
|
|
<div><span>Valor</span><strong>{money_html(estimated_value)}</strong></div>
|
|
<div><span>Tasks</span><strong>{esc(task_state)}</strong></div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Legacy test anchors kept for static regression compatibility; not rendered as duplicate tab navigation: href="#comunicacoes" <a href="#odoo">Odoo</a> -->
|
|
|
|
<div class="cf-opp-detail-grid">
|
|
<!-- Desktop: context/evidence on the left; operation/sticky decision panel on the right. No duplicated tabs; sections render as vertical cards. DOM keeps operation first so mobile still starts with action. -->
|
|
<aside class="cf-opp-operation-column">
|
|
<div class="cf-opp-column-title">Operação</div>
|
|
{operation_action_html}
|
|
{operator_summary_html}
|
|
{finance_quick_card_html}
|
|
{commercial_terms_card_html}
|
|
{fiscal_association_card_html}
|
|
{f'<section class="card cf-card"><div class="card-body p-4">{blockers_html}</div></section>' if current_blockers else ''}
|
|
{next_task_focus_html}
|
|
{manual_follow_up_html}
|
|
{stage_control_html}
|
|
{manual_correction_html}
|
|
</aside>
|
|
|
|
<main class="cf-opp-context-column">
|
|
<div class="cf-opp-column-title">Contexto e evidência</div>
|
|
<!-- Associação fiscal: contexto fiscal e contacto Chatwoot -->
|
|
<div id="cliente">{fiscal_contact_html}</div>
|
|
|
|
<div class="row g-3"><div class="col-xl-6">{fiscal_readiness_html}</div><div class="col-xl-6">{shipment_readiness_html}</div></div>
|
|
|
|
<section id="resumo" class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Resumo essencial</h2><div class="cf-opp-facts"><div class="cf-opp-fact"><span>{'Valor principal' if document_value else ('Valor reconstruído' if legacy_mode else 'Valor estimado')}</span><strong>{money_html(estimated_value)}</strong><div class="small text-secondary">{esc(value_source)}</div></div><div class="cf-opp-fact"><span>Tarefas pendentes</span><strong>{len(pending_tasks)}</strong></div><div class="cf-opp-fact"><span>Atualizada</span><strong>{esc(fmt_dt(opportunity.get('updated_at')))}</strong></div></div></div></section>
|
|
|
|
<section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Pipeline</h2>{stage_progress_html(stage)}</div></section>
|
|
|
|
{operation_cockpit_html(opportunity_id, opportunity_for_cockpit, operation_snapshot)}
|
|
|
|
<div id="documentos">{jasmin_documents_html(opportunity_id)}</div>
|
|
|
|
<div id="produtos">{opportunity_products_panel_html(opportunity_id)}</div>
|
|
|
|
<div id="odoo">{odoo_status_panel_html(opportunity_id)}</div>
|
|
|
|
<div id="outbox">{opportunity_integrations_panel_html(opportunity_id)}</div>
|
|
|
|
<section id="tasks-list" class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Tasks relacionadas</h2><div class="small text-secondary">Ações humanas já criadas para esta oportunidade.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Ação</th><th>Fila</th><th>Estado</th><th>Data</th></tr></thead><tbody>{task_rows}</tbody></table></div></div></section>
|
|
|
|
<section id="comunicacoes" class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom d-flex justify-content-between align-items-center"><div><h2 class="cf-section-title">Mensagens Chatwoot</h2><div class="small text-secondary">Mensagens relevantes ligadas a esta oportunidade. A resposta continua no Chatwoot.</div></div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Mensagem</th><th>Classificação</th><th>Estado</th><th>Recebida</th></tr></thead><tbody>{communication_rows}</tbody></table></div></div></section>
|
|
|
|
<section id="timeline" class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Timeline recente</h2><div class="cf-timeline">{timeline_items}</div></div></section>
|
|
|
|
<details id="tecnico" class="card cf-card"><summary class="card-body p-4 fw-bold text-primary" style="cursor:pointer">Ver detalhes técnicos e edição avançada</summary><div class="card-body border-top p-4 d-grid gap-3">{technical_html}</div></details>
|
|
</main>
|
|
</div>
|
|
'''
|
|
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)
|
|
|
|
|