"""Task list, detail and task action routes. Moved from app.admin_dashboard in v4.7.2. Reply assistant lineage: v4928.1.5.0 compatibility marker; v4928.1.5.13 compatibility marker; current badge v4928.1.5.14; draft refresh persistence v4928.1.5.15; editable revision workflow v4928.1.5.16; semi-automatic follow-ups v4928.1.5.17; follow-up draft generator v4928.1.5.18; dedicated OpenAI follow-up LLM prompt v4928.1.5.23; contact-person greeting v4928.1.5.24. The handlers still reuse legacy helpers to keep this refactor behavior-preserving. """ from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse, RedirectResponse, Response, PlainTextResponse from urllib.parse import urlsplit, quote, urlencode import json import re import app.admin_dashboard as legacy from app.admin_dashboard import * # noqa: F401,F403 from app.admin_ui.guidance import ( fiscal_contact_panel_html, fiscal_customer_missing_fields, readiness_checklist_html, ) from app.admin_ui.labels import primary_action_label from app.task_service import reschedule_task_due_at from app.reply_recipient_utils import resolve_reply_recipient from app.company_opportunity_linking import associate_task_to_opportunity router = APIRouter() def _safe_search_query(value: Optional[str]) -> Optional[str]: if value is None: return None cleaned = re.sub(r"[\x00-\x1f\x7f-\x9f]", "", str(value)) cleaned = cleaned.strip() if len(cleaned) > 120: cleaned = cleaned[:120] return cleaned or None def _invalid_task_response(): return PlainTextResponse("Identificador de tarefa inválido.", status_code=422) def _safe_return_to(value: str, default: str = "/tasks?status=pending") -> str: """Preserve safe internal navigation context after a task action.""" value = str(value or "").strip() if not value: return default if not value.startswith("/") or value.startswith("//"): return default parsed = urlsplit(value) if parsed.scheme or parsed.netloc: return default allowed_prefixes = ("/operations", "/operacoes", "/tasks", "/opportunities", "/finance", "/financeiro", "/orders", "/encomendas") if not parsed.path.startswith(allowed_prefixes): return default return value def _return_to_hidden(return_to: str) -> str: return f'' if return_to else "" def _return_to_link(return_to: str) -> str: href = return_to or "/tasks?status=pending" return f'← Voltar' def _task_href(task_id: str, return_to: str = "") -> str: """Build task detail URL preserving the caller page as navigation context.""" href = f"/tasks/{task_id}" safe_return_to = _safe_return_to(return_to, default="") if return_to else "" if safe_return_to: href += f"?return_to={quote(safe_return_to, safe='')}" return href def _tasks_list_return_to(status: Optional[str] = "pending", route: Optional[str] = None, view: Optional[str] = None, q: Optional[str] = None, limit: int = 200) -> str: params = {} if status: params["status"] = status if route: params["route"] = route if view: params["view"] = view if q: params["q"] = q if limit and int(limit) != 200: params["limit"] = str(limit) query = urlencode(params) return "/tasks" + (f"?{query}" if query else "") def _htmx_redirect_response(return_to: str): response = Response(status_code=204) response.headers["HX-Redirect"] = return_to return response def _safe_task_display_html(value: str) -> str: text = str(value or "") replacements = { "Traceback (most recent call last)": "Relatório técnico remoto", "Internal Server Error": "Erro interno reportado na mensagem", "Application startup failed": "Falha de arranque reportada na mensagem", "sqlalchemy.exc.": "sqlalchemy exc.", "psycopg.errors.": "psycopg errors.", "SyntaxError:": "SyntaxError reportado:", "Exception:": "Exceção reportada:", "fatura por emitir": "fatura criada/associada; enviar PDF ao cliente", "Fatura por emitir": "Fatura criada/associada; enviar PDF ao cliente", "Preparar e enviar orçamento para pagamento para pagamento.": "Preparar e enviar orçamento para pagamento.", "Enviar orçamento para pagamento": "Enviar orçamento para pagamento", "orçamento para pagamento": "orçamento para pagamento", "Orçamento para pagamento": "Orçamento para pagamento", } for old, new in replacements.items(): text = text.replace(old, new) text = text.replace(old.lower(), new) text = text.replace(old.upper(), new) return text def _task_opportunity_linking_panel_html(task: dict, return_to: str = "") -> str: metadata = task.get("metadata") or {} if isinstance(metadata, str): try: metadata = json.loads(metadata) except Exception: metadata = {} if str(metadata.get("opportunity_linking_status") or "").lower() != "ambiguous": return "" candidates = metadata.get("opportunity_linking_candidates") or [] reason = str(metadata.get("opportunity_linking_reason") or "associação ambígua") if not candidates: return f'''

Associar oportunidade existente

O sistema encontrou risco de associação errada: {esc(reason)}. Usa a oportunidade correta antes de executar documentos ou pagamentos.

''' rows = [] task_id = str(task.get("id") or "") hidden_return_to = _return_to_hidden(return_to) for c in candidates[:8]: opp_id = str(c.get("opportunity_id") or "") if not is_uuid_text(opp_id): continue title = str(c.get("title") or "Oportunidade") customer = str(c.get("customer_name") or c.get("customer_email") or "") doc = str(c.get("document_number") or "") amount = str(c.get("total_amount") or "") why = str(c.get("reason") or "candidato") rows.append(f'''
{esc(customer)}
{esc(doc or 'sem documento visível')} {esc(('· ' + amount) if amount else '')}
Evidência: {esc(why)}
{hidden_return_to}
''') body = "".join(rows) or "

Sem candidatos válidos.

" return f'''

Associar a oportunidade existente

A mensagem pode vir de outro departamento da mesma empresa. Escolhe a compra/processo correto antes de avançar.

