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, }