Release v4928.1.4.2 stable

This commit is contained in:
2026-06-09 22:55:58 +01:00
commit 6445044ac6
280 changed files with 41775 additions and 0 deletions

353
app/admin_ui/guidance.py Normal file
View File

@@ -0,0 +1,353 @@
"""Small operator-facing UI helpers for guided operations.
v4.8.2 intentionally keeps business rules unchanged. Prontidão is shown as UI guidance only. These helpers only turn
existing opportunity/task/outbox data into clearer labels, blockers and
checklists for the operator.
"""
from __future__ import annotations
import html
from typing import Any, Iterable
def esc(value: Any) -> str:
return html.escape("" if value is None else str(value))
def _present(value: Any) -> bool:
return bool(str(value or "").strip())
def _field(data: dict[str, Any] | None, *keys: str) -> str:
data = data or {}
for key in keys:
value = data.get(key)
if _present(value):
return str(value).strip()
return ""
DOCUMENT_ACTION_CODES = {
"SEND_PROFORMA",
"SEND_INVOICE",
}
# Pagamento e envio têm checklists próprias. Não devem bloquear uma oportunidade
# antiga/reconstruída só porque faltam campos fiscais no ClientFlow depois de o
# documento oficial já ter sido emitido.
FULFILMENT_ACTION_CODES = {
"CONFIRM_PAYMENT_AND_PREPARE_SHIPMENT",
"PREPARE_ORDER",
"CREATE_SHIPMENT",
}
DOCUMENT_STAGES = {
"PROFORMA_REQUESTED",
"PROFORMA_SENT",
"INVOICE_REQUESTED",
"INVOICE_SENT",
"WAITING_PAYMENT",
"PAYMENT_CONFIRMED",
"ORDER_PREPARATION",
"READY_TO_SHIP",
"INVOICED",
"SHIPMENT_CREATED",
"SHIPPED",
"TRACKING_SENT",
"DELIVERED",
}
SHIPMENT_ACTION_CODES = {"CONFIRM_PAYMENT_AND_PREPARE_SHIPMENT", "PREPARE_ORDER", "CREATE_SHIPMENT"}
SHIPMENT_STAGES = {"PAYMENT_CONFIRMED", "ORDER_PREPARATION", "READY_TO_SHIP", "SHIPMENT_CREATED", "SHIPPED", "TRACKING_SENT", "DELIVERED"}
def normalized_action_code(value: Any) -> str:
return str(value or "").strip().upper()
def action_requires_fiscal_customer(action_code: Any) -> bool:
return normalized_action_code(action_code) in DOCUMENT_ACTION_CODES
def action_requires_shipment_readiness(action_code: Any) -> bool:
return normalized_action_code(action_code) in SHIPMENT_ACTION_CODES
def stage_requires_fiscal_customer(stage: Any) -> bool:
return str(stage or "").strip().upper() in DOCUMENT_STAGES
def stage_requires_shipment_readiness(stage: Any) -> bool:
return str(stage or "").strip().upper() in SHIPMENT_STAGES
def item_requires_fiscal_customer(item: dict[str, Any] | None) -> bool:
item = item or {}
return action_requires_fiscal_customer(item.get("action_code"))
def item_requires_shipment_readiness(item: dict[str, Any] | None) -> bool:
item = item or {}
return action_requires_shipment_readiness(item.get("action_code"))
def fiscal_customer_missing_fields(customer: dict[str, Any] | None) -> list[str]:
"""Minimum fields needed before fiscal documents should be issued."""
if not customer:
return ["cliente fiscal associado"]
missing: list[str] = []
if not _field(customer, "name", "linked_customer_name"):
missing.append("nome fiscal")
if not _field(customer, "tax_id", "linked_customer_tax_id"):
missing.append("NIF")
if not _field(customer, "email", "linked_customer_email", "customer_email"):
missing.append("email de faturação")
if not _field(customer, "street_name"):
missing.append("morada fiscal")
if not _field(customer, "postal_zone"):
missing.append("código postal")
if not _field(customer, "city_name"):
missing.append("localidade")
return missing
def shipment_missing_fields(customer: dict[str, Any] | None, opportunity: dict[str, Any] | None = None) -> list[str]:
missing: list[str] = []
if not customer:
return ["cliente fiscal associado", "morada de entrega", "telefone"]
if not _field(customer, "street_name"):
missing.append("morada de entrega")
if not _field(customer, "postal_zone"):
missing.append("código postal de entrega")
if not _field(customer, "city_name"):
missing.append("localidade de entrega")
if not (_field(customer, "phone") or _field(opportunity or {}, "customer_phone")):
missing.append("telefone")
return missing
def opportunity_context_customer(opportunity: dict[str, Any], linked_customer: dict[str, Any] | None = None) -> dict[str, Any] | None:
if linked_customer:
return linked_customer
if opportunity.get("linked_customer_id") or opportunity.get("linked_customer_name"):
return {
"id": opportunity.get("linked_customer_id"),
"name": opportunity.get("linked_customer_name"),
"email": opportunity.get("linked_customer_email"),
"tax_id": opportunity.get("linked_customer_tax_id"),
"street_name": opportunity.get("linked_customer_street_name"),
"postal_zone": opportunity.get("linked_customer_postal_zone"),
"city_name": opportunity.get("linked_customer_city_name"),
"phone": opportunity.get("linked_customer_phone"),
}
return None
def opportunity_blockers(
opportunity: dict[str, Any],
linked_customer: dict[str, Any] | None = None,
*,
action_code: Any = None,
) -> list[str]:
"""Return only blockers that matter for the current journey stage.
Missing fiscal data is not automatically a blocker at the first contact. It
becomes a current blocker only when the next action/stage needs fiscal
documents, payment or fulfilment.
"""
customer = opportunity_context_customer(opportunity, linked_customer)
blockers: list[str] = []
linking_status = str(opportunity.get("opportunity_linking_status") or opportunity.get("linking_status") or "").lower()
if linking_status == "ambiguous":
blockers.append("Associação de oportunidade por confirmar")
needs_fiscal = action_requires_fiscal_customer(action_code) if action_code else stage_requires_fiscal_customer(opportunity.get("stage"))
if needs_fiscal:
if not customer:
blockers.append("Cliente fiscal por associar")
else:
for item in fiscal_customer_missing_fields(customer):
blockers.append(f"Cliente fiscal sem {item}")
needs_product = needs_fiscal or str(opportunity.get("stage") or "").upper() in {"QUOTE_REQUESTED", "QUOTE_SENT"}
if needs_product and not _field(opportunity, "product_interest", "title"):
blockers.append("Produto/interesse por definir")
return blockers
def work_item_fiscal_customer(item: dict[str, Any]) -> dict[str, Any] | None:
"""Return the fiscal customer carried by an Operations item, if any.
The work queue must not infer a fiscal customer from Chatwoot contact_id. It
only uses the opportunity/customer links already resolved by the backend.
"""
item = item or {}
if not _field(item, "fiscal_customer_name"):
return None
return {
"name": item.get("fiscal_customer_name"),
"email": item.get("fiscal_customer_email"),
"tax_id": item.get("fiscal_customer_tax_id"),
"street_name": item.get("fiscal_customer_street_name"),
"postal_zone": item.get("fiscal_customer_postal_zone"),
"city_name": item.get("fiscal_customer_city_name"),
}
def work_item_blockers(item: dict[str, Any]) -> list[str]:
"""Compact blockers for Operations cards, based on the next action."""
blockers: list[str] = []
linking_status = str(item.get("opportunity_linking_status") or "").lower()
if linking_status == "ambiguous":
blockers.append("Associação de oportunidade por confirmar")
if item_requires_fiscal_customer(item):
customer = work_item_fiscal_customer(item)
if not customer:
blockers.append("Cliente fiscal por associar")
else:
for missing in fiscal_customer_missing_fields(customer):
blockers.append(f"Cliente fiscal sem {missing}")
return blockers
def blocker_alert_html(blockers: Iterable[str], *, empty_text: str = "Sem bloqueios críticos visíveis.") -> str:
blockers = [str(item) for item in blockers if str(item or "").strip()]
if not blockers:
return f'<div class="alert alert-success border-0 mb-0"><strong>{esc(empty_text)}</strong></div>'
items = "".join(f"<li>{esc(item)}</li>" for item in blockers)
return f'<div class="alert alert-warning border-0 mb-0"><strong>Bloqueios atuais</strong><ul class="mb-0 mt-2">{items}</ul></div>'
def readiness_checklist_html(
*,
title: str,
missing: Iterable[str],
ok_text: str = "Pronto para avançar.",
blocked_text: str = "Ação bloqueada até corrigir os dados em falta.",
) -> str:
missing_items = [str(item) for item in missing if str(item or "").strip()]
if missing_items:
pills = "".join(f'<span class="cf-readiness-pill missing">⚠ {esc(item)}</span>' for item in missing_items)
state = f'<div class="small text-danger fw-bold mt-2">{esc(blocked_text)}</div>'
else:
pills = '<span class="cf-readiness-pill ok">✓ Dados mínimos completos</span>'
state = f'<div class="small text-success fw-bold mt-2">{esc(ok_text)}</div>'
return f'''
<section class="card cf-card cf-readiness-card">
<div class="card-body p-4">
<h2 class="cf-section-title mb-2">{esc(title)}</h2>
<div class="d-flex flex-wrap gap-2">{pills}</div>
{state}
</div>
</section>
'''
def fiscal_contact_panel_html(
*,
fiscal_customer: dict[str, Any] | None,
contact_name: Any = "",
contact_email: Any = "",
contact_phone: Any = "",
conversation_id: Any = "",
contact_id: Any = "",
customer_href: str = "",
) -> str:
customer = fiscal_customer or {}
customer_name = _field(customer, "name", "linked_customer_name") or "Cliente fiscal por associar"
tax_id = _field(customer, "tax_id", "linked_customer_tax_id") or ""
email = _field(customer, "email", "linked_customer_email") or ""
address_bits = [
_field(customer, "street_name"),
" ".join(bit for bit in [_field(customer, "postal_zone"), _field(customer, "city_name")] if bit),
]
address = " · ".join(bit for bit in address_bits if bit) or ""
contact_name = str(contact_name or "Contacto por confirmar")
contact_email = str(contact_email or "")
contact_phone = str(contact_phone or "")
conversation = str(conversation_id or "")
contact_ref = str(contact_id or "")
customer_button = f'<a class="btn btn-sm btn-outline-secondary mt-2" href="{esc(customer_href)}">Ver cliente fiscal</a>' if customer_href else ""
return f'''
<section class="card cf-card cf-fiscal-contact-panel">
<div class="card-body p-4">
<h2 class="cf-section-title mb-3">Cliente fiscal e contacto Chatwoot</h2>
<div class="cf-fiscal-contact-grid">
<div class="cf-fiscal-box fiscal">
<div class="cf-fiscal-label">Cliente fiscal</div>
<strong>{esc(customer_name)}</strong>
<div class="small text-secondary">NIF {esc(tax_id)}</div>
<div class="small text-secondary text-break">{esc(email)}</div>
<div class="small text-secondary text-break">{esc(address)}</div>
{customer_button}
</div>
<div class="cf-fiscal-box contact">
<div class="cf-fiscal-label">Contacto Chatwoot</div>
<strong>{esc(contact_name)}</strong>
<div class="small text-secondary text-break">{esc(contact_email)}</div>
<div class="small text-secondary">{esc(contact_phone)}</div>
<div class="small text-secondary">{esc('Conversa #' + conversation if conversation else 'Sem conversa ligada')}</div>
<div class="small text-secondary">{esc('Contacto #' + contact_ref if contact_ref else '')}</div>
</div>
</div>
</div>
</section>
'''
def fiscal_contact_inline_html(
*,
fiscal_name: Any = "",
contact_ref: Any = "",
conversation_id: Any = "",
show_fiscal: bool = True,
fiscal_required: bool = False,
) -> str:
contact = str(contact_ref or "").strip()
conversation = str(conversation_id or "").strip()
contact_bits = []
if contact:
contact_bits.append(f"contacto {contact}")
if conversation:
contact_bits.append(f"conversa #{conversation}")
contact_line = " · ".join(contact_bits) or "contacto por confirmar"
fiscal_value = str(fiscal_name or "").strip()
if not show_fiscal and not fiscal_required:
return f'''
<div class="cf-fiscal-mini compact">
<div><span>Contacto Chatwoot</span><strong>{esc(contact_line)}</strong></div>
</div>
'''
fiscal_label = fiscal_value or "Por associar"
return f'''
<div class="cf-fiscal-mini">
<div><span>Cliente fiscal</span><strong>{esc(fiscal_label)}</strong></div>
<div><span>Contacto Chatwoot</span><strong>{esc(contact_line)}</strong></div>
</div>
'''
def outbox_operator_message(item: dict[str, Any]) -> dict[str, str]:
target = str(item.get("target_system") or "integração").strip()
action = str(item.get("action_type") or "ação").strip()
error = str(item.get("last_error") or "").strip()
lower = error.casefold()
title = f"Falha em {target}.{action}" if error else f"Ação {target}.{action}"
probable = "Ver o detalhe técnico do erro antes de reprocessar."
fix_label = "Ver detalhe"
if "tax" in lower or "nif" in lower or "vat" in lower or "fiscal" in lower:
probable = "Cliente fiscal sem NIF válido ou dados fiscais incompletos."
fix_label = "Corrigir cliente"
elif "address" in lower or "morada" in lower or "postal" in lower or "city" in lower:
probable = "Morada fiscal ou morada de entrega incompleta."
fix_label = "Corrigir morada"
elif "product" in lower or "item" in lower or "sales_item" in lower or "artigo" in lower:
probable = "Produto sem código externo/Jasmin ou artigo inválido."
fix_label = "Corrigir produto"
elif "payment" in lower or "pagamento" in lower:
probable = "Pagamento ainda não validado ou estado financeiro inconsistente."
fix_label = "Ver pagamento"
elif "timeout" in lower or "connection" in lower or "tempor" in lower:
probable = "Falha temporária de ligação à integração externa."
fix_label = "Reprocessar depois"
return {"title": title, "probable": probable, "fix_label": fix_label, "technical": error}