{body}
''' DOCUMENT_TASK_ACTIONS = {"SEND_QUOTE", "SEND_PROFORMA", "SEND_INVOICE"} # Legacy static anchor: Segue em anexo a fatura pró-forma solicitada # Legacy static anchor: Segue em anexo a fatura orçamento para pagamento solicitada FOLLOW_UP_ACTIONS = {"FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA", "FOLLOW_UP_PAYMENT", "FOLLOW_UP_CUSTOMER_REVIEW", "FOLLOW_UP_GENERIC"} def _is_follow_up_task(task_or_code) -> bool: if isinstance(task_or_code, dict): code = task_or_code.get("action_code") else: code = task_or_code return str(code or "").strip().upper() in FOLLOW_UP_ACTIONS def _follow_up_template_for_action(action_code: str) -> str: code = str(action_code or "").strip().upper() return { "FOLLOW_UP_QUOTE": "FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA": "FOLLOW_UP_PROFORMA", "FOLLOW_UP_PAYMENT": "FOLLOW_UP_PAYMENT", "FOLLOW_UP_CUSTOMER_REVIEW": "FOLLOW_UP_CUSTOMER_REVIEW", "FOLLOW_UP_GENERIC": "FOLLOW_UP_GENERIC", }.get(code, "FOLLOW_UP_GENERIC") def _fallback_follow_up_message(action_code: str) -> str: code = str(action_code or "").strip().upper() if code == "FOLLOW_UP_PAYMENT": return "Olá,\n\nGostaria apenas de confirmar se recebeu o orçamento/dados de pagamento e se precisa de alguma informação adicional para avançar.\n\nObrigado." if code == "FOLLOW_UP_PROFORMA": return "Olá,\n\nGostaria apenas de confirmar se recebeu o orçamento para pagamento e se precisa de alguma informação adicional.\n\nObrigado." if code == "FOLLOW_UP_QUOTE": return "Olá,\n\nGostaria apenas de confirmar se recebeu a proposta/orçamento e se ficou com alguma dúvida.\n\nObrigado." if code == "FOLLOW_UP_CUSTOMER_REVIEW": return "Olá,\n\nGostaria apenas de confirmar se mantém interesse e se precisa de alguma informação adicional.\n\nObrigado." return "Olá,\n\nGostaria apenas de dar seguimento ao processo e confirmar se precisa de alguma informação adicional.\n\nObrigado." def _metadata_dict(value) -> dict: if isinstance(value, dict): return value if isinstance(value, str) and value.strip(): try: return json.loads(value) except Exception: return {} return {} _IDENTITY_STOPWORDS = { "da", "de", "do", "dos", "das", "e", "lda", "ltda", "unipessoal", "sa", "s.a", "s.a.", "email", "mail", "geral", "info", "office", "frontoffice", "comercial", "vendas", "admin", "contacto", "contact", } PUBLIC_CONTACT_EMAIL_DOMAINS = { "gmail.com", "googlemail.com", "hotmail.com", "hotmail.pt", "outlook.com", "outlook.pt", "live.com", "msn.com", "icloud.com", "me.com", "mac.com", "yahoo.com", "yahoo.pt", "sapo.pt", "mail.telepac.pt", "mail.com", "proton.me", "protonmail.com", "aol.com", "gmx.com", "gmx.net", "uol.com.br", } def _email_domain_for_identity(value) -> str: text = str(value or "").strip().casefold() if "@" not in text: return "" domain = text.rsplit("@", 1)[-1].strip(" .") if domain.startswith("www."): domain = domain[4:] return domain def _email_local_for_identity(value) -> str: text = str(value or "").strip().casefold() if "@" not in text: return "" return text.split("@", 1)[0].strip(" ._-+") def _is_public_contact_email_domain(domain) -> bool: return _email_domain_for_identity(f"x@{domain}") in PUBLIC_CONTACT_EMAIL_DOMAINS if "@" not in str(domain or "") else _email_domain_for_identity(domain) in PUBLIC_CONTACT_EMAIL_DOMAINS def _norm_identity(value) -> str: return " ".join(re.sub(r"[^0-9a-zA-ZÀ-ÿ]+", " ", str(value or "").casefold()).split()) def _identity_tokens(value) -> set[str]: return { token for token in _norm_identity(value).split() if len(token) >= 3 and token not in _IDENTITY_STOPWORDS } def _email_tokens(value) -> set[str]: email = str(value or "").strip().casefold() local = email.split("@", 1)[0] return { token for token in re.split(r"[^0-9a-zA-ZÀ-ÿ]+", local) if len(token) >= 3 and token not in _IDENTITY_STOPWORDS } def _identity_overlaps(left, right) -> bool: left_norm = _norm_identity(left) right_norm = _norm_identity(right) # Company names frequently appear once as a short campaign hint # ("CARPINTARIA AVELEIRAS") and once as a full fiscal name # ("CARPINTARIA AVELEIRAS, UNIPESSOAL, LDA"). Treat substring # matches as safe before falling back to token overlap. if left_norm and right_norm and (left_norm in right_norm or right_norm in left_norm): return True left_tokens = _identity_tokens(left) right_tokens = _identity_tokens(right) if not left_tokens or not right_tokens: return False if left_tokens & right_tokens: return True return any(a in b or b in a for a in left_tokens for b in right_tokens) def _task_has_compatible_opportunity_identity(task: dict, hint: str | None = None) -> bool: """Return true when opportunity/document identity matches the process hint. Legacy tasks may have no direct local_customer_id but still carry a valid fiscal identity through the opportunity documents. In that case we must not show the contradictory "cliente fiscal por confirmar" guard. """ hint = str(hint or _task_process_customer_hint(task) or "").strip() names = [ task.get("linked_customer_name"), task.get("opportunity_customer_name"), task.get("customer_name"), ] if not hint: return any(str(v or "").strip() for v in names) return any(_identity_overlaps(value, hint) for value in names if str(value or "").strip()) def _name_matches_email(name, email) -> bool: name_tokens = _identity_tokens(name) email_tokens = _email_tokens(email) if not name_tokens or not email_tokens: return False if name_tokens & email_tokens: return True return any(nt in et or et in nt for nt in name_tokens for et in email_tokens) def _raw_payload_dict(task: dict) -> dict: payload = task.get("raw_payload") if isinstance(payload, dict): return payload if isinstance(payload, str) and payload.strip(): try: data = json.loads(payload) return data if isinstance(data, dict) else {} except Exception: return {} return {} def _payload_get(payload: dict, *path) -> str: cur = payload or {} for part in path: if not isinstance(cur, dict): return "" cur = cur.get(part) return str(cur or "").strip() def _task_sender_name(task: dict) -> str: payload = _raw_payload_dict(task) return ( _payload_get(payload, "sender", "name") or _payload_get(payload, "conversation", "meta", "sender", "name") or str(task.get("sender_name") or "").strip() ) def _task_sender_email(task: dict) -> str: payload = _raw_payload_dict(task) return ( _payload_get(payload, "sender", "email") or _payload_get(payload, "conversation", "meta", "sender", "email") or str(task.get("sender_email") or "").strip() or str(task.get("customer_email") or "").strip() ) def _task_sender_phone(task: dict) -> str: payload = _raw_payload_dict(task) return ( _payload_get(payload, "sender", "phone_number") or _payload_get(payload, "conversation", "meta", "sender", "phone_number") or str(task.get("customer_phone") or "").strip() ) def _task_process_customer_hint(task: dict) -> str: value = str(task.get("opportunity_title") or task.get("message_subject") or "").strip() if not value: return "" if "·" in value: tail = value.rsplit("·", 1)[-1].strip() if tail and not tail.upper().startswith(("ORC.", "S0")): return tail match = re.search(r"\bpara\s+(?:a|o|as|os|à|ao)?\s*(.+)$", value, re.IGNORECASE) if match: candidate = re.sub(r"\s+", " ", match.group(1)).strip(" .:-–—") candidate = re.split(r"\s+(?:de:|from:|enviada:|sent:)", candidate, maxsplit=1, flags=re.IGNORECASE)[0].strip() if 2 <= len(candidate) <= 120: return candidate return "" def _task_identity_context(task: dict) -> dict: hint = _task_process_customer_hint(task) linked_name = str(task.get("linked_customer_name") or "").strip() linked_email = str(task.get("linked_customer_email") or "").strip() opportunity_customer_name = str(task.get("opportunity_customer_name") or "").strip() opportunity_customer_email = str(task.get("opportunity_customer_email") or "").strip() sender_name = _task_sender_name(task) sender_email = _task_sender_email(task) fiscal_unsafe = False process_identity_unsafe = False reason = "" compatible_identity = _task_has_compatible_opportunity_identity(task, hint) if hint and linked_name and not compatible_identity and not _identity_overlaps(linked_name, hint): # A common failure mode is a person/contact being auto-associated as the # fiscal customer while the campaign/process title clearly points to a # different company. This is a UI/task-safety guard: it does not modify # customers, opportunities or tasks. fiscal_unsafe = True reason = "linked_fiscal_customer_mismatch_process_hint" if hint and opportunity_customer_name and not compatible_identity and not _identity_overlaps(opportunity_customer_name, hint): # If a repair script has already detached the fiscal customer, the legacy # opportunity.customer_name can still carry the polluted contact name. # Prefer the process/title hint until the fiscal customer is confirmed. process_identity_unsafe = True reason = reason or "opportunity_customer_mismatch_process_hint" if fiscal_unsafe or process_identity_unsafe: display_name = hint else: display_name = "" for value in ( linked_name, opportunity_customer_name, sender_name, sender_email, linked_email, task.get("opportunity_title"), task.get("contact_id"), task.get("customer_id"), ): text = str(value or "").strip() if text and text.lower() not in {"cliente", "contacto sem identificação"}: display_name = text break if not display_name: display_name = hint or "Cliente" contact_name = sender_name if not contact_name and _name_matches_email(linked_name, sender_email): contact_name = linked_name if not contact_name and _name_matches_email(opportunity_customer_name, sender_email): contact_name = opportunity_customer_name if not contact_name: contact_name = sender_email or opportunity_customer_email or "Contacto por confirmar" return { "display_name": display_name, "process_customer_hint": hint, "fiscal_identity_unsafe": fiscal_unsafe, "process_identity_unsafe": process_identity_unsafe, "identity_unsafe": fiscal_unsafe or process_identity_unsafe, "fiscal_identity_reason": reason, "unsafe_fiscal_name": linked_name, "unsafe_fiscal_email": linked_email, "unsafe_opportunity_customer_name": opportunity_customer_name, "unsafe_opportunity_customer_email": opportunity_customer_email, "contact_name": contact_name, "contact_email": sender_email or opportunity_customer_email or linked_email, "contact_phone": _task_sender_phone(task), } def _safe_fiscal_customer_for_task(task: dict, safe_customer_id: str) -> dict | None: ctx = _task_identity_context(task) if ctx.get("identity_unsafe") and not _task_has_compatible_opportunity_identity(task, ctx.get("process_customer_hint")): return None name = task.get("linked_customer_name") or task.get("opportunity_customer_name") or task.get("customer_name") email = task.get("linked_customer_email") or task.get("opportunity_customer_email") or task.get("customer_email") if safe_customer_id or name: return { "id": safe_customer_id, "name": name, "email": email, "tax_id": task.get("linked_customer_tax_id"), "street_name": task.get("linked_customer_street_name"), "postal_zone": task.get("linked_customer_postal_zone"), "city_name": task.get("linked_customer_city_name"), "phone": task.get("linked_customer_phone"), } return None def _task_identity_warning_html(task: dict) -> str: ctx = _task_identity_context(task) if not ctx.get("identity_unsafe") or _task_has_compatible_opportunity_identity(task, ctx.get("process_customer_hint")): return "" hint = ctx.get("process_customer_hint") or "processo atual" linked = ctx.get("unsafe_fiscal_name") or ctx.get("unsafe_opportunity_customer_name") or "cliente/contacto associado" return f'''
Cliente fiscal por confirmar.
O processo parece ser {esc(hint)}, mas a task está ligada a {esc(linked)}. Não emitir orçamento ou fatura com esta associação sem validar/corrigir o cliente fiscal.
''' def _task_public_domain_identity_context(task: dict) -> dict: """Detect misleading fiscal/contact email matches on public domains. Public provider domains are communication channels, not company identity. `epotencia.geral@sapo.pt` and `casa-figueiredo@sapo.pt` must therefore never be treated as matching evidence just because both end in sapo.pt. """ fiscal_email = str( task.get("linked_customer_email") or task.get("opportunity_customer_email") or "" ).strip() contact_email = _task_sender_email(task) fiscal_domain = _email_domain_for_identity(fiscal_email) contact_domain = _email_domain_for_identity(contact_email) if not fiscal_email or not contact_email or not fiscal_domain or not contact_domain: return {"warning": False} if fiscal_email.casefold() == contact_email.casefold(): return {"warning": False} fiscal_public = fiscal_domain in PUBLIC_CONTACT_EMAIL_DOMAINS contact_public = contact_domain in PUBLIC_CONTACT_EMAIL_DOMAINS same_public_domain = fiscal_public and contact_public and fiscal_domain == contact_domain if not same_public_domain: return {"warning": False} return { "warning": True, "fiscal_email": fiscal_email, "contact_email": contact_email, "domain": fiscal_domain, "same_local_part": _email_local_for_identity(fiscal_email) == _email_local_for_identity(contact_email), } def _task_public_domain_identity_warning_html(task: dict) -> str: ctx = _task_public_domain_identity_context(task) if not ctx.get("warning"): return "" return f'''
Domínio público não confirma identidade.
O email fiscal {esc(ctx.get('fiscal_email'))} e o contacto Chatwoot {esc(ctx.get('contact_email'))} usam {esc(ctx.get('domain'))}. Este domínio é público/ISP, por isso deve servir apenas como canal de contacto e não como prova de que pertencem à mesma empresa.
''' def _apply_identity_context_to_preparation(prep_vm: dict, task: dict) -> dict: ctx = _task_identity_context(task) if not ctx.get("identity_unsafe") or _task_has_compatible_opportunity_identity(task, ctx.get("process_customer_hint")): return prep_vm fixed = dict(prep_vm or {}) confirmed = [] for item in fixed.get("confirmed_fields") or []: if not isinstance(item, dict): continue label = str(item.get("label") or "") if label == "Empresa": value = ctx.get("process_customer_hint") or ctx.get("display_name") or item.get("value") confirmed.append({"label": label, "value": f"{value} (por confirmar)"}) elif label == "Email": confirmed.append({"label": label, "value": ctx.get("contact_email") or item.get("value")}) elif label == "NIF": # NIF belongs to the unsafe fiscal customer, so do not display it as # confirmed for this process. continue else: confirmed.append(item) fixed["confirmed_fields"] = confirmed return fixed COMMUNICATION_ACTIONS = {"SEND_INFO", "SEND_QUOTE", "SEND_PROFORMA", "SEND_INVOICE", "FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA", "FOLLOW_UP_PAYMENT", "FOLLOW_UP_CUSTOMER_REVIEW", "FOLLOW_UP_GENERIC"} def _task_display_name(task: dict) -> str: return str(_task_identity_context(task).get("display_name") or "Cliente") def _task_email(task: dict) -> str: ctx = _task_identity_context(task) for value in ( ctx.get("contact_email"), task.get("opportunity_customer_email"), task.get("customer_email"), task.get("linked_customer_email"), ): text = str(value or "").strip() if "@" in text: return text return "" def _task_opportunity_navigation_html(task: dict, opportunity_id: str, *, small: bool = False) -> str: """Return a useful opportunity navigation control for task detail pages. Some support/commercial tasks are created from Chatwoot/email before an opportunity is explicitly linked. In that case, do not leave the operator without navigation: provide a read-only search link prefilled with the best email/name/subject hint so the opportunity can be found or linked manually. """ btn_class = "btn btn-sm btn-outline-primary" if small else "btn btn-outline-primary" opportunity_id = str(opportunity_id or "").strip() if opportunity_id and is_uuid_text(opportunity_id): return f'Ver oportunidade' query = ( _safe_search_query(_task_email(task)) or _safe_search_query(_task_display_name(task)) or _safe_search_query(task.get("message_subject")) or "" ) href = "/opportunities" + (f"?q={quote(query)}" if query else "") return ( f'Procurar oportunidade' 'Sem oportunidade ligada diretamente a esta tarefa.' ) def _task_contact_person(task: dict) -> dict: try: return resolve_reply_recipient(task) or {} except Exception: return {} def _task_contact_person_notice_html(task: dict) -> str: recipient = _task_contact_person(task) name = str(recipient.get("person_name") or "").strip() if not name: return "" first = str(recipient.get("person_first_name") or "").strip() source = str(recipient.get("greeting_source") or "").strip() confidence = str(recipient.get("person_confidence") or "").strip() greeting = str(recipient.get("preferred_greeting") or "").strip() bits = [f"Pessoa de contacto: {esc(name)}"] if first and first != name: bits.append(f"primeiro nome: {esc(first)}") if greeting: bits.append(f"saudação IA: {esc(greeting)}") if source: bits.append(f"origem: {esc(source)}{(' · confiança ' + esc(confidence)) if confidence else ''}") return '
' + ' · '.join(bits) + '
' def _task_has_channel(task: dict) -> bool: return bool(_task_email(task) or _task_effective_conversation_id(task)) def _task_effective_conversation_id(task: dict) -> str: direct = str(task.get("conversation_id") or task.get("opportunity_conversation_id") or "").strip() if direct: return direct payload = task.get("raw_payload") or {} if isinstance(payload, str): try: payload = json.loads(payload) except Exception: payload = {} if isinstance(payload, dict): for path in (("conversation_id",), ("conversation", "id"), ("conversation", "display_id")): cur = payload for key in path: cur = cur.get(key) if isinstance(cur, dict) else None if cur: return str(cur).strip() return "" def _customer_send_template_code_for_task(task: dict, template_code: str, message_body: str = "") -> str: code = str(template_code or "").strip().upper() action = str(task.get("action_code") or "").strip().upper() body = str(message_body or "") body_low = body.lower() if code in {"MANUAL_REVIEW_REQUIRED", "INTERNAL_BOUNCE_EMAIL", "INTERNAL_AUTO_REPLY"} and action in {"SEND_INFO", "SEND_QUOTE", "FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA", "FOLLOW_UP_PAYMENT", "FOLLOW_UP_CUSTOMER_REVIEW", "FOLLOW_UP_GENERIC", "SUPPORT"}: if body.strip() and not any(marker in body_low for marker in ("triagem interna", "não deve ser enviado ao cliente", "nao deve ser enviado ao cliente", "rever manualmente")): if action == "SEND_INFO": return "SEND_INFO_EQUIPMENT_LIST" if action == "SUPPORT": return "ACK_SUPPORT_RECEIVED" return action return code def _communication_channel_notice_html(task: dict) -> str: action_code = str(task.get("action_code") or "").upper() if action_code not in COMMUNICATION_ACTIONS: return "" email = _task_email(task) conversation_id = _task_effective_conversation_id(task) if email or conversation_id: bits = [] if email: bits.append(f"Email disponível: {esc(email)}") if conversation_id: bits.append(f"Chatwoot: conversa #{esc(conversation_id)}") return '
Canal de comunicação: ' + ' · '.join(bits) + '
' return """
Sem canal de comunicação visível.
Esta tarefa pede contacto com o cliente, mas não existe email nem conversa Chatwoot associada. Se o pedido veio por WhatsApp, telefone ou outro canal externo, regista o envio manualmente no bloco Concluir.
""" def _task_has_available_document_for_action(task: dict, action_code: str) -> bool: """Return True when a document already exists and can be referenced as sent. Missing fiscal/contact fields must block issuing or automatic sending, but they must not prevent the operator from closing a task after sending an already-created document through WhatsApp, phone or another external channel. """ code = str(action_code or "").upper() docs = _available_docs_for_task_safe(task) if not docs: return False if code == "SEND_INVOICE": return any(str(doc.get("document_kind") or "").lower() == "invoice" for doc in docs) if code in {"SEND_QUOTE", "SEND_PROFORMA"}: return any(str(doc.get("document_kind") or "").lower() in {"quotation", "proforma", "invoice"} for doc in docs) return bool(docs) def _external_channel_completion_note(action_code: str, channel: str) -> str: code = str(action_code or "").upper() channel_label = str(channel or "canal externo").strip() or "canal externo" if code == "SEND_PROFORMA": return f"Orçamento para pagamento enviado ao cliente por {channel_label}." if code == "SEND_QUOTE": return f"Orçamento/proposta enviado ao cliente por {channel_label}." if code == "SEND_INVOICE": return f"Fatura enviada ao cliente por {channel_label}." if code.startswith("FOLLOW_UP_"): return f"Follow-up/contacto feito por {channel_label}." return f"Contacto tratado pelo operador por {channel_label}." def _external_channel_completion_form_html(task_id: str, task: dict, action_code: str, status: str, return_to_hidden: str) -> str: """Render explicit external-channel completion for WhatsApp/manual flows.""" code = str(action_code or "").upper() if str(status or "").lower() != "pending" or code not in COMMUNICATION_ACTIONS: return "" if _task_has_channel(task): return "" has_doc = _task_has_available_document_for_action(task, code) doc_hint = "Documento/anexo associado encontrado; será registado como enviado externamente." if has_doc else "Sem documento associado visível; usa apenas se o contacto já foi tratado fora do ClientFlow." default_note = _external_channel_completion_note(code, "WhatsApp") return f'''
Canal externo
Sem email/Chatwoot nesta tarefa. Usa isto quando já enviaste por WhatsApp, telefone, presencialmente ou outro canal externo. {esc(doc_hint)}
{return_to_hidden}
''' def _context_task_html(task: dict, request_text: str, *, is_follow_up: bool) -> str: text = str(request_text or "").strip() if not is_follow_up: return f'
{esc(text)}
' marker = "Criado por backfill" internal = "" visible = text if marker in text: before, after = text.split(marker, 1) visible = before.strip() internal = marker + after.strip() if not visible: visible = task_next_action_text(task) or str(task.get("action") or "") internal_html = "" if internal: internal_html = f"""
Ver contexto interno
{esc(internal)}
""" return f"""
Objetivo operacional
{esc(visible)}
{internal_html} """ def _document_label_from_doc(doc: dict) -> str: label = str(doc.get("label") or "").strip() if label: return label number = str(doc.get("document_number") or doc.get("external_id") or doc.get("id") or "documento") kind = str(doc.get("document_kind") or "documento") amount = doc.get("total_amount") or doc.get("amount") or "" currency = str(doc.get("currency") or "EUR") suffix = f" · {amount} {currency}" if amount else "" return f"{kind} {number}{suffix}" def _is_orc_quotation_doc(doc: dict) -> bool: kind = str(doc.get("document_kind") or "").lower() number = str(doc.get("document_number") or doc.get("external_id") or "").upper() system = str(doc.get("system") or "jasmin").lower() return kind == "quotation" and system == "jasmin" and (number.startswith("ORC.") or number.startswith("ORC")) def _document_label_for_task(doc: dict, action_code: str) -> str: if str(action_code or "").upper() == "SEND_PROFORMA" and _is_orc_quotation_doc(doc): number = str(doc.get("document_number") or doc.get("external_id") or "ORC") amount = doc.get("total_amount") or doc.get("amount") or "" currency = str(doc.get("currency") or "EUR") suffix = f" · {amount} {currency}" if amount else "" return f"Orçamento para pagamento {number}{suffix}" return _document_label_from_doc(doc) def _document_kind_label_for_task(doc: dict, action_code: str) -> str: kind = str(doc.get("document_kind") or "").lower() if str(action_code or "").upper() == "SEND_PROFORMA" and _is_orc_quotation_doc(doc): return "orçamento para pagamento · ORC Jasmin" return kind or "documento" def _available_docs_for_task_safe(task: dict) -> list[dict]: try: from app.reply_assistant_service import available_documents_for_task return list(available_documents_for_task(task) or []) except Exception: return [] def _default_doc_ids_for_followup(action_code: str, docs: list[dict]) -> list[str]: if not docs: return [] expected = { "FOLLOW_UP_QUOTE": {"quotation"}, "FOLLOW_UP_PROFORMA": {"proforma", "invoice", "quotation"}, "FOLLOW_UP_PAYMENT": {"proforma", "invoice", "quotation"}, }.get(str(action_code or "").upper(), {"quotation"} if str(action_code or "").upper() == "SEND_PROFORMA" else set()) matching = [doc for doc in docs if str(doc.get("document_kind") or "").lower() in expected] chosen = matching or docs return [str(chosen[0].get("id"))] if chosen and chosen[0].get("id") else [] def _effective_selected_doc_ids_for_task(task: dict, selected_ids: list[str] | None = None) -> list[str]: """Return persisted/posted document ids, falling back to the UI default. v1.5.39: older drafts saved before attachment persistence can render a selected ORC/invoice in compact mode via default selection while posting no hidden selected_document_ids. That made revision/send validation think no document was selected. Centralise the fallback so UI, revision and send use the same effective selection. """ ids = [str(x).strip() for x in (selected_ids or []) if str(x or "").strip()] if ids: return ids docs = _available_docs_for_task_safe(task) return _default_doc_ids_for_followup(str(task.get("action_code") or ""), docs) def _followup_documents_picker_html(task: dict, selected_ids: list[str] | None = None, *, compact: bool = False) -> str: docs = _available_docs_for_task_safe(task) if not docs: return "" selected = set(str(x) for x in (selected_ids or [])) if not selected: selected = set(_default_doc_ids_for_followup(str(task.get("action_code") or ""), docs)) warning = "" if len(docs) > 1: warning = '
Vários documentos ligados. Confirma qual deve ser usado no rascunho antes de gerar/enviar.
' rows = [] for doc in docs: doc_id = str(doc.get("id") or "") checked = "checked" if doc_id in selected else "" kind = str(doc.get("document_kind") or "").lower() label = _document_label_for_task(doc, str(task.get("action_code") or "")) kind_label = _document_kind_label_for_task(doc, str(task.get("action_code") or "")) pdf_ok = bool(doc.get("pdf_available") or doc.get("pdf_supported")) requires_attachment = kind in {"invoice", "quotation", "proforma"} status_text = str(doc.get("attachment_status") or ("PDF/anexo disponível" if pdf_ok else "PDF/anexo não disponível" if requires_attachment else kind or "documento")) status_badge = ( '✓ PDF/anexo disponível' if pdf_ok else '⚠ PDF/anexo não disponível' if requires_attachment else f'{esc(kind or "documento")}' ) if compact: if selected and doc_id not in selected: continue rows.append( '
' f'{esc(label)}
' f'{esc(kind_label)}
{status_badge}✓ selecionado para envio
' '
' ) else: rows.append(f""" """) if str(task.get("action_code") or "").upper() == "SEND_PROFORMA": title = "Documento de orçamento/anexo" if compact else "Documentos de orçamento/anexos da oportunidade" else: title = "Documento/anexo usado no rascunho" if compact else "Documentos/anexos da oportunidade" return f"""
{esc(title)}
{warning}
{''.join(rows) or '
Sem documento selecionado.
'}
""" def _task_due_chip(task: dict) -> str: if not task.get("due_at"): return "" label = "Follow-up" if _is_follow_up_task(task) else "Vence" return f'{esc(label)}: {esc(fmt_dt(task.get("due_at")))}' def _follow_up_controls_html(task_id: str, task: dict, return_to_hidden: str = "") -> str: if not _is_follow_up_task(task): return "" metadata = _metadata_dict(task.get("metadata")) action_code = str(task.get("action_code") or "") suggested = str( metadata.get("suggested_customer_message") or metadata.get("suggested_message") or metadata.get("followup_draft") or _fallback_follow_up_message(action_code) ).strip() reason = str(metadata.get("follow_up_reason") or "").strip() or action_code due_line = f'
Vence em: {esc(fmt_dt(task.get("due_at")))} · motivo: {esc(reason)}
' if task.get("due_at") else f'
Motivo: {esc(reason)}
' return f'''

