300 lines
13 KiB
Python
300 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any, Dict, Optional, Set
|
|
|
|
from sqlalchemy import text
|
|
|
|
from app.db import engine
|
|
|
|
|
|
class OperationActionBlocked(RuntimeError):
|
|
pass
|
|
|
|
|
|
ACTION_LABELS: Dict[str, str] = {
|
|
"jasmin_quotation": "Criar orçamento Jasmin",
|
|
"jasmin_proforma": "Emitir/registar pró-forma",
|
|
"payment_confirmed": "Confirmar pagamento",
|
|
"odoo_sale_order": "Criar/registar venda Odoo",
|
|
"odoo_production": "Registar produção/preparação",
|
|
"odoo_physical_validated": "Validar encomenda física",
|
|
"jasmin_invoice": "Emitir/registar fatura",
|
|
"packlink_shipment": "Criar envio Packlink",
|
|
"tracking_sent": "Enviar/registar tracking",
|
|
"delivered": "Marcar entregue/concluir",
|
|
}
|
|
|
|
|
|
def _to_bool(value: Any) -> bool:
|
|
if isinstance(value, bool):
|
|
return value
|
|
return str(value or "").strip().lower() in {"1", "true", "t", "yes", "sim"}
|
|
|
|
|
|
def _json(value: Any) -> Dict[str, Any]:
|
|
if isinstance(value, dict):
|
|
return value
|
|
if isinstance(value, str) and value:
|
|
try:
|
|
parsed = json.loads(value)
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
except Exception:
|
|
return {}
|
|
return {}
|
|
|
|
|
|
def get_workflow_context(opportunity_id: str) -> Dict[str, Any]:
|
|
with engine.begin() as conn:
|
|
opp = conn.execute(text("""
|
|
SELECT id::text, stage, status
|
|
FROM opportunities
|
|
WHERE id = CAST(:opportunity_id AS UUID)
|
|
"""), {"opportunity_id": str(opportunity_id)}).mappings().first()
|
|
|
|
links = conn.execute(text("""
|
|
SELECT system, external_type, status, external_id, external_name, external_url, payload, updated_at
|
|
FROM operation_links
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
"""), {"opportunity_id": str(opportunity_id)}).mappings().all()
|
|
|
|
item_count = conn.execute(text("""
|
|
SELECT count(*)
|
|
FROM opportunity_items
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
"""), {"opportunity_id": str(opportunity_id)}).scalar() or 0
|
|
|
|
jasmin_doc_counts = conn.execute(text("""
|
|
SELECT
|
|
count(*) FILTER (WHERE document_kind = 'quotation') AS quotation_count,
|
|
count(*) FILTER (WHERE document_kind = 'proforma') AS proforma_count,
|
|
count(*) FILTER (WHERE document_kind = 'invoice') AS invoice_count
|
|
FROM commercial_documents
|
|
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
|
AND system = 'jasmin'
|
|
"""), {"opportunity_id": str(opportunity_id)}).mappings().first()
|
|
|
|
by_key = {}
|
|
for row in links:
|
|
d = dict(row)
|
|
d["payload"] = _json(d.get("payload"))
|
|
by_key[f"{d.get('system')}:{d.get('external_type')}"] = d
|
|
|
|
physical = by_key.get("odoo:physical_status") or {}
|
|
physical_payload = _json(physical.get("payload"))
|
|
|
|
return {
|
|
"opportunity": dict(opp or {}),
|
|
"links": by_key,
|
|
"item_count": int(item_count),
|
|
"jasmin_quotation_count": int((jasmin_doc_counts or {}).get("quotation_count") or 0),
|
|
"jasmin_proforma_count": int((jasmin_doc_counts or {}).get("proforma_count") or 0),
|
|
"jasmin_invoice_count": int((jasmin_doc_counts or {}).get("invoice_count") or 0),
|
|
"physical_status": physical,
|
|
"physical_payload": physical_payload,
|
|
"ready_to_ship": _to_bool(physical_payload.get("ready_to_ship")),
|
|
}
|
|
|
|
|
|
def _has(ctx: Dict[str, Any], key: str, statuses: Optional[Set[str]] = None) -> bool:
|
|
link = (ctx.get("links") or {}).get(key)
|
|
if not link:
|
|
return False
|
|
if statuses is None:
|
|
return True
|
|
return str(link.get("status") or "") in statuses
|
|
|
|
|
|
def blocked_reason(ctx: Dict[str, Any], action_key: str) -> Optional[str]:
|
|
opp = ctx.get("opportunity") or {}
|
|
if not opp:
|
|
return "Oportunidade não encontrada."
|
|
|
|
stage = str(opp.get("stage") or "")
|
|
opp_status = str(opp.get("status") or "")
|
|
|
|
if opp_status == "closed" and action_key != "delivered":
|
|
return "A oportunidade já está fechada."
|
|
|
|
item_count = int(ctx.get("item_count") or 0)
|
|
ready_to_ship = bool(ctx.get("ready_to_ship"))
|
|
|
|
quotation = _has(ctx, "jasmin:quotation", {"created", "sent", "converted"}) or int(ctx.get("jasmin_quotation_count") or 0) > 0
|
|
proforma = _has(ctx, "jasmin:proforma", {"issued"}) or int(ctx.get("jasmin_proforma_count") or 0) > 0
|
|
payment = _has(ctx, "clientflow:payment", {"confirmed"})
|
|
sale = _has(ctx, "odoo:sale_order", {"created"})
|
|
physical_validated = _has(ctx, "odoo:physical_validation", {"validated"})
|
|
invoice = _has(ctx, "jasmin:invoice", {"issued"}) or int(ctx.get("jasmin_invoice_count") or 0) > 0
|
|
shipment = _has(ctx, "packlink:shipment", {"created"})
|
|
tracking = _has(ctx, "clientflow:tracking", {"sent"})
|
|
|
|
if action_key == "jasmin_quotation":
|
|
if invoice:
|
|
return "Já existe fatura Jasmin associada/importada nesta oportunidade."
|
|
if proforma:
|
|
return "Já existe pró-forma Jasmin associada/importada nesta oportunidade."
|
|
if quotation:
|
|
return "Já existe orçamento Jasmin associado/importado nesta oportunidade."
|
|
if item_count <= 0:
|
|
return "Não é possível criar orçamento sem produtos na oportunidade."
|
|
if stage in {"WON", "LOST", "NO_INTEREST"}:
|
|
return "A oportunidade já está fechada/perdida."
|
|
return None
|
|
|
|
if action_key == "jasmin_proforma":
|
|
if item_count <= 0:
|
|
return "Não é possível emitir pró-forma sem produtos na oportunidade."
|
|
if stage in {"WON", "LOST", "NO_INTEREST"}:
|
|
return "A oportunidade já está fechada/perdida."
|
|
return None
|
|
|
|
if action_key == "payment_confirmed":
|
|
if not (proforma or stage in {"QUOTE_SENT", "PROFORMA_SENT", "WAITING_PAYMENT", "PAYMENT_CONFIRMED"}):
|
|
return "Confirmação de pagamento só deve ocorrer depois de orçamento/pró-forma/pedido de pagamento."
|
|
return None
|
|
|
|
if action_key == "odoo_sale_order":
|
|
if not payment:
|
|
return "Só é possível criar/registar venda Odoo após pagamento confirmado."
|
|
return None
|
|
|
|
if action_key == "odoo_production":
|
|
if not sale:
|
|
return "Produção/preparação exige venda Odoo criada."
|
|
return None
|
|
|
|
if action_key == "odoo_physical_validated":
|
|
if not sale:
|
|
return "Validação física exige venda Odoo criada."
|
|
if not ready_to_ship:
|
|
return "Odoo ainda não indica encomenda pronta para despacho."
|
|
return None
|
|
|
|
if action_key == "jasmin_invoice":
|
|
if not payment:
|
|
return "Fatura Jasmin exige pagamento confirmado."
|
|
if not sale:
|
|
return "Fatura Jasmin exige venda Odoo criada."
|
|
return None
|
|
|
|
if action_key == "packlink_shipment":
|
|
if not payment:
|
|
return "Envio Packlink exige pagamento confirmado."
|
|
if not invoice:
|
|
return "Envio Packlink exige fatura emitida."
|
|
if not (ready_to_ship or physical_validated):
|
|
return "Odoo ainda não indica encomenda pronta para despacho."
|
|
return None
|
|
|
|
if action_key == "tracking_sent":
|
|
if not shipment:
|
|
return "Só é possível enviar/registar tracking após criar envio Packlink."
|
|
return None
|
|
|
|
if action_key == "delivered":
|
|
if not (tracking or shipment):
|
|
return "Só é possível marcar entregue depois de envio/tracking."
|
|
return None
|
|
|
|
return f"Ação desconhecida: {action_key}"
|
|
|
|
|
|
def validate_operation_action(opportunity_id: str, action_key: str) -> None:
|
|
ctx = get_workflow_context(opportunity_id)
|
|
reason = blocked_reason(ctx, action_key)
|
|
if reason:
|
|
raise OperationActionBlocked(reason)
|
|
|
|
|
|
def get_workflow_action_plan(opportunity_id: str) -> Dict[str, Any]:
|
|
ctx = get_workflow_context(opportunity_id)
|
|
opp = ctx.get("opportunity") or {}
|
|
stage = str(opp.get("stage") or "")
|
|
status = str(opp.get("status") or "")
|
|
|
|
item_count = int(ctx.get("item_count") or 0)
|
|
physical_payload = ctx.get("physical_payload") or {}
|
|
ready_to_ship = bool(ctx.get("ready_to_ship"))
|
|
|
|
quotation = _has(ctx, "jasmin:quotation", {"created", "sent", "converted"}) or int(ctx.get("jasmin_quotation_count") or 0) > 0
|
|
proforma = _has(ctx, "jasmin:proforma", {"issued"}) or int(ctx.get("jasmin_proforma_count") or 0) > 0
|
|
payment = _has(ctx, "clientflow:payment", {"confirmed"})
|
|
sale = _has(ctx, "odoo:sale_order", {"created"})
|
|
physical_status = _has(ctx, "odoo:physical_status")
|
|
physical_validated = _has(ctx, "odoo:physical_validation", {"validated"})
|
|
invoice = _has(ctx, "jasmin:invoice", {"issued"}) or int(ctx.get("jasmin_invoice_count") or 0) > 0
|
|
shipment = _has(ctx, "packlink:shipment", {"created"})
|
|
tracking = _has(ctx, "clientflow:tracking", {"sent"})
|
|
delivered = _has(ctx, "packlink:delivery", {"delivered"})
|
|
|
|
if status == "closed" or stage in {"WON", "LOST", "NO_INTEREST"} or delivered:
|
|
if stage == "NO_INTEREST":
|
|
state_label = "Sem interesse"
|
|
reason = "Cliente indicou ausência de interesse atual/necessidade."
|
|
elif stage == "LOST":
|
|
state_label = "Perdida"
|
|
reason = "Oportunidade perdida por decisão comercial."
|
|
else:
|
|
state_label = "Concluída"
|
|
reason = "Processo concluído."
|
|
next_action = {"kind": "none", "label": "Sem ação necessária", "reason": reason}
|
|
elif invoice and not payment:
|
|
state_label = "Fatura enviada"
|
|
next_action = {"kind": "operation", "action_key": "payment_confirmed", "label": ACTION_LABELS["payment_confirmed"], "reason": "Fatura Jasmin já existe; confirmar pagamento ou rever se é registo histórico/reconstruído."}
|
|
elif item_count <= 0 and not (quotation or proforma or invoice or payment or sale):
|
|
state_label = "Preparar proposta"
|
|
next_action = {"kind": "manual", "label": "Adicionar produto/proposta", "reason": "A oportunidade ainda não tem produtos."}
|
|
elif not quotation and not proforma and not invoice and not payment:
|
|
state_label = "Proposta/pagamento"
|
|
next_action = {"kind": "operation", "action_key": "jasmin_quotation", "label": ACTION_LABELS["jasmin_quotation"], "reason": "Criar orçamento Jasmin para validação pelo cliente."}
|
|
elif not payment:
|
|
state_label = "A aguardar pagamento"
|
|
next_action = {"kind": "operation", "action_key": "payment_confirmed", "label": ACTION_LABELS["payment_confirmed"], "reason": "Confirmar quando o pagamento estiver validado."}
|
|
elif not sale:
|
|
state_label = "Pagamento confirmado"
|
|
next_action = {"kind": "operation", "action_key": "odoo_sale_order", "label": ACTION_LABELS["odoo_sale_order"], "reason": "Criar/registar venda oficial no Odoo."}
|
|
elif not physical_status:
|
|
state_label = "Venda criada no Odoo"
|
|
next_action = {"kind": "sync_odoo", "label": "Sincronizar estado Odoo", "reason": "Ler se a encomenda física está em produção, bloqueada ou pronta."}
|
|
elif not ready_to_ship:
|
|
state_label = physical_payload.get("label") or "Em preparação"
|
|
next_action = {"kind": "wait", "label": "Aguardar Odoo", "reason": physical_payload.get("next_action") or physical_payload.get("reason") or "Odoo ainda não indica pronto para despacho."}
|
|
elif not physical_validated:
|
|
state_label = "Pronta para despacho"
|
|
next_action = {"kind": "operation", "action_key": "odoo_physical_validated", "label": ACTION_LABELS["odoo_physical_validated"], "reason": "Confirmar no ClientFlow que a encomenda física está pronta."}
|
|
elif not invoice:
|
|
state_label = "Pronta para faturar"
|
|
next_action = {"kind": "operation", "action_key": "jasmin_invoice", "label": ACTION_LABELS["jasmin_invoice"], "reason": "Emitir/registar fatura antes do envio."}
|
|
elif not shipment:
|
|
state_label = "Pronta para envio"
|
|
next_action = {"kind": "operation", "action_key": "packlink_shipment", "label": ACTION_LABELS["packlink_shipment"], "reason": "Criar envio/recolha Packlink."}
|
|
elif not tracking:
|
|
state_label = "Envio criado"
|
|
next_action = {"kind": "operation", "action_key": "tracking_sent", "label": ACTION_LABELS["tracking_sent"], "reason": "Enviar tracking ao cliente."}
|
|
else:
|
|
state_label = "A acompanhar entrega"
|
|
next_action = {"kind": "operation", "action_key": "delivered", "label": ACTION_LABELS["delivered"], "reason": "Marcar como entregue/concluído quando confirmado."}
|
|
|
|
allowed = []
|
|
blocked = []
|
|
for key, label in ACTION_LABELS.items():
|
|
reason = blocked_reason(ctx, key)
|
|
item = {"action_key": key, "label": label, "reason": reason or ""}
|
|
if reason:
|
|
blocked.append(item)
|
|
else:
|
|
allowed.append(item)
|
|
|
|
return {
|
|
"state_label": state_label,
|
|
"stage": stage,
|
|
"status": status,
|
|
"ready_to_ship": ready_to_ship,
|
|
"physical_label": physical_payload.get("label") or "",
|
|
"physical_reason": physical_payload.get("reason") or "",
|
|
"physical_next_action": physical_payload.get("next_action") or "",
|
|
"next_action": next_action,
|
|
"allowed_actions": allowed,
|
|
"blocked_actions": blocked,
|
|
}
|