3429 lines
190 KiB
Python
3429 lines
190 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 sqlalchemy.exc import OperationalError
|
|
from app.db import engine
|
|
from urllib.parse import quote
|
|
import json
|
|
import time
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
import app.admin_dashboard as legacy
|
|
from app.admin_dashboard import * # noqa: F401,F403
|
|
from app.admin_ui.labels import primary_action_label
|
|
from app.operation_noise import is_noise_operation_item
|
|
from app.opportunity_next_action_service import get_opportunity_next_action
|
|
from app.opportunity_action_task_materializer import ensure_pending_task_for_next_action
|
|
from app.work_center_action_policy import (
|
|
canonical_action_code,
|
|
reconstructed_review_required,
|
|
reconstructed_review_status,
|
|
)
|
|
from app.opportunity_service import (
|
|
LOSS_REASON_LABELS,
|
|
OPPORTUNITY_LIFECYCLE_STATES,
|
|
lifecycle_label,
|
|
mark_opportunity_lost,
|
|
set_opportunity_lifecycle,
|
|
)
|
|
from app.admin_ui.guidance import (
|
|
blocker_alert_html,
|
|
fiscal_contact_inline_html,
|
|
fiscal_contact_panel_html,
|
|
fiscal_customer_missing_fields,
|
|
opportunity_blockers,
|
|
opportunity_context_customer,
|
|
readiness_checklist_html,
|
|
shipment_missing_fields,
|
|
stage_requires_fiscal_customer,
|
|
)
|
|
|
|
_opportunity_board_column_for_stage = legacy._opportunity_board_column_for_stage
|
|
|
|
router = APIRouter()
|
|
|
|
PAYMENT_TERM_LABELS = {
|
|
"before_shipping": "Antes do envio",
|
|
"after_delivery": "Após entrega",
|
|
"agreement": "Conforme acordo",
|
|
"undefined": "A definir",
|
|
}
|
|
|
|
|
|
_OBSOLETE_AFTER_PAYMENT_TASK_CODES = {
|
|
"CONFIRM_PAYMENT",
|
|
"FOLLOW_UP_PAYMENT",
|
|
"FOLLOW_UP_PROFORMA",
|
|
"FOLLOW_UP_QUOTE",
|
|
"CONFIRM_DELIVERY",
|
|
"RECOVER_OPPORTUNITY",
|
|
"REVIEW_NURTURE",
|
|
}
|
|
|
|
|
|
def _is_obsolete_after_payment_task(task: dict, payment_confirmed_for_ui: bool) -> bool:
|
|
if not payment_confirmed_for_ui:
|
|
return False
|
|
code = str((task or {}).get("action_code") or "").upper().strip()
|
|
if code in _OBSOLETE_AFTER_PAYMENT_TASK_CODES:
|
|
metadata = (task or {}).get("metadata") or {}
|
|
if isinstance(metadata, dict):
|
|
metadata.setdefault("ui_reason", "obsoleta: pagamento já confirmado")
|
|
return True
|
|
return False
|
|
|
|
DELIVERY_TERM_LABELS = {
|
|
"carrier": "Transportadora",
|
|
"pickup": "Levantamento",
|
|
"install_partner": "Eletricista/instalador do cliente",
|
|
"undefined": "A definir",
|
|
}
|
|
|
|
# UI simplification: commercial phases stay short; financial/Odoo/shipping details
|
|
# remain visible as derived evidence cards instead of becoming dozens of manual phases.
|
|
COMMERCIAL_STAGE_OPTIONS = [
|
|
("NEW_LEAD", "Novo pedido"),
|
|
("INFO_SENT", "Informação enviada"),
|
|
("QUOTE_SENT", "Orçamento enviado"),
|
|
("WAITING_PAYMENT", "A aguardar pagamento"),
|
|
("PAYMENT_CONFIRMED", "Pagamento confirmado"),
|
|
("ODOO_ORDER_CREATED", "Encomenda confirmada / em execução"),
|
|
("WON", "Concluído"),
|
|
("REVIEW", "Rever"),
|
|
]
|
|
|
|
_DETAILED_OPERATIONAL_STAGES = {
|
|
"INFO_REQUESTED",
|
|
"QUOTE_REQUESTED",
|
|
"PROFORMA_REQUESTED",
|
|
"INVOICE_REQUESTED",
|
|
"INVOICE_SENT",
|
|
"WAITING_PAYMENT",
|
|
"PAYMENT_CONFIRMED",
|
|
"IN_PRODUCTION",
|
|
"READY_TO_SHIP",
|
|
"INVOICED",
|
|
"SHIPMENT_CREATED",
|
|
"TRACKING_SENT",
|
|
"DELIVERED",
|
|
"ORDER_PREPARATION",
|
|
"SHIPPED",
|
|
"NO_INTEREST",
|
|
"ARCHIVED",
|
|
}
|
|
|
|
def _opportunity_metadata(opportunity: dict) -> dict:
|
|
raw = opportunity.get("metadata") if isinstance(opportunity, dict) else {}
|
|
return raw if isinstance(raw, dict) else {}
|
|
|
|
|
|
def _commercial_stage_options_html(current_stage: str) -> str:
|
|
current_stage = str(current_stage or "NEW_LEAD").upper()
|
|
option_values = {value for value, _label in COMMERCIAL_STAGE_OPTIONS}
|
|
html = ""
|
|
if current_stage not in option_values and current_stage in OPPORTUNITY_STAGE_LABELS:
|
|
html += (
|
|
'<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
|
|
|
|
|
|
def _opportunity_payment_confirmed(opportunity_id: str) -> bool:
|
|
if not is_uuid_text(opportunity_id):
|
|
return False
|
|
with engine.begin() as conn:
|
|
return bool(conn.execute(text('''
|
|
SELECT 1
|
|
FROM operation_links
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
AND system = 'clientflow'
|
|
AND external_type = 'payment'
|
|
AND status = 'confirmed'
|
|
LIMIT 1
|
|
'''), {"opportunity_id": opportunity_id}).scalar())
|
|
|
|
|
|
def _opportunity_invoice_sent_evidence(opportunity_id: str, invoice_number: str | None = None) -> bool:
|
|
"""Return local evidence that an invoice was sent to the customer.
|
|
|
|
Commercial documents imported from Jasmin do not always carry a sent flag.
|
|
Reconstructed opportunities often have the evidence only as a completed
|
|
SEND_INVOICE task, so the UI must use the same evidence model as the
|
|
central next-action engine.
|
|
"""
|
|
if not is_uuid_text(opportunity_id):
|
|
return False
|
|
normalized_invoice = str(invoice_number or "").strip().upper()
|
|
with engine.begin() as conn:
|
|
row = conn.execute(text('''
|
|
SELECT 1
|
|
FROM tasks
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
AND action_code = 'SEND_INVOICE'
|
|
AND LOWER(COALESCE(status, '')) IN ('done','completed','complete','closed','resolved','concluida','concluído','concluída')
|
|
AND (
|
|
:invoice_number = ''
|
|
OR UPPER(COALESCE(action, '') || ' ' || COALESCE(note, '') || ' ' || COALESCE(metadata::text, '')) LIKE '%' || :invoice_number || '%'
|
|
OR COALESCE(metadata->>'document_number', metadata->>'invoice_number', '') = ''
|
|
)
|
|
LIMIT 1
|
|
'''), {
|
|
"opportunity_id": opportunity_id,
|
|
"invoice_number": normalized_invoice,
|
|
}).scalar()
|
|
return bool(row)
|
|
|
|
|
|
def _document_display_number(doc: dict | None) -> str:
|
|
if not doc:
|
|
return "—"
|
|
return str(doc.get("document_number") or doc.get("external_id") or doc.get("id") or "documento")
|
|
|
|
|
|
def _finance_quick_card_html(opportunity_id: str, linked_documents: list[dict], payment_term: str, payment_term_label: str) -> str:
|
|
# BLIF default flow: quotation -> payment -> invoice -> prepare/ship.
|
|
quotation = next((d for d in linked_documents if str(d.get("document_kind") or "") in {"quotation", "proforma"} and str(d.get("role") or "current") in {"current", "accepted"}), None)
|
|
invoice = next((d for d in linked_documents if str(d.get("document_kind") or "") == "invoice" and str(d.get("role") or "current") in {"current", "accepted"}), None)
|
|
payment_confirmed = _opportunity_payment_confirmed(opportunity_id)
|
|
base_doc = invoice or quotation
|
|
base_doc_label = "Fatura" if invoice else ("Orçamento" if quotation else "Documento")
|
|
amount = (base_doc.get("total_amount") or base_doc.get("amount")) if base_doc else None
|
|
amount_html = money_html(float(amount or 0)) if amount else "—"
|
|
payment_status = "Confirmado" if payment_confirmed else ("Pendente pós-entrega" if payment_term == "after_delivery" else "Por confirmar")
|
|
|
|
if not base_doc:
|
|
action_html = '<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_payload = invoice.get("payload") if isinstance(invoice.get("payload"), dict) else {}
|
|
invoice_sent = bool(
|
|
invoice.get("sent_at")
|
|
or invoice.get("sent")
|
|
or str(invoice.get("status") or "").lower() in {"sent", "issued_sent"}
|
|
or invoice_payload.get("clientflow_invoice_sent_evidence")
|
|
or invoice_payload.get("invoice_sent_at")
|
|
or _opportunity_invoice_sent_evidence(opportunity_id, _document_display_number(invoice))
|
|
)
|
|
if invoice_sent:
|
|
detail = "Fatura enviada e pagamento confirmado. Continua pela próxima ação operacional indicada acima."
|
|
else:
|
|
detail = "Fatura criada/associada. Envia o PDF ao cliente; depois acompanha preparação/Odoo."
|
|
action_html = f'<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.
|
|
|
|
This card is auxiliary/advanced UI only. It must never make the
|
|
opportunity detail page fail if PostgreSQL detects a transient lock cycle
|
|
while reconciliation/sync jobs are rebuilding evidence. Retry once and then
|
|
return a safe degraded state instead of surfacing a 500.
|
|
"""
|
|
safe_empty = {
|
|
"odoo_links": 0,
|
|
"jasmin_links": 0,
|
|
"jasmin_documents": 0,
|
|
"imported_lines": 0,
|
|
"reconciliation_items": 0,
|
|
}
|
|
if not is_uuid_text(opportunity_id):
|
|
return safe_empty
|
|
|
|
last_lock_error = None
|
|
for attempt in range(2):
|
|
try:
|
|
with engine.begin() as conn:
|
|
row = conn.execute(text("""
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE system = 'odoo')::int AS odoo_links,
|
|
COUNT(*) FILTER (WHERE system = 'jasmin')::int AS jasmin_links
|
|
FROM operation_links
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
"""), {"opportunity_id": opportunity_id}).mappings().first() or {}
|
|
docs = conn.execute(text("""
|
|
SELECT COUNT(*)::int
|
|
FROM commercial_documents
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
AND system = 'jasmin'
|
|
"""), {"opportunity_id": opportunity_id}).scalar() or 0
|
|
imported = conn.execute(text("""
|
|
SELECT COUNT(*)::int
|
|
FROM opportunity_items
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
AND (
|
|
UPPER(COALESCE(status,'')) IN ('ODOO_IMPORTED','JASMIN_IMPORTED')
|
|
OR UPPER(COALESCE(status,'')) LIKE '%\\_IMPORTED' ESCAPE '\\'
|
|
OR COALESCE(metadata->>'source_system','') IN ('odoo','jasmin')
|
|
)
|
|
"""), {"opportunity_id": opportunity_id}).scalar() or 0
|
|
reconciliation = conn.execute(text("""
|
|
SELECT COUNT(*)::int
|
|
FROM reconciliation_items
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
AND source_system IN ('odoo','jasmin')
|
|
"""), {"opportunity_id": opportunity_id}).scalar() or 0
|
|
data = dict(row)
|
|
data["jasmin_documents"] = int(docs or 0)
|
|
data["imported_lines"] = int(imported or 0)
|
|
data["reconciliation_items"] = int(reconciliation or 0)
|
|
return data
|
|
except OperationalError as exc:
|
|
msg = str(exc).lower()
|
|
if "deadlock detected" in msg or "lock timeout" in msg or "could not obtain lock" in msg:
|
|
last_lock_error = exc
|
|
if attempt == 0:
|
|
time.sleep(0.25)
|
|
continue
|
|
degraded = dict(safe_empty)
|
|
degraded["unavailable"] = True
|
|
degraded["unavailable_reason"] = "lock_timeout"
|
|
return degraded
|
|
raise
|
|
|
|
if last_lock_error:
|
|
degraded = dict(safe_empty)
|
|
degraded["unavailable"] = True
|
|
degraded["unavailable_reason"] = "lock_timeout"
|
|
return degraded
|
|
return safe_empty
|
|
|
|
|
|
|
|
|
|
def _opportunity_archive_spam_state(opportunity_id: str) -> dict:
|
|
"""Best-effort state for the UI-only spam archive card.
|
|
|
|
The POST action still performs the authoritative safety check. This helper
|
|
must never break the opportunity page; deadlocks/reconciliation locks simply
|
|
hide the archive card for this request.
|
|
"""
|
|
if not is_uuid_text(opportunity_id):
|
|
return {"can_archive": False, "unavailable": True}
|
|
try:
|
|
with engine.begin() as conn:
|
|
row = conn.execute(text("""
|
|
SELECT
|
|
(SELECT COUNT(*) FROM commercial_documents WHERE opportunity_id = CAST(:opportunity_id AS UUID)) AS docs,
|
|
(SELECT COUNT(*) FROM reconciliation_items WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND source_system IN ('jasmin','odoo')) AS linked_external,
|
|
(SELECT COUNT(*) FROM operation_links WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND system IN ('jasmin','odoo','packlink')) AS operation_links,
|
|
(SELECT COUNT(*) FROM tasks WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND action_code = 'IGNORE_SPAM') AS spam_tasks,
|
|
(SELECT COUNT(*) FROM tasks WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND route = 'spam') AS spam_route_tasks,
|
|
(SELECT COUNT(*) FROM communications WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND classification IN ('IGNORE_SPAM','SPAM')) AS spam_communications,
|
|
(SELECT status FROM opportunities WHERE id = CAST(:opportunity_id AS UUID)) AS status
|
|
"""), {"opportunity_id": opportunity_id}).mappings().first() or {}
|
|
except OperationalError:
|
|
return {"can_archive": False, "unavailable": True}
|
|
except Exception:
|
|
return {"can_archive": False, "unavailable": True}
|
|
docs = int(row.get("docs") or 0)
|
|
linked_external = int(row.get("linked_external") or 0)
|
|
operation_links = int(row.get("operation_links") or 0)
|
|
spam_evidence = int(row.get("spam_tasks") or 0) + int(row.get("spam_route_tasks") or 0) + int(row.get("spam_communications") or 0)
|
|
status = str(row.get("status") or "").lower()
|
|
return {
|
|
"can_archive": status != "archived" and docs == 0 and linked_external == 0 and operation_links == 0,
|
|
"has_spam_evidence": spam_evidence > 0,
|
|
"docs": docs,
|
|
"linked_external": linked_external,
|
|
"operation_links": operation_links,
|
|
"spam_evidence": spam_evidence,
|
|
"status": status,
|
|
}
|
|
|
|
def _recalculate_opportunity_value_after_manual_correction(conn, opportunity_id: str):
|
|
manual_total = conn.execute(text("""
|
|
SELECT COALESCE(SUM(total_price), 0)::numeric
|
|
FROM opportunity_items
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
AND UPPER(COALESCE(status,'')) NOT IN ('REJECTED','CANCELLED','DELIVERED','HISTORICAL','ODOO_IMPORTED','JASMIN_IMPORTED')
|
|
AND UPPER(COALESCE(status,'')) NOT LIKE '%\\_IMPORTED' ESCAPE '\\'
|
|
AND COALESCE(metadata->>'source_system','manual') NOT IN ('odoo','jasmin')
|
|
"""), {"opportunity_id": opportunity_id}).scalar()
|
|
return manual_total or 0
|
|
|
|
|
|
def apply_manual_external_correction(
|
|
opportunity_id: str,
|
|
*,
|
|
unlink_odoo: bool,
|
|
unlink_jasmin: bool,
|
|
remove_imported_lines: bool,
|
|
new_stage: str,
|
|
note: str,
|
|
actor: str = "operator_manual_correction",
|
|
) -> dict:
|
|
"""Manual override for wrongly linked Odoo/Jasmin evidence.
|
|
|
|
This is intentionally auditable and local-only: it never deletes data in Odoo,
|
|
Jasmin or Chatwoot. It only detaches ClientFlow evidence from the opportunity.
|
|
"""
|
|
if not is_uuid_text(opportunity_id):
|
|
raise ValueError("Identificador de oportunidade inválido.")
|
|
new_stage = str(new_stage or "INFO_SENT").strip().upper()
|
|
if new_stage not in OPPORTUNITY_STAGE_LABELS:
|
|
raise ValueError(f"Fase inválida: {new_stage}")
|
|
action_by_stage = {
|
|
"INFO_SENT": "SEND_INFO",
|
|
"INFO_REQUESTED": "SEND_INFO",
|
|
"QUOTE_REQUESTED": "SEND_QUOTE",
|
|
"QUOTE_SENT": "SEND_QUOTE",
|
|
"PROFORMA_REQUESTED": "SEND_PROFORMA",
|
|
"PROFORMA_SENT": "SEND_PROFORMA",
|
|
"INVOICE_REQUESTED": "SEND_INVOICE",
|
|
"INVOICE_SENT": "SEND_INVOICE",
|
|
"WAITING_PAYMENT": "CONFIRM_PAYMENT",
|
|
"REVIEW": "REVIEW_MANUALLY",
|
|
"LOST": "MARK_NO_INTEREST",
|
|
"NO_INTEREST": "MARK_NO_INTEREST",
|
|
}
|
|
new_action = action_by_stage.get(new_stage, "SEND_INFO")
|
|
sources: list[str] = []
|
|
if unlink_odoo:
|
|
sources.append("odoo")
|
|
if unlink_jasmin:
|
|
sources.append("jasmin")
|
|
if not sources and not new_stage:
|
|
return {"changed": 0}
|
|
|
|
result = {
|
|
"operation_links_deleted": 0,
|
|
"jasmin_documents_deleted": 0,
|
|
"imported_lines_deleted": 0,
|
|
"reconciliation_items_unlinked": 0,
|
|
"stage": new_stage,
|
|
}
|
|
note = (note or "Correção manual: associação externa errada removida pelo operador.").strip()
|
|
metadata_patch = {
|
|
"manual_external_correction": True,
|
|
"manual_external_correction_sources": sources,
|
|
"manual_external_correction_note": note,
|
|
"manual_external_correction_actor": actor,
|
|
}
|
|
|
|
with engine.begin() as conn:
|
|
current = conn.execute(text("""
|
|
SELECT stage, value_amount, last_action_code
|
|
FROM opportunities
|
|
WHERE id = CAST(:opportunity_id AS UUID)
|
|
"""), {"opportunity_id": opportunity_id}).mappings().first()
|
|
if not current:
|
|
raise ValueError("Oportunidade não encontrada.")
|
|
old_stage = str(current.get("stage") or "NEW_LEAD")
|
|
|
|
if unlink_odoo:
|
|
result["operation_links_deleted"] += conn.execute(text("""
|
|
DELETE FROM operation_links
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
AND system = 'odoo'
|
|
"""), {"opportunity_id": opportunity_id}).rowcount or 0
|
|
|
|
if unlink_jasmin:
|
|
# Commercial document lines are removed by ON DELETE CASCADE.
|
|
result["jasmin_documents_deleted"] += conn.execute(text("""
|
|
DELETE FROM commercial_documents
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
AND system = 'jasmin'
|
|
"""), {"opportunity_id": opportunity_id}).rowcount or 0
|
|
result["operation_links_deleted"] += conn.execute(text("""
|
|
DELETE FROM operation_links
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
AND system = 'jasmin'
|
|
"""), {"opportunity_id": opportunity_id}).rowcount or 0
|
|
|
|
if remove_imported_lines and sources:
|
|
result["imported_lines_deleted"] += conn.execute(text("""
|
|
DELETE FROM opportunity_items
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
AND (
|
|
(:unlink_odoo IS TRUE AND (COALESCE(metadata->>'source_system','') = 'odoo' OR UPPER(COALESCE(status,'')) = 'ODOO_IMPORTED'))
|
|
OR (:unlink_jasmin IS TRUE AND (COALESCE(metadata->>'source_system','') = 'jasmin' OR UPPER(COALESCE(status,'')) = 'JASMIN_IMPORTED'))
|
|
OR ((:unlink_odoo IS TRUE OR :unlink_jasmin IS TRUE) AND UPPER(COALESCE(status,'')) LIKE '%\\_IMPORTED' ESCAPE '\\')
|
|
)
|
|
"""), {"opportunity_id": opportunity_id, "unlink_odoo": bool(unlink_odoo), "unlink_jasmin": bool(unlink_jasmin)}).rowcount or 0
|
|
|
|
if sources:
|
|
result["reconciliation_items_unlinked"] += conn.execute(text("""
|
|
UPDATE reconciliation_items
|
|
SET opportunity_id = NULL,
|
|
status = CASE WHEN status IN ('resolved','linked','applied','open','needs_review','conflict') THEN 'needs_review' ELSE status END,
|
|
resolution_note = COALESCE(resolution_note || ' | ', '') || :note,
|
|
resolved_at = NULL,
|
|
payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB),
|
|
updated_at = now()
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
AND (
|
|
(:unlink_odoo IS TRUE AND source_system = 'odoo')
|
|
OR (:unlink_jasmin IS TRUE AND source_system = 'jasmin')
|
|
)
|
|
"""), {
|
|
"opportunity_id": opportunity_id,
|
|
"unlink_odoo": bool(unlink_odoo),
|
|
"unlink_jasmin": bool(unlink_jasmin),
|
|
"note": note,
|
|
"payload": _json_payload({"manual_unlinked_from_opportunity_id": opportunity_id, "sources": sources, "actor": actor}),
|
|
}).rowcount or 0
|
|
|
|
manual_total = _recalculate_opportunity_value_after_manual_correction(conn, opportunity_id)
|
|
conn.execute(text("""
|
|
UPDATE opportunities
|
|
SET stage = :stage,
|
|
status = CASE WHEN :stage IN ('WON','LOST','NO_INTEREST','DELIVERED') THEN 'closed' ELSE 'open' END,
|
|
last_action_code = :action_code,
|
|
value_amount = CAST(:value_amount AS NUMERIC),
|
|
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB),
|
|
closed_at = CASE WHEN :stage IN ('WON','LOST','NO_INTEREST','DELIVERED') THEN COALESCE(closed_at, now()) ELSE NULL END,
|
|
updated_at = now()
|
|
WHERE id = CAST(:opportunity_id AS UUID)
|
|
"""), {
|
|
"opportunity_id": opportunity_id,
|
|
"stage": new_stage,
|
|
"action_code": new_action,
|
|
"value_amount": manual_total,
|
|
"metadata": _json_payload(metadata_patch),
|
|
})
|
|
conn.execute(text("""
|
|
INSERT INTO opportunity_events (
|
|
id, opportunity_id, event_type, action_code, from_stage, to_stage, note, payload, created_by
|
|
) VALUES (
|
|
CAST(:id AS UUID), CAST(:opportunity_id AS UUID), 'manual_external_correction',
|
|
:action_code, :from_stage, :to_stage, :note, CAST(:payload AS JSONB), :created_by
|
|
)
|
|
"""), {
|
|
"id": str(uuid.uuid4()),
|
|
"opportunity_id": opportunity_id,
|
|
"action_code": new_action,
|
|
"from_stage": old_stage,
|
|
"to_stage": new_stage,
|
|
"note": note,
|
|
"payload": _json_payload(result),
|
|
"created_by": actor,
|
|
})
|
|
return result
|
|
|
|
|
|
def ignore_external_candidate_for_opportunity(opportunity_id: str, item_id: str, *, actor: str = "operator_ui_ignore_candidate") -> int:
|
|
if not is_uuid_text(opportunity_id) or not is_uuid_text(item_id):
|
|
raise ValueError("Identificador inválido.")
|
|
with engine.begin() as conn:
|
|
count = conn.execute(text("""
|
|
UPDATE reconciliation_items
|
|
SET status = 'ignored',
|
|
opportunity_id = CASE WHEN opportunity_id = CAST(:opportunity_id AS UUID) THEN NULL ELSE opportunity_id END,
|
|
resolution_note = COALESCE(resolution_note || ' | ', '') || 'Ignorado manualmente a partir da oportunidade.',
|
|
resolved_at = now(),
|
|
payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB),
|
|
updated_at = now()
|
|
WHERE id = CAST(:item_id AS UUID)
|
|
AND source_system IN ('odoo','jasmin')
|
|
"""), {
|
|
"opportunity_id": opportunity_id,
|
|
"item_id": item_id,
|
|
"payload": _json_payload({"ignored_from_opportunity_id": opportunity_id, "actor": actor}),
|
|
}).rowcount or 0
|
|
if count:
|
|
conn.execute(text("""
|
|
INSERT INTO opportunity_events (
|
|
id, opportunity_id, event_type, action_code, note, payload, created_by
|
|
) VALUES (
|
|
CAST(:id AS UUID), CAST(:opportunity_id AS UUID), 'external_candidate_ignored',
|
|
'REVIEW_RECONCILIATION', :note, CAST(:payload AS JSONB), :created_by
|
|
)
|
|
"""), {
|
|
"id": str(uuid.uuid4()),
|
|
"opportunity_id": opportunity_id,
|
|
"note": "Candidato externo ignorado manualmente.",
|
|
"payload": _json_payload({"item_id": item_id}),
|
|
"created_by": actor,
|
|
})
|
|
return int(count or 0)
|
|
|
|
|
|
|
|
|
|
def unlink_commercial_document_from_opportunity(
|
|
opportunity_id: str,
|
|
document_id: str,
|
|
*,
|
|
remove_imported_lines: bool = True,
|
|
note: str = "",
|
|
actor: str = "operator_ui_document_unlink",
|
|
) -> dict:
|
|
"""Detach one local commercial document from an opportunity.
|
|
|
|
This is the granular counterpart to the broad manual external correction.
|
|
It does not delete anything in Jasmin/Odoo. It only removes the document
|
|
from this ClientFlow opportunity and, when requested, removes imported
|
|
opportunity lines that explicitly came from that document reference.
|
|
"""
|
|
if not is_uuid_text(opportunity_id) or not is_uuid_text(document_id):
|
|
raise ValueError("Identificador inválido.")
|
|
note = (note or "Documento desassociado manualmente desta oportunidade.").strip()
|
|
result = {"document_unlinked": 0, "imported_lines_deleted": 0, "reconciliation_items_unlinked": 0}
|
|
with engine.begin() as conn:
|
|
doc = conn.execute(text("""
|
|
SELECT id::text, opportunity_id::text, system, document_kind, external_id, document_number,
|
|
role, is_primary, total_amount, amount
|
|
FROM commercial_documents
|
|
WHERE id = CAST(:document_id AS UUID)
|
|
AND opportunity_id = CAST(:opportunity_id AS UUID)
|
|
LIMIT 1
|
|
"""), {"document_id": document_id, "opportunity_id": opportunity_id}).mappings().first()
|
|
if not doc:
|
|
raise ValueError("Documento não encontrado nesta oportunidade.")
|
|
refs = [str(doc.get("document_number") or "").strip(), str(doc.get("external_id") or "").strip()]
|
|
refs = [r for r in refs if r]
|
|
payload = _json_payload({
|
|
"manual_document_unlink": True,
|
|
"opportunity_id": opportunity_id,
|
|
"document_id": document_id,
|
|
"document_number": doc.get("document_number"),
|
|
"external_id": doc.get("external_id"),
|
|
"actor": actor,
|
|
"note": note,
|
|
})
|
|
result["document_unlinked"] = conn.execute(text("""
|
|
UPDATE commercial_documents
|
|
SET opportunity_id = NULL,
|
|
role = 'detached',
|
|
is_primary = FALSE,
|
|
is_active = FALSE,
|
|
payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB),
|
|
updated_at = now()
|
|
WHERE id = CAST(:document_id AS UUID)
|
|
AND opportunity_id = CAST(:opportunity_id AS UUID)
|
|
"""), {"document_id": document_id, "opportunity_id": opportunity_id, "payload": payload}).rowcount or 0
|
|
|
|
if remove_imported_lines and refs:
|
|
result["imported_lines_deleted"] = conn.execute(text("""
|
|
DELETE FROM opportunity_items
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
AND (
|
|
metadata->>'source_system' = CAST(:system AS TEXT)
|
|
OR (:system = 'jasmin' AND status = 'JASMIN_IMPORTED')
|
|
OR (:system = 'odoo' AND status = 'ODOO_IMPORTED')
|
|
)
|
|
AND (
|
|
metadata->>'source_document' = ANY(:refs)
|
|
OR metadata->>'source_external_id' = ANY(:refs)
|
|
OR source_document = ANY(:refs)
|
|
)
|
|
"""), {"opportunity_id": opportunity_id, "system": doc.get("system") or "jasmin", "refs": refs}).rowcount or 0
|
|
|
|
if refs:
|
|
result["reconciliation_items_unlinked"] = conn.execute(text("""
|
|
UPDATE reconciliation_items
|
|
SET opportunity_id = NULL,
|
|
status = CASE WHEN status IN ('resolved','linked','applied','open','needs_review','conflict') THEN 'needs_review' ELSE status END,
|
|
resolution_note = COALESCE(resolution_note || ' | ', '') || :note,
|
|
resolved_at = NULL,
|
|
payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB),
|
|
updated_at = now()
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
AND source_system = CAST(:system AS TEXT)
|
|
AND (
|
|
document_number = ANY(:refs)
|
|
OR external_id = ANY(:refs)
|
|
OR payload::text ILIKE '%' || CAST(:document_id AS TEXT) || '%'
|
|
)
|
|
"""), {
|
|
"opportunity_id": opportunity_id,
|
|
"system": doc.get("system") or "jasmin",
|
|
"refs": refs,
|
|
"document_id": document_id,
|
|
"note": note,
|
|
"payload": payload,
|
|
}).rowcount or 0
|
|
|
|
conn.execute(text("""
|
|
INSERT INTO opportunity_events (id, opportunity_id, event_type, action_code, note, payload, created_by)
|
|
VALUES (CAST(:id AS UUID), CAST(:opportunity_id AS UUID), 'commercial_document_unlinked',
|
|
'REVIEW_RECONCILIATION', :note, CAST(:payload AS JSONB), :actor)
|
|
"""), {
|
|
"id": str(uuid.uuid4()),
|
|
"opportunity_id": opportunity_id,
|
|
"note": note,
|
|
"payload": payload,
|
|
"actor": actor,
|
|
})
|
|
return result
|
|
|
|
|
|
def set_commercial_document_role_for_opportunity(
|
|
opportunity_id: str,
|
|
document_id: str,
|
|
*,
|
|
role: str = "current",
|
|
make_primary: bool = True,
|
|
actor: str = "operator_ui_document_role",
|
|
) -> dict:
|
|
"""Choose which document belongs to the current process without deleting evidence."""
|
|
if not is_uuid_text(opportunity_id) or not is_uuid_text(document_id):
|
|
raise ValueError("Identificador inválido.")
|
|
role = str(role or "current").strip().lower()
|
|
if role not in {"current", "accepted", "related", "historical"}:
|
|
raise ValueError("Papel de documento inválido.")
|
|
with engine.begin() as conn:
|
|
doc = conn.execute(text("""
|
|
SELECT id::text, system, document_kind, document_number
|
|
FROM commercial_documents
|
|
WHERE id = CAST(:document_id AS UUID)
|
|
AND opportunity_id = CAST(:opportunity_id AS UUID)
|
|
LIMIT 1
|
|
"""), {"document_id": document_id, "opportunity_id": opportunity_id}).mappings().first()
|
|
if not doc:
|
|
raise ValueError("Documento não encontrado nesta oportunidade.")
|
|
if make_primary and role in {"current", "accepted"}:
|
|
conn.execute(text("""
|
|
UPDATE commercial_documents
|
|
SET role = CASE WHEN COALESCE(role, 'current') = 'current' THEN 'historical' ELSE role END,
|
|
is_primary = FALSE,
|
|
is_active = CASE WHEN COALESCE(role, 'current') = 'current' THEN FALSE ELSE COALESCE(is_active, TRUE) END,
|
|
updated_at = now()
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
AND system = CAST(:system AS TEXT)
|
|
AND document_kind = CAST(:document_kind AS TEXT)
|
|
AND id <> CAST(:document_id AS UUID)
|
|
"""), {
|
|
"opportunity_id": opportunity_id,
|
|
"system": doc.get("system"),
|
|
"document_kind": doc.get("document_kind"),
|
|
"document_id": document_id,
|
|
})
|
|
conn.execute(text("""
|
|
UPDATE commercial_documents
|
|
SET role = :role,
|
|
is_primary = :is_primary,
|
|
is_active = TRUE,
|
|
updated_at = now(),
|
|
payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB)
|
|
WHERE id = CAST(:document_id AS UUID)
|
|
AND opportunity_id = CAST(:opportunity_id AS UUID)
|
|
"""), {
|
|
"document_id": document_id,
|
|
"opportunity_id": opportunity_id,
|
|
"role": role,
|
|
"is_primary": bool(make_primary and role in {"current", "accepted"}),
|
|
"payload": _json_payload({"manual_document_role": role, "manual_primary": bool(make_primary), "actor": actor}),
|
|
})
|
|
conn.execute(text("""
|
|
INSERT INTO opportunity_events (id, opportunity_id, event_type, action_code, note, payload, created_by)
|
|
VALUES (CAST(:id AS UUID), CAST(:opportunity_id AS UUID), 'commercial_document_role_changed',
|
|
'REVIEW_RECONCILIATION', :note, CAST(:payload AS JSONB), :actor)
|
|
"""), {
|
|
"id": str(uuid.uuid4()),
|
|
"opportunity_id": opportunity_id,
|
|
"note": f"Documento {doc.get('document_number') or document_id} marcado como {role}.",
|
|
"payload": _json_payload({"document_id": document_id, "role": role, "make_primary": bool(make_primary)}),
|
|
"actor": actor,
|
|
})
|
|
return {"changed": 1, "role": role, "is_primary": bool(make_primary and role in {"current", "accepted"})}
|
|
|
|
def _odoo_m2o_label(value) -> str:
|
|
if isinstance(value, (list, tuple)) and len(value) >= 2:
|
|
return str(value[1] or "")
|
|
if isinstance(value, dict):
|
|
return str(value.get("name") or value.get("display_name") or value.get("id") or "")
|
|
return str(value or "")
|
|
|
|
|
|
def _odoo_status_badge(status: object) -> str:
|
|
s = str(status or "").lower()
|
|
cls = "text-bg-secondary"
|
|
if s in {"done", "shipped", "delivered", "validated", "sale", "created", "order_created", "ready_to_ship"}:
|
|
cls = "text-bg-success"
|
|
elif s in {"assigned", "confirmed", "waiting", "in_production", "progress", "pending", "sent", "quote_only"}:
|
|
cls = "text-bg-warning"
|
|
elif s in {"cancel", "cancelled", "failed", "not_found", "blocked"}:
|
|
cls = "text-bg-danger"
|
|
return f'<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 ""
|
|
physical_status_value = str(physical.get("status") or physical_payload.get("physical_status") or physical_payload.get("status") or "").strip().lower()
|
|
picking_states = {
|
|
str(p.get("state") or "").strip().lower()
|
|
for p in pickings
|
|
if isinstance(p, dict) and str(p.get("state") or "").strip()
|
|
}
|
|
whout_done = (
|
|
bool(physical_payload.get("delivery_done"))
|
|
or physical_status_value in {"done", "shipped", "delivered", "validated"}
|
|
or (bool(picking_states) and picking_states <= {"done", "cancel"} and "done" in picking_states)
|
|
)
|
|
whout_ready = (
|
|
bool(physical_payload.get("ready_to_ship") or physical_payload.get("delivery_ready"))
|
|
or physical_status_value in {"ready_to_ship", "ready", "validated"}
|
|
or "assigned" in picking_states
|
|
)
|
|
if whout_done:
|
|
physical_reason = "WH/OUT concluído no Odoo."
|
|
physical_next = "Processo pronto para conclusão quando fatura enviada e pagamento confirmado."
|
|
elif whout_ready:
|
|
physical_label = "Picking reservado — validação física pendente"
|
|
physical_reason = "Odoo assigned indica stock reservado; ainda falta confirmar fisicamente a preparação da encomenda."
|
|
physical_next = "Validar encomenda física antes de criar envio/tracking."
|
|
|
|
notice_html = f'<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 = ""
|
|
manual_sale_link_html = ""
|
|
if not sale:
|
|
no_sale_warning = '<div class="alert alert-warning py-2 small mb-2"><strong>Sem venda Odoo ligada.</strong><br>Regista o número da venda Odoo (ex.: S00308) ou usa candidatos abaixo para ligar a venda correta antes de confiar no fluxo físico.</div>'
|
|
manual_sale_link_html = f'''
|
|
<div class="cf-soft-box mt-2">
|
|
<div class="small text-secondary fw-bold text-uppercase mb-1">Associar venda Odoo manualmente</div>
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/odoo/link-sale" hx-post="/opportunities/{esc(opportunity_id)}/odoo/link-sale" hx-target="#odoo-status-panel" hx-swap="outerHTML" class="row g-2 align-items-end">
|
|
<div class="col-md-8">
|
|
<label class="form-label small text-secondary mb-1">Nº da venda Odoo</label>
|
|
<input class="form-control form-control-sm" name="sale_ref" placeholder="Ex.: S00308" autocomplete="off" required>
|
|
</div>
|
|
<div class="col-md-4 d-grid">
|
|
<button class="btn btn-sm btn-primary" type="submit">Registar venda Odoo</button>
|
|
</div>
|
|
<div class="col-12 small text-secondary">Não cria nada no Odoo; apenas liga a venda já criada ao processo e sincroniza WH/OUT.</div>
|
|
</form>
|
|
</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 Odoo e entrega/WH-OUT. Ordens de fabrico ficam em detalhe técnico e não conduzem o fluxo do operador.</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}{manual_sale_link_html}</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>
|
|
<details class="p-3 border-top">
|
|
<summary class="small fw-bold text-secondary" style="cursor:pointer">Detalhes técnicos Odoo / fabrico</summary>
|
|
<div class="small text-secondary mt-1">Informativo. O fluxo do ClientFlow usa a venda Odoo e o estado da entrega/WH-OUT; ordens WH/MO não bloqueiam o fecho comercial.</div>
|
|
<div class="cf-table-wrap border-0 rounded-0 mt-2">
|
|
<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>
|
|
<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 _parse_opportunity_dt(value: object):
|
|
if not value:
|
|
return None
|
|
if isinstance(value, datetime):
|
|
dt = value
|
|
else:
|
|
try:
|
|
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
except Exception:
|
|
return None
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
return dt.astimezone(timezone.utc)
|
|
|
|
|
|
def _opportunity_lifecycle_state(opp: dict) -> str:
|
|
state = str(opp.get("lifecycle_state") or "active").strip().lower() or "active"
|
|
now = datetime.now(timezone.utc)
|
|
nurture_until = _parse_opportunity_dt(opp.get("nurture_until"))
|
|
next_follow_up = _parse_opportunity_dt(opp.get("next_follow_up_at"))
|
|
if state == "nurture" and nurture_until and nurture_until <= now:
|
|
return "follow_up_due"
|
|
if state in {"awaiting_customer", "active"} and next_follow_up and next_follow_up <= now:
|
|
return "follow_up_due"
|
|
return state
|
|
|
|
|
|
def _opportunity_last_commercial_activity(opp: dict):
|
|
for key in ("last_customer_activity_at", "last_operator_activity_at", "last_commercial_activity_at", "last_message_at"):
|
|
dt = _parse_opportunity_dt(opp.get(key))
|
|
if dt:
|
|
return dt
|
|
return None
|
|
|
|
|
|
def _opportunity_inactive(opp: dict) -> bool:
|
|
if _opportunity_lifecycle_state(opp) in {"recovery", "nurture"}:
|
|
return False
|
|
last_activity = _opportunity_last_commercial_activity(opp)
|
|
attempts = int(opp.get("follow_up_attempts") or 0)
|
|
if not last_activity:
|
|
return attempts >= 2
|
|
days = (datetime.now(timezone.utc) - last_activity).days
|
|
return days >= 10 and attempts >= 2
|
|
|
|
|
|
def _opportunity_query_string(q: Optional[str] = None, status: Optional[str] = "open", scope: Optional[str] = "all", limit: int = 300) -> str:
|
|
parts = []
|
|
if q:
|
|
parts.append(f"q={esc(q)}")
|
|
if status and status != "open":
|
|
parts.append(f"status={esc(status)}")
|
|
if scope and scope != "all":
|
|
parts.append(f"scope={esc(scope)}")
|
|
if limit and int(limit) != 300:
|
|
parts.append(f"limit={int(limit)}")
|
|
return ("?" + "&".join(parts)) if parts else ""
|
|
|
|
|
|
def _attach_central_next_actions(opportunities: list[dict]) -> None:
|
|
"""Enrich board rows with the same next-action engine used by details.
|
|
|
|
The board used to derive labels from the stored legacy stage, which made
|
|
closed-ready opportunities appear as "Enviar tracking" and WH/MO cases as
|
|
"Acompanhar produção". Keep this read-only and best-effort: if the
|
|
central engine fails for one card, the card falls back to the legacy text.
|
|
"""
|
|
for opp in opportunities:
|
|
if isinstance(opp.get("clientflow_next_action"), dict):
|
|
continue
|
|
oid = str(opp.get("id") or "").strip()
|
|
if not oid:
|
|
continue
|
|
try:
|
|
decision = get_opportunity_next_action(oid)
|
|
except Exception as exc:
|
|
decision = {
|
|
"action_code": "DECISION_ERROR",
|
|
"label": opportunity_next_action_text(opp),
|
|
"description": f"Falha ao calcular próxima ação central: {exc}",
|
|
}
|
|
if isinstance(decision, dict):
|
|
opp["clientflow_next_action"] = decision
|
|
|
|
|
|
def _central_next_action_for_card(opp: dict) -> dict:
|
|
decision = opp.get("clientflow_next_action")
|
|
return decision if isinstance(decision, dict) else {}
|
|
|
|
|
|
def _opportunity_visible_set(q: Optional[str] = None, status: Optional[str] = "open", scope: Optional[str] = "all", limit: int = 300) -> tuple[list[dict], dict, list[tuple[str, str, object]]]:
|
|
if (status or "open") == "closed":
|
|
status = "open"
|
|
opportunities = list_opportunities(q=q, status=status or "open", limit=limit)
|
|
_attach_central_next_actions(opportunities)
|
|
visible_board_columns = [column for column in OPPORTUNITY_BOARD_COLUMNS if column[0] not in {"closed", "archived"}]
|
|
grouped = {key: [] for key, _label, _stages in visible_board_columns}
|
|
visible = []
|
|
for opportunity in opportunities:
|
|
if _is_noise_opportunity(opportunity):
|
|
continue
|
|
key = _opportunity_board_column_for_opportunity(opportunity)
|
|
if key in {"closed", "archived"}:
|
|
continue
|
|
if scope and scope not in {"all", "open"}:
|
|
lifecycle_state = _opportunity_lifecycle_state(opportunity)
|
|
scope_to_column = {"new": "requests", "quote": "sent", "shipment": "operations"}
|
|
if scope == "blocked":
|
|
pending = int(opportunity.get("pending_task_count") or 0)
|
|
if pending <= 0 and not opportunity_customer_mismatch(opportunity):
|
|
continue
|
|
elif scope == "active":
|
|
if lifecycle_state not in {"active", "awaiting_customer"} or _opportunity_inactive(opportunity):
|
|
continue
|
|
elif scope == "awaiting_customer":
|
|
if lifecycle_state != "awaiting_customer":
|
|
continue
|
|
elif scope == "follow_up_due":
|
|
if lifecycle_state != "follow_up_due":
|
|
continue
|
|
elif scope == "recovery":
|
|
if lifecycle_state != "recovery":
|
|
continue
|
|
elif scope == "nurture":
|
|
if lifecycle_state != "nurture":
|
|
continue
|
|
elif scope == "inactive":
|
|
if not _opportunity_inactive(opportunity):
|
|
continue
|
|
elif scope == "unvalued":
|
|
if float(opportunity.get("value_amount") or 0) > 0:
|
|
continue
|
|
elif key != scope_to_column.get(scope, scope):
|
|
continue
|
|
visible.append(opportunity)
|
|
grouped.setdefault(key, []).append(opportunity)
|
|
return visible, grouped, visible_board_columns
|
|
|
|
|
|
def _compact_identity(value: object) -> str:
|
|
value = compact_text(str(value or "").strip(), 42)
|
|
if value.casefold() in {"", "geral", "cliente", "contacto"} or value.isdigit():
|
|
return ""
|
|
return value
|
|
|
|
|
|
def _opportunity_card_identity(opp: dict) -> tuple[str, str]:
|
|
fiscal = _compact_identity(opp.get("linked_customer_name"))
|
|
contact_name = _compact_identity(opp.get("customer_name"))
|
|
contact_email = _compact_identity(opp.get("customer_email"))
|
|
if fiscal:
|
|
subtitle = contact_email or contact_name
|
|
return fiscal, (f"Contacto: {subtitle}" if subtitle and subtitle != fiscal else "")
|
|
if contact_name:
|
|
return contact_name, contact_email if contact_email and contact_email != contact_name else ""
|
|
if contact_email:
|
|
return contact_email, ""
|
|
conversation = str(opp.get("conversation_id") or "").strip()
|
|
return "Contacto sem identificação", (f"Conversa Chatwoot #{conversation}" if conversation else "")
|
|
|
|
|
|
# Legacy regression context: cta_label = "Concluir tarefa pendente" if pending else "Ver oportunidade".
|
|
# v4.8.5 replaces that generic CTA with a specific action label.
|
|
def _opportunity_card_next_action(opp: dict) -> str:
|
|
lifecycle_state = _opportunity_lifecycle_state(opp)
|
|
pending_follow_up_action = str(opp.get("pending_follow_up_action") or "").strip()
|
|
pending_follow_up_code = str(opp.get("pending_follow_up_action_code") or "").strip()
|
|
if lifecycle_state == "recovery":
|
|
return pending_follow_up_action or "Recuperar oportunidade sem resposta"
|
|
if lifecycle_state == "follow_up_due":
|
|
return pending_follow_up_action or primary_action_label(pending_follow_up_code, fallback="Executar follow-up vencido")
|
|
if lifecycle_state == "nurture":
|
|
return pending_follow_up_action or "Aguardar data para retomar contacto"
|
|
if lifecycle_state == "awaiting_customer":
|
|
due = _parse_opportunity_dt(opp.get("next_follow_up_at"))
|
|
return f"Aguardar resposta até {due.strftime('%d/%m')}" if due else "Aguardar resposta do cliente"
|
|
metadata = opp.get("metadata") if isinstance(opp.get("metadata"), dict) else {}
|
|
if reconstructed_review_required(metadata):
|
|
return "Validar processo reconstruído"
|
|
|
|
pending_code = canonical_action_code(opp.get("pending_primary_action_code"))
|
|
if pending_code:
|
|
return str(opp.get("pending_primary_action") or primary_action_label(pending_code, fallback="Ver tarefa pendente"))
|
|
|
|
central = _central_next_action_for_card(opp)
|
|
if central.get("label"):
|
|
return str(central.get("label") or "")
|
|
if int(opp.get("pending_task_count") or 0) > 0:
|
|
action_code = str(opp.get("last_action_code") or "").strip()
|
|
return primary_action_label(action_code, fallback="Ver tarefa pendente")
|
|
return opportunity_next_action_text(opp)
|
|
|
|
|
|
def _is_noise_opportunity(opp: dict) -> bool:
|
|
"""Hide old bounce/NDR opportunities from the commercial board.
|
|
|
|
Operations already hides technical mailbox noise; the opportunity board must
|
|
use the same guard so legacy Mail Delivery/postmaster opportunities do not
|
|
keep appearing as commercial work.
|
|
"""
|
|
return is_noise_operation_item({
|
|
"customer_name": opp.get("customer_name"),
|
|
"contact_display_name": opp.get("customer_name"),
|
|
"fiscal_customer_name": opp.get("linked_customer_name"),
|
|
"message_subject": opp.get("product_interest"),
|
|
"title": opp.get("title"),
|
|
"detail": opp.get("product_interest"),
|
|
"request_text": (opp.get("metadata") or {}).get("request_text") if isinstance(opp.get("metadata"), dict) else "",
|
|
"source_system": opp.get("source_system"),
|
|
"action_code": opp.get("last_action_code"),
|
|
"no_opportunity_reason": (opp.get("metadata") or {}).get("no_opportunity_reason") if isinstance(opp.get("metadata"), dict) else "",
|
|
"status": opp.get("status"),
|
|
})
|
|
|
|
|
|
def _opportunity_board_column_for_opportunity(opp: dict) -> str:
|
|
"""Choose the visual column from the same first-safe-action precedence."""
|
|
metadata = opp.get("metadata") if isinstance(opp.get("metadata"), dict) else {}
|
|
if reconstructed_review_required(metadata):
|
|
return "requests"
|
|
|
|
pending_code = canonical_action_code(opp.get("pending_primary_action_code"))
|
|
central_code = canonical_action_code(_central_next_action_for_card(opp).get("action_code"))
|
|
effective_code = pending_code or central_code
|
|
|
|
if effective_code in {"SEND_INVOICE", "SEND_PROFORMA", "CONFIRM_PAYMENT", "FOLLOW_UP_PAYMENT"}:
|
|
return "payment"
|
|
if effective_code in {
|
|
"PREPARE_ORDER", "CREATE_SHIPMENT", "WAIT_PRODUCTION", "WAIT_ODOO",
|
|
"CLOSE_OPPORTUNITY", "VALIDATE_PHYSICAL_ORDER",
|
|
}:
|
|
return "operations"
|
|
if effective_code in {"ASSOCIATE_OPPORTUNITY", "REVIEW_ASSOCIATION", "LINK_DOCUMENT", "REVIEW_RECONSTRUCTED_PROCESS"}:
|
|
return "requests"
|
|
return _opportunity_board_column_for_stage(opp.get("stage"))
|
|
|
|
|
|
def _render_opportunity_card(opp: dict) -> str:
|
|
oid = str(opp.get("id") or "")
|
|
title, subtitle = _opportunity_card_identity(opp)
|
|
subject = compact_text(opp.get("product_interest") or opp.get("title") or "Pedido comercial", 64)
|
|
next_action = compact_text(_opportunity_card_next_action(opp), 72)
|
|
pending = int(opp.get("pending_task_count") or 0)
|
|
blockers = opportunity_blockers(opp)
|
|
lifecycle_state = _opportunity_lifecycle_state(opp)
|
|
state_label = lifecycle_label(lifecycle_state)
|
|
state_class = {
|
|
"active": "text-bg-success",
|
|
"awaiting_customer": "text-bg-info",
|
|
"follow_up_due": "text-bg-warning",
|
|
"recovery": "text-bg-danger",
|
|
"nurture": "text-bg-secondary",
|
|
}.get(lifecycle_state, "text-bg-light")
|
|
last_customer = _parse_opportunity_dt(opp.get("last_customer_activity_at") or opp.get("last_message_at"))
|
|
customer_age = "Sem atividade do cliente registada"
|
|
if last_customer:
|
|
days = max(0, (datetime.now(timezone.utc) - last_customer).days)
|
|
customer_age = "Cliente respondeu hoje" if days == 0 else f"Sem resposta do cliente há {days} dia(s)"
|
|
next_follow = _parse_opportunity_dt(opp.get("next_follow_up_at") or opp.get("nurture_until"))
|
|
follow_text = f"Próximo contacto: {next_follow.strftime('%d/%m/%Y')}" if next_follow else "Sem próximo contacto agendado"
|
|
attempts = int(opp.get("follow_up_attempts") or 0)
|
|
value = float(opp.get("value_amount") or 0)
|
|
value_text = money_html(value) if value > 0 else "Valor por definir"
|
|
cta_label = next_action if pending else "Ver oportunidade"
|
|
cta_class = "btn-primary" if pending or lifecycle_state in {"follow_up_due", "recovery"} else "btn-outline-primary"
|
|
blocker_html = blocker_alert_html(blockers, empty_text="") if blockers else ""
|
|
subtitle_html = f'<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="d-flex flex-wrap justify-content-between gap-2 mb-1"><span class="badge {esc(state_class)}">{esc(state_label)}</span><strong class="small">{value_text}</strong></div>
|
|
<div class="cf-opportunity-card-title">{esc(title)}</div>
|
|
{subtitle_html}
|
|
<div class="cf-opportunity-card-subject">{esc(subject)}</div>
|
|
<div class="small text-secondary mt-2">{esc(customer_age)} · {esc(follow_text)} · {attempts} tentativa(s)</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]
|
|
active_count = sum(1 for opp in visible_opportunities if _opportunity_lifecycle_state(opp) in {"active", "awaiting_customer"} and not _opportunity_inactive(opp))
|
|
due_count = sum(1 for opp in visible_opportunities if _opportunity_lifecycle_state(opp) == "follow_up_due")
|
|
recovery_count = sum(1 for opp in visible_opportunities if _opportunity_lifecycle_state(opp) == "recovery")
|
|
unvalued_count = sum(1 for opp in visible_opportunities if float(opp.get("value_amount") or 0) <= 0)
|
|
|
|
if is_htmx(request):
|
|
return HTMLResponse(render_opportunities_board_partial(q=q, status=status, scope=scope, limit=limit))
|
|
|
|
status_options = ""
|
|
for value, label in [("open", "Abertas"), ("all", "Todas")]:
|
|
selected = "selected" if (status or "open") == value else ""
|
|
status_options += f'<option value="{esc(value)}" {selected}>{esc(label)}</option>'
|
|
|
|
stage_tabs = ""
|
|
filters = [
|
|
("all", "Todas"),
|
|
("new", "Novas"),
|
|
("quote", "Orçamento enviado"),
|
|
("payment", "Pagamento pendente"),
|
|
("active", "Ativas"),
|
|
("awaiting_customer", "A aguardar cliente"),
|
|
("follow_up_due", "Follow-up vencido"),
|
|
("recovery", "Recuperação"),
|
|
("unvalued", "Por valorizar"),
|
|
("inactive", "Inativas"),
|
|
("nurture", "Acompanhamento futuro"),
|
|
("shipment", "Operação/entrega"),
|
|
("blocked", "Bloqueadas"),
|
|
]
|
|
for key, label in filters:
|
|
href = "/opportunities" + _opportunity_query_string(q=q, status=status, scope=key, limit=limit)
|
|
partial_href = "/opportunities/partials/board" + _opportunity_query_string(q=q, status=status, scope=key, limit=limit)
|
|
active = "btn-primary" if (scope or "all") == key else "btn-outline-secondary"
|
|
stage_tabs += f'<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?scope=active"><div class="card-body"><div class="small text-secondary fw-bold text-uppercase">Ativas</div><div class="h4 fw-bold mb-1">{active_count}</div><div class="small">com contacto em curso</div></div></a></div>
|
|
<div class="col"><a class="card cf-card text-decoration-none text-reset overflow-hidden" href="/opportunities?scope=follow_up_due"><div class="card-body"><div class="small text-secondary fw-bold text-uppercase">Follow-up vencido</div><div class="h4 fw-bold mb-1">{due_count}</div><div class="small">contactar agora</div></div></a></div>
|
|
<div class="col"><a class="card cf-card text-decoration-none text-reset overflow-hidden" href="/opportunities?scope=recovery"><div class="card-body"><div class="small text-secondary fw-bold text-uppercase">Recuperação</div><div class="h4 fw-bold mb-1">{recovery_count}</div><div class="small">decisão comercial necessária</div></div></a></div>
|
|
<div class="col"><a class="card cf-card text-decoration-none text-reset overflow-hidden" href="/opportunities?scope=unvalued"><div class="card-body"><div class="small text-secondary fw-bold text-uppercase">Por valorizar</div><div class="h4 fw-bold mb-1">{unvalued_count}</div><div class="small">de {total_open} abertas · {money_html(total_value)}</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>
|
|
</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"}
|
|
all_pending_tasks = [t for t in tasks if str(t.get("status")) == "pending"]
|
|
payment_confirmed_for_ui = stage == "PAYMENT_CONFIRMED"
|
|
if terminal_stage:
|
|
pending_tasks = [
|
|
t for t in all_pending_tasks
|
|
if str(t.get("action_code") or "").upper() not in {
|
|
"FOLLOW_UP_QUOTE",
|
|
"FOLLOW_UP_PROFORMA",
|
|
"FOLLOW_UP_PAYMENT",
|
|
"FOLLOW_UP_CUSTOMER_REVIEW",
|
|
"FOLLOW_UP_GENERIC",
|
|
"CONFIRM_DELIVERY",
|
|
"RECOVER_OPPORTUNITY",
|
|
"REVIEW_NURTURE",
|
|
}
|
|
]
|
|
else:
|
|
pending_tasks = [t for t in all_pending_tasks if not _is_obsolete_after_payment_task(t, payment_confirmed_for_ui)]
|
|
next_task = pending_tasks[0] if pending_tasks else None
|
|
opportunity_items = list_opportunity_items(opportunity_id)
|
|
active_products = list_products(active="true", limit=200)
|
|
try:
|
|
from app.commercial_service import list_commercial_documents
|
|
linked_documents = list_commercial_documents(opportunity_id=opportunity_id, limit=8)
|
|
except Exception:
|
|
linked_documents = []
|
|
primary_document = next(
|
|
(
|
|
doc for doc in linked_documents
|
|
if str(doc.get("document_kind") or "") == "invoice"
|
|
and str(doc.get("role") or "current") in {"current", "accepted"}
|
|
and bool(doc.get("is_primary", True))
|
|
),
|
|
next(
|
|
(
|
|
doc for doc in linked_documents
|
|
if str(doc.get("role") or "current") in {"current", "accepted"}
|
|
and bool(doc.get("is_primary", True))
|
|
),
|
|
linked_documents[0] if linked_documents else None,
|
|
),
|
|
)
|
|
opportunity_items_total = sum(
|
|
float(item.get("total_price") or 0)
|
|
for item in opportunity_items
|
|
if str(item.get("status") or "").upper() not in {"REJECTED", "CANCELLED", "DELIVERED", "HISTORICAL"}
|
|
)
|
|
document_value = float(primary_document.get("total_amount") or primary_document.get("amount") or 0) if primary_document else 0
|
|
estimated_value = document_value or opportunity_items_total or float(opportunity.get("value_amount") or 0)
|
|
value_source = "documento principal" if document_value else ("linhas atuais" if opportunity_items_total else "oportunidade")
|
|
operation_snapshot = get_operation_snapshot(opportunity_id)
|
|
opportunity_for_cockpit = dict(opportunity)
|
|
opportunity_for_cockpit["pending_task_count"] = len(pending_tasks)
|
|
try:
|
|
opportunity_communications = list_communications_for_opportunity(opportunity_id, limit=12)
|
|
except Exception:
|
|
opportunity_communications = []
|
|
notice_html = f'<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:
|
|
review_state = reconstructed_review_status(metadata)
|
|
if review_state in {"validated", "waived"}:
|
|
legacy_notice_html = (
|
|
'<div class="alert alert-info border-0">'
|
|
'<strong>Registo reconstruído validado.</strong><br>'
|
|
'A oportunidade foi normalizada a partir de documentos existentes e a revisão obrigatória já foi concluída.'
|
|
'</div>'
|
|
)
|
|
elif review_state == "required":
|
|
legacy_notice_html = (
|
|
'<div class="alert alert-warning border-0">'
|
|
'<strong>Processo reconstruído por validar.</strong><br>'
|
|
'Confirma cliente, documento principal, valor e evidência de pagamento antes de executar ações sensíveis.'
|
|
'</div>'
|
|
)
|
|
else:
|
|
legacy_notice_html = (
|
|
'<div class="alert alert-warning border-0">'
|
|
'<strong>Registo antigo/reconstruído sem estado explícito de revisão.</strong><br>'
|
|
'Executa a migração v132 para definir se a revisão está pendente ou já foi concluída.'
|
|
'</div>'
|
|
)
|
|
|
|
opportunity_return_to = f"/opportunities/{opportunity_id}"
|
|
|
|
try:
|
|
next_action = get_opportunity_next_action(opportunity_id)
|
|
except Exception:
|
|
next_action = {}
|
|
|
|
lifecycle_state_for_detail = _opportunity_lifecycle_state(opportunity)
|
|
lifecycle_task = next((
|
|
task for task in pending_tasks
|
|
if str(task.get("action_code") or "").upper() in {
|
|
"CONFIRM_DELIVERY", "FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA",
|
|
"FOLLOW_UP_PAYMENT", "FOLLOW_UP_CUSTOMER_REVIEW", "FOLLOW_UP_GENERIC",
|
|
"RECOVER_OPPORTUNITY", "REVIEW_NURTURE",
|
|
}
|
|
), None)
|
|
lifecycle_override = False
|
|
if lifecycle_task and lifecycle_state_for_detail in {"follow_up_due", "recovery", "nurture"}:
|
|
lifecycle_override = True
|
|
next_action = {
|
|
"action_code": str(lifecycle_task.get("action_code") or "FOLLOW_UP_GENERIC"),
|
|
"label": str(lifecycle_task.get("action") or primary_action_label(lifecycle_task.get("action_code"))),
|
|
"description": str(lifecycle_task.get("note") or "Continuar acompanhamento comercial."),
|
|
"target_url": f"/tasks/{lifecycle_task.get('id')}",
|
|
"source": "lifecycle_task",
|
|
}
|
|
|
|
if reconstructed_review_required(metadata):
|
|
review_task = next((
|
|
task for task in pending_tasks
|
|
if str(task.get("action_code") or "").upper() == "REVIEW_RECONSTRUCTED_PROCESS"
|
|
), None)
|
|
next_action = {
|
|
"action_code": "REVIEW_RECONSTRUCTED_PROCESS",
|
|
"label": "Validar processo reconstruído",
|
|
"description": "Confirmar cliente, documento principal, valor e evidências antes de executar a ação sensível seguinte.",
|
|
"target_url": f"/tasks/{review_task.get('id')}" if review_task else f"/opportunities/{opportunity_id}",
|
|
"source": "explicit_reconstructed_review",
|
|
}
|
|
elif not lifecycle_override and next_task:
|
|
next_action = {
|
|
"action_code": str(next_task.get("action_code") or "REVIEW_MANUALLY"),
|
|
"label": str(next_task.get("action") or primary_action_label(next_task.get("action_code"))),
|
|
"description": str(next_task.get("note") or "Executar tarefa pendente."),
|
|
"target_url": f"/tasks/{next_task.get('id')}",
|
|
"source": "pending_task",
|
|
}
|
|
|
|
# Materialize human-only central actions into actual pending tasks.
|
|
# The top-level next action should not be an abstract label when the workbench
|
|
# expects an operator to perform it. The helper is idempotent and currently
|
|
# creates SEND_INVOICE/FOLLOW_UP_PAYMENT tasks when needed.
|
|
try:
|
|
materialized_task = ensure_pending_task_for_next_action(
|
|
opportunity_id,
|
|
next_action if isinstance(next_action, dict) else {},
|
|
source="opportunity_detail",
|
|
actor="system",
|
|
)
|
|
except Exception:
|
|
materialized_task = {"created": False}
|
|
if materialized_task.get("created"):
|
|
tasks = list_opportunity_tasks(opportunity_id, limit=100)
|
|
all_pending_tasks = [t for t in tasks if str(t.get("status")) == "pending"]
|
|
if terminal_stage:
|
|
pending_tasks = [
|
|
t for t in all_pending_tasks
|
|
if str(t.get("action_code") or "").upper() not in {
|
|
"FOLLOW_UP_QUOTE",
|
|
"FOLLOW_UP_PROFORMA",
|
|
"FOLLOW_UP_PAYMENT",
|
|
"FOLLOW_UP_CUSTOMER_REVIEW",
|
|
"FOLLOW_UP_GENERIC",
|
|
}
|
|
]
|
|
else:
|
|
pending_tasks = [t for t in all_pending_tasks if not _is_obsolete_after_payment_task(t, payment_confirmed_for_ui)]
|
|
next_task = pending_tasks[0] if pending_tasks else None
|
|
opportunity_for_cockpit["pending_task_count"] = len(pending_tasks)
|
|
try:
|
|
next_action = get_opportunity_next_action(opportunity_id)
|
|
except Exception:
|
|
pass
|
|
if isinstance(next_action, dict):
|
|
# v1.5.107: keep the legacy operational cockpit aligned with the
|
|
# central decision engine. Without this, CLOSE_OPPORTUNITY could show
|
|
# at the top while the cockpit still suggested an old SEND_INVOICE
|
|
# action from the legacy workflow plan.
|
|
opportunity_for_cockpit["clientflow_next_action"] = dict(next_action)
|
|
|
|
if next_action:
|
|
primary_action = next_action.get("label") or action_label(next_action.get("action_code"))
|
|
primary_note = _safe_opportunity_task_text(next_action.get("description") or "Continuar a próxima ação recomendada.")
|
|
action_code_upper = str(next_action.get("action_code") or "").upper()
|
|
target_url = next_action.get("target_url") or (f"/tasks/{next_task.get('id')}" if next_task else "/tasks?status=pending")
|
|
if str(target_url).startswith("/tasks/") and "return_to=" not in str(target_url):
|
|
sep = "&" if "?" in str(target_url) else "?"
|
|
target_url = f"{target_url}{sep}return_to={quote(opportunity_return_to, safe='')}"
|
|
button_label = "Abrir tarefa" if str(target_url).startswith("/tasks/") else "Continuar"
|
|
if action_code_upper == "VALIDATE_FISCAL_CUSTOMER":
|
|
primary_button = f'<form method="post" action="/opportunities/{esc(opportunity_id)}/fiscal-enrich"><button class="btn btn-primary" type="submit">Enriquecer cliente fiscal</button></form>'
|
|
elif action_code_upper == "CLOSE_OPPORTUNITY":
|
|
primary_button = (
|
|
f'<form method="post" action="/opportunities/{esc(opportunity_id)}/operations/delivered" class="d-grid gap-2">'
|
|
'<input type="hidden" name="external_name" value="Oportunidade concluída">'
|
|
'<input type="hidden" name="note" value="Fatura enviada, pagamento confirmado e Odoo/WH-OUT concluído.">'
|
|
'<button class="btn btn-success" type="submit">Concluir oportunidade</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_rows += f'''
|
|
<tr>
|
|
<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(compact_text(_safe_opportunity_task_text(task.get('note') or task.get('action') or ''), 70))}</div></td>
|
|
<td>{route_badge(task.get('route'))}</td>
|
|
<td>{status_badge(task.get('status'))}</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)
|
|
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>'
|
|
display_next_action_code = str((next_action.get('action_code') if isinstance(next_action, dict) else None) or (next_task.get('action_code') if next_task else None) or opportunity.get('last_action_code') or 'FOLLOW_UP').upper()
|
|
if display_next_action_code in {'WAIT_PRODUCTION', 'WAIT_ODOO'} and stage in {'READY_TO_SHIP', 'SHIPMENT_CREATED'}:
|
|
display_next_action_code = 'SHIP_ORDER'
|
|
if isinstance(next_action, dict) and str(next_action.get('action_code') or '').upper() == 'SEND_INVOICE':
|
|
display_next_action_code = 'SEND_INVOICE'
|
|
operator_summary_html = f'''
|
|
<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(display_next_action_code)}</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 não cria confirmações de entrega automaticamente; usa “Verificar entrega” apenas quando o histórico sugere um problema real.</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>
|
|
<option value="delivery">Verificar entrega (manual)</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>
|
|
'''
|
|
|
|
lifecycle_state = _opportunity_lifecycle_state(opportunity)
|
|
lifecycle_state_label = lifecycle_label(lifecycle_state)
|
|
last_customer_dt = _parse_opportunity_dt(opportunity.get("last_customer_activity_at") or opportunity.get("last_message_at"))
|
|
last_operator_dt = _parse_opportunity_dt(opportunity.get("last_operator_activity_at"))
|
|
next_follow_dt = _parse_opportunity_dt(opportunity.get("next_follow_up_at") or opportunity.get("nurture_until"))
|
|
lifecycle_summary = [
|
|
f"Última atividade do cliente: {last_customer_dt.strftime('%d/%m/%Y %H:%M') if last_customer_dt else 'sem registo'}",
|
|
f"Último contacto do operador: {last_operator_dt.strftime('%d/%m/%Y %H:%M') if last_operator_dt else 'sem registo'}",
|
|
f"Próximo contacto: {next_follow_dt.strftime('%d/%m/%Y') if next_follow_dt else 'não agendado'}",
|
|
f"Tentativas: {int(opportunity.get('follow_up_attempts') or 0)}",
|
|
f"Entrega da última comunicação: {str(opportunity.get('last_delivery_status') or 'não verificada')}",
|
|
]
|
|
loss_reason_options = ''.join(
|
|
f'<option value="{esc(code)}">{esc(label)}</option>'
|
|
for code, label in LOSS_REASON_LABELS.items()
|
|
if code != "future_timing"
|
|
)
|
|
lifecycle_management_html = f'''
|
|
<section class="card cf-card"><div class="card-body p-4">
|
|
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-2">
|
|
<div><h2 class="cf-section-title mb-1">Atividade comercial</h2><div class="small text-secondary">Controla espera, recuperação, acompanhamento futuro e perda sem usar updated_at técnico como sinal de atividade.</div></div>
|
|
<span class="badge text-bg-light">{esc(lifecycle_state_label)}</span>
|
|
</div>
|
|
<ul class="small text-secondary mb-3">{''.join(f'<li>{esc(item)}</li>' for item in lifecycle_summary)}</ul>
|
|
<div class="row g-3">
|
|
<div class="col-lg-6">
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/lifecycle" class="d-grid gap-2">
|
|
<label class="form-label small fw-bold text-secondary mb-0">Estado operacional</label>
|
|
<select class="form-select" name="state">
|
|
<option value="active">Ativa</option>
|
|
<option value="recovery">Recuperação</option>
|
|
<option value="nurture">Acompanhamento futuro</option>
|
|
</select>
|
|
<input class="form-control" type="date" name="nurture_until" aria-label="Data para retomar contacto">
|
|
<textarea class="form-control" name="reason" rows="2" placeholder="Motivo ou próxima abordagem"></textarea>
|
|
<button class="btn btn-outline-primary" type="submit">Guardar estado operacional</button>
|
|
</form>
|
|
</div>
|
|
<div class="col-lg-6">
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/lost" class="d-grid gap-2" onsubmit="return confirm('Marcar esta oportunidade como perdida?');">
|
|
<label class="form-label small fw-bold text-secondary mb-0">Fechar como perdida</label>
|
|
<select class="form-select" name="reason_code" required><option value="">Selecionar motivo…</option>{loss_reason_options}</select>
|
|
<textarea class="form-control" name="note" rows="2" placeholder="Detalhe opcional"></textarea>
|
|
<button class="btn btn-outline-danger" type="submit">Marcar como perdida</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div></section>
|
|
'''
|
|
|
|
correction_state = _opportunity_manual_correction_state(opportunity_id)
|
|
if correction_state.get("unavailable"):
|
|
correction_badge_html = """
|
|
<div class="small text-warning mb-2">
|
|
Ligações atuais temporariamente indisponíveis por sincronização/reconciliação em curso. Reabre esta secção dentro de segundos se precisares de corrigir associações.
|
|
</div>
|
|
"""
|
|
else:
|
|
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>
|
|
"""
|
|
|
|
archive_spam_state = _opportunity_archive_spam_state(opportunity_id)
|
|
archive_spam_html = ""
|
|
if archive_spam_state.get("can_archive"):
|
|
archive_hint = "Existe evidência de spam nesta oportunidade." if archive_spam_state.get("has_spam_evidence") else "Usa apenas para falso positivo/spam sem documentos nem Odoo/Jasmin."
|
|
archive_spam_html = f"""
|
|
<details class="card cf-card border-danger cf-advanced-actions">
|
|
<summary class="card-body p-4 fw-bold text-danger" style="cursor:pointer">
|
|
Arquivar spam/falso positivo
|
|
<div class="small text-secondary fw-normal mt-1">Exclui esta oportunidade do funil sem contar como perdida. Mantém auditoria.</div>
|
|
</summary>
|
|
<div class="card-body border-top p-4">
|
|
<div class="alert alert-danger py-2 small">{esc(archive_hint)} A ação é recusada se houver documentos Jasmin, Odoo, Packlink ou reconciliação externa ligada.</div>
|
|
<form method="post" action="/opportunities/{esc(opportunity_id)}/archive-spam" class="d-grid gap-2" onsubmit="return confirm('Arquivar esta oportunidade como spam/falso positivo? Não conta como perdida e sai do funil.');">
|
|
<textarea class="form-control" name="reason" rows="2" placeholder="Motivo opcional">spam/falso positivo</textarea>
|
|
<button class="btn btn-outline-danger" type="submit">Arquivar como spam</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"
|
|
fillable_text = ("Campos a preencher: " + ", ".join(str(x) for x in fillable)) if fillable else "Jasmin encontrado, mas não contém novos campos; associa cliente fiscal com base no documento Jasmin."
|
|
jasmin_fiscal_sync_html = (
|
|
'<div class="cf-soft-box mt-3">'
|
|
'<div class="small text-secondary fw-bold">Dados fiscais disponíveis no Jasmin</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">{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}
|
|
{lifecycle_management_html}
|
|
{stage_control_html}
|
|
{archive_spam_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}/archive-spam")
|
|
async def opportunity_archive_spam_action(opportunity_id: str, request: Request):
|
|
if not is_uuid_text(opportunity_id):
|
|
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
|
|
form = await request.form()
|
|
reason = str(form.get("reason") or "spam/falso positivo").strip()
|
|
try:
|
|
from app.opportunity_service import archive_spam_opportunity_if_safe
|
|
|
|
result = archive_spam_opportunity_if_safe(
|
|
opportunity_id,
|
|
reason=reason or "spam/falso positivo",
|
|
actor="operator_ui_archive_spam",
|
|
)
|
|
except Exception as exc:
|
|
notice = quote(f"Não foi possível arquivar spam: {exc}")
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303)
|
|
if not result.get("ok"):
|
|
msg = result.get("reason") or "não permitido"
|
|
if msg == "has_commercial_or_external_evidence":
|
|
msg = "A oportunidade tem documentos ou ligações externas; não foi arquivada automaticamente."
|
|
notice = quote(f"Arquivar spam recusado: {msg}")
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303)
|
|
notice = quote("Oportunidade arquivada como spam/falso positivo e excluída do funil.")
|
|
return RedirectResponse(f"/opportunities?notice={notice}", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/manual-correction")
|
|
async def opportunity_manual_correction_action(opportunity_id: str, request: Request):
|
|
if not is_uuid_text(opportunity_id):
|
|
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
|
|
form = await request.form()
|
|
unlink_odoo = str(form.get("unlink_odoo") or "") == "1"
|
|
unlink_jasmin = str(form.get("unlink_jasmin") or "") == "1"
|
|
remove_imported_lines = str(form.get("remove_imported_lines") or "") == "1"
|
|
stage = str(form.get("stage") or "INFO_SENT").strip().upper()
|
|
note = str(form.get("note") or "").strip() or "Correção manual: Odoo/Jasmin pertenciam a outro processo; classificado como informação enviada."
|
|
try:
|
|
result = apply_manual_external_correction(
|
|
opportunity_id,
|
|
unlink_odoo=unlink_odoo,
|
|
unlink_jasmin=unlink_jasmin,
|
|
remove_imported_lines=remove_imported_lines,
|
|
new_stage=stage,
|
|
note=note,
|
|
actor="operator_ui_manual_correction",
|
|
)
|
|
except Exception as exc:
|
|
if is_htmx(request):
|
|
return PlainTextResponse(f"Erro na correção manual: {exc}", status_code=409)
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote('Erro na correção manual: ' + str(exc))}", status_code=303)
|
|
notice = (
|
|
"Correção aplicada: "
|
|
f"Odoo/Jasmin desligados; {result.get('imported_lines_deleted', 0)} linha(s) importada(s) removida(s); "
|
|
f"fase definida como {OPPORTUNITY_STAGE_LABELS.get(stage, stage)}."
|
|
)
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/odoo/unlink")
|
|
async def opportunity_odoo_unlink_action(opportunity_id: str, request: Request):
|
|
try:
|
|
result = apply_manual_external_correction(
|
|
opportunity_id,
|
|
unlink_odoo=True,
|
|
unlink_jasmin=False,
|
|
remove_imported_lines=True,
|
|
new_stage="INFO_SENT",
|
|
note="Correção manual: venda Odoo desassociada da oportunidade.",
|
|
actor="operator_ui_odoo_unlink",
|
|
)
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(odoo_status_panel_html(opportunity_id, notice=f"Odoo desassociado. Linhas removidas: {result.get('imported_lines_deleted', 0)}"))
|
|
except Exception as exc:
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=f"Erro ao desassociar Odoo: {exc}"), status_code=409)
|
|
return PlainTextResponse(f"Erro ao desassociar Odoo: {exc}", status_code=500)
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Odoo%20desassociado", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/jasmin/unlink")
|
|
async def opportunity_jasmin_unlink_action(opportunity_id: str, request: Request):
|
|
try:
|
|
result = apply_manual_external_correction(
|
|
opportunity_id,
|
|
unlink_odoo=False,
|
|
unlink_jasmin=True,
|
|
remove_imported_lines=True,
|
|
new_stage="INFO_SENT",
|
|
note="Correção manual: documentos/candidatos Jasmin desassociados da oportunidade.",
|
|
actor="operator_ui_jasmin_unlink",
|
|
)
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, notice=f"Jasmin desassociado. Documentos removidos: {result.get('jasmin_documents_deleted', 0)} · linhas removidas: {result.get('imported_lines_deleted', 0)}"))
|
|
except Exception as exc:
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao desassociar Jasmin: {exc}"), status_code=409)
|
|
return PlainTextResponse(f"Erro ao desassociar Jasmin: {exc}", status_code=500)
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Jasmin%20desassociado", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/external-candidate/{item_id}/ignore")
|
|
async def opportunity_ignore_external_candidate_action(opportunity_id: str, item_id: str, request: Request):
|
|
source_system = ""
|
|
try:
|
|
with engine.begin() as conn:
|
|
source_system = str(conn.execute(text("""
|
|
SELECT source_system FROM reconciliation_items WHERE id = CAST(:item_id AS UUID)
|
|
"""), {"item_id": item_id}).scalar() or "")
|
|
count = ignore_external_candidate_for_opportunity(opportunity_id, item_id)
|
|
except Exception as exc:
|
|
if request.headers.get("hx-request"):
|
|
if source_system == "odoo":
|
|
return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=f"Erro ao ignorar candidato: {exc}"), status_code=409)
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao ignorar candidato: {exc}"), status_code=409)
|
|
return PlainTextResponse(f"Erro ao ignorar candidato: {exc}", status_code=500)
|
|
notice = "Candidato ignorado." if count else "Candidato não encontrado ou já ignorado."
|
|
if request.headers.get("hx-request"):
|
|
if source_system == "odoo":
|
|
return HTMLResponse(odoo_status_panel_html(opportunity_id, notice=notice))
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice))
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/follow-up")
|
|
async def create_opportunity_follow_up_action(opportunity_id: str, request: Request):
|
|
if not is_uuid_text(opportunity_id):
|
|
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
|
|
form = await request.form()
|
|
follow_up_type = str(form.get("follow_up_type") or "generic").strip()
|
|
note = str(form.get("note") or "").strip()
|
|
try:
|
|
delay_days = int(str(form.get("delay_days") or "3"))
|
|
except Exception:
|
|
delay_days = 3
|
|
try:
|
|
from app.followup_service import create_manual_follow_up_for_opportunity
|
|
|
|
result = create_manual_follow_up_for_opportunity(
|
|
opportunity_id=opportunity_id,
|
|
follow_up_type=follow_up_type,
|
|
delay_days=delay_days,
|
|
note=note,
|
|
created_by="operator",
|
|
)
|
|
notice = "Follow-up agendado." if result.get("ok") else "Não foi possível agendar follow-up."
|
|
except Exception as exc:
|
|
notice = f"Erro ao agendar follow-up: {exc}"
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/commercial-terms")
|
|
async def update_opportunity_commercial_terms_action(opportunity_id: str, request: Request):
|
|
if not is_uuid_text(opportunity_id):
|
|
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
|
|
form = await request.form()
|
|
payment_terms = str(form.get("payment_terms") or "before_shipping").strip()
|
|
delivery_terms = str(form.get("delivery_terms") or "carrier").strip()
|
|
note = str(form.get("note") or "").strip()
|
|
if payment_terms not in PAYMENT_TERM_LABELS:
|
|
return PlainTextResponse("Condição de pagamento inválida.", status_code=422)
|
|
if delivery_terms not in DELIVERY_TERM_LABELS:
|
|
return PlainTextResponse("Condição de entrega inválida.", status_code=422)
|
|
payload = {
|
|
"payment_terms": payment_terms,
|
|
"delivery_terms": delivery_terms,
|
|
"commercial_terms_note": note,
|
|
"commercial_terms_updated_by": "operator",
|
|
}
|
|
try:
|
|
with engine.begin() as conn:
|
|
exists = conn.execute(text("""
|
|
SELECT 1 FROM opportunities WHERE id = CAST(:opportunity_id AS UUID) LIMIT 1
|
|
"""), {"opportunity_id": opportunity_id}).scalar()
|
|
if not exists:
|
|
return PlainTextResponse("Oportunidade não encontrada.", status_code=404)
|
|
conn.execute(text("""
|
|
UPDATE opportunities
|
|
SET metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:payload AS JSONB),
|
|
updated_at = now()
|
|
WHERE id = CAST(:opportunity_id AS UUID)
|
|
"""), {
|
|
"opportunity_id": opportunity_id,
|
|
"payload": _json_payload(payload),
|
|
})
|
|
except Exception as exc:
|
|
notice = quote(f"Não foi possível guardar condições comerciais: {exc}")
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303)
|
|
notice = quote("Condições comerciais guardadas.")
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/lifecycle")
|
|
async def update_opportunity_lifecycle_action(opportunity_id: str, request: Request):
|
|
if not is_uuid_text(opportunity_id):
|
|
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
|
|
form = await request.form()
|
|
state = str(form.get("state") or "active").strip().lower()
|
|
nurture_until = str(form.get("nurture_until") or "").strip()
|
|
reason = str(form.get("reason") or "").strip()
|
|
if state == "nurture" and not nurture_until:
|
|
return PlainTextResponse("Indica a data para retomar o contacto.", status_code=422)
|
|
try:
|
|
changed = set_opportunity_lifecycle(
|
|
opportunity_id,
|
|
state,
|
|
nurture_until=nurture_until or None,
|
|
reason=reason,
|
|
created_by="operator",
|
|
)
|
|
except ValueError as exc:
|
|
return PlainTextResponse(str(exc), status_code=422)
|
|
except Exception as exc:
|
|
notice = quote(f"Não foi possível alterar o estado operacional: {exc}")
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303)
|
|
if not changed:
|
|
return PlainTextResponse("Oportunidade não encontrada.", status_code=404)
|
|
try:
|
|
from app.followup_service import cancel_pending_followups_for_opportunity, create_follow_up_task
|
|
cancel_pending_followups_for_opportunity(
|
|
opportunity_id=opportunity_id,
|
|
reason=f"manual_lifecycle_change:{state}",
|
|
created_by="operator",
|
|
)
|
|
if state == "recovery":
|
|
create_follow_up_task(
|
|
opportunity_id=opportunity_id,
|
|
action_code="RECOVER_OPPORTUNITY",
|
|
route="vendas",
|
|
action="Recuperar oportunidade sem resposta",
|
|
note=reason or "Rever canal, abordagem, timing e decidir entre nova tentativa, acompanhamento futuro ou perda.",
|
|
reason="MANUAL_RECOVERY",
|
|
delay_days=1,
|
|
created_by="operator",
|
|
idempotency_suffix=f"manual-recovery:{time.time_ns()}",
|
|
follow_up_family="generic",
|
|
follow_up_stage=99,
|
|
follow_up_max_stage=99,
|
|
cascade=False,
|
|
contact_purpose="recovery_review",
|
|
)
|
|
elif state == "nurture":
|
|
create_follow_up_task(
|
|
opportunity_id=opportunity_id,
|
|
action_code="REVIEW_NURTURE",
|
|
route="vendas",
|
|
action="Rever oportunidade em acompanhamento futuro",
|
|
note=reason or "Retomar contacto na data acordada ou rever se o timing continua válido.",
|
|
reason="NURTURE_REVIEW",
|
|
delay_days=1,
|
|
created_by="operator",
|
|
idempotency_suffix=f"nurture:{nurture_until}:{time.time_ns()}",
|
|
follow_up_family="generic",
|
|
follow_up_stage=99,
|
|
follow_up_max_stage=99,
|
|
cascade=False,
|
|
contact_purpose="nurture_review",
|
|
due_at_override=_parse_opportunity_dt(nurture_until),
|
|
)
|
|
except Exception:
|
|
pass
|
|
notice = quote(f"Estado operacional alterado para {lifecycle_label(state)}.")
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/lost")
|
|
async def mark_opportunity_lost_action(opportunity_id: str, request: Request):
|
|
if not is_uuid_text(opportunity_id):
|
|
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
|
|
form = await request.form()
|
|
reason_code = str(form.get("reason_code") or "").strip().lower()
|
|
note = str(form.get("note") or "").strip()
|
|
try:
|
|
changed = mark_opportunity_lost(
|
|
opportunity_id,
|
|
reason_code=reason_code,
|
|
note=note,
|
|
created_by="operator",
|
|
)
|
|
except ValueError as exc:
|
|
return PlainTextResponse(str(exc), status_code=422)
|
|
except Exception as exc:
|
|
notice = quote(f"Não foi possível marcar como perdida: {exc}")
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303)
|
|
if not changed:
|
|
return PlainTextResponse("Oportunidade não encontrada.", status_code=404)
|
|
return RedirectResponse("/opportunities?status=open&scope=recovery", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/stage")
|
|
async def update_opportunity_stage_action(opportunity_id: str, request: Request):
|
|
if not is_uuid_text(opportunity_id):
|
|
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
|
|
form = await request.form()
|
|
stage = str(form.get("stage") or "").strip().upper()
|
|
# Backwards-compatible alias used by older UI/tests.
|
|
# The canonical ClientFlow stage is NEW_LEAD.
|
|
stage_aliases = {"NEW": "NEW_LEAD"}
|
|
stage = stage_aliases.get(stage, stage)
|
|
note = str(form.get("note") or "").strip()
|
|
if not stage or stage not in OPPORTUNITY_STAGE_LABELS:
|
|
return PlainTextResponse("Fase de oportunidade inválida.", status_code=422)
|
|
if stage in {"LOST", "NO_INTEREST"}:
|
|
return PlainTextResponse("Usa a ação 'Fechar como perdida' e indica o motivo.", status_code=422)
|
|
try:
|
|
set_opportunity_stage(opportunity_id, stage, note=note, created_by="operator")
|
|
except ValueError as exc:
|
|
return PlainTextResponse(f"Transição de fase inválida: {exc}", status_code=409)
|
|
except Exception as exc:
|
|
notice = quote(f"Não foi possível alterar fase: {exc}")
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303)
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/customer")
|
|
async def opportunity_link_customer_action(opportunity_id: str, request: Request):
|
|
form = await request.form()
|
|
customer_id = str(form.get("customer_id") or "").strip()
|
|
try:
|
|
from app.commercial_service import link_customer_to_opportunity, unlink_customer_from_opportunity
|
|
if customer_id:
|
|
link_customer_to_opportunity(customer_id, opportunity_id)
|
|
else:
|
|
unlink_customer_from_opportunity(opportunity_id)
|
|
except Exception as exc:
|
|
return PlainTextResponse(f"Erro ao associar cliente: {exc}", status_code=500)
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/email-identity/extract")
|
|
async def opportunity_email_identity_extract_action(opportunity_id: str, request: Request):
|
|
try:
|
|
from app.email_identity_extraction_service import extract_identity_for_opportunity
|
|
result = extract_identity_for_opportunity(opportunity_id, refresh=True, use_llm=True)
|
|
except Exception as exc:
|
|
return PlainTextResponse(f"Erro ao extrair identidade do email: {exc}", status_code=500)
|
|
if not result:
|
|
notice = "Sem mensagem associada para extrair identidade."
|
|
else:
|
|
companies = result.get("company_mentions") or []
|
|
notice = "Identidade extraída" + (f": {', '.join(companies[:2])}" if companies else ".")
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/email-identity/assist")
|
|
async def opportunity_email_identity_assist_action(opportunity_id: str, request: Request):
|
|
try:
|
|
from app.fiscal_enrichment_service import assist_email_identity_enrichment
|
|
result = assist_email_identity_enrichment(opportunity_id, refresh=True, apply_safe=False)
|
|
except Exception as exc:
|
|
return PlainTextResponse(f"Erro ao procurar cliente fiscal por identidade: {exc}", status_code=500)
|
|
if result.get("conflict"):
|
|
notice = "Possível conflito fiscal detetado pela identidade extraída."
|
|
elif result.get("status") == "email_identity_matches_current_fiscal_customer":
|
|
notice = "Identidade extraída confirma o cliente fiscal atual."
|
|
elif result.get("suggested"):
|
|
notice = "Sugestão fiscal criada a partir da identidade extraída."
|
|
else:
|
|
notice = "Identidade extraída, mas sem cliente fiscal compatível encontrado."
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/email-identity/cleanup-invalid")
|
|
async def opportunity_email_identity_cleanup_invalid_action(opportunity_id: str, request: Request):
|
|
try:
|
|
from app.email_identity_cleanup_service import cleanup_invalid_email_identity_state
|
|
result = cleanup_invalid_email_identity_state(
|
|
opportunity_id=opportunity_id,
|
|
include_accepted=True,
|
|
fix_extractions=True,
|
|
apply=True,
|
|
)
|
|
except Exception as exc:
|
|
return PlainTextResponse(f"Erro ao limpar identidade inválida: {exc}", status_code=500)
|
|
notice = (
|
|
f"Limpeza de identidade: {result.get('rejected', 0)} sugestão(ões) rejeitada(s), "
|
|
f"{result.get('fixed_extractions', 0)} extração(ões) corrigida(s)."
|
|
)
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/fiscal-enrich")
|
|
async def opportunity_fiscal_enrich_action(opportunity_id: str, request: Request):
|
|
try:
|
|
from app.fiscal_enrichment_service import enrich_opportunity
|
|
result = enrich_opportunity(opportunity_id, apply_safe=True)
|
|
except Exception as exc:
|
|
return PlainTextResponse(f"Erro ao enriquecer cliente fiscal: {exc}", status_code=500)
|
|
if result.get("auto_applied"):
|
|
notice = "Cliente fiscal auto-associado por enriquecimento."
|
|
elif result.get("suggested"):
|
|
notice = "Sugestão fiscal criada para revisão."
|
|
else:
|
|
notice = f"Sem sugestão fiscal: {result.get('reason') or 'sem correspondência'}"
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303)
|
|
|
|
|
|
@router.post("/fiscal-suggestions/{suggestion_id}/accept")
|
|
async def fiscal_suggestion_accept_action(suggestion_id: str, request: Request):
|
|
try:
|
|
from app.fiscal_enrichment_service import apply_fiscal_suggestion
|
|
result = apply_fiscal_suggestion(suggestion_id, actor="operator_ui")
|
|
except Exception as exc:
|
|
return PlainTextResponse(f"Erro ao aplicar sugestão fiscal: {exc}", status_code=500)
|
|
opportunity_id = result.get("opportunity_id") or ""
|
|
if not result.get("applied"):
|
|
return PlainTextResponse(f"Sugestão não aplicada: {result.get('reason')}", status_code=409)
|
|
return RedirectResponse(f"/opportunities/{esc(opportunity_id)}?notice=Sugest%C3%A3o%20fiscal%20aplicada", status_code=303)
|
|
|
|
|
|
@router.post("/fiscal-suggestions/{suggestion_id}/reject")
|
|
async def fiscal_suggestion_reject_action(suggestion_id: str, request: Request):
|
|
from app.admin_auth import safe_local_redirect
|
|
try:
|
|
from app.fiscal_enrichment_service import reject_fiscal_suggestion
|
|
reject_fiscal_suggestion(suggestion_id, actor="operator_ui")
|
|
except Exception as exc:
|
|
return PlainTextResponse(f"Erro ao rejeitar sugestão fiscal: {exc}", status_code=500)
|
|
redirect_target = safe_local_redirect(
|
|
request.headers.get("referer"),
|
|
fallback="/opportunities",
|
|
)
|
|
return RedirectResponse(redirect_target, status_code=303)
|
|
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/jasmin/complete-fiscal")
|
|
async def opportunity_jasmin_complete_fiscal_action(opportunity_id: str, request: Request):
|
|
try:
|
|
from app.jasmin_fiscal_sync_service import apply_jasmin_fiscal_sync
|
|
result = apply_jasmin_fiscal_sync(opportunity_id, actor="operator_ui_jasmin_fiscal_sync")
|
|
filled = result.get("filled_fields") or []
|
|
if filled:
|
|
notice = "Dados fiscais completados com Jasmin: " + ", ".join(str(x) for x in filled)
|
|
else:
|
|
notice = "Cliente fiscal associado/completado com dados Jasmin."
|
|
except Exception as exc:
|
|
notice = "Erro ao completar dados fiscais com Jasmin: " + str(exc)
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303)
|
|
|
|
@router.post("/opportunities/{opportunity_id}/jasmin/sync-candidates")
|
|
async def opportunity_jasmin_sync_candidates_action(opportunity_id: str, request: Request):
|
|
try:
|
|
from app.external_reconciliation_sync import sync_jasmin_reconciliation_candidates
|
|
result = await sync_jasmin_reconciliation_candidates(limit=100, days=30)
|
|
except Exception as exc:
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao sincronizar Jasmin: {exc}"), status_code=409)
|
|
return PlainTextResponse(f"Erro ao sincronizar Jasmin: {exc}", status_code=500)
|
|
seen = result.get("seen", 0)
|
|
created = result.get("created_or_updated", 0)
|
|
notice = f"Jasmin sincronizado: {seen} documento(s) visto(s), {created} criado(s)/atualizado(s)."
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice))
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Jasmin%20sincronizado", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/jasmin/reimport-details")
|
|
async def opportunity_jasmin_reimport_details_action(opportunity_id: str, request: Request):
|
|
try:
|
|
from app.jasmin_backfill_service import backfill_jasmin_opportunity_details_async
|
|
result = await backfill_jasmin_opportunity_details_async(
|
|
opportunity_id=opportunity_id,
|
|
fetch_detail=True,
|
|
actor="operator_ui_reimport",
|
|
dry_run=False,
|
|
)
|
|
except Exception as exc:
|
|
if is_htmx(request):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao reimportar detalhes Jasmin: {exc}"), status_code=409)
|
|
return PlainTextResponse(f"Erro ao reimportar detalhes Jasmin: {exc}", status_code=500)
|
|
if not result.get("ok"):
|
|
msg = result.get("error") or "sem itens Jasmin para reimportar"
|
|
if is_htmx(request):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Não foi possível reimportar: {msg}"), status_code=409)
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=N%C3%A3o%20foi%20poss%C3%ADvel%20reimportar%20Jasmin", status_code=303)
|
|
import_result = result.get("import_result") or {}
|
|
docs = int(import_result.get("documents") or 0)
|
|
lines = int(import_result.get("lines") or 0)
|
|
notice = f"Detalhes Jasmin reimportados: {docs} documento(s), {lines} linha(s). Recarregue a página para atualizar produtos/valor no topo."
|
|
if is_htmx(request):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice))
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={esc(notice)}", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/jasmin/link-candidate/{item_id}")
|
|
async def opportunity_jasmin_link_candidate_action(opportunity_id: str, item_id: str, request: Request):
|
|
conflict_msg = _jasmin_candidate_tax_conflict_message(opportunity_id, item_id)
|
|
if conflict_msg:
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=conflict_msg), status_code=409)
|
|
return PlainTextResponse(conflict_msg, status_code=409)
|
|
try:
|
|
from app.jasmin_backfill_service import link_and_import_jasmin_candidate_async
|
|
result = await link_and_import_jasmin_candidate_async(
|
|
opportunity_id=opportunity_id,
|
|
item_id=item_id,
|
|
actor="operator_ui_link_existing_jasmin",
|
|
)
|
|
except Exception as exc:
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao associar documento Jasmin: {exc}"), status_code=409)
|
|
return PlainTextResponse(f"Erro ao associar documento Jasmin: {exc}", status_code=500)
|
|
if not result.get("ok"):
|
|
msg = result.get("error") or "não foi possível associar documento Jasmin"
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Não foi possível associar: {msg}"), status_code=409)
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=N%C3%A3o%20foi%20poss%C3%ADvel%20associar%20Jasmin", status_code=303)
|
|
import_result = result.get("import_result") or {}
|
|
docs = import_result.get("documents", 0)
|
|
lines = import_result.get("lines", 0)
|
|
notice = f"Documento Jasmin associado e importado: {docs} documento(s), {lines} linha(s). Recarregue a página para atualizar valor/produtos no topo."
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice))
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Documento%20Jasmin%20associado", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/jasmin/replace-candidate/{item_id}")
|
|
async def opportunity_jasmin_replace_candidate_action(opportunity_id: str, item_id: str, request: Request):
|
|
conflict_msg = _jasmin_candidate_tax_conflict_message(opportunity_id, item_id)
|
|
if conflict_msg:
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=conflict_msg), status_code=409)
|
|
return PlainTextResponse(conflict_msg, status_code=409)
|
|
try:
|
|
from app.jasmin_backfill_service import replace_jasmin_document_for_opportunity_async
|
|
result = await replace_jasmin_document_for_opportunity_async(
|
|
opportunity_id=opportunity_id,
|
|
item_id=item_id,
|
|
actor="operator_ui_replace_existing_jasmin",
|
|
dry_run=False,
|
|
)
|
|
except Exception as exc:
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao substituir documento Jasmin: {exc}"), status_code=409)
|
|
return PlainTextResponse(f"Erro ao substituir documento Jasmin: {exc}", status_code=500)
|
|
if not result.get("ok"):
|
|
msg = result.get("error") or "não foi possível substituir documento Jasmin"
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Não foi possível substituir: {msg}"), status_code=409)
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=N%C3%A3o%20foi%20poss%C3%ADvel%20substituir%20Jasmin", status_code=303)
|
|
import_result = result.get("import_result") or {}
|
|
docs = import_result.get("documents", 0)
|
|
lines = import_result.get("lines", 0)
|
|
removed_docs = result.get("removed_documents", 0)
|
|
notice = f"Documento Jasmin substituído: {removed_docs} anterior(es) removido(s), {docs} documento(s), {lines} linha(s) importada(s). Recarregue a página para atualizar valor/produtos no topo."
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice))
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Documento%20Jasmin%20substitu%C3%ADdo", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/jasmin/create-quotation")
|
|
async def opportunity_jasmin_create_quotation(opportunity_id: str, request: Request):
|
|
try:
|
|
if settings.jasmin_enabled:
|
|
from app.jasmin_service import enqueue_create_quotation
|
|
enqueue_create_quotation(opportunity_id, created_by="operator")
|
|
else:
|
|
return PlainTextResponse("JASMIN_ENABLED=false", status_code=409)
|
|
except Exception as exc:
|
|
print(f"ClientFlow Jasmin create quotation failed: {exc}", flush=True)
|
|
if is_htmx(request):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=str(exc)), status_code=409)
|
|
notice = quote(f"Não foi possível criar orçamento Jasmin: {exc}")
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303)
|
|
if is_htmx(request):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, notice="Pedido de orçamento enviado para a outbox Jasmin."))
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Pedido%20de%20or%C3%A7amento%20enviado%20para%20a%20outbox%20Jasmin", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/jasmin/convert-invoice")
|
|
async def opportunity_jasmin_convert_invoice(opportunity_id: str, request: Request):
|
|
try:
|
|
if settings.jasmin_enabled:
|
|
from app.jasmin_service import enqueue_convert_latest_to_invoice
|
|
enqueue_convert_latest_to_invoice(opportunity_id, created_by="operator")
|
|
else:
|
|
return PlainTextResponse("JASMIN_ENABLED=false", status_code=409)
|
|
except Exception as exc:
|
|
print(f"ClientFlow Jasmin convert invoice failed: {exc}", flush=True)
|
|
if is_htmx(request):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=str(exc)), status_code=409)
|
|
notice = quote(f"Não foi possível criar fatura Jasmin: {exc}")
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303)
|
|
if is_htmx(request):
|
|
return HTMLResponse(jasmin_documents_html(opportunity_id, notice="Pedido de fatura enviado para a outbox Jasmin."))
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Pedido%20de%20fatura%20enviado%20para%20a%20outbox%20Jasmin", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/operations/{action_key}")
|
|
async def opportunity_operation_action(opportunity_id: str, action_key: str, request: Request):
|
|
if not is_uuid_text(opportunity_id):
|
|
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
|
|
# Accept legacy/semantic action names used by older UI buttons and E2E audits.
|
|
action_aliases = {
|
|
"prepare_order": "odoo_sale_order",
|
|
"prepare_shipping": "packlink_shipment",
|
|
"send_followup": "tracking_sent",
|
|
}
|
|
action_key = action_aliases.get(str(action_key or ""), str(action_key or ""))
|
|
form = await request.form()
|
|
external_id = str(form.get("external_id") or "").strip()
|
|
external_name = str(form.get("external_name") or form.get("external_ref") or form.get("title") or "").strip()
|
|
external_url = str(form.get("external_url") or "").strip()
|
|
note = str(form.get("note") or "").strip()
|
|
try:
|
|
# Jasmin e Packlink, sem referência manual, criam itens de outbox para a API real.
|
|
# Se o operador preencher external_id/external_name, mantém o modo manual/fallback.
|
|
if action_key == "jasmin_quotation" and not external_id and not external_name:
|
|
if settings.jasmin_enabled:
|
|
from app.jasmin_service import enqueue_create_quotation
|
|
enqueue_create_quotation(opportunity_id, created_by="operator")
|
|
else:
|
|
register_operation_action(opportunity_id, action_key, external_id=external_id, external_name=external_name, external_url=external_url, note=note, created_by="operator")
|
|
elif action_key == "packlink_shipment" and not external_id and not external_name:
|
|
if settings.packlink_enabled:
|
|
from app.packlink_service import enqueue_packlink_shipment
|
|
enqueue_packlink_shipment(opportunity_id, created_by="operator")
|
|
else:
|
|
register_operation_action(opportunity_id, action_key, external_id=external_id, external_name=external_name, external_url=external_url, note=note, created_by="operator")
|
|
else:
|
|
register_operation_action(opportunity_id, action_key, external_id=external_id, external_name=external_name, external_url=external_url, note=note, created_by="operator")
|
|
except OperationActionBlocked as exc:
|
|
# Ações operacionais incompatíveis com o estado da oportunidade são bloqueios reais,
|
|
# não sucesso silencioso. Devolve 409 para testes/API e HTMX; a UI mostra a razão.
|
|
return PlainTextResponse(f"Ação bloqueada: {exc}", status_code=409)
|
|
except Exception as exc:
|
|
print(f"ClientFlow operation action failed: {exc}", flush=True)
|
|
if is_htmx(request):
|
|
return PlainTextResponse(f"Erro ao registar ação: {exc}", status_code=409)
|
|
notice = quote(f"Não foi possível registar ação: {exc}")
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303)
|
|
return RedirectResponse(f"/opportunities/{opportunity_id}", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/odoo/link-sale")
|
|
async def opportunity_odoo_link_sale_number_action(opportunity_id: str, request: Request):
|
|
if not is_uuid_text(opportunity_id):
|
|
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
|
|
form = await request.form()
|
|
sale_ref = str(form.get("sale_ref") or form.get("external_name") or form.get("external_ref") or "").strip()
|
|
if not sale_ref:
|
|
msg = "Indica o nº da venda Odoo, por exemplo S00308."
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=msg), status_code=422)
|
|
return PlainTextResponse(msg, status_code=422)
|
|
|
|
sale_ref = sale_ref.upper() if sale_ref.lower().startswith("s") else sale_ref
|
|
external_id = sale_ref if sale_ref.isdigit() else ""
|
|
external_name = sale_ref
|
|
notice = f"Venda Odoo {sale_ref} registada na oportunidade."
|
|
|
|
try:
|
|
from app.operation_service import register_operation_action
|
|
|
|
register_operation_action(
|
|
opportunity_id,
|
|
"odoo_sale_order",
|
|
external_id=external_id,
|
|
external_name=external_name,
|
|
note=f"Venda Odoo {sale_ref} associada manualmente pelo operador.",
|
|
payload={"manual_odoo_sale_ref": sale_ref},
|
|
created_by="operator_ui_odoo_sale_ref",
|
|
)
|
|
|
|
with engine.begin() as conn:
|
|
conn.execute(text("""
|
|
UPDATE tasks
|
|
SET status = 'done',
|
|
note = COALESCE(note, '') || CAST(:note AS TEXT),
|
|
updated_at = NOW()
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
AND action_code = 'PREPARE_ORDER'
|
|
AND status = 'pending'
|
|
"""), {
|
|
"opportunity_id": opportunity_id,
|
|
"note": f"\n\nConcluída automaticamente: venda Odoo {sale_ref} associada manualmente.",
|
|
})
|
|
|
|
try:
|
|
sync_opportunity_odoo_status(opportunity_id)
|
|
notice = f"Venda Odoo {sale_ref} associada e estado WH/OUT sincronizado."
|
|
except Exception as sync_exc:
|
|
notice = f"Venda Odoo {sale_ref} associada. Sincronização Odoo falhou: {sync_exc}"
|
|
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(odoo_status_panel_html(opportunity_id, notice=notice))
|
|
return RedirectResponse(url=f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303)
|
|
except OperationActionBlocked as exc:
|
|
msg = f"Ação bloqueada: {exc}"
|
|
except Exception as exc:
|
|
msg = f"Erro ao associar venda Odoo: {exc}"
|
|
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=msg), status_code=409)
|
|
return RedirectResponse(url=f"/opportunities/{opportunity_id}?notice={quote(msg)}", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/odoo/sync-status")
|
|
async def opportunity_odoo_sync_status_action(opportunity_id: str, request: Request):
|
|
try:
|
|
result = sync_opportunity_odoo_status(opportunity_id)
|
|
label = result.get("label") or result.get("physical_status") or "estado Odoo atualizado"
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(odoo_status_panel_html(opportunity_id, notice=f"Odoo sincronizado: {label}"))
|
|
except Exception as exc:
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=str(exc)), status_code=409)
|
|
return RedirectResponse(url=f"/opportunities/{opportunity_id}?notice=Erro%20ao%20sincronizar%20Odoo", status_code=303)
|
|
return RedirectResponse(url=f"/opportunities/{opportunity_id}?notice=Odoo%20sincronizado", status_code=303)
|
|
|
|
|
|
@router.post("/opportunities/{opportunity_id}/odoo/link-candidate/{item_id}")
|
|
async def opportunity_odoo_link_candidate_action(opportunity_id: str, item_id: str, request: Request):
|
|
try:
|
|
from app.reconciliation_service import link_reconciliation_to_opportunity
|
|
link_reconciliation_to_opportunity(item_id, opportunity_id, actor="operator_ui_odoo_panel")
|
|
try:
|
|
sync_opportunity_odoo_status(opportunity_id)
|
|
except Exception:
|
|
pass
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(odoo_status_panel_html(opportunity_id, notice="Venda Odoo associada à oportunidade."))
|
|
except Exception as exc:
|
|
if request.headers.get("hx-request"):
|
|
return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=f"Erro ao associar venda Odoo: {exc}"), status_code=409)
|
|
return PlainTextResponse(f"Erro ao associar venda Odoo: {exc}", status_code=500)
|
|
return RedirectResponse(url=f"/opportunities/{opportunity_id}?notice=Venda%20Odoo%20associada", status_code=303)
|
|
|