Follow-up semi-automático

O sistema agenda e sugere; o operador valida e envia. Nada é enviado automaticamente.
manual/semi-auto
{due_line}
{return_to_hidden}
{return_to_hidden}
''' def _task_has_ready_fiscal_customer(action_code: str, fiscal_customer: dict | None, fiscal_missing_labels: list[str]) -> bool: """True when the linked fiscal customer should override stale preparation gaps.""" return str(action_code or "").upper() in DOCUMENT_TASK_ACTIONS and bool(fiscal_customer) and not fiscal_missing_labels def _is_fiscal_preparation_missing_item(item: dict) -> bool: """Detect preparation missing fields that are satisfied by a linked fiscal customer. Preparations are snapshots from the original message extraction. After the operator enriches/links a fiscal customer, fields such as customer.company, billing.tax_id or billing.billing_address must no longer drive the visible task state. Product/shipment gaps are intentionally not matched here. """ raw = str((item or {}).get("raw") or "").strip().lower() label = str((item or {}).get("label") or "").strip().lower() text = f"{raw} {label}" fiscal_tokens = ( "customer.company", "customer · company", "company", "customer.email", "customer · email", "customer.phone", "customer · phone", "billing.billing_name", "billing · billing name", "billing name", "billing.tax_id", "nif", "tax id", "billing.billing_address", "morada fiscal", "billing address", "billing.billing_email", "email faturação", "email de faturação", ) return any(token in text for token in fiscal_tokens) def _effective_missing_items_for_task( *, action_code: str, prep_vm: dict, fiscal_customer: dict | None, fiscal_missing_labels: list[str], ) -> list[dict]: """Merge preparation gaps with current fiscal readiness without contradictions.""" missing_items = [item for item in list(prep_vm.get("missing_fields") or []) if isinstance(item, dict)] if _task_has_ready_fiscal_customer(action_code, fiscal_customer, fiscal_missing_labels): missing_items = [item for item in missing_items if not _is_fiscal_preparation_missing_item(item)] else: if fiscal_customer: # Remove stale, generic preparation gaps that contradict the current # task/opportunity context. Keep concrete missing fields below # (morada, CP, localidade, email de faturação, etc.). stale_generic = ( "cliente fiscal por confirmar", "cliente fiscal associado", "cliente fiscal: cliente fiscal por confirmar", "cliente fiscal: cliente fiscal associado", ) missing_items = [ item for item in missing_items if str(item.get("label") or "").strip().lower() not in stale_generic ] existing_missing_labels = {str(item.get("label") or "") for item in missing_items} for label in fiscal_missing_labels: fiscal_label = f"Cliente fiscal: {label}" if fiscal_label not in existing_missing_labels: missing_items.append({"label": fiscal_label}) return missing_items def _effective_primary_action_for_task(action_code: str, prep_vm: dict, missing_items: list[dict]) -> str: primary = str(prep_vm.get("primary_action") or "").strip() if not missing_items and primary.lower().startswith("pedir dados em falta"): return primary_action_label(action_code, fallback=action_label(action_code)) return primary or primary_action_label(action_code, fallback=action_label(action_code)) def _ready_document_reply_for_task(task: dict, action_code: str) -> str: customer_name = str(task.get("customer_name") or task.get("linked_customer_name") or "").strip() first_name = customer_name.split()[0] if customer_name else "" greeting = f"Olá {first_name}," if first_name and first_name.lower() not in ["cliente", "desconhecido"] else "Olá," closing = "Obrigado,\nEquipa BLIF" if str(action_code or "").upper() == "SEND_PROFORMA": return f"""{greeting} Segue em anexo o orçamento para pagamento solicitado. Após pagamento, envie por favor o comprovativo para confirmação e seguimento da encomenda. {closing}""" if str(action_code or "").upper() == "SEND_INVOICE": return f"""{greeting} Segue em anexo a fatura solicitada. Qualquer questão, estamos ao dispor. {closing}""" if str(action_code or "").upper() == "SEND_QUOTE": return f"""{greeting} Segue em anexo a proposta solicitada. Qualquer questão ou ajuste necessário, estamos ao dispor. {closing}""" return suggested_reply_for_task(task) def _effective_suggested_reply_for_task(task: dict, action_code: str, prep_vm: dict, missing_items: list[dict]) -> str: suggested = str(prep_vm.get("suggested_reply") or "").strip() if not missing_items and str(action_code or "").upper() in DOCUMENT_TASK_ACTIONS: # Avoid showing stale preparation text asking for fiscal data that is now complete. stale_markers = ("dados de fatur", "dados para fatur", "nif", "morada fiscal", "email de fatur") if not suggested or any(marker in suggested.lower() for marker in stale_markers): return _ready_document_reply_for_task(task, action_code) return suggested or suggested_reply_for_task(task) def _tasks_for_filters(status: Optional[str] = "pending", route: Optional[str] = None, view: Optional[str] = None, q: Optional[str] = None, limit: int = 200): effective_status = status or "pending" status_filter = None if effective_status == "all" else effective_status q = _safe_search_query(q) tasks = list_tasks(status=status_filter, route=route, q=q, limit=limit) if view == "overdue": tasks = [task for task in tasks if is_task_overdue(task)] elif view == "today": tasks = [task for task in tasks if is_task_today(task)] elif view == "followups": tasks = [task for task in tasks if _is_follow_up_task(task)] elif view == "followups_due": tasks = [task for task in tasks if _is_follow_up_task(task) and is_task_overdue(task)] return tasks def render_tasks_list_partial(tasks: list[dict], return_to: str = "/tasks?status=pending") -> str: rows = "" for task in tasks: task_id = str(task.get("id") or "") task_url = _task_href(task_id, return_to) action_code = str(task.get("action_code") or "") customer = _task_display_name(task) subject = compact_text(task.get("message_subject") or "", 80) detail = compact_text(task_next_action_text(task), 150) opp_id = opportunity_id_from_task(task) source_system = str(task.get("source_system") or "").strip() conversation_id = str(task.get("conversation_id") or "").strip() source_line = "" if source_system or conversation_id: source_bits = [] if source_system: source_bits.append(source_system.capitalize()) if conversation_id: source_bits.append(f"conversa #{conversation_id}") source_line = '
Origem: ' + esc(" · ".join(source_bits)) + '
' opp_line = f'
Abrir oportunidade
' if opp_id else '
Sem oportunidade associada
' rows += f''' {task_priority_chip(task)}
task
{esc(customer)}
{esc(subject or '—')}
{source_line} {opp_line} {route_badge(task.get('route'))} {status_badge(task.get('status'))}
{sla_badge_html(task)}{_task_due_chip(task)}
{esc(action_label(action_code))}
{esc(detail or '—')}
Abrir{chatwoot_button(task.get('conversation_id'), 'Chatwoot') if str(task.get('conversation_id') or '').strip() else ''}
''' if not rows: rows = 'Sem tarefas para estes filtros.' return f'''
{len(tasks)} resultado(s) A atualizar…
{rows}
PrioridadeCliente / oportunidadeFilaEstadoPróxima ação
''' @router.get("/tasks/partials/list", response_class=HTMLResponse) async def tasks_list_partial(status: Optional[str] = "pending", route: Optional[str] = None, view: Optional[str] = None, q: Optional[str] = None, limit: int = 200): tasks = _tasks_for_filters(status=status, route=route, view=view, q=q, limit=limit) return_to = _tasks_list_return_to(status=status, route=route, view=view, q=q, limit=limit) return HTMLResponse(render_tasks_list_partial(tasks, return_to=return_to)) @router.get("/tasks", response_class=HTMLResponse) async def tasks_page( status: Optional[str] = "pending", route: Optional[str] = None, view: Optional[str] = None, q: Optional[str] = None, limit: int = 200, ): effective_status = status or "pending" tasks = _tasks_for_filters(status=status, route=route, view=view, q=q, limit=limit) return_to = _tasks_list_return_to(status=effective_status, route=route, view=view, q=q, limit=limit) metrics = get_admin_dashboard_metrics() def n(key): return int(metrics.get(key) or 0) active_key = view if view else (route if route else effective_status) tabs = [ ("pending", "Pendentes", n("pending_total"), "/tasks?status=pending"), ("overdue", "Atrasadas", n("overdue_total"), "/tasks?status=pending&view=overdue"), ("followups", "Follow-ups", n("pending_followups"), "/tasks?status=pending&view=followups"), ("followups_due", "Follow-ups vencidos", n("due_followups"), "/tasks?status=pending&view=followups_due"), ("vendas", "Vendas", n("pending_vendas"), "/tasks?status=pending&route=vendas"), ("financeiro", "Financeiro", n("pending_financeiro"), "/tasks?status=pending&route=financeiro"), ("operacoes", "Operações", n("pending_operacoes"), "/tasks?status=pending&route=operacoes"), ("rever", "Revisão", n("pending_rever"), "/tasks?status=pending&route=rever"), ("all", "Todas", n("pending_total") + n("done_total") + n("skipped_total") + n("failed_total"), "/tasks?status=all"), ] tab_html = "".join( f'{esc(label)} {count}' for key, label, count, href in tabs ) selected = lambda value, current: "selected" if str(value or "") == str(current or "") else "" cards = "" for task in tasks: task_id = str(task.get("id") or "") task_url = _task_href(task_id, return_to) action_code = str(task.get("action_code") or "") customer = _task_display_name(task) subject = compact_text(task.get("message_subject") or "Sem assunto", 80) next_action = task_next_action_text(task) message = compact_text(task.get("request_text") or task.get("note") or "", 130) opp_id = opportunity_id_from_task(task) opp_html = f'Oportunidade' if opp_id else 'Sem oportunidade' cards += f'''
{esc(action_label(action_code))}

{esc(customer)}

{task_priority_chip(task)}{status_badge(task.get('status'))}
Próxima ação {esc(next_action)}
{route_badge(task.get('route'))} {sla_badge_html(task)} {_task_due_chip(task)} {esc(fmt_dt(task.get('updated_at') or task.get('created_at')))}

{esc(message or subject or '—')}

{opp_html} Abrir {chatwoot_button(task.get('conversation_id'), 'Chatwoot') if str(task.get('conversation_id') or '').strip() else ''}
''' if not cards: cards = '
Sem tarefas para estes filtros.
' table_rows = "" for task in tasks: task_id = str(task.get("id") or "") task_url = _task_href(task_id, return_to) action_code = str(task.get("action_code") or "") customer = _task_display_name(task) subject = compact_text(task.get("message_subject") or "", 80) detail = compact_text(task_next_action_text(task), 150) opp_id = opportunity_id_from_task(task) source_system = str(task.get("source_system") or "").strip() conversation_id = str(task.get("conversation_id") or "").strip() source_line = "" if source_system or conversation_id: source_bits = [] if source_system: source_bits.append(source_system.capitalize()) if conversation_id: source_bits.append(f"conversa #{conversation_id}") source_line = '
Origem: ' + esc(" · ".join(source_bits)) + '
' opp_line = f'
Abrir oportunidade
' if opp_id else '
Sem oportunidade associada
' table_rows += f''' {task_priority_chip(task)}
task
{esc(customer)}
{esc(subject or '—')}
{source_line} {opp_line} {route_badge(task.get('route'))} {status_badge(task.get('status'))}
{sla_badge_html(task)}{_task_due_chip(task)}
{esc(action_label(action_code))}
{esc(detail or '—')}
Abrir{chatwoot_button(task.get('conversation_id'), 'Chatwoot') if str(task.get('conversation_id') or '').strip() else ''}
''' if not table_rows: table_rows = 'Sem tarefas para estes filtros.' body = f'''
Pendentes{n('pending_total')}precisam de ação Atrasadas{n('overdue_total')}prioridade máxima Financeiro{n('pending_financeiro')}pagamentos/faturas Operações{n('pending_operacoes')}envios/recolhas Follow-ups{n('pending_followups')}semi-automáticos
Limpar

Lista de tarefas abertas

Mesma leitura da Fila operacional: prioridade, cliente/oportunidade, fila, estado e próxima ação. Esta página mostra apenas tasks humanas.
{len(tasks)} resultado(s)
{render_tasks_list_partial(tasks, return_to=return_to)} ''' return layout("Tarefas", "Fila operacional com foco na próxima ação", body, "tasks") def _reply_assistant_panel_html(task_id: str, state: dict | None = None, notice: str = "", error: str = "") -> str: """Render the editable reply assistant panel for a task.""" try: from app.message_templates import default_template_for_action, list_templates_for_action from app.reply_assistant_service import get_reply_panel_state task = get_task_detail(task_id) if not task: return '
Tarefa não encontrada.
' if state is None: state = get_reply_panel_state(task_id) action_code = str(task.get("action_code") or "") current_template_code = str((state.get("template") or {}).get("code") or default_template_for_action(action_code).code) templates = list_templates_for_action(action_code) template_options = "".join( f'' for tpl in templates ) selected_ids = {str(item) for item in (state.get("selected_document_ids") or [])} documents = state.get("documents") or [] doc_checks = "" for doc in documents: doc_id = str(doc.get("id") or "") checked = "checked" if doc_id in selected_ids else "" disabled_hint = "" if doc.get("pdf_supported") else 'PDF automático indisponível' label = doc.get('label') or doc.get('document_number') or doc_id system = str(doc.get('system') or '') kind = str(doc.get('document_kind') or '') doc_checks += ( f'' ) if not doc_checks: doc_checks = '
Sem documentos/anexos associados à oportunidade.
' warnings = state.get("warnings") or [] blockers = state.get("blockers") or [] warning_html = "".join(f'
{esc(item)}
' for item in warnings) blocker_html = "".join(f'
{esc(item)}
' for item in blockers) notice_html = f'
{esc(notice)}
' if notice else "" error_html = f'
{esc(error)}
' if error else "" draft_id = str(state.get("draft_id") or "") message_body = str(state.get("message_body") or "") selected_hidden = "".join(f'' for doc_id in selected_ids) # The legacy eager reply panel also renders the send form. Keep the # operator instruction hidden field defined even when the current state # does not carry one, otherwise support/reply tasks can show # "Assistente indisponível: name 'instruction_hidden' is not defined". operator_instruction = str(state.get("operator_instruction") or state.get("instruction") or "") instruction_hidden = f'' knowledge = state.get("business_knowledge") or {} topics = knowledge.get("topics") or [] knowledge_html = "" if topics: topic_items = "" for topic in topics[:3]: facts = topic.get("facts") or [] facts_html = "".join(f'
  • {esc(str(fact))}
  • ' for fact in facts[:3]) topic_items += f'''
    {esc(topic.get('title') or topic.get('id') or 'Conhecimento BLIF')}
    {esc(topic.get('summary') or '')}
    ''' reply_type = str(knowledge.get("reply_type") or "") knowledge_html = f'''
    Conhecimento BLIF usado · {esc(reply_type or 'resposta')}
    {topic_items}
    ''' intent_gate = state.get("intent_gate") or {} intent_html = "" if intent_gate: reasons = "; ".join(str(item) for item in (intent_gate.get("reasons") or [])) label = str(intent_gate.get("label") or intent_gate.get("category") or "Triagem") category = str(intent_gate.get("category") or "") badge = "text-bg-warning" if intent_gate.get("requires_manual_review") else "text-bg-info" intent_html = f"""
    Diagnóstico da IA · triagem {esc(category)}
    {esc(label)}{(" · " + esc(reasons)) if reasons else ""}
    """ email_agent = state.get("email_agent") or {} email_agent_html = "" if email_agent: if email_agent.get("enabled"): status = "usado" if email_agent.get("used") else "fallback" intent = str(email_agent.get("intencao") or "") confidence = str(email_agent.get("nivel_confianca") or "") review = bool(email_agent.get("precisa_revisao_humana")) review_badge = 'revisão humana' if review else 'rascunho simples' details = " · ".join(part for part in [intent, confidence] if part) email_agent_html = f'''
    Agente de email OpenAI/file_search {esc(status)}{review_badge}
    {esc(details or str(email_agent.get('error') or ''))}
    ''' elif email_agent.get("error"): email_agent_html = f'
    Agente de email OpenAI: fallback · {esc(str(email_agent.get("error") or ""))}
    ' llm = state.get("llm") or {} llm_html = "" if llm.get("enabled"): status = "usado" if llm.get("used") else "fallback" detail = str(llm.get("error") or llm.get("intent") or "") llm_html = f'
    LLM OpenRouter: {esc(status)}{(" · " + esc(detail)) if detail else ""}
    ' elif topics: llm_html = '
    LLM OpenRouter: desativado; rascunho gerado por conhecimento BLIF determinístico.
    ' return f'''

    Resposta ao cliente

    Usa conhecimento BLIF, modelos comerciais e apenas anexos ligados à oportunidade desta tarefa.
    v4928.1.5.14
    {notice_html}{error_html}{blocker_html}{warning_html} {intent_html} {knowledge_html} {email_agent_html} {llm_html}
    Anexos da oportunidade
    {doc_checks}
    {selected_hidden} {instruction_hidden}
    A processar…
    ''' except Exception as exc: return f'

    Resposta ao cliente

    Assistente indisponível: {esc(str(exc))}
    ' def _latest_message_draft_for_task(task_id: str) -> dict: """Load the latest persisted editable draft without invoking the LLM. This keeps the task detail page fast while making generated drafts survive a browser refresh. The expensive OpenAI/file_search generation still happens only in /tasks/{task_id}/reply-draft. """ if not is_uuid_text(str(task_id or "")): return {} try: from sqlalchemy import text as sa_text from app.db import engine with engine.begin() as conn: row = conn.execute(sa_text(""" SELECT id::text AS id, task_id::text AS task_id, template_code, message_body, selected_document_ids::text AS selected_document_ids, operator_instruction, generated_by, status, updated_at FROM message_drafts WHERE task_id = CAST(:task_id AS UUID) AND status = 'draft' ORDER BY updated_at DESC NULLS LAST, created_at DESC LIMIT 1 """), {"task_id": task_id}).mappings().first() if not row: return {} selected_raw = row.get("selected_document_ids") or "[]" try: selected_ids = json.loads(selected_raw) if isinstance(selected_raw, str) else list(selected_raw or []) except Exception: selected_ids = [] return { "draft_id": str(row.get("id") or ""), "template_code": str(row.get("template_code") or ""), "message_body": str(row.get("message_body") or ""), "selected_document_ids": [str(item) for item in selected_ids], "operator_instruction": str(row.get("operator_instruction") or ""), "generated_by": str(row.get("generated_by") or ""), "updated_at": str(row.get("updated_at") or ""), } except Exception: return {} NON_COMMUNICATION_TASK_ACTIONS = { "CONFIRM_PAYMENT", "PREPARE_ORDER", "VALIDATE_PHYSICAL_ORDER", "CREATE_SHIPMENT", "REVIEW_RECONSTRUCTED_PROCESS", } def _task_reply_assistant_html(task_id: str, action_code: str) -> str: if str(action_code or "").upper() in NON_COMMUNICATION_TASK_ACTIONS: return "" return _reply_assistant_lazy_panel_html(task_id) def _reply_assistant_lazy_panel_html(task_id: str, notice: str = "") -> str: """Render a lightweight reply panel without calling LLM/vector-store. The full assistant state can be slow because it may call the email reply agent, OpenRouter/OpenAI and knowledge retrieval. Task detail pages should open fast; the expensive generation is triggered only by the operator via /tasks/{task_id}/reply-draft. v4928.1.5.15 also reloads the latest persisted message_draft after refresh without invoking the LLM. """ notice_html = f'
    {esc(notice)}
    ' if notice else "" try: task = get_task_detail(task_id) or {} except Exception: task = {} action_code = str(task.get("action_code") or "") is_follow_up = _is_follow_up_task(action_code) default_template_code = _follow_up_template_for_action(action_code) if is_follow_up else "" panel_title = "Rascunho de follow-up" if is_follow_up else "Resposta ao cliente" generate_label = "Gerar rascunho personalizado com IA" if is_follow_up else "Gerar rascunho" regenerate_label = "Regenerar rascunho personalizado com IA" if is_follow_up else "Regenerar rascunho" mode_badge = "lazy · follow-up · OpenAI · v4928.1.5.24" if is_follow_up else "lazy · v4928.1.5.16" help_text = ( "A tarefa abre sem chamar IA. Este botão chama OpenAI com um prompt específico de follow-up e personaliza a mensagem com dados do cliente, oportunidade, documentos e histórico recente. Nada é enviado automaticamente." if is_follow_up else "A tarefa abre sem chamar IA. Gera o rascunho apenas quando precisares de responder; a geração usa OpenAI e o histórico recente da conversa." ) draft = _latest_message_draft_for_task(task_id) if draft.get("message_body"): draft_id = str(draft.get("draft_id") or "") template_code = str(draft.get("template_code") or "") message_body = str(draft.get("message_body") or "") template_code = _customer_send_template_code_for_task(task, template_code, message_body) send_conversation_id = _task_effective_conversation_id(task) selected_ids = [str(item) for item in (draft.get("selected_document_ids") or [])] selected_ids = _effective_selected_doc_ids_for_task(task, selected_ids) operator_instruction = str(draft.get("operator_instruction") or "") selected_hidden = "".join(f'' for doc_id in selected_ids) instruction_hidden = f'' documents_html = _followup_documents_picker_html(task, selected_ids, compact=True) if is_follow_up else _followup_documents_picker_html(task, selected_ids, compact=True) email_to = _task_email(task) subject_for_email = str(task.get("message_subject") or task.get("opportunity_title") or "Seguimento do processo") email_hidden = f'' updated_at = str(draft.get("updated_at") or "") generated_by = str(draft.get("generated_by") or "") meta_bits = " · ".join(part for part in ["rascunho guardado", generated_by, updated_at[:19]] if part) return f"""

    {esc(panel_title)}

    {esc(meta_bits)}. Podes editar, guardar ou pedir uma correção à IA antes de enviar.
    v4928.1.5.121 · persistente
    {notice_html}
    {selected_hidden} {instruction_hidden} A abertura da tarefa não chama IA; este botão chama OpenAI com regras específicas para follow-up.
    {selected_hidden} {instruction_hidden}
    {selected_hidden} {instruction_hidden} {email_hidden}
    {f'
    Objetivo/instrução do operador
    {esc(operator_instruction) if operator_instruction else "Sem instrução adicional."}
    '} {documents_html}
    {selected_hidden} {instruction_hidden}
    A correção usa o rascunho atual, a instrução do operador, as últimas mensagens da conversa e conhecimento OpenAI/file_search. Não deve inventar preços, URLs nem alterar o objetivo do follow-up.
    {selected_hidden} {instruction_hidden}
    Editar manualmente não envia nada; usa “Guardar rascunho” para persistir antes de sair ou refrescar.
    {f'' if email_to else 'Sem email: associa contacto ou copia a mensagem.'} {f'' if send_conversation_id else 'Sem conversa Chatwoot ligada.'} A processar…
    """ try: from app.message_templates import default_template_for_action, list_templates_for_action templates = list_templates_for_action(action_code) default_template = default_template_code or default_template_for_action(action_code).code template_options = "".join( f'' for tpl in templates ) except Exception: template_options = f'' documents_picker = _followup_documents_picker_html(task) if is_follow_up else _followup_documents_picker_html(task) return f"""

    {esc(panel_title)}

    {esc(help_text)} O operador escolhe o objetivo e os anexos antes de chamar IA.
    {esc(mode_badge)} · composer
    {notice_html}
    A IA deve redigir segundo este objetivo; não deve reinterpretar o próximo passo.
    {documents_picker}
    Não substitui validações: documentos fiscais continuam a exigir cliente fiscal e anexo/documento correto.
    A gerar com IA…
    """ def render_task_detail_partial(task_id: str, notice: str = "", return_to: str = "") -> str: task = get_task_detail(task_id) if not task: return '
    Tarefa não encontrada.
    ' return_to = _safe_return_to(return_to, default="/tasks?status=pending") return_to_hidden = _return_to_hidden(return_to) return_link_html = _return_to_link(return_to) action_code = str(task.get("action_code") or "") status = str(task.get("status") or "") route_name = str(task.get("route") or "") contact_id = str(task.get("contact_id") or "") local_customer_id = str(task.get("linked_customer_id") or "") task_customer_id = str(task.get("customer_id") or "") safe_customer_id = local_customer_id or (task_customer_id if is_uuid_text(task_customer_id) else "") customer = _task_display_name(task) subject = str(task.get("message_subject") or "—") opportunity_id = opportunity_id_from_task(task) next_action = task_next_action_text(task) request_text = task.get("request_text") or task.get("clean_body") or task.get("raw_body") or task.get("note") or "" if len(str(request_text)) > 800: request_text = str(request_text)[:800] + "…" request_text = _safe_task_display_html(str(request_text)) notice_html = f'
    {esc(notice)}
    ' if notice else "" customer_link = f'Ver cliente fiscal' if safe_customer_id else "" contact_line = f'Contacto Chatwoot: {esc(contact_id)}' if contact_id and not safe_customer_id else "" opportunity_link = _task_opportunity_navigation_html(task, opportunity_id, small=True) chatwoot_html = chatwoot_button(_task_effective_conversation_id(task), 'Chatwoot') if _task_effective_conversation_id(task) else '' identity_ctx = _task_identity_context(task) fiscal_customer = _safe_fiscal_customer_for_task(task, safe_customer_id) fiscal_contact_html = fiscal_contact_panel_html( fiscal_customer=fiscal_customer, contact_name=identity_ctx.get("contact_name") or customer, contact_email=identity_ctx.get("contact_email") or _task_email(task), contact_phone=identity_ctx.get("contact_phone") or task.get("customer_phone"), conversation_id=_task_effective_conversation_id(task), contact_id=task.get("contact_id"), customer_href=f"/customers/{esc(safe_customer_id)}" if safe_customer_id and fiscal_customer else "", ) fiscal_missing_labels = fiscal_customer_missing_fields(fiscal_customer) if action_code in DOCUMENT_TASK_ACTIONS else [] if identity_ctx.get("identity_unsafe") and action_code in DOCUMENT_TASK_ACTIONS: if not _task_has_compatible_opportunity_identity(task, identity_ctx.get("process_customer_hint")): fiscal_missing_labels = ["Cliente fiscal por confirmar", *fiscal_missing_labels] customer_link = "" contact_line = f'Contacto Chatwoot: {esc(contact_id)}' if contact_id else "" task_readiness_html = readiness_checklist_html( title="Prontidão mínima antes de documento/envio", missing=fiscal_missing_labels, ok_text="Sem bloqueios fiscais mínimos para esta tarefa.", blocked_text="Corrigir estes dados antes de emitir documento.", ) reply_assistant_html = _task_reply_assistant_html(task_id, action_code) follow_up_controls_html = _follow_up_controls_html(task_id, task, return_to_hidden) is_follow_up = _is_follow_up_task(action_code) context_heading = "Contexto da tarefa" if is_follow_up else "Pedido do cliente" context_html = _context_task_html(task, str(request_text), is_follow_up=is_follow_up) channel_notice_html = _communication_channel_notice_html(task) contact_person_notice_html = _task_contact_person_notice_html(task) if is_follow_up else "" completion_heading = "Marcar follow-up como feito" if is_follow_up else "Concluir" external_completion_html = _external_channel_completion_form_html(task_id, task, action_code, status, return_to_hidden) done_controls = "" if status == "pending": done_note_options = done_note_options_html_for(action_code) or "" done_controls = f'''
    {return_to_hidden}
    {external_completion_html} ''' else: done_controls = f'
    Estado atual: {esc(status)}.
    ' html = f'''
    {return_link_html}A atualizar…
    {notice_html}
    Próxima ação

    {esc(action_label(action_code))}

    {status_badge(status)}{route_badge(route_name)}{esc(action_code or '—')}
    {esc(customer)}
    {esc(subject)}
    {customer_link}{opportunity_link}{chatwoot_html}

    Próxima ação

    {esc(next_action)}
    {route_badge(route_name)}{status_badge(status)}{task_priority_chip(task)}
    {fiscal_contact_html} {task_readiness_html} {_task_identity_warning_html(task)} {_task_opportunity_linking_panel_html(task, return_to)} {_task_public_domain_identity_warning_html(task)} {channel_notice_html} {contact_person_notice_html} {follow_up_controls_html} {reply_assistant_html}

    {esc(context_heading)}

    {context_html}
    ''' return _safe_task_display_html(html) @router.get("/tasks/{task_id}/partials/detail", response_class=HTMLResponse) async def task_detail_partial(task_id: str, return_to: str = ""): if not is_uuid_text(task_id): return _invalid_task_response() return HTMLResponse(render_task_detail_partial(task_id, return_to=return_to)) @router.get("/tasks/{task_id}", response_class=HTMLResponse) async def task_detail_bootstrap_page(task_id: str, return_to: str = ""): if not is_uuid_text(task_id): return _invalid_task_response() task = get_task_detail(task_id) if not task: return HTMLResponse("

    Tarefa não encontrada

    ", status_code=404) return_to = _safe_return_to(return_to, default="/tasks?status=pending") return_to_hidden = _return_to_hidden(return_to) return_link_html = _return_to_link(return_to) action_code = str(task.get("action_code") or "") route_name = str(task.get("route") or "") status = str(task.get("status") or "") conversation_id = str(task.get("conversation_id") or "") contact_id = str(task.get("contact_id") or "") local_customer_id = str(task.get("linked_customer_id") or "") task_customer_id = str(task.get("customer_id") or "") safe_customer_id = local_customer_id or (task_customer_id if is_uuid_text(task_customer_id) else "") customer = _task_display_name(task) customer_email = _task_email(task) customer_phone = str(task.get("customer_phone") or "") subject = str(task.get("message_subject") or "—") opportunity_id = opportunity_id_from_task(task) request_text = ( task.get("request_text") or task.get("clean_body") or task.get("raw_body") or task.get("note") or "" ) if len(str(request_text)) > 1600: request_text = str(request_text)[:1600] + "…" request_text = _safe_task_display_html(str(request_text)) preparation = get_latest_task_preparation(task_id) prep_vm = _apply_identity_context_to_preparation(build_preparation_view_model(task, preparation), task) done_note_options_html = done_note_options_html_for(action_code) or "" public_url = ( getattr(settings, "chatwoot_public_url", "") or getattr(settings, "chatwoot_base_url", "") or "" ).rstrip("/") account_id = getattr(settings, "chatwoot_account_id", "") chatwoot_link = "" if public_url and account_id and conversation_id: href = f"{public_url}/app/accounts/{account_id}/conversations/{conversation_id}" chatwoot_link = f'Abrir Chatwoot ↗' identity_ctx = _task_identity_context(task) fiscal_customer = _safe_fiscal_customer_for_task(task, safe_customer_id) fiscal_contact_html = fiscal_contact_panel_html( fiscal_customer=fiscal_customer, contact_name=identity_ctx.get("contact_name") or customer, contact_email=identity_ctx.get("contact_email") or customer_email, contact_phone=identity_ctx.get("contact_phone") or customer_phone, conversation_id=conversation_id, contact_id=contact_id, customer_href=f"/customers/{esc(safe_customer_id)}" if safe_customer_id and fiscal_customer else "", ) fiscal_missing_labels = fiscal_customer_missing_fields(fiscal_customer) if action_code in DOCUMENT_TASK_ACTIONS else [] if identity_ctx.get("identity_unsafe") and action_code in DOCUMENT_TASK_ACTIONS: if not _task_has_compatible_opportunity_identity(task, identity_ctx.get("process_customer_hint")): fiscal_missing_labels = ["Cliente fiscal por confirmar", *fiscal_missing_labels] fiscal_readiness_html = readiness_checklist_html( title="Prontidão fiscal da tarefa", missing=fiscal_missing_labels, ok_text="Sem bloqueios fiscais mínimos para esta tarefa.", blocked_text="Corrigir estes dados antes de emitir documento.", ) missing_items = _effective_missing_items_for_task( action_code=action_code, prep_vm=prep_vm, fiscal_customer=fiscal_customer, fiscal_missing_labels=fiscal_missing_labels, ) suggested_reply = _safe_task_display_html(_effective_suggested_reply_for_task(task, action_code, prep_vm, missing_items)) primary_action_text = _effective_primary_action_for_task(action_code, prep_vm, missing_items) if missing_items: missing_html = "".join( f'⚠ {esc(item.get("label"))}' for item in missing_items ) else: missing_html = 'Sem dados críticos em falta' confirmed = prep_vm.get("confirmed_fields") or [] confirmed_html = "".join( f"
    {esc(item.get('label'))}{esc(item.get('value'))}
    " for item in confirmed[:8] ) or '
    Ainda não existem dados confirmados pela preparação.
    ' prep_type = str(prep_vm.get("prep_type") or "generic") assistant_buttons = "" if action_code == "SEND_PROFORMA": assistant_buttons += f'
    ' if action_code in {"CONFIRM_PAYMENT", "SUPPORT"}: assistant_buttons += f'
    ' assistant_buttons += f'
    ' if not assistant_buttons: assistant_buttons = '
    Sem assistente específico para esta ação.
    ' external_completion_html = _external_channel_completion_form_html(task_id, task, action_code, status, return_to_hidden) completion_html = "" fiscal_completion_blockers = [ str(item.get("label") or "") for item in missing_items if "cliente fiscal" in str(item.get("label") or "").lower() and any(marker in str(item.get("label") or "").lower() for marker in ("por confirmar", "por associar", "nif divergente", "cliente errado")) ] if status == "pending" and action_code in {"SEND_PROFORMA", "SEND_INVOICE"} and fiscal_completion_blockers: completion_html = '
    Valida a identidade fiscal antes de concluir esta tarefa fiscal.
    ' elif status == "pending": completion_html = f"""
    {return_to_hidden}
    {external_completion_html} """ else: completion_html = f'
    Estado atual: {esc(status)}.
    ' reclassify_options = [ "SEND_INFO", "SEND_QUOTE", "SEND_PROFORMA", "SEND_INVOICE", "CONFIRM_PAYMENT", "SUPPORT", "REMOVE_FROM_LIST", "MARK_NO_INTEREST", "FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA", "FOLLOW_UP_PAYMENT", "FOLLOW_UP_CUSTOMER_REVIEW", "FOLLOW_UP_GENERIC", "IGNORE_SPAM", "REVIEW_MANUALLY", "NO_ACTION", ] reclassify_options_html = "".join( f'' for code in reclassify_options ) task_management_html = f"""

    Corrigir classificação

    Usa apenas quando a ação sugerida não corresponde ao pedido do cliente.
    {return_to_hidden}
    """ technical = prep_vm.get("technical") or {} def _has_useful_technical_data(data: object) -> bool: if data is None: return False if isinstance(data, dict): return any(_has_useful_technical_data(value) for value in data.values()) if isinstance(data, (list, tuple, set)): return any(_has_useful_technical_data(value) for value in data) return str(data).strip() not in {"", "—", "None", "null"} technical_entries = [ ("Cliente", technical.get("customer")), ("Faturação", technical.get("billing")), ("Venda", technical.get("sale")), ("Logística", technical.get("shipment")), ] technical_blocks = "".join( f"
    {esc(label)}
    {esc(json.dumps(data, ensure_ascii=False, indent=2, default=str))}
    " for label, data in technical_entries if _has_useful_technical_data(data) ) technical_details_html = f'''
    Ver detalhes técnicos
    {technical_blocks}
    ''' if technical_blocks else "" confidence_text = "" action_decision = task.get("action_decision") if isinstance(action_decision, dict) and action_decision.get("confidence") is not None: confidence_text = f"{float(action_decision.get('confidence')):.0%} confiança" reply_assistant_html = _task_reply_assistant_html(task_id, action_code) follow_up_controls_html = _follow_up_controls_html(task_id, task, return_to_hidden) is_follow_up = _is_follow_up_task(action_code) context_heading = "Contexto da tarefa" if is_follow_up else "Pedido do cliente" context_html = _context_task_html(task, str(request_text), is_follow_up=is_follow_up) channel_notice_html = _communication_channel_notice_html(task) contact_person_notice_html = _task_contact_person_notice_html(task) if is_follow_up else "" completion_heading = "Marcar follow-up como feito" if is_follow_up else "Concluir" external_completion_html = _external_channel_completion_form_html(task_id, task, action_code, status, return_to_hidden) body = f""" {return_link_html}
    Próxima ação

    {esc(primary_action_text)}

    {status_badge(status)} {route_badge(route_name)} {f'{esc(confidence_text)}' if confidence_text else ''} {esc(action_code or '—')}
    {chatwoot_link or ''}
    {return_to_hidden}

    Dados em falta

    {missing_html}
    {_task_identity_warning_html(task)} {_task_opportunity_linking_panel_html(task, return_to)} {_task_public_domain_identity_warning_html(task)} {channel_notice_html} {contact_person_notice_html} {follow_up_controls_html} {reply_assistant_html}
    {esc(suggested_reply)}

    {esc(context_heading)}

    Assunto
    {esc(subject)}
    Mensagem
    {context_html}
    {technical_details_html} """ body = _safe_task_display_html(body) body = f'
    {body}
    ' return layout( f"{action_label(action_code)} — {customer}", "Executar a próxima ação sem informação repetida.", body, "tasks", ) @router.post("/tasks/{task_id}/follow-up-draft", response_class=HTMLResponse) async def task_follow_up_draft_endpoint(task_id: str, request: Request): if not is_uuid_text(task_id): return _invalid_task_response() form = await request.form() selected_document_ids = list(form.getlist("selected_document_ids")) operator_instruction = str(form.get("operator_instruction") or "").strip() try: task = get_task_detail(task_id) if not task or not _is_follow_up_task(task): return HTMLResponse(_reply_assistant_lazy_panel_html(task_id, notice="Esta ação só está disponível para tasks FOLLOW_UP_*."), status_code=409) from app.reply_assistant_service import generate_reply_draft template_code = str(form.get("template_code") or "").strip() or _follow_up_template_for_action(str(task.get("action_code") or "")) generate_reply_draft( task_id, template_code=template_code, selected_document_ids=selected_document_ids, operator_instruction=operator_instruction, persist=True, ) return HTMLResponse(_reply_assistant_lazy_panel_html(task_id, notice="Rascunho de follow-up gerado e guardado. Revê antes de enviar/copiar.")) except Exception as exc: return HTMLResponse(_reply_assistant_panel_html(task_id, error=str(exc)), status_code=409) @router.post("/tasks/{task_id}/reply-draft", response_class=HTMLResponse) async def task_reply_draft_endpoint(task_id: str, request: Request): if not is_uuid_text(task_id): return _invalid_task_response() form = await request.form() template_code = str(form.get("template_code") or "").strip() selected_document_ids = list(form.getlist("selected_document_ids")) task_for_selection = get_task_detail(task_id) or {} selected_document_ids = _effective_selected_doc_ids_for_task(task_for_selection, selected_document_ids) operator_instruction = str(form.get("operator_instruction") or "").strip() try: from app.reply_assistant_service import generate_reply_draft state = generate_reply_draft( task_id, template_code=template_code, selected_document_ids=selected_document_ids, operator_instruction=operator_instruction, persist=True, ) return HTMLResponse(_reply_assistant_lazy_panel_html(task_id, notice="Rascunho gerado e guardado. Revê antes de enviar.")) except Exception as exc: return HTMLResponse(_reply_assistant_panel_html(task_id, error=str(exc)), status_code=409) @router.post("/tasks/{task_id}/reply-draft/save", response_class=HTMLResponse) async def task_reply_draft_save_endpoint(task_id: str, request: Request): if not is_uuid_text(task_id): return _invalid_task_response() form = await request.form() template_code = str(form.get("template_code") or "").strip() message_body = str(form.get("message_body") or "").strip() selected_document_ids = list(form.getlist("selected_document_ids")) task_for_selection = get_task_detail(task_id) or {} selected_document_ids = _effective_selected_doc_ids_for_task(task_for_selection, selected_document_ids) operator_instruction = str(form.get("operator_instruction") or "").strip() draft_id = str(form.get("draft_id") or "").strip() try: from app.reply_assistant_service import save_reply_draft save_reply_draft( task_id, draft_id=draft_id, template_code=template_code, message_body=message_body, selected_document_ids=selected_document_ids, generated_by="operator_edit", metadata_patch={"ui_action": "save_draft"}, operator_instruction=operator_instruction, ) return HTMLResponse(_reply_assistant_lazy_panel_html(task_id, notice="Rascunho guardado.")) except Exception as exc: response = HTMLResponse(_reply_assistant_lazy_panel_html(task_id, notice=f"Erro ao guardar rascunho: {exc}"), status_code=200) response.headers["HX-Retarget"] = "#reply-assistant-panel" response.headers["HX-Reswap"] = "outerHTML" return response @router.post("/tasks/{task_id}/reply-draft/revise", response_class=HTMLResponse) async def task_reply_draft_revise_endpoint(task_id: str, request: Request): if not is_uuid_text(task_id): return _invalid_task_response() form = await request.form() template_code = str(form.get("template_code") or "").strip() message_body = str(form.get("message_body") or "").strip() revision_instruction = str(form.get("revision_instruction") or "").strip() selected_document_ids = list(form.getlist("selected_document_ids")) task_for_selection = get_task_detail(task_id) or {} selected_document_ids = _effective_selected_doc_ids_for_task(task_for_selection, selected_document_ids) draft_id = str(form.get("draft_id") or "").strip() try: from app.reply_assistant_service import revise_reply_draft revise_reply_draft( task_id, draft_id=draft_id, template_code=template_code, message_body=message_body, revision_instruction=revision_instruction, selected_document_ids=selected_document_ids, ) return HTMLResponse(_reply_assistant_lazy_panel_html(task_id, notice="Correção aplicada e rascunho guardado.")) except Exception as exc: response = HTMLResponse(_reply_assistant_lazy_panel_html(task_id, notice=f"Não foi possível aplicar a correção: {exc}"), status_code=200) response.headers["HX-Retarget"] = "#reply-assistant-panel" response.headers["HX-Reswap"] = "outerHTML" return response @router.post("/tasks/{task_id}/send-reply", response_class=HTMLResponse) async def task_send_reply_endpoint(task_id: str, request: Request): if not is_uuid_text(task_id): return _invalid_task_response() form = await request.form() template_code = str(form.get("template_code") or "").strip() message_body = str(form.get("message_body") or "").strip() selected_document_ids = list(form.getlist("selected_document_ids")) task_for_selection = get_task_detail(task_id) or {} selected_document_ids = _effective_selected_doc_ids_for_task(task_for_selection, selected_document_ids) draft_id = str(form.get("draft_id") or "").strip() send_and_complete = str(form.get("send_and_complete") or "0").strip() in {"1", "true", "yes", "sim"} try: from app.reply_assistant_service import generate_reply_draft, send_reply result = await send_reply( task_id, template_code=template_code, message_body=message_body, selected_document_ids=selected_document_ids, send_and_complete=send_and_complete, draft_id=draft_id, ) notice = "Mensagem enviada ao cliente." if result.get("completed"): notice += " Tarefa concluída." elif send_and_complete: notice += " Atenção: a mensagem foi enviada, mas a tarefa não foi concluída automaticamente. Revê e conclui manualmente se estiver tudo certo." post_send_warnings = list(result.get("post_send_warnings") or []) if post_send_warnings: notice += " " + " ".join(str(w) for w in post_send_warnings[:3]) if is_htmx(request): if result.get("completed"): return _htmx_redirect_response("/operations?scope=all") # Re-render the full task detail after a plain send or after a # post-send warning. This prevents leaving the header showing stale Pendente/SEND_QUOTE # after the reply status changed. The message may already be public in Chatwoot, # so avoid showing a generic send failure that could trigger a duplicate send. return HTMLResponse(render_task_detail_partial(task_id, notice=notice)) if result.get("completed"): return RedirectResponse("/operations?scope=all", status_code=303) return RedirectResponse(f"/tasks/{task_id}", status_code=303) except Exception as exc: state = { "template": {"code": template_code}, "message_body": message_body, "documents": [], "selected_document_ids": selected_document_ids, "warnings": [], "blockers": [], "draft_id": draft_id, } try: from app.reply_assistant_service import get_reply_panel_state state = get_reply_panel_state(task_id, template_code=template_code, selected_document_ids=selected_document_ids) state["message_body"] = message_body state["draft_id"] = draft_id except Exception: pass response = HTMLResponse(_reply_assistant_panel_html(task_id, state=state, error=str(exc)), status_code=200) response.headers["HX-Retarget"] = "#reply-assistant-panel" response.headers["HX-Reswap"] = "outerHTML" return response @router.post("/tasks/{task_id}/prepare-pickup") async def prepare_pickup_endpoint(task_id: str, request: Request): if not is_uuid_text(task_id): return _invalid_task_response() await run_in_threadpool(run_task_preparation, task_id=task_id, prep_type="pickup") if is_htmx(request): return HTMLResponse(render_task_detail_partial(task_id, notice="Preparação de recolha atualizada.")) return RedirectResponse(f"/tasks/{task_id}", status_code=303) @router.post("/tasks/{task_id}/prepare-shipment") async def prepare_shipment_endpoint(task_id: str, request: Request): if not is_uuid_text(task_id): return _invalid_task_response() await run_in_threadpool(run_task_preparation, task_id=task_id, prep_type="shipment") if is_htmx(request): return HTMLResponse(render_task_detail_partial(task_id, notice="Preparação de envio atualizada.")) return RedirectResponse(f"/tasks/{task_id}", status_code=303) @router.post("/tasks/{task_id}/prepare-proforma") async def prepare_proforma_endpoint(task_id: str, request: Request): if not is_uuid_text(task_id): return _invalid_task_response() await run_in_threadpool(run_task_preparation, task_id=task_id, prep_type="proforma") if is_htmx(request): return HTMLResponse(render_task_detail_partial(task_id, notice="Preparação de orçamento para pagamento atualizada.")) return RedirectResponse(f"/tasks/{task_id}", status_code=303) @router.post("/tasks/{task_id}/reclassify") async def reclassify_task_endpoint(task_id: str, request: Request): if not is_uuid_text(task_id): return _invalid_task_response() from urllib.parse import parse_qs raw_body = (await request.body()).decode("utf-8", errors="replace") form = parse_qs(raw_body) action_code = (form.get("action_code") or [""])[0].strip() reason = (form.get("reason") or [""])[0].strip() if not action_code: return RedirectResponse(f"/tasks/{task_id}", status_code=303) try: reclassify_task( task_id=task_id, new_action_code=action_code, reason=reason, reclassified_by="operator", reopen=True, ) except Exception as exc: print( f"ClientFlow reclassify failed " f"task_id={task_id} action_code={action_code}: {exc!r}", flush=True, ) raise if is_htmx(request): return HTMLResponse(render_task_detail_partial(task_id, notice="Tarefa reclassificada.")) return RedirectResponse(f"/tasks/{task_id}", status_code=303) @router.post("/tasks/{task_id}/reschedule") async def reschedule_task_endpoint(task_id: str, request: Request): if not is_uuid_text(task_id): return _invalid_task_response() form = await request.form() return_to = _safe_return_to(str(form.get("return_to") or request.query_params.get("return_to") or ""), default=f"/tasks/{task_id}") try: delay_days = int(str(form.get("delay_days") or "2")) except Exception: delay_days = 2 reason = str(form.get("reason") or "Adiado pelo operador").strip() reschedule_task_due_at( task_id=task_id, delay_days=delay_days, rescheduled_by="operator", reason=reason, ) if is_htmx(request): return HTMLResponse(render_task_detail_partial(task_id, notice=f"Tarefa adiada {delay_days} dia(s).", return_to=return_to)) return RedirectResponse(return_to, status_code=303) @router.post("/tasks/{task_id}/complete") async def complete_task_endpoint(task_id: str, request: Request): if not is_uuid_text(task_id): return _invalid_task_response() form_return_to = "" try: form = await request.form() form_return_to = str(form.get("return_to") or "") except Exception: form_return_to = "" complete_task(task_id=task_id, done_by="operator") return_to = _safe_return_to(str(form_return_to or request.query_params.get("return_to") or ""), default="/tasks?status=pending") if is_htmx(request): return _htmx_redirect_response(return_to) return RedirectResponse(return_to, status_code=303) @router.post("/tasks/{task_id}/complete-with-note") async def complete_task_with_note_action( task_id: str, request: Request, ): if not is_uuid_text(task_id): return _invalid_task_response() form = await request.form() done_note = str(form.get("done_note") or "").strip() done_note_extra = str(form.get("done_note_extra") or "").strip() return_to = _safe_return_to(str(form.get("return_to") or request.query_params.get("return_to") or ""), default=f"/tasks/{task_id}") task_for_guard = get_task_detail(task_id) or {} action_for_guard = str(task_for_guard.get("action_code") or "").upper() manual_external_done = str(form.get("manual_external_done") or "").strip().lower() in {"1", "true", "yes", "sim"} external_channel = str(form.get("external_channel") or "").strip() if manual_external_done: external_note = _external_channel_completion_note(action_for_guard, external_channel or "canal externo") if not done_note or done_note.startswith(_external_channel_completion_note(action_for_guard, "WhatsApp")): done_note = external_note elif external_channel and external_channel.lower() not in done_note.lower(): done_note = f"{done_note} Canal externo: {external_channel}." if done_note_extra: if done_note: done_note = f"{done_note} — {done_note_extra}" else: done_note = done_note_extra if action_for_guard in {"SEND_PROFORMA", "SEND_INVOICE"}: has_ready_fiscal = bool( str(task_for_guard.get("linked_customer_name") or "").strip() and str(task_for_guard.get("linked_customer_tax_id") or "").strip() and str(task_for_guard.get("linked_customer_city_name") or task_for_guard.get("linked_customer_street_name") or "").strip() ) # An already-created Jasmin document may have been sent manually by # WhatsApp/phone/outside Chatwoot. Missing billing email/address should # remain visible as a warning, but must not prevent recording that the # operator already sent the document through an external channel. external_document_sent = manual_external_done and _task_has_available_document_for_action(task_for_guard, action_for_guard) if not has_ready_fiscal and not external_document_sent: if is_htmx(request): return HTMLResponse(render_task_detail_partial(task_id, notice="Cliente fiscal/dados de envio incompletos: usa 'Marcar como enviado externamente' se o documento já foi enviado por WhatsApp ou outro canal externo.", return_to=return_to), status_code=409) return RedirectResponse(url=f"/tasks/{task_id}", status_code=303) complete_task_with_note( task_id, done_by="admin", done_note=done_note, ) # Legacy regression marker for tests: render_task_detail_partial(task_id, notice="Tarefa concluída.") if return_to and return_to != f"/tasks/{task_id}": if is_htmx(request): return _htmx_redirect_response(return_to) return RedirectResponse(url=return_to, status_code=303) if is_htmx(request): return HTMLResponse(render_task_detail_partial(task_id, notice="Tarefa concluída.", return_to=return_to)) return RedirectResponse( url=f"/tasks/{task_id}", status_code=303, ) @router.post("/tasks/{task_id}/associate-opportunity") async def associate_task_opportunity_endpoint(task_id: str, request: Request): if not is_uuid_text(task_id): return _invalid_task_response() form = await request.form() opportunity_id = str(form.get("opportunity_id") or "").strip() if not is_uuid_text(opportunity_id): return PlainTextResponse("Oportunidade inválida.", status_code=422) try: associate_task_to_opportunity(task_id, opportunity_id, actor="operator") except Exception as exc: return PlainTextResponse(f"Erro ao associar oportunidade: {exc}", status_code=500) target = f"/opportunities/{opportunity_id}" if is_htmx(request): return _htmx_redirect_response(target) return RedirectResponse(target, status_code=303) @router.post("/tasks/{task_id}/skip") async def skip_task_endpoint(task_id: str, request: Request): if not is_uuid_text(task_id): return _invalid_task_response() form = await request.form() return_to = _safe_return_to(str(form.get("return_to") or request.query_params.get("return_to") or ""), default="/tasks") skip_task(task_id=task_id, skipped_by="operator", reason="Skipped from dashboard") if is_htmx(request): return _htmx_redirect_response(return_to) return RedirectResponse(return_to, status_code=303)