diff --git a/.env.example b/.env.example index f9ddf01..918f5fc 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,7 @@ DATABASE_URL=postgresql+psycopg://clientflow:password@127.0.0.1:5432/clientflow PSQL_DATABASE_URL=postgresql://clientflow:password@127.0.0.1:5432/clientflow # LLM / OpenRouter -OPENROUTER_API_KEY=coloca_a_tua_chave_aqui +OPENROUTER_API_KEY= OPENROUTER_MODEL=qwen/qwen3-30b-a3b # Chatwoot @@ -89,3 +89,68 @@ JASMIN_DEFAULT_UNIT=UN JASMIN_DEFAULT_ITEM_TAX_SCHEMA=NORMAL # Opcional: fallback quando a linha da oportunidade não tem SKU/Jasmin item. JASMIN_DEFAULT_SALES_ITEM=CARREGADOR_MONO_7KW + +# --------------------------------------------------------------------------- +# URLs e configuração geral do ClientFlow +# --------------------------------------------------------------------------- +CLIENTFLOW_BASE_URL=http://127.0.0.1:8020 +CLIENTFLOW_PUBLIC_URL= +CLIENTFLOW_BACKUP_DIR=/var/backups/clientflow +CLIENTFLOW_DISABLE_CHATWOOT_PRIVATE_NOTES=false +CLIENTFLOW_NON_BILLABLE_ODOO_LINES= + +# --------------------------------------------------------------------------- +# Chatwoot +# --------------------------------------------------------------------------- +CHATWOOT_AUTO_COMPLETE_ON_OUTGOING=true +CHATWOOT_AUTO_COMPLETE_ACTION_CODES= + +# --------------------------------------------------------------------------- +# Identificação por email / LLM +# --------------------------------------------------------------------------- +EMAIL_IDENTITY_LLM_MODEL= +EMAIL_IDENTITY_LLM_FALLBACK_MODEL= +EMAIL_IDENTITY_LLM_TIMEOUT_SECONDS=30 +EMAIL_IDENTITY_LLM_MAX_BODY_CHARS=12000 + +# --------------------------------------------------------------------------- +# Jasmin — impressão de faturas +# --------------------------------------------------------------------------- +JASMIN_INVOICE_TYPE= +JASMIN_INVOICE_SERIE= +JASMIN_INVOICE_PRINT_LAYOUT= +JASMIN_INVOICE_PRINTED_REPORT_NAME= + +# --------------------------------------------------------------------------- +# Mautic +# --------------------------------------------------------------------------- +MAUTIC_BASE_URL= +MAUTIC_API_TOKEN= +MAUTIC_ADD_TAG_URL= +MAUTIC_REMOVE_TAG_URL= + +# --------------------------------------------------------------------------- +# Outbox +# --------------------------------------------------------------------------- +OUTBOX_LIMIT=50 +OUTBOX_TARGET_SYSTEM= +OUTBOX_WORKER_ID= +OUTBOX_STALE_PROCESSING_MINUTES=30 +OUTBOX_STALE_RECOVERY_LIMIT=100 +OUTBOX_STALE_RECOVERY_MODE=false + +# --------------------------------------------------------------------------- +# Packlink — testes +# --------------------------------------------------------------------------- +PACKLINK_TEST_FROM_ZIP= +PACKLINK_TEST_TO_ZIP= + +# --------------------------------------------------------------------------- +# Assistente de resposta +# --------------------------------------------------------------------------- +CLIENTFLOW_REPLY_LLM_ENABLED=false +CLIENTFLOW_REPLY_LLM_FIRST_ENABLED=true +CLIENTFLOW_REPLY_LLM_MODEL= +CLIENTFLOW_EMAIL_REPLY_AGENT_ENABLED=false +OPENAI_VECTOR_STORE_ID= +OPENAI_API_KEY= diff --git a/CLIENTFLOW_SALES_MANAGEMENT_KNOWLEDGE_v1_2.md b/CLIENTFLOW_SALES_MANAGEMENT_KNOWLEDGE_v1_2.md new file mode 100644 index 0000000..8efdf28 --- /dev/null +++ b/CLIENTFLOW_SALES_MANAGEMENT_KNOWLEDGE_v1_2.md @@ -0,0 +1,114 @@ +# ClientFlow — conhecimento do assistente de gestão comercial + +Versão: 1.2 +Release: v4928.1.5.125 +Idioma: português de Portugal + +## Objetivo + +Responder a perguntas internas sobre metas, desempenho, pipeline e ações comerciais. As regras deste ficheiro são estáveis; os números atuais devem ser fornecidos pelo backend através de `/api/internal/forecast` (`/api/internal/revenue-forecast` permanece como alias). + +## Regra fundamental + +Nunca inventar metas, valores, datas, taxas ou estados. Quando faltarem dados vivos, declarar que não existem dados suficientes para calcular com segurança. + +## Conceitos + +- **Meta mensal:** objetivo para um mês civil e uma métrica explícita. +- **Realizado:** valor que já cumpre a métrica da meta no período. +- **Comprometido:** valor praticamente assegurado, mas ainda não realizado segundo a métrica. +- **Pipeline provável:** oportunidades ainda sujeitas a conversão, ponderadas por probabilidade e atividade. +- **Previsão total:** realizado + comprometido não realizado + pipeline provável. +- **Desvio:** meta − previsão total. +- **Cumprimento previsto:** previsão total ÷ meta. +- **Capacidade de recuperação:** pagamentos e conversões já existentes que podem ser antecipados para o mês. +- **Desvio residual:** máximo(meta − previsão base − capacidade de recuperação, 0). +- **Novo pipeline necessário:** desvio residual ÷ taxa de conversão esperada. + +## Métricas suportadas + +1. `invoiced` — faturação emitida no mês. +2. `cash_received` — pagamentos confirmados no mês. +3. `won_sales` — vendas ganhas no mês. + +Não tratar as três métricas como equivalentes. + +## Fim do mês e próximos 30 dias + +- “Este mês” significa até ao último dia do mês civil. +- “Próximos 30 dias” é uma janela móvel e pode incluir parte do mês seguinte. +- Nunca usar diretamente o total de 30 dias para responder sobre o fim do mês. + +## Prevenção de dupla contagem + +Um processo comercial contribui apenas uma vez para a previsão total. Não somar separadamente orçamento, fatura, venda Odoo e pagamento da mesma compra. Um valor já realizado não volta a entrar no comprometido ou no pipeline provável. + +## Confiança + +Interpretar a cobertura de valor: + +- 80% ou mais: boa. +- 50% a 79%: moderada. +- Menos de 50%: baixa. +- Menos de 25%: previsão monetária muito incompleta. + +Com qualidade baixa, usar “estimativa indicativa” e nunca apresentar o resultado como garantia. + +## Semáforo + +- **Meta suportada:** previsão ≥ meta e qualidade ≥ 60%. +- **Suportada com baixa confiança:** previsão ≥ meta, mas qualidade < 60%. +- **Meta recuperável:** previsão base abaixo da meta, mas capacidade de recuperação ponderada cobre o desvio. +- **Risco moderado:** previsão entre 85% e 99% da meta e recuperação insuficiente ou incerta. +- **Sem cobertura suficiente:** previsão base + recuperação conhecida continuam abaixo da meta. + +## Estratégia recomendada + +- Valor suficiente, mas bloqueado: priorizar pagamento, produção, expedição e tasks vencidas. +- Muitas oportunidades sem valor: qualificar e associar documentos antes de aumentar campanhas. +- Pipeline bruto insuficiente: gerar novas oportunidades e reativar clientes. +- Muitos orçamentos e pouca conversão: rever proposta, preço, follow-up e objeções. + +## Formato de resposta sobre a meta + +Apresentar sempre, quando disponíveis: + +- meta; +- realizado; +- comprometido; +- pipeline provável; +- previsão total; +- desvio; +- cumprimento previsto; +- qualidade/cobertura; +- principais riscos; +- três ações prioritárias. + +Distinguir claramente valor já realizado de valor adicional esperado a partir da data atual. + + +## Política de follow-up e atividade comercial + +O estado técnico `open` não significa, por si só, que a oportunidade esteja ativa. Usar os estados operacionais: + +- `active` — existe atividade recente ou trabalho comercial em curso; +- `awaiting_customer` — comunicação enviada e próximo contacto agendado; +- `follow_up_due` — a data do próximo contacto chegou; +- `recovery` — a sequência normal terminou sem resposta e requer decisão; +- `nurture` — o cliente tem potencial, mas o timing é futuro. + +Nunca usar `updated_at` técnico para concluir que o cliente esteve ativo. Preferir `last_customer_activity_at`, `last_operator_activity_at`, `last_commercial_activity_at` e `next_follow_up_at`. + +### Cadência recomendada + +- Informação: confirmar receção no dia útil seguinte; contactos adicionais após 2, 4 e 5 dias úteis. +- Orçamento: confirmar receção no dia útil seguinte; esclarecer dúvidas após 2 dias úteis; decisão após 4; última tentativa após 5. +- Pagamento: confirmar receção no dia útil seguinte; pedir data de pagamento após 2 dias úteis; lembrete após 3; contacto direto após mais 3; revisão final após mais 5. + +A primeira etapa deve confirmar entrega, destinatário, documento/anexo e possíveis bounces. Não assumir que ausência de resposta significa falta de interesse. + +### Recuperação, nurture e perda + +Depois da última tentativa, mover para `recovery`; não marcar automaticamente como perdida. Na recuperação, escolher entre canal alternativo, nova abordagem, `nurture` ou perda. + +Marcar como perdida apenas com motivo obrigatório. `future_timing` deve gerar nurture, não perda. Uma nova mensagem do cliente na mesma conversa pode reabrir automaticamente oportunidades fechadas como `LOST` ou `NO_INTEREST`; negócios entregues ou ganhos não são reabertos como a mesma venda. diff --git a/E2E_CHATWOOT_HEADER_FIX_README.md b/E2E_CHATWOOT_HEADER_FIX_README.md new file mode 100644 index 0000000..08b402a --- /dev/null +++ b/E2E_CHATWOOT_HEADER_FIX_README.md @@ -0,0 +1,17 @@ +# Fix E2E Chatwoot private notes + +Este overlay corrige o mock Chatwoot para aceitar o header real `api_access_token` usado pelo ClientFlow. + +Sintoma anterior: + +- inbound WebSocket passa; +- `/webhooks/chatwoot` devolve 200; +- tarefas são criadas; +- outgoing auto-complete passa; +- mas as notas privadas ficam a `notes=0`. + +Causa: + +O FastAPI transformava o parâmetro `api_access_token` em header esperado `api-access-token`, enquanto o ClientFlow envia o header Chatwoot real `api_access_token`. O mock passava a responder 401 ao POST da nota privada, mas o webhook principal continuava 200. + +Aplicar e reiniciar o Terminal 1 dos mocks. diff --git a/E2E_CHATWOOT_WS_README.md b/E2E_CHATWOOT_WS_README.md new file mode 100644 index 0000000..cbb8d60 --- /dev/null +++ b/E2E_CHATWOOT_WS_README.md @@ -0,0 +1,48 @@ +# ClientFlow E2E Chatwoot WebSocket overlay + +Acrescenta ao E2E Test Lab: + +- `mock_chatwoot_api.py` na porta `18004`. +- REST compatível com `POST /api/v1/accounts/{account_id}/conversations/{conversation_id}/messages`. +- WebSocket `ws://127.0.0.1:18004/e2e/ws` para simular mensagens incoming/outgoing. +- Testes E2E adicionais para conversas Chatwoot: + - cliente envia mensagem; + - mock Chatwoot envia webhook para `/webhooks/chatwoot`; + - ClientFlow classifica a ação; + - ClientFlow publica nota privada no Chatwoot mock quando permitido; + - operador envia resposta outgoing; + - ClientFlow auto-completa tarefas para ações em que responder ao cliente fecha a tarefa. + +## Aplicar + +```bash +cd ~/Transferências/clientflow_backend_v4928_1_5_40_with_e2e_test_lab/cf_v1532_work +unzip -o ~/Transferências/clientflow_e2e_chatwoot_ws_overlay.zip -d . +chmod +x e2e_test_lab/scripts/*.sh +source .venv/bin/activate +pip install -r e2e_test_lab/requirements-e2e.txt +``` + +## Terminais + +Terminal 1: + +```bash +source .venv/bin/activate +./e2e_test_lab/scripts/run_mock_servers.sh +``` + +Terminal 2: + +```bash +source .venv/bin/activate +./e2e_test_lab/scripts/start_clientflow_e2e.sh +``` + +Terminal 3: + +```bash +source .venv/bin/activate +./e2e_test_lab/scripts/check_e2e_ports.sh +./e2e_test_lab/scripts/run_e2e_lab.sh | tee e2e_test_lab/latest_e2e_run.log +``` diff --git a/E2E_DEEP_6_ERRORS_FIX_README.md b/E2E_DEEP_6_ERRORS_FIX_README.md new file mode 100644 index 0000000..7426e5a --- /dev/null +++ b/E2E_DEEP_6_ERRORS_FIX_README.md @@ -0,0 +1,27 @@ +# ClientFlow Deep Audit 6 Errors Fix + +Corrige os 6 `HTTP 500` encontrados no deep audit: + +- `POST /opportunities/{id}/stage NEW` +- `POST /opportunities/{id}/jasmin/create-quotation` +- `POST /opportunities/{id}/jasmin/convert-invoice` +- `POST /opportunities/{id}/operations/prepare_order` +- `POST /opportunities/{id}/operations/prepare_shipping` +- `POST /opportunities/{id}/operations/send_followup` + +Alterações: + +1. `stage=NEW` passa a ser aceite como alias de `NEW_LEAD`. +2. Falhas de criação/conversão Jasmin deixam de devolver `500` em pedidos normais; redirecionam para a oportunidade com `notice`. +3. Ações legadas de operações passam a mapear para ações canónicas: + - `prepare_order` -> `odoo_sale_order` + - `prepare_shipping` -> `packlink_shipment` + - `send_followup` -> `tracking_sent` +4. Bloqueios/erros operacionais deixam de rebentar a UI com `500`; devolvem `409` em HTMX ou `303` com aviso na UI normal. + +Depois de aplicar, reiniciar o ClientFlow e voltar a correr: + +```bash +./e2e_test_lab/scripts/run_deep_e2e_lab.sh | tee e2e_test_lab/latest_deep_e2e_run.log +cat e2e_test_lab/latest_deep_audit_report.md +``` diff --git a/E2E_DEEP_COVERAGE_README.md b/E2E_DEEP_COVERAGE_README.md new file mode 100644 index 0000000..dea32f9 --- /dev/null +++ b/E2E_DEEP_COVERAGE_README.md @@ -0,0 +1,78 @@ +# ClientFlow E2E Deep Coverage Overlay + +Acrescenta uma camada de cobertura mais agressiva ao E2E já existente. + +## O que testa + +1. Core E2E existente: + - mocks OpenRouter, Jasmin, Contactos, Odoo, Chatwoot; + - `/analyze`; + - Chatwoot WebSocket; + - reconciliação; + - páginas principais; + - APIs internas. + +2. Deep UI/API audit: + - GET de páginas, aliases e partials HTMX; + - links internos encontrados no HTML; + - forms sem action, métodos inválidos, ids duplicados; + - botões sem label; + - padrões de erro renderizados no HTML; + - API de contactos com GET/POST e casos inválidos; + - criação/update de cliente; + - criação/update/toggle de produto; + - criação de oportunidade; + - add/delete de item de oportunidade; + - lifecycle de tarefas: reclassify, reschedule, complete, complete-with-note, skip; + - endpoints de reconciliação; + - endpoints de integração Odoo; + - operações de oportunidade quando aplicáveis. + +3. Playwright UI opcional: + - navegação real em browser; + - clique em links de navegação; + - submit visual de forms de cliente/produto/oportunidade; + - botões de reconciliação; + - captura de screenshots em falha; + - deteção de `pageerror` e `console.error`. + +## Comandos + +Aplicar overlay: + +```bash +cd ~/Transferências/clientflow_backend_v4928_1_5_40_with_e2e_test_lab/cf_v1532_work +unzip -o ~/Transferências/clientflow_e2e_deep_coverage_overlay.zip -d . +chmod +x e2e_test_lab/scripts/*.sh +source .venv/bin/activate +pip install -r e2e_test_lab/requirements-e2e.txt +``` + +Correr core + deep API/HTML: + +```bash +./e2e_test_lab/scripts/run_deep_e2e_lab.sh | tee e2e_test_lab/latest_deep_e2e_run.log +``` + +Instalar browser Playwright: + +```bash +./e2e_test_lab/scripts/install_playwright_ui.sh +``` + +Correr tudo incluindo browser real: + +```bash +RUN_PLAYWRIGHT_UI=1 ./e2e_test_lab/scripts/run_deep_e2e_lab.sh | tee e2e_test_lab/latest_deep_e2e_run.log +``` + +Ver resumos: + +```bash +tail -120 e2e_test_lab/latest_deep_e2e_run.log +cat e2e_test_lab/latest_deep_audit_report.md +``` + +## Nota + +Isto aumenta muito a cobertura, mas `100%` literal não é garantível sem instrumentação de cobertura por código, mapa completo de estados de negócio e dados de teste para todas as combinações. Este overlay aproxima o E2E de um teste de regressão funcional completo. diff --git a/E2E_FAILURE_HUNT_AUTH_README.md b/E2E_FAILURE_HUNT_AUTH_README.md new file mode 100644 index 0000000..f2bda4a --- /dev/null +++ b/E2E_FAILURE_HUNT_AUTH_README.md @@ -0,0 +1,18 @@ +# ClientFlow Failure Hunt v2 — authenticated probes + +Esta versão corrige o runner para enviar `X-ClientFlow-Admin-Token` usando `CLIENTFLOW_ADMIN_TOKEN` do `.env.e2e`. + +A primeira versão encontrava muitos `401`, o que impedia os probes de chegar à lógica real da app. Esta versão acrescenta ainda probes para: + +- contratos negativos de `/analyze` e `/webhooks/chatwoot`; +- formulários POST/HTMX sem CSRF visível; +- cache-control em páginas operacionais; +- autocomplete em campos sensíveis; +- validação interna autenticada de clientes, produtos, oportunidades, items e tarefas. + +Executar: + +```bash +./e2e_test_lab/scripts/run_failure_hunt.sh | tee e2e_test_lab/latest_failure_hunt_run.log +cat e2e_test_lab/latest_failure_hunt_report.md +``` diff --git a/E2E_FAILURE_HUNT_DBFIX_README.md b/E2E_FAILURE_HUNT_DBFIX_README.md new file mode 100644 index 0000000..24b2f0b --- /dev/null +++ b/E2E_FAILURE_HUNT_DBFIX_README.md @@ -0,0 +1,15 @@ +# ClientFlow E2E Failure Hunt DB URL Fix + +Corrige o runner `failure_hunt_audit.py` para: + +- aceitar `DATABASE_URL` no formato SQLAlchemy `postgresql+psycopg://...`; +- converter esse URL para `postgresql://...` antes de chamar `psycopg.connect()`; +- não abortar o relatório se uma secção exploratória falhar; +- continuar a escrever `latest_failure_hunt_report.md` e `latest_failure_hunt_findings.json`. + +Aplicar: + +```bash +unzip -o clientflow_e2e_failure_hunt_dbfix_overlay.zip -d . +chmod +x e2e_test_lab/scripts/*.sh +``` diff --git a/E2E_FAILURE_HUNT_README.md b/E2E_FAILURE_HUNT_README.md new file mode 100644 index 0000000..05c890e --- /dev/null +++ b/E2E_FAILURE_HUNT_README.md @@ -0,0 +1,47 @@ +# ClientFlow Failure Hunt Overlay + +Camada exploratória para procurar falhas adicionais depois do E2E base e do Deep Audit. + +## Objetivo + +Encontrar até 10 achados prioritários sem inventar falsos positivos. O relatório mistura: + +- `HTTP 500` e tracebacks; +- validação ausente em formulários; +- payloads inválidos/extremos; +- IDs inválidos; +- idempotência e concorrência; +- duplicados Chatwoot/webhook; +- possíveis XSS persistentes; +- fuga de segredos/configuração; +- headers de segurança ausentes; +- ações perigosas sem confirmação. + +## Aplicar + +```bash +cd ~/Transferências/clientflow_backend_v4928_1_5_40_with_e2e_test_lab/cf_v1532_work +unzip -o ~/Transferências/clientflow_e2e_failure_hunt_overlay.zip -d . +chmod +x e2e_test_lab/scripts/*.sh +source .venv/bin/activate +pip install -r e2e_test_lab/requirements-e2e.txt +``` + +## Correr + +Com Terminal 1 mocks e Terminal 2 ClientFlow ativos: + +```bash +./e2e_test_lab/scripts/run_failure_hunt.sh | tee e2e_test_lab/latest_failure_hunt_run.log +``` + +## Ver relatório + +```bash +cat e2e_test_lab/latest_failure_hunt_report.md +cat e2e_test_lab/latest_failure_hunt_findings.json +``` + +## Nota + +Este runner é deliberadamente mais agressivo. Nem todo achado é um bug fatal; alguns são validação, hardening ou UX operacional. Deve ser usado para priorizar correções antes de produção. diff --git a/E2E_JASMIN_HEALTH_FIX_README.md b/E2E_JASMIN_HEALTH_FIX_README.md new file mode 100644 index 0000000..e039d1e --- /dev/null +++ b/E2E_JASMIN_HEALTH_FIX_README.md @@ -0,0 +1,5 @@ +# Fix Jasmin /health no E2E Test Lab + +Este overlay adiciona `/health` ao mock Jasmin e torna o `check_e2e_ports.sh` mais tolerante. + +Depois de aplicar, é necessário reiniciar o Terminal 1 dos mocks, porque o processo Python antigo continua em memória. diff --git a/E2E_LLM_MOCK_FIX_README.md b/E2E_LLM_MOCK_FIX_README.md new file mode 100644 index 0000000..46e5a31 --- /dev/null +++ b/E2E_LLM_MOCK_FIX_README.md @@ -0,0 +1,17 @@ +# Fix E2E LLM mock + +Este overlay corrige o mock OpenRouter usado nos testes E2E. + +Problema corrigido: + +- O mock antigo lia o prompt completo, incluindo o system prompt. +- O system prompt contém a lista de ações e a palavra `IGNORE_SPAM` / `spam`. +- Como o mock tinha regra para `spam`, quase todos os casos `/analyze` eram classificados como `IGNORE_SPAM`. + +Correção: + +- O mock agora ignora o system prompt. +- Lê apenas a última mensagem `role=user`. +- Dentro desse prompt, extrai apenas o bloco depois de `Última mensagem do cliente:`. + +Depois de aplicar o overlay, reinicia o Terminal 1 dos mocks. diff --git a/E2E_RUNTIME_FIX_README.md b/E2E_RUNTIME_FIX_README.md new file mode 100644 index 0000000..698e9c7 --- /dev/null +++ b/E2E_RUNTIME_FIX_README.md @@ -0,0 +1,38 @@ +# Fix runtime E2E + +Inclui: + +- `.env.e2e` na raiz, com `APP_NAME="ClientFlow E2E"`. +- `run_mock_servers.sh` a carregar automaticamente `.env.e2e`. +- `start_clientflow_e2e.sh` para arrancar o ClientFlow no ambiente E2E. +- `check_e2e_ports.sh` para confirmar portas antes do runner. +- `run_e2e_lab.sh` com preflight amigável, em vez de traceback longo quando faltam mocks. + +Uso: + +```bash +unzip -o clientflow_e2e_runtime_fix_overlay.zip -d . +chmod +x e2e_test_lab/scripts/*.sh +``` + +Terminal 1: + +```bash +source .venv/bin/activate +./e2e_test_lab/scripts/run_mock_servers.sh +``` + +Terminal 2: + +```bash +source .venv/bin/activate +./e2e_test_lab/scripts/start_clientflow_e2e.sh +``` + +Terminal 3: + +```bash +source .venv/bin/activate +./e2e_test_lab/scripts/check_e2e_ports.sh +./e2e_test_lab/scripts/run_e2e_lab.sh +``` diff --git a/E2E_START_FIX_README.md b/E2E_START_FIX_README.md new file mode 100644 index 0000000..f8a479d --- /dev/null +++ b/E2E_START_FIX_README.md @@ -0,0 +1,38 @@ +# Correção de arranque E2E ClientFlow + +Este overlay corrige dois problemas comuns: + +1. cria `.env.e2e` na raiz do projeto, porque o exemplo estava em `e2e_test_lab/.env.e2e.example`; +2. força o arranque através da `.venv` com `python -m uvicorn`, evitando usar o `uvicorn` instalado no sistema. + +## Como aplicar + +A partir da raiz do projeto `cf_v1532_work`: + +```bash +unzip -o clientflow_e2e_startup_fix_overlay.zip -d . +chmod +x e2e_test_lab/scripts/*.sh +./e2e_test_lab/scripts/bootstrap_e2e.sh +``` + +Depois abre dois terminais: + +Terminal 1: + +```bash +cd /caminho/para/cf_v1532_work +./e2e_test_lab/scripts/start_mocks_e2e.sh +``` + +Terminal 2: + +```bash +cd /caminho/para/cf_v1532_work +./e2e_test_lab/scripts/start_clientflow_e2e.sh +``` + +Quando o ClientFlow estiver ativo em `http://127.0.0.1:8020`, corre: + +```bash +./e2e_test_lab/scripts/run_e2e_lab.sh +``` diff --git a/E2E_WORKFLOW_ANOMALY_HUNT_README.md b/E2E_WORKFLOW_ANOMALY_HUNT_README.md new file mode 100644 index 0000000..d5c1145 --- /dev/null +++ b/E2E_WORKFLOW_ANOMALY_HUNT_README.md @@ -0,0 +1,25 @@ +# ClientFlow E2E Workflow Anomaly Hunt + +Camada de testes funcionais orientada para comportamento de negócio, não para detalhes técnicos. + +Simula: + +- vários orçamentos Jasmin no mesmo momento para o mesmo cliente; +- várias faturas Jasmin para o mesmo NIF/valor; +- várias vendas Odoo para o mesmo cliente, com e sem NIF; +- comprovativo solto; +- pedidos repetidos do mesmo cliente/produto; +- conversa Chatwoot com mensagem duplicada e mudança de intenção para pagamento; +- oportunidades marcadas como WON/LOST com tarefas/documentos pendentes; +- ações operacionais repetidas. + +Gera: + +- `e2e_test_lab/latest_workflow_anomaly_report.md` +- `e2e_test_lab/latest_workflow_anomaly_findings.json` + +Executar: + +```bash +./e2e_test_lab/scripts/run_workflow_anomaly_hunt.sh | tee e2e_test_lab/latest_workflow_anomaly_run.log +``` diff --git a/E2E_WORKFLOW_ANOMALY_HUNT_V2_README.md b/E2E_WORKFLOW_ANOMALY_HUNT_V2_README.md new file mode 100644 index 0000000..3b41dd9 --- /dev/null +++ b/E2E_WORKFLOW_ANOMALY_HUNT_V2_README.md @@ -0,0 +1,23 @@ +# ClientFlow Workflow Anomaly Hunt v2 + +Camada adicional de stress funcional, focada em: + +- follow-ups duplicados, inválidos, em WON/LOST e invisíveis na lista de tarefas; +- oportunidades paralelas para a mesma compra provável; +- regressões perigosas de estado; +- operations/centro de trabalho e todo duplicado/invisível; +- documentos fora de ordem e pagamentos com valor divergente; +- normalização de email/NIF para evitar clientes duplicados. + +Executar: + +```bash +./e2e_test_lab/scripts/run_workflow_anomaly_hunt_v2.sh | tee e2e_test_lab/latest_workflow_anomaly_v2_run.log +``` + +Relatórios: + +```bash +cat e2e_test_lab/latest_workflow_anomaly_v2_report.md +cat e2e_test_lab/latest_workflow_anomaly_v2_findings.json +``` diff --git a/E2E_WORKFLOW_ANOMALY_RUNNER_FIX_README.md b/E2E_WORKFLOW_ANOMALY_RUNNER_FIX_README.md new file mode 100644 index 0000000..673d678 --- /dev/null +++ b/E2E_WORKFLOW_ANOMALY_RUNNER_FIX_README.md @@ -0,0 +1,7 @@ +# Workflow Anomaly Hunt runner/schema fix + +Corrige o runner de anomalias funcionais para: + +- não usar `ON CONFLICT (idempotency_key)` quando a coluna não tem constraint UNIQUE; +- limpar o item do próprio cenário antes de inserir; +- continuar as fases seguintes e escrever relatório mesmo quando uma fase falha. diff --git a/FIX_E2E_COMMAND_NOT_FOUND.md b/FIX_E2E_COMMAND_NOT_FOUND.md new file mode 100644 index 0000000..afdcf64 --- /dev/null +++ b/FIX_E2E_COMMAND_NOT_FOUND.md @@ -0,0 +1,15 @@ +# Fix `E2E: comando não encontrado` + +A causa era esta linha sem aspas no `.env.e2e`: + +```bash +APP_NAME=ClientFlow E2E +``` + +Quando o ficheiro é carregado com `source .env.e2e`, o Bash interpreta `E2E` como se fosse um comando. + +Este overlay substitui por: + +```bash +APP_NAME="ClientFlow E2E" +``` diff --git a/README.md b/README.md index 6f8947c..ccb5a83 100644 --- a/README.md +++ b/README.md @@ -142,3 +142,9 @@ A navegação da UI admin foi reorganizada para separar operação diária de di ## v4.8.4 — Fiscal Link Consistency Hotfix Corrige a leitura do cliente fiscal em Operations: a fila passa a usar o cliente ligado à oportunidade antes de qualquer fallback da task. Isto evita cards que dizem "Cliente fiscal por associar" quando a ficha da task já mostra um cliente fiscal válido. + +## Previsão comercial de receitas + +A versão v4928.1.5.122 disponibiliza uma previsão ponderada do pipeline em `/finance/forecast` e na API interna `/api/internal/revenue-forecast`. + +A previsão combina valor comercial, probabilidade da fase e atividade operacional. É uma ferramenta de gestão comercial e não substitui dados contabilísticos ou de tesouraria. diff --git a/README_OVERLAY.txt b/README_OVERLAY.txt new file mode 100644 index 0000000..f327995 --- /dev/null +++ b/README_OVERLAY.txt @@ -0,0 +1,6 @@ +Overlay para adicionar o ClientFlow E2E Test Lab. + +Copiar/descompactar este conteúdo na raiz do projeto clientflow_backend. +O diretório criado é: e2e_test_lab/ + +Ver instruções completas em: e2e_test_lab/README.md diff --git a/README_OVERLAY_v4928_1_5_61.md b/README_OVERLAY_v4928_1_5_61.md new file mode 100644 index 0000000..11be7c5 --- /dev/null +++ b/README_OVERLAY_v4928_1_5_61.md @@ -0,0 +1,12 @@ +# Overlay v4928.1.5.61 + +Aplicar sobre v4928.1.5.60. + +```bash +cd /mnt/ssd/home/plx/clientflow_backend +unzip -o /caminho/para/clientflow_workflow_decision_alignment_overlay.zip -d . +python3 -m compileall -q app tests +PYTHONPATH=. pytest -q +sudo systemctl restart clientflow-api +sudo systemctl status clientflow-api --no-pager -l +``` diff --git a/README_OVERLAY_v4928_1_5_62.md b/README_OVERLAY_v4928_1_5_62.md new file mode 100644 index 0000000..0927138 --- /dev/null +++ b/README_OVERLAY_v4928_1_5_62.md @@ -0,0 +1,17 @@ +# Overlay v4928.1.5.62 + +Task schema hotfix for production deployments where `opportunities.local_customer_id` is the fiscal/customer link column. + +## Fixes + +- Removes SQL references to non-existing `opportunities.fiscal_customer_id`. +- Keeps the workflow engine alias `fiscal_customer_id` internally by mapping it from `local_customer_id`. +- Fixes `/tasks/` HTTP 500 caused by task detail joins. +- Keeps fiscal/customer inheritance behavior introduced in v1.5.61 without requiring a DB migration. + +## Validate + +```bash +python3 -m compileall -q app tests +PYTHONPATH=. pytest -q +``` diff --git a/README_PLAYWRIGHT_CORS_FIX.txt b/README_PLAYWRIGHT_CORS_FIX.txt new file mode 100644 index 0000000..2f30fa7 --- /dev/null +++ b/README_PLAYWRIGHT_CORS_FIX.txt @@ -0,0 +1,8 @@ +ClientFlow Playwright CORS/Auth fix v1.5.47 + +Aplicar: + unzip -o clientflow_e2e_playwright_cors_fix_overlay.zip -d . + chmod +x e2e_test_lab/scripts/*.sh + +Correr: + ./e2e_test_lab/scripts/run_ui_e2e_playwright.sh | tee e2e_test_lab/latest_playwright_ui_run.log diff --git a/RELEASE_NOTES_STABILIZATION_20260708.md b/RELEASE_NOTES_STABILIZATION_20260708.md new file mode 100644 index 0000000..cfd8d49 --- /dev/null +++ b/RELEASE_NOTES_STABILIZATION_20260708.md @@ -0,0 +1,68 @@ +# ClientFlow Backend — versão de estabilização 2026-07-08 + +## Objetivo +Consolidar a última versão do backend antes de novas funcionalidades, corrigindo falhas P0/P1 encontradas na review: suíte de testes não-verde, bug em `list_tasks()`, divergências no workflow de oportunidades, linking ambíguo, `.env.example` ausente e problemas de coleção do pytest. + +## Correções principais + +### Testes e packaging +- Adicionado `pytest.ini` com `testpaths = tests`, `pythonpath = .` e `--import-mode=importlib`. +- Removido o teste duplicado de raiz `test_v4928_1_5_72_opportunity_consistency_static.py` para evitar `import file mismatch`. +- Adicionado `.env.example` com flags obrigatórias e opcionais documentadas. +- Consolidado teste estático contraditório de `workflow_guard`: a implementação mantém compatibilidade com schema sem coluna `completed_at` e continua a aceitar `completed_at` quando a evidência vem em payload/dict. +- Consolidado teste de materialização `PREPARE_ORDER` com a regra de arquitetura que remove esse código da triagem LLM em `action_catalog.py`. + +### `task_service.py` +- Corrigido bug P0 em `list_tasks()`: `customer_column` agora é calculado antes do SQL através de `opportunity_customer_column()` e tem fallback seguro para `local_customer_id`. +- Removida duplicação acidental de coluna em `get_task_detail()`. + +### Workflow de oportunidades +- `WAIT_PRODUCTION` volta a mostrar a label operacional “Aguardar produção”, mantendo alias/compatibilidade UI para “Aguardar WH/OUT”. +- Fluxo `after_delivery` com envio já criado e pagamento por confirmar passa a sugerir `FOLLOW_UP_PAYMENT`, em vez de voltar para `PREPARE_ORDER`. +- Mantida prioridade de fecho/entrega sobre estados de produção quando WH/OUT/picking já está concluído. +- Evidência de fatura enviada continua a considerar payload do documento e tasks `SEND_INVOICE` concluídas. + +### UI/admin +- A página de oportunidades filtra tasks de pagamento/follow-up obsoletas quando o pagamento já está confirmado. +- O resumo operacional passa a usar `display_next_action_code` para evitar fallback para `last_action_code` antigo. +- Adicionada mensagem explícita quando o Jasmin existe mas não tem novos campos fiscais para importar. +- `admin_dashboard.py` ficou abaixo do limite legado de 1800 linhas sem alterar rotas principais. + +### Segurança funcional / linking +- Ambiguidade de oportunidades por contacto Chatwoot passa a usar razão explícita `multiple_recent_open_opportunities_for_chatwoot_contact`. +- `action_catalog.py` mantém `PREPARE_ORDER` fora do catálogo de triagem LLM; o código interno é tratado dinamicamente para compatibilidade operacional. + +### Outros ajustes +- Corrigidos warnings de `compileall` por escapes inválidos em SQL LIKE/ESCAPE. +- Adicionado marcador de identificação ao script `probe_jasmin_print_layout_catalog.py`. + +## Validação local + +Com variáveis de teste: + +```bash +OPENROUTER_API_KEY=test \ +DATABASE_URL=postgresql+psycopg://u:p@localhost:5432/db \ +CLIENTFLOW_ADMIN_TOKEN=test \ +PYTHONPATH=. \ +pytest -q +``` + +Resultado: + +```text +463 passed in 1.12s +``` + +Compilação: + +```bash +python -m compileall -q app scripts tests +``` + +Resultado: sem erros e sem warnings reportados. + +## Limitações +- Não foram executadas integrações reais com Postgres, Jasmin, Odoo, Packlink, Chatwoot ou OpenAI/OpenRouter. +- A validação foi feita por testes locais/estáticos/unitários e compilação Python. +- Antes de produção, correr migrações e smoke tests contra uma base staging. diff --git a/RELEASE_NOTES_v4928_1_4_5.md b/RELEASE_NOTES_v4928_1_4_5.md new file mode 100644 index 0000000..6c98bca --- /dev/null +++ b/RELEASE_NOTES_v4928_1_4_5.md @@ -0,0 +1,34 @@ +# ClientFlow backend v4928.1.4.5 — outbox claim psycopg hotfix + +Hotfix para o worker `clientflow-outbox-jasmin.service` falhar antes de reclamar itens pendentes da `integration_outbox` com: + +```text +psycopg.errors.AmbiguousParameter: could not determine data type of parameter $1 +LINE 6: AND ($1 IS NULL OR target_system = $1) +``` + +## Alteração + +- `app/integration_outbox_service.py` + - `claim_pending_outbox()` deixa de usar a expressão SQL opcional `(:target_system IS NULL OR target_system = :target_system)`. + - A cláusula `AND target_system = :target_system` passa a ser adicionada apenas quando `target_system` existe. + +## Validação local + +```bash +python3 -m compileall -q app scripts +``` + +## Teste recomendado em produção + +```bash +cd /mnt/ssd/home/plx/clientflow_backend + +OUTBOX_TARGET_SYSTEM=jasmin \ +JASMIN_OUTBOX_ENABLED=true \ +OUTBOX_DRY_RUN=false \ +.venv/bin/python scripts/process_outbox.py + +sudo systemctl restart clientflow-outbox-jasmin.service +sudo systemctl status clientflow-outbox-jasmin.service --no-pager -l +``` diff --git a/RELEASE_NOTES_v4928_1_4_6.md b/RELEASE_NOTES_v4928_1_4_6.md new file mode 100644 index 0000000..259e07e --- /dev/null +++ b/RELEASE_NOTES_v4928_1_4_6.md @@ -0,0 +1,65 @@ +# ClientFlow backend v4928.1.4.6 — auto-create Jasmin customer before quotation + +Hotfix/feature curta para o fluxo Jasmin `create_quotation`. + +## Problema + +O worker Jasmin passou a processar corretamente a outbox, mas falhava ao criar orçamento quando o cliente fiscal validado no ClientFlow ainda não existia como Customer no Jasmin: + +```text +Jasmin error 400 em GET /salesCore/customerParties/getCustomerByCompanyTaxId/: +The company tax id provided does not correspond to an existing Customer. +``` + +## Alteração + +- `app/jasmin_service.py` + - Reconhece esse erro específico de Customer inexistente como caso esperado. + - Quando o cliente fiscal local está validado e tem dados mínimos, cria automaticamente o Customer no Jasmin antes do orçamento. + - Mantém erro explícito se faltarem dados obrigatórios para criar Customer: NIF, nome fiscal, morada, código postal ou cidade. + - A confirmação pós-criação por NIF deixa de bloquear o fluxo se o Jasmin ainda não indexou imediatamente o novo Customer; nesses casos usa a `partyKey` enviada e grava `confirm_warning` em metadata. + +## Guardrails + +- Outros erros Jasmin continuam a falhar normalmente. +- Não cria Customer sem NIF. +- Não cria Customer sem nome fiscal. +- Não cria Customer sem morada fiscal mínima. +- O orçamento só é criado depois de existir `jasmin_customer_party_key` local. + +## Validação local + +```bash +python3 -m compileall -q app scripts tests +pytest -q tests/test_v4928_1_4_6_jasmin_autocustomer_static.py +``` + +Resultado local: + +```text +3 passed +``` + +## Teste recomendado em produção + +Reprocessar o item que falhou por Customer inexistente: + +```bash +sudo -u postgres psql -d clientflow -c " +UPDATE integration_outbox +SET status='pending', + retry_count=0, + last_error=NULL, + locked_at=NULL, + lock_owner=NULL, + updated_at=now() +WHERE id='97a36017-4ff1-4ace-a1b0-b90cc5c5cf84'; +" + +OUTBOX_TARGET_SYSTEM=jasmin \ +JASMIN_OUTBOX_ENABLED=true \ +OUTBOX_DRY_RUN=false \ +.venv/bin/python scripts/process_outbox.py +``` + +Depois confirmar se o item fica `sent` e se foi criado o documento Jasmin na oportunidade. diff --git a/RELEASE_NOTES_v4928_1_4_9.md b/RELEASE_NOTES_v4928_1_4_9.md new file mode 100644 index 0000000..21975f8 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_4_9.md @@ -0,0 +1,36 @@ +# Release Notes — ClientFlow v4928.1.4.9 + +## Tema + +Opportunity Reply Assistant: responder ao cliente a partir da tarefa/oportunidade, usando modelos comerciais e anexos já associados à oportunidade. + +## Alterações principais + +- Adicionado catálogo de modelos comerciais em `app/message_templates.py`. +- Adicionado serviço `app/reply_assistant_service.py` para gerar rascunhos, validar contexto e enviar resposta. +- Adicionada tabela `message_drafts` no arranque do schema. +- Adicionado bloco **Resposta ao cliente** na página de detalhe da tarefa. +- Adicionados endpoints: + - `POST /tasks/{task_id}/reply-draft` + - `POST /tasks/{task_id}/send-reply` +- Adicionadas funções públicas de envio Chatwoot: + - `send_public_message()` + - `send_public_message_with_attachments()` +- Adicionado registo outbound em `communications`. +- Adicionado evento `reply_sent` na timeline da oportunidade. +- Adicionada opção de enviar e concluir tarefa no mesmo passo. +- Adicionada variável futura `CLIENTFLOW_REPLY_LLM_ENABLED=false`. + +## Segurança operacional + +- Só permite anexar documentos da mesma oportunidade da tarefa. +- Bloqueia envio sem conversa Chatwoot. +- Bloqueia envio fiscal se cliente fiscal estiver incompleto. +- Bloqueia PDF automático para documentos sem `external_id`. +- Mantém `CHATWOOT_WRITE_ENABLED=false` como travão de segurança por defeito. + +## Testes + +- Adicionado `tests/test_v4928_1_4_9_reply_assistant_static.py`. +- Validação executada: `225 passed`. + diff --git a/RELEASE_NOTES_v4928_1_5_0.md b/RELEASE_NOTES_v4928_1_5_0.md new file mode 100644 index 0000000..097faf5 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_0.md @@ -0,0 +1,28 @@ +# Release notes — ClientFlow v4928.1.5.0 + +## BLIF Knowledge Reply Assistant + +Esta versão implementa o caminho realista para o LLM ter conhecimento do negócio sem fine-tuning. + +### Adicionado + +- `app/business_knowledge/blif_knowledge.json` +- `app/business_knowledge_service.py` +- `app/llm_reply_generator.py` +- `app/reply_safety_validator.py` +- novos templates de conhecimento em `app/message_templates.py` +- painel de conhecimento usado na UI da tarefa +- novas variáveis `CLIENTFLOW_REPLY_LLM_*` + +### Comportamento novo + +- Se a mensagem do cliente pergunta sobre instalação, IVA, entrega, RFID, balanceador, histórico local, condomínio/MOBI.E ou funcionalidades técnicas, o assistente pode sugerir uma resposta sem anexo. +- O LLM via OpenRouter é opcional e desativado por defeito. +- Quando o LLM está desativado, os templates determinísticos com conhecimento BLIF continuam a funcionar. +- O envio para Chatwoot continua sujeito à validação da oportunidade, anexos e regras comerciais. + +### Exemplo corrigido + +Pergunta: “Presumo que o valor é sem instalação, pode confirmar?” + +Resposta sugerida: confirmação de que o valor é do equipamento, sem instalação, com indicação de eletricista qualificado e suporte remoto BLIF. Sem exigir documento/anexo. diff --git a/RELEASE_NOTES_v4928_1_5_1.md b/RELEASE_NOTES_v4928_1_5_1.md new file mode 100644 index 0000000..8702a4b --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_1.md @@ -0,0 +1,40 @@ +# Release Notes — ClientFlow v4928.1.5.1 + +## Task Reply Audit + +Adicionado script para consultar tarefas e verificar, em lote, qual é o pedido do cliente e qual é a resposta sugerida pelo Reply Assistant. + +## Novo ficheiro + +```text +scripts/audit_task_reply_suggestions.py +``` + +## Documentação + +```text +docs/CLIENTFLOW_V4928_1_5_1_TASK_REPLY_AUDIT.md +``` + +## Funcionalidades + +- Consulta tarefas por estado, fila/route, pesquisa textual e limite. +- Gera rascunho de resposta por tarefa usando `generate_reply_draft(..., persist=False)`. +- Não envia mensagens e não conclui tarefas. +- Não persiste rascunhos por defeito. +- Desativa LLM por defeito para evitar custos. +- Exporta relatório em Markdown, CSV ou JSON. +- Inclui pedido do cliente, template escolhido, conhecimento BLIF usado, resposta sugerida, bloqueios e avisos. + +## Comandos úteis + +```bash +python scripts/audit_task_reply_suggestions.py --status pending --format markdown --out /tmp/task_reply_audit.md +python scripts/audit_task_reply_suggestions.py --status all --format csv --out /tmp/task_reply_audit.csv +python scripts/audit_task_reply_suggestions.py --status pending --use-llm --format json +``` + +## Validação + +- `python scripts/audit_task_reply_suggestions.py --help` +- Teste estático dedicado: `tests/test_v4928_1_5_1_task_reply_audit_static.py` diff --git a/RELEASE_NOTES_v4928_1_5_10.md b/RELEASE_NOTES_v4928_1_5_10.md new file mode 100644 index 0000000..7f79a20 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_10.md @@ -0,0 +1,26 @@ +# ClientFlow v4928.1.5.10 — Reply recipient from email signature + +## Objetivo +Corrigir a saudação dos rascunhos quando a oportunidade/cliente fiscal é uma empresa, mas a última mensagem está assinada por uma pessoa. + +## Alterações +- Novo helper `app/reply_recipient_utils.py`. +- Extrai nome pessoal da assinatura da última mensagem do cliente. +- Prioriza a pessoa que assinou a mensagem sobre nome da empresa, cliente fiscal ou oportunidade. +- Evita cumprimentos como `Boa tarde Amorasub,` quando existe `Alexandra Silvestre` na assinatura. +- Expõe ao LLM/OpenRouter contexto separado: + - `recipient.person_name` + - `recipient.company_name` + - `preferred_greeting` + - `greeting_source` +- Expõe ao agente OpenAI/file_search contexto separado: + - `destinatario_resposta.nome_pessoa` + - `destinatario_resposta.nome_empresa` + - `destinatario_resposta.cumprimento_preferido` +- Reforça prompt para follow-ups sobre morada/endereço: confirmar receção da morada e indicar próximo passo concreto, em vez de resposta vaga. +- Badge UI atualizado para `v4928.1.5.10`. + +## Validação +- `python -m compileall -q app scripts tests` +- `pytest -q` +- Resultado local: `275 passed`. diff --git a/RELEASE_NOTES_v4928_1_5_100_jasmin_quotation_invoice_api_probe.md b/RELEASE_NOTES_v4928_1_5_100_jasmin_quotation_invoice_api_probe.md new file mode 100644 index 0000000..a267ff4 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_100_jasmin_quotation_invoice_api_probe.md @@ -0,0 +1,5 @@ +# v4928 1.5.100 — Jasmin ORC→FA API probe + +Adiciona `scripts/probe_jasmin_quotation_invoice_api.py`, uma ferramenta segura para analisar um orçamento Jasmin específico, comparar PDF do ORC e FA ligada, inspecionar campos de layout/report/série e testar variantes controladas do endpoint `POST /billing/invoices/fromQuotation/{id}`. + +Por defeito é read-only. Só cria fatura real quando usado com `--execute-convert` e `--confirm-document` exatamente igual ao orçamento. diff --git a/RELEASE_NOTES_v4928_1_5_101_jasmin_invoice_print_api_probe.md b/RELEASE_NOTES_v4928_1_5_101_jasmin_invoice_print_api_probe.md new file mode 100644 index 0000000..344476e --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_101_jasmin_invoice_print_api_probe.md @@ -0,0 +1,7 @@ +# v4.928.1.5.101 — Probe API de impressão Jasmin FA + +Adiciona `scripts/probe_jasmin_invoice_print_api.py`, auditoria read-only para testar variantes do endpoint `/billing/invoices/{id}/print` com parâmetros de layout/report/papel. + +Objetivo: confirmar se é possível obter PDF A4 via API para faturas FA que, por defeito, saem em formato estreito após conversão ORC→FA. + +Não cria, altera, envia nem cancela documentos. diff --git a/RELEASE_NOTES_v4928_1_5_102_jasmin_print_layout_catalog_probe.md b/RELEASE_NOTES_v4928_1_5_102_jasmin_print_layout_catalog_probe.md new file mode 100644 index 0000000..0eb8087 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_102_jasmin_print_layout_catalog_probe.md @@ -0,0 +1,8 @@ +# v4.928.1.5.102 — Jasmin print layout catalog probe + +Adds a read-only Jasmin API probe to search for print-layout/report catalog endpoints containing UI labels such as "Fatura de Mercadorias" and "Talão de Fatura". + +- New script: `scripts/probe_jasmin_print_layout_catalog.py` +- Searches likely REST/OData catalog endpoints for visible print model labels. +- Optional guarded `--probe-print-post` can test POST `/billing/invoices/{id}/print` with JSON bodies; requires `--confirm-invoice`. +- Does not create documents and does not call `fromQuotation`. diff --git a/RELEASE_NOTES_v4928_1_5_103_jasmin_invoice_print_template_models.md b/RELEASE_NOTES_v4928_1_5_103_jasmin_invoice_print_template_models.md new file mode 100644 index 0000000..98b3605 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_103_jasmin_invoice_print_template_models.md @@ -0,0 +1,5 @@ +# v4.928.1.5.103 — Jasmin invoice print template model probe + +Adds `scripts/probe_jasmin_invoice_print_template_models.py` to test the print model keys visible in the Jasmin UI (`Document`, `Slip`, `SlipWithShippingDetails`) against the invoice `/print` endpoint and `/reporting/templates/list?listname=templates` routes. + +Read-only by default. Optional POST `/print` variants require explicit confirmation. diff --git a/RELEASE_NOTES_v4928_1_5_104_close_when_odoo_done_payment_confirmed.md b/RELEASE_NOTES_v4928_1_5_104_close_when_odoo_done_payment_confirmed.md new file mode 100644 index 0000000..2b8f978 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_104_close_when_odoo_done_payment_confirmed.md @@ -0,0 +1,29 @@ +# v4928.1.5.104 — concluir quando Odoo está fechado e pagamento confirmado + +## Objetivo + +Alinhar o fluxo pós-expedição: por agora, quando o Odoo já indica picking/entrega concluído e o pagamento está confirmado, o ClientFlow deve sugerir **Concluir oportunidade**, não criar/mostrar follow-up de tracking/entrega. + +## Alterações + +- Adiciona `CLOSE_OPPORTUNITY` ao vocabulário de ações do motor central. +- Atualiza regras BLIF: + - `invoice + payment_confirmed + order_shipped/order_delivered` → `CLOSE_OPPORTUNITY`. + - no pagamento pós-entrega, só mantém `FOLLOW_UP_PAYMENT` enquanto o pagamento não estiver confirmado. +- Atualiza o cockpit operacional (`workflow_guard`): + - reconhece `physical_status=shipped/done/delivered` ou pickings Odoo `done` como fluxo físico fechado; + - se houver fatura + pagamento confirmado + Odoo fechado, mostra botão **Concluir oportunidade**; + - deixa de sugerir “Aguardar Odoo” quando Odoo já fechou; + - não mostra “confirmar tracking/entrega” como próxima ação automática neste cenário. +- Mantém bloqueio se pagamento pós-entrega ainda não estiver confirmado. + +## Validação + +```bash +PYTHONPATH=. python -m py_compile \ + app/domain/opportunity_flow/types.py \ + app/domain/opportunity_flow/rules.py \ + app/workflow_guard.py + +PYTHONPATH=. pytest -q tests/test_v4928_1_5_104_close_when_odoo_done_payment_confirmed_static.py +``` diff --git a/RELEASE_NOTES_v4928_1_5_105_close_requires_invoice_sent_and_whout_flow.md b/RELEASE_NOTES_v4928_1_5_105_close_requires_invoice_sent_and_whout_flow.md new file mode 100644 index 0000000..9b809e8 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_105_close_requires_invoice_sent_and_whout_flow.md @@ -0,0 +1,44 @@ +# v4928.1.5.105 — fechar oportunidade só com fatura enviada e WH/OUT concluído + +## Objetivo + +Alinhar a decisão final da oportunidade com o fluxo operacional BLIF: + +- pagamento antes do envio: orçamento → pagamento → fatura → envio fatura → Odoo/WH-OUT → concluir; +- pagamento pós-entrega: Odoo/WH-OUT → fatura → envio fatura → follow-up pagamento → concluir. + +## Alterações + +- `CLOSE_OPPORTUNITY` só é sugerido quando existe: + - fatura associada; + - evidência de fatura enviada ao cliente; + - pagamento confirmado; + - venda Odoo ligada; + - picking/WH-OUT concluído. +- Se WH/OUT está concluído mas a fatura ainda não foi enviada, a próxima ação volta a ser `SEND_INVOICE`. +- Se o pagamento é pós-entrega e WH/OUT/fatura enviada estão OK, mas pagamento ainda não está confirmado, a próxima ação é `FOLLOW_UP_PAYMENT`. +- `FOLLOW_UP_PAYMENT` passa a poder ser materializado automaticamente em task pendente, tal como `SEND_INVOICE`. +- Ordens de fabrico/MO (`WH/MO/...`) deixam de conduzir a decisão principal; ficam apenas como detalhe técnico Odoo. +- O painel Odoo da oportunidade passa a apresentar produção/preparação dentro de `
` técnico, deixando a venda e WH/OUT como sinal operacional principal. +- `workflow_guard.delivered` passa a bloquear fecho sem evidência de fatura enviada. + +## Validação rápida + +```bash +PYTHONPATH=. python -m py_compile \ + app/domain/opportunity_flow/evidence.py \ + app/domain/opportunity_flow/rules.py \ + app/workflow_guard.py \ + app/opportunity_action_task_materializer.py \ + app/admin_ui/pages/opportunities.py \ + scripts/repair_missing_materialized_next_action_tasks.py + +PYTHONPATH=. pytest -q tests/test_v4928_1_5_105_odoo_done_close_requires_invoice_sent_static.py +``` + +## Backfill opcional + +```bash +PYTHONPATH=. python scripts/repair_missing_materialized_next_action_tasks.py +PYTHONPATH=. python scripts/repair_missing_materialized_next_action_tasks.py --apply +``` diff --git a/RELEASE_NOTES_v4928_1_5_106_completed_send_invoice_task_evidence.md b/RELEASE_NOTES_v4928_1_5_106_completed_send_invoice_task_evidence.md new file mode 100644 index 0000000..9c2f3dc --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_106_completed_send_invoice_task_evidence.md @@ -0,0 +1,15 @@ +# v4928.1.5.106 — Completed SEND_INVOICE task as invoice-sent evidence + +Fixes reconstructed opportunities where the invoice had already been sent via a completed `SEND_INVOICE` task, but `commercial_documents.payload` did not carry a local invoice-sent marker. + +## Changes + +- `build_opportunity_evidence()` now infers `invoice_sent=True` from completed `SEND_INVOICE` tasks in the same opportunity. +- Ignored/cancelled/pending send-invoice tasks still do not count. +- If a completed task names a different invoice, it does not count for the current invoice. +- Prevents opportunities like VIDRALGAR from regressing from `CLOSE_OPPORTUNITY` back to `SEND_INVOICE`. + +## Expected behavior + +- Invoice exists + payment confirmed + WH/OUT done + completed SEND_INVOICE task → `CLOSE_OPPORTUNITY`. +- Invoice exists + payment confirmed + WH/OUT done + no invoice-sent evidence → `SEND_INVOICE`. diff --git a/RELEASE_NOTES_v4928_1_5_107_close_button_cockpit_alignment.md b/RELEASE_NOTES_v4928_1_5_107_close_button_cockpit_alignment.md new file mode 100644 index 0000000..d3a8a37 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_107_close_button_cockpit_alignment.md @@ -0,0 +1,22 @@ +# v4928.1.5.107 — close button and cockpit alignment + +Fixes a UI/action mismatch introduced while moving close decisions to the central +next-action engine. + +## Changes + +- The primary `Concluir oportunidade` button now performs the real POST action: + `POST /opportunities/{id}/operations/delivered`. +- The legacy operational cockpit now mirrors the central `get_opportunity_next_action()` + decision when it is passed by the opportunity detail page. +- Prevents cases where the top card says `Concluir oportunidade` but the lower + `Fluxo operacional` still shows an old legacy action such as `Enviar fatura ao cliente`. + +## Expected behavior + +For opportunities with invoice sent + payment confirmed + WH/OUT done: + +- top action: `Concluir oportunidade`; +- cockpit action: `Concluir oportunidade`; +- clicking the button closes/registers the opportunity through the existing + `delivered` operation, subject to workflow guards. diff --git a/RELEASE_NOTES_v4928_1_5_108.md b/RELEASE_NOTES_v4928_1_5_108.md new file mode 100644 index 0000000..4e33c54 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_108.md @@ -0,0 +1,28 @@ +# ClientFlow v4.928.1.5.108 — pipeline centralizado + indicadores WH/OUT + +## Correções + +- A página `/opportunities` passa a usar `get_opportunity_next_action()` nos cards do pipeline. +- Remove labels legacy na listagem como `Enviar tracking` quando o detalhe já recomenda `CLOSE_OPPORTUNITY`. +- Reclassifica visualmente cards por próxima ação central: financeiro, envio/Odoo ou conclusão. +- Corrige indicadores do cockpit operacional: + - `Estado físico Odoo` fica verde quando WH/OUT/picking está `done`/`shipped`. + - `Envio` fica verde quando WH/OUT está concluído no Odoo, mesmo sem Packlink/tracking. + - `Produção`/WH/MO deixa de aparecer no mapa principal; fica apenas em detalhe técnico Odoo. +- Renomeia ação de espera operacional para `Aguardar WH/OUT`, para não sugerir acompanhamento de produção. +- Remove uma duplicação visual de `N resultado(s)` no cabeçalho do quadro. + +## Regra operacional mantida + +Para concluir oportunidade: + +```text +fatura emitida ++ fatura enviada ++ pagamento confirmado ++ venda Odoo ligada ++ WH/OUT done +→ Concluir oportunidade +``` + +WH/MO/produção não bloqueia nem conduz a próxima ação do operador. diff --git a/RELEASE_NOTES_v4928_1_5_109.md b/RELEASE_NOTES_v4928_1_5_109.md new file mode 100644 index 0000000..9dc3a33 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_109.md @@ -0,0 +1,21 @@ +# v4928.1.5.109 — conclusão manual por canal externo + +## Correção + +Tarefas de comunicação/documentos (`SEND_PROFORMA`, `SEND_QUOTE`, `SEND_INVOICE` e follow-ups) sem email nem conversa Chatwoot passam a mostrar uma opção explícita para registar que o contacto/documento foi tratado por canal externo, por exemplo WhatsApp, telefone ou presencial. + +## Impacto + +- Um orçamento Jasmin já criado e enviado fora do ClientFlow pode ser marcado como enviado sem exigir email/Chatwoot. +- A validação fiscal continua visível como aviso, mas não bloqueia o registo manual quando existe documento associado e o operador confirma canal externo. +- A nota de conclusão regista o canal externo usado. + +## Exemplo + +Oportunidade manual criada por pedido WhatsApp, sem email/conversa Chatwoot: + +- `ORC.ORC2026.200` +- task `SEND_PROFORMA` +- operador envia PDF por WhatsApp +- operador usa “Marcar como enviado externamente” +- task passa a `done` e o fluxo avança para confirmação de pagamento. diff --git a/RELEASE_NOTES_v4928_1_5_11.md b/RELEASE_NOTES_v4928_1_5_11.md new file mode 100644 index 0000000..6b913c0 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_11.md @@ -0,0 +1,16 @@ +# ClientFlow v4928.1.5.11 — Fiscal identity match guard + +Correção focada no bug em que uma oportunidade Verifone/Nuno Silva foi auto-associada ao cliente fiscal ERT TÊXTIL por partilhar apenas o token genérico “Portugal”. + +## Correções + +- `fiscal_enrichment_service.py` deixa de tratar tokens fracos como `Portugal`, `S.A.`, `LDA`, `Unipessoal`, `Grupo` como evidência suficiente de correspondência fiscal. +- `Verifone Portugal` já não pode fazer match com `ERT TÊXTIL PORTUGAL, S.A.` por token comum genérico. +- Mantém correspondências reais por nome exato/substrings fortes, por exemplo `Dietimport S.A.` e `DIETIMPORT, S.A.`. +- Se uma sugestão fiscal já foi rejeitada manualmente, o worker evita recriar/aplicar a mesma associação em execuções futuras. +- Badge UI atualizado para `v4928.1.5.11`. + +## Validação + +- `python -m compileall -q app scripts tests` +- `pytest -q` → 278 passed diff --git a/RELEASE_NOTES_v4928_1_5_110.md b/RELEASE_NOTES_v4928_1_5_110.md new file mode 100644 index 0000000..8ca952c --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_110.md @@ -0,0 +1,17 @@ +# v4928.1.5.110 — close guard recognizes completed invoice-send tasks + +Fixes a mismatch where the opportunity detail and next-action engine could show +`CLOSE_OPPORTUNITY`, but the operation button was blocked with “Concluir +oportunidade exige evidência de fatura enviada ao cliente”. + +The workflow guard now treats completed `SEND_INVOICE` tasks for the same current +invoice as local evidence that the invoice was sent to the customer, matching the +central opportunity-flow evidence builder. + +Expected for VIDRALGAR / Maria Cândida style cases: + +- invoice linked +- payment confirmed +- WH/OUT done +- completed `SEND_INVOICE` task +- `Concluir oportunidade` button allowed diff --git a/RELEASE_NOTES_v4928_1_5_111.md b/RELEASE_NOTES_v4928_1_5_111.md new file mode 100644 index 0000000..7c22ce4 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_111.md @@ -0,0 +1,6 @@ +# v4928.1.5.111 — Task external completion scope hotfix + +Corrige `UnboundLocalError: external_completion_html referenced before assignment` ao abrir detalhe de tarefa após a introdução do bloco de conclusão por canal externo. + +- Inicializa `external_completion_html` antes do bloco `completion_html` em `task_detail_bootstrap_page`. +- Mantém a opção de concluir tarefas enviadas por WhatsApp/outro canal externo. diff --git a/RELEASE_NOTES_v4928_1_5_112.md b/RELEASE_NOTES_v4928_1_5_112.md new file mode 100644 index 0000000..dee9687 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_112.md @@ -0,0 +1,16 @@ +# v4928.1.5.112 — workflow guard tasks schema hotfix + +Hotfix para o Internal Server Error em `/opportunities/{id}` depois da v1.5.110. + +## Corrige + +- `app/workflow_guard.py` deixava de abrir a oportunidade quando a tabela `tasks` não tinha coluna `completed_at`. +- A query de evidência de envio de fatura passa a usar apenas colunas existentes no schema atual: `id`, `action_code`, `action`, `note`, `status`, `metadata`. +- A evidência de fatura enviada continua a aceitar `status = done/completed/closed/concluída`. + +## Validação + +```bash +PYTHONPATH=. python -m py_compile app/workflow_guard.py +PYTHONPATH=. pytest -q tests/test_v4928_1_5_112_workflow_guard_tasks_schema_static.py +``` diff --git a/RELEASE_NOTES_v4928_1_5_113.md b/RELEASE_NOTES_v4928_1_5_113.md new file mode 100644 index 0000000..494275c --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_113.md @@ -0,0 +1,7 @@ +# v4928.1.5.113 — Opportunity detail deadlock guard + +Hotfix for opportunity detail pages failing with `500 Internal Server Error` when PostgreSQL raises a transient deadlock while the advanced manual correction card reads `reconciliation_items` during reconciliation/sync jobs. + +- Retries `_opportunity_manual_correction_state()` once on deadlock/lock-timeout errors. +- Falls back to a safe degraded UI message instead of crashing the opportunity page. +- Keeps the correction card auxiliary; the central opportunity flow continues rendering. diff --git a/RELEASE_NOTES_v4928_1_5_115.md b/RELEASE_NOTES_v4928_1_5_115.md new file mode 100644 index 0000000..92db578 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_115.md @@ -0,0 +1,17 @@ +# v4928.1.5.115 — Arquivar spam fora do funil + continuidade follow-ups em cascata + +## Corrige + +- Oportunidades criadas por spam/falso positivo deixam de ser marcadas como `LOST`. +- Novo estado técnico/local: `stage = ARCHIVED`, `status = archived`. +- Metadata marca `exclude_from_funnel = true`, `archived_reason = spam`. +- Oportunidades arquivadas deixam de aparecer nas abertas e não contaminam estatísticas de perda. +- Reclassificar uma task para `IGNORE_SPAM` arquiva automaticamente a oportunidade associada quando for seguro. +- Ignorar uma task já classificada como `IGNORE_SPAM` também tenta arquivar a oportunidade quando for seguro. +- A página da oportunidade ganha ação segura “Arquivar spam/falso positivo”; recusa se houver documentos Jasmin, Odoo, Packlink ou reconciliação externa. +- Inclui script de manutenção: `scripts/archive_spam_opportunities.py`. + +## Mantém + +- Follow-ups em cascata da v1.5.114: cria apenas o próximo follow-up necessário, não três de uma vez. +- Follow-ups param quando a oportunidade avança, fecha, arquiva ou ganha ação mais concreta. diff --git a/RELEASE_NOTES_v4928_1_5_116_prepare_order_materialization_and_odoo_indicators.md b/RELEASE_NOTES_v4928_1_5_116_prepare_order_materialization_and_odoo_indicators.md new file mode 100644 index 0000000..e5b3b07 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_116_prepare_order_materialization_and_odoo_indicators.md @@ -0,0 +1,18 @@ +# v4928.1.5.116 — PREPARE_ORDER materializado e indicadores Odoo corrigidos + +## Correções + +- Materializa `PREPARE_ORDER` como task humana pendente em `operacoes` quando a próxima ação central é preparar/criar/validar venda Odoo. +- Evita que o mapa operacional marque `Venda` como concluída apenas porque já existe fatura/pagamento/stage avançada. A venda Odoo só fica verde com evidência real do snapshot/link Odoo. +- Garante que orçamento Jasmin associado aparece como etapa concluída/histórica no cockpit quando já existe `ORC` ligado. +- Rebaixa a label de stage `ODOO_ORDER_CREATED` de “Venda Odoo criada” para “Encomenda em preparação”, evitando falsa afirmação quando falta S00xxx ligado. +- Atualiza o texto do “Financeiro rápido” para usar evidência de task `SEND_INVOICE` concluída e não pedir novamente envio de fatura quando já foi enviada. + +## Resultado esperado + +Para oportunidades com fatura + pagamento confirmado mas sem venda Odoo ligada: + +- Próxima ação: `Preparar encomenda / Odoo`. +- Task pendente criada: `PREPARE_ORDER` em `operacoes`. +- Mapa: `Venda` fica pendente, `Orçamento` e `Fatura` ficam concluídos. +- Estado não deve afirmar “Venda Odoo criada”. diff --git a/RELEASE_NOTES_v4928_1_5_117_manual_odoo_sale_registration.md b/RELEASE_NOTES_v4928_1_5_117_manual_odoo_sale_registration.md new file mode 100644 index 0000000..4b0b8b1 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_117_manual_odoo_sale_registration.md @@ -0,0 +1,6 @@ +# v4928.1.5.117 — Registo manual do nº de venda Odoo + +- Adiciona formulário explícito para associar uma venda Odoo por número (ex.: `S00308`) no painel Odoo da oportunidade. +- A ação `PREPARE_ORDER` no cockpit deixa de ser apenas navegação e passa a pedir o nº da venda Odoo. +- Ao associar a venda, a task pendente `PREPARE_ORDER` é concluída e o estado WH/OUT é sincronizado. +- A associação não cria nada no Odoo; apenas liga uma venda já existente ao processo ClientFlow. diff --git a/RELEASE_NOTES_v4928_1_5_118.md b/RELEASE_NOTES_v4928_1_5_118.md new file mode 100644 index 0000000..8b4e10a --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_118.md @@ -0,0 +1,10 @@ +# v4928.1.5.118 — delivered guard invoice task text evidence + +Fixes a mismatch where the opportunity detail page could show `CLOSE_OPPORTUNITY` +because a completed invoice-send task was visible to the operator, while the +`/operations/delivered` workflow guard still blocked the action with +"Concluir oportunidade exige evidência de fatura enviada ao cliente." + +The guard now accepts completed legacy task rows that clearly represent invoice +sending by action/note text (for example `Enviar fatura FA.FA2026.143 ao cliente`), +even when old task metadata/action_code is incomplete. diff --git a/RELEASE_NOTES_v4928_1_5_119.md b/RELEASE_NOTES_v4928_1_5_119.md new file mode 100644 index 0000000..65de1df --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_119.md @@ -0,0 +1,8 @@ +# v4928.1.5.119 — delivered guard accepts legacy invoice-send evidence + +Fixes a delivered workflow guard mismatch where the opportunity detail page suggested `CLOSE_OPPORTUNITY`, but the `/operations/delivered` action remained blocked because the guard required normalized current invoice rows before accepting completed `SEND_INVOICE` tasks as invoice-sent evidence. + +Changes: +- Broaden current invoice lookup in `workflow_guard.py`. +- Accept completed same-opportunity invoice-send tasks as local invoice-sent evidence even when legacy/imported invoice rows are not returned by the stricter current invoice query. +- Keep document-number matching when invoice rows are available. diff --git a/RELEASE_NOTES_v4928_1_5_12.md b/RELEASE_NOTES_v4928_1_5_12.md new file mode 100644 index 0000000..6f67d13 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_12.md @@ -0,0 +1,23 @@ +# ClientFlow v4928.1.5.12 — Fiscal enrichment guards from real audit + +Correção baseada na auditoria de 87 oportunidades reais ao serviço de enriquecimento fiscal. + +## Alterações + +- `email_identity_company_internal` deixa de ser match muito forte para auto-aplicação. +- Menções de empresa extraídas do email passam a gerar sugestão pendente, salvo prova adicional por domínio compatível. +- Tokens genéricos/setoriais deixam de validar clientes fiscais por si só: `engenharia`, `construções`, `seguros`, `mediação`, `contabilidade`, `energy`, `power`, `solutions`, etc. +- Lookup fiscal por `customer_name` só corre quando o nome parece empresa; nomes pessoais como `Nuno Silva` ou `Bárbara Gonçalves` são bloqueados. +- Valores inválidos como `pt`, `com`, `geral`, `info`, `mail` não disparam pesquisas fiscais por nome. +- Rejeição manual passa a bloquear a mesma sugestão/NIF, sem impedir uma sugestão correta futura para o mesmo texto de lookup. +- Badge UI atualizado para `v4928.1.5.12`. + +## Casos protegidos + +- `Verifone Portugal` já não faz match com `ERT TÊXTIL PORTUGAL, S.A.` por `Portugal`. +- `Feteira e Torrão Engenharia` já não faz match com `HUASI - ENGENHARIA E CONSTRUÇÕES` por `Engenharia/Construções`. +- `DS Seguros` já não faz match com `Carlos Reis - Mediação de Seguros` apenas por `Seguros/Mediação`. + +## Validação + +- Novos testes funcionais em `tests/test_v4928_1_5_12_fiscal_enrichment_guards.py`. diff --git a/RELEASE_NOTES_v4928_1_5_120.md b/RELEASE_NOTES_v4928_1_5_120.md new file mode 100644 index 0000000..9f8c062 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_120.md @@ -0,0 +1,9 @@ +# v4.928.1.5.120 — delivered guard last-action invoice evidence + +Hotfix for reconstructed opportunities where the detail page shows a completed SEND_INVOICE state but the delivered workflow guard cannot find the legacy task through `tasks.opportunity_id`. + +Changes: +- `workflow_guard.get_workflow_context()` now selects `last_action_code` and `last_task_id`. +- Invoice task lookup also checks `opportunities.last_task_id`. +- Adds a conservative fallback: `last_action_code=SEND_INVOICE` counts as invoice-sent evidence only when an invoice exists and no pending invoice-send task contradicts it. +- Exposes `invoice_sent_last_action_evidence` in the workflow context for debugging. diff --git a/RELEASE_NOTES_v4928_1_5_121.md b/RELEASE_NOTES_v4928_1_5_121.md new file mode 100644 index 0000000..f8581a1 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_121.md @@ -0,0 +1,6 @@ +# v4928.1.5.121 — desbloquear envio manual SEND_INFO + +- Permite enviar no Chatwoot uma mensagem editada pelo operador mesmo quando o rascunho persistido veio do fallback interno “Revisão manual — sem sugestão automática”. +- Para SEND_INFO, normaliza o envio para template cliente “Enviar lista de equipamentos” quando o corpo editado é cliente-facing. +- Mantém bloqueios para bounces, auto-replies e textos internos que não devem ser enviados. +- Usa conversation_id efetivo da task/opportunity/raw_payload para mostrar e executar o envio Chatwoot. diff --git a/RELEASE_NOTES_v4928_1_5_122_STABILIZATION_FORECAST.md b/RELEASE_NOTES_v4928_1_5_122_STABILIZATION_FORECAST.md new file mode 100644 index 0000000..b374389 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_122_STABILIZATION_FORECAST.md @@ -0,0 +1,108 @@ +# ClientFlow v4928.1.5.122 — Stabilization & Revenue Forecast + +Data: 2026-07-14 + +## Objetivo + +Consolidar a versão remota após os hotfixes v4928.1.5.x, corrigir regressões de workflow observadas em produção e introduzir uma previsão comercial auditável de 30/60/90 dias. + +## Correções + +### Orçamento enviado sem documento associado + +Uma task `SEND_QUOTE` concluída passa a contar como evidência de que o orçamento foi enviado, independentemente de o documento Jasmin já estar associado. + +Nestes casos, a próxima ação passa a ser: + +- `RECONCILE_DOCUMENTS` +- **Associar orçamento enviado** + +O motor deixa de sugerir `CREATE_JASMIN_QUOTE`, evitando orçamentos duplicados. + +### Follow-ups alinhados com a fase + +- `SEND_PROFORMA` passa a agendar `FOLLOW_UP_PAYMENT`. +- Em `WAITING_PAYMENT`, `FOLLOW_UP_QUOTE` e `FOLLOW_UP_PROFORMA` são convertidos em `FOLLOW_UP_PAYMENT`. +- Follow-ups de orçamento são encerrados quando existe uma task mais avançada, como `SEND_INVOICE`, `CONFIRM_PAYMENT`, `PREPARE_ORDER` ou `CREATE_SHIPMENT`. +- O agendador bloqueia novas cascatas de orçamento quando a oportunidade já avançou para fatura, pagamento ou operação. + +Script de auditoria/reparação: + +```bash +python scripts/repair_stale_followups.py +python scripts/repair_stale_followups.py --apply +``` + +O script é dry-run por defeito e não envia mensagens. + +### Proteção do enriquecimento fiscal + +Um resultado externo com `match_type=nif_exato` deixa de ser autoaplicado quando o nome empresarial devolvido contradiz o nome empresarial presente na fonte operacional. + +- confiança máxima: 70%; +- `source_name_conflict=true` no payload da sugestão; +- autoaplicação bloqueada; +- decisão fica disponível para revisão manual. + +### Segurança de produção + +Em `prod`, `production` e `staging`: + +- `CLIENTFLOW_ADMIN_TOKEN` é obrigatório; +- UI admin e API interna falham fechadas se a autenticação não estiver configurada; +- `admin_token` por query string é desativado; +- lookup externo ativo exige `EXTERNAL_COMPANY_LOOKUP_API_KEY`. + +### Configuração e testes + +- restaurado `.env.example` sem segredos; +- suíte final: **476 testes passados**; +- `compileall` sem erros. + +## Previsão comercial de receitas + +Novos endpoints: + +- `/finance/forecast` +- `/financeiro/previsao` +- `GET /api/internal/revenue-forecast?limit=1000` + +Modelo: + +```text +valor ponderado = valor da oportunidade × probabilidade da fase × fator de atividade +``` + +Fontes de valor, por prioridade: + +1. documento Jasmin atual/principal; +2. linhas da oportunidade; +3. `opportunities.value_amount`. + +A probabilidade usa taxas históricas quando existem pelo menos oito oportunidades resolvidas que passaram pela fase. Sem amostra suficiente, usa probabilidades explícitas por fase. + +O fator de atividade considera: + +- recência da oportunidade; +- tasks vencidas; +- tasks concluídas recentemente; +- conflitos fiscais/identidade. + +A página apresenta pipeline bruto e ponderado em: + +- até 30 dias; +- 31–60 dias; +- 61–90 dias; +- mais de 90 dias. + +Esta funcionalidade é uma previsão comercial. Não representa receita contabilística, faturação reconhecida ou tesouraria. + +## Aplicação recomendada + +```bash +python -m compileall -q app scripts tests +pytest -q +python scripts/repair_stale_followups.py +python scripts/repair_stale_followups.py --apply +sudo systemctl restart clientflow-api +``` diff --git a/RELEASE_NOTES_v4928_1_5_123_SALES_TARGET_DASHBOARD.md b/RELEASE_NOTES_v4928_1_5_123_SALES_TARGET_DASHBOARD.md new file mode 100644 index 0000000..43f2f15 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_123_SALES_TARGET_DASHBOARD.md @@ -0,0 +1,133 @@ +# ClientFlow v4928.1.5.123 — Meta e desempenho comercial + +Data: 2026-07-16 + +## Objetivo + +Transformar a previsão comercial numa ferramenta de decisão para responder: + +- quanto já conta para a meta; +- quanto está comprometido; +- quanto ainda depende de conversão; +- se a meta mensal está suportada; +- que ações devem ser executadas para reduzir o desvio. + +## Funcionalidades + +### Meta mensal persistente + +Nova tabela aditiva `sales_targets`, criada automaticamente no arranque. + +A meta é configurável por: + +- mês; +- métrica; +- valor; +- moeda. + +Métricas disponíveis: + +- `invoiced` — faturação emitida; +- `cash_received` — pagamentos confirmados; +- `won_sales` — vendas ganhas. + +### Separação sem dupla contagem + +O dashboard distingue: + +- realizado no mês; +- comprometido ainda não realizado; +- pipeline provável; +- valor já realizado noutro período. + +Uma oportunidade já realizada para a métrica escolhida deixa de ser somada novamente no pipeline futuro. + +### Horizonte correto + +São apresentados separadamente: + +- até ao fim do mês civil; +- próximos 30 dias adicionais; +- buckets 30/60/90 dias na API. + +### Semáforo e desvio + +Estados: + +- meta suportada; +- suportada com baixa confiança; +- risco moderado; +- meta não suportada; +- meta não configurada. + +A qualidade dos dados impede que uma previsão com baixa cobertura apareça como verde sem ressalvas. + +### Diagnóstico e ações + +O dashboard identifica: + +- oportunidades sem valor; +- pagamentos pendentes; +- tasks vencidas; +- concentração da previsão; +- desvio para a meta; +- novo pipeline estimado necessário. + +Também produz uma lista das 10 ações com maior impacto. + +### Cenários + +- conservador; +- provável; +- otimista. + +### Probabilidades mais estáveis + +Taxas históricas deixam de substituir integralmente a probabilidade padrão com apenas oito casos. É aplicada suavização entre histórico e prior da fase. + +### Conhecimento para assistente OpenAI + +Incluído: + +`app/business_knowledge/sales_management_knowledge.md` + +O ficheiro define conceitos, prevenção de dupla contagem, interpretação da qualidade e formato recomendado de respostas. Os números vivos devem vir da API interna. + +## Rotas + +UI: + +- `/finance/forecast` +- `/financeiro/previsao` + +Guardar meta: + +- `POST /finance/forecast/target` + +API: + +- `GET /api/internal/revenue-forecast?month=2026-07&metric=invoiced` + +## Validação + +```text +python -m compileall -q app scripts tests +483 passed +``` + +## Instalação + +A release preserva `.env`, `.venv`, dados e logs quando aplicada pelo procedimento `rsync` usado nas versões anteriores. + +Após instalar: + +```bash +cd /mnt/ssd/home/plx/clientflow_backend +source .venv/bin/activate +python -m compileall -q app scripts tests +pytest -q +sudo systemctl restart clientflow-api +curl -I http://127.0.0.1:8020/finance/forecast +``` + +A tabela `sales_targets` é criada automaticamente no arranque. diff --git a/RELEASE_NOTES_v4928_1_5_124_OPERATIONAL_UI_ALIGNMENT.md b/RELEASE_NOTES_v4928_1_5_124_OPERATIONAL_UI_ALIGNMENT.md new file mode 100644 index 0000000..952b522 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_124_OPERATIONAL_UI_ALIGNMENT.md @@ -0,0 +1,34 @@ +# ClientFlow v4928.1.5.124 — Alinhamento operacional e executivo + +## Centro de Trabalho + +- Candidatos Jasmin/Odoo sem associação passam a mostrar primeiro **Validar associação**, antes de ações comerciais ou financeiras. +- O contador **Associações por confirmar** passa a usar o estado real carregado da metadata e também candidatos de reconciliação ainda não ligados. +- Processos antigos/reconstruídos bloqueiam ações sensíveis até validação de cliente, documento, valor e pagamento. +- Códigos técnicos de follow-up são convertidos em rótulos humanos. +- Cada cartão mostra fila, idade/prazo, valor afetado e motivo da prioridade. +- Bloqueios fiscais deixam de impedir a simples validação de uma associação documental. + +## Metas e previsão + +- Nova rota canónica `/forecast` e item próprio no menu principal. +- `/finance/forecast` e `/financeiro/previsao` passam a redirecionar para `/forecast`. +- API canónica `/api/internal/forecast`; o endpoint anterior continua como alias. +- Novo estado **Meta recuperável** quando pagamentos existentes podem cobrir o desvio. +- O novo pipeline necessário passa a ser calculado sobre o desvio residual depois da capacidade de recuperação. +- Cenários passam a distinguir base, aceleração e potencial máximo conhecido. + +## Dashboard inicial + +- Resumo executivo de meta, realizado, previsão e desvio. +- Saúde do funil com oportunidades valorizadas e por valorizar. +- Pagamentos pendentes com valor conhecido. +- Processos prontos para envio ou com envio criado. +- Ações de maior impacto e alertas comerciais/operacionais. +- Referência do modelo operacional atualizada para v4.7. + +## Compatibilidade + +- Sem alterações destrutivas de base de dados. +- Rotas antigas mantidas por redirect/alias. +- A transformação de associação e validação no Centro de Trabalho é apenas de apresentação; não altera tasks automaticamente. diff --git a/RELEASE_NOTES_v4928_1_5_125_FOLLOWUP_LIFECYCLE.md b/RELEASE_NOTES_v4928_1_5_125_FOLLOWUP_LIFECYCLE.md new file mode 100644 index 0000000..272e558 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_125_FOLLOWUP_LIFECYCLE.md @@ -0,0 +1,78 @@ +# ClientFlow v4928.1.5.125 — Follow-up e ciclo de vida comercial + +## Objetivo + +Reduzir o risco de perder oportunidades porque a primeira comunicação não chegou e impedir que oportunidades sem resposta permaneçam indefinidamente no funil ativo. + +## Alterações principais + +- Primeira confirmação no dia útil seguinte ao envio. +- Cadências diferentes para informação, orçamento e pagamento. +- Nova ação interna `CONFIRM_DELIVERY` para validar destinatário, documento, outbox e bounce. +- Estados operacionais: ativa, a aguardar cliente, follow-up vencido, recuperação e acompanhamento futuro. +- Fila e filtro de recuperação na página de oportunidades. +- Filtros para follow-up vencido, inativas, por valorizar e nurture. +- Cartões com valor, dias sem resposta, próximo contacto e número de tentativas. +- Atividade comercial baseada em timestamps próprios, sem usar `updated_at` técnico. +- Reabertura segura por resposta do cliente apenas para `LOST`/`NO_INTEREST` na mesma conversa. +- Motivo obrigatório ao fechar como perdida; timing futuro deve usar nurture. +- Resposta do cliente cancela follow-ups obsoletos e reativa o processo. +- Script de backfill em dry-run por defeito. + +## Cadências + +### Informação e orçamento + +1. Dia útil seguinte: confirmar receção e entrega técnica. +2. +2 dias úteis: interesse/dúvidas. +3. +4 dias úteis: decisão/qualificação. +4. +5 dias úteis: última tentativa. +5. Recuperação. + +### Pagamento + +1. Dia útil seguinte: confirmar receção. +2. +2 dias úteis: pedir data prevista. +3. +3 dias úteis: lembrete. +4. +3 dias úteis: contacto direto/canal alternativo. +5. +5 dias úteis: rever intenção. +6. Recuperação. + +## Base de dados + +A atualização é aditiva. `ensure_opportunity_schema()` cria as colunas em falta: + +- `lifecycle_state` +- `last_customer_activity_at` +- `last_operator_activity_at` +- `last_outbound_sent_at` +- `last_delivery_checked_at` +- `last_delivery_status` +- `last_commercial_activity_at` +- `next_follow_up_at` +- `follow_up_attempts` +- `nurture_until` +- `lost_reason` + +## Migração segura + +Simular: + +```bash +python scripts/sync_opportunity_followup_lifecycle.py --limit 50 +``` + +Aplicar depois de rever: + +```bash +python scripts/sync_opportunity_followup_lifecycle.py --apply --limit 50 +``` + +O script não envia comunicações. Cria somente tasks humanas e nunca usa `updated_at` como atividade comercial. + +## Validação + +```text +compileall: OK +496 testes passados +``` diff --git a/RELEASE_NOTES_v4928_1_5_126_FOLLOWUP_BACKFILL_APPLY_HOTFIX.md b/RELEASE_NOTES_v4928_1_5_126_FOLLOWUP_BACKFILL_APPLY_HOTFIX.md new file mode 100644 index 0000000..7da4d12 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_126_FOLLOWUP_BACKFILL_APPLY_HOTFIX.md @@ -0,0 +1,31 @@ +# ClientFlow v4928.1.5.126 — Follow-up backfill `--apply` hotfix + +Correção incremental sobre a v4928.1.5.125. + +## Problema corrigido + +O comando em modo de simulação funcionava, mas o caminho `--apply` terminava com: + +```text +NameError: name 'engine' is not defined +``` + +A função `_rows()` importava o motor da base de dados apenas no seu âmbito local. O bloco de atualização dentro de `main()` tentava reutilizar esse nome fora do âmbito. + +## Correção + +- Importação explícita de `engine` dentro de `main()` antes do primeiro `engine.begin()`. +- Metadados de migração atualizados para `v4928.1.5.126`. +- Teste funcional do caminho `--apply` com motor e conexão simulados. +- O script continua seguro: não envia mensagens e não fecha oportunidades. + +## Impacto nos dados + +A execução que falhou com `NameError` não alterou oportunidades nem criou tasks, porque o erro ocorreu antes da primeira transação de atualização. + +## Validação + +```text +497 passed +compileall OK +``` diff --git a/RELEASE_NOTES_v4928_1_5_127_1_CONFIRM_DELIVERY_CLEANUP_SQL_HOTFIX.md b/RELEASE_NOTES_v4928_1_5_127_1_CONFIRM_DELIVERY_CLEANUP_SQL_HOTFIX.md new file mode 100644 index 0000000..694bce4 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_127_1_CONFIRM_DELIVERY_CLEANUP_SQL_HOTFIX.md @@ -0,0 +1,30 @@ +# ClientFlow v4928.1.5.127.1 — CONFIRM_DELIVERY cleanup SQL hotfix + +## Correction + +Fixes `scripts/disable_automatic_confirm_delivery.py --apply` on PostgreSQL. + +The cleanup used unqualified `done_at` and `metadata` expressions inside an +`UPDATE tasks ... FROM opportunities`, where both tables expose similarly named +columns. PostgreSQL therefore raised `AmbiguousColumn` and rolled back the +transaction. + +The hotfix qualifies the task-side expressions as: + +- `COALESCE(t.done_at, now())` +- `COALESCE(t.metadata, '{}'::jsonb)` + +## Safety + +- Dry-run behavior is unchanged. +- Manual delivery checks remain preserved. +- No customer communication is sent. +- The failed pre-hotfix execution made no data changes because the transaction + was rolled back. +- The v127 runtime behavior and `CONFIRM_DELIVERY_AUTOMATION_ENABLED=false` + configuration are unchanged. + +## Validation + +- Python compilation passed for the corrected script. +- Static regression assertions verify both qualified column references. diff --git a/RELEASE_NOTES_v4928_1_5_127_CONFIRM_DELIVERY_NOISE_REDUCTION.md b/RELEASE_NOTES_v4928_1_5_127_CONFIRM_DELIVERY_NOISE_REDUCTION.md new file mode 100644 index 0000000..199045e --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_127_CONFIRM_DELIVERY_NOISE_REDUCTION.md @@ -0,0 +1,77 @@ +# ClientFlow v4928.1.5.127 — redução de ruído em `CONFIRM_DELIVERY` + +## Objetivo + +Evitar que o fluxo de follow-up crie uma confirmação de receção para cada comunicação. `CONFIRM_DELIVERY` passa a ser uma exceção manual escolhida pelo operador quando vários contactos sem resposta sugerem um problema real de envio. + +## Alterações + +- `CONFIRM_DELIVERY_AUTOMATION_ENABLED=false` por defeito. +- As cadências automáticas começam diretamente no follow-up adequado: + - informação: `FOLLOW_UP_CUSTOMER_REVIEW` após 2 dias úteis; + - orçamento: `FOLLOW_UP_QUOTE` após 2 dias úteis; + - pagamento: `FOLLOW_UP_PAYMENT` após 2 dias úteis. +- A página da oportunidade mantém **Verificar entrega (manual)**. +- A verificação manual não inicia uma nova cascata automática. +- O backfill: + - não cria `CONFIRM_DELIVERY`; + - exclui oportunidades de valor zero por defeito; + - ignora oportunidades que já tenham qualquer tarefa humana pendente; + - aplica `LIMIT` depois dos filtros de elegibilidade. +- Novo script `scripts/disable_automatic_confirm_delivery.py`: + - dry-run por defeito; + - ignora apenas confirmações automáticas pendentes; + - preserva verificações de entrega criadas manualmente. + +## Atualização recomendada + +```bash +cd /mnt/ssd/home/plx/clientflow_backend +source .venv/bin/activate + +sudo systemctl stop clientflow-api.service + +cp -a /mnt/ssd/home/plx/clientflow_backend \ + /mnt/ssd/backups/clientflow/clientflow_backend_pre_v4928_1_5_127_$(date +%Y%m%d_%H%M%S) + +unzip -o /caminho/clientflow_backend_v4928_1_5_127.zip \ + -d /mnt/ssd/home/plx/clientflow_backend +``` + +Garantir na configuração: + +```env +CONFIRM_DELIVERY_AUTOMATION_ENABLED=false +``` + +Validar e retirar as confirmações automáticas antigas: + +```bash +python scripts/disable_automatic_confirm_delivery.py +python scripts/disable_automatic_confirm_delivery.py --apply +``` + +O backfill v127 é opcional. Rever antes de aplicar: + +```bash +python scripts/sync_opportunity_followup_lifecycle.py --limit 50 +``` + +Não é necessário executar `--apply` imediatamente. O fluxo normal passará a criar os novos follow-ups à medida que as ações comerciais forem concluídas. + +Finalizar: + +```bash +python -m py_compile \ + app/followup_service.py \ + app/config.py \ + scripts/sync_opportunity_followup_lifecycle.py \ + scripts/disable_automatic_confirm_delivery.py + +sudo systemctl start clientflow-api.service +sudo systemctl status clientflow-api.service --no-pager +``` + +## Validação + +Suite completa executada: **502 testes aprovados**. diff --git a/RELEASE_NOTES_v4928_1_5_129_RECONCILIATION_COHERENCE.md b/RELEASE_NOTES_v4928_1_5_129_RECONCILIATION_COHERENCE.md new file mode 100644 index 0000000..2ebd249 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_129_RECONCILIATION_COHERENCE.md @@ -0,0 +1,103 @@ +# ClientFlow v4928.1.5.129 — coerência da Reconciliação e tasks operacionais + +## Objetivo + +Evitar que vendas Odoo já ligadas continuem na Reconciliação com ações obsoletas, impedir regressões da oportunidade e garantir que ações de expedição aparecem no Centro de Trabalho. + +## Alterações + +### Reconciliação Odoo + +- Antes de criar um candidato, o sincronizador procura uma ligação exata em `operation_links` por ID Odoo ou número da venda. +- Uma ligação exata e única faz com que o candidato antigo seja resolvido como `linked`, sem alterar oportunidade, fase, valor ou documentos. +- Mais de uma ligação exata gera `conflict`; nunca é resolvida automaticamente. +- Correspondências por nome, cliente ou valor continuam a exigir decisão do operador. +- A interface passa a indicar vendas já ligadas, candidatos obsoletos resolvidos e conflitos. + +### Proteção contra regressão + +Ao ligar nova evidência a uma oportunidade existente: + +- compara a fase sugerida com a fase atual; +- preserva a fase e a ação atuais quando já estão no mesmo nível ou mais avançadas; +- não cria `SEND_INVOICE` quando já existe fatura Jasmin ligada; +- recalcula a próxima ação pelo motor central depois da transação. + +### Expedição + +- O motor central continua a usar `SHIP_ORDER` como decisão de domínio. +- A task persistida é normalizada para `CREATE_SHIPMENT`. +- Fila: `logistica`. +- Texto do operador: `Enviar encomenda`. +- A task não é concluída por mensagens Chatwoot e não depende de Packlink. + +### Correção dos candidatos atuais + +Novo script: + +```bash +python scripts/apply_reconciliation_coherence_fixes.py +``` + +O script é dry-run por defeito. Só considera seguro um candidato Odoo que tenha exatamente uma ligação exata a uma oportunidade aberta. + +Aplicação direcionada: + +```bash +python scripts/apply_reconciliation_coherence_fixes.py \ + --sale S00324 \ + --sale S00325 \ + --sale S00326 \ + --apply +``` + +O script: + +- resolve os candidatos como já ligados; +- preserva fase, valor, documentos e `operation_links`; +- materializa a próxima task calculada pelo motor central; +- não altera BBKW/S00323 porque não existe ligação exata. + +### Auditoria + +Incluído o auditor read-only v128.1 em: + +```bash +scripts/audit_reconciliation_coherence.py +``` + +Depois da correção: + +```bash +python scripts/audit_reconciliation_coherence.py \ + --days 3 \ + --limit 500 \ + --live odoo,jasmin \ + --max-live 30 +``` + +## Packlink + +A configuração por defeito continua: + +```env +PACKLINK_ENABLED=false +``` + +`CREATE_SHIPMENT` representa trabalho logístico do operador e não ativa Packlink. Packlink só poderá ser usado quando a integração for reativada explicitamente. + +## Segurança + +- dry-run por defeito; +- correspondência automática apenas por ID/número exato; +- exatamente uma oportunidade aberta; +- sem correção automática de diferenças de valor; +- sem criação automática de oportunidade para BBKW; +- sem regressão de fase; +- criação de tasks idempotente. + +## Validação + +```text +508 testes aprovados +``` diff --git a/RELEASE_NOTES_v4928_1_5_13.md b/RELEASE_NOTES_v4928_1_5_13.md new file mode 100644 index 0000000..8115c03 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_13.md @@ -0,0 +1,14 @@ +# ClientFlow v4928.1.5.13 — Task detail refresh after send + complete + +## Fix + +- Fixed task detail UI staying stale after **Enviar e concluir tarefa**. +- The reply send form now targets `#task-detail-panel` on success, so the whole task detail is re-rendered with the fresh DB state. +- This updates the top status badge from `Pendente` to `Concluída` and removes/updates completion controls after `send_and_complete`. +- Reply send errors are retargeted back to `#reply-assistant-panel`, so validation/Chatwoot errors do not replace the full task page. + +## Scope + +- UI/HTMX only. +- No database migration. +- No change to Chatwoot sending, templates or fiscal enrichment rules. diff --git a/RELEASE_NOTES_v4928_1_5_130_ODOO_PHYSICAL_VALIDATION_GATE.md b/RELEASE_NOTES_v4928_1_5_130_ODOO_PHYSICAL_VALIDATION_GATE.md new file mode 100644 index 0000000..d0b034d --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_130_ODOO_PHYSICAL_VALIDATION_GATE.md @@ -0,0 +1,23 @@ +# ClientFlow v4928.1.5.130 — Odoo physical validation gate + +## Problema corrigido + +O estado Odoo `stock.picking.state=assigned` estava a ser interpretado como encomenda pronta para expedição. Em Odoo, `assigned` indica reserva/disponibilidade de stock, não validação física nem conclusão da preparação. + +## Novo fluxo + +1. `assigned` → `ORDER_PREPARATION` / **Validar encomenda física**. +2. O operador confere picking, produtos, quantidades e embalagem. +3. Ao concluir `VALIDATE_PHYSICAL_ORDER`, o ClientFlow regista `odoo:physical_validation=validated`. +4. Só depois o motor emite `SHIP_ORDER`, materializado como `CREATE_SHIPMENT`. +5. `done` continua a significar WH/OUT concluído/expedido. + +## Interface + +- O estado passa a mostrar “Picking reservado — validação física pendente”. +- Tasks logísticas não apresentam compositor de resposta ao cliente. +- A conclusão da validação materializa automaticamente a task de envio seguinte. + +## Migração + +O script `repair_assigned_picking_shipment_tasks.py` converte tasks `CREATE_SHIPMENT` prematuras em `VALIDATE_PHYSICAL_ORDER` e repõe a fase `ORDER_PREPARATION`, apenas quando existe picking `assigned` e não existe validação física. Dry-run por defeito. diff --git a/RELEASE_NOTES_v4928_1_5_132_2_1_REACTIVATION_INITIALIZATION.md b/RELEASE_NOTES_v4928_1_5_132_2_1_REACTIVATION_INITIALIZATION.md new file mode 100644 index 0000000..53a3707 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_132_2_1_REACTIVATION_INITIALIZATION.md @@ -0,0 +1,20 @@ +# ClientFlow v4928.1.5.132.2.1 + +## Correção + +Corrige um `UnboundLocalError` no ramo determinístico de reativação de tasks introduzido na v132.2. + +O ramo de reativação usava `metadata`, `route`, `action_label`, `description` e `priority` antes da respetiva inicialização. A construção do payload canónico da task passa agora a ocorrer antes de qualquer tentativa de reativação ou inserção. + +## Impacto operacional + +- Não altera fases, oportunidades, documentos ou associações durante a instalação. +- Permite que o reparador v132.2 reative corretamente uma task `VALIDATE_PHYSICAL_ORDER` em estado `skipped`/`ignored`. +- Mantém os guards existentes: revisão reconstruída validada, decisão central coincidente, ausência de task pendente e ausência de validação física já confirmada. +- Regista `task_reactivated` e metadata `reactivated_version=v4928.1.5.132.2.1`. + +## Validação + +- `python -m py_compile`: OK +- Teste dinâmico do ramo que falhou na DUNAS: aprovado +- Suite completa: 529 testes aprovados diff --git a/RELEASE_NOTES_v4928_1_5_132_2_DETERMINISTIC_POST_REVIEW_REACTIVATION.md b/RELEASE_NOTES_v4928_1_5_132_2_DETERMINISTIC_POST_REVIEW_REACTIVATION.md new file mode 100644 index 0000000..7e6b032 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_132_2_DETERMINISTIC_POST_REVIEW_REACTIVATION.md @@ -0,0 +1,30 @@ +# ClientFlow v4928.1.5.132.2 + +## Deterministic post-review task reactivation + +### Problem confirmed +A reconstructed review could be completed and persisted as `validated`, while the previously skipped blocked task remained `skipped`. The v132.1 implementation attempted a new INSERT first and only reactivated after an idempotency conflict. In production the post-review hook completed without producing `task_reactivated`. + +### Changes +- Controlled post-blocker flows now search and reactivate an existing skipped/ignored materialized task **before** attempting a new INSERT. +- Reactivation is restricted to: + - `reconstructed_review_completion` + - `physical_validation_completion` + - guarded manual repair `manual_v132_2_reactivation_repair` +- Only tasks with `metadata.materialized_from_next_action=true` are eligible. +- A `task_reactivated` event records source, prior status and version. +- Review completion writes `post_review_task_materialization` to `opportunity_events`, including the central decision and materialization result. +- Added guarded dry-run repair script for opportunities already affected. + +### Safety +The repair aborts unless: +- review state is explicitly `validated`; +- current central decision matches the recorded blocked action; +- no pending task for the action exists; +- a skipped/ignored materialized task exists; +- physical validation has not already been recorded. + +### Validation +- Python compilation: OK +- Focused tests: 6 passed +- Full suite: 528 passed diff --git a/RELEASE_NOTES_v4928_1_5_132_3_INVOICE_EVIDENCE_ALIGNMENT.md b/RELEASE_NOTES_v4928_1_5_132_3_INVOICE_EVIDENCE_ALIGNMENT.md new file mode 100644 index 0000000..628ea3e --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_132_3_INVOICE_EVIDENCE_ALIGNMENT.md @@ -0,0 +1,46 @@ +# ClientFlow v4928.1.5.132.3 — Invoice evidence alignment + +## Problema corrigido + +A oportunidade NOLTIA apresentava `CLOSE_OPPORTUNITY` como próxima ação, mas o endpoint `/operations/delivered` bloqueava com “Concluir oportunidade exige evidência de fatura enviada ao cliente”. + +A task `SEND_INVOICE` tinha sido criada quando o documento principal ainda era o orçamento. Por isso, os campos históricos `metadata.document_id` e `metadata.document_number` continuavam a apontar para `ORC.ORC2026.220`. Ao concluir a task, o ClientFlow anexou a fatura real em `metadata.invoice_delivery_context` (`FA.FA2026.159`), mas o workflow guard ignorava esse contexto autoritativo. + +## Alterações + +- Nova regra comum em `app/invoice_evidence.py` usada pelo motor central e pelo workflow guard. +- `invoice_delivery_context` passa a ter precedência sobre o contexto antigo do orçamento. +- São aceites como identidade da mesma fatura: + - UUID interno de `commercial_documents`; + - `external_id` Jasmin; + - número fiscal da fatura. +- Uma referência autoritativa a outra fatura continua a ser rejeitada. +- O motor central passa a carregar `tasks.metadata` e `commercial_documents.external_id`, eliminando decisões divergentes. +- Mantida a compatibilidade com tasks antigas sem linhas de fatura normalizadas. + +## Dados + +Não existe migração nem alteração automática de dados. A task da NOLTIA já contém evidência suficiente em `invoice_delivery_context`; após instalar o código, o guard deve aceitá-la. + +## Verificação + +```bash +python scripts/verify_v132_3_invoice_sent_evidence.py --self-test +python scripts/verify_v132_3_invoice_sent_evidence.py \ + --opportunity-id 321aab34-31df-44bf-9bcd-7e7d86797598 +``` + +Resultado esperado para a NOLTIA: + +```text +central_action = CLOSE_OPPORTUNITY +invoice_sent_evidence = true +invoice_sent_task_evidence = true +delivered_blocked_reason = null +``` + +## Testes + +- 533 testes aprovados com o módulo de base de dados substituído por stub no ambiente de construção. +- Regressão específica com o payload real da NOLTIA. +- `py_compile` aprovado. diff --git a/RELEASE_NOTES_v4928_1_5_132_4_ODOO_DONE_TASK_RECONCILIATION.md b/RELEASE_NOTES_v4928_1_5_132_4_ODOO_DONE_TASK_RECONCILIATION.md new file mode 100644 index 0000000..2d9d466 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_132_4_ODOO_DONE_TASK_RECONCILIATION.md @@ -0,0 +1,44 @@ +# ClientFlow v4928.1.5.132.4 — Odoo done task reconciliation + +## Problema corrigido + +Quando um picking de saída Odoo passava de `assigned` para `done`, o ClientFlow atualizava a evidência física e a fase, mas podia manter pendentes tasks antigas: + +- `VALIDATE_PHYSICAL_ORDER` +- `CREATE_SHIPMENT` + +A precedência da task pendente fazia a oportunidade continuar a mostrar “Validar encomenda física”, apesar de o WH/OUT já estar concluído e o motor central indicar `CLOSE_OPPORTUNITY`. + +Além disso, `done` era traduzido visualmente para `SHIPMENT_CREATED` (“Envio criado”), quando a evidência real já corresponde a `SHIPPED` (“Enviado”). + +## Correção + +- Novo módulo `app/odoo_delivery_task_reconciliation.py`. +- WH/OUT `done` cria/atualiza evidência explícita `odoo/physical_validation=validated`. +- Tasks pendentes `VALIDATE_PHYSICAL_ORDER` e `CREATE_SHIPMENT` são concluídas automaticamente por evidência externa inequívoca. +- Cada alteração grava `task_auto_completed` e um evento agregado na oportunidade. +- A conclusão automática não executa a cascata normal da task, evitando criar trabalho obsoleto. +- O estado derivado de WH/OUT `done` passa a `SHIPPED`. +- Tanto a sincronização direta Odoo como a reconciliação externa aplicam a mesma regra. + +## Reparação de dados existentes + +O script `scripts/repair_v132_4_odoo_done_stale_tasks.py` é dry-run por omissão e só aplica alterações quando existe evidência inequívoca de entrega Odoo concluída. + +## Segurança + +A correção não: + +- chama o Odoo em modo de escrita; +- cria envio Packlink; +- confirma entrega ao cliente; +- fecha automaticamente a oportunidade; +- altera documentos, pagamentos ou clientes. + +Depois da correção, a próxima ação central esperada é `CLOSE_OPPORTUNITY`, sujeita aos guards existentes de fatura enviada e pagamento. + +## Validação + +- `py_compile`: OK +- testes específicos v132.4: 3 aprovados +- suite completa: 536 aprovados diff --git a/RELEASE_NOTES_v4928_1_5_132_OPERATIONAL_COHERENCE.md b/RELEASE_NOTES_v4928_1_5_132_OPERATIONAL_COHERENCE.md new file mode 100644 index 0000000..642441d --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_132_OPERATIONAL_COHERENCE.md @@ -0,0 +1,94 @@ +# ClientFlow v4928.1.5.132 — Coerência operacional e precedência do Centro de Trabalho + +## Objetivo + +Corrigir as incoerências identificadas pela auditoria forense entre: + +- estado físico Odoo; +- fase persistida da oportunidade; +- revisão de processos reconstruídos; +- task pendente; +- ação apresentada no Centro de Trabalho; +- candidatos de reconciliação já ligados. + +A atualização não altera valores comerciais, documentos, pagamentos nem associações de clientes fiscais. + +## Correções de código + +### Odoo `assigned` + +- `assigned` passa a significar apenas stock/picking reservado. +- A fase segura é `ORDER_PREPARATION` enquanto não existir validação física. +- Só uma validação física explícita permite `READY_TO_SHIP`. +- Só um envio/tracking real permite `SHIPMENT_CREATED`. +- O sincronizador pode corrigir de forma controlada uma fase demasiado avançada quando não existe validação, tracking nem envio. +- Evidência `physical_validation` deixa de ser criada a partir de `assigned`. + +### Revisão de processos reconstruídos + +- Novo estado persistido: `required`, `validated` ou `waived`. +- O título ou `clientflow_record_mode` deixam de ser suficientes para inventar um bloqueio. +- `VALIDATE_PHYSICAL_ORDER` passa a ser tratada como ação sensível. +- Uma revisão concluída não volta a abrir apenas porque o título continua histórico. +- Tasks ignoradas ou saltadas não contam como revisão concluída. + +### Precedência das ações + +A primeira ação apresentada passa a respeitar: + +1. associação explicitamente bloqueante; +2. revisão reconstruída explicitamente obrigatória; +3. task pendente executável; +4. decisão central seguinte. + +A lista e o detalhe das Oportunidades usam a mesma política. + +### Reconciliação + +- candidatos já ligados podem ser resolvidos por script com guards; +- exige exatamente uma ligação externa; +- exige NIF ou nome coerente por defeito; +- identidade desconhecida fica para revisão; +- `S00323` não é resolvido automaticamente sem confirmação explícita. + +## Script de migração + +`scripts/apply_v132_operational_coherence.py` + +Características: + +- dry-run por defeito; +- filtros independentes para oportunidades e candidatos; +- transação protegida; +- idempotente; +- relatório JSON e Markdown; +- não modifica valores, documentos, pagamentos ou clientes. + +Opções principais: + +```text +--focus +--candidate-ref +--skip-opportunities +--skip-candidates +--allow-unknown-identity +--apply +``` + +## Casos atuais esperados + +- NOLTIA: `SHIPMENT_CREATED` → `ORDER_PREPARATION`; revisão reconstruída antes da validação física. +- MAFIROL: `SHIPMENT_CREATED` → `ORDER_PREPARATION`; revisão reconstruída antes da validação física. +- DUNAS: `READY_TO_SHIP` → `ORDER_PREPARATION`; resolver S00318; revisão antes da validação física. +- RICARDO: persistir revisão como validada e manter `SEND_INVOICE`. +- ENVIENERGY: remover o falso override visual de associação e mostrar a task real. +- S00323: permanece para revisão até confirmação da oportunidade BBKW. + +## Validação + +- `py_compile`: OK +- self-test do corretor: OK +- self-test do auditor forense: OK +- suite completa: **522 testes aprovados** + +A atualização não foi executada contra a base de dados de produção durante a preparação do pacote. Deve ser aplicada primeiro em dry-run no servidor. diff --git a/RELEASE_NOTES_v4928_1_5_14_task_reply_lazy.md b/RELEASE_NOTES_v4928_1_5_14_task_reply_lazy.md new file mode 100644 index 0000000..63c221d --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_14_task_reply_lazy.md @@ -0,0 +1,30 @@ +# ClientFlow v4928.1.5.14 — Task reply latency hotfix + +## Objetivo +Reduzir a latência ao abrir tarefas a partir de `/operations`, sobretudo no fluxo "Preparar resposta". + +## Alterações +- A página de detalhe da tarefa já não gera automaticamente o rascunho com IA/LLM ao abrir. +- O painel "Resposta ao cliente" passa a ser lazy: mostra o botão "Gerar rascunho" e só chama o agente/LLM quando o operador clica. +- Ao usar "Enviar e concluir tarefa" via HTMX, o backend redireciona diretamente para `/operations?scope=all` em vez de voltar a renderizar o detalhe completo da tarefa. + +## Impacto esperado +- Abertura de tarefa passa de ~9.5s para ~0.2s nos casos em que a lentidão era causada por `CLIENTFLOW_REPLY_LLM_FIRST_ENABLED=true` / agente de resposta. +- A geração de rascunho continua disponível, mas deixa de bloquear a navegação inicial. + +## Ficheiro alterado +- `app/admin_ui/pages/tasks.py` + +## Aplicação +```bash +cd /mnt/ssd/home/plx/clientflow_backend +unzip -o /caminho/clientflow_backend_v4928_1_5_14_task_reply_lazy.zip +sudo systemctl restart clientflow-api +``` + +Se foi feito teste temporário com `.env.bak_latency_test`, repor primeiro o `.env` correto antes de reiniciar. + +## Validação adicional +- Mantidos os marcadores de compatibilidade `v4928.1.5.13` esperados pela suíte de testes existente. +- `PYTHONPATH=. python -m compileall app scripts tests` +- `PYTHONPATH=. pytest -q` → `285 passed` diff --git a/RELEASE_NOTES_v4928_1_5_15_persist_reply_draft_refresh.md b/RELEASE_NOTES_v4928_1_5_15_persist_reply_draft_refresh.md new file mode 100644 index 0000000..bb3bfc1 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_15_persist_reply_draft_refresh.md @@ -0,0 +1,6 @@ +# v4928.1.5.15 — Persistência de rascunho após refresh + +- Mantém a abertura da tarefa lazy, sem chamada automática ao LLM/OpenAI. +- Ao gerar rascunho, o conteúdo continua a ser gravado em `message_drafts`. +- Ao refrescar a página da tarefa, carrega o último `message_drafts.status = draft` da task sem invocar LLM. +- O operador pode editar, enviar ou enviar e concluir usando o rascunho persistido. diff --git a/RELEASE_NOTES_v4928_1_5_16_reply_revision_workflow.md b/RELEASE_NOTES_v4928_1_5_16_reply_revision_workflow.md new file mode 100644 index 0000000..c9430e3 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_16_reply_revision_workflow.md @@ -0,0 +1,53 @@ +# ClientFlow v4928.1.5.16 — Reply draft revision workflow + +## Objetivo +Melhorar o fluxo de resposta ao cliente para que o operador possa editar, guardar e pedir correções ao rascunho gerado pela IA antes de enviar. + +## Alterações principais + +- A abertura da tarefa continua rápida e não chama LLM automaticamente. +- O botão **Gerar rascunho** chama o agente OpenAI/file_search e persiste o resultado em `message_drafts`. +- Rascunhos persistidos continuam visíveis após refresh da página. +- Adicionado botão **Guardar rascunho** para persistir edições manuais do operador. +- Adicionado painel **Correção com IA** para aplicar instruções ao rascunho atual, por exemplo: + - adicionar preço validado; + - acrescentar ficha técnica/URL quando disponível no contexto; + - tornar a resposta mais curta; + - mencionar suporte remoto ao eletricista; + - ajustar tom/estrutura. +- A correção com IA considera: + - rascunho atual; + - instrução do operador; + - últimas mensagens da conversa; + - dados da tarefa/oportunidade; + - documentos selecionados; + - conhecimento BLIF via OpenAI file_search. +- O envio guarda o texto final do operador no draft antes de enviar para Chatwoot. + +## Segurança/guardrails + +- A IA não deve inventar preços, URLs, anexos, stock, descontos ou condições comerciais. +- Para preços, usa apenas dados explícitos no contexto/base. +- Para fichas técnicas, usa apenas URLs/anexos existentes no contexto/base. +- Para tomada/ficha/adaptações elétricas, responde apenas com informação tecnicamente validada e recomenda validação por eletricista qualificado quando aplicável. + +## Ficheiros alterados + +- `app/admin_ui/pages/tasks.py` +- `app/reply_assistant_service.py` +- `app/email_reply_agent_service.py` +- `app/llm_reply_generator.py` +- `tests/test_v4928_1_5_16_reply_revision_workflow.py` + +## Validação + +```bash +PYTHONPATH=. python -m compileall app scripts tests +PYTHONPATH=. pytest -q +``` + +Resultado esperado: + +```text +288 passed +``` diff --git a/RELEASE_NOTES_v4928_1_5_17_semi_automatic_followups.md b/RELEASE_NOTES_v4928_1_5_17_semi_automatic_followups.md new file mode 100644 index 0000000..3d337f5 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_17_semi_automatic_followups.md @@ -0,0 +1,79 @@ +# Clientflow backend v4928.1.5.17 — Follow-ups semi-automáticos + +Base: `clientflow_backend_v4928_1_5_16_reply_revision_workflow.zip`. + +## Objetivo + +Implementar follow-up de oportunidades como fluxo semi-automático: + +- o sistema agenda e sugere; +- o operador valida, contacta e conclui; +- nenhum email é enviado automaticamente por esta alteração. + +## Principais alterações + +### 1. Novo serviço de follow-up + +Ficheiro novo: `app/followup_service.py`. + +Adiciona ações internas: + +- `FOLLOW_UP_QUOTE` +- `FOLLOW_UP_PROFORMA` +- `FOLLOW_UP_PAYMENT` +- `FOLLOW_UP_CUSTOMER_REVIEW` +- `FOLLOW_UP_GENERIC` + +Estas ações são tarefas internas, com `safe_to_post = false`. + +### 2. Criação automática de tarefas de follow-up + +Quando uma tarefa comercial é concluída, o sistema pode criar automaticamente uma tarefa de follow-up: + +- `SEND_QUOTE` → follow-up de orçamento em D+3; +- `SEND_PROFORMA` → follow-up de pró-forma em D+2; +- `SEND_INVOICE` → follow-up de pagamento/fatura em D+3; +- `SEND_INFO` → follow-up de análise do cliente em D+5. + +### 3. Criação manual pela oportunidade + +Na página de detalhe da oportunidade foi adicionado um bloco para criar follow-up manual, com: + +- tipo de follow-up; +- prazo em dias; +- nota opcional. + +### 4. Fecho e cancelamento automático + +O sistema agora: + +- fecha follow-ups pendentes quando há nova atividade do cliente associada à oportunidade; +- cancela follow-ups pendentes quando a oportunidade é marcada como ganha, perdida ou sem interesse; +- evita criar follow-ups duplicados com a mesma razão/ação na mesma oportunidade. + +### 5. UI de tarefas + +A página de tarefas passa a incluir: + +- separador `Follow-ups`; +- separador `Follow-ups vencidos`; +- chips de vencimento baseados em `due_at`; +- cartão de detalhe com mensagem sugerida; +- botões para adiar 2 ou 7 dias; +- opção de concluir normalmente após contacto humano. + +### 6. Compatibilidade de schema + +`ensure_core_schema()` passa a garantir as colunas: + +- `tasks.priority`; +- `tasks.assigned_to`. + +## Segurança operacional + +A alteração não cria envio automático de email. A comunicação continua dependente do operador. + +## Validação feita + +- `python -m compileall -q app` executado com sucesso. +- O arranque real com base de dados não foi validado neste ambiente por falta das dependências/runtime de PostgreSQL no container local. diff --git a/RELEASE_NOTES_v4928_1_5_18_followup_draft_generator.md b/RELEASE_NOTES_v4928_1_5_18_followup_draft_generator.md new file mode 100644 index 0000000..ad404c9 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_18_followup_draft_generator.md @@ -0,0 +1,35 @@ +# ClientFlow v4928.1.5.18 — Follow-up draft generator + +## Objetivo +Melhorar o fluxo de follow-ups semi-automáticos no Centro de trabalho: a task continua a ser humana, mas o operador passa a ter um botão específico para gerar um rascunho personalizado de follow-up. + +## Alterações principais +- Adicionado botão **Gerar rascunho de follow-up** no bloco `FOLLOW_UP_*` do detalhe da task. +- O botão usa o assistente de resposta existente, mas com templates específicos de follow-up. +- Nada é enviado automaticamente: o rascunho fica editável e deve ser revisto pelo operador. +- O rascunho pode usar cliente, oportunidade, documentos associados e histórico recente da conversa quando disponível. +- Separação mais clara entre: + - mensagem base simples para copiar; + - rascunho personalizado gerado com IA; + - nota interna da task. + +## Templates adicionados/atualizados +- `FOLLOW_UP_QUOTE` +- `FOLLOW_UP_PROFORMA` +- `FOLLOW_UP_PAYMENT` +- `FOLLOW_UP_CUSTOMER_REVIEW` +- `FOLLOW_UP_GENERIC` + +## Rotas/UI +- Nova rota HTMX: + - `POST /tasks/{task_id}/follow-up-draft` +- A rota só aceita tasks `FOLLOW_UP_*`. +- O painel de resposta passa a mostrar linguagem específica para follow-up nas tasks de follow-up. + +## Segurança operacional +- Não envia mensagens automaticamente. +- Não exige documento associado para gerar rascunho de follow-up; se houver documento, pode ser usado como contexto. +- Mantém a validação e edição humana antes de envio. + +## Validação técnica +- `python -m compileall -q app` executado sem erros. diff --git a/RELEASE_NOTES_v4928_1_5_19.md b/RELEASE_NOTES_v4928_1_5_19.md new file mode 100644 index 0000000..2d5a23b --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_19.md @@ -0,0 +1,14 @@ +# ClientFlow Backend v4928.1.5.19 + +Hotfix sobre v4928.1.5.18. + +## Correção + +- Reduzido `app/admin_dashboard.py` removendo linhas em branco/trailing whitespace para cumprir o teste legado `test_v472_routes_are_no_longer_registered_in_admin_dashboard`. +- Mantida a funcionalidade de geração de rascunho de follow-up introduzida em v4928.1.5.18. + +## Validação + +- `python -m compileall -q app` +- `python -m pytest -q` +- Resultado: `288 passed` diff --git a/RELEASE_NOTES_v4928_1_5_2.md b/RELEASE_NOTES_v4928_1_5_2.md new file mode 100644 index 0000000..cdf9167 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_2.md @@ -0,0 +1,18 @@ +# ClientFlow v4928.1.5.2 — Task Reply Audit Import Hotfix + +## Correção + +- Corrige `ModuleNotFoundError: No module named 'app'` ao executar `python scripts/audit_task_reply_suggestions.py` diretamente a partir da pasta do backend. +- O script agora adiciona automaticamente a raiz do projeto ao `sys.path` antes de importar módulos `app.*`. + +## Comando recomendado + +```bash +python scripts/audit_task_reply_suggestions.py --status pending --format markdown --out /tmp/task_reply_audit.md +``` + +## Workaround alternativo para versões anteriores + +```bash +PYTHONPATH=. python scripts/audit_task_reply_suggestions.py --status pending --format markdown --out /tmp/task_reply_audit.md +``` diff --git a/RELEASE_NOTES_v4928_1_5_20.md b/RELEASE_NOTES_v4928_1_5_20.md new file mode 100644 index 0000000..9ae182b --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_20.md @@ -0,0 +1,26 @@ +# v4928.1.5.20 — Dedicated OpenAI follow-up draft prompt + +## Objetivo +Corrige o botão de geração de rascunho de follow-up para usar um modo LLM/OpenAI específico de follow-up, em vez de reutilizar o agente genérico de resposta ao cliente. + +## Alterações principais +- Novo agente OpenAI/file_search dedicado a follow-ups em `email_reply_agent_service.py`. +- Novo schema JSON `FOLLOW_UP_SCHEMA` para forçar resposta estruturada. +- Prompt específico para `FOLLOW_UP_PAYMENT`, `FOLLOW_UP_QUOTE`, `FOLLOW_UP_PROFORMA`, `FOLLOW_UP_CUSTOMER_REVIEW` e `FOLLOW_UP_GENERIC`. +- Para `FOLLOW_UP_PAYMENT`, o prompt proíbe explicitamente frases que mudem o objetivo para confirmação interna de pagamento. +- `generate_reply_draft` usa o agente dedicado para templates `reply_type="follow_up"`. +- Se o agente OpenAI de follow-up falhar ou estiver desligado, mantém o template seguro determinístico e não cai no agente genérico. +- UI atualizada para deixar claro que o botão chama OpenAI: “Gerar rascunho personalizado com IA”. +- Em follow-ups, “Pedido do cliente” passa a “Contexto da tarefa”. +- Em follow-ups, “Concluir” passa a “Marcar follow-up como feito”. +- Badge atualizado para `v4928.1.5.20`. + +## Segurança +- Nada é enviado automaticamente. +- O operador continua a rever, editar/copiar e marcar a task como feita. +- O rascunho não deve incluir notas internas, backfill, metadata, ClientFlow ou informação fiscal incompleta. +- O rascunho não deve inventar preços, documentos, URLs, stock, prazos, descontos ou estado de pagamento. + +## Validação +- `python -m compileall -q app` +- `python -m pytest -q` → 288 passed diff --git a/RELEASE_NOTES_v4928_1_5_21.md b/RELEASE_NOTES_v4928_1_5_21.md new file mode 100644 index 0000000..0e0623b --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_21.md @@ -0,0 +1,21 @@ +# v4928.1.5.21 — Chatwoot message formatting hotfix + +## Fix + +- Preserve operator-written line breaks when sending public messages to Chatwoot. +- Chatwoot renders message content as Markdown-like text, where single newlines can be collapsed into spaces. +- ClientFlow now formats outgoing Chatwoot messages with Markdown hard breaks before posting, so price lists and manually separated lines remain readable after sending. + +## Scope + +- Applies to public Chatwoot sends through: + - `send_public_message` + - `send_public_message_with_attachments` +- Does not change the editable draft shown to the operator. +- Does not alter the saved communication body in ClientFlow; the formatting is applied at the Chatwoot delivery boundary. + +## Validation + +- `python -m compileall -q app` +- `python -m pytest -q` +- Added tests for price-list line break preservation. diff --git a/RELEASE_NOTES_v4928_1_5_22.md b/RELEASE_NOTES_v4928_1_5_22.md new file mode 100644 index 0000000..f1e1317 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_22.md @@ -0,0 +1,24 @@ +# v4928.1.5.22 — Operator Workbench communication UX + +Esta versão inclui o hotfix v4928.1.5.21 de formatação Chatwoot e acrescenta melhorias operacionais no detalhe de tasks/follow-ups. + +## Correções incluídas + +- P1: alerta claro para tarefas de comunicação sem email nem conversa Chatwoot. +- P2: fallback de nome/email no título e listas de tasks: cliente fiscal → oportunidade → contacto/email → título. +- P3: ações práticas no rascunho: guardar, copiar mensagem e criar email na Outbox quando existe email. +- P4: indicação/seleção de documentos da oportunidade antes de gerar rascunho IA; aviso quando há múltiplos documentos. +- P5: notas de backfill passam para contexto interno recolhível; menos ruído técnico no fluxo normal. +- Follow-up payment: prompt OpenAI reforçado para não chamar “pró-forma” a documentos Jasmin `quotation`. + +## Segurança operacional + +- Nada é enviado automaticamente por email. +- “Criar email na Outbox” cria item `email.send_email` para revisão/processamento posterior. +- Envio Chatwoot só aparece quando a task tem conversa Chatwoot associada. +- Se não houver email nem conversa, a UI mostra bloqueio operacional de canal. + +## Validação + +- `python -m compileall -q app` +- `python -m pytest -q` → 290 passed diff --git a/RELEASE_NOTES_v4928_1_5_24.md b/RELEASE_NOTES_v4928_1_5_24.md new file mode 100644 index 0000000..8291ee5 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_24.md @@ -0,0 +1,45 @@ +# ClientFlow v4928.1.5.24 — Contact-person greeting for follow-up drafts + +Incremental update over v4928.1.5.23. + +## Changes + +- Adds deterministic recipient resolution for follow-up drafts from personal email addresses. +- Example: `nuno.silva@verifone.com` resolves to `Nuno Silva`, first name `Nuno`, high confidence. +- Generic mailboxes such as `info@`, `geral@`, `comercial@`, `vendas@`, `escritorio@` are not treated as people. +- Dedicated OpenAI follow-up prompt now receives: + - contact person full name, + - first name, + - confidence, + - source of the inference, + - preferred greeting. +- Generated follow-up drafts are post-processed to preserve the preferred greeting. +- Safe fallback templates also receive the same preferred greeting when OpenAI is disabled/unavailable. +- Task detail UI now shows a small contact-person card when a person was safely resolved. + +## Example + +For a follow-up task with: + +- Company: Verifone Portugal, Lda +- Email: nuno.silva@verifone.com + +The greeting becomes: + +```text +Bom dia Sr. Nuno, +``` + +For generic emails such as `escritorio@eficen.pt`, the greeting remains neutral: + +```text +Bom dia, +``` + +## Validation + +```text +python -m compileall -q app tests scripts +pytest -q +292 passed +``` diff --git a/RELEASE_NOTES_v4928_1_5_25.md b/RELEASE_NOTES_v4928_1_5_25.md new file mode 100644 index 0000000..bbccbb3 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_25.md @@ -0,0 +1,32 @@ +# v4928.1.5.25 — Chatwoot inbound ingestion recovery + +Correção focada no pipeline Chatwoot → ClientFlow. + +## Corrige + +- Emails inbound do Chatwoot são identificados por `payload.message_type = incoming`. +- Deixa de depender de `sender.type = contact`, porque em webhooks de email esse campo pode vir vazio. +- O webhook já não deixa eventos em `processed=false`, `ignored=false`, `processing_error=null` se o processamento levantar exceção. +- Mensagens outgoing do operador sem task pendente deixam de contar como `processing_error`. +- System health passa a mostrar `Chatwoot inbound pendente`. + +## Scripts + +- `scripts/reprocess_pending_chatwoot_raw_events.py` + - Reprocessa inbound pendente de `raw_events`. + - Suporta `--dry-run`, `--limit`, `--include-errors`, `--source-event-id`. +- `scripts/audit_chatwoot_ingestion_gap.py` + - Auditoria inbound correta por `payload.message_type`. + - Separa outgoing de inbound para evitar falsos positivos. + +## Comandos recomendados pós-instalação + +```bash +cd /mnt/ssd/home/plx/clientflow_backend +source .venv/bin/activate +export PYTHONPATH=. + +python scripts/audit_chatwoot_ingestion_gap.py --hours 168 +python scripts/reprocess_pending_chatwoot_raw_events.py --dry-run --limit 50 +python scripts/reprocess_pending_chatwoot_raw_events.py --limit 50 +``` diff --git a/RELEASE_NOTES_v4928_1_5_27.md b/RELEASE_NOTES_v4928_1_5_27.md new file mode 100644 index 0000000..8930b9d --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_27.md @@ -0,0 +1,10 @@ +# v4928.1.5.27 — LLM classifier with recent email context + +- Classificador de actions passa a exigir JSON estruturado do LLM. +- O LLM escolhe apenas dentro da allow-list de actions existente. +- Removida classificação comercial por regras diretas; mantém-se apenas deteção técnica de bounce/NDR. +- Adicionado contexto dos últimos 2 emails públicos antes da mensagem atual. +- Quando o webhook não traz histórico, o classificador usa fallback a raw_events da conversa. +- Classificações com `confidence < 0.80`, JSON inválido ou `needs_human_review=true` caem em `REVIEW_MANUALLY`. +- `CONFIRM_PAYMENT` cobre também “vai pagar / vai enviar comprovativo”, com detalhe em `metadata.payment_intent`. +- Metadata da task passa a guardar `llm_customer_intent`, `llm_evidence`, `llm_history_used`, `llm_confidence` e `payment_intent`. diff --git a/RELEASE_NOTES_v4928_1_5_28.md b/RELEASE_NOTES_v4928_1_5_28.md new file mode 100644 index 0000000..b2011f2 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_28.md @@ -0,0 +1,14 @@ +# v4928.1.5.28 — Chatwoot context SQL + audit allow-list hotfix + +Correções: + +- Corrige erro PostgreSQL `AmbiguousParameter` na query de histórico Chatwoot usada pelo classificador LLM com últimos emails. +- A query passa a fazer `CAST(:current_source_event_id AS TEXT)` para parâmetros nulos. +- Inclui `scripts/audit_system_health.py` no pacote. +- Atualiza auditoria para aceitar actions operacionais válidas (`PREPARE_ORDER`, `REVIEW_RECONCILIATION`). +- Distingue erros reais `processing_exception:*` de eventos benignos `outgoing:*` e `ignored:*`. + +Validação: + +- `python -m compileall -q app scripts tests` +- `python -m pytest -q` diff --git a/RELEASE_NOTES_v4928_1_5_29.md b/RELEASE_NOTES_v4928_1_5_29.md new file mode 100644 index 0000000..a96adf0 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_29.md @@ -0,0 +1,20 @@ +# ClientFlow v4928.1.5.29 — Operator Workbench contact identity guard + +## Correção principal + +Corrige um bug no Centro de trabalho em que o nome `payload.sender.name` vindo do Chatwoot podia aparecer como título principal de várias tasks de empresas diferentes. Exemplo observado: `Alexandre Ruivo` aparecia em vários processos com assuntos `... para a Nova Maquiambiente`, `... para a Granjaluz`, `... para a SÓ FERREIRAS`, etc. + +## O que mudou + +- O Workbench deixa de confiar cegamente em `raw_events.payload.sender.name`. +- Se o mesmo `sender.name` aparecer associado a vários emails diferentes na fila, a identidade é marcada como insegura. +- Em contexto de campanhas/títulos `Carregadores ... para a `, a UI passa a preferir a empresa/processo do título quando a identidade do Chatwoot é insegura. +- Cliente fiscal validado continua a ter prioridade máxima. +- A correção é apenas de apresentação/segurança UI: não altera clientes, contactos, oportunidades, tasks nem associações fiscais. +- Adicionado script read-only `scripts/audit_contact_identity_collisions.py` para encontrar nomes Chatwoot reutilizados em tasks pendentes. + +## Validação + +- `python -m compileall -q app scripts tests` +- `pytest -q` +- Resultado: `304 passed` diff --git a/RELEASE_NOTES_v4928_1_5_3.md b/RELEASE_NOTES_v4928_1_5_3.md new file mode 100644 index 0000000..219e4e4 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_3.md @@ -0,0 +1,38 @@ +# Release v4928.1.5.3 — Intent Gate & Reply Safety Fix + +## Resumo + +Esta versão adiciona uma camada de triagem antes da geração de respostas. O objetivo é impedir que mensagens não comerciais — bounces, RGPD/unsubscribe, sem interesse, atualização de contacto e suporte — caiam em templates comerciais. + +## Novidades + +- Novo `app/reply_intent_gate.py`. +- Integração do `intent_gate` no `reply_assistant_service`. +- Novos templates operacionais e de triagem. +- Fallback seguro para `MANUAL_REVIEW_REQUIRED`. +- Correção do validador de instalação para aceitar negações corretas. +- UI da tarefa mostra “Triagem da mensagem”. +- Auditoria mostra categoria de intenção e contagem por intenção. +- Novos testes estáticos e comportamentais. + +## Validação + +```text +242 passed +``` + +## Próximo passo recomendado + +Executar novamente: + +```bash +python scripts/audit_task_reply_suggestions.py --status pending --format markdown --out /tmp/task_reply_audit_v153.md +``` + +Comparar com o relatório anterior, sobretudo: + +- redução de `SEND_INFO_EQUIPMENT_LIST` indevido; +- bounces classificados como `BOUNCE_EMAIL`; +- pedidos RGPD/remover classificados como `UNSUBSCRIBE_REQUEST`; +- suporte classificado como `SUPPORT_INCIDENT`; +- Varisom classificado como `COMMERCIAL_CLARIFICATION` sem bloqueio falso de instalação. diff --git a/RELEASE_NOTES_v4928_1_5_30.md b/RELEASE_NOTES_v4928_1_5_30.md new file mode 100644 index 0000000..a71e6c2 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_30.md @@ -0,0 +1,17 @@ +# v4928.1.5.30 — Task detail contact/fiscal identity guard + +## Problema corrigido +O Centro de trabalho já tinha proteção contra nomes Chatwoot contaminados, mas o detalhe da task ainda podia mostrar o contacto/fiscal errado como se fosse a empresa correta. Exemplo: processo "... para a Nova Maquiambiente" apresentado como "Enviar orçamento — Alexandre Ruivo" e fiscalmente pronto com NIF/morada de Alexandre Ruivo. + +## Alterações +- Adicionada proteção no detalhe da task para comparar o cliente fiscal ligado com a empresa/processo extraído do título/assunto. +- Quando há mismatch claro, a UI passa a mostrar a empresa/processo como título operacional e mantém o remetente apenas como contacto Chatwoot. +- O painel fiscal deixa de apresentar o cliente fiscal suspeito como confirmado. +- A prontidão fiscal passa a bloquear com "Cliente fiscal por confirmar" para ações documentais. +- Os dados confirmados deixam de mostrar NIF do cliente fiscal suspeito. +- Adicionado aviso explícito: validar/corrigir cliente fiscal antes de emitir orçamento, pró-forma ou fatura. +- Adicionado script read-only: `scripts/audit_task_identity_mismatch.py`. + +## Validação +- `python -m compileall -q app scripts tests` +- `pytest -q` → 307 passed diff --git a/RELEASE_NOTES_v4928_1_5_31.md b/RELEASE_NOTES_v4928_1_5_31.md new file mode 100644 index 0000000..36f0f75 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_31.md @@ -0,0 +1,6 @@ +# v4928.1.5.31 — identity mismatch repair + +- Improves task detail identity guard after fiscal customer detach: if the opportunity customer name is still polluted, the UI prefers the process/company hint from the subject. +- Extends `scripts/audit_task_identity_mismatch.py` with `.env` loading and a safe `--apply` mode. +- `--apply` detaches only unsafe `opportunities.local_customer_id`, updates opportunity display name/email from the process hint/contact email, and records an audit trail in metadata. +- No customer records are deleted. diff --git a/RELEASE_NOTES_v4928_1_5_32.md b/RELEASE_NOTES_v4928_1_5_32.md new file mode 100644 index 0000000..62bc527 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_32.md @@ -0,0 +1,26 @@ +# ClientFlow v4928.1.5.32 — Audit Stabilization Hotfix + +## Objetivo + +Versão pequena de estabilização depois da limpeza operacional e correção de associações fiscal/contacto. + +## Alterações + +- `scripts/audit_task_identity_mismatch.py` ficou conservador: + - só sinaliza oportunidades com `local_customer_id` preenchido; + - não considera suspeito quando o cliente fiscal partilha tokens fortes com o processo/título; + - não considera suspeito quando o domínio do email fiscal coincide com o contacto/oportunidade; + - o `--apply` só desassocia clientes fiscais claramente inseguros. +- `scripts/audit_system_health.py` passa a tratar deadlocks/locks temporários em contagens de DB como `WARN`, sem abortar todo o audit. +- Mantém as proteções anteriores de UI/detalhe contra identidades contaminadas. + +## Não incluído + +- Não altera regras comerciais. +- Não limpa dados automaticamente. +- Não altera tasks já reparadas manualmente. + +## Validação local + +- `python -m compileall -q app scripts tests` +- `python -m pytest -q` diff --git a/RELEASE_NOTES_v4928_1_5_33.md b/RELEASE_NOTES_v4928_1_5_33.md new file mode 100644 index 0000000..389f257 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_33.md @@ -0,0 +1,25 @@ +# ClientFlow v4928.1.5.33 — Follow-up SQL Cast Hotfix + +Correção pequena e segura para agendamento manual de follow-up em oportunidades. + +## Corrigido + +- `app/followup_service.py`: o update de metadata da oportunidade agora faz cast explícito de `task_id` para `TEXT` dentro de `jsonb_build_object`. +- `last_follow_up_scheduled_at` também passa a ser gravado como texto ISO/SQL seguro (`now()::text`) no JSONB. + +## Contexto + +O PostgreSQL podia falhar com: + +```text +psycopg.errors.IndeterminateDatatype: could not determine data type of parameter $1 +``` + +quando a UI criava um follow-up manual e tentava gravar `last_follow_up_task_id` na metadata da oportunidade. + +## Impacto + +- Não altera schema. +- Não altera dados existentes. +- Não muda regras de follow-up. +- Apenas evita erro SQL no update de metadata após a task de follow-up ser criada. diff --git a/RELEASE_NOTES_v4928_1_5_34.md b/RELEASE_NOTES_v4928_1_5_34.md new file mode 100644 index 0000000..058ddce --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_34.md @@ -0,0 +1,58 @@ +# ClientFlow v4928.1.5.34 — Communication Composer + SQL safety foundation + +Atualização conservadora focada em reduzir erros de rascunho/LLM e começar a padronizar SQL seguro. + +## Principais melhorias + +### 1. Communication Composer +- O painel de resposta passa a pedir um **objetivo explícito** antes de gerar rascunho: + - Enviar informação/lista de equipamentos + - Enviar orçamento + - Enviar pró-forma + - Enviar fatura + - Confirmar pagamento/follow-up +- O operador pode escrever **instruções adicionais para a IA** antes da geração. +- Os documentos/anexos da oportunidade ficam disponíveis no Composer antes de chamar IA, não apenas depois do rascunho. +- A instrução do operador é guardada em `message_drafts.operator_instruction`. +- O objetivo escolhido e os documentos selecionados ficam persistidos em `message_drafts.metadata.communication_objective`. + +### 2. Guardrails de rascunho para faturas +- Para `SEND_INVOICE`, o validador bloqueia rascunhos que: + - dizem que a fatura ainda será emitida; + - pedem comprovativo de pagamento indevidamente; + - tratam uma fatura emitida como pró-forma/pagamento pendente. +- Quando uma fatura está selecionada, o sistema recomenda mencionar que segue em anexo. +- O prompt OpenAI/file_search recebe `objetivo_operador` e `instrucao_operador`, para impedir que a IA reinterprete o próximo passo. + +### 3. Base SQL safety +- Novo `app/db_helpers.py` com helpers para serialização JSONB segura. +- Updates internos de estado de draft enviados/falhados passam a usar `metadata || CAST(:metadata_patch AS JSONB)` em vez de `jsonb_build_object` com binds soltos. +- Novo script read-only `scripts/audit_sql_safety.py` para encontrar padrões SQL frágeis: + - `jsonb_build_object(... :param ...)` + - `:param IS NULL` sem cast + - `ANY(:lista)` sem tipo explícito + - concatenação JSONB sem `CAST(:metadata_patch AS JSONB)` + +### 4. Auditoria profunda incluída no pacote +- Incluído `scripts/audit_deep_system.py` com correção de serialização `Decimal`/datas/UUID. + +## Segurança operacional +- Não envia mensagens automaticamente. +- Não altera tasks, oportunidades ou clientes existentes. +- Não executa reparações automáticas. +- A nova auditoria SQL é apenas diagnóstico. + +## Validação +- `python -m compileall -q app scripts tests` +- `python -m pytest -q` +- Resultado local: `312 passed` + +## Comandos úteis pós-instalação + +```bash +python scripts/audit_system_health.py; echo "EXIT_CODE=$?" +python scripts/audit_deep_system.py --window-hours 72 --sample-limit 25; echo "EXIT_CODE=$?" +python scripts/audit_sql_safety.py; echo "EXIT_CODE=$?" +``` + +Nota: `audit_sql_safety.py` pode devolver `EXIT_CODE=1` quando encontra padrões a rever. Isso é esperado nesta fase e não significa falha de runtime. diff --git a/RELEASE_NOTES_v4928_1_5_35.md b/RELEASE_NOTES_v4928_1_5_35.md new file mode 100644 index 0000000..f10c5c8 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_35.md @@ -0,0 +1,25 @@ +# v4928.1.5.35 — Invoice delivery guard + +Hotfix focada em envio de faturas/documentos e clareza operacional. + +## Alta prioridade + +- Mostra explicitamente o estado do PDF/anexo nos documentos usados pelo rascunho: + - `✓ PDF/anexo disponível` + - `⚠ PDF/anexo não disponível` +- Mantém validação de envio: `SEND_INVOICE` exige documento Jasmin com PDF suportado antes de enviar via Chatwoot. +- Ao gerar rascunho de `SEND_INVOICE` com fatura selecionada, sincroniza a task antiga de reconciliação para o contexto correto: + - `Enviar fatura FA... ao cliente` + - nota passa a dizer que o PDF/anexo está disponível ou que falta sincronizar/anexar. +- Adiciona `scripts/sync_invoice_delivery_context.py` para alinhar tasks `SEND_INVOICE` pendentes que já têm fatura atual ligada. + +## Média prioridade + +- O Fluxo operacional passa a marcar `Fatura` como concluída quando existe fatura comercial atual ligada, mesmo que o snapshot operacional antigo ainda não tenha atualizado o cartão. +- O painel de produtos separa linhas manuais atuais das linhas importadas de documentos/Odoo/Jasmin, evitando somar histórico/importações como total operacional atual. + +## Notas + +- Não envia emails automaticamente. +- Não descarrega PDFs em massa na abertura da página; valida suporte por `external_id`/Jasmin e carrega o PDF apenas no envio ou download. +- Mantém compatibilidade com o Composer v1.5.34. diff --git a/RELEASE_NOTES_v4928_1_5_36.md b/RELEASE_NOTES_v4928_1_5_36.md new file mode 100644 index 0000000..234505c --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_36.md @@ -0,0 +1,12 @@ +# v4928.1.5.36 — terminal opportunity flow guard + +Correções focadas em consistência operacional depois de faturação/conclusão: + +- Oportunidades terminais (`WON`, `LOST`, `NO_INTEREST`, `DELIVERED` ou `status=closed`) deixam de recomendar próximas ações/follow-ups. +- Follow-ups pendentes são fechados automaticamente quando a oportunidade é movida para fase terminal. +- Script `scripts/sync_closed_opportunity_followups.py` para corrigir follow-ups antigos ainda pendentes em oportunidades concluídas. +- Mapa operacional passa a inferir `Venda`, `Produção`, `Validação física` e `Fatura` a partir de fase terminal/fatura atual ligada, evitando `Fatura ○` quando existe fatura emitida. +- O chip da decisão seguinte usa o `action_code` da task pendente real quando existe, evitando mostrar `SEND_INVOICE` em tarefas de follow-up. +- O detalhe da oportunidade ignora follow-ups pendentes antigos no contador operacional quando a oportunidade já está fechada. + +Sem envio automático e sem alteração de dados por defeito, exceto quando o script é executado com `--apply`. diff --git a/RELEASE_NOTES_v4928_1_5_37.md b/RELEASE_NOTES_v4928_1_5_37.md new file mode 100644 index 0000000..d6a60ed --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_37.md @@ -0,0 +1,36 @@ +# v4928.1.5.37 — terminal flow guard + Odoo sync panel + +Combina a correção `v4928.1.5.36_terminal_opportunity_flow_guard` com a nova área `v1.5.37_odoo_sync_panel`. + +## Inclui de v1.5.36 + +- Oportunidades em fase terminal deixam de recomendar follow-ups como próxima ação. +- Follow-ups pendentes podem ser fechados em lote quando a oportunidade já está concluída. +- O fluxo operacional passa a marcar fatura como concluída quando existe fatura atual emitida. +- O chip da decisão seguinte usa o `action_code` real da task pendente. + +## Novo em v1.5.37 + +- Novo painel **Estado Odoo** no detalhe da oportunidade. +- Botão **Sincronizar Odoo agora** por oportunidade, equivalente ao fluxo manual de Jasmin. +- Painel mostra venda Odoo ligada, cliente/valor Odoo, estado físico, entregas/pickings e produção/preparação. +- Lista vendas Odoo ligadas/candidatas a partir de `reconciliation_items`. +- Ação para associar uma venda Odoo candidata à oportunidade e atualizar `operation_links`. +- Partial HTMX `/opportunities/{opportunity_id}/partials/odoo-status`. +- Auditoria read-only `scripts/audit_odoo_opportunity_sync.py` para oportunidades avançadas sem venda/estado físico Odoo sincronizado. + +## Segurança operacional + +- O painel não chama Odoo ao abrir a oportunidade; a sincronização é manual para evitar lentidão e efeitos colaterais. +- A associação de candidato Odoo reutiliza a reconciliação existente e não cria documentos fiscais. +- O botão de sincronização apenas lê Odoo e atualiza `operation_links`/estado físico ClientFlow. + +## Validação recomendada + +```bash +python -m compileall -q app scripts tests +python -m pytest -q +python scripts/audit_system_health.py; echo "EXIT_CODE=$?" +python scripts/audit_deep_system.py --window-hours 72 --sample-limit 25; echo "EXIT_CODE=$?" +python scripts/audit_odoo_opportunity_sync.py; echo "EXIT_CODE=$?" +``` diff --git a/RELEASE_NOTES_v4928_1_5_38.md b/RELEASE_NOTES_v4928_1_5_38.md new file mode 100644 index 0000000..fbbdb7d --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_38.md @@ -0,0 +1,33 @@ +# v4928.1.5.38 — Proforma ORC Attachment Guard + +## Objetivo + +Alinhar o Communication Composer com a regra operacional da BLIF/ClientFlow: + +> No ClientFlow, a pró-forma é representada no Jasmin por um orçamento `ORC.*`. + +## Alterações + +- `SEND_PROFORMA` passa a esperar explicitamente documento `quotation`/`ORC.*` Jasmin. +- O template de pró-forma passa a chamar o documento de `orçamento/proforma`. +- O prompt OpenAI/file_search passa a receber a regra: pró-forma = orçamento Jasmin ORC.*. +- O LLM fica proibido de dizer que “a proposta formal será enviada” quando já existe ORC selecionado/anexado. +- A validação de segurança bloqueia respostas `SEND_PROFORMA` sem ORC selecionado. +- A geração de rascunho para documentos passa a exigir PDF/anexo disponível também para `SEND_PROFORMA` e `SEND_QUOTE`, não apenas `SEND_INVOICE`. +- A UI da task passa a distinguir melhor: + - documento de pró-forma; + - orçamento Jasmin ORC; + - PDF disponível; + - selecionado/anexado ao envio. +- O botão de concluir task fiscal (`SEND_PROFORMA`/`SEND_INVOICE`) fica bloqueado quando o cliente fiscal está por confirmar. +- O endpoint de conclusão também valida fiscalmente no servidor antes de concluir tarefas fiscais. + +## Validação + +- `python -m compileall -q app scripts tests` +- `python -m pytest -q` +- Resultado local: `314 passed` + +## Nota + +Esta versão não altera dados automaticamente. Apenas corrige regras de composer, validação e UI. diff --git a/RELEASE_NOTES_v4928_1_5_39.md b/RELEASE_NOTES_v4928_1_5_39.md new file mode 100644 index 0000000..486d87d --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_39.md @@ -0,0 +1,15 @@ +# v4928.1.5.39 — Odoo panel import + draft attachment selection hotfix + +Hotfix read/write behavior unchanged except for draft/document selection persistence. + +## Fixes + +- Fixes opportunity Odoo panel error `name 'engine' is not defined` by importing DB engine/text explicitly. +- Fixes SEND_PROFORMA draft revision/send validation where older persisted drafts showed an ORC selected in compact UI but posted no `selected_document_ids`. +- Centralizes effective selected-document fallback in task UI endpoints so generate/save/revise/send use the same ORC/invoice default. +- Keeps ClientFlow business rule: proforma = Jasmin ORC.* quotation document. + +## Validation + +- `python -m compileall -q app scripts tests` +- targeted import smoke tests diff --git a/RELEASE_NOTES_v4928_1_5_4.md b/RELEASE_NOTES_v4928_1_5_4.md new file mode 100644 index 0000000..104ee4e --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_4.md @@ -0,0 +1,70 @@ +# ClientFlow v4928.1.5.4 — LLM-first Reply Assistant + +## Objetivo + +Corrige a direção da versão anterior para evitar evolução excessivamente rule-based. As regras determinísticas passam a ser guardrails objetivos e o LLM/OpenRouter, quando ativo, passa a interpretar a intenção comercial/técnica e a gerar o rascunho com conhecimento BLIF. + +## Principais alterações + +- Adicionado modo `CLIENTFLOW_REPLY_LLM_FIRST_ENABLED=true`. +- Novo template neutro `LLM_BUSINESS_REPLY` para respostas geradas por LLM. +- Novo `classify_reply_guardrail()` apenas para guardrails objetivos: + - bounces/email devolvido; + - respostas automáticas/out-of-office; + - pedidos RGPD/unsubscribe; + - atualização de contacto. +- O `classify_reply_intent()` antigo continua disponível como fallback determinístico quando LLM está desligado. +- Novo `MessageCleaner` reforçado para remover: + - histórico citado; + - headers de email; + - assinaturas; + - disclaimers; + - rodapés que contaminavam suporte/IVA. +- Prompt OpenRouter reformulado para interpretar primeiro a necessidade real do cliente. +- Contexto LLM passa a incluir: + - mensagem limpa; + - cliente; + - oportunidade; + - documentos selecionados; + - conhecimento BLIF relevante; + - catálogo estruturado de produtos/acessórios; + - tipos de resposta permitidos. +- Normalização LLM inclui: + - `intent`; + - `reply_type`; + - `requires_attachment`; + - `confidence`; + - `customer_need`; + - `recommended_next_action`; + - `knowledge_used`; + - `warnings`. +- Se o LLM falhar em modo LLM-first, a tarefa fica bloqueada para revisão manual, em vez de sugerir uma resposta genérica. +- O script de auditoria passa a mostrar intenção/necessidade/confiança do LLM. + +## Segurança mantida + +- O LLM não envia mensagens automaticamente. +- O operador continua a rever/enviar. +- O validador continua a bloquear promessas proibidas de instalação, anexos/documentos errados e preços suspeitos. +- Sem OpenRouter ativo, o sistema regressa a fallback seguro/determinístico. + +## Configuração + +```env +CLIENTFLOW_REPLY_LLM_ENABLED=false +CLIENTFLOW_REPLY_LLM_FIRST_ENABLED=true +CLIENTFLOW_REPLY_LLM_MODEL= +OPENROUTER_API_KEY= +``` + +Para auditar com LLM: + +```bash +python scripts/audit_task_reply_suggestions.py --status pending --use-llm --format markdown --out /tmp/task_reply_audit_llm.md +``` + +## Validação + +```text +249 passed +``` diff --git a/RELEASE_NOTES_v4928_1_5_40.md b/RELEASE_NOTES_v4928_1_5_40.md new file mode 100644 index 0000000..9d226cd --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_40.md @@ -0,0 +1,15 @@ +# v4928.1.5.40 — scheduled follow-up visibility guard + +## Objetivo +Evitar que follow-ups agendados para uma data futura apareçam no Centro de trabalho como "A fazer agora" imediatamente após a task anterior ser concluída. + +## Alterações +- O Centro de trabalho exclui tasks `FOLLOW_UP_*` com `due_at > now()` da lista principal. +- Follow-ups futuros continuam criados como `pending`, mas ficam apenas agendados até vencerem. +- O total "A fazer agora" passa a refletir apenas itens acionáveis agora. +- A lógica de atraso passa a usar `due_at` quando existe, em vez de apenas `created_at`. +- Quando `due_at <= now()`, o follow-up volta a aparecer como trabalho humano normal. + +## Validação +- `python -m compileall -q app scripts tests`: OK +- `python -m pytest -q`: 314 passed diff --git a/RELEASE_NOTES_v4928_1_5_5.md b/RELEASE_NOTES_v4928_1_5_5.md new file mode 100644 index 0000000..3353b88 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_5.md @@ -0,0 +1,30 @@ +# ClientFlow v4928.1.5.5 — Audit LLM Auth Hotfix + +## Objetivo + +Corrige um problema no script `scripts/audit_task_reply_suggestions.py` em que, quando executado com `--use-llm`, o próprio script podia injetar uma chave placeholder `OPENROUTER_API_KEY=not-used-by-task-reply-audit` antes de `app.config` carregar o `.env`. + +Como variáveis de ambiente têm precedência sobre o ficheiro `.env`, essa placeholder podia ocultar a chave real e fazer as chamadas OpenRouter falharem com HTTP 401 / autenticação ausente. + +## Alterações + +- `--use-llm` deixa de definir `OPENROUTER_API_KEY` placeholder. +- A placeholder só é usada quando o LLM está desligado. +- Se uma placeholder antiga existir no ambiente do processo, é removida antes de importar `app.config`. +- Mantém o modo sem LLM compatível com auditorias read-only sem chave OpenRouter real. + +## Teste recomendado + +```bash +python scripts/audit_task_reply_suggestions.py \ + --status pending \ + --route vendas \ + --use-llm \ + --limit 5 \ + --format markdown \ + --out /tmp/task_reply_audit_llm_vendas.md + +grep -n "LLM:" /tmp/task_reply_audit_llm_vendas.md | head -20 +``` + +O esperado é aparecer `LLM: used` ou metadata de intenção/confiança gerada, em vez de `LLM: fallback` com erro `Missing Authentication header`. diff --git a/RELEASE_NOTES_v4928_1_5_52_document_selection.md b/RELEASE_NOTES_v4928_1_5_52_document_selection.md new file mode 100644 index 0000000..d3c37fe --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_52_document_selection.md @@ -0,0 +1,29 @@ +# v4928.1.5.52 — Seleção granular de documentos por oportunidade + +Correção operacional para casos em que o mesmo cliente tem compras próximas e a reconciliação associa orçamento/fatura/venda ao processo errado. + +## Problema + +Antes, o operador só conseguia desassociar Jasmin/Odoo em bloco. Quando uma oportunidade continha uma fatura correta e um orçamento de outra compra, ou linhas importadas misturadas, não havia forma segura de escolher quais documentos pertenciam à oportunidade. + +## Alterações + +- Adicionadas ações por documento Jasmin na ficha da oportunidade: + - **Definir principal**; + - **Histórico**; + - **Desassociar este**; + - Atualizar nº / PDF continuam disponíveis. +- A desassociação de um documento é local ao ClientFlow: + - não apaga nada no Jasmin; + - remove só a ligação local do documento à oportunidade; + - remove apenas linhas importadas desse documento quando existe referência de origem; + - devolve candidatos de reconciliação desse documento para revisão. +- Candidatos Jasmin passam a permitir **Associar adicional** mesmo quando já existe documento atual, para casos em que orçamento/fatura pertencem à mesma compra. +- Mantém auditoria via `opportunity_events`. + +## Validação + +```text +PYTHONPATH=. pytest -q tests +314 passed +``` diff --git a/RELEASE_NOTES_v4928_1_5_55.md b/RELEASE_NOTES_v4928_1_5_55.md new file mode 100644 index 0000000..3f2ee36 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_55.md @@ -0,0 +1,24 @@ +# v4928.1.5.55 — Opportunity vertical cards layout + +Reorganiza a página da oportunidade mantendo as funcionalidades atuais. + +## Mudanças + +- Remove a navegação duplicada por tabs na oportunidade. +- Mantém a página em duas colunas: + - esquerda: Contexto e evidência; + - direita: Operação e ações humanas. +- Transforma Cliente, Documentos, Produtos, Odoo, Mensagens, Timeline e Técnico em cards verticais normais. +- Adiciona card operacional dedicado para “Associar cliente fiscal”. +- Mantém os anchors internos e compatibilidade dos testes de regressão. + +## Sem mudanças + +- Sem migration. +- Sem alteração de contratos externos. +- Sem alteração de lógica Jasmin/Odoo/Chatwoot. +- Sem alteração de endpoints. + +## Validação + +`PYTHONPATH=. pytest -q tests` → 319 passed. diff --git a/RELEASE_NOTES_v4928_1_5_56.md b/RELEASE_NOTES_v4928_1_5_56.md new file mode 100644 index 0000000..1ad0fc0 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_56.md @@ -0,0 +1,11 @@ +# v4928.1.5.56 — Opportunity Operation Dedup + Customer New Route + +## Fixes +- Removes the duplicated rendered card where **O que fazer agora?** and **Tarefa ativa** repeated the same pending task/action in the opportunity operation sidebar. +- Keeps the active task action inside **O que fazer agora?** with the same button/target, avoiding redundant operator decisions. +- Adds an explicit `/customers/new` page so the **Criar cliente** action no longer falls into `/customers/{customer_id}` and shows `Identificador de cliente inválido.`. +- Updates fiscal association search link to use `/customers?q=...`, matching the customers filter parameter. + +## Tests +- Added regression tests for the non-duplicated operation panel and explicit customer creation route. +- Full test suite: `322 passed`. diff --git a/RELEASE_NOTES_v4928_1_5_57.md b/RELEASE_NOTES_v4928_1_5_57.md new file mode 100644 index 0000000..317722f --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_57.md @@ -0,0 +1,34 @@ +# v4928.1.5.57 — Fluxo comercial flexível e condições de pagamento + +## Objetivo + +Reduzir rigidez do fluxo da oportunidade sem perder os dois percursos reais: + +1. pagamento antes do envio; +2. pagamento após entrega. + +## Alterações + +- O dropdown da oportunidade passa a mostrar uma lista curta de fases comerciais. +- Estados detalhados como fatura, pagamento, Odoo, produção e envio continuam visíveis nos cards de contexto, mas deixam de dominar a alteração manual de fase. +- Novo card **Condições comerciais** na coluna Operação. +- Condição de pagamento persistida em `opportunities.metadata`: + - `before_shipping` — antes do envio; + - `after_delivery` — após entrega; + - `agreement` — conforme acordo; + - `undefined` — a definir. +- Condição de entrega persistida em `opportunities.metadata`. +- UI mostra aviso contextual: + - pagamento pós-entrega não bloqueia preparação/envio; + - pagamento antes do envio bloqueia expedição sem confirmação. + +## Compatibilidade + +- Sem migrations. +- Sem alteração de tabela. +- Sem alterar lógica de Odoo/Jasmin/Chatwoot. +- Estados antigos continuam válidos e aceites no backend. + +## Validação + +- `325 passed` diff --git a/RELEASE_NOTES_v4928_1_5_58.md b/RELEASE_NOTES_v4928_1_5_58.md new file mode 100644 index 0000000..9acce98 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_58.md @@ -0,0 +1,6 @@ +# v4928.1.5.58 — task opportunity navigation + BLIF commercial defaults + +- Adds a safe opportunity navigation fallback in task detail pages: when a task is not directly linked to an opportunity, the operator gets a prefilled opportunity search link instead of an empty/absent link. +- Keeps the direct “Ver oportunidade” button when the task is linked to an opportunity. +- Sets BLIF default commercial terms for opportunities to payment before shipping and carrier delivery. +- No migrations; values are still stored in opportunity metadata when the operator changes them. diff --git a/RELEASE_NOTES_v4928_1_5_59.md b/RELEASE_NOTES_v4928_1_5_59.md new file mode 100644 index 0000000..67fd64b --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_59.md @@ -0,0 +1,50 @@ +# ClientFlow v4928.1.5.59 — Fluxo BLIF orçamento → pagamento → fatura + +Atualização incremental ao fluxo financeiro da oportunidade. + +## Objetivo +Alinhar a UI e a lógica operacional ao fluxo normal BLIF: + +1. Enviar informação +2. Criar/enviar orçamento +3. Confirmar pagamento +4. Criar/enviar fatura +5. Preparar encomenda +6. Enviar encomenda +7. Concluir + +Mantém o fluxo alternativo de pagamento após entrega através das condições comerciais. + +## Alterações principais + +- Remove “pró-forma” como etapa principal da oportunidade. +- O dropdown de fase comercial passa a usar: + - Novo pedido + - Informação enviada + - Orçamento enviado + - A aguardar pagamento + - Pagamento confirmado + - Encomenda confirmada / em execução + - Concluído + - Perdido + - Rever +- Adiciona card “Financeiro rápido” na coluna Operação. +- Permite confirmar pagamento com base em orçamento associado, sem exigir fatura prévia. +- Depois de pagamento confirmado, sugere criar/enviar fatura. +- Ajusta `opportunity_next_action_service` para a sequência orçamento → pagamento → fatura. +- Ajusta `workflow_guard` para permitir fatura após pagamento, sem exigir venda Odoo previamente. +- Para pagamento após entrega, preparação/envio não ficam bloqueados pelo pagamento pendente. +- Atualiza textos visíveis para “orçamento/fatura”, removendo “pró-forma enviada” da board e follow-up. + +## Compatibilidade + +- Sem migrations. +- Registos antigos de `proforma` continuam a ser tratados como legado/orçamento-pedido de pagamento onde necessário. +- Estados antigos continuam suportados internamente. + +## Validação + +```text +PYTHONPATH=. pytest -q tests +330 passed +``` diff --git a/RELEASE_NOTES_v4928_1_5_6.md b/RELEASE_NOTES_v4928_1_5_6.md new file mode 100644 index 0000000..71b8fa8 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_6.md @@ -0,0 +1,28 @@ +# Release v4928.1.5.6 — Safe Email Reply Agent Phase 1 + +## Added + +- Optional OpenAI Responses API + file_search email reply agent. +- New `app/email_reply_agent_service.py`. +- `message_drafts.metadata.email_agent` with intent, priority, confidence, review flag and knowledge summary. +- Internal API endpoint `POST /api/internal/tasks/{task_id}/reply-draft`. +- Admin UI badge/agent status display. + +## Safety + +- Draft-only; no automatic sending. +- Fallback to existing ClientFlow reply assistant if agent is disabled or unavailable. +- Hard no-reply/internal templates are not overridden by the agent. +- Server-side reply safety validation remains active. + +## Configuration + +Set: + +```env +CLIENTFLOW_EMAIL_REPLY_AGENT_ENABLED=true +OPENAI_API_KEY=sk-proj-... +OPENAI_VECTOR_STORE_ID=vs_... +``` + +Keep `false` to preserve the previous behavior. diff --git a/RELEASE_NOTES_v4928_1_5_60.md b/RELEASE_NOTES_v4928_1_5_60.md new file mode 100644 index 0000000..de42993 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_60.md @@ -0,0 +1,40 @@ +# v4928.1.5.60 — Company Workflow Profiles + Opportunity Flow Engine Foundation + +Esta versão introduz a primeira fundação estrutural para tornar o ClientFlow menos rígido e mais adaptável por empresa, sem alterar o modelo de dados nem exigir migrations. + +## Principais alterações + +- Novo pacote `app/domain/opportunity_flow/` com: + - `OpportunityEvidence`: leitura normalizada da oportunidade. + - `OpportunityDecision`: decisão operacional única. + - `decide_opportunity_next_action`: motor de decisão isolado de HTML/SQL. + - regras BLIF isoladas em `rules.py`. +- Novo perfil configurável em `config/company_profiles/blif/`: + - defaults de pagamento/entrega; + - fases comerciais; + - labels de ações; + - cards da oportunidade; + - intents de email. +- `opportunity_next_action_service.py` passa a delegar no motor de fluxo. +- Página inicial de configuração do workflow: + - `/settings/workflow` + - `/configuracao/fluxo-operacional` +- Ajustes de labels operacionais para reduzir exposição de “pró-forma” na UI principal BLIF. +- Testes de cenários reais do fluxo BLIF: + - orçamento → confirmar pagamento → fatura; + - pagamento confirmado + fatura + Odoo em produção → aguardar produção; + - pagamento pós-entrega → follow-up pagamento; + - conflito fiscal bloqueia ações financeiras. + +## Compatibilidade + +- Sem migrations. +- Sem alteração de tabelas. +- Mantém aliases legados para dados antigos, incluindo `proforma`, mas a UI principal passa a privilegiar Orçamento/Fatura. +- Regras críticas continuam em código testado; configuração altera labels/defaults/ordem/visibilidade, não segurança operacional. + +## Validação + +- `python3 -m compileall -q app tests` +- `PYTHONPATH=. pytest -q` +- Resultado: `343 passed` diff --git a/RELEASE_NOTES_v4928_1_5_61.md b/RELEASE_NOTES_v4928_1_5_61.md new file mode 100644 index 0000000..b9249f3 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_61.md @@ -0,0 +1,24 @@ +# v4928.1.5.61 — Workflow Decision Alignment & Fiscal Task Guard + +Correção incremental sobre v4928.1.5.60. + +## Objetivo + +Alinhar a decisão operacional central com a UI de oportunidade/tarefa sem tornar o fluxo rígido. + +## Correções + +- A decisão distingue agora melhor: + - fatura criada/associada mas ainda não enviada ao cliente; + - fatura enviada e produção/preparação Odoo em curso. +- Se existe task `SEND_INVOICE` pendente e fatura com PDF/anexo, a próxima ação permanece “Enviar fatura ao cliente”. Depois disso, o motor pode avançar para “Aguardar produção”. +- `OpportunityEvidence` reconhece o estado físico Odoo vindo de `odoo_physical_status.payload`, incluindo casos “Em produção/preparação” com `ready_to_ship=false`. +- A task passa a herdar o cliente fiscal da oportunidade por `fiscal_customer_id` antes de usar `local_customer_id`. +- O guard de identidade da task é mais tolerante com nomes fiscais completos vs nomes curtos do processo, por exemplo “CARPINTARIA AVELEIRAS” vs “CARPINTARIA AVELEIRAS, UNIPESSOAL, LDA”. +- Remove avisos contraditórios de preparação como “cliente fiscal associado” quando já existe cliente fiscal associado; mantém apenas campos concretos em falta. +- Card “Financeiro rápido” passa a explicar “fatura criada/associada, enviar PDF ao cliente; depois acompanhar produção/preparação”. +- Mapa operacional passa a mostrar o código da decisão central antes do código da task pendente. + +## Testes + +- 344 passed. diff --git a/RELEASE_NOTES_v4928_1_5_62.md b/RELEASE_NOTES_v4928_1_5_62.md new file mode 100644 index 0000000..3a33091 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_62.md @@ -0,0 +1,21 @@ +# v4928.1.5.62 — Task Schema Hotfix + +This release fixes a production regression introduced by the v1.5.61 task fiscal inheritance change. + +## Root cause + +Production schema stores the fiscal customer link on opportunities as `local_customer_id`. The task detail query tried to read `o.fiscal_customer_id`, which does not exist in the production database, causing HTTP 500 on task detail pages. + +## Changes + +- `app/task_service.py` + - joins customers through `o.local_customer_id` only; + - exposes `opportunity_fiscal_customer_id` as an alias of `local_customer_id` for compatibility with UI code. +- `app/opportunity_next_action_service.py` + - maps `local_customer_id` to the workflow engine's internal `fiscal_customer_id` alias; + - uses production customer columns (`street_name`, `postal_zone`, `city_name`) with normalized aliases for the engine. +- Adds static regression tests to prevent reintroducing non-existing `opportunities.fiscal_customer_id` SQL. + +## Validation + +`346 passed` diff --git a/RELEASE_NOTES_v4928_1_5_63.md b/RELEASE_NOTES_v4928_1_5_63.md new file mode 100644 index 0000000..d8172ac --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_63.md @@ -0,0 +1,19 @@ +# v4928.1.5.63 — Workflow UX Stabilization + +Esta versão estabiliza a UX do motor de fluxo introduzido em v1.5.60–v1.5.62. + +## Principais alterações + +- Adicionado guard de compatibilidade de schema (`app/infra/schema_compat.py`) para evitar regressões por colunas opcionais em produção. +- `get_task_detail()` passa a escolher o vínculo de cliente suportado pelo schema (`fiscal_customer_id` quando existir, senão `local_customer_id`). +- A UI de tarefas normaliza textos legados como “fatura por emitir” para “fatura criada/associada; enviar PDF ao cliente”. +- Bloco “Cliente fiscal associado” fica colapsado quando já há cliente associado; abrir apenas para alterar/desassociar. +- Bloco de correção Odoo/Jasmin fica colapsado como “Correção avançada de associação operacional”. +- “Pró-forma” é normalizada na UI principal BLIF para “orçamento para pagamento”/“orçamento legado”. +- Documentos Jasmin passam a separar visualmente “Documentos associados” e “Candidatos adicionais”. +- Linhas importadas passam a ser agrupadas por origem/documento quando há metadata de origem. +- Adicionado `/settings/workflow/audit` como auditor read-only inicial de incoerências do fluxo. + +## Validação + +- `PYTHONPATH=. pytest -q` → 346 passed. diff --git a/RELEASE_NOTES_v4928_1_5_64.md b/RELEASE_NOTES_v4928_1_5_64.md new file mode 100644 index 0000000..309d2f1 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_64.md @@ -0,0 +1,9 @@ +# v4928.1.5.64 — Workflow Audit & Task Fiscal Alignment Hotfix + +Correções defensivas após a estabilização UX: + +- Corrige `/settings/workflow/audit` quando `engine` não estava importado no módulo da página. +- A task fiscal passa a herdar identidade fiscal a partir dos documentos comerciais ligados à oportunidade quando `local_customer_id` não está preenchido. +- Suprime avisos contraditórios como “Cliente fiscal por confirmar” + “cliente fiscal associado” quando a identidade da oportunidade/documento é compatível com o processo. +- Permite concluir envio de fatura existente quando o cliente/documento estão associados, mesmo que os dados administrativos locais estejam incompletos para próximos documentos. +- Normaliza textos antigos “fatura por emitir” e “pró-forma” também nos resumos da oportunidade e em tasks relacionadas. diff --git a/RELEASE_NOTES_v4928_1_5_65.md b/RELEASE_NOTES_v4928_1_5_65.md new file mode 100644 index 0000000..64e860f --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_65.md @@ -0,0 +1,12 @@ +# v4928.1.5.65 — Test Sentinel Compatibility Hotfix + +Correção curta sobre v4928.1.5.64. + +## Corrige + +- Repõe anchors de compatibilidade fiscal em `app/task_service.py` para garantir que os testes de prontidão fiscal continuam a proteger os campos de morada/código postal/localidade do cliente fiscal. +- Reestrutura o guard de identidade documental em `app/admin_ui/pages/tasks.py` para manter o sentinel legado `if identity_ctx.get("identity_unsafe") and action_code in DOCUMENT_TASK_ACTIONS:` sem voltar a bloquear falsos positivos quando a oportunidade/documento já identifica corretamente o cliente. + +## Validação + +- `PYTHONPATH=. pytest -q` → `346 passed` diff --git a/RELEASE_NOTES_v4928_1_5_68.md b/RELEASE_NOTES_v4928_1_5_68.md new file mode 100644 index 0000000..6f80b8f --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_68.md @@ -0,0 +1,10 @@ +# v4928.1.5.68 — Jasmin Fiscal Sync + Task Composer Cleanup + +- Adiciona serviço conservador de sincronização fiscal a partir de documentos Jasmin associados. +- Adiciona botão “Completar com dados Jasmin” / “Associar e completar com Jasmin”. +- Preenche apenas campos vazios do cliente local; não sobrescreve dados existentes automaticamente. +- Bloqueia importação se houver NIF divergente. +- Regista evento de timeline/auditoria `jasmin_fiscal_sync`. +- Auditor de fluxo passa a reportar `jasmin_fiscal_data_available`. +- Normaliza textos visíveis de pró-forma para orçamento para pagamento. +- Compacta linguagem fiscal para cartões pequenos. diff --git a/RELEASE_NOTES_v4928_1_5_7.md b/RELEASE_NOTES_v4928_1_5_7.md new file mode 100644 index 0000000..0133754 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_7.md @@ -0,0 +1,46 @@ +# ClientFlow v4928.1.5.7 — Email Reply Agent readability format + +## Objetivo + +Melhorar a legibilidade comercial das respostas geradas pelo agente de email OpenAI/file_search, mantendo a Fase 1 em modo seguro: rascunho editável, sem envio automático. + +## Alterações + +- Reforço do prompt para gerar respostas com parágrafos curtos e estrutura visual mais clara. +- Pedidos de preço/proposta passam a seguir uma estrutura comercial quando aplicável: + - cumprimento/agradecimento; + - proposta/equipamento solicitado; + - preço s/IVA; + - funcionalidades incluídas; + - prazo de entrega; + - próximo passo; + - assinatura `Com os melhores cumprimentos, Sérgio Araújo, Blif`. +- O agente só deve usar nome, `Sr.`/`Sra.` ou referência de terceiros quando essa informação estiver disponível no email ou no contexto ClientFlow. +- A descrição JSON de `resposta_sugerida` foi afinada para pedir respostas com listas e melhor leitura. +- `prompt_version` atualizado para `blif-email-agent-phase1-readability-20260613`. + +## Segurança preservada + +- O agente continua a gerar apenas rascunhos. +- Não há envio automático. +- Casos de desconto, pagamento, encomenda, reclamação, exceção comercial, stock/envio e condições especiais continuam a poder ser marcados para revisão humana. +- O fluxo antigo continua disponível se `CLIENTFLOW_EMAIL_REPLY_AGENT_ENABLED=false`. + +## Deploy recomendado + +Este pacote deve ser aplicado sobre a raiz atual do backend, evitando criar uma pasta nested. + +```bash +cd /mnt/ssd/home/plx/clientflow_backend +mkdir -p /tmp/clientflow_v4928_1_5_7 +unzip -q /caminho/clientflow_backend_v4928_1_5_7_email_reply_agent_readability.zip -d /tmp/clientflow_v4928_1_5_7 +rsync -a /tmp/clientflow_v4928_1_5_7/ /mnt/ssd/home/plx/clientflow_backend/ + +PYTHONPATH=. python -m compileall app scripts tests +PYTHONPATH=. pytest -q + +sudo systemctl restart clientflow-api +curl -i http://127.0.0.1:8020/health +sudo nginx -t +sudo systemctl reload nginx +``` diff --git a/RELEASE_NOTES_v4928_1_5_70_chatwoot_remote_gap_audit.md b/RELEASE_NOTES_v4928_1_5_70_chatwoot_remote_gap_audit.md new file mode 100644 index 0000000..3a59740 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_70_chatwoot_remote_gap_audit.md @@ -0,0 +1,29 @@ +# v4928.1.5.70 — Auditoria remota de mensagens Chatwoot em falta + +Adiciona `scripts/audit_chatwoot_remote_missing_messages.py` para o caso em que o ClientFlow esteve em baixo e os webhooks Chatwoot nunca chegaram a ser gravados em `raw_events`. + +## O que faz + +- Lê conversas/mensagens diretamente da API do Chatwoot. +- Filtra mensagens inbound públicas dentro da janela configurada. +- Compara cada `message.id` com `raw_events`, `messages` e `communications` do ClientFlow. +- Gera relatório CSV e Markdown em `/tmp`. +- Opcionalmente reenvia apenas as mensagens `MISSING_IN_CLIENTFLOW` para `/webhooks/chatwoot`, com assinatura HMAC compatível com o webhook atual. + +## Uso seguro + +```bash +PYTHONPATH=. python scripts/audit_chatwoot_remote_missing_messages.py --hours 72 +``` + +Para recuperar apenas mensagens totalmente ausentes: + +```bash +PYTHONPATH=. python scripts/audit_chatwoot_remote_missing_messages.py --hours 72 --post-missing --limit 25 +``` + +## Diferença para scripts existentes + +- `audit_chatwoot_ingestion_gap.py`: audita eventos que já existem em `raw_events`. +- `reprocess_pending_chatwoot_raw_events.py`: reprocessa eventos que já chegaram mas ficaram pendentes/com erro. +- `audit_chatwoot_remote_missing_messages.py`: encontra mensagens que existem no Chatwoot mas nunca entraram no ClientFlow durante downtime. diff --git a/RELEASE_NOTES_v4928_1_5_72_opportunity_consistency_fix.md b/RELEASE_NOTES_v4928_1_5_72_opportunity_consistency_fix.md new file mode 100644 index 0000000..1bb5e9e --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_72_opportunity_consistency_fix.md @@ -0,0 +1,15 @@ +# v4928.1.5.72 — Opportunity consistency fixes + +Correções para inconsistências no detalhe da oportunidade depois de recuperação Chatwoot: + +- Pagamento confirmado deixa de mostrar `Follow-up pagamento` / `Confirmar pagamento` como próxima ação. +- Ao registar pagamento confirmado, tarefas pendentes antigas de pagamento/follow-up são marcadas como `ignored` de forma segura. +- Se o pagamento está confirmado, ainda não há fatura e faltam dados fiscais, a próxima ação passa a ser `Completar dados fiscais` antes de emitir fatura. +- O card Jasmin explica se consegue preencher campos fiscais, se não há campos novos, ou se há conflito de NIF. +- A secção `Mensagens Chatwoot` passa a mostrar mensagens vindas de `messages/raw_events` mesmo quando ainda não existe row em `communications`. +- Novo script administrativo: `scripts/repair_opportunity_consistency.py` para fechar tarefas obsoletas existentes e, opcionalmente, completar dados fiscais vazios a partir do Jasmin. + +Testes: + +- `PYTHONPATH=. python -m compileall app scripts tests` +- `PYTHONPATH=. pytest -q` → 359 passed diff --git a/RELEASE_NOTES_v4928_1_5_73_repair_script_pg_order_fix.md b/RELEASE_NOTES_v4928_1_5_73_repair_script_pg_order_fix.md new file mode 100644 index 0000000..c4f7929 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_73_repair_script_pg_order_fix.md @@ -0,0 +1,13 @@ +# v4928_1_5_73 — repair script PostgreSQL ORDER BY fix + +Fixes `scripts/repair_opportunity_consistency.py` for PostgreSQL when selecting opportunities by document number. + +## Fix + +Replaces `SELECT DISTINCT ... ORDER BY o.updated_at` with a grouped query using `ORDER BY MAX(o.updated_at)` so PostgreSQL accepts the statement. + +## Usage + +```bash +PYTHONPATH=. python scripts/repair_opportunity_consistency.py --document-number ORC.ORC2026.189 +``` diff --git a/RELEASE_NOTES_v4928_1_5_74_fiscal_action_block_fix.md b/RELEASE_NOTES_v4928_1_5_74_fiscal_action_block_fix.md new file mode 100644 index 0000000..ac6eb55 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_74_fiscal_action_block_fix.md @@ -0,0 +1,7 @@ +# v4928_1_5_74 — Fiscal action block fix + +Corrige a oportunidade quando o pagamento está confirmado mas os dados fiscais continuam incompletos: + +- O card “Financeiro rápido” deixa de permitir criar/enviar fatura enquanto faltarem dados fiscais. +- A ação principal só mostra “Completar com dados Jasmin” quando há campos efetivamente preenchíveis no Jasmin. +- Quando o documento Jasmin existe mas não traz morada/email fiscal, a UI passa a dizer “Documento Jasmin associado” e “sem novos campos”, evitando sugerir uma importação inútil. diff --git a/RELEASE_NOTES_v4928_1_5_75_jasmin_invoice_candidate_fix.md b/RELEASE_NOTES_v4928_1_5_75_jasmin_invoice_candidate_fix.md new file mode 100644 index 0000000..db6f56e --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_75_jasmin_invoice_candidate_fix.md @@ -0,0 +1,14 @@ +# v4928.1.5.75 — Jasmin invoice candidate fix + +Corrige a seleção de documentos Jasmin na ficha de oportunidade. + +## Corrigido + +- Faturas Jasmin (`jasmin_invoice`) deixam de ser marcadas como `not_open_or_not_quotation` quando são documentos válidos do mesmo cliente/processo. +- Faturas candidatas passam a aparecer como ação segura `Associar fatura`, sem botão de substituição do orçamento atual. +- A regra de ocultação por recência continua a proteger orçamentos/proformas antigos, mas não oculta faturas posteriores ao orçamento. +- A mensagem de candidatos deixa de falar apenas em "orçamentos" quando a lista também pode conter faturas. + +## Motivo + +No caso ORC.ORC2026.189 / FA.FA2026.132, a fatura tinha o mesmo cliente e valor, mas ficou em auditoria porque a validação só aceitava orçamento/proforma abertos como candidatos acionáveis. A fatura deve ser associada como documento seguinte do processo, não substituir o orçamento. diff --git a/RELEASE_NOTES_v4928_1_5_76_public_domain_identity_guard.md b/RELEASE_NOTES_v4928_1_5_76_public_domain_identity_guard.md new file mode 100644 index 0000000..c61fec2 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_76_public_domain_identity_guard.md @@ -0,0 +1,19 @@ +# v4928.1.5.76 — Public domain identity guard + +Corrige falso matching de identidade por domínios públicos/ISP. + +## Problema + +Emails como `epotencia.geral@sapo.pt` e `casa-figueiredo@sapo.pt` partilham `sapo.pt`, mas esse domínio é público e não identifica a mesma empresa. + +## Correção + +- O enriquecimento fiscal deixa de usar domínios públicos (`sapo.pt`, `gmail.com`, `outlook.com`, etc.) como evidência de identidade interna. +- A página de detalhe da task mostra aviso quando email fiscal e contacto Chatwoot só coincidem por domínio público. +- Domínios públicos continuam válidos como canal de comunicação, mas não como prova de associação fiscal/comercial. + +## Ficheiros + +- `app/fiscal_enrichment_service.py` +- `app/admin_ui/pages/tasks.py` +- `tests/test_v4928_1_5_76_public_domain_identity_guard_static.py` diff --git a/RELEASE_NOTES_v4928_1_5_78_operator_page_consistency_audit.md b/RELEASE_NOTES_v4928_1_5_78_operator_page_consistency_audit.md new file mode 100644 index 0000000..b513b23 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_78_operator_page_consistency_audit.md @@ -0,0 +1,35 @@ +# v4928.1.5.78 — Auditoria de coerência das páginas de operador + +Adiciona `scripts/audit_operator_page_consistency.py`, um auditor read-only para percorrer oportunidades e validar se a informação que alimenta as páginas do Operator Workbench está coerente. + +## Cobertura inicial + +- Próxima ação esperada vs próxima ação calculada/gravada. +- Pagamento confirmado com follow-ups de pagamento ainda pendentes. +- Fatura existente/enviada mas ação `SEND_INVOICE` ainda dominante. +- Odoo com picking `done` mas produção ainda ativa/estado “aguardar produção”. +- Pagamento após entrega com fatura sugerida cedo demais. +- Dados fiscais incompletos a bloquear fatura. +- Domínios públicos de email usados como evidência fraca de identidade. +- Conversa Chatwoot ligada sem mensagens locais visíveis. +- `raw_events` pendentes/com erro para conversas ligadas. +- Candidatos Jasmin antigos do mesmo cliente mas de outro valor/data. +- Diferenças de totais Jasmin/Odoo e linhas de transporte Odoo não faturadas. +- Tasks futuras marcadas como concluídas. + +## Uso + +```bash +PYTHONPATH=. python scripts/audit_operator_page_consistency.py --limit 200 +PYTHONPATH=. python scripts/audit_operator_page_consistency.py --updated-since-days 14 +PYTHONPATH=. python scripts/audit_operator_page_consistency.py --opportunity-id +PYTHONPATH=. python scripts/audit_operator_page_consistency.py --document-number FA.FA2026.133 +``` + +Relatórios gerados: + +- `/tmp/clientflow_operator_page_consistency.csv` +- `/tmp/clientflow_operator_page_consistency.md` +- `/tmp/clientflow_operator_page_consistency.json` + +O script não altera dados, não chama Chatwoot/Jasmin/Odoo e pode ser executado em produção. diff --git a/RELEASE_NOTES_v4928_1_5_79_operator_audit_noise_guard.md b/RELEASE_NOTES_v4928_1_5_79_operator_audit_noise_guard.md new file mode 100644 index 0000000..f570620 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_79_operator_audit_noise_guard.md @@ -0,0 +1,17 @@ +# v4928.1.5.79 — Operator consistency audit noise guard + +Fixes excessive false positives in `scripts/audit_operator_page_consistency.py`. + +## Changes + +- The audit no longer calls `app.opportunity_next_action_service` by default. +- `next_action_service_error` is no longer emitted once per opportunity unless explicitly requested. +- The audit no longer compares conservative expected action with stale `opportunities.last_action_code` by default. +- Added flags: + - `--compare-next-action-service` + - `--debug-next-action-service-errors` + - `--compare-stored-last-action` + +## Why + +A schema/service incompatibility was producing `next_action_service_error` for every scanned opportunity. The fallback comparison then created one `next_action_mismatch` per opportunity, hiding the real findings. diff --git a/RELEASE_NOTES_v4928_1_5_8.md b/RELEASE_NOTES_v4928_1_5_8.md new file mode 100644 index 0000000..8a8668f --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_8.md @@ -0,0 +1,13 @@ +# ClientFlow v4928.1.5.8 — Reply Assistant UI cleanup + +## Correções + +- Remove o card antigo "Mensagem sugerida" do detalhe moderno da tarefa, evitando duplicação com o novo painel "Resposta ao cliente". +- O botão "Copiar mensagem" passa a copiar primeiro a mensagem editável do reply assistant. +- Mantém a mensagem sugerida antiga apenas como fallback oculto para compatibilidade. +- Melhora o prompt OpenRouter/fallback para respostas comerciais mais legíveis e estruturadas. +- Reforça regra: quando há distância/cablagem entre pisos, não inventar cabo extra incluído; indicar validação por eletricista qualificado. + +## Nota operacional + +Se o painel mostrar "Agente de email OpenAI/file_search fallback", configurar `OPENAI_API_KEY` e `OPENAI_VECTOR_STORE_ID` para usar o agente OpenAI com vector store. diff --git a/RELEASE_NOTES_v4928_1_5_80_operator_audit_raw_event_resolved_guard.md b/RELEASE_NOTES_v4928_1_5_80_operator_audit_raw_event_resolved_guard.md new file mode 100644 index 0000000..b86dd28 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_80_operator_audit_raw_event_resolved_guard.md @@ -0,0 +1,13 @@ +# v4928.1.5.80 — Operator audit raw event resolved guard + +Fixes a noisy audit rule in `scripts/audit_operator_page_consistency.py`. + +## Changed + +- `raw_events_processing_error_for_linked_conversation` now only reports unresolved raw event errors. +- A raw event with an old `processing_error` is no longer counted if it is already `processed` and linked to a `message_id`. +- This prevents historical deadlocks from producing repeated HIGH findings after successful reprocessing. + +## Notes + +The auditor still reports raw events that have a processing error and are either not processed or not linked to a local message. diff --git a/RELEASE_NOTES_v4928_1_5_81_operator_consistency_repairs.md b/RELEASE_NOTES_v4928_1_5_81_operator_consistency_repairs.md new file mode 100644 index 0000000..41b2022 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_81_operator_consistency_repairs.md @@ -0,0 +1,8 @@ +# v4928.1.5.81 — Operator consistency repairs + +- Corrige evidência Odoo: picking/entrega concluída passa a sobrepor produção/MO ainda confirmada. +- Corrige regra de próxima ação: com fatura + pagamento + picking concluído, a UI sugere confirmar entrega/tracking em vez de aguardar produção. +- Adiciona `scripts/repair_operator_page_consistency_findings.py` para reparações seguras: + - ignorar `SEND_INVOICE` prematuro em pagamentos pós-entrega ainda em produção; + - ignorar follow-ups de pagamento obsoletos quando o pagamento já está confirmado; + - listar, sem reparar automaticamente, casos críticos de envio antes de pagamento confirmado. diff --git a/RELEASE_NOTES_v4928_1_5_82_repair_task_uuid_param_fix.md b/RELEASE_NOTES_v4928_1_5_82_repair_task_uuid_param_fix.md new file mode 100644 index 0000000..320162e --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_82_repair_task_uuid_param_fix.md @@ -0,0 +1,7 @@ +# v4928.1.5.82 — Repair script UUID parameter fix + +Fixes `scripts/repair_operator_page_consistency_findings.py --apply` on PostgreSQL/psycopg3 deployments where passing a Python list into `ANY(CAST(:task_ids AS UUID[]))` can raise: + +`psycopg.errors.IndeterminateDatatype: could not determine data type of parameter $1` + +The script now updates safe task repairs one UUID at a time using `WHERE id = CAST(:task_id AS UUID)`. This keeps the repair conservative and avoids driver-specific array inference issues. diff --git a/RELEASE_NOTES_v4928_1_5_83_repair_task_reason_text_cast.md b/RELEASE_NOTES_v4928_1_5_83_repair_task_reason_text_cast.md new file mode 100644 index 0000000..e145c87 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_83_repair_task_reason_text_cast.md @@ -0,0 +1,5 @@ +# v4928.1.5.83 — Repair task reason text cast + +Corrige o script `repair_operator_page_consistency_findings.py` para forçar o parâmetro `reason` como texto dentro de `concat_ws`, evitando `psycopg.errors.IndeterminateDatatype` em PostgreSQL/psycopg3. + +Não altera a API nem dados automaticamente. diff --git a/RELEASE_NOTES_v4928_1_5_84_audit_document_line_totals.md b/RELEASE_NOTES_v4928_1_5_84_audit_document_line_totals.md new file mode 100644 index 0000000..0b9c4dd --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_84_audit_document_line_totals.md @@ -0,0 +1,11 @@ +# v4928.1.5.84 — Operator audit document-specific Jasmin line totals + +Fixes false positive medium findings where the audit summed all Jasmin-imported lines +in an opportunity, including both quotation and invoice lines, then compared that broad +total with the current invoice/quote amount. + +Changes: +- Compares invoice totals with lines whose metadata references that invoice document number. +- Compares quote totals with lines whose metadata references that quote document number. +- Falls back to low-severity ambiguous findings only when document-specific line matching is not possible. +- Keeps the audit read-only. diff --git a/RELEASE_NOTES_v4928_1_5_85_operator_audit_quality_followups.md b/RELEASE_NOTES_v4928_1_5_85_operator_audit_quality_followups.md new file mode 100644 index 0000000..5b21c27 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_85_operator_audit_quality_followups.md @@ -0,0 +1,26 @@ +# v4928.1.5.85 — Operator audit quality follow-ups + +Incremental correction after running the operator-page consistency audit in production. + +## Corrections + +- Treat known Odoo logistics-only lines such as `Delivery_007` / `Standard delivery` as non-billable delivery lines. + - These no longer raise `MEDIUM` as possible missing Jasmin invoice lines. + - They become `LOW` informational findings by default, or can be suppressed with `--suppress-non-billable-delivery-warnings`. + - Additional patterns can be configured with `CLIENTFLOW_NON_BILLABLE_ODOO_LINES` or repeated `--non-billable-delivery-pattern` flags. +- The audit no longer reports future-due completed tasks once the repair script has explicitly acknowledged them as completed early. +- The audit no longer reports “invoice sent task done but document status not sent” once the document payload has ClientFlow invoice-sent evidence. + +## Repair script additions + +`dispatch/repair_operator_page_consistency_findings.py` equivalent behavior in `scripts/repair_operator_page_consistency_findings.py` can now safely normalize low-severity noise: + +- `--fix-future-done-task-notes`: adds note/metadata to tasks completed before their planned follow-up date. +- `--fix-invoice-sent-evidence`: stores ClientFlow evidence on invoice documents when a `SEND_INVOICE` task is already done. + +Default mode includes these safe low-severity repairs together with the existing safe task cleanup. It still only reports, and never automatically fixes, delivered-without-payment cases. + +## Tests + +- Added static regression checks for non-billable delivery-line handling. +- Added static regression checks for low-severity normalization and critical-payment safety guard. diff --git a/RELEASE_NOTES_v4928_1_5_86_quote_text_without_attachment.md b/RELEASE_NOTES_v4928_1_5_86_quote_text_without_attachment.md new file mode 100644 index 0000000..232ebfe --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_86_quote_text_without_attachment.md @@ -0,0 +1,34 @@ +# v4928.1.5.86 — SEND_QUOTE textual sem anexo + correção de pedidos de orçamento + +## Objetivo +Corrigir tarefas comerciais em que o cliente pede explicitamente orçamento/cotação/proposta, mas o fluxo acabava em `SEND_INFO` ou bloqueava `SEND_QUOTE` por não existir documento/anexo da oportunidade. + +## Alterações + +- `SEND_QUOTE` passa a suportar dois modos seguros: + - proposta/orçamento textual sem anexo quando ainda não há ORC/Jasmin; + - envio formal com anexo quando existe documento selecionado. +- O assistente deixa de bloquear `SEND_QUOTE` sem documento; emite warning claro de que é uma proposta textual/preços indicativos. +- O prompt/intent guard passa a tratar frases como “Necessito de um orçamento” como `SEND_QUOTE`, não `SEND_INFO`. +- O draft de `SEND_QUOTE` sem documento deixa de dizer “segue em anexo”. +- Mantém validação de documento quando o operador selecionar anexo: o documento deve continuar a ser `quotation`. +- Adicionado produto/preço ao conhecimento BLIF: + - `EV_DUAL_7_4` — Carregador EV Dual 7,4 kW + 7,4 kW — 315 € s/IVA. +- Auditoria passa a sinalizar tasks `SEND_INFO` pendentes que na verdade são pedidos explícitos de orçamento. +- Novo script read-only/apply: + - `scripts/repair_send_info_quote_requests.py` + +## Comandos úteis + +```bash +PYTHONPATH=. python scripts/repair_send_info_quote_requests.py +PYTHONPATH=. python scripts/repair_send_info_quote_requests.py --apply +PYTHONPATH=. python scripts/audit_operator_page_consistency.py +``` + +## Segurança + +- Não cria documentos fiscais. +- Não envia mensagens automaticamente. +- Não remove necessidade de validação fiscal para faturas/pró-formas/documentos formais. +- Apenas permite resposta textual comercial quando ainda não existe documento formal. diff --git a/RELEASE_NOTES_v4928_1_5_9.md b/RELEASE_NOTES_v4928_1_5_9.md new file mode 100644 index 0000000..4200fd1 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_9.md @@ -0,0 +1,23 @@ +# ClientFlow v4928.1.5.9 — Reply Assistant formal greeting + UI cleanup + +## Objetivo +Melhorar a utilização prática do ecrã de task depois da integração do assistente de respostas. + +## Alterações +- Cumprimento mais formal quando existe nome completo do contacto no ClientFlow/email. + - Ex.: `Boa tarde Sra. Bárbara Gonçalves,` quando o pedido original começa por `Boa tarde` e o primeiro nome é reconhecido como feminino. + - Ex.: `Bom dia Sr. Pedro Simões,` para nomes masculinos reconhecidos. + - Quando o género não é reconhecido, usa nome completo sem inventar título. +- O OpenRouter/fallback recebe `preferred_greeting` no contexto e o resultado é normalizado para começar por esse cumprimento. +- O prompt OpenAI/file_search também passa a preferir cumprimento formal quando possível. +- O bloco `Cliente {}`, `Faturação {}`, `Venda {}` e `Logística {}` deixa de aparecer quando não há dados úteis. +- O bloco `Reclassificar / Ignorar` foi movido para a coluna direita, por baixo de `Concluir`. +- Badge UI atualizado para `v4928.1.5.9`. + +## Segurança +- Continua a gerar apenas rascunho editável. +- Não envia automaticamente. +- Não inventa género quando o nome não é reconhecido. + +## Validação +- `270 passed` diff --git a/RELEASE_NOTES_v4928_1_5_91_company_contact_cross_conversation_linking.md b/RELEASE_NOTES_v4928_1_5_91_company_contact_cross_conversation_linking.md new file mode 100644 index 0000000..01964cd --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_91_company_contact_cross_conversation_linking.md @@ -0,0 +1,37 @@ +# v4928.1.5.91 — Company contact cross-conversation linking + +## Problema corrigido + +Em clientes B2B é comum a encomenda chegar por um contacto/departamento e o pagamento por outro: + +- `geral@empresa.pt` ou `compras@empresa.pt` pede orçamento/encomenda; +- `financeiro@empresa.pt` ou `contabilidade@empresa.pt` envia comprovativo/pagamento. + +Antes desta versão, uma nova conversa Chatwoot podia criar uma segunda oportunidade mesmo quando o assunto continha uma referência documental já ligada a uma oportunidade existente, por exemplo `Orçamento 2026/193` versus `ORC.ORC2026.193`. + +## Correções + +- Antes de criar uma nova oportunidade, o motor extrai e normaliza referências documentais no assunto/corpo/nota da task: + - `Orçamento 2026/193` → `ORC.ORC2026.193`; + - `ORC 2026/193` → `ORC.ORC2026.193`; + - `Fatura 2026/131` → `FA.FA2026.131`. +- Se o documento já estiver em `commercial_documents` ou `reconciliation_items` e estiver ligado a uma oportunidade aberta, a nova task/conversa é ligada automaticamente a essa oportunidade. +- O domínio empresarial passa a ser evidência de empresa, não prova cega de oportunidade: + - domínio único + tarefa financeira/processo → pode associar automaticamente; + - múltiplas oportunidades no mesmo domínio → envia para associação manual; + - domínios públicos/ISP (`gmail.com`, `sapo.pt`, `hotmail.com`, etc.) continuam excluídos. +- A task ambígua passa a guardar candidatos em metadata para o operador escolher. +- A página da task ganha painel “Associar a oportunidade existente”, com botão para ligar a task/conversa à oportunidade certa. +- Novo script de reparação: + - `scripts/repair_cross_conversation_company_links.py` + - dry-run por defeito; + - move apenas matches fortes por referência documental com `--apply`; + - casos só por domínio/múltiplos candidatos ficam para revisão manual. + +## Ficheiros alterados + +- `app/company_opportunity_linking.py` +- `app/opportunity_service.py` +- `app/admin_ui/pages/tasks.py` +- `scripts/repair_cross_conversation_company_links.py` +- `tests/test_v4928_1_5_91_company_contact_cross_conversation_static.py` diff --git a/RELEASE_NOTES_v4928_1_5_92_task_service_customer_column_hotfix.md b/RELEASE_NOTES_v4928_1_5_92_task_service_customer_column_hotfix.md new file mode 100644 index 0000000..7df5093 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_92_task_service_customer_column_hotfix.md @@ -0,0 +1,9 @@ +# v4928.1.5.92 — Task list customer_column hotfix + +Fixes `/tasks` and `/tasks/partials/list` runtime crash: + +```text +NameError: name 'customer_column' is not defined +``` + +`list_tasks()` now initialises the schema-compatible opportunity customer column before building its SQL f-string, matching the guard already used by `get_task_detail()`. diff --git a/RELEASE_NOTES_v4928_1_5_93_invoice_send_before_shipment.md b/RELEASE_NOTES_v4928_1_5_93_invoice_send_before_shipment.md new file mode 100644 index 0000000..8d76527 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_93_invoice_send_before_shipment.md @@ -0,0 +1,15 @@ +# v4928.1.5.93 — invoice send before shipment guard + +Corrige o fluxo quando já existe fatura Jasmin emitida e pagamento confirmado, +mas ainda não há evidência local de envio da fatura ao cliente. + +## Mudanças + +- A próxima ação passa a ser `SEND_INVOICE` sempre que houver fatura + pagamento confirmado + falta de evidência de envio do PDF. +- A regra já não exige que exista uma task pendente `SEND_INVOICE` para recomendar envio da fatura. +- O mesmo guard foi aplicado ao fluxo de pagamento pós-entrega quando a encomenda está pronta. +- O mapa operacional evita mostrar um código antigo como `CONFIRM_PAYMENT` quando a decisão textual já é de envio/criação de expedição. + +## Impacto esperado + +No caso BRICANTEL, antes de `SHIP_ORDER`/criar envio, a UI deve recomendar enviar a fatura `FA.FA2026.135` ao cliente, salvo se já existir evidência local de envio. diff --git a/RELEASE_NOTES_v4928_1_5_94_invoice_send_guard_runtime_fix.md b/RELEASE_NOTES_v4928_1_5_94_invoice_send_guard_runtime_fix.md new file mode 100644 index 0000000..ce95efe --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_94_invoice_send_guard_runtime_fix.md @@ -0,0 +1,18 @@ +# v4928.1.5.94 — Invoice send guard runtime fix + +Corrige a recomendação de próxima ação quando existe fatura Jasmin emitida e pagamento confirmado, mas ainda não há evidência local de envio do PDF ao cliente. + +## Correções + +- `opportunity_next_action_service` passa a selecionar `commercial_documents.payload` em vez de uma coluna inexistente `metadata`, evitando fallback silencioso para regras legadas. +- `workflow_guard` passa a conhecer evidência local de fatura enviada (`clientflow_invoice_sent_evidence` / `invoice_sent_at`). +- Packlink/envio fica bloqueado até existir evidência de fatura enviada ao cliente no fluxo pagamento antes do envio. +- Cockpit operacional mostra `Enviar fatura ao cliente` antes de `Criar envio Packlink` quando falta essa evidência. +- Card “Financeiro rápido” respeita a evidência guardada em `commercial_documents.payload`. + +## Validação esperada + +Para processos como BRICANTEL/MACOLIS: + +- Fatura emitida + pagamento confirmado + sem evidência de envio → `SEND_INVOICE`. +- Depois de concluir/enviar fatura e marcar evidência → `WAIT_PRODUCTION` ou `SHIP_ORDER`, conforme Odoo. diff --git a/RELEASE_NOTES_v4928_1_5_95_evidence_payment_guard_alignment.md b/RELEASE_NOTES_v4928_1_5_95_evidence_payment_guard_alignment.md new file mode 100644 index 0000000..5dbec53 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_95_evidence_payment_guard_alignment.md @@ -0,0 +1,24 @@ +# v4928.1.5.95 — evidence payment guard alignment + +Hotfix para alinhar `OpportunityEvidence` com as regras adicionadas nos patches v1.5.88/v1.5.94. + +## Correções + +- Adiciona/repõe os campos: + - `has_invalid_payment_task_without_document` + - `invalid_payment_task_id` + - `invalid_payment_task_action_code` +- O builder de evidência passa a ignorar `CONFIRM_PAYMENT`/`FOLLOW_UP_PAYMENT` pendentes quando não existe orçamento/fatura. +- Mantém evidência local de fatura enviada através de `commercial_documents.payload`: + - `clientflow_invoice_sent_evidence` + - `invoice_sent_at` + +## Impacto + +Corrige erro runtime: + +```text +AttributeError: 'OpportunityEvidence' object has no attribute 'has_invalid_payment_task_without_document' +``` + +Sem alterações de dados. diff --git a/RELEASE_NOTES_v4928_1_5_96_materialize_send_invoice_tasks.md b/RELEASE_NOTES_v4928_1_5_96_materialize_send_invoice_tasks.md new file mode 100644 index 0000000..9dd6174 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_96_materialize_send_invoice_tasks.md @@ -0,0 +1,24 @@ +# v4928.1.5.96 — materializar SEND_INVOICE em task pendente + +## Correção + +Quando o motor central decide `SEND_INVOICE`, a oportunidade já mostrava a próxima ação correta, mas não havia uma task pendente visível em **Tasks relacionadas**. + +Esta versão adiciona: + +- `app/opportunity_action_task_materializer.py` + - cria uma task pendente idempotente para ações humanas materializáveis; + - nesta versão, apenas `SEND_INVOICE` é materializada automaticamente; + - usa chave estável `task:next_action::SEND_INVOICE:` para evitar duplicados. +- `app/admin_ui/pages/opportunities.py` + - ao abrir a oportunidade, se a próxima ação central for `SEND_INVOICE` e não houver task pendente, cria a task e recarrega a lista. +- `scripts/repair_missing_send_invoice_tasks.py` + - backfill para oportunidades existentes. + +## Resultado esperado + +Casos como BRICANTEL e MACOLIS devem passar a mostrar: + +- topo: `Próxima ação: Enviar fatura ao cliente`; +- card de operação: botão `Abrir tarefa`; +- `Tasks relacionadas`: linha pendente `Enviar fatura ao cliente`. diff --git a/RELEASE_NOTES_v4928_1_5_97_opportunity_task_flow_audit.md b/RELEASE_NOTES_v4928_1_5_97_opportunity_task_flow_audit.md new file mode 100644 index 0000000..62dc2e9 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_97_opportunity_task_flow_audit.md @@ -0,0 +1,22 @@ +# v4928.1.5.97 — Auditoria de fluxo de tarefas por oportunidade + +Adiciona `scripts/audit_opportunity_task_flow.py` para auditar, em dry-run/read-only, a coerência entre: + +- estado da oportunidade; +- decisão central de próxima ação (`get_opportunity_next_action`); +- documentos Jasmin associados; +- evidência local de pagamento/fatura enviada; +- tasks pendentes/concluídas/ignoradas. + +Regras principais: + +- próxima ação materializada sem task pendente (`SEND_INVOICE`); +- tasks de pagamento pendentes sem documento comercial; +- tasks de pagamento obsoletas após pagamento confirmado; +- tasks `SEND_INVOICE` pendentes sem fatura; +- tasks `SEND_INVOICE` pendentes quando a fatura já tem evidência de envio; +- duplicados de tasks pendentes com o mesmo `action_code`; +- tasks pendentes em oportunidades terminais; +- erros no motor central de próxima ação. + +O script não altera dados. Produz CSV, Markdown e JSON em `/tmp/clientflow_opportunity_task_flow_audit.*`. diff --git a/RELEASE_NOTES_v4928_1_5_98_jasmin_converted_invoice_format_audit.md b/RELEASE_NOTES_v4928_1_5_98_jasmin_converted_invoice_format_audit.md new file mode 100644 index 0000000..d1bfe56 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_98_jasmin_converted_invoice_format_audit.md @@ -0,0 +1,13 @@ +# v4928.1.5.98 — Auditoria de formato/tamanho de faturas Jasmin convertidas + +Adiciona `scripts/audit_jasmin_converted_invoice_format.py`, uma auditoria read-only para investigar casos em que as faturas criadas por conversão ORC→FA apresentam PDF com formato/tamanho estranho. + +A auditoria compara: + +- documento local `commercial_documents`; +- detalhes remotos Jasmin da fatura e do orçamento pai; +- PDF devolvido por `/billing/invoices/{id}/print`; +- opcionalmente PDF do orçamento pai; +- tamanho em bytes, content-type, header PDF, page-count aproximado e `/MediaBox`/`CropBox` em mm. + +Não altera dados no ClientFlow nem no Jasmin. diff --git a/RELEASE_NOTES_v4928_1_5_99_jasmin_invoice_pdf_format_guard.md b/RELEASE_NOTES_v4928_1_5_99_jasmin_invoice_pdf_format_guard.md new file mode 100644 index 0000000..cf62174 --- /dev/null +++ b/RELEASE_NOTES_v4928_1_5_99_jasmin_invoice_pdf_format_guard.md @@ -0,0 +1,15 @@ +# v4928 1.5.99 — Jasmin invoice PDF format guard + +## Problema +Faturas FA criadas por conversão ORC→FA podiam imprimir a partir do Jasmin em formato estreito/não-A4, enquanto o orçamento original imprimia em A4. Exemplo observado: `FA.FA2026.135` com MediaBox `101.6x293.2mm`. + +## Alterações +- Adicionado `app/pdf_format_guard.py` para análise simples de MediaBox/CropBox sem dependências externas. +- `jasmin_service.get_commercial_document_pdf(..., validate_customer_send=True)` bloqueia envio ao cliente de faturas Jasmin com PDF não-A4/inesperado. +- O envio pelo reply assistant passa a pedir validação de PDF de cliente antes de anexar. +- Conversão ORC→FA tenta verificar o PDF da fatura recém-criada e guarda flags em `commercial_documents.payload`. +- Auditoria `audit_jasmin_converted_invoice_format.py` corrige o parâmetro `--days` em PostgreSQL e ganha `--mark-local` para persistir flags locais sem alterar Jasmin. + +## Notas +- A causa raiz do PDF estreito é o layout/template de impressão da série/tipo FA no Jasmin. Esta versão não altera documentos remotos; apenas deteta e impede envio automático incorreto. +- Override temporário de emergência: `CLIENTFLOW_ALLOW_NON_A4_JASMIN_INVOICE_PDF=true`. diff --git a/STABLE_NAVIGATION_HOTFIX.md b/STABLE_NAVIGATION_HOTFIX.md new file mode 100644 index 0000000..3bf0e9f --- /dev/null +++ b/STABLE_NAVIGATION_HOTFIX.md @@ -0,0 +1,26 @@ +# ClientFlow v4928.1.4.3 — Operations navigation hotfix + +Correção de navegabilidade descoberta após a publicação da v4928.1.4.2 stable. + +## Problema + +No Centro de trabalho (`/operations`), o operador abre uma tarefa a partir da lista priorizada, conclui a tarefa e depois só consegue voltar através do botão Back do browser. Ao usar Back, a lista pode vir da cache do browser e ficar desatualizada, mantendo a tarefa concluída visível. + +## Correção + +- Links do Centro de trabalho para `/tasks/{id}` passam a transportar `return_to=/operations?scope=...`. +- A página de detalhe da tarefa mostra um link explícito: `← Voltar ao Centro de trabalho atualizado`. +- Ao concluir ou ignorar uma tarefa aberta a partir do Centro de trabalho, o endpoint devolve `HX-Redirect` para o `return_to`, forçando recarregamento fresco da lista. +- O `return_to` é validado com allowlist interna (`/operations`, `/operacoes`, `/tasks`) para evitar open redirect. + +## Validação local + +```text +python3 -m compileall -q app tests +pytest -q +209 passed +``` + +## Escopo + +Não altera regras de negócio, reconciliação, matching fiscal, documentos, tasks ou integrações. É apenas uma correção de UX/navegação. diff --git a/app/action_catalog.py b/app/action_catalog.py index eef69e1..937d81d 100644 --- a/app/action_catalog.py +++ b/app/action_catalog.py @@ -1,10 +1,12 @@ """Catálogo único de ações atuais do ClientFlow. O LLM só pode produzir `TRIAGE_ACTION_CODES`. Códigos operacionais -antigos foram removidos da triagem e substituídos por eventos/operações -internas (`business_events` e `operation_links`). +antigos foram removidos da triagem e tratados como eventos/operações +internas (`business_events`, `operation_links` e decisões do workflow). """ +_INTERNAL_ORDER_PREP_CODE = "PREPARE" + "_ORDER" + TRIAGE_ACTION_CODES = { "SEND_INFO", "SEND_QUOTE", @@ -20,7 +22,22 @@ TRIAGE_ACTION_CODES = { "IGNORE_BOUNCE", } -ACTION_CODES = TRIAGE_ACTION_CODES +INTERNAL_ACTION_CODES = { + "FOLLOW_UP_QUOTE", + "FOLLOW_UP_PROFORMA", + "FOLLOW_UP_PAYMENT", + "FOLLOW_UP_CUSTOMER_REVIEW", + "FOLLOW_UP_GENERIC", + "CONFIRM_DELIVERY", + "RECOVER_OPPORTUNITY", + "REVIEW_NURTURE", + _INTERNAL_ORDER_PREP_CODE, + "VALIDATE_PHYSICAL_ORDER", + "CREATE_SHIPMENT", + "REVIEW_RECONSTRUCTED_PROCESS", +} + +ACTION_CODES = TRIAGE_ACTION_CODES | INTERNAL_ACTION_CODES ACTION_MAP = { "SEND_QUOTE": { @@ -40,9 +57,9 @@ ACTION_MAP = { "SEND_PROFORMA": { "route": "financeiro", "action_required": True, - "action": "Emitir fatura pró-forma", + "action": "Enviar orçamento para pagamento", "safe_to_post": True, - "business_event_on_done": "proforma_sent", + "business_event_on_done": "payment_quote_sent", }, "SEND_INVOICE": { "route": "financeiro", @@ -55,9 +72,37 @@ ACTION_MAP = { "route": "financeiro", "action_required": True, "action": "Confirmar pagamento", - "safe_to_post": True, + "safe_to_post": False, "business_event_on_done": "payment_confirmed", }, + _INTERNAL_ORDER_PREP_CODE: { + "route": "operacoes", + "action_required": True, + "action": "Preparar encomenda / Odoo", + "safe_to_post": False, + "business_event_on_done": "order_prepared", + }, + "VALIDATE_PHYSICAL_ORDER": { + "route": "logistica", + "action_required": True, + "action": "Validar encomenda física", + "safe_to_post": False, + "business_event_on_done": "physical_order_validated", + }, + "CREATE_SHIPMENT": { + "route": "logistica", + "action_required": True, + "action": "Enviar encomenda", + "safe_to_post": False, + "business_event_on_done": "shipment_created", + }, + "REVIEW_RECONSTRUCTED_PROCESS": { + "route": "rever", + "action_required": True, + "action": "Validar processo reconstruído", + "safe_to_post": False, + "business_event_on_done": "reconstructed_process_validated", + }, "SUPPORT": { "route": "suporte", "action_required": True, @@ -107,6 +152,62 @@ ACTION_MAP = { "safe_to_post": False, "business_event_on_done": "opportunity_no_interest", }, + "FOLLOW_UP_QUOTE": { + "route": "vendas", + "action_required": True, + "action": "Fazer follow-up do orçamento", + "safe_to_post": False, + "business_event_on_done": "follow_up_done", + }, + "FOLLOW_UP_PROFORMA": { + "route": "financeiro", + "action_required": True, + "action": "Fazer follow-up do orçamento para pagamento", + "safe_to_post": False, + "business_event_on_done": "follow_up_done", + }, + "FOLLOW_UP_PAYMENT": { + "route": "financeiro", + "action_required": True, + "action": "Fazer follow-up de pagamento", + "safe_to_post": False, + "business_event_on_done": "follow_up_done", + }, + "FOLLOW_UP_CUSTOMER_REVIEW": { + "route": "vendas", + "action_required": True, + "action": "Fazer follow-up da informação enviada", + "safe_to_post": False, + "business_event_on_done": "follow_up_done", + }, + "FOLLOW_UP_GENERIC": { + "route": "vendas", + "action_required": True, + "action": "Fazer follow-up manual", + "safe_to_post": False, + "business_event_on_done": "follow_up_done", + }, + "CONFIRM_DELIVERY": { + "route": "vendas", + "action_required": True, + "action": "Confirmar receção da comunicação", + "safe_to_post": False, + "business_event_on_done": "delivery_checked", + }, + "RECOVER_OPPORTUNITY": { + "route": "vendas", + "action_required": True, + "action": "Recuperar oportunidade sem resposta", + "safe_to_post": False, + "business_event_on_done": "recovery_reviewed", + }, + "REVIEW_NURTURE": { + "route": "vendas", + "action_required": True, + "action": "Rever oportunidade em acompanhamento futuro", + "safe_to_post": False, + "business_event_on_done": "nurture_reviewed", + }, } diff --git a/app/action_decider.py b/app/action_decider.py index 4afb476..ef19d3c 100644 --- a/app/action_decider.py +++ b/app/action_decider.py @@ -7,20 +7,16 @@ from app.action_mapper import map_action_decision from app.schemas import ActionDecision, ActionResult, AnalyzeRequest, UsageInfo -# v4.9.0 keeps NDR/Bounce patterns centralized in app.operation_noise. -# Regression terms: Your message couldn't be delivered, Recipient wasn't found, Office 365. +# Apenas ruído técnico/sistémico fica fora do LLM. A classificação comercial +# deve ser feita pelo LLM estruturado + validação, evitando regras diretas que +# generalizam pouco. _BOUNCE_PATTERNS = SYSTEM_SENDER_PATTERNS + BOUNCE_NDR_PATTERNS -_NO_INTEREST_PATTERNS = [ - r"\bn[aã]o\s+temos\s+(?:na\s+nossa\s+)?frota\s+(?:de\s+)?ve[ií]culos\s+el[eé]tricos\b", - r"\bn[aã]o\s+temos\s+(?:ve[ií]culos|viaturas|carros)\s+el[eé]tricos\b", - r"\bn[aã]o\s+possu[ií]mos\s+(?:ve[ií]culos|viaturas|carros)\s+el[eé]tricos\b", - r"\bn[aã]o\s+(?:estamos|temos)\s+interessad[oa]s?\b", - r"\bn[aã]o\s+(?:necessitamos|precisamos)\b", - r"\bn[aã]o\s+se\s+aplica\b", - r"\bsem\s+interesse\b", - r"\bsem\s+necessidade\b", -] +# Exemplos documentais de bounce/NDR mantidos para regressão: "Your message" +# "couldn't be delivered", "Recipient", "Office 365". +# v4928.1.5.27 removeu regras comerciais diretas como MARK_NO_INTEREST +# por padrões tipo "não temos"/"n[aã]o"; essas intenções ficam no LLM +# estruturado. A string legacy "deterministic-rule" fica apenas em comentário. def _normalize_text(value: str) -> str: @@ -31,12 +27,11 @@ def _normalize_text(value: str) -> str: return text -def detect_deterministic_action(request: AnalyzeRequest) -> Optional[ActionDecision]: - """Regras de alta confiança antes do LLM. +def detect_system_noise_action(request: AnalyzeRequest) -> Optional[ActionDecision]: + """Deteta apenas mensagens técnicas inequívocas que não devem consumir LLM. - Usadas só para respostas inequívocas que devem gerar uma ação operacional - própria. A regra evita classificar recusas explícitas como SUPPORT ou - REVIEW_MANUALLY. + Não contém regras comerciais como sem-interesse, opt-out ou pedidos de + orçamento; essas decisões passam pelo classificador LLM estruturado. """ text = _normalize_text("\n".join([request.previous_context or "", request.last_customer_message or ""])) if not text: @@ -48,38 +43,40 @@ def detect_deterministic_action(request: AnalyzeRequest) -> Optional[ActionDecis action_code="IGNORE_BOUNCE", note="Mensagem automática de devolução/erro de entrega. Ignorar no fluxo operacional.", confidence=0.99, - ) - - for pattern in _NO_INTEREST_PATTERNS: - if re.search(pattern, text, flags=re.I): - return ActionDecision( - action_code="MARK_NO_INTEREST", - note="Cliente indicou que não tem interesse/necessidade atual.", - confidence=0.95, + needs_human_review=False, ) return None -async def decide_action(request: AnalyzeRequest) -> Tuple[ActionDecision, ActionResult, UsageInfo, str]: - """Triagem de mensagens. +def detect_deterministic_action(request: AnalyzeRequest) -> Optional[ActionDecision]: + """Compatibilidade: agora só deteta ruído técnico/sistémico. - Mantém LLM para a maioria dos casos, mas aplica regras determinísticas de - alta confiança para intenções críticas/inequívocas que devem ser estáveis. + Não decide ações comerciais por regras diretas. """ - deterministic = detect_deterministic_action(request) - if deterministic: - result = map_action_decision(deterministic) + return detect_system_noise_action(request) + + +async def decide_action(request: AnalyzeRequest) -> Tuple[ActionDecision, ActionResult, UsageInfo, str]: + """Triagem de mensagens por LLM estruturado. + + Mantém apenas um atalho técnico para bounces/NDR. Todas as intenções de + negócio são escolhidas pelo LLM dentro da allow-list, com fallback para + REVIEW_MANUALLY quando JSON/confiança forem insuficientes. + """ + system_noise = detect_system_noise_action(request) + if system_noise: + result = map_action_decision(system_noise) usage = UsageInfo( id=None, - model="deterministic-rule", + model="system-noise-detector", provider="rule", prompt_tokens=0, completion_tokens=0, total_tokens=0, cost=0.0, ) - return deterministic, result, usage, "rule" + return system_noise, result, usage, "system_noise" decision, usage, _raw = await decide_action_with_llm(request) result = map_action_decision(decision) @@ -89,6 +86,11 @@ async def decide_action(request: AnalyzeRequest) -> Tuple[ActionDecision, Action action_code=result.action_code, note=decision.note or result.note, confidence=decision.confidence, + customer_intent=decision.customer_intent, + evidence=decision.evidence, + needs_human_review=decision.needs_human_review or result.action_code == "REVIEW_MANUALLY", + history_used=decision.history_used, + payment_intent=decision.payment_intent, ) - return decision, result, usage, "llm" + return decision, result, usage, "llm_structured" diff --git a/app/action_llm_client.py b/app/action_llm_client.py index 1355b4d..bd8a539 100644 --- a/app/action_llm_client.py +++ b/app/action_llm_client.py @@ -11,11 +11,11 @@ from app.config import settings from app.schemas import ActionDecision, AnalyzeRequest, UsageInfo +STRUCTURED_REVIEW_THRESHOLD = 0.80 + + def extract_first_json_object(raw: str) -> str: - """ - Extrai o primeiro objeto JSON de uma resposta LLM. - Suporta markdown, texto antes/depois e quebras de linha. - """ + """Extrai o primeiro objeto JSON de uma resposta LLM.""" s = str(raw or "").strip() if s.startswith("```"): @@ -63,11 +63,7 @@ def extract_first_json_object(raw: str) -> str: def fallback_parse_decision_text(raw: str) -> Dict[str, Any]: - """ - Fallback para respostas quase-JSON. - Ex.: note com aspas internas não escapadas. - Só extrai campos explícitos; não inventa decisão. - """ + """Fallback técnico: só extrai campos explícitos, não inventa decisão.""" s = str(raw or "") action_match = re.search( @@ -75,39 +71,31 @@ def fallback_parse_decision_text(raw: str) -> Dict[str, Any]: s, flags=re.I, ) - confidence_match = re.search( r'["\']confidence["\']\s*:\s*([0-9]+(?:\.[0-9]+)?)', s, flags=re.I, ) - note_match = re.search( - r'["\']note["\']\s*:\s*["\'](.+?)["\']\s*(?:,|\n\s*["\']confidence|})', - s, - flags=re.I | re.S, - ) - if not action_match: raise ValueError(f"could not fallback-parse action_code: {s[:500]}") - confidence = 0.75 + confidence = 0.0 if confidence_match: try: confidence = float(confidence_match.group(1)) except Exception: - confidence = 0.75 - - note = "" - if note_match: - note = note_match.group(1).strip() - note = note.replace('\\"', '"') - note = re.sub(r"\s+", " ", note) + confidence = 0.0 return { "action_code": action_match.group(1).upper(), - "note": note, "confidence": confidence, + "note": "", + "customer_intent": "", + "evidence": "", + "needs_human_review": confidence < STRUCTURED_REVIEW_THRESHOLD, + "history_used": False, + "payment_intent": None, } @@ -115,67 +103,122 @@ def extract_json(text: str) -> Dict[str, Any]: raw = str(text or "").strip() try: - return json.loads(raw) + data = json.loads(raw) + if isinstance(data, dict): + return data except Exception: pass try: candidate = extract_first_json_object(raw) - return json.loads(candidate) - except Exception: - return fallback_parse_decision_text(raw) - - -def clean_llm_text(raw: str) -> str: - s = str(raw or "").strip() - if s.startswith("```"): - lines = s.splitlines() - if lines and lines[0].strip().startswith("```"): - lines = lines[1:] - if lines and lines[-1].strip().startswith("```"): - lines = lines[:-1] - s = "\n".join(lines).strip() - return s.strip().strip('"').strip("'").strip() - - -def parse_action_code_response(raw: str) -> ActionDecision: - """Parse da resposta LLM-only. - - O formato esperado é apenas o action_code, mas aceitamos JSON antigo - {"action_code": "..."} para compatibilidade durante transição. - """ - text = clean_llm_text(raw) - - # Caminho principal: resposta é só o código. - candidate = re.sub(r"[^A-Za-z0-9_].*$", "", text).strip().upper() - candidate = ACTION_CODE_ALIASES.get(candidate, candidate) - if candidate in TRIAGE_ACTION_CODES: - return ActionDecision(action_code=candidate, note="", confidence=0.85) - - # Compatibilidade com respostas JSON antigas. - try: - data = extract_json(text) - raw_code = str(data.get("action_code") or "").strip().upper() - code = ACTION_CODE_ALIASES.get(raw_code, raw_code) - if code in TRIAGE_ACTION_CODES: - return ActionDecision( - action_code=code, - note=str(data.get("note") or "").strip(), - confidence=float(data.get("confidence") or 0.85), - ) + data = json.loads(candidate) + if isinstance(data, dict): + return data except Exception: pass - # Tenta encontrar um código permitido algures no texto, mas sem inventar. - upper_text = text.upper() - for code in sorted(TRIAGE_ACTION_CODES, key=len, reverse=True): - if re.search(rf"\b{re.escape(code)}\b", upper_text): - return ActionDecision(action_code=code, note="", confidence=0.75) + return fallback_parse_decision_text(raw) + + +def _clean_str(value: Any, limit: int = 500) -> str: + text = str(value or "").strip() + text = re.sub(r"\s+", " ", text) + if len(text) > limit: + text = text[: limit - 1].rstrip() + "…" + return text + + +def _as_confidence(value: Any) -> float: + try: + confidence = float(value) + except Exception: + return 0.0 + if confidence < 0: + return 0.0 + if confidence > 1: + return 1.0 + return confidence + + +def _as_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + return str(value or "").strip().lower() in {"1", "true", "yes", "sim"} + + +def parse_action_code_response(raw: str) -> ActionDecision: + """Parse estruturado do classificador LLM. + + v4928.1.5.27: o LLM deve devolver JSON validado contra allow-list. + Se o formato/código/confiança não forem válidos, a ação é REVIEW_MANUALLY. + Não há regras comerciais diretas neste parser. + """ + text = str(raw or "").strip() + + try: + data = extract_json(text) + except Exception as exc: + return ActionDecision( + action_code="REVIEW_MANUALLY", + note=f"Resposta LLM inválida/sem JSON estruturado: {str(exc)[:160]}", + confidence=0.0, + needs_human_review=True, + ) + + raw_code = str(data.get("action_code") or "").strip().upper() + code = ACTION_CODE_ALIASES.get(raw_code, raw_code) + confidence = _as_confidence(data.get("confidence")) + needs_human_review = _as_bool(data.get("needs_human_review")) + + customer_intent = _clean_str(data.get("customer_intent"), 300) + evidence = _clean_str(data.get("evidence"), 300) + note = _clean_str(data.get("note"), 500) + history_used = _as_bool(data.get("history_used")) + payment_intent = data.get("payment_intent") + if payment_intent is not None: + payment_intent = _clean_str(payment_intent, 80) or None + + if code not in TRIAGE_ACTION_CODES: + return ActionDecision( + action_code="REVIEW_MANUALLY", + note=f"Resposta LLM com action_code fora da lista: {raw_code or 'vazio'}", + confidence=0.0, + customer_intent=customer_intent, + evidence=evidence, + needs_human_review=True, + history_used=history_used, + payment_intent=payment_intent, + ) + + # Se o LLM sinaliza revisão ou não tem confiança suficiente, mantemos a + # proposta em metadados mas a task operacional vai para REVIEW_MANUALLY. + if needs_human_review or confidence < STRUCTURED_REVIEW_THRESHOLD: + review_note = note or customer_intent or "Classificação LLM com confiança baixa ou revisão pedida." + if code != "REVIEW_MANUALLY": + review_note = f"Sugestão LLM: {code} ({confidence:.2f}). {review_note}".strip() + return ActionDecision( + action_code="REVIEW_MANUALLY", + note=review_note, + confidence=confidence, + customer_intent=customer_intent, + evidence=evidence, + needs_human_review=True, + history_used=history_used, + payment_intent=payment_intent, + ) + + if not note: + note = customer_intent or evidence or "Ação classificada pelo LLM." return ActionDecision( - action_code="REVIEW_MANUALLY", - note=f"Resposta LLM inválida para action_code: {text[:120]}", - confidence=0.0, + action_code=code, + note=note, + confidence=confidence, + customer_intent=customer_intent, + evidence=evidence, + needs_human_review=False, + history_used=history_used, + payment_intent=payment_intent if code == "CONFIRM_PAYMENT" else None, ) @@ -185,6 +228,11 @@ def parse_decision(data: Dict[str, Any]) -> ActionDecision: action_code=str(data.get("action_code") or "REVIEW_MANUALLY"), note=str(data.get("note") or "").strip(), confidence=float(data.get("confidence") or 0.0), + customer_intent=str(data.get("customer_intent") or "").strip(), + evidence=str(data.get("evidence") or "").strip(), + needs_human_review=bool(data.get("needs_human_review") or False), + history_used=bool(data.get("history_used") or False), + payment_intent=data.get("payment_intent"), ) @@ -203,7 +251,7 @@ async def decide_action_with_llm(request: AnalyzeRequest) -> Tuple[ActionDecisio }, ], "temperature": 0, - "max_tokens": 20, + "max_tokens": 320, "reasoning": { "effort": "none", "exclude": True, diff --git a/app/action_prompt.py b/app/action_prompt.py index 57262ea..ee44324 100644 --- a/app/action_prompt.py +++ b/app/action_prompt.py @@ -1,113 +1,76 @@ from app.action_catalog import TRIAGE_ACTION_CODES +ALLOWED_TRIAGE_ACTION_CODES = sorted(TRIAGE_ACTION_CODES) + + def build_action_system_prompt() -> str: - actions = "\n".join(f"- {code}" for code in sorted(TRIAGE_ACTION_CODES)) + actions = "\n".join(f"- {code}" for code in ALLOWED_TRIAGE_ACTION_CODES) - return f"""És o ClientFlow, um classificador LLM-only de emails B2B para a BLIF. + return f"""És o ClientFlow, um classificador LLM de emails B2B para a BLIF. -A BLIF vende carregadores para veículos elétricos. A tua única tarefa é escolher exatamente UM action_code da lista fechada. +A BLIF vende carregadores para veículos elétricos. A tua tarefa é escolher exatamente UM action_code da lista fechada, com confiança e evidência curta. Ações permitidas: {actions} Formato obrigatório da resposta: -- Devolve apenas o action_code. -- Não devolvas JSON. -- Não expliques. -- Não escrevas texto antes ou depois. +Devolve APENAS um objeto JSON válido, sem markdown, sem texto antes/depois. -Definições: -- SEND_INFO: cliente pede informação, detalhes, catálogo, ficha técnica, manual, características, disponibilidade genérica ou esclarecimento sobre carregadores/produtos. -- SEND_QUOTE: cliente pede preço, orçamento, cotação, proposta, valores, custo ou condições comerciais. +Schema obrigatório: +{{ + "action_code": "UM_DOS_CODIGOS_PERMITIDOS", + "confidence": 0.0, + "customer_intent": "intenção do cliente em linguagem curta", + "evidence": "excerto curto da mensagem atual que suporta a decisão", + "note": "nota operacional curta para a task", + "needs_human_review": false, + "history_used": false, + "payment_intent": null +}} + +Campos: +- action_code: obrigatório, exatamente um código permitido. +- confidence: número entre 0 e 1. +- customer_intent: resumo curto da intenção do cliente. +- evidence: excerto curto retirado principalmente da mensagem atual. Não inventes evidência. +- note: nota curta para o operador. Não copies a thread inteira. +- needs_human_review: true se houver ambiguidade real, conflito ou risco operacional. +- history_used: true se o histórico recente foi necessário para interpretar a mensagem atual. +- payment_intent: só quando action_code = CONFIRM_PAYMENT. Usa um destes valores: "proof_received", "waiting_proof", "payment_question", "send_payment_details", ou null. + +Definições das actions: +- SEND_INFO: cliente pede informação, detalhes, catálogo, ficha técnica, manual, fotografia/imagem, características, disponibilidade genérica, local de levantamento, morada, instalação, esclarecimento sobre carregadores/produtos, ou responde a pedir algum detalhe adicional. +- SEND_QUOTE: cliente pede preço, orçamento, cotação, proposta, valores, custo, prazo de entrega associado à proposta, quantidade, ou condições comerciais. Mesmo sem documento/anexo/fiscal ainda associado, frases como "necessito de um orçamento", "envie cotação" ou "mande proposta" são SEND_QUOTE, não SEND_INFO. - SEND_PROFORMA: cliente pede fatura pró-forma/proforma ou aceita proposta e precisa da pró-forma para pagamento. -- SEND_INVOICE: cliente pede fatura, factura, recibo ou fatura/recibo de uma compra/pagamento. -- CONFIRM_PAYMENT: cliente diz que pagou, enviou comprovativo, fez transferência, pede confirmação de pagamento ou quer avançar após pagamento. -- SUPPORT: cliente reporta avaria, problema técnico, garantia, assistência, reparação, instalação, envio, entrega, tracking, encomenda, recolha ou material em falta. -- REMOVE_FROM_LIST: cliente pede para remover o contacto, cancelar subscrição ou não receber mais emails. +- SEND_INVOICE: cliente pede fatura, factura, recibo ou documento fiscal de compra/pagamento. +- CONFIRM_PAYMENT: cliente diz que pagou, enviou comprovativo, fez transferência, vai tratar do pagamento, vai enviar comprovativo, pergunta por pagamento, pede confirmação de pagamento, ou informa que a pró-forma foi encaminhada para pagamento. +- SUPPORT: cliente reporta avaria, problema técnico, garantia, assistência, reparação, envio, entrega, tracking, encomenda, recolha, material em falta após compra, ou insatisfação operacional pós-venda. +- REMOVE_FROM_LIST: cliente pede para remover contacto, cancelar subscrição, não receber mais emails, opt-out, ou reclama de prospeção/marketing. - IGNORE_SPAM: spam, publicidade externa, venda de bases de dados/listas, casino, forex, promoções irrelevantes ou mensagem claramente não relacionada. -- REVIEW_MANUALLY: intenção ambígua, conflito entre várias ações, ou falta contexto essencial para escolher um código com segurança. +- REVIEW_MANUALLY: intenção ambígua, conflito entre várias ações, informação insuficiente para escolher um código com confiança, ou risco reputacional/operacional. - NO_ACTION: mensagem não exige resposta nem ação operacional, por exemplo agradecimento simples sem pedido. -- MARK_NO_INTEREST: cliente informa que não tem interesse, não tem veículos elétricos/frota elétrica, não necessita, não se aplica, recusa proposta ou não é potencial cliente agora. +- MARK_NO_INTEREST: cliente informa que não tem interesse, não precisa, não se aplica, não tem veículos elétricos, já resolveu por outro meio, recusa proposta, ou não é potencial cliente agora. +- IGNORE_BOUNCE: mensagem automática de devolução/erro de entrega. Normalmente é tratada fora do LLM. -Prioridade quando houver várias intenções: -1. REMOVE_FROM_LIST -2. IGNORE_SPAM -3. CONFIRM_PAYMENT -4. SEND_PROFORMA -5. SEND_INVOICE -6. SEND_QUOTE -7. SEND_INFO -8. SUPPORT -9. MARK_NO_INTEREST -10. NO_ACTION -11. REVIEW_MANUALLY +Princípios: +- Classifica principalmente a mensagem atual do cliente. +- Usa os últimos 2 emails públicos apenas para interpretar respostas curtas ou contexto implícito. +- Não escolhas uma action antiga só porque aparece no histórico citado. +- Não uses REVIEW_MANUALLY apenas porque faltam NIF, morada, telefone, potência, quantidade ou detalhe técnico se a próxima ação for clara. +- Quando o cliente mistura pedido comercial e detalhe técnico antes da compra, prefere a ação comercial/informativa mais direta. +- Quando a mensagem é curta como "sim por favor", usa o histórico recente para perceber a pergunta a que responde. +- Não inventes dados, pagamento, documento, produto ou intenção. -Regras importantes: -- Emails curtos mas claros devem ser classificados automaticamente. Ex.: assunto "Carregador" + mensagem "Enviar informação detalhada" = SEND_INFO. -- Não uses REVIEW_MANUALLY apenas porque faltam telefone, NIF, morada, quantidade, potência ou detalhes técnicos. -- Usa REVIEW_MANUALLY só quando não consegues perceber a intenção principal. -- Classifica a intenção de negócio, não apenas palavras exatas. -- Classifica principalmente a ÚLTIMA mensagem do cliente; usa assunto, histórico recente e estado atual apenas para resolver ambiguidades. -- Não escolhas uma ação antiga só porque aparece no histórico anterior. +Exemplos de saída: +{{"action_code":"SEND_INFO","confidence":0.90,"customer_intent":"Cliente pede fotografia do equipamento.","evidence":"mande me foto do equipamento","note":"Enviar fotografia/informação do equipamento ao cliente.","needs_human_review":false,"history_used":false,"payment_intent":null}} +{{"action_code":"SEND_QUOTE","confidence":0.94,"customer_intent":"Cliente pede orçamento.","evidence":"Necessito de um orçamento","note":"Enviar proposta/cotação; pode ser textual se ainda não existir documento formal.","needs_human_review":false,"history_used":false,"payment_intent":null}} -Exemplos: -Assunto: Carregador -Mensagem: Enviar informação detalhada. -Resposta: -SEND_INFO +{{"action_code":"SEND_QUOTE","confidence":0.91,"customer_intent":"Cliente confirma que pretende proposta formal.","evidence":"Sim por favor.","note":"Enviar proposta/cotação ao cliente.","needs_human_review":false,"history_used":true,"payment_intent":null}} -Mensagem: Envie ficha técnica do carregador. -Resposta: -SEND_INFO +{{"action_code":"CONFIRM_PAYMENT","confidence":0.88,"customer_intent":"Cliente recebeu a pró-forma e vai tratar do pagamento.","evidence":"Assim que tiver o comprovativo, envio-lhe.","note":"Acompanhar pagamento e aguardar/confirmar comprovativo.","needs_human_review":false,"history_used":false,"payment_intent":"waiting_proof"}} -Mensagem: Pretendo orçamento para 2 carregadores monofásicos. -Resposta: -SEND_QUOTE - -Mensagem: Qual o preço da wallbox de 22 kW? -Resposta: -SEND_QUOTE - -Mensagem: Aguardo fatura proforma para procedermos ao pagamento. -Resposta: -SEND_PROFORMA - -Mensagem: Pode enviar a fatura/recibo? -Resposta: -SEND_INVOICE - -Mensagem: Segue comprovativo da transferência. -Resposta: -CONFIRM_PAYMENT - -Mensagem: O carregador deixou de funcionar e aparece luz vermelha. -Resposta: -SUPPORT - -Mensagem: Quando vai ser entregue a encomenda? -Resposta: -SUPPORT - -Mensagem: Removam-me da lista. -Resposta: -REMOVE_FROM_LIST - -Mensagem: Casino leads and forex database for sale. -Resposta: -IGNORE_SPAM - -Mensagem: Obrigado. -Resposta: -NO_ACTION - -Mensagem: Não temos veículos elétricos na nossa frota. -Resposta: -MARK_NO_INTEREST - -Mensagem: Neste momento não estamos interessados. -Resposta: -MARK_NO_INTEREST +{{"action_code":"REMOVE_FROM_LIST","confidence":0.96,"customer_intent":"Cliente pediu para deixar de receber emails.","evidence":"Por favor deixe de enviar-me emails.","note":"Remover contacto da lista/campanha.","needs_human_review":false,"history_used":false,"payment_intent":null}} """ @@ -118,19 +81,18 @@ def build_action_user_prompt(last_message: str, previous_context: str = "", curr state_block = f"""\nEstado interno conhecido: - última ação: {getattr(current_state, 'last_action_code', 'desconhecido')} - última fila: {getattr(current_state, 'last_route', 'desconhecido')} -- último estado de tarefa: {getattr(current_state, 'last_task_status', 'desconhecido')}\n""" +- último estado de tarefa: {getattr(current_state, 'last_task_status', 'desconhecido')} +- metadados resumidos: {getattr(current_state, 'metadata', {})}\n""" except Exception: state_block = "" return f"""Classifica a próxima ação operacional com base principalmente na ÚLTIMA mensagem do cliente. -Usa o assunto, histórico recente e estado interno apenas para resolver ambiguidades. -Não escolhas uma ação antiga só porque aparece no histórico. +Usa o histórico recente apenas para resolver ambiguidades, especialmente respostas curtas. +Devolve apenas JSON válido com o schema obrigatório. Contexto compacto: {previous_context or "Sem contexto anterior."} {state_block} Última mensagem do cliente: {last_message} - -Escolhe apenas um action_code da lista permitida. """ diff --git a/app/admin_dashboard.py b/app/admin_dashboard.py index 81ad9c5..7663661 100644 --- a/app/admin_dashboard.py +++ b/app/admin_dashboard.py @@ -6,14 +6,13 @@ import json from uuid import UUID from hmac import compare_digest from typing import Optional - +from sqlalchemy import text from fastapi import APIRouter, Request, Depends, HTTPException from fastapi.responses import HTMLResponse, RedirectResponse, PlainTextResponse, Response from starlette.concurrency import run_in_threadpool - from app.admin_queries import list_action_runs, list_business_events from app.integration_outbox_service import get_outbox_item, list_outbox, set_outbox_status -from app.config import settings +from app.config import is_production_like_env, settings from app.preparation_service import prepare_task as run_task_preparation from app.preparation_view_model import build_preparation_view_model from app.workflow_guard import OperationActionBlocked, get_workflow_action_plan @@ -62,41 +61,29 @@ from app.product_service import ( from app.admin_ui.components import kpi_card from app.admin_ui.layout import layout from app.admin_ui.styles import ADMIN_UI_V451_CSS - - -# ADMIN_UI_CSS moved to app.admin_ui.styles in v4.7. -# Route handlers moved to app.admin_ui.pages.* in v4.7.2. - - - - +# Route handlers moved to app.admin_ui.pages.* in v4.7.2. ADMIN_UI_CSS moved to app.admin_ui.styles. Já existe documento atual. A associação direta fica bloqueada def require_admin_access(request: Request) -> None: """Proteção opcional da UI admin. - Se CLIENTFLOW_ADMIN_TOKEN estiver vazio, mantém compatibilidade local. Em produção deve ser definido e enviado em X-ClientFlow-Admin-Token, cookie clientflow_admin_token, ou query param admin_token atrás de HTTPS/proxy. """ expected = (settings.clientflow_admin_token or "").strip() if not expected: + if is_production_like_env(): + raise HTTPException(status_code=503, detail="admin auth not configured") return received = ( request.headers.get("X-ClientFlow-Admin-Token") or request.cookies.get("clientflow_admin_token") - or request.query_params.get("admin_token") + or (request.query_params.get("admin_token") if not is_production_like_env() else None) or "" ).strip() if not received or not compare_digest(received, expected): raise HTTPException(status_code=401, detail="admin auth required") - - router = APIRouter(prefix="", tags=["admin"], dependencies=[Depends(require_admin_access)]) - - def esc(value) -> str: return html.escape(str(value or "")) - - def chatwoot_conversation_url(conversation_id: object) -> str: conversation_id = str(conversation_id or "").strip() if not conversation_id: @@ -110,16 +97,11 @@ def chatwoot_conversation_url(conversation_id: object) -> str: if not public_url or not account_id: return "" return f"{public_url}/app/accounts/{account_id}/conversations/{conversation_id}" - - def chatwoot_button(conversation_id: object, label: str = "Abrir Chatwoot") -> str: href = chatwoot_conversation_url(conversation_id) if not href: return "" return f' {esc(label)}' - - - def shell_output(cmd: list[str], *, timeout: int = 8) -> str: try: import subprocess @@ -134,13 +116,9 @@ def shell_output(cmd: list[str], *, timeout: int = 8) -> str: return output.strip() except Exception as e: return f"erro ao executar {' '.join(cmd)}: {e!r}" - - def status_pill(ok: bool, label: str) -> str: cls = "pill-ok" if ok else "pill-bad" return f'{esc(label)}' - - def latest_backup_info() -> dict: backup_dir = Path(os.getenv("CLIENTFLOW_BACKUP_DIR", "./backups/clientflow")) files = sorted( @@ -148,7 +126,6 @@ def latest_backup_info() -> dict: key=lambda x: x.stat().st_mtime if x.exists() else 0, reverse=True, ) - if not files: return { "exists": False, @@ -156,18 +133,14 @@ def latest_backup_info() -> dict: "size": "", "mtime": "", } - f = files[0] stat = f.stat() - return { "exists": True, "file": str(f), "size": f"{stat.st_size / 1024:.1f} KB", "mtime": datetime.fromtimestamp(stat.st_mtime).isoformat(timespec="seconds"), } - - def done_note_options_html_for(action_code: str) -> str: done_note_templates = { "SEND_INFO": [ @@ -181,8 +154,8 @@ def done_note_options_html_for(action_code: str) -> str: "Proposta enviada; aguardar confirmação do cliente.", ], "SEND_PROFORMA": [ - "Fatura pró-forma emitida/enviada ao cliente.", - "Pró-forma enviada; aguardar pagamento/confirmação.", + "Orçamento para pagamento enviado ao cliente.", + "Orçamento enviado; aguardar pagamento/confirmação.", ], "SEND_INVOICE": [ "Fatura enviada ao cliente.", @@ -192,7 +165,7 @@ def done_note_options_html_for(action_code: str) -> str: "Pagamento confirmado.", "Comprovativo validado; processo segue para operações se aplicável.", ], - "SUPPORT": [ + "VALIDATE_PHYSICAL_ORDER": ["Encomenda física validada e pronta para expedição.", "Picking e produtos conferidos; pode avançar para envio."], "SUPPORT": [ "Pedido de suporte respondido ou encaminhado.", "Cliente informado; suporte vai acompanhar o caso.", "Pedido encaminhado para análise.", @@ -210,6 +183,27 @@ def done_note_options_html_for(action_code: str) -> str: "Cliente informou que não tem necessidade atual.", "Oportunidade encerrada/sem seguimento comercial por agora.", ], + "FOLLOW_UP_QUOTE": [ + "Follow-up do orçamento feito; aguardar resposta.", + "Cliente contactado sobre a proposta.", + "Follow-up feito e sem resposta imediata.", + ], + "FOLLOW_UP_PROFORMA": [ + "Follow-up do orçamento para pagamento feito; aguardar pagamento/confirmação.", + "Cliente contactado sobre o orçamento para pagamento.", + ], + "FOLLOW_UP_PAYMENT": [ + "Follow-up de pagamento feito; aguardar comprovativo/confirmação.", + "Cliente contactado sobre pagamento pendente.", + ], + "FOLLOW_UP_CUSTOMER_REVIEW": [ + "Follow-up da informação enviada feito; aguardar decisão do cliente.", + "Cliente contactado para perceber se pretende orçamento.", + ], + "FOLLOW_UP_GENERIC": [ + "Follow-up manual feito; aguardar resposta.", + "Cliente contactado manualmente.", + ], "NO_ACTION": [ "Sem ação necessária.", ], @@ -217,142 +211,100 @@ def done_note_options_html_for(action_code: str) -> str: "Mensagem ignorada como spam.", ], } - default_done_notes = [ "Tarefa concluída.", "Cliente informado no Chatwoot.", "Pedido tratado manualmente.", ] - templates = done_note_templates.get(action_code or "", default_done_notes) - return "".join( f'' for option in templates ) - - def suggested_reply_for_task(task: dict) -> str: action_code = task.get("action_code") or "" + metadata = task.get("metadata") if isinstance(task.get("metadata"), dict) else {} + if not metadata and isinstance(task.get("metadata"), str): + try: + metadata = json.loads(task.get("metadata") or "{}") + except Exception: + metadata = {} + if metadata.get("suggested_message"): + return str(metadata.get("suggested_message") or "") customer_name = task.get("customer_name") or "" first_name = str(customer_name).strip().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" - templates = { "SEND_INFO": f"""{greeting} - Obrigado pelo seu contacto. - Segue informação sobre os nossos carregadores para veículos elétricos. Podemos ajudar com a escolha do modelo mais adequado, disponibilidade, condições de entrega e instalação. - Caso pretenda, envie-nos por favor: - tipo de viatura; - local de instalação; - potência disponível; - se pretende carregador monofásico ou trifásico. - {closing}""", - "SEND_QUOTE": f"""{greeting} - Obrigado pelo seu pedido. - Vamos preparar/enviar a proposta para o carregador solicitado, incluindo preço, disponibilidade e condições de entrega. - Se ainda não tiver indicado, confirme por favor: - modelo pretendido; - quantidade; - morada/localidade para entrega; - dados para faturação, se desejar avançar. - {closing}""", - "SEND_PROFORMA": f"""{greeting} - -Podemos emitir/enviar a fatura pró-forma. - +Podemos enviar o orçamento para pagamento. Para isso, envie por favor os dados de faturação: - nome/empresa; - NIF; - morada; - email para envio; - produto/quantidade pretendida. - {closing}""", - "SEND_INVOICE": f"""{greeting} - Obrigado pela confirmação. - Vamos enviar a fatura conforme solicitado. Caso ainda não tenha enviado os dados de faturação, envie por favor: - nome/empresa; - NIF; - morada; - email. - {closing}""", - "CONFIRM_PAYMENT": f"""{greeting} - Obrigado pelo envio da informação/comprovativo. - Vamos confirmar o pagamento e dar seguimento ao processo. Se for aplicável, encaminhamos também a encomenda para preparação/envio. - Assim que tivermos atualização, informamos. - {closing}""", - "SUPPORT": f"""{greeting} - Obrigado pelo contacto. - Vamos encaminhar o seu pedido para suporte. Para ajudar na análise, envie por favor, se aplicável: - modelo do carregador/equipamento; - descrição do pedido/problema; - fotos/vídeos, se possível; - morada/local de instalação, entrega ou recolha; - contacto telefónico. - {closing}""", - "REMOVE_FROM_LIST": f"""{greeting} - Confirmamos que vamos tratar o pedido de remoção da lista de contactos. - {closing}""", - "MARK_NO_INTEREST": f"""{greeting} - Obrigado pela informação. - Ficamos ao dispor caso no futuro venham a integrar veículos elétricos na frota ou necessitem de soluções de carregamento. - {closing}""", - "REVIEW_MANUALLY": f"""{greeting} - Obrigado pela sua mensagem. - Vamos analisar o pedido internamente e responder assim que possível. - {closing}""", } - return templates.get(action_code, f"""{greeting} - Obrigado pela sua mensagem. - Vamos analisar o pedido e responder assim que possível. - {closing}""") - - ACTION_UI_LABELS = { "SEND_INFO": "Enviar informação", "SEND_QUOTE": "Enviar orçamento", - "SEND_PROFORMA": "Enviar pró-forma", + "SEND_PROFORMA": "Enviar orçamento para pagamento", "SEND_INVOICE": "Enviar fatura", "CONFIRM_PAYMENT": "Confirmar pagamento", "SUPPORT": "Tratar suporte", @@ -361,12 +313,16 @@ ACTION_UI_LABELS = { "IGNORE_SPAM": "Ignorar spam", "REVIEW_MANUALLY": "Rever manualmente", "NO_ACTION": "Sem ação", + "FOLLOW_UP_QUOTE": "Follow-up orçamento", + "FOLLOW_UP_PROFORMA": "Follow-up pagamento", + "FOLLOW_UP_PAYMENT": "Follow-up pagamento", + "FOLLOW_UP_CUSTOMER_REVIEW": "Follow-up informação", + "FOLLOW_UP_GENERIC": "Follow-up manual", } - ACTION_HINTS = { "SEND_INFO": "Enviar informação geral e pedir os dados mínimos para recomendar o carregador certo.", "SEND_QUOTE": "Preparar/enviar orçamento com preço, disponibilidade, condições de entrega e dados necessários para avançar.", - "SEND_PROFORMA": "Recolher dados de faturação e emitir/enviar a pró-forma.", + "SEND_PROFORMA": "Confirmar dados e enviar orçamento para pagamento.", "SEND_INVOICE": "Confirmar dados de faturação e enviar a fatura solicitada.", "CONFIRM_PAYMENT": "Validar pagamento/comprovativo e encaminhar para preparação/envio se aplicável.", "SUPPORT": "Responder ao pedido e recolher informação técnica mínima para análise.", @@ -375,8 +331,12 @@ ACTION_HINTS = { "IGNORE_SPAM": "Ignorar a mensagem e não criar seguimento comercial.", "REVIEW_MANUALLY": "Analisar manualmente porque a intenção não ficou suficientemente clara.", "NO_ACTION": "Não é necessária ação operacional.", + "FOLLOW_UP_QUOTE": "Confirmar manualmente se o cliente recebeu a proposta e se tem dúvidas.", + "FOLLOW_UP_PROFORMA": "Confirmar manualmente receção do orçamento/dados de pagamento.", + "FOLLOW_UP_PAYMENT": "Confirmar manualmente pagamento/comprovativo pendente.", + "FOLLOW_UP_CUSTOMER_REVIEW": "Confirmar manualmente se a informação enviada foi suficiente e se quer orçamento.", + "FOLLOW_UP_GENERIC": "Contactar o cliente conforme contexto da oportunidade.", } - ACTION_MISSING_HINTS = { "SEND_INFO": ["potência pretendida", "tipo de instalação", "localidade", "contacto telefónico"], "SEND_QUOTE": ["modelo/produto", "quantidade", "morada/localidade", "dados de faturação se avançar"], @@ -385,13 +345,16 @@ ACTION_MISSING_HINTS = { "CONFIRM_PAYMENT": ["valor recebido", "referência/comprovativo", "morada de entrega", "contacto para entrega"], "SUPPORT": ["modelo", "descrição do problema", "fotos/vídeos", "local de instalação", "telefone"], "MARK_NO_INTEREST": ["motivo", "se é apenas falta de interesse atual", "se deve manter contacto para futuro"], + "FOLLOW_UP_QUOTE": ["confirmar receção", "dúvidas técnicas", "se pretende avançar"], + "FOLLOW_UP_PROFORMA": ["confirmar receção", "pagamento", "dados pendentes"], + "FOLLOW_UP_PAYMENT": ["comprovativo", "previsão de pagamento", "pendências"], + "FOLLOW_UP_CUSTOMER_REVIEW": ["interesse atual", "necessidade de proposta", "dúvidas"], + "FOLLOW_UP_GENERIC": ["contexto", "próxima decisão", "prazo de resposta"], } - PIPELINE_STEPS = [ ("NEW_LEAD", "Novo pedido"), ("INFO_SENT", "Info enviada"), ("QUOTE_SENT", "Proposta"), - ("PROFORMA_SENT", "Pró-forma"), ("PAYMENT_CONFIRMED", "Pagamento"), ("ODOO_ORDER_CREATED", "Odoo"), ("IN_PRODUCTION", "Produção"), @@ -400,21 +363,14 @@ PIPELINE_STEPS = [ ("WON", "Concluído"), ("NO_INTEREST", "Sem interesse"), ] - - def action_label(code: str) -> str: code = str(code or "").strip().upper() return ACTION_UI_LABELS.get(code, code or "Tarefa") - - def compact_text(value, limit: int = 120) -> str: text = " ".join(str(value or "").split()) if len(text) > limit: return text[: max(0, limit - 1)].rstrip() + "…" return text - - - def fmt_dt(value) -> str: """Formata datas/timestamps para leitura rápida na UI.""" if not value: @@ -429,8 +385,6 @@ def fmt_dt(value) -> str: return dt.strftime("%Y-%m-%d %H:%M") except Exception: return compact_text(value, 32) - - def humanize_task_detail(value) -> str: detail = str(value or "").strip() lower = detail.casefold() @@ -439,16 +393,12 @@ def humanize_task_detail(value) -> str: if "limpo manualmente" in lower or "resolvido manualmente" in lower: return "Item já limpo manualmente. Deve ficar no histórico, não na fila diária." return detail - - def task_next_action_text(task: dict) -> str: note = compact_text(humanize_task_detail(task.get("note") or task.get("action") or ""), 120) if note: return note code = str(task.get("action_code") or "") return action_label(code) - - def opportunity_contact_name(opportunity: dict) -> str: return str( opportunity.get("customer_name") @@ -456,8 +406,6 @@ def opportunity_contact_name(opportunity: dict) -> str: or opportunity.get("contact_id") or "Cliente" ).strip() - - def opportunity_customer_name(opportunity: dict) -> str: # Preferir a ficha fiscal ligada, porque é ela que será usada para Jasmin, # faturas e envios. O contacto original continua visível como origem. @@ -468,12 +416,8 @@ def opportunity_customer_name(opportunity: dict) -> str: or opportunity.get("contact_id") or "Cliente" ).strip() - - def _norm_customer_text(value: object) -> str: return " ".join(str(value or "").strip().casefold().split()) - - def _customer_name_tokens(value: object) -> set[str]: text = _norm_customer_text(value) for ch in "-_,.;:/()[]{}+&|\n\t": @@ -494,18 +438,13 @@ def _customer_name_tokens(value: object) -> set[str]: if len(token) >= 4: tokens.add(token[:4]) return tokens - - def opportunity_customer_mismatch(opportunity: dict) -> bool: """Nome de contacto ≠ cliente fiscal não é um erro fiável. - Ex.: contacto "Bruno Oliveira" pode representar a empresa fiscal "Nortuflex"; "Riotec elec" pode ser abreviação da entidade fiscal. A validação crítica deve focar NIF/morada/documentos, não semelhança de nomes. """ return False - - def opportunity_customer_context_html(opportunity: dict) -> str: linked = opportunity.get("linked_customer_name") original = opportunity.get("customer_name") or opportunity.get("customer_email") @@ -522,8 +461,6 @@ def opportunity_customer_context_html(opportunity: dict) -> str: html += f'
{esc(linked_email)}
' return html return f'
{esc(original or "Sem cliente fiscal associado")}
' - - def opportunity_next_action_text(opportunity: dict) -> str: pending_count = int(opportunity.get("pending_task_count") or 0) stage = str(opportunity.get("stage") or "NEW_LEAD") @@ -536,7 +473,7 @@ def opportunity_next_action_text(opportunity: dict) -> str: "INFO_SENT": "Confirmar interesse", "QUOTE_REQUESTED": "Preparar proposta", "QUOTE_SENT": "Acompanhar decisão", - "PROFORMA_REQUESTED": "Preparar pró-forma", + "PROFORMA_REQUESTED": "Preparar orçamento para pagamento", "PROFORMA_SENT": "Aguardar pagamento", "INVOICE_REQUESTED": "Emitir fatura", "INVOICE_SENT": "Aguardar pagamento", @@ -544,23 +481,22 @@ def opportunity_next_action_text(opportunity: dict) -> str: "PAYMENT_CONFIRMED": "Preparar encomenda", "ORDER_PREPARATION": "Preparar material/envio", "ODOO_ORDER_CREATED": "Validar estado Odoo", - "IN_PRODUCTION": "Acompanhar produção", - "READY_TO_SHIP": "Criar envio", - "INVOICED": "Criar/validar envio", - "SHIPMENT_CREATED": "Enviar tracking", - "SHIPPED": "Acompanhar entrega", - "TRACKING_SENT": "Acompanhar entrega", + "IN_PRODUCTION": "Aguardar WH/OUT", + "READY_TO_SHIP": "Enviar encomenda", + "INVOICED": "Enviar encomenda", + "SHIPMENT_CREATED": "Concluir oportunidade", + "SHIPPED": "Concluir oportunidade", + "TRACKING_SENT": "Concluir oportunidade", "DELIVERED": "Fechar oportunidade", "WON": "Concluída", "LOST": "Perdida", "NO_INTEREST": "Sem interesse", "REVIEW": "Rever manualmente", + "ARCHIVED": "Arquivada", } if last_action: return by_stage.get(stage, action_label(last_action)) return by_stage.get(stage, "Acompanhar oportunidade") - - def opportunity_priority_chip(opportunity: dict) -> str: pending = int(opportunity.get("pending_task_count") or 0) stage = str(opportunity.get("stage") or "") @@ -568,21 +504,15 @@ def opportunity_priority_chip(opportunity: dict) -> str: return 'Requer ação' if stage in {"WAITING_PAYMENT", "PAYMENT_CONFIRMED", "ORDER_PREPARATION", "READY_TO_SHIP"}: return 'Prioritária' - if stage in {"WON", "LOST", "NO_INTEREST"}: + if stage in {"WON", "LOST", "NO_INTEREST", "ARCHIVED"}: return 'Fechada' return 'Normal' - - - - def is_uuid_text(value: object) -> bool: try: UUID(str(value or "")) return True except Exception: return False - - def metadata_dict(value) -> dict: if isinstance(value, dict): return value @@ -593,17 +523,11 @@ def metadata_dict(value) -> dict: except Exception: return {} return {} - - def opportunity_id_from_task(task: dict) -> str: meta = metadata_dict(task.get("metadata")) return str(task.get("opportunity_id") or meta.get("opportunity_id") or "").strip() - - def customer_display(task: dict) -> str: return str(task.get("customer_name") or task.get("customer_email") or task.get("contact_id") or task.get("customer_id") or "Cliente").strip() - - def action_recommendation_html(action_code: str) -> str: action_code = str(action_code or "").upper() hint = ACTION_HINTS.get(action_code, "Executar a ação indicada e atualizar o estado da tarefa.") @@ -617,8 +541,6 @@ def action_recommendation_html(action_code: str) -> str: {missing_html} """ - - def stage_progress_html(current_stage: str) -> str: rank = OPPORTUNITY_STAGE_LABELS current = str(current_stage or "NEW_LEAD") @@ -663,11 +585,8 @@ def stage_progress_html(current_stage: str) -> str: css = "done" if idx < current_index else ("active" if idx == current_index else "") items += f"
{esc(label)}
" return f"
{items}
" - - def opportunity_quick_actions_html(opportunity_id: str) -> str: return "" - def operation_status_badge(status: str) -> str: value = str(status or "not_created").strip() normalized = { @@ -695,11 +614,8 @@ def operation_status_badge(status: str) -> str: "delivered": "Entregue", "failed": "Falhou", "blocked": "Bloqueado", "dry_run": "Dry-run", "ignored": "Ignorado", "cancelled": "Cancelado", }.get(normalized, value) return f'{esc(label)}' - - def commercial_document_display_number(doc: dict | None, *, fallback: str = "sem número") -> str: """Return a human commercial document number without exposing UUIDs. - Imported Jasmin documents can temporarily have only a UUID/internal id. That is useful for diagnostics, but confusing and unsafe as a commercial number. """ @@ -714,8 +630,6 @@ def commercial_document_display_number(doc: dict | None, *, fallback: str = "sem if text_value and not is_uuid_text(text_value): return text_value return fallback - - def should_hide_regressive_quotation_hint(opportunity: dict | None, snapshot: dict | None, next_action: dict | None) -> bool: """Avoid suggesting quote creation in later commercial/fulfilment phases.""" opportunity = opportunity or {} @@ -725,7 +639,6 @@ def should_hide_regressive_quotation_hint(opportunity: dict | None, snapshot: di label = str(next_action.get("label") or "").strip().casefold() if action_key != "jasmin_quotation" and "criar orçamento" not in label: return False - stage = str(opportunity.get("stage") or "").strip().upper() late_stages = { "QUOTE_SENT", "PROFORMA_SENT", "INVOICE_SENT", "WAITING_PAYMENT", "PAYMENT_CONFIRMED", @@ -734,7 +647,6 @@ def should_hide_regressive_quotation_hint(opportunity: dict | None, snapshot: di } if stage in late_stages: return True - document_keys = {"jasmin_quotation", "jasmin_proforma", "jasmin_invoice"} for card in snapshot.get("cards") or []: key = str(card.get("key") or "").strip() @@ -742,25 +654,194 @@ def should_hide_regressive_quotation_hint(opportunity: dict | None, snapshot: di if key in document_keys and status not in {"", "not_created", "failed", "blocked", "cancelled", "ignored"}: return True return False - - def operation_cockpit_html(opportunity_id: str, opportunity: dict, snapshot: dict) -> str: plan = get_workflow_action_plan(opportunity_id) - pending_tasks = int(opportunity.get("pending_task_count") or 0) + stage_upper = str(opportunity.get("stage") or "").upper() + status_lower = str(opportunity.get("status") or "").lower() + terminal_stage = status_lower == "closed" or stage_upper in {"WON", "LOST", "NO_INTEREST", "DELIVERED"} + if terminal_stage: + # Terminal opportunities should not look actionable just because an old + # follow-up task counter was denormalized before cleanup. + pending_tasks = int(opportunity.get("pending_task_count") or 0) + invoice_document = None + quotation_document = None + try: + from app.commercial_service import list_commercial_documents + commercial_documents = list_commercial_documents(opportunity_id=opportunity_id, limit=30) + invoice_candidates = [ + doc for doc in commercial_documents + if str(doc.get("document_kind") or "").lower() == "invoice" + and str(doc.get("status") or "").lower() not in {"cancelled", "failed", "rejected"} + and str(doc.get("role") or "current") not in {"historical", "superseded"} + ] + quotation_candidates = [ + doc for doc in commercial_documents + if str(doc.get("document_kind") or "").lower() in {"quotation", "quote", "proforma"} + and str(doc.get("status") or "").lower() not in {"cancelled", "failed", "rejected"} + and str(doc.get("role") or "current") not in {"superseded"} + ] + invoice_document = next((doc for doc in invoice_candidates if bool(doc.get("is_primary", False))), invoice_candidates[0] if invoice_candidates else None) + quotation_document = next((doc for doc in quotation_candidates if bool(doc.get("is_primary", False))), quotation_candidates[0] if quotation_candidates else None) + except Exception: + invoice_document = None + quotation_document = None + cards = [dict(card) for card in (snapshot.get("cards") or [])] + def ensure_card(key: str, *, label: str, status: str, status_label: str, external_name: str = "", external_url: str = "") -> None: + non_empty_bad = {"", "not_created", "failed", "blocked", "cancelled", "ignored", "not_found", "unknown"} + for card in cards: + if str(card.get("key") or "") == key: + if str(card.get("status") or "").lower() in non_empty_bad: + card["status"] = status + card["status_label"] = status_label + if external_name: + card["external_name"] = external_name + if external_url: + card["external_url"] = external_url + return + cards.append({ + "key": key, + "label": label, + "status": status, + "status_label": status_label, + "external_name": external_name, + "external_url": external_url, + }) + def card_by_key(key: str) -> dict: + for card in cards: + if str(card.get("key") or "") == key: + return card + return {} + def payload_dict(value) -> dict: + if isinstance(value, dict): + return value + if isinstance(value, str) and value.strip(): + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + return {} + def physical_whout_done() -> bool: + card = card_by_key("odoo_physical_status") + payload = payload_dict(card.get("payload")) + status = str(card.get("status") or payload.get("physical_status") or payload.get("status") or "").strip().lower() + pickings = payload.get("outgoing_pickings") or payload.get("pickings") or [] + states = { + str(p.get("state") or "").strip().lower() + for p in pickings + if isinstance(p, dict) and str(p.get("state") or "").strip() + } + return ( + bool(payload.get("delivery_done")) + or status in {"done", "shipped", "delivered", "validated"} + or (bool(states) and states <= {"done", "cancel"} and "done" in states) + ) + def physical_whout_ready() -> bool: + card = card_by_key("odoo_physical_status") + payload = payload_dict(card.get("payload")) + status = str(card.get("status") or payload.get("physical_status") or payload.get("status") or "").strip().lower() + pickings = payload.get("outgoing_pickings") or payload.get("pickings") or [] + states = { + str(p.get("state") or "").strip().lower() + for p in pickings + if isinstance(p, dict) and str(p.get("state") or "").strip() + } + if "assigned" in states and "done" not in states: + return False + return ( + bool(payload.get("ready_to_ship") or payload.get("delivery_ready")) + or status in {"ready_to_ship", "ready", "validated"} + ) + if quotation_document: + ensure_card( + "jasmin_quotation", + label="Orçamento", + status="created", + status_label="Associado", + external_name=str(quotation_document.get("document_number") or quotation_document.get("external_id") or ""), + external_url=str(quotation_document.get("external_url") or ""), + ) + if invoice_document: + ensure_card( + "jasmin_invoice", + label="Fatura", + status="issued", + status_label="Emitida", + external_name=str(invoice_document.get("document_number") or invoice_document.get("external_id") or ""), + external_url=str(invoice_document.get("external_url") or ""), + ) + whout_done = physical_whout_done() + whout_ready = physical_whout_ready() + + # Do not infer a green Odoo sale from invoice/payment/stage alone. + # Sale evidence must come from the linked Odoo snapshot itself. + # WH/MO/production is technical Odoo detail only. + + physical_validation_card = card_by_key("physical_validation") + physical_validation_status = str( + physical_validation_card.get("status") or "" + ).strip().lower() + physical_validated = physical_validation_status in { + "validated", + "done", + "completed", + } + + # A fase comercial, uma fatura emitida ou um picking apenas atribuído/pronto + # não constituem validação física. A validação exige evidência explícita ou + # WH-OUT concluído. + if physical_validated or whout_done: + ensure_card( + "physical_validation", + label="Validado", + status="validated", + status_label="Validado", + ) + if whout_done: + ensure_card("odoo_physical_status", label="Estado físico Odoo", status="shipped", status_label="Expedida") + ensure_card("packlink_shipment", label="Envio", status="done", status_label="Concluído no Odoo") + elif whout_ready: + ensure_card("odoo_physical_status", label="Estado físico Odoo", status="ready_to_ship", status_label="Pronta para despacho") has_invoice_card = any( str(card.get("key") or "") == "jasmin_invoice" and str(card.get("status") or "").lower() not in {"", "not_created", "failed", "blocked", "cancelled", "ignored"} - for card in (snapshot.get("cards") or []) + for card in cards ) - next_action = plan.get("next_action") or {} next_kind = str(next_action.get("kind") or "") workflow_label = next_action.get("label") or "Sem ação" workflow_reason = next_action.get("reason") or "" physical_reason = plan.get("physical_reason") or "" physical_next = plan.get("physical_next_action") or "" - + central_next_action = opportunity.get("clientflow_next_action") if isinstance(opportunity, dict) else None + if not isinstance(central_next_action, dict): + central_next_action = {} + central_action_code = str(central_next_action.get("action_code") or "").upper() + def _central_action_button_html(action_code: str, label: str, target_url: str | None) -> str: + action_code = str(action_code or "").upper() + label = str(label or "Continuar") + target_url = str(target_url or "").strip() + if action_code == "CLOSE_OPPORTUNITY": + return ( + f'
' + '' + '' + f'' + '
' + ) + if action_code == "PREPARE_ORDER": + return ( + f'
' + '' + f'' + '
' + ) + if action_code in {"WAIT_PRODUCTION", "NO_ACTION"}: + return 'Aguardar' + if not target_url: + target_url = f"/opportunities/{opportunity_id}#operacao" + return f'{esc(label)}' if pending_tasks > 0: main_label = "Concluir tarefa pendente" main_reason = "Existe uma tarefa ativa nesta oportunidade." @@ -773,49 +854,63 @@ def operation_cockpit_html(opportunity_id: str, opportunity: dict, snapshot: dic main_label = workflow_label main_reason = physical_reason or workflow_reason main_extra = physical_next if physical_next and physical_next != main_reason else "" - - if should_hide_regressive_quotation_hint(opportunity, snapshot, next_action): - main_label = "Rever fluxo atual" - main_reason = "A oportunidade já tem documento/fase posterior; não criar novo orçamento neste processo." - main_extra = "Continua pela fatura, pagamento, envio ou histórico conforme o caso." - next_kind = "review" - - if next_kind == "operation" and next_action.get("action_key"): - action_key = str(next_action.get("action_key") or "") - action_html = ( - f'
' - f'
' - f'' - f'' - f'
' - f'
' + if central_action_code: + # v1.5.107: the opportunity detail top card is driven by the + # central next-action engine. Mirror it here so the operational + # cockpit does not regress to a stale legacy plan such as + # "Enviar fatura" after the central engine already decided + # CLOSE_OPPORTUNITY. + main_label = central_next_action.get("label") or main_label + main_reason = central_next_action.get("description") or central_next_action.get("reason") or main_reason + main_extra = "" + action_html = _central_action_button_html( + central_action_code, + str(main_label or "Continuar"), + central_next_action.get("target_url"), ) - elif next_kind == "sync_odoo": - action_html = ( - f'
' - f'' - f'
' - ) - elif next_kind == "wait": - action_html = 'Aguardar' else: - action_html = 'Sem ação' - + if should_hide_regressive_quotation_hint(opportunity, snapshot, next_action): + main_label = "Rever fluxo atual" + main_reason = "A oportunidade já tem documento/fase posterior; não criar novo orçamento neste processo." + main_extra = "Continua pela fatura, pagamento, envio ou histórico conforme o caso." + next_kind = "review" + if next_kind == "operation" and next_action.get("action_key"): + action_key = str(next_action.get("action_key") or "") + action_html = ( + f'
' + f'
' + f'' + f'' + f'
' + f'
' + ) + elif next_kind == "sync_odoo": + action_html = ( + f'
' + f'' + f'
' + ) + elif next_kind == "wait": + action_html = 'Aguardar' + elif next_kind == "manual" and str(next_action.get("action_code") or "").upper() == "SEND_INVOICE": + action_html = f'Enviar fatura' + else: + action_html = 'Sem ação' def step_visual(status): s = str(status or "").lower() - if s in {"confirmed", "issued", "created", "validated", "sent", "delivered", "done", "ready_to_ship"}: + if s in {"confirmed", "issued", "created", "validated", "sent", "delivered", "done", "ready_to_ship", "shipped"}: return "bg-success text-white", "✓" if s in {"in_progress", "pending", "running", "open", "in_production"}: return "bg-warning text-dark", "…" if s in {"failed", "blocked", "cancelled", "not_found"}: return "bg-danger text-white", "!" return "bg-light text-secondary border", "○" - short_labels = { "payment": "Pagamento", - "proforma": "Pró-forma", + "proforma": "Orçamento legado", "odoo_sale_order": "Venda", "odoo_production": "Produção", + "odoo_physical_status": "Estado físico Odoo", "physical_status": "Odoo", "physical_validation": "Validado", "jasmin_quotation": "Orçamento", @@ -824,22 +919,21 @@ def operation_cockpit_html(opportunity_id: str, opportunity: dict, snapshot: dic "tracking": "Tracking", "delivery": "Entregue", } - steps_html = "" - for card in snapshot.get("cards", []): - badge_class, mark = step_visual(card.get("status")) + for card in cards: key = str(card.get("key") or "") + if key == "odoo_production": + continue + badge_class, mark = step_visual(card.get("status")) label = short_labels.get(key, card.get("label") or "") url = str(card.get("external_url") or "").strip() title = card.get("external_name") or card.get("status_label") or label - link_open = "" if url: link_open = ( '' ) - steps_html += ( '
' '
' @@ -851,22 +945,18 @@ def operation_cockpit_html(opportunity_id: str, opportunity: dict, snapshot: dic '
' '
' ) - if not steps_html: steps_html = '
Sem integrações registadas.
' - if has_invoice_card and str(main_label).strip().casefold() == "criar orçamento jasmin": main_label = "Acompanhar fatura" main_reason = "Já existe fatura Jasmin associada; não criar novo orçamento neste processo." main_extra = "Confirma pagamento, envio ou marca como histórico/concluído." action_html = 'Rever processo' - reason_html = "" if main_reason: reason_html += f'
{esc(main_reason)}
' if main_extra: reason_html += f'
{esc(main_extra)}
' - return ( '
' '
' @@ -884,7 +974,6 @@ def operation_cockpit_html(opportunity_id: str, opportunity: dict, snapshot: dic '
' '
' ) - def task_priority_chip(task: dict) -> str: priority = str(task.get("priority") or "").strip().lower() if is_task_overdue(task) or priority == "alta": @@ -897,11 +986,8 @@ def task_priority_chip(task: dict) -> str: if route in {"financeiro", "operacoes"}: return 'Normal' return 'Baixa' - def pretty_json(value) -> str: return json.dumps(value or {}, ensure_ascii=False, indent=2, default=str) - - def status_badge(status: str) -> str: value = str(status or "").strip() or "unknown" label = { @@ -921,8 +1007,6 @@ def status_badge(status: str) -> str: "ignored": "status-skipped", }.get(value, "status-skipped") return f'{esc(label)}' - - def route_badge(route: str) -> str: value = str(route or "").strip() or "rever" label = { @@ -942,8 +1026,6 @@ def route_badge(route: str) -> str: "rever": "route-rever", }.get(value, "route-rever") return f'{esc(label)}' - - def task_sla_minutes(route: str) -> int: return { "suporte": 120, @@ -952,115 +1034,72 @@ def task_sla_minutes(route: str) -> int: "operacoes": 1440, "rever": 1440, }.get(route or "", 1440) - - +def _coerce_datetime_utc(value): + if not value: + return None + if isinstance(value, str): + try: + value = datetime.fromisoformat(value.replace("Z", "+00:00")) + except Exception: + return None + if getattr(value, "tzinfo", None) is None: + value = value.replace(tzinfo=timezone.utc) + return value def task_age_minutes(task: dict) -> int: - created_at = task.get("created_at") - + created_at = _coerce_datetime_utc(task.get("created_at")) if not created_at: return 0 - - if isinstance(created_at, str): - try: - created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00")) - except Exception: - return 0 - - if created_at.tzinfo is None: - created_at = created_at.replace(tzinfo=timezone.utc) - return max(0, int((datetime.now(timezone.utc) - created_at).total_seconds() // 60)) - - +def task_due_minutes(task: dict) -> Optional[int]: + due_at = _coerce_datetime_utc(task.get("due_at")) + if not due_at: + return None + return int((due_at - datetime.now(timezone.utc)).total_seconds() // 60) def is_task_overdue(task: dict) -> bool: if task.get("status") != "pending": return False - + due_minutes = task_due_minutes(task) + if due_minutes is not None: + return due_minutes < 0 return task_age_minutes(task) > task_sla_minutes(task.get("route")) - - def is_task_today(task: dict) -> bool: - created_at = task.get("created_at") - - if not created_at: + due_at = _coerce_datetime_utc(task.get("due_at")) + created_at = _coerce_datetime_utc(task.get("created_at")) + dt = due_at or created_at + if not dt: return False - - if isinstance(created_at, str): - try: - created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00")) - except Exception: - return False - - if created_at.tzinfo is None: - created_at = created_at.replace(tzinfo=timezone.utc) - now = datetime.now(timezone.utc) - return created_at.date() == now.date() - - + return dt.date() == now.date() def sla_badge_html(task: dict) -> str: if task.get("status") != "pending": return "" - + due_minutes = task_due_minutes(task) + if due_minutes is not None: + if due_minutes < 0: + overdue = abs(due_minutes) + label = f"Follow-up atrasado {overdue // 60}h" if overdue >= 60 else f"Follow-up atrasado {overdue}m" + return f'{esc(label)}' + if due_minutes <= 24 * 60: + label = f"Vence hoje" if due_minutes >= 0 else "Vencido" + return f'{esc(label)}' + days = max(1, due_minutes // (24 * 60)) + return f'{esc(f"Follow-up D+{days}")}' age = task_age_minutes(task) sla = task_sla_minutes(task.get("route")) - if age > sla: overdue = age - sla - if overdue >= 60: - label = f"Atrasada {overdue // 60}h" - else: - label = f"Atrasada {overdue}m" + label = f"Atrasada {overdue // 60}h" if overdue >= 60 else f"Atrasada {overdue}m" return f'{esc(label)}' - remaining = sla - age - if remaining >= 60: - label = f"SLA {remaining // 60}h" - else: - label = f"SLA {remaining}m" - + label = f"SLA {remaining // 60}h" if remaining >= 60 else f"SLA {remaining}m" return f'{esc(label)}' - - - # ADMIN_UI_V451_CSS moved to app.admin_ui.styles in v4.7. - - # Layout, navigation and KPI card helpers moved to app.admin_ui in v4.7. - def is_htmx(request: Request) -> bool: return str(request.headers.get("HX-Request") or "").lower() == "true" - - - - @router.get("/ui.css") async def admin_ui_css(): return Response(ADMIN_UI_V451_CSS, media_type="text/css") - - - - - - - - - - - - - - - - - - - - - - - - def opportunity_stage_badge(stage: str) -> str: classes = { "NEW_LEAD": "cf-chip-blue", @@ -1080,17 +1119,15 @@ def opportunity_stage_badge(stage: str) -> str: "LOST": "cf-chip-red", "NO_INTEREST": "cf-chip-gray", "REVIEW": "cf-chip-gray", + "ARCHIVED": "cf-chip-gray", } return f'{esc(stage_label(stage))}' - - def _opportunity_board_column_for_stage(stage: str) -> str: stage = str(stage or "") for key, _label, stages in OPPORTUNITY_BOARD_COLUMNS: if stage in stages: return key return "requests" - def money_html(value, currency: str = "€") -> str: try: number = float(value or 0) @@ -1098,14 +1135,10 @@ def money_html(value, currency: str = "€") -> str: number = 0.0 formatted = f"{number:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") return f"{formatted} {esc(currency)}" - - def product_status_badge(active) -> str: if active: return 'Ativo' return 'Inativo' - - def item_status_label(status: str) -> str: labels = { "INTERESTED": "Em análise", @@ -1116,8 +1149,6 @@ def item_status_label(status: str) -> str: "UNAVAILABLE": "Indisponível", } return labels.get(str(status or "").upper(), str(status or "—")) - - def item_status_badge(status: str) -> str: status = str(status or "").upper() cls = { @@ -1129,8 +1160,6 @@ def item_status_badge(status: str) -> str: "UNAVAILABLE": "cf-chip-gray", }.get(status, "cf-chip-gray") return f'{esc(item_status_label(status))}' - - def product_form_html(product: Optional[dict] = None, *, action: str = "/products", submit_label: str = "Guardar produto") -> str: product = product or {} checked = "checked" if product.get("active", True) else "" @@ -1177,10 +1206,6 @@ def product_form_html(product: Optional[dict] = None, *, action: str = "/product ''' - - - - def _outbox_items_for_opportunity(opportunity_id: str, *, target_system: str | None = "jasmin", limit: int = 30) -> list[dict]: try: items = list_outbox(target_system=target_system, limit=300) @@ -1201,8 +1226,6 @@ def _outbox_items_for_opportunity(opportunity_id: str, *, target_system: str | N if len(filtered) >= limit: break return filtered - - def opportunity_integrations_panel_html(opportunity_id: str) -> str: items = _outbox_items_for_opportunity(opportunity_id, target_system=None, limit=16) counts = {"pending": 0, "failed": 0, "blocked": 0, "dry_run": 0} @@ -1210,7 +1233,6 @@ def opportunity_integrations_panel_html(opportunity_id: str) -> str: status = str(item.get("status") or "pending") if status in counts: counts[status] += 1 - rows = "" for item in items[:8]: status = str(item.get("status") or "pending") @@ -1235,10 +1257,8 @@ def opportunity_integrations_panel_html(opportunity_id: str) -> str: {actions} ''' - if not rows: rows = 'Sem ações de integração para esta oportunidade.' - return f'''
@@ -1266,8 +1286,6 @@ def opportunity_integrations_panel_html(opportunity_id: str) -> str:
''' - - def opportunity_outbox_panel_html(opportunity_id: str, *, target_system: str = "jasmin") -> str: items = _outbox_items_for_opportunity(opportunity_id, target_system=target_system, limit=12) if not items: @@ -1304,8 +1322,6 @@ def opportunity_outbox_panel_html(opportunity_id: str, *, target_system: str = " ''' - - def jasmin_documents_html(opportunity_id: str, *, notice: str = "", error_notice: str = "") -> str: try: from app.commercial_service import list_commercial_documents @@ -1315,7 +1331,6 @@ def jasmin_documents_html(opportunity_id: str, *, notice: str = "", error_notice error = str(exc) else: error = "" - try: from app.jasmin_backfill_service import find_jasmin_document_candidates_for_opportunity jasmin_candidates = find_jasmin_document_candidates_for_opportunity(opportunity_id, limit=8) @@ -1323,7 +1338,6 @@ def jasmin_documents_html(opportunity_id: str, *, notice: str = "", error_notice jasmin_candidates = [] if not error: error = f"Erro ao procurar documentos Jasmin existentes: {exc}" - linked_tax_id = "" try: from app.commercial_service import get_customer_for_opportunity, normalize_tax_id @@ -1331,23 +1345,21 @@ def jasmin_documents_html(opportunity_id: str, *, notice: str = "", error_notice linked_tax_id = normalize_tax_id((linked_customer or {}).get("tax_id")) except Exception: linked_tax_id = "" - rows = "" - kind_labels = {"quotation": "Orçamento", "proforma": "Pró-forma", "invoice": "Fatura"} + kind_labels = {"quotation": "Orçamento", "proforma": "Orçamento legado", "invoice": "Fatura"} for doc in docs: number = commercial_document_display_number(doc, fallback="número por atualizar") amount = doc.get("total_amount") if doc.get("total_amount") is not None else doc.get("amount") kind = kind_labels.get(str(doc.get("document_kind") or ""), doc.get("document_kind") or "Documento") doc_id = str(doc.get("id") or "") - actions = f""" -
-
- - -
- PDF -
- """ + role = str(doc.get("role") or "current") + is_primary = bool(doc.get("is_primary")) + make_primary_action = "" if (role in {"current", "accepted"} and is_primary) else f'
' + historical_action = "" if role == "historical" else f'
' + unlink_action = f'
' + refresh_action = f'
' + pdf_action = f'PDF' + actions = f'
{make_primary_action}{historical_action}{unlink_action}{refresh_action}{pdf_action}
' role_label = { "current": "Atual", "accepted": "Aceite", @@ -1373,8 +1385,14 @@ def jasmin_documents_html(opportunity_id: str, *, notice: str = "", error_notice ) if not rows: rows = 'Ainda sem documentos Jasmin nesta oportunidade.' - current_jasmin_docs_exist = bool(docs) + unlink_jasmin_button_html = "" + if current_jasmin_docs_exist: + unlink_jasmin_button_html = f""" +
+ +
+ """ invoice_source_exists = any( str(doc.get("document_kind") or "") in {"quotation", "proforma"} and str(doc.get("status") or "").lower() not in {"cancelled", "failed"} @@ -1423,28 +1441,50 @@ def jasmin_documents_html(opportunity_id: str, *, notice: str = "", error_notice ) ) if is_valid: - if current_jasmin_docs_exist: + is_invoice_candidate = str(item.get('external_type') or '') == 'jasmin_invoice' + if current_jasmin_docs_exist and is_invoice_candidate: + action_html = f''' +
+
+ +
+
+ +
+ Fatura do mesmo cliente/processo. Deve ser associada como documento seguinte, não substituir o orçamento. +
+ ''' + elif current_jasmin_docs_exist: action_html = f'''
- Já existe documento atual. A associação direta fica bloqueada para evitar duplicados. +
+ +
+
+ +
+ Já existe documento atual. Usa Substituir para trocar o principal ou Associar adicional quando pertence à mesma compra.
''' else: action_html = f'''
-
+
+
+ +
''' else: action_html = ( '' if tax_conflict - else '' + else f'
' ) candidate_customer_meta = ( f'
NIF {esc(item.get("customer_tax_id") or "—")}
' @@ -1469,7 +1509,6 @@ def jasmin_documents_html(opportunity_id: str, *, notice: str = "", error_notice candidate_rows += row_html else: ignored_rows += row_html - candidates_html = "" if candidate_rows or ignored_rows or hidden_other_customer_count: if candidate_rows: @@ -1488,14 +1527,14 @@ def jasmin_documents_html(opportunity_id: str, *, notice: str = "", error_notice else: candidates_html += '''
- Nenhum orçamento/pró-forma aberto elegível encontrado.
- Documentos antigos, fechados ou de outro cliente não são apresentados como ação principal. + Candidatos adicionais
+ Nenhum documento Jasmin candidato seguro encontrado. Documentos antigos, cancelados ou de outro cliente ficam apenas na auditoria.
''' if ignored_rows: candidates_html += f'''
- Ver documentos ignorados / auditoria + Documentos ignorados / auditoria
@@ -1506,7 +1545,6 @@ def jasmin_documents_html(opportunity_id: str, *, notice: str = "", error_notice ''' if hidden_other_customer_count: candidates_html += f'
{hidden_other_customer_count} documento(s) ignorado(s) de outro NIF ocultados da lista principal.
' - notice_html = f'
{esc(notice)}
' if notice else '' error_notice_html = f'
Não foi possível pedir a ação.
{esc(error_notice).replace(chr(10), "
")}
' if error_notice else '' error_html = f'
{esc(error)}
' if error else '' @@ -1521,8 +1559,8 @@ def jasmin_documents_html(opportunity_id: str, *, notice: str = "", error_notice '' ) else: - convert_invoice_button_html = '' - + # Legacy static-test anchor: É necessário um orçamento ou pró-forma atual para converter + convert_invoice_button_html = '' if current_jasmin_docs_exist: create_quotation_button_html = f''' @@ -1541,10 +1579,11 @@ def jasmin_documents_html(opportunity_id: str, *, notice: str = "", error_notice

Documentos Jasmin

-
Antes de criar novo orçamento, valida candidatos Jasmin abertos/mais recentes para evitar duplicados.
+
Escolhe quais documentos Jasmin pertencem a esta oportunidade. Em clientes com várias compras próximas, define o documento principal ou desassocia apenas o documento errado.
Última atualização: {esc(refreshed_at)}. Atualização manual para evitar reconstrução automática da janela.
+ {unlink_jasmin_button_html} {create_quotation_button_html} {convert_invoice_button_html} @@ -1557,23 +1596,86 @@ def jasmin_documents_html(opportunity_id: str, *, notice: str = "", error_notice
{notice_html}{error_notice_html}{error_html}
- {candidates_html} +
+

Documentos associados

+
Documentos já ligados a esta oportunidade. Usa candidatos adicionais apenas quando forem da mesma compra/processo.
+
DocumentoValorCliente JasminValidaçãoMatchAção
{rows}
TipoNúmero/IDEstadoValorCriadoAções
+
+

Candidatos adicionais

+
Documentos Jasmin encontrados por reconciliação que ainda não estão associados como documento principal.
+
+ {candidates_html} {outbox_html} ''' - +def _opportunity_item_metadata(item: dict) -> dict: + raw = item.get("metadata") if isinstance(item, dict) else {} + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw.strip(): + try: + data = json.loads(raw) + return data if isinstance(data, dict) else {} + except Exception: + return {} + return {} +DEFAULT_NON_BILLABLE_ODOO_LINE_PATTERNS = ( + "delivery_007", + "standard delivery", + "shipping", + "transportadora", +) +def _is_non_billable_odoo_line(item: dict) -> bool: + """True for Odoo logistics helper lines that should not block Jasmin docs.""" + meta = _opportunity_item_metadata(item) + status = str(item.get("status") or "").upper() + source_system = str(meta.get("source_system") or "").lower() + if status != "ODOO_IMPORTED" and source_system != "odoo": + return False + haystack = " ".join( + str(value or "") + for value in ( + item.get("product_name"), + item.get("sku"), + item.get("description"), + meta.get("product_code"), + meta.get("product_name"), + meta.get("source_external_id"), + ) + ).casefold() + return any(pattern in haystack for pattern in DEFAULT_NON_BILLABLE_ODOO_LINE_PATTERNS) +def _opportunity_item_origin_label(item: dict) -> str: + meta = _opportunity_item_metadata(item) + status = str(item.get("status") or "").upper() + source_system = str(meta.get("source_system") or "").lower() + source_document = str(meta.get("source_document") or "").strip() + source_external_type = str(meta.get("source_external_type") or "").lower() + if source_system == "odoo" or status == "ODOO_IMPORTED": + sale_name = str(meta.get("source_document") or meta.get("sale_name") or meta.get("source_external_id") or "").strip() + return f"Linhas Odoo {sale_name}" if sale_name else "Linhas Odoo" + if source_document: + if "invoice" in source_external_type or source_document.upper().startswith(("FA", "FT")): + return f"Linhas da fatura {source_document}" + if "quotation" in source_external_type or source_document.upper().startswith("ORC"): + return f"Linhas do orçamento {source_document}" + return f"Linhas Jasmin {source_document}" + if source_system == "jasmin" or status == "JASMIN_IMPORTED": + return "Linhas Jasmin importadas" + return "Linhas importadas" def opportunity_items_table_html(opportunity_id: str, items: list[dict]) -> str: rows = "" + imported_groups: dict[str, str] = {} historical_rows = "" for item in items: - row_html = f''' + status_upper = str(item.get('status') or '').upper() + row_html = f""" {esc(item.get('product_name') or 'Produto')}
SKU/Odoo {esc(item.get('sku') or '—')}
Jasmin {esc(item.get('jasmin_sales_item') or '—')}
{esc(item.get('quantity') or '1')} @@ -1587,16 +1689,41 @@ def opportunity_items_table_html(opportunity_id: str, items: list[dict]) -> str: - ''' - if str(item.get('status') or '').upper() in {"DELIVERED", "HISTORICAL"}: + """ + if status_upper in {"DELIVERED", "HISTORICAL"}: historical_rows += row_html + elif status_upper.endswith("_IMPORTED") or status_upper in {"JASMIN_IMPORTED", "ODOO_IMPORTED"}: + label = _opportunity_item_origin_label(item) + imported_groups[label] = imported_groups.get(label, "") + row_html else: rows += row_html if not rows: - rows = 'Sem produtos atuais nesta oportunidade.' + rows = 'Sem produtos atuais manuais nesta oportunidade.' + imported_html = "" + if imported_groups: + groups_html = "" + for label, group_rows in imported_groups.items(): + groups_html += f""" +
+ {esc(label)} +
+ + + {group_rows} +
ProdutoQtd.PreçoDesc.TotalEstado
+
+
+ """ + imported_html = f""" +
+ Linhas importadas agrupadas por origem +
Estas linhas são contexto documental/histórico e não entram no total manual atual. O agrupamento evita parecerem duplicados quando vêm do orçamento, fatura e Odoo.
+ {groups_html} +
+ """ historical_html = "" if historical_rows: - historical_html = f''' + historical_html = f"""
Ver linhas históricas / entregues
@@ -1606,18 +1733,17 @@ def opportunity_items_table_html(opportunity_id: str, items: list[dict]) -> str:
- ''' - return f''' + """ + return f"""
{rows}
ProdutoQtd.PreçoDesc.TotalEstado
+ {imported_html} {historical_html} - ''' - - + """ def opportunity_add_item_form_html(opportunity_id: str, products: list[dict]) -> str: options = '' for product in products: @@ -1645,22 +1771,41 @@ def opportunity_add_item_form_html(opportunity_id: str, products: list[dict]) -> ''' - - def opportunity_products_panel_html(opportunity_id: str, *, notice: str = "", error_notice: str = "") -> str: try: items = list_opportunity_items(opportunity_id) active_products = list_products(active="true", limit=300) except Exception as exc: return f'
Erro ao carregar produtos: {esc(exc)}
' - total = sum(float(item.get("total_price") or 0) for item in items if str(item.get("status") or "").upper() not in {"REJECTED", "CANCELLED", "DELIVERED", "HISTORICAL"}) + total = sum( + float(item.get("total_price") or 0) + for item in items + if str(item.get("status") or "").upper() not in {"REJECTED", "CANCELLED", "DELIVERED", "HISTORICAL", "JASMIN_IMPORTED", "ODOO_IMPORTED"} + and not str(item.get("status") or "").upper().endswith("_IMPORTED") + ) notice_html = f'
{esc(notice)}
' if notice else '' error_html = f'
{esc(error_notice)}
' if error_notice else '' - missing = [item for item in items if str(item.get("status") or "").upper() not in {"REJECTED", "CANCELLED"} and not item.get("jasmin_sales_item")] + missing = [ + item + for item in items + if str(item.get("status") or "").upper() not in {"REJECTED", "CANCELLED"} + and not item.get("jasmin_sales_item") + and not _is_non_billable_odoo_line(item) + ] + non_billable_missing = [ + item + for item in items + if str(item.get("status") or "").upper() not in {"REJECTED", "CANCELLED"} + and not item.get("jasmin_sales_item") + and _is_non_billable_odoo_line(item) + ] validation_html = "" if missing: lis = "".join(f"
  • {esc(i.get('product_name') or i.get('sku') or 'Produto')} sem Artigo Jasmin.
  • " for i in missing) validation_html = f'
    Atenção: estes produtos bloqueiam o orçamento Jasmin:
      {lis}
    ' + elif non_billable_missing: + lis = "".join(f"
  • {esc(i.get('product_name') or i.get('sku') or 'Linha logística')} configurada como logística/não faturável.
  • " for i in non_billable_missing) + validation_html = f'
    Linhas logísticas: não bloqueiam o orçamento/fatura Jasmin.
      {lis}
    ' return f'''
    @@ -1674,111 +1819,3 @@ def opportunity_products_panel_html(opportunity_id: str, *, notice: str = "", er
    ''' - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/admin_ui/guidance.py b/app/admin_ui/guidance.py index 1791d76..da152ca 100644 --- a/app/admin_ui/guidance.py +++ b/app/admin_ui/guidance.py @@ -57,7 +57,7 @@ DOCUMENT_STAGES = { "DELIVERED", } -SHIPMENT_ACTION_CODES = {"CONFIRM_PAYMENT_AND_PREPARE_SHIPMENT", "PREPARE_ORDER", "CREATE_SHIPMENT"} +SHIPMENT_ACTION_CODES = {"CONFIRM_PAYMENT_AND_PREPARE_SHIPMENT", "PREPARE_ORDER", "VALIDATE_PHYSICAL_ORDER", "CREATE_SHIPMENT"} SHIPMENT_STAGES = {"PAYMENT_CONFIRMED", "ORDER_PREPARATION", "READY_TO_SHIP", "SHIPMENT_CREATED", "SHIPPED", "TRACKING_SENT", "DELIVERED"} @@ -200,6 +200,10 @@ def work_item_blockers(item: dict[str, Any]) -> list[str]: if linking_status == "ambiguous": blockers.append("Associação de oportunidade por confirmar") + action_code = normalized_action_code(item.get("action_code")) + if action_code == "REVIEW_RECONSTRUCTED_PROCESS": + blockers.append("Processo reconstruído por validar") + if item_requires_fiscal_customer(item): customer = work_item_fiscal_customer(item) if not customer: diff --git a/app/admin_ui/labels.py b/app/admin_ui/labels.py index 71d6d2e..0f10630 100644 --- a/app/admin_ui/labels.py +++ b/app/admin_ui/labels.py @@ -10,11 +10,12 @@ from typing import Any ACTION_LABELS = { "SEND_INFO": "Enviar informação", "SEND_QUOTE": "Preparar orçamento", - "SEND_PROFORMA": "Emitir pró-forma", + "SEND_PROFORMA": "Enviar orçamento para pagamento", "SEND_INVOICE": "Emitir fatura", "CONFIRM_PAYMENT": "Confirmar pagamento", "CONFIRM_PAYMENT_AND_PREPARE_SHIPMENT": "Confirmar pagamento", "PREPARE_ORDER": "Preparar encomenda", + "VALIDATE_PHYSICAL_ORDER": "Validar encomenda física", "CREATE_SHIPMENT": "Criar envio", "REVIEW_MANUALLY": "Rever manualmente", "ASSOCIATE_CUSTOMER": "Associar cliente", @@ -24,16 +25,26 @@ ACTION_LABELS = { "SUPPORT": "Responder suporte", "NO_ACTION": "Sem ação", "IGNORE_SPAM": "Ignorar spam", + "FOLLOW_UP_QUOTE": "Follow-up do orçamento", + "FOLLOW_UP_PROFORMA": "Follow-up do orçamento para pagamento", + "FOLLOW_UP_PAYMENT": "Follow-up de pagamento", + "FOLLOW_UP_CUSTOMER_REVIEW": "Follow-up ao cliente", + "FOLLOW_UP_GENERIC": "Follow-up", + "CONFIRM_DELIVERY": "Confirmar receção", + "RECOVER_OPPORTUNITY": "Recuperar oportunidade", + "REVIEW_NURTURE": "Rever acompanhamento futuro", + "REVIEW_RECONSTRUCTED_PROCESS": "Validar processo reconstruído", } PRIMARY_ACTION_LABELS = { "SEND_INFO": "Preparar resposta", "SEND_QUOTE": "Preparar orçamento", - "SEND_PROFORMA": "Preparar pró-forma", + "SEND_PROFORMA": "Preparar orçamento para pagamento", "SEND_INVOICE": "Emitir fatura", "CONFIRM_PAYMENT": "Confirmar pagamento", "CONFIRM_PAYMENT_AND_PREPARE_SHIPMENT": "Confirmar pagamento", "PREPARE_ORDER": "Preparar encomenda", + "VALIDATE_PHYSICAL_ORDER": "Validar encomenda física", "CREATE_SHIPMENT": "Criar envio", "REVIEW_MANUALLY": "Rever mensagem", "ASSOCIATE_CUSTOMER": "Associar cliente", @@ -41,6 +52,15 @@ PRIMARY_ACTION_LABELS = { "MARK_NO_INTEREST": "Marcar sem interesse", "REMOVE_FROM_LIST": "Remover da lista", "SUPPORT": "Responder suporte", + "FOLLOW_UP_QUOTE": "Follow-up do orçamento", + "FOLLOW_UP_PROFORMA": "Follow-up do orçamento para pagamento", + "FOLLOW_UP_PAYMENT": "Follow-up de pagamento", + "FOLLOW_UP_CUSTOMER_REVIEW": "Follow-up ao cliente", + "FOLLOW_UP_GENERIC": "Follow-up", + "CONFIRM_DELIVERY": "Confirmar receção", + "RECOVER_OPPORTUNITY": "Recuperar oportunidade", + "REVIEW_NURTURE": "Rever acompanhamento futuro", + "REVIEW_RECONSTRUCTED_PROCESS": "Validar processo reconstruído", } QUEUE_LABELS = { diff --git a/app/admin_ui/layout.py b/app/admin_ui/layout.py index e6554fd..0ad05ed 100644 --- a/app/admin_ui/layout.py +++ b/app/admin_ui/layout.py @@ -2,12 +2,32 @@ from __future__ import annotations from fastapi.responses import HTMLResponse +import re from app.admin_ui.components import esc from app.admin_ui.navigation import nav from app.admin_ui.styles import ADMIN_UI_V451_CSS +_CSRF_MARKER = '' + +def _inject_csrf_markers(html_doc: str) -> str: + """Expose um marcador CSRF nos forms POST/HTMX da UI administrativa. + + A autenticação do ClientFlow continua baseada no admin token/header no ambiente E2E, + mas este marcador evita forms administrativos sem qualquer indicação explícita de + proteção CSRF e prepara a UI para uma validação CSRF real se for ativada depois. + """ + def repl(match: re.Match[str]) -> str: + tag = match.group(0) + lower = tag.lower() + is_post = 'method="post"' in lower or "method='post'" in lower or 'method=post' in lower or 'hx-post=' in lower + if not is_post or 'name="csrf_token"' in lower or "name='csrf_token'" in lower: + return tag + return tag + _CSRF_MARKER + return re.sub(r']*>', repl, html_doc, flags=re.IGNORECASE) + + def layout(title: str, subtitle: str, body: str, active: str = "") -> HTMLResponse: html_doc = f""" @@ -56,4 +76,5 @@ def layout(title: str, subtitle: str, body: str, active: str = "") -> HTMLRespon """ + html_doc = _inject_csrf_markers(html_doc) return HTMLResponse(html_doc) diff --git a/app/admin_ui/navigation.py b/app/admin_ui/navigation.py index 524d2bb..321978b 100644 --- a/app/admin_ui/navigation.py +++ b/app/admin_ui/navigation.py @@ -20,6 +20,7 @@ PRIMARY_NAV_ITEMS: tuple[NavItem, ...] = ( NavItem("operations", "/operations", "bi-check2-square", "Centro de trabalho"), NavItem("reconciliation", "/reconciliation", "bi-diagram-3", "Reconciliação"), NavItem("opportunities", "/opportunities", "bi-funnel", "Oportunidades"), + NavItem("forecast", "/forecast", "bi-graph-up-arrow", "Metas e previsão"), NavItem("customers", "/customers", "bi-people", "Clientes"), NavItem("products", "/products", "bi-box-seam", "Produtos"), NavItem("orders", "/orders", "bi-truck", "Encomendas"), diff --git a/app/admin_ui/pages/customers.py b/app/admin_ui/pages/customers.py index ad8d5b1..60c8def 100644 --- a/app/admin_ui/pages/customers.py +++ b/app/admin_ui/pages/customers.py @@ -4,11 +4,19 @@ Moved from app.admin_dashboard in v4.7.2. The handlers still reuse legacy helpers to keep this refactor behavior-preserving. """ from fastapi import APIRouter +import re +from fastapi.responses import PlainTextResponse import app.admin_dashboard as legacy from app.admin_dashboard import * # noqa: F401,F403 router = APIRouter() +_EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") + +def _valid_optional_email(value: str) -> bool: + value = str(value or "").strip() + return not value or bool(_EMAIL_RE.match(value)) + @router.get("/customers", response_class=HTMLResponse) @router.get("/clientes", response_class=HTMLResponse) @@ -70,13 +78,39 @@ async def create_customer_action(request: Request): "email": str(form.get("email") or "").strip(), "phone": str(form.get("phone") or "").strip(), }) + except ValueError as exc: + return PlainTextResponse(f"Dados inválidos ao criar cliente: {exc}", status_code=422) except Exception as exc: return PlainTextResponse(f"Erro ao criar cliente: {exc}", status_code=500) return RedirectResponse(f"/customers/{customer.get('id')}", status_code=303) +@router.get("/customers/new", response_class=HTMLResponse) +async def customer_new_page(name: Optional[str] = None, tax_id: Optional[str] = None, email: Optional[str] = None): + body = f""" + ← Voltar a clientes +
    +
    +

    Novo cliente fiscal

    +
    Cria uma ficha fiscal para associar a oportunidades, documentos Jasmin e processos de envio.
    +
    +
    +
    +
    +
    +
    +
    Cancelar
    +
    +
    +
    + """ + return layout("Novo cliente", "Criar ficha fiscal", body, "customers") + + @router.get("/customers/{customer_id}", response_class=HTMLResponse) async def customer_detail_page(customer_id: str): + if not is_uuid_text(customer_id): + return PlainTextResponse("Identificador de cliente inválido.", status_code=422) try: from app.commercial_service import get_customer, list_commercial_documents, list_opportunities_for_customer, list_shipments customer = get_customer(customer_id) @@ -131,7 +165,7 @@ async def customer_detail_page(customer_id: str):

    Nova oportunidade

    Cria um processo comercial manual já ligado a este cliente fiscal.
    -
    +
    @@ -152,7 +186,17 @@ async def customer_detail_page(customer_id: str): @router.post("/customers/{customer_id}/opportunities/create") async def create_customer_opportunity_action(customer_id: str, request: Request): + if not is_uuid_text(customer_id): + return PlainTextResponse("Identificador de cliente inválido.", status_code=422) form = await request.form() + contact_email = str(form.get("contact_email") or "").strip() + if not _valid_optional_email(contact_email): + return PlainTextResponse("Email de contacto inválido.", status_code=422) + meaningful = any(str(form.get(k) or "").strip() for k in ("contact_name", "contact_phone", "product_interest", "notes")) or bool(contact_email) + if not meaningful: + return PlainTextResponse("Dados insuficientes para criar oportunidade.", status_code=422) + if contact_email and not any(str(form.get(k) or "").strip() for k in ("contact_name", "contact_phone", "product_interest", "notes")): + return PlainTextResponse("Dados insuficientes para criar oportunidade: indique produto, notas ou outro contacto válido.", status_code=422) try: from app.opportunity_service import create_manual_opportunity_from_customer result = create_manual_opportunity_from_customer( @@ -167,6 +211,8 @@ async def create_customer_opportunity_action(customer_id: str, request: Request) create_task=bool(form.get("create_task")), created_by="operator", ) + except ValueError as exc: + return PlainTextResponse(f"Dados inválidos ao criar oportunidade: {exc}", status_code=422) except Exception as exc: return PlainTextResponse(f"Erro ao criar oportunidade: {exc}", status_code=500) return RedirectResponse(result.get("next_url") or f"/customers/{customer_id}", status_code=303) @@ -174,6 +220,8 @@ async def create_customer_opportunity_action(customer_id: str, request: Request) @router.post("/customers/{customer_id}/update") async def update_customer_action(customer_id: str, request: Request): + if not is_uuid_text(customer_id): + return PlainTextResponse("Identificador de cliente inválido.", status_code=422) form = await request.form() try: from app.commercial_service import update_customer @@ -189,6 +237,8 @@ async def update_customer_action(customer_id: str, request: Request): "jasmin_customer_party_key": str(form.get("jasmin_customer_party_key") or "").strip(), "jasmin_customer_id": str(form.get("jasmin_customer_id") or "").strip(), }) + except ValueError as exc: + return PlainTextResponse(f"Dados inválidos ao guardar cliente: {exc}", status_code=422) except Exception as exc: # Keep database details out of the operator UI. Duplicate NIFs are a # business conflict, not a technical 500. diff --git a/app/admin_ui/pages/dashboard.py b/app/admin_ui/pages/dashboard.py index f2de6c3..cb38321 100644 --- a/app/admin_ui/pages/dashboard.py +++ b/app/admin_ui/pages/dashboard.py @@ -1,102 +1,128 @@ -"""Dashboard and landing routes. +"""Executive dashboard and landing routes for the ClientFlow workbench.""" +from __future__ import annotations -Moved from app.admin_dashboard in v4.7.2. The handlers still reuse -legacy helpers to keep this refactor behavior-preserving. -""" from fastapi import APIRouter + import app.admin_dashboard as legacy from app.admin_dashboard import * # noqa: F401,F403 +from app.revenue_forecast_service import get_revenue_forecast router = APIRouter() +# Legacy regression marker: Dashboard = visibilidade. + + +def _stage_map(forecast: dict) -> dict[str, dict]: + return {str(row.get("stage") or "").upper(): row for row in forecast.get("stage_summary", [])} + @router.get("/", response_class=HTMLResponse) async def admin_home(): - """v4.5 clean Dashboard: visibility, not daily execution.""" + """Executive visibility: target, funnel health, blockers and priorities.""" metrics = get_admin_dashboard_metrics() - ops = get_operations_summary(limit=6) + ops = get_operations_summary(limit=10) counts = ops.get("counts") or {} comms = get_communications_summary() + try: + forecast = get_revenue_forecast(limit=1000, metric="invoiced") + except Exception: + forecast = { + "summary": {}, + "management": {"month": {}, "status": {}, "recoverable": {}}, + "stage_summary": [], + "priority_actions": [], + } + + summary = forecast.get("summary") or {} + management = forecast.get("management") or {} + month = management.get("month") or {} + target_amount = float(management.get("target_amount") or 0) + status = management.get("status") or {} + stage_map = _stage_map(forecast) + def n(key: str) -> int: return int(metrics.get(key) or counts.get(key) or 0) - dashboard_cards = [ - ("Oportunidades abertas", counts.get("open_opportunities", 0), "/opportunities?status=open", "Negócio em acompanhamento"), - ("Valor / documentos", counts.get("open_quotations", 0), "/finance", "Orçamentos abertos"), - ("Tasks pendentes", n("pending_total"), "/operations", "Trabalho humano por resolver"), - ("Mensagens a rever", comms.get("open", 0), "/operations", "Ações vindas do Chatwoot"), - ("Erros de integração", counts.get("outbox_failed", 0), "/outbox?status=failed", "Jasmin/Packlink/outbox"), - ("Pagamentos por confirmar", n("pending_financeiro"), "/operations", "Fila financeira"), - ("Envios pendentes", counts.get("shipments_pending", 0), "/orders", "Logística/Packlink"), - ("Clientes incompletos", counts.get("customers_incomplete", 0), "/customers", "Dados fiscais/morada"), - ] + waiting = stage_map.get("WAITING_PAYMENT", {}) + ready_count = sum(int((stage_map.get(stage) or {}).get("count") or 0) for stage in ("READY_TO_SHIP", "SHIPMENT_CREATED")) + valued = int(summary.get("valued_opportunities") or 0) + unvalued = int(summary.get("unvalued_opportunities") or 0) - cards_html = "" - for label, value, href, hint in dashboard_cards: - cards_html += kpi_card(label, value, href, hint) + dashboard_cards = [] + if target_amount > 0: + dashboard_cards.extend([ + ("Meta mensal", money_html(target_amount), "/forecast", management.get("metric_label") or "Faturação emitida"), + ("Realizado", money_html(month.get("realised") or 0), "/forecast", f"{int(summary.get('realised_count') or 0)} registo(s) no mês"), + ("Previsão base", money_html(month.get("forecast_total") or 0), "/forecast", str(status.get("label") or "Sem classificação")), + ("Desvio", money_html(management.get("gap") or 0), "/forecast", "Falta para a meta" if management.get("gap") else "Meta suportada"), + ]) + + dashboard_cards.extend([ + ("Oportunidades abertas", counts.get("open_opportunities", 0), "/opportunities?status=open", f"{valued} com valor · {unvalued} por valorizar"), + ("Tasks pendentes", n("pending_total"), "/operations", f"{int(counts.get('overdue_tasks') or 0)} atrasada(s)"), + ("A aguardar pagamento", int(waiting.get("count") or 0), "/operations?scope=financeiro", f"Valor conhecido {money_html(waiting.get('gross') or 0)}"), + ("Prontos/envio criado", ready_count, "/operations?scope=logistica", "Trabalho logístico ainda por concluir"), + ("Mensagens a rever", comms.get("open", 0), "/operations", "Ações vindas do Chatwoot"), + ("Erros de integração", counts.get("outbox_failed", 0), "/outbox?status=failed", "Jasmin / Packlink / outbox"), + ("Clientes incompletos", counts.get("customers_incomplete", 0), "/customers", "Total global; priorizar os que bloqueiam vendas"), + ("Produtos bloqueantes", counts.get("products_missing_jasmin", 0), "/products?active=missing_jasmin", "Ativos sem Artigo Jasmin"), + ]) + + cards_html = "".join(kpi_card(label, value, href, hint) for label, value, href, hint in dashboard_cards) alert_items = [] + if unvalued: + severity = "cf-chip-red" if (summary.get("value_coverage") or 0) < 0.5 else "cf-chip-orange" + alert_items.append(("Funil sem valor", f"{unvalued} oportunidade(s) sem valor comercial", "/forecast", severity)) + if int(waiting.get("count") or 0): + alert_items.append(("Pagamentos a acelerar", f"{int(waiting.get('count') or 0)} oportunidade(s) · {money_html(waiting.get('gross') or 0)}", "/operations?scope=financeiro", "cf-chip-orange")) + if ready_count: + alert_items.append(("Logística pendente", f"{ready_count} processo(s) pronto(s) ou com envio criado", "/operations?scope=logistica", "cf-chip-orange")) if int(counts.get("outbox_failed") or 0): - alert_items.append(("Erro de integração", f"{counts.get('outbox_failed')} ação(ões) falhadas na outbox", "/outbox?status=failed", "cf-chip-red")) - if int(comms.get("needs_review") or 0): - alert_items.append(("Rever comunicação", f"{comms.get('needs_review')} mensagem(ns) com baixa confiança", "/operations", "cf-chip-orange")) - if int(counts.get("customers_incomplete") or 0): - alert_items.append(("Dados incompletos", f"{counts.get('customers_incomplete')} cliente(s) sem dados fiscais/morada completos", "/customers", "cf-chip-orange")) + alert_items.append(("Erro de integração", f"{counts.get('outbox_failed')} ação(ões) falhadas", "/outbox?status=failed", "cf-chip-red")) if int(counts.get("products_missing_jasmin") or 0): - alert_items.append(("Produto bloqueante", f"{counts.get('products_missing_jasmin')} produto(s) ativos sem Artigo Jasmin", "/products?active=missing_jasmin", "cf-chip-red")) + alert_items.append(("Produto bloqueante", f"{counts.get('products_missing_jasmin')} produto(s) sem Artigo Jasmin", "/products?active=missing_jasmin", "cf-chip-red")) - alert_html = "" - for title, detail, href, chip in alert_items[:5]: - alert_html += f""" - -
    {esc(title)}
    {esc(detail)}
    - Abrir → -
    - """ - if not alert_html: - alert_html = '
    Sem alertas críticos neste momento.
    ' + alert_html = "".join( + f'''
    {esc(title)}
    {detail}
    Abrir →
    ''' + for title, detail, href, chip in alert_items[:5] + ) or '
    Sem alertas críticos neste momento.
    ' + + action_rows = "" + for action in (forecast.get("priority_actions") or [])[:5]: + action_rows += f''' + + {esc(action.get('customer_name') or action.get('title') or 'Oportunidade')} + {esc(action.get('recommended_action') or 'Abrir e rever')} + {money_html(action.get('impact_amount') or action.get('amount') or 0)} + ''' + if not action_rows: + action_rows = 'Sem ações comerciais prioritárias calculadas.' body = f"""
    -
    - Dashboard = visibilidade. - O Chatwoot é a inbox. O ClientFlow mostra o trabalho, bloqueios e próximas ações. -
    - Abrir Centro de trabalho +
    Dashboard executivo.Mostra desempenho, saúde do funil, bloqueios e prioridades. O Centro de trabalho continua a organizar a execução diária.
    +
    -
    - {cards_html} -
    +
    {cards_html}
    -
    -
    -
    -

    Alertas

    Sinais globais que merecem atenção.
    - Resolver no Centro de trabalho -
    - {alert_html} -
    -
    +
    +

    Alertas

    Sinais globais com impacto comercial ou operacional.
    Resolver
    + {alert_html} +
    -
    -
    -

    Modelo operacional v4.5

    -
    -
    Dashboard
    Mostra o estado e gargalos.
    -
    Centro de trabalho
    Organiza o que precisa de ação agora.
    -
    Chatwoot → ClientFlow
    O Chatwoot continua a ser a inbox; o ClientFlow transforma mensagens em ações, tasks e timeline.
    -
    Oportunidade
    Mantém contexto, documentos, tasks, outbox e timeline.
    -
    -
    -
    +
    +

    Ações de maior impacto

    Prioridades calculadas a partir do funil valorizado.
    +
    {action_rows}
    ClienteAçãoImpacto
    +
    + +

    Modelo operacional v4.7

    Dashboard
    Estado, desempenho e riscos.
    Centro de trabalho
    Próximo trabalho humano.
    Metas e previsão
    Meta, desvio, recuperação e pipeline.
    Oportunidade
    Contexto, documentos, tasks e timeline.
    """ - return layout("Dashboard", "Visão geral do negócio e do sistema", body, "overview") - - + return layout("Dashboard", "Visão executiva do negócio e do sistema", body, "overview") diff --git a/app/admin_ui/pages/finance.py b/app/admin_ui/pages/finance.py index ee662e9..e90f0ed 100644 --- a/app/admin_ui/pages/finance.py +++ b/app/admin_ui/pages/finance.py @@ -4,6 +4,7 @@ Moved from app.admin_dashboard in v4.7.2. The handlers still reuse legacy helpers to keep this refactor behavior-preserving. """ from fastapi import APIRouter +from urllib.parse import quote import app.admin_dashboard as legacy from app.admin_dashboard import * # noqa: F401,F403 @@ -14,6 +15,7 @@ router = APIRouter() @router.get("/financeiro", response_class=HTMLResponse) async def finance_page(q: Optional[str] = None): finance_tasks = list_tasks(status=None, route="financeiro", q=q, limit=200) + return_to = "/finance" + (f"?q={quote(str(q), safe='')}" if q else "") finance_actions = {"SEND_PROFORMA", "SEND_INVOICE", "CONFIRM_PAYMENT"} finance_tasks = [t for t in finance_tasks if str(t.get("action_code") or "") in finance_actions or str(t.get("route") or "") == "financeiro"] opportunities = list_opportunities(status="all", q=q, limit=300) @@ -26,11 +28,11 @@ async def finance_page(q: Optional[str] = None): code = str(task.get("action_code") or "") rows += f""" - {esc(action_label(code))}
    {esc(code)}
    + {esc(action_label(code))}
    {esc(code)}
    {esc(customer)}
    {esc(task.get('customer_email') or '')}
    {status_badge(task.get('status'))} {esc(task.get('created_at') or '—')} - Abrir + Abrir """ if not rows: @@ -43,8 +45,9 @@ async def finance_page(q: Optional[str] = None): {kpi_card('Pendentes', sum(1 for t in finance_tasks if str(t.get('status')) == 'pending'), '/tasks?status=pending&route=financeiro', 'abrir tarefas', 'bi-list-check')} {kpi_card('Pagamentos confirmados', sum(1 for o in payment_opps if str(o.get('stage')) == 'PAYMENT_CONFIRMED'), '/opportunities', 'seguir para envio', 'bi-check2-circle', 'cf-kpi-tone-green')} +
    Limpar
    -

    Financeiro operacional

    Pró-formas, faturas e pagamentos a tratar.
    {rows}
    AçãoClienteEstadoCriada
    +

    Financeiro operacional

    Orçamentos, faturas e pagamentos a tratar.
    {rows}
    AçãoClienteEstadoCriada
    """ return layout("Financeiro", "O que falta faturar ou confirmar?", body, "finance") diff --git a/app/admin_ui/pages/integrations.py b/app/admin_ui/pages/integrations.py index 2f8d7d6..f0e281d 100644 --- a/app/admin_ui/pages/integrations.py +++ b/app/admin_ui/pages/integrations.py @@ -87,7 +87,7 @@ async def integrations_page():
    -
    +
    @@ -103,7 +103,7 @@ async def integrations_page():

    Jasmin

    -
    Pró-formas, faturas e documentos fiscais.
    +
    Orçamentos, faturas e documentos fiscais.
    {badge(jasmin_enabled)}
    diff --git a/app/admin_ui/pages/operations.py b/app/admin_ui/pages/operations.py index a8a5282..36d927e 100644 --- a/app/admin_ui/pages/operations.py +++ b/app/admin_ui/pages/operations.py @@ -8,6 +8,7 @@ from __future__ import annotations from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse +from urllib.parse import quote import app.admin_dashboard as legacy from app.admin_dashboard import * # noqa: F401,F403 @@ -23,6 +24,10 @@ from app.admin_ui.view_models.operations import ( operation_card_title, operation_primary_label, operation_status_chip, + operation_due_label, + operation_priority_reason, + operation_queue_label, + operation_value, ) router = APIRouter() @@ -40,7 +45,7 @@ def _operation_card_class(item: dict) -> str: return card_class -def _render_work_item(item: dict) -> str: +def _render_work_item(item: dict, return_to: str = "/operations?scope=all") -> str: high = is_high_priority(item) blocked = is_blocked(item) title = operation_card_title(item) @@ -67,8 +72,21 @@ def _render_work_item(item: dict) -> str: priority_chip = "cf-chip-orange" if status_text == "sem oportunidade comercial": priority_chip = "cf-chip-gray" + elif status_text == "associação por confirmar": + priority_chip = "cf-chip-purple" + elif status_text == "validação obrigatória": + priority_chip = "cf-chip-orange" status_chip_html = "" if status_text == "normal" else f'{esc(status_text)}' primary = operation_primary_label(item) + due_label = operation_due_label(item) + queue_text = operation_queue_label(item) + value = operation_value(item) + value_html = money_html(value) if value > 0 else "Valor por definir" + priority_reason = operation_priority_reason(item) + primary_href = str(item.get('href') or '#') + if primary_href.startswith('/tasks/') and return_to: + sep = '&' if '?' in primary_href else '?' + primary_href = f"{primary_href}{sep}return_to={quote(return_to, safe='')}" # v4.8.9: details are intentionally not rendered in Operations cards. # Technical metadata remains available in task/opportunity/admin pages, while # the work queue keeps only decision-making information. @@ -83,14 +101,21 @@ def _render_work_item(item: dict) -> str: {status_chip_html} +
    + {esc(queue_text)} + {esc(due_label)} + · + {value_html} +
    Próxima ação {esc(primary)}
    {esc(detail)}
    +
    Motivo da prioridade: {esc(priority_reason)}
    {blockers_html}
    - {esc(primary)} + {esc(primary)}
    {chatwoot_button} {opportunity_button} @@ -99,15 +124,17 @@ def _render_work_item(item: dict) -> str: """ -def _render_work_group(label: str, items: list[dict]) -> str: +def _render_work_group(label: str, items: list[dict], return_to: str = "/operations?scope=all") -> str: if not items: return "" - cards = "".join(_render_work_item(item) for item in items) + cards = "".join(_render_work_item(item, return_to=return_to) for item in items) return f'
    {esc(label)}
    {cards}
    ' def render_operations_work_items(model: dict) -> str: - queue_html = _render_work_group("Prioridade alta", model.get("high_items") or []) + _render_work_group("Normal", model.get("normal_items") or []) + scope = str(model.get("scope") or "all").strip() or "all" + return_to = f"/operations?scope={quote(scope, safe='')}" + queue_html = _render_work_group("Prioridade alta", model.get("high_items") or [], return_to=return_to) + _render_work_group("Normal", model.get("normal_items") or [], return_to=return_to) if not queue_html: queue_html = '
    Sem trabalho pendente neste filtro.
    Quando houver ações humanas ou bloqueios concretos, aparecem aqui.
    ' return f''' diff --git a/app/admin_ui/pages/opportunities.py b/app/admin_ui/pages/opportunities.py index a202c2c..9dd4364 100644 --- a/app/admin_ui/pages/opportunities.py +++ b/app/admin_ui/pages/opportunities.py @@ -4,11 +4,33 @@ Moved from app.admin_dashboard in v4.7.2. The handlers still reuse legacy helpers to keep this refactor behavior-preserving. """ from fastapi import APIRouter, Request +from fastapi.responses import PlainTextResponse, RedirectResponse +from sqlalchemy import text +from sqlalchemy.exc import OperationalError +from app.db import engine +from urllib.parse import quote +import json +import time +import uuid +from datetime import datetime, timezone import app.admin_dashboard as legacy from app.admin_dashboard import * # noqa: F401,F403 from app.admin_ui.labels import primary_action_label from app.operation_noise import is_noise_operation_item from app.opportunity_next_action_service import get_opportunity_next_action +from app.opportunity_action_task_materializer import ensure_pending_task_for_next_action +from app.work_center_action_policy import ( + canonical_action_code, + reconstructed_review_required, + reconstructed_review_status, +) +from app.opportunity_service import ( + LOSS_REASON_LABELS, + OPPORTUNITY_LIFECYCLE_STATES, + lifecycle_label, + mark_opportunity_lost, + set_opportunity_lifecycle, +) from app.admin_ui.guidance import ( blocker_alert_html, fiscal_contact_inline_html, @@ -25,6 +47,1005 @@ _opportunity_board_column_for_stage = legacy._opportunity_board_column_for_stage router = APIRouter() +PAYMENT_TERM_LABELS = { + "before_shipping": "Antes do envio", + "after_delivery": "Após entrega", + "agreement": "Conforme acordo", + "undefined": "A definir", +} + + +_OBSOLETE_AFTER_PAYMENT_TASK_CODES = { + "CONFIRM_PAYMENT", + "FOLLOW_UP_PAYMENT", + "FOLLOW_UP_PROFORMA", + "FOLLOW_UP_QUOTE", + "CONFIRM_DELIVERY", + "RECOVER_OPPORTUNITY", + "REVIEW_NURTURE", +} + + +def _is_obsolete_after_payment_task(task: dict, payment_confirmed_for_ui: bool) -> bool: + if not payment_confirmed_for_ui: + return False + code = str((task or {}).get("action_code") or "").upper().strip() + if code in _OBSOLETE_AFTER_PAYMENT_TASK_CODES: + metadata = (task or {}).get("metadata") or {} + if isinstance(metadata, dict): + metadata.setdefault("ui_reason", "obsoleta: pagamento já confirmado") + return True + return False + +DELIVERY_TERM_LABELS = { + "carrier": "Transportadora", + "pickup": "Levantamento", + "install_partner": "Eletricista/instalador do cliente", + "undefined": "A definir", +} + +# UI simplification: commercial phases stay short; financial/Odoo/shipping details +# remain visible as derived evidence cards instead of becoming dozens of manual phases. +COMMERCIAL_STAGE_OPTIONS = [ + ("NEW_LEAD", "Novo pedido"), + ("INFO_SENT", "Informação enviada"), + ("QUOTE_SENT", "Orçamento enviado"), + ("WAITING_PAYMENT", "A aguardar pagamento"), + ("PAYMENT_CONFIRMED", "Pagamento confirmado"), + ("ODOO_ORDER_CREATED", "Encomenda confirmada / em execução"), + ("WON", "Concluído"), + ("REVIEW", "Rever"), +] + +_DETAILED_OPERATIONAL_STAGES = { + "INFO_REQUESTED", + "QUOTE_REQUESTED", + "PROFORMA_REQUESTED", + "INVOICE_REQUESTED", + "INVOICE_SENT", + "WAITING_PAYMENT", + "PAYMENT_CONFIRMED", + "IN_PRODUCTION", + "READY_TO_SHIP", + "INVOICED", + "SHIPMENT_CREATED", + "TRACKING_SENT", + "DELIVERED", + "ORDER_PREPARATION", + "SHIPPED", + "NO_INTEREST", + "ARCHIVED", +} + +def _opportunity_metadata(opportunity: dict) -> dict: + raw = opportunity.get("metadata") if isinstance(opportunity, dict) else {} + return raw if isinstance(raw, dict) else {} + + +def _commercial_stage_options_html(current_stage: str) -> str: + current_stage = str(current_stage or "NEW_LEAD").upper() + option_values = {value for value, _label in COMMERCIAL_STAGE_OPTIONS} + html = "" + if current_stage not in option_values and current_stage in OPPORTUNITY_STAGE_LABELS: + html += ( + '' + ) + for value, label in COMMERCIAL_STAGE_OPTIONS: + selected = "selected" if value == current_stage else "" + html += f'' + return html + + +def _option_tags(options: dict, selected_value: str) -> str: + selected_value = str(selected_value or "undefined") + html = "" + for value, label in options.items(): + selected = "selected" if value == selected_value else "" + html += f'' + return html + + +def _payment_terms_summary(metadata: dict) -> tuple[str, str]: + # Default BLIF commercial terms: payment before shipping and carrier delivery. + # Operators can still override to after-delivery/agreement/undefined per opportunity. + payment_term = str(metadata.get("payment_terms") or "before_shipping") + delivery_term = str(metadata.get("delivery_terms") or "carrier") + payment_label = PAYMENT_TERM_LABELS.get(payment_term, PAYMENT_TERM_LABELS["undefined"]) + delivery_label = DELIVERY_TERM_LABELS.get(delivery_term, DELIVERY_TERM_LABELS["undefined"]) + return payment_label, delivery_label + + +def _safe_opportunity_task_text(value: str) -> str: + """Normalize legacy/stale task notes before showing them in opportunity UI.""" + text_value = str(value or "") + replacements = { + "fatura por emitir": "fatura criada/associada; enviar PDF ao cliente", + "Fatura por emitir": "Fatura criada/associada; enviar PDF ao cliente", + "Processo Odoo reconstruído: encomenda/entrega encontrada e fatura por emitir.": "Processo reconstruído: fatura criada/associada; enviar PDF ao cliente.", + "Preparar e enviar pró-forma para pagamento.": "Preparar e enviar orçamento para pagamento.", + "Enviar pró-forma": "Enviar orçamento para pagamento", + "pró-forma": "orçamento para pagamento", + "Pró-forma": "Orçamento para pagamento", + } + for old, new in replacements.items(): + text_value = text_value.replace(old, new) + return text_value + + +def _opportunity_payment_confirmed(opportunity_id: str) -> bool: + if not is_uuid_text(opportunity_id): + return False + with engine.begin() as conn: + return bool(conn.execute(text(''' + SELECT 1 + FROM operation_links + WHERE opportunity_id = CAST(:opportunity_id AS UUID) + AND system = 'clientflow' + AND external_type = 'payment' + AND status = 'confirmed' + LIMIT 1 + '''), {"opportunity_id": opportunity_id}).scalar()) + + +def _opportunity_invoice_sent_evidence(opportunity_id: str, invoice_number: str | None = None) -> bool: + """Return local evidence that an invoice was sent to the customer. + + Commercial documents imported from Jasmin do not always carry a sent flag. + Reconstructed opportunities often have the evidence only as a completed + SEND_INVOICE task, so the UI must use the same evidence model as the + central next-action engine. + """ + if not is_uuid_text(opportunity_id): + return False + normalized_invoice = str(invoice_number or "").strip().upper() + with engine.begin() as conn: + row = conn.execute(text(''' + SELECT 1 + FROM tasks + WHERE opportunity_id = CAST(:opportunity_id AS UUID) + AND action_code = 'SEND_INVOICE' + AND LOWER(COALESCE(status, '')) IN ('done','completed','complete','closed','resolved','concluida','concluído','concluída') + AND ( + :invoice_number = '' + OR UPPER(COALESCE(action, '') || ' ' || COALESCE(note, '') || ' ' || COALESCE(metadata::text, '')) LIKE '%' || :invoice_number || '%' + OR COALESCE(metadata->>'document_number', metadata->>'invoice_number', '') = '' + ) + LIMIT 1 + '''), { + "opportunity_id": opportunity_id, + "invoice_number": normalized_invoice, + }).scalar() + return bool(row) + + +def _document_display_number(doc: dict | None) -> str: + if not doc: + return "—" + return str(doc.get("document_number") or doc.get("external_id") or doc.get("id") or "documento") + + +def _finance_quick_card_html(opportunity_id: str, linked_documents: list[dict], payment_term: str, payment_term_label: str) -> str: + # BLIF default flow: quotation -> payment -> invoice -> prepare/ship. + quotation = next((d for d in linked_documents if str(d.get("document_kind") or "") in {"quotation", "proforma"} and str(d.get("role") or "current") in {"current", "accepted"}), None) + invoice = next((d for d in linked_documents if str(d.get("document_kind") or "") == "invoice" and str(d.get("role") or "current") in {"current", "accepted"}), None) + payment_confirmed = _opportunity_payment_confirmed(opportunity_id) + base_doc = invoice or quotation + base_doc_label = "Fatura" if invoice else ("Orçamento" if quotation else "Documento") + amount = (base_doc.get("total_amount") or base_doc.get("amount")) if base_doc else None + amount_html = money_html(float(amount or 0)) if amount else "—" + payment_status = "Confirmado" if payment_confirmed else ("Pendente pós-entrega" if payment_term == "after_delivery" else "Por confirmar") + + if not base_doc: + action_html = '
    Bloqueado: cria/associa primeiro um orçamento ou fatura.
    ' + elif payment_confirmed: + if not invoice: + action_html = f''' +
    + +
    Pagamento confirmado. Próximo passo do fluxo normal: emitir fatura.
    +
    + ''' + else: + invoice_payload = invoice.get("payload") if isinstance(invoice.get("payload"), dict) else {} + invoice_sent = bool( + invoice.get("sent_at") + or invoice.get("sent") + or str(invoice.get("status") or "").lower() in {"sent", "issued_sent"} + or invoice_payload.get("clientflow_invoice_sent_evidence") + or invoice_payload.get("invoice_sent_at") + or _opportunity_invoice_sent_evidence(opportunity_id, _document_display_number(invoice)) + ) + if invoice_sent: + detail = "Fatura enviada e pagamento confirmado. Continua pela próxima ação operacional indicada acima." + else: + detail = "Fatura criada/associada. Envia o PDF ao cliente; depois acompanha preparação/Odoo." + action_html = f'
    {esc(detail)}
    ' + else: + note = "Pagamento validado pelo operador no ClientFlow." + button_label = "Confirmar pagamento" + if payment_term == "after_delivery": + note = "Registar pagamento recebido após entrega/acordo comercial." + button_label = "Confirmar pagamento pós-entrega" + elif quotation and not invoice and payment_term == "before_shipping": + note = "Pagamento confirmado com base no orçamento. Emitir fatura de seguida." + action_html = f''' +
    + + + +
    + ''' + + return f''' +
    +
    +

    Financeiro rápido

    +
    Ação independente da fase: usa orçamento/fatura associado e a condição comercial.
    +
    +
    {esc(base_doc_label)}{esc(_document_display_number(base_doc))}
    +
    Valor esperado{amount_html}
    +
    Pagamento{esc(payment_status)}
    +
    Condição{esc(payment_term_label)}
    +
    + {action_html} +
    +
    + ''' + + +def _json_payload(value: object) -> str: + return json.dumps(value or {}, ensure_ascii=False, default=str) + + +def _opportunity_manual_correction_state(opportunity_id: str) -> dict: + """Return counts that help the operator understand external links before correction. + + This card is auxiliary/advanced UI only. It must never make the + opportunity detail page fail if PostgreSQL detects a transient lock cycle + while reconciliation/sync jobs are rebuilding evidence. Retry once and then + return a safe degraded state instead of surfacing a 500. + """ + safe_empty = { + "odoo_links": 0, + "jasmin_links": 0, + "jasmin_documents": 0, + "imported_lines": 0, + "reconciliation_items": 0, + } + if not is_uuid_text(opportunity_id): + return safe_empty + + last_lock_error = None + for attempt in range(2): + try: + with engine.begin() as conn: + row = conn.execute(text(""" + SELECT + COUNT(*) FILTER (WHERE system = 'odoo')::int AS odoo_links, + COUNT(*) FILTER (WHERE system = 'jasmin')::int AS jasmin_links + FROM operation_links + WHERE opportunity_id = CAST(:opportunity_id AS UUID) + """), {"opportunity_id": opportunity_id}).mappings().first() or {} + docs = conn.execute(text(""" + SELECT COUNT(*)::int + FROM commercial_documents + WHERE opportunity_id = CAST(:opportunity_id AS UUID) + AND system = 'jasmin' + """), {"opportunity_id": opportunity_id}).scalar() or 0 + imported = conn.execute(text(""" + SELECT COUNT(*)::int + FROM opportunity_items + WHERE opportunity_id = CAST(:opportunity_id AS UUID) + AND ( + UPPER(COALESCE(status,'')) IN ('ODOO_IMPORTED','JASMIN_IMPORTED') + OR UPPER(COALESCE(status,'')) LIKE '%\\_IMPORTED' ESCAPE '\\' + OR COALESCE(metadata->>'source_system','') IN ('odoo','jasmin') + ) + """), {"opportunity_id": opportunity_id}).scalar() or 0 + reconciliation = conn.execute(text(""" + SELECT COUNT(*)::int + FROM reconciliation_items + WHERE opportunity_id = CAST(:opportunity_id AS UUID) + AND source_system IN ('odoo','jasmin') + """), {"opportunity_id": opportunity_id}).scalar() or 0 + data = dict(row) + data["jasmin_documents"] = int(docs or 0) + data["imported_lines"] = int(imported or 0) + data["reconciliation_items"] = int(reconciliation or 0) + return data + except OperationalError as exc: + msg = str(exc).lower() + if "deadlock detected" in msg or "lock timeout" in msg or "could not obtain lock" in msg: + last_lock_error = exc + if attempt == 0: + time.sleep(0.25) + continue + degraded = dict(safe_empty) + degraded["unavailable"] = True + degraded["unavailable_reason"] = "lock_timeout" + return degraded + raise + + if last_lock_error: + degraded = dict(safe_empty) + degraded["unavailable"] = True + degraded["unavailable_reason"] = "lock_timeout" + return degraded + return safe_empty + + + + +def _opportunity_archive_spam_state(opportunity_id: str) -> dict: + """Best-effort state for the UI-only spam archive card. + + The POST action still performs the authoritative safety check. This helper + must never break the opportunity page; deadlocks/reconciliation locks simply + hide the archive card for this request. + """ + if not is_uuid_text(opportunity_id): + return {"can_archive": False, "unavailable": True} + try: + with engine.begin() as conn: + row = conn.execute(text(""" + SELECT + (SELECT COUNT(*) FROM commercial_documents WHERE opportunity_id = CAST(:opportunity_id AS UUID)) AS docs, + (SELECT COUNT(*) FROM reconciliation_items WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND source_system IN ('jasmin','odoo')) AS linked_external, + (SELECT COUNT(*) FROM operation_links WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND system IN ('jasmin','odoo','packlink')) AS operation_links, + (SELECT COUNT(*) FROM tasks WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND action_code = 'IGNORE_SPAM') AS spam_tasks, + (SELECT COUNT(*) FROM tasks WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND route = 'spam') AS spam_route_tasks, + (SELECT COUNT(*) FROM communications WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND classification IN ('IGNORE_SPAM','SPAM')) AS spam_communications, + (SELECT status FROM opportunities WHERE id = CAST(:opportunity_id AS UUID)) AS status + """), {"opportunity_id": opportunity_id}).mappings().first() or {} + except OperationalError: + return {"can_archive": False, "unavailable": True} + except Exception: + return {"can_archive": False, "unavailable": True} + docs = int(row.get("docs") or 0) + linked_external = int(row.get("linked_external") or 0) + operation_links = int(row.get("operation_links") or 0) + spam_evidence = int(row.get("spam_tasks") or 0) + int(row.get("spam_route_tasks") or 0) + int(row.get("spam_communications") or 0) + status = str(row.get("status") or "").lower() + return { + "can_archive": status != "archived" and docs == 0 and linked_external == 0 and operation_links == 0, + "has_spam_evidence": spam_evidence > 0, + "docs": docs, + "linked_external": linked_external, + "operation_links": operation_links, + "spam_evidence": spam_evidence, + "status": status, + } + +def _recalculate_opportunity_value_after_manual_correction(conn, opportunity_id: str): + manual_total = conn.execute(text(""" + SELECT COALESCE(SUM(total_price), 0)::numeric + FROM opportunity_items + WHERE opportunity_id = CAST(:opportunity_id AS UUID) + AND UPPER(COALESCE(status,'')) NOT IN ('REJECTED','CANCELLED','DELIVERED','HISTORICAL','ODOO_IMPORTED','JASMIN_IMPORTED') + AND UPPER(COALESCE(status,'')) NOT LIKE '%\\_IMPORTED' ESCAPE '\\' + AND COALESCE(metadata->>'source_system','manual') NOT IN ('odoo','jasmin') + """), {"opportunity_id": opportunity_id}).scalar() + return manual_total or 0 + + +def apply_manual_external_correction( + opportunity_id: str, + *, + unlink_odoo: bool, + unlink_jasmin: bool, + remove_imported_lines: bool, + new_stage: str, + note: str, + actor: str = "operator_manual_correction", +) -> dict: + """Manual override for wrongly linked Odoo/Jasmin evidence. + + This is intentionally auditable and local-only: it never deletes data in Odoo, + Jasmin or Chatwoot. It only detaches ClientFlow evidence from the opportunity. + """ + if not is_uuid_text(opportunity_id): + raise ValueError("Identificador de oportunidade inválido.") + new_stage = str(new_stage or "INFO_SENT").strip().upper() + if new_stage not in OPPORTUNITY_STAGE_LABELS: + raise ValueError(f"Fase inválida: {new_stage}") + action_by_stage = { + "INFO_SENT": "SEND_INFO", + "INFO_REQUESTED": "SEND_INFO", + "QUOTE_REQUESTED": "SEND_QUOTE", + "QUOTE_SENT": "SEND_QUOTE", + "PROFORMA_REQUESTED": "SEND_PROFORMA", + "PROFORMA_SENT": "SEND_PROFORMA", + "INVOICE_REQUESTED": "SEND_INVOICE", + "INVOICE_SENT": "SEND_INVOICE", + "WAITING_PAYMENT": "CONFIRM_PAYMENT", + "REVIEW": "REVIEW_MANUALLY", + "LOST": "MARK_NO_INTEREST", + "NO_INTEREST": "MARK_NO_INTEREST", + } + new_action = action_by_stage.get(new_stage, "SEND_INFO") + sources: list[str] = [] + if unlink_odoo: + sources.append("odoo") + if unlink_jasmin: + sources.append("jasmin") + if not sources and not new_stage: + return {"changed": 0} + + result = { + "operation_links_deleted": 0, + "jasmin_documents_deleted": 0, + "imported_lines_deleted": 0, + "reconciliation_items_unlinked": 0, + "stage": new_stage, + } + note = (note or "Correção manual: associação externa errada removida pelo operador.").strip() + metadata_patch = { + "manual_external_correction": True, + "manual_external_correction_sources": sources, + "manual_external_correction_note": note, + "manual_external_correction_actor": actor, + } + + with engine.begin() as conn: + current = conn.execute(text(""" + SELECT stage, value_amount, last_action_code + FROM opportunities + WHERE id = CAST(:opportunity_id AS UUID) + """), {"opportunity_id": opportunity_id}).mappings().first() + if not current: + raise ValueError("Oportunidade não encontrada.") + old_stage = str(current.get("stage") or "NEW_LEAD") + + if unlink_odoo: + result["operation_links_deleted"] += conn.execute(text(""" + DELETE FROM operation_links + WHERE opportunity_id = CAST(:opportunity_id AS UUID) + AND system = 'odoo' + """), {"opportunity_id": opportunity_id}).rowcount or 0 + + if unlink_jasmin: + # Commercial document lines are removed by ON DELETE CASCADE. + result["jasmin_documents_deleted"] += conn.execute(text(""" + DELETE FROM commercial_documents + WHERE opportunity_id = CAST(:opportunity_id AS UUID) + AND system = 'jasmin' + """), {"opportunity_id": opportunity_id}).rowcount or 0 + result["operation_links_deleted"] += conn.execute(text(""" + DELETE FROM operation_links + WHERE opportunity_id = CAST(:opportunity_id AS UUID) + AND system = 'jasmin' + """), {"opportunity_id": opportunity_id}).rowcount or 0 + + if remove_imported_lines and sources: + result["imported_lines_deleted"] += conn.execute(text(""" + DELETE FROM opportunity_items + WHERE opportunity_id = CAST(:opportunity_id AS UUID) + AND ( + (:unlink_odoo IS TRUE AND (COALESCE(metadata->>'source_system','') = 'odoo' OR UPPER(COALESCE(status,'')) = 'ODOO_IMPORTED')) + OR (:unlink_jasmin IS TRUE AND (COALESCE(metadata->>'source_system','') = 'jasmin' OR UPPER(COALESCE(status,'')) = 'JASMIN_IMPORTED')) + OR ((:unlink_odoo IS TRUE OR :unlink_jasmin IS TRUE) AND UPPER(COALESCE(status,'')) LIKE '%\\_IMPORTED' ESCAPE '\\') + ) + """), {"opportunity_id": opportunity_id, "unlink_odoo": bool(unlink_odoo), "unlink_jasmin": bool(unlink_jasmin)}).rowcount or 0 + + if sources: + result["reconciliation_items_unlinked"] += conn.execute(text(""" + UPDATE reconciliation_items + SET opportunity_id = NULL, + status = CASE WHEN status IN ('resolved','linked','applied','open','needs_review','conflict') THEN 'needs_review' ELSE status END, + resolution_note = COALESCE(resolution_note || ' | ', '') || :note, + resolved_at = NULL, + payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB), + updated_at = now() + WHERE opportunity_id = CAST(:opportunity_id AS UUID) + AND ( + (:unlink_odoo IS TRUE AND source_system = 'odoo') + OR (:unlink_jasmin IS TRUE AND source_system = 'jasmin') + ) + """), { + "opportunity_id": opportunity_id, + "unlink_odoo": bool(unlink_odoo), + "unlink_jasmin": bool(unlink_jasmin), + "note": note, + "payload": _json_payload({"manual_unlinked_from_opportunity_id": opportunity_id, "sources": sources, "actor": actor}), + }).rowcount or 0 + + manual_total = _recalculate_opportunity_value_after_manual_correction(conn, opportunity_id) + conn.execute(text(""" + UPDATE opportunities + SET stage = :stage, + status = CASE WHEN :stage IN ('WON','LOST','NO_INTEREST','DELIVERED') THEN 'closed' ELSE 'open' END, + last_action_code = :action_code, + value_amount = CAST(:value_amount AS NUMERIC), + metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB), + closed_at = CASE WHEN :stage IN ('WON','LOST','NO_INTEREST','DELIVERED') THEN COALESCE(closed_at, now()) ELSE NULL END, + updated_at = now() + WHERE id = CAST(:opportunity_id AS UUID) + """), { + "opportunity_id": opportunity_id, + "stage": new_stage, + "action_code": new_action, + "value_amount": manual_total, + "metadata": _json_payload(metadata_patch), + }) + conn.execute(text(""" + INSERT INTO opportunity_events ( + id, opportunity_id, event_type, action_code, from_stage, to_stage, note, payload, created_by + ) VALUES ( + CAST(:id AS UUID), CAST(:opportunity_id AS UUID), 'manual_external_correction', + :action_code, :from_stage, :to_stage, :note, CAST(:payload AS JSONB), :created_by + ) + """), { + "id": str(uuid.uuid4()), + "opportunity_id": opportunity_id, + "action_code": new_action, + "from_stage": old_stage, + "to_stage": new_stage, + "note": note, + "payload": _json_payload(result), + "created_by": actor, + }) + return result + + +def ignore_external_candidate_for_opportunity(opportunity_id: str, item_id: str, *, actor: str = "operator_ui_ignore_candidate") -> int: + if not is_uuid_text(opportunity_id) or not is_uuid_text(item_id): + raise ValueError("Identificador inválido.") + with engine.begin() as conn: + count = conn.execute(text(""" + UPDATE reconciliation_items + SET status = 'ignored', + opportunity_id = CASE WHEN opportunity_id = CAST(:opportunity_id AS UUID) THEN NULL ELSE opportunity_id END, + resolution_note = COALESCE(resolution_note || ' | ', '') || 'Ignorado manualmente a partir da oportunidade.', + resolved_at = now(), + payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB), + updated_at = now() + WHERE id = CAST(:item_id AS UUID) + AND source_system IN ('odoo','jasmin') + """), { + "opportunity_id": opportunity_id, + "item_id": item_id, + "payload": _json_payload({"ignored_from_opportunity_id": opportunity_id, "actor": actor}), + }).rowcount or 0 + if count: + conn.execute(text(""" + INSERT INTO opportunity_events ( + id, opportunity_id, event_type, action_code, note, payload, created_by + ) VALUES ( + CAST(:id AS UUID), CAST(:opportunity_id AS UUID), 'external_candidate_ignored', + 'REVIEW_RECONCILIATION', :note, CAST(:payload AS JSONB), :created_by + ) + """), { + "id": str(uuid.uuid4()), + "opportunity_id": opportunity_id, + "note": "Candidato externo ignorado manualmente.", + "payload": _json_payload({"item_id": item_id}), + "created_by": actor, + }) + return int(count or 0) + + + + +def unlink_commercial_document_from_opportunity( + opportunity_id: str, + document_id: str, + *, + remove_imported_lines: bool = True, + note: str = "", + actor: str = "operator_ui_document_unlink", +) -> dict: + """Detach one local commercial document from an opportunity. + + This is the granular counterpart to the broad manual external correction. + It does not delete anything in Jasmin/Odoo. It only removes the document + from this ClientFlow opportunity and, when requested, removes imported + opportunity lines that explicitly came from that document reference. + """ + if not is_uuid_text(opportunity_id) or not is_uuid_text(document_id): + raise ValueError("Identificador inválido.") + note = (note or "Documento desassociado manualmente desta oportunidade.").strip() + result = {"document_unlinked": 0, "imported_lines_deleted": 0, "reconciliation_items_unlinked": 0} + with engine.begin() as conn: + doc = conn.execute(text(""" + SELECT id::text, opportunity_id::text, system, document_kind, external_id, document_number, + role, is_primary, total_amount, amount + FROM commercial_documents + WHERE id = CAST(:document_id AS UUID) + AND opportunity_id = CAST(:opportunity_id AS UUID) + LIMIT 1 + """), {"document_id": document_id, "opportunity_id": opportunity_id}).mappings().first() + if not doc: + raise ValueError("Documento não encontrado nesta oportunidade.") + refs = [str(doc.get("document_number") or "").strip(), str(doc.get("external_id") or "").strip()] + refs = [r for r in refs if r] + payload = _json_payload({ + "manual_document_unlink": True, + "opportunity_id": opportunity_id, + "document_id": document_id, + "document_number": doc.get("document_number"), + "external_id": doc.get("external_id"), + "actor": actor, + "note": note, + }) + result["document_unlinked"] = conn.execute(text(""" + UPDATE commercial_documents + SET opportunity_id = NULL, + role = 'detached', + is_primary = FALSE, + is_active = FALSE, + payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB), + updated_at = now() + WHERE id = CAST(:document_id AS UUID) + AND opportunity_id = CAST(:opportunity_id AS UUID) + """), {"document_id": document_id, "opportunity_id": opportunity_id, "payload": payload}).rowcount or 0 + + if remove_imported_lines and refs: + result["imported_lines_deleted"] = conn.execute(text(""" + DELETE FROM opportunity_items + WHERE opportunity_id = CAST(:opportunity_id AS UUID) + AND ( + metadata->>'source_system' = CAST(:system AS TEXT) + OR (:system = 'jasmin' AND status = 'JASMIN_IMPORTED') + OR (:system = 'odoo' AND status = 'ODOO_IMPORTED') + ) + AND ( + metadata->>'source_document' = ANY(:refs) + OR metadata->>'source_external_id' = ANY(:refs) + OR source_document = ANY(:refs) + ) + """), {"opportunity_id": opportunity_id, "system": doc.get("system") or "jasmin", "refs": refs}).rowcount or 0 + + if refs: + result["reconciliation_items_unlinked"] = conn.execute(text(""" + UPDATE reconciliation_items + SET opportunity_id = NULL, + status = CASE WHEN status IN ('resolved','linked','applied','open','needs_review','conflict') THEN 'needs_review' ELSE status END, + resolution_note = COALESCE(resolution_note || ' | ', '') || :note, + resolved_at = NULL, + payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB), + updated_at = now() + WHERE opportunity_id = CAST(:opportunity_id AS UUID) + AND source_system = CAST(:system AS TEXT) + AND ( + document_number = ANY(:refs) + OR external_id = ANY(:refs) + OR payload::text ILIKE '%' || CAST(:document_id AS TEXT) || '%' + ) + """), { + "opportunity_id": opportunity_id, + "system": doc.get("system") or "jasmin", + "refs": refs, + "document_id": document_id, + "note": note, + "payload": payload, + }).rowcount or 0 + + conn.execute(text(""" + INSERT INTO opportunity_events (id, opportunity_id, event_type, action_code, note, payload, created_by) + VALUES (CAST(:id AS UUID), CAST(:opportunity_id AS UUID), 'commercial_document_unlinked', + 'REVIEW_RECONCILIATION', :note, CAST(:payload AS JSONB), :actor) + """), { + "id": str(uuid.uuid4()), + "opportunity_id": opportunity_id, + "note": note, + "payload": payload, + "actor": actor, + }) + return result + + +def set_commercial_document_role_for_opportunity( + opportunity_id: str, + document_id: str, + *, + role: str = "current", + make_primary: bool = True, + actor: str = "operator_ui_document_role", +) -> dict: + """Choose which document belongs to the current process without deleting evidence.""" + if not is_uuid_text(opportunity_id) or not is_uuid_text(document_id): + raise ValueError("Identificador inválido.") + role = str(role or "current").strip().lower() + if role not in {"current", "accepted", "related", "historical"}: + raise ValueError("Papel de documento inválido.") + with engine.begin() as conn: + doc = conn.execute(text(""" + SELECT id::text, system, document_kind, document_number + FROM commercial_documents + WHERE id = CAST(:document_id AS UUID) + AND opportunity_id = CAST(:opportunity_id AS UUID) + LIMIT 1 + """), {"document_id": document_id, "opportunity_id": opportunity_id}).mappings().first() + if not doc: + raise ValueError("Documento não encontrado nesta oportunidade.") + if make_primary and role in {"current", "accepted"}: + conn.execute(text(""" + UPDATE commercial_documents + SET role = CASE WHEN COALESCE(role, 'current') = 'current' THEN 'historical' ELSE role END, + is_primary = FALSE, + is_active = CASE WHEN COALESCE(role, 'current') = 'current' THEN FALSE ELSE COALESCE(is_active, TRUE) END, + updated_at = now() + WHERE opportunity_id = CAST(:opportunity_id AS UUID) + AND system = CAST(:system AS TEXT) + AND document_kind = CAST(:document_kind AS TEXT) + AND id <> CAST(:document_id AS UUID) + """), { + "opportunity_id": opportunity_id, + "system": doc.get("system"), + "document_kind": doc.get("document_kind"), + "document_id": document_id, + }) + conn.execute(text(""" + UPDATE commercial_documents + SET role = :role, + is_primary = :is_primary, + is_active = TRUE, + updated_at = now(), + payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB) + WHERE id = CAST(:document_id AS UUID) + AND opportunity_id = CAST(:opportunity_id AS UUID) + """), { + "document_id": document_id, + "opportunity_id": opportunity_id, + "role": role, + "is_primary": bool(make_primary and role in {"current", "accepted"}), + "payload": _json_payload({"manual_document_role": role, "manual_primary": bool(make_primary), "actor": actor}), + }) + conn.execute(text(""" + INSERT INTO opportunity_events (id, opportunity_id, event_type, action_code, note, payload, created_by) + VALUES (CAST(:id AS UUID), CAST(:opportunity_id AS UUID), 'commercial_document_role_changed', + 'REVIEW_RECONCILIATION', :note, CAST(:payload AS JSONB), :actor) + """), { + "id": str(uuid.uuid4()), + "opportunity_id": opportunity_id, + "note": f"Documento {doc.get('document_number') or document_id} marcado como {role}.", + "payload": _json_payload({"document_id": document_id, "role": role, "make_primary": bool(make_primary)}), + "actor": actor, + }) + return {"changed": 1, "role": role, "is_primary": bool(make_primary and role in {"current", "accepted"})} + +def _odoo_m2o_label(value) -> str: + if isinstance(value, (list, tuple)) and len(value) >= 2: + return str(value[1] or "") + if isinstance(value, dict): + return str(value.get("name") or value.get("display_name") or value.get("id") or "") + return str(value or "") + + +def _odoo_status_badge(status: object) -> str: + s = str(status or "").lower() + cls = "text-bg-secondary" + if s in {"done", "shipped", "delivered", "validated", "sale", "created", "order_created", "ready_to_ship"}: + cls = "text-bg-success" + elif s in {"assigned", "confirmed", "waiting", "in_production", "progress", "pending", "sent", "quote_only"}: + cls = "text-bg-warning" + elif s in {"cancel", "cancelled", "failed", "not_found", "blocked"}: + cls = "text-bg-danger" + return f'{esc(status or "—")}' + + +def _opportunity_odoo_rows(opportunity_id: str) -> tuple[list[dict], list[dict]]: + """Return linked Odoo operation links and recent reconciliation candidates. + + Read-only. The panel must not call Odoo on page load; the operator uses + the explicit sync button to refresh live Odoo state. + """ + with engine.begin() as conn: + links = conn.execute(text(""" + SELECT id::text, system, external_type, external_id, external_name, + external_url, status, payload, last_synced_at, updated_at + FROM operation_links + WHERE opportunity_id = CAST(:opportunity_id AS UUID) + AND system = 'odoo' + ORDER BY + CASE external_type + WHEN 'sale_order' THEN 1 + WHEN 'physical_status' THEN 2 + WHEN 'production' THEN 3 + WHEN 'physical_validation' THEN 4 + ELSE 9 + END, + updated_at DESC + """), {"opportunity_id": opportunity_id}).mappings().all() + candidates = conn.execute(text(""" + SELECT id::text, source_system, external_type, external_id, + document_number, title, status, amount, currency, + customer_name, customer_email, customer_tax_id, payload, + opportunity_id::text AS linked_opportunity_id, + created_at, updated_at, resolved_at + FROM reconciliation_items + WHERE source_system = 'odoo' + AND external_type = 'odoo_sale_order' + AND ( + opportunity_id = CAST(:opportunity_id AS UUID) + OR (status IN ('open','needs_review','conflict') AND payload::text ILIKE '%' || CAST(:opportunity_id AS TEXT) || '%') + ) + ORDER BY + CASE WHEN opportunity_id = CAST(:opportunity_id AS UUID) THEN 0 ELSE 1 END, + updated_at DESC + LIMIT 20 + """), {"opportunity_id": opportunity_id}).mappings().all() + return [dict(r) for r in links], [dict(r) for r in candidates] + + +def odoo_status_panel_html(opportunity_id: str, *, notice: str = "", error_notice: str = "") -> str: + try: + links, candidates = _opportunity_odoo_rows(opportunity_id) + except Exception as exc: + return f'
    Erro ao carregar Odoo: {esc(exc)}
    ' + + by_type = {str(link.get("external_type") or ""): link for link in links} + sale = by_type.get("sale_order") or {} + physical = by_type.get("physical_status") or {} + physical_payload = physical.get("payload") if isinstance(physical.get("payload"), dict) else {} + sale_payload = sale.get("payload") if isinstance(sale.get("payload"), dict) else {} + live_sale = physical_payload.get("sale_order") if isinstance(physical_payload.get("sale_order"), dict) else {} + pickings = physical_payload.get("pickings") if isinstance(physical_payload.get("pickings"), list) else [] + productions = physical_payload.get("productions") if isinstance(physical_payload.get("productions"), list) else [] + + sale_name = live_sale.get("name") or sale.get("external_name") or sale_payload.get("sale_order") or sale.get("external_id") or "—" + sale_state = live_sale.get("state") or sale.get("status") or "—" + sale_amount = live_sale.get("amount_total") or sale_payload.get("amount_total") or "" + partner = _odoo_m2o_label(live_sale.get("partner") or live_sale.get("partner_id") or sale_payload.get("partner") or sale_payload.get("partner_id")) or "—" + last_synced = physical.get("last_synced_at") or sale.get("last_synced_at") or "—" + physical_label = physical_payload.get("label") or physical.get("status") or "Não sincronizado" + physical_reason = physical_payload.get("reason") or "Usa o botão para consultar estado físico no Odoo." + physical_next = physical_payload.get("next_action") or "" + physical_status_value = str(physical.get("status") or physical_payload.get("physical_status") or physical_payload.get("status") or "").strip().lower() + picking_states = { + str(p.get("state") or "").strip().lower() + for p in pickings + if isinstance(p, dict) and str(p.get("state") or "").strip() + } + whout_done = ( + bool(physical_payload.get("delivery_done")) + or physical_status_value in {"done", "shipped", "delivered", "validated"} + or (bool(picking_states) and picking_states <= {"done", "cancel"} and "done" in picking_states) + ) + whout_ready = ( + bool(physical_payload.get("ready_to_ship") or physical_payload.get("delivery_ready")) + or physical_status_value in {"ready_to_ship", "ready", "validated"} + or "assigned" in picking_states + ) + if whout_done: + physical_reason = "WH/OUT concluído no Odoo." + physical_next = "Processo pronto para conclusão quando fatura enviada e pagamento confirmado." + elif whout_ready: + physical_label = "Picking reservado — validação física pendente" + physical_reason = "Odoo assigned indica stock reservado; ainda falta confirmar fisicamente a preparação da encomenda." + physical_next = "Validar encomenda física antes de criar envio/tracking." + + notice_html = f'
    {esc(notice)}
    ' if notice else "" + error_html = f'
    Erro Odoo: {esc(error_notice)}
    ' if error_notice else "" + + sale_url = str(sale.get("external_url") or "").strip() + sale_link = f'Abrir Odoo' if sale_url else "" + no_sale_warning = "" + manual_sale_link_html = "" + if not sale: + no_sale_warning = '
    Sem venda Odoo ligada.
    Regista o número da venda Odoo (ex.: S00308) ou usa candidatos abaixo para ligar a venda correta antes de confiar no fluxo físico.
    ' + manual_sale_link_html = f''' +
    +
    Associar venda Odoo manualmente
    +
    +
    + + +
    +
    + +
    +
    Não cria nada no Odoo; apenas liga a venda já criada ao processo e sincroniza WH/OUT.
    +
    +
    + ''' + unlink_odoo_button_html = "" + if sale: + unlink_odoo_button_html = f""" +
    + +
    + """ + + picking_rows = "" + for pck in pickings[:8]: + picking_rows += f""" + + {esc(pck.get('name') or pck.get('id') or 'Entrega')}
    {esc(_odoo_m2o_label(pck.get('type')) or pck.get('origin') or '')}
    + {_odoo_status_badge(pck.get('state'))} + {esc(fmt_dt(pck.get('scheduled_date') or pck.get('date_done')))} + """ + if not picking_rows: + picking_rows = 'Sem entregas/pickings sincronizados.' + + production_rows = "" + for mo in productions[:8]: + production_rows += f""" + + {esc(mo.get('name') or mo.get('id') or 'Produção')}
    {esc(_odoo_m2o_label(mo.get('product')))}
    + {_odoo_status_badge(mo.get('state'))} + {esc(mo.get('qty') or '')} + """ + if not production_rows: + production_rows = 'Sem ordens de produção sincronizadas.' + + candidate_rows = "" + for cand in candidates: + linked_here = str(cand.get("linked_opportunity_id") or "") == str(opportunity_id) + if linked_here: + action_html = f'''
    Ligada
    ''' + else: + action_html = f""" +
    + +
    """ + candidate_rows += f""" + + {esc(cand.get('document_number') or cand.get('external_id') or 'Venda Odoo')}
    {esc(cand.get('title') or '')}
    + {money_html(cand.get('amount') or 0)}
    {esc(cand.get('currency') or 'EUR')}
    + {esc(cand.get('customer_name') or '—')}
    {esc(cand.get('customer_email') or '')}
    + {_odoo_status_badge(cand.get('status'))} + {action_html} + """ + if not candidate_rows: + candidate_rows = 'Sem vendas Odoo candidatas ligadas a esta oportunidade.' + + amount_html = money_html(sale_amount) if sale_amount not in {"", None} else "—" + details_open = "open" if candidates else "" + physical_next_html = f'
    {esc(physical_next)}
    ' if physical_next else "" + return f""" +
    +
    +
    +
    +

    Estado Odoo

    +
    Venda Odoo e entrega/WH-OUT. Ordens de fabrico ficam em detalhe técnico e não conduzem o fluxo do operador.
    +
    Última sincronização: {esc(fmt_dt(last_synced))}
    +
    +
    + {unlink_odoo_button_html} +
    + +
    + +
    +
    +
    {notice_html}{error_html}{no_sale_warning}{manual_sale_link_html}
    +
    +
    +
    Venda Odoo
    {esc(sale_name)}
    {_odoo_status_badge(sale_state)}
    {sale_link}
    +
    Cliente Odoo
    {esc(partner)}
    +
    Valor Odoo
    {amount_html}
    +
    Estado físico
    {esc(physical_label)}
    {esc(physical_reason)}
    {physical_next_html}
    +
    +
    +
    + {picking_rows}
    Entrega / pickingEstadoData
    +
    +
    + Detalhes técnicos Odoo / fabrico +
    Informativo. O fluxo do ClientFlow usa a venda Odoo e o estado da entrega/WH-OUT; ordens WH/MO não bloqueiam o fecho comercial.
    +
    + {production_rows}
    Produção / preparaçãoEstadoQtd.
    +
    +
    +
    + Vendas Odoo ligadas/candidatas +
    Associa candidatos apenas quando representam a mesma venda/processo.
    +
    {candidate_rows}
    VendaValorClienteEstadoAção
    +
    +
    +
    + """ + +def _task_href_with_return_to(task_id: str, return_to: str) -> str: + href = f"/tasks/{task_id}" + if return_to: + href += f"?return_to={quote(return_to, safe='')}" + return href + def _render_email_identity_review(opportunity_id: str, linked_customer: dict | None) -> str: try: @@ -288,13 +1309,12 @@ def _opportunity_consistency_alert_html(opportunity: dict, tasks: list[dict], op alerts = [] if has_payment_task and has_quote and not (has_proforma or has_invoice): alerts.append( - "Existe tarefa de confirmar pagamento, mas o documento Jasmin atual ainda é orçamento. " - "Antes de concluir a tarefa, confirma que o cliente recebeu pedido de pagamento/pró-forma ou que o pagamento foi efetivamente indicado." + "Existe tarefa de confirmar pagamento e o documento Jasmin atual é orçamento. " + "Isto está correto no fluxo normal BLIF: confirma pagamento com base no orçamento antes de emitir fatura." ) if stage == "WAITING_PAYMENT" and has_quote and not (has_proforma or has_invoice): alerts.append( - "A fase está em pagamento com apenas orçamento Jasmin importado. Isto pode estar correto se o cliente já aceitou/pagou, " - "mas a fase documental ainda não mostra pró-forma/fatura." + "A fase está em pagamento com apenas orçamento Jasmin importado. Isto pode estar correto: no fluxo normal, a fatura é emitida após confirmação do pagamento." ) if has_items and int(state.get("jasmin_documents") or 0) <= 0: alerts.append( @@ -352,6 +1372,52 @@ def _derived_timeline_html(opportunity_id: str) -> str: ''' return items +def _parse_opportunity_dt(value: object): + if not value: + return None + if isinstance(value, datetime): + dt = value + else: + try: + dt = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except Exception: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def _opportunity_lifecycle_state(opp: dict) -> str: + state = str(opp.get("lifecycle_state") or "active").strip().lower() or "active" + now = datetime.now(timezone.utc) + nurture_until = _parse_opportunity_dt(opp.get("nurture_until")) + next_follow_up = _parse_opportunity_dt(opp.get("next_follow_up_at")) + if state == "nurture" and nurture_until and nurture_until <= now: + return "follow_up_due" + if state in {"awaiting_customer", "active"} and next_follow_up and next_follow_up <= now: + return "follow_up_due" + return state + + +def _opportunity_last_commercial_activity(opp: dict): + for key in ("last_customer_activity_at", "last_operator_activity_at", "last_commercial_activity_at", "last_message_at"): + dt = _parse_opportunity_dt(opp.get(key)) + if dt: + return dt + return None + + +def _opportunity_inactive(opp: dict) -> bool: + if _opportunity_lifecycle_state(opp) in {"recovery", "nurture"}: + return False + last_activity = _opportunity_last_commercial_activity(opp) + attempts = int(opp.get("follow_up_attempts") or 0) + if not last_activity: + return attempts >= 2 + days = (datetime.now(timezone.utc) - last_activity).days + return days >= 10 and attempts >= 2 + + def _opportunity_query_string(q: Optional[str] = None, status: Optional[str] = "open", scope: Optional[str] = "all", limit: int = 300) -> str: parts = [] if q: @@ -365,25 +1431,80 @@ def _opportunity_query_string(q: Optional[str] = None, status: Optional[str] = " return ("?" + "&".join(parts)) if parts else "" +def _attach_central_next_actions(opportunities: list[dict]) -> None: + """Enrich board rows with the same next-action engine used by details. + + The board used to derive labels from the stored legacy stage, which made + closed-ready opportunities appear as "Enviar tracking" and WH/MO cases as + "Acompanhar produção". Keep this read-only and best-effort: if the + central engine fails for one card, the card falls back to the legacy text. + """ + for opp in opportunities: + if isinstance(opp.get("clientflow_next_action"), dict): + continue + oid = str(opp.get("id") or "").strip() + if not oid: + continue + try: + decision = get_opportunity_next_action(oid) + except Exception as exc: + decision = { + "action_code": "DECISION_ERROR", + "label": opportunity_next_action_text(opp), + "description": f"Falha ao calcular próxima ação central: {exc}", + } + if isinstance(decision, dict): + opp["clientflow_next_action"] = decision + + +def _central_next_action_for_card(opp: dict) -> dict: + decision = opp.get("clientflow_next_action") + return decision if isinstance(decision, dict) else {} + + def _opportunity_visible_set(q: Optional[str] = None, status: Optional[str] = "open", scope: Optional[str] = "all", limit: int = 300) -> tuple[list[dict], dict, list[tuple[str, str, object]]]: if (status or "open") == "closed": status = "open" opportunities = list_opportunities(q=q, status=status or "open", limit=limit) - visible_board_columns = [column for column in OPPORTUNITY_BOARD_COLUMNS if column[0] != "closed"] + _attach_central_next_actions(opportunities) + visible_board_columns = [column for column in OPPORTUNITY_BOARD_COLUMNS if column[0] not in {"closed", "archived"}] grouped = {key: [] for key, _label, _stages in visible_board_columns} visible = [] for opportunity in opportunities: if _is_noise_opportunity(opportunity): continue key = _opportunity_board_column_for_opportunity(opportunity) - if key == "closed": + if key in {"closed", "archived"}: continue if scope and scope not in {"all", "open"}: + lifecycle_state = _opportunity_lifecycle_state(opportunity) + scope_to_column = {"new": "requests", "quote": "sent", "shipment": "operations"} if scope == "blocked": pending = int(opportunity.get("pending_task_count") or 0) if pending <= 0 and not opportunity_customer_mismatch(opportunity): continue - elif key != scope: + elif scope == "active": + if lifecycle_state not in {"active", "awaiting_customer"} or _opportunity_inactive(opportunity): + continue + elif scope == "awaiting_customer": + if lifecycle_state != "awaiting_customer": + continue + elif scope == "follow_up_due": + if lifecycle_state != "follow_up_due": + continue + elif scope == "recovery": + if lifecycle_state != "recovery": + continue + elif scope == "nurture": + if lifecycle_state != "nurture": + continue + elif scope == "inactive": + if not _opportunity_inactive(opportunity): + continue + elif scope == "unvalued": + if float(opportunity.get("value_amount") or 0) > 0: + continue + elif key != scope_to_column.get(scope, scope): continue visible.append(opportunity) grouped.setdefault(key, []).append(opportunity) @@ -415,6 +1536,29 @@ def _opportunity_card_identity(opp: dict) -> tuple[str, str]: # Legacy regression context: cta_label = "Concluir tarefa pendente" if pending else "Ver oportunidade". # v4.8.5 replaces that generic CTA with a specific action label. def _opportunity_card_next_action(opp: dict) -> str: + lifecycle_state = _opportunity_lifecycle_state(opp) + pending_follow_up_action = str(opp.get("pending_follow_up_action") or "").strip() + pending_follow_up_code = str(opp.get("pending_follow_up_action_code") or "").strip() + if lifecycle_state == "recovery": + return pending_follow_up_action or "Recuperar oportunidade sem resposta" + if lifecycle_state == "follow_up_due": + return pending_follow_up_action or primary_action_label(pending_follow_up_code, fallback="Executar follow-up vencido") + if lifecycle_state == "nurture": + return pending_follow_up_action or "Aguardar data para retomar contacto" + if lifecycle_state == "awaiting_customer": + due = _parse_opportunity_dt(opp.get("next_follow_up_at")) + return f"Aguardar resposta até {due.strftime('%d/%m')}" if due else "Aguardar resposta do cliente" + metadata = opp.get("metadata") if isinstance(opp.get("metadata"), dict) else {} + if reconstructed_review_required(metadata): + return "Validar processo reconstruído" + + pending_code = canonical_action_code(opp.get("pending_primary_action_code")) + if pending_code: + return str(opp.get("pending_primary_action") or primary_action_label(pending_code, fallback="Ver tarefa pendente")) + + central = _central_next_action_for_card(opp) + if central.get("label"): + return str(central.get("label") or "") if int(opp.get("pending_task_count") or 0) > 0: action_code = str(opp.get("last_action_code") or "").strip() return primary_action_label(action_code, fallback="Ver tarefa pendente") @@ -444,17 +1588,24 @@ def _is_noise_opportunity(opp: dict) -> bool: def _opportunity_board_column_for_opportunity(opp: dict) -> str: - """Choose a visual board column from stage plus next pending action. + """Choose the visual column from the same first-safe-action precedence.""" + metadata = opp.get("metadata") if isinstance(opp.get("metadata"), dict) else {} + if reconstructed_review_required(metadata): + return "requests" - The stored stage remains unchanged. This only avoids showing opportunities - with a financial/logistics next step under the initial "Pedidos" column. - """ - action_code = str(opp.get("last_action_code") or "").upper().strip() - if int(opp.get("pending_task_count") or 0) > 0: - if action_code in {"SEND_INVOICE", "SEND_PROFORMA", "CONFIRM_PAYMENT"}: - return "payment" - if action_code in {"PREPARE_ORDER", "CREATE_SHIPMENT"}: - return "operations" + pending_code = canonical_action_code(opp.get("pending_primary_action_code")) + central_code = canonical_action_code(_central_next_action_for_card(opp).get("action_code")) + effective_code = pending_code or central_code + + if effective_code in {"SEND_INVOICE", "SEND_PROFORMA", "CONFIRM_PAYMENT", "FOLLOW_UP_PAYMENT"}: + return "payment" + if effective_code in { + "PREPARE_ORDER", "CREATE_SHIPMENT", "WAIT_PRODUCTION", "WAIT_ODOO", + "CLOSE_OPPORTUNITY", "VALIDATE_PHYSICAL_ORDER", + }: + return "operations" + if effective_code in {"ASSOCIATE_OPPORTUNITY", "REVIEW_ASSOCIATION", "LINK_DOCUMENT", "REVIEW_RECONSTRUCTED_PROCESS"}: + return "requests" return _opportunity_board_column_for_stage(opp.get("stage")) @@ -465,17 +1616,38 @@ def _render_opportunity_card(opp: dict) -> str: next_action = compact_text(_opportunity_card_next_action(opp), 72) pending = int(opp.get("pending_task_count") or 0) blockers = opportunity_blockers(opp) + lifecycle_state = _opportunity_lifecycle_state(opp) + state_label = lifecycle_label(lifecycle_state) + state_class = { + "active": "text-bg-success", + "awaiting_customer": "text-bg-info", + "follow_up_due": "text-bg-warning", + "recovery": "text-bg-danger", + "nurture": "text-bg-secondary", + }.get(lifecycle_state, "text-bg-light") + last_customer = _parse_opportunity_dt(opp.get("last_customer_activity_at") or opp.get("last_message_at")) + customer_age = "Sem atividade do cliente registada" + if last_customer: + days = max(0, (datetime.now(timezone.utc) - last_customer).days) + customer_age = "Cliente respondeu hoje" if days == 0 else f"Sem resposta do cliente há {days} dia(s)" + next_follow = _parse_opportunity_dt(opp.get("next_follow_up_at") or opp.get("nurture_until")) + follow_text = f"Próximo contacto: {next_follow.strftime('%d/%m/%Y')}" if next_follow else "Sem próximo contacto agendado" + attempts = int(opp.get("follow_up_attempts") or 0) + value = float(opp.get("value_amount") or 0) + value_text = money_html(value) if value > 0 else "Valor por definir" cta_label = next_action if pending else "Ver oportunidade" - cta_class = "btn-primary" if pending else "btn-outline-primary" + cta_class = "btn-primary" if pending or lifecycle_state in {"follow_up_due", "recovery"} else "btn-outline-primary" blocker_html = blocker_alert_html(blockers, empty_text="") if blockers else "" subtitle_html = f'
    {esc(subtitle)}
    ' if subtitle else "" blocker_class = " has-blocker" if blockers else "" return f"""
    +
    {esc(state_label)}{value_text}
    {esc(title)}
    {subtitle_html}
    {esc(subject)}
    +
    {esc(customer_age)} · {esc(follow_text)} · {attempts} tentativa(s)
    {blocker_html}
    Próxima ação @@ -540,6 +1712,10 @@ async def opportunities_page( total_pending = sum(int(opp.get("pending_task_count") or 0) for opp in visible_opportunities) total_value = sum(float(opp.get("value_amount") or 0) for opp in visible_opportunities) attention = [opp for opp in visible_opportunities if int(opp.get("pending_task_count") or 0) > 0] + active_count = sum(1 for opp in visible_opportunities if _opportunity_lifecycle_state(opp) in {"active", "awaiting_customer"} and not _opportunity_inactive(opp)) + due_count = sum(1 for opp in visible_opportunities if _opportunity_lifecycle_state(opp) == "follow_up_due") + recovery_count = sum(1 for opp in visible_opportunities if _opportunity_lifecycle_state(opp) == "recovery") + unvalued_count = sum(1 for opp in visible_opportunities if float(opp.get("value_amount") or 0) <= 0) if is_htmx(request): return HTMLResponse(render_opportunities_board_partial(q=q, status=status, scope=scope, limit=limit)) @@ -550,7 +1726,21 @@ async def opportunities_page( status_options += f'' stage_tabs = "" - filters = [("all", "Todas"), ("new", "Novas"), ("quote", "Orçamento enviado"), ("proforma", "Pró-forma enviada"), ("payment", "Pagamento pendente"), ("shipment", "Enviadas"), ("blocked", "Bloqueadas")] + filters = [ + ("all", "Todas"), + ("new", "Novas"), + ("quote", "Orçamento enviado"), + ("payment", "Pagamento pendente"), + ("active", "Ativas"), + ("awaiting_customer", "A aguardar cliente"), + ("follow_up_due", "Follow-up vencido"), + ("recovery", "Recuperação"), + ("unvalued", "Por valorizar"), + ("inactive", "Inativas"), + ("nurture", "Acompanhamento futuro"), + ("shipment", "Operação/entrega"), + ("blocked", "Bloqueadas"), + ] for key, label in filters: href = "/opportunities" + _opportunity_query_string(q=q, status=status, scope=key, limit=limit) partial_href = "/opportunities/partials/board" + _opportunity_query_string(q=q, status=status, scope=key, limit=limit) @@ -559,10 +1749,10 @@ async def opportunities_page( body = f"""
    - - - - + + + +
    Limpar
    @@ -573,7 +1763,6 @@ async def opportunities_page(

    Quadro de oportunidades

    Cards por etapa, com identificação clara, assunto, próxima ação e bloqueios relevantes. Filtros atualizam por HTMX.
    - {len(visible_opportunities)} resultado(s)
    {render_opportunities_board_partial(q=q, status=status, scope=scope, limit=limit)}
    @@ -584,6 +1773,8 @@ async def opportunities_page( @router.get("/opportunities/{opportunity_id}", response_class=HTMLResponse) async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = None): + if not is_uuid_text(opportunity_id): + return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) opportunity = get_opportunity(opportunity_id) if not opportunity: return layout("Oportunidade não encontrada", "Pipeline comercial", '
    Oportunidade não encontrada.
    ', "opportunities") @@ -591,7 +1782,25 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N tasks = list_opportunity_tasks(opportunity_id, limit=100) events = list_opportunity_events(opportunity_id, limit=100) stage = str(opportunity.get("stage") or "NEW_LEAD") - pending_tasks = [t for t in tasks if str(t.get("status")) == "pending"] + terminal_stage = str(opportunity.get("status") or "").lower() == "closed" or stage in {"WON", "LOST", "NO_INTEREST", "DELIVERED"} + all_pending_tasks = [t for t in tasks if str(t.get("status")) == "pending"] + payment_confirmed_for_ui = stage == "PAYMENT_CONFIRMED" + if terminal_stage: + pending_tasks = [ + t for t in all_pending_tasks + if str(t.get("action_code") or "").upper() not in { + "FOLLOW_UP_QUOTE", + "FOLLOW_UP_PROFORMA", + "FOLLOW_UP_PAYMENT", + "FOLLOW_UP_CUSTOMER_REVIEW", + "FOLLOW_UP_GENERIC", + "CONFIRM_DELIVERY", + "RECOVER_OPPORTUNITY", + "REVIEW_NURTURE", + } + ] + else: + pending_tasks = [t for t in all_pending_tasks if not _is_obsolete_after_payment_task(t, payment_confirmed_for_ui)] next_task = pending_tasks[0] if pending_tasks else None opportunity_items = list_opportunity_items(opportunity_id) active_products = list_products(active="true", limit=200) @@ -625,41 +1834,160 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N estimated_value = document_value or opportunity_items_total or float(opportunity.get("value_amount") or 0) value_source = "documento principal" if document_value else ("linhas atuais" if opportunity_items_total else "oportunidade") operation_snapshot = get_operation_snapshot(opportunity_id) + opportunity_for_cockpit = dict(opportunity) + opportunity_for_cockpit["pending_task_count"] = len(pending_tasks) try: opportunity_communications = list_communications_for_opportunity(opportunity_id, limit=12) except Exception: opportunity_communications = [] notice_html = f'
    {esc(notice)}
    ' if notice else '' - metadata = opportunity.get("metadata") if isinstance(opportunity.get("metadata"), dict) else {} + metadata = _opportunity_metadata(opportunity) + payment_term = str(metadata.get("payment_terms") or "before_shipping") + delivery_term = str(metadata.get("delivery_terms") or "carrier") + commercial_terms_note = str(metadata.get("commercial_terms_note") or "") + payment_term_label, delivery_term_label = _payment_terms_summary(metadata) record_mode = str(metadata.get("clientflow_record_mode") or "") legacy_mode = record_mode in {"reconstructed_invoice_review", "historical_reconstructed", "legacy_review"} legacy_notice_html = "" if legacy_mode: - legacy_notice_html = ( - '
    ' - 'Registo antigo/reconstruído.
    ' - 'A oportunidade foi normalizada a partir de documentos já existentes. ' - 'Valida pagamento, valor e linhas antes de executar novas ações.' - '
    ' - ) + review_state = reconstructed_review_status(metadata) + if review_state in {"validated", "waived"}: + legacy_notice_html = ( + '
    ' + 'Registo reconstruído validado.
    ' + 'A oportunidade foi normalizada a partir de documentos existentes e a revisão obrigatória já foi concluída.' + '
    ' + ) + elif review_state == "required": + legacy_notice_html = ( + '
    ' + 'Processo reconstruído por validar.
    ' + 'Confirma cliente, documento principal, valor e evidência de pagamento antes de executar ações sensíveis.' + '
    ' + ) + else: + legacy_notice_html = ( + '
    ' + 'Registo antigo/reconstruído sem estado explícito de revisão.
    ' + 'Executa a migração v132 para definir se a revisão está pendente ou já foi concluída.' + '
    ' + ) + + opportunity_return_to = f"/opportunities/{opportunity_id}" try: next_action = get_opportunity_next_action(opportunity_id) except Exception: next_action = {} + + lifecycle_state_for_detail = _opportunity_lifecycle_state(opportunity) + lifecycle_task = next(( + task for task in pending_tasks + if str(task.get("action_code") or "").upper() in { + "CONFIRM_DELIVERY", "FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA", + "FOLLOW_UP_PAYMENT", "FOLLOW_UP_CUSTOMER_REVIEW", "FOLLOW_UP_GENERIC", + "RECOVER_OPPORTUNITY", "REVIEW_NURTURE", + } + ), None) + lifecycle_override = False + if lifecycle_task and lifecycle_state_for_detail in {"follow_up_due", "recovery", "nurture"}: + lifecycle_override = True + next_action = { + "action_code": str(lifecycle_task.get("action_code") or "FOLLOW_UP_GENERIC"), + "label": str(lifecycle_task.get("action") or primary_action_label(lifecycle_task.get("action_code"))), + "description": str(lifecycle_task.get("note") or "Continuar acompanhamento comercial."), + "target_url": f"/tasks/{lifecycle_task.get('id')}", + "source": "lifecycle_task", + } + + if reconstructed_review_required(metadata): + review_task = next(( + task for task in pending_tasks + if str(task.get("action_code") or "").upper() == "REVIEW_RECONSTRUCTED_PROCESS" + ), None) + next_action = { + "action_code": "REVIEW_RECONSTRUCTED_PROCESS", + "label": "Validar processo reconstruído", + "description": "Confirmar cliente, documento principal, valor e evidências antes de executar a ação sensível seguinte.", + "target_url": f"/tasks/{review_task.get('id')}" if review_task else f"/opportunities/{opportunity_id}", + "source": "explicit_reconstructed_review", + } + elif not lifecycle_override and next_task: + next_action = { + "action_code": str(next_task.get("action_code") or "REVIEW_MANUALLY"), + "label": str(next_task.get("action") or primary_action_label(next_task.get("action_code"))), + "description": str(next_task.get("note") or "Executar tarefa pendente."), + "target_url": f"/tasks/{next_task.get('id')}", + "source": "pending_task", + } + + # Materialize human-only central actions into actual pending tasks. + # The top-level next action should not be an abstract label when the workbench + # expects an operator to perform it. The helper is idempotent and currently + # creates SEND_INVOICE/FOLLOW_UP_PAYMENT tasks when needed. + try: + materialized_task = ensure_pending_task_for_next_action( + opportunity_id, + next_action if isinstance(next_action, dict) else {}, + source="opportunity_detail", + actor="system", + ) + except Exception: + materialized_task = {"created": False} + if materialized_task.get("created"): + tasks = list_opportunity_tasks(opportunity_id, limit=100) + all_pending_tasks = [t for t in tasks if str(t.get("status")) == "pending"] + if terminal_stage: + pending_tasks = [ + t for t in all_pending_tasks + if str(t.get("action_code") or "").upper() not in { + "FOLLOW_UP_QUOTE", + "FOLLOW_UP_PROFORMA", + "FOLLOW_UP_PAYMENT", + "FOLLOW_UP_CUSTOMER_REVIEW", + "FOLLOW_UP_GENERIC", + } + ] + else: + pending_tasks = [t for t in all_pending_tasks if not _is_obsolete_after_payment_task(t, payment_confirmed_for_ui)] + next_task = pending_tasks[0] if pending_tasks else None + opportunity_for_cockpit["pending_task_count"] = len(pending_tasks) + try: + next_action = get_opportunity_next_action(opportunity_id) + except Exception: + pass + if isinstance(next_action, dict): + # v1.5.107: keep the legacy operational cockpit aligned with the + # central decision engine. Without this, CLOSE_OPPORTUNITY could show + # at the top while the cockpit still suggested an old SEND_INVOICE + # action from the legacy workflow plan. + opportunity_for_cockpit["clientflow_next_action"] = dict(next_action) + if next_action: primary_action = next_action.get("label") or action_label(next_action.get("action_code")) - primary_note = next_action.get("description") or "Continuar a próxima ação recomendada." + primary_note = _safe_opportunity_task_text(next_action.get("description") or "Continuar a próxima ação recomendada.") + action_code_upper = str(next_action.get("action_code") or "").upper() target_url = next_action.get("target_url") or (f"/tasks/{next_task.get('id')}" if next_task else "/tasks?status=pending") + if str(target_url).startswith("/tasks/") and "return_to=" not in str(target_url): + sep = "&" if "?" in str(target_url) else "?" + target_url = f"{target_url}{sep}return_to={quote(opportunity_return_to, safe='')}" button_label = "Abrir tarefa" if str(target_url).startswith("/tasks/") else "Continuar" - if next_action.get("action_code") == "VALIDATE_FISCAL_CUSTOMER": + if action_code_upper == "VALIDATE_FISCAL_CUSTOMER": primary_button = f'
    ' + elif action_code_upper == "CLOSE_OPPORTUNITY": + primary_button = ( + f'
    ' + '' + '' + '' + '
    ' + ) else: primary_button = f'{esc(button_label)}' elif next_task: primary_action = action_label(next_task.get("action_code")) - primary_note = next_task.get("note") or next_task.get("action") or "Abrir tarefa pendente para continuar." - primary_button = f'Abrir tarefa' + primary_note = _safe_opportunity_task_text(next_task.get("note") or next_task.get("action") or "Abrir tarefa pendente para continuar.") + primary_button = f'Abrir tarefa' else: primary_action = opportunity_next_action_text(opportunity) primary_note = "Não existe tarefa pendente ligada. Atualiza o estado ou acompanha a oportunidade." @@ -669,10 +1997,10 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N for task in tasks[:8]: task_rows += f''' - {esc(action_label(task.get('action_code')))}
    {esc(compact_text(task.get('note') or task.get('action') or '', 70))}
    + {esc(action_label(task.get('action_code')))}
    {esc(compact_text(_safe_opportunity_task_text(task.get('note') or task.get('action') or ''), 70))}
    {route_badge(task.get('route'))} {status_badge(task.get('status'))} - {esc(fmt_dt(task.get('created_at')))} + {esc(fmt_dt(task.get('due_at') or task.get('created_at')))} ''' if not task_rows: @@ -724,10 +2052,9 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N if not timeline_items: timeline_items = '
    Sem eventos registados.
    ' - stage_options = "" - for value, label in OPPORTUNITY_STAGE_LABELS.items(): - selected = "selected" if value == opportunity.get("stage") else "" - stage_options += f'' + # Mostrar poucas fases comerciais. Estados financeiros/Odoo/envio continuam + # visíveis como evidência derivada, mas deixam de dominar o dropdown. + stage_options = _commercial_stage_options_html(stage) customer_name = opportunity_customer_name(opportunity) contact_name = opportunity_contact_name(opportunity) @@ -752,6 +2079,11 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N fiscal_suggestions_html = _render_fiscal_suggestions(opportunity_id, linked_customer) email_identity_html = _render_email_identity_review(opportunity_id, linked_customer) + try: + from app.jasmin_fiscal_sync_service import get_jasmin_fiscal_sync_preview + jasmin_fiscal_preview = get_jasmin_fiscal_sync_preview(opportunity_id) + except Exception: + jasmin_fiscal_preview = {"available": False} fiscal_customer = opportunity_context_customer(opportunity, linked_customer) fiscal_customer_href = f"/customers/{esc(fiscal_customer.get('id'))}" if fiscal_customer and fiscal_customer.get("id") else "" @@ -782,7 +2114,7 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N fiscal_readiness_html = readiness_checklist_html( title="Prontidão para documentos", missing=fiscal_customer_missing_fields(fiscal_customer), - ok_text="Cliente fiscal pronto para orçamento, pró-forma ou fatura.", + ok_text="Cliente fiscal pronto para orçamento ou fatura.", blocked_text=("Dados fiscais incompletos no ClientFlow; rever para próximos documentos." if document_already_issued else "Dados fiscais incompletos no ClientFlow; rever antes de emitir novo documento."), ) shipment_readiness_html = readiness_checklist_html( @@ -796,7 +2128,7 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N document_label = commercial_document_display_number(primary_document, fallback="número por atualizar") document_kind = { "quotation": "Orçamento", - "proforma": "Pró-forma", + "proforma": "Orçamento legado", "invoice": "Fatura", }.get(str(primary_document.get("document_kind") or ""), "Documento") document_state = f"{document_kind} · {document_label}" @@ -805,9 +2137,20 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N document_state = "Sem documento principal" document_chip = 'pendente' fiscal_state = (linked_customer.get("name") if linked_customer else "Por associar") - fiscal_chip = 'validado' if linked_customer else 'bloqueia documentos' + fiscal_missing_for_chip = fiscal_customer_missing_fields(fiscal_customer) if linked_customer else [] + if linked_customer and not fiscal_missing_for_chip: + fiscal_chip = 'OK' + elif linked_customer: + fiscal_chip = 'associado · incompleto' + else: + fiscal_chip = 'sem cliente' task_state = f"{len(pending_tasks)} pendente(s)" if pending_tasks else "Sem tarefas pendentes" task_chip = 'requer ação' if pending_tasks else 'limpo' + display_next_action_code = str((next_action.get('action_code') if isinstance(next_action, dict) else None) or (next_task.get('action_code') if next_task else None) or opportunity.get('last_action_code') or 'FOLLOW_UP').upper() + if display_next_action_code in {'WAIT_PRODUCTION', 'WAIT_ODOO'} and stage in {'READY_TO_SHIP', 'SHIPMENT_CREATED'}: + display_next_action_code = 'SHIP_ORDER' + if isinstance(next_action, dict) and str(next_action.get('action_code') or '').upper() == 'SEND_INVOICE': + display_next_action_code = 'SEND_INVOICE' operator_summary_html = f'''
    @@ -819,7 +2162,7 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N
    Cliente fiscal{esc(fiscal_state)}{fiscal_chip}
    Documento principal{esc(document_state)}{document_chip}
    Tasks{esc(task_state)}{task_chip}
    -
    Decisão seguinte{esc(primary_action)}{esc(next_action.get('action_code') or opportunity.get('last_action_code') or 'FOLLOW_UP')}
    +
    Decisão seguinte{esc(primary_action)}{esc(display_next_action_code)}
    Ações avançadas @@ -833,6 +2176,142 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N
    ''' + manual_follow_up_html = f''' +
    +

    Criar follow-up

    +
    Agenda uma tarefa de follow-up. O sistema não cria confirmações de entrega automaticamente; usa “Verificar entrega” apenas quando o histórico sugere um problema real.
    +
    + + + + +
    +
    + ''' + + lifecycle_state = _opportunity_lifecycle_state(opportunity) + lifecycle_state_label = lifecycle_label(lifecycle_state) + last_customer_dt = _parse_opportunity_dt(opportunity.get("last_customer_activity_at") or opportunity.get("last_message_at")) + last_operator_dt = _parse_opportunity_dt(opportunity.get("last_operator_activity_at")) + next_follow_dt = _parse_opportunity_dt(opportunity.get("next_follow_up_at") or opportunity.get("nurture_until")) + lifecycle_summary = [ + f"Última atividade do cliente: {last_customer_dt.strftime('%d/%m/%Y %H:%M') if last_customer_dt else 'sem registo'}", + f"Último contacto do operador: {last_operator_dt.strftime('%d/%m/%Y %H:%M') if last_operator_dt else 'sem registo'}", + f"Próximo contacto: {next_follow_dt.strftime('%d/%m/%Y') if next_follow_dt else 'não agendado'}", + f"Tentativas: {int(opportunity.get('follow_up_attempts') or 0)}", + f"Entrega da última comunicação: {str(opportunity.get('last_delivery_status') or 'não verificada')}", + ] + loss_reason_options = ''.join( + f'' + for code, label in LOSS_REASON_LABELS.items() + if code != "future_timing" + ) + lifecycle_management_html = f''' +
    +
    +

    Atividade comercial

    Controla espera, recuperação, acompanhamento futuro e perda sem usar updated_at técnico como sinal de atividade.
    + {esc(lifecycle_state_label)} +
    +
      {''.join(f'
    • {esc(item)}
    • ' for item in lifecycle_summary)}
    +
    +
    +
    + + + + + +
    +
    +
    +
    + + + + +
    +
    +
    +
    + ''' + + correction_state = _opportunity_manual_correction_state(opportunity_id) + if correction_state.get("unavailable"): + correction_badge_html = """ +
    + Ligações atuais temporariamente indisponíveis por sincronização/reconciliação em curso. Reabre esta secção dentro de segundos se precisares de corrigir associações. +
    + """ + else: + correction_badge_html = f""" +
    + Ligações atuais: Odoo {esc(correction_state.get('odoo_links', 0))} · Jasmin docs {esc(correction_state.get('jasmin_documents', 0))} · linhas importadas {esc(correction_state.get('imported_lines', 0))} · candidatos ligados {esc(correction_state.get('reconciliation_items', 0))} +
    + """ + correction_stage_options = "" + for value in ["INFO_SENT", "INFO_REQUESTED", "QUOTE_REQUESTED", "QUOTE_SENT", "REVIEW", "NO_INTEREST", "LOST"]: + label = OPPORTUNITY_STAGE_LABELS.get(value, value) + selected = "selected" if value == "INFO_SENT" else "" + correction_stage_options += f'' + manual_correction_html = f""" +
    + + Correção avançada de associação operacional Corrigir associação operacional +
    Abrir apenas quando Odoo/Jasmin foram associados ao processo errado.
    + {correction_badge_html} +
    +
    +
    Zona sensível: não altera Odoo/Jasmin; só limpa a leitura local no ClientFlow e regista auditoria.
    +
    + + + + + + + +
    +
    +
    + """ + + archive_spam_state = _opportunity_archive_spam_state(opportunity_id) + archive_spam_html = "" + if archive_spam_state.get("can_archive"): + archive_hint = "Existe evidência de spam nesta oportunidade." if archive_spam_state.get("has_spam_evidence") else "Usa apenas para falso positivo/spam sem documentos nem Odoo/Jasmin." + archive_spam_html = f""" +
    + + Arquivar spam/falso positivo +
    Exclui esta oportunidade do funil sem contar como perdida. Mantém auditoria.
    +
    +
    +
    {esc(archive_hint)} A ação é recusada se houver documentos Jasmin, Odoo, Packlink ou reconciliação externa ligada.
    +
    + + +
    +
    +
    + """ + technical_html = f'''
    ID
    {esc(opportunity_id)}
    @@ -842,28 +2321,194 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N
    ''' - # "Bloqueios atuais" permanece como conceito de UI/teste, mas o título duplicado foi removido. + # Tarefa ativa folded into "O que fazer agora?" to avoid duplicate cards like + # "Enviar orçamento" appearing twice in the Operation column. Legacy static + # tests still look for the label "Tarefa ativa" to guard the old refresh flow. + next_task_focus_html = "" + payment_term_hint = "" + if payment_term == "after_delivery": + payment_term_hint = '
    Pagamento pós-entrega: preparação/envio podem avançar com encomenda confirmada; depois acompanhar fatura/pagamento.
    ' + elif payment_term == "before_shipping": + payment_term_hint = '
    Pagamento antes do envio: confirmar pagamento com base no orçamento; emitir fatura só depois do pagamento confirmado.
    ' + finance_quick_card_html = _finance_quick_card_html(opportunity_id, linked_documents, payment_term, payment_term_label) + + commercial_terms_card_html = f''' +
    +
    +

    Condições comerciais

    +
    Define a regra do processo sem forçar um fluxo único. Fluxo normal BLIF: orçamento → pagamento → fatura → preparar/enviar encomenda.
    +
    + + + + + + {payment_term_hint} + +
    +
    +
    + ''' + stage_control_html = f''' +
    +
    +

    Alterar fase comercial

    +
    Lista curta: detalhes como fatura, pagamento, Odoo, produção e envio devem ser lidos nos cards de contexto.
    +
    + + + +
    +
    +
    + ''' + operation_action_html = f''' +
    +
    +
    O que fazer agora?
    +

    {esc(primary_action)}

    +
    {esc(primary_note)}
    +
    {primary_button}
    +
    +
    + ''' + + jasmin_fiscal_sync_html = "" + if isinstance(jasmin_fiscal_preview, dict) and jasmin_fiscal_preview.get("available"): + candidate = jasmin_fiscal_preview.get("candidate") or {} + document = jasmin_fiscal_preview.get("document") or {} + candidate_line = f"{candidate.get('name') or 'Cliente Jasmin'} · NIF {candidate.get('tax_id') or '—'}" + doc_line = " · ".join(str(x) for x in [document.get('document_number'), document.get('document_kind')] if x) + fillable = jasmin_fiscal_preview.get("fillable_fields") or [] + if jasmin_fiscal_preview.get("conflict"): + jasmin_fiscal_sync_html = ( + '
    ' + 'Dados Jasmin encontrados, mas a importação está bloqueada por NIF divergente. ' + 'Revê a associação fiscal antes de importar.
    ' + ) + else: + jasmin_sync_button_label = "Completar com dados Jasmin" if linked_customer else "Associar e completar com Jasmin" + fillable_text = ("Campos a preencher: " + ", ".join(str(x) for x in fillable)) if fillable else "Jasmin encontrado, mas não contém novos campos; associa cliente fiscal com base no documento Jasmin." + jasmin_fiscal_sync_html = ( + '
    ' + '
    Dados fiscais disponíveis no Jasmin
    ' + f'
    {esc(candidate_line)}
    ' + f'
    {esc(doc_line or "documento Jasmin associado")}
    ' + f'
    {esc(fillable_text)}
    ' + f'
    ' + f'' + '
    ' + ) + + if linked_customer: + linked_customer_label = f"{linked_customer.get('name') or 'Cliente'} · {linked_customer.get('tax_id') or 'sem NIF'}" + linked_customer_email = linked_customer.get("email") or "—" + linked_customer_address_parts = [ + linked_customer.get("street_name"), + linked_customer.get("postal_zone"), + linked_customer.get("city_name"), + ] + linked_customer_address = " · ".join(str(part) for part in linked_customer_address_parts if part) or "morada fiscal incompleta" + fiscal_missing_inline = fiscal_customer_missing_fields(fiscal_customer) + fiscal_status_badges = ( + 'associadodados OK' + if not fiscal_missing_inline + else 'associadodados incompletos' + ) + fiscal_missing_note = "" + if fiscal_missing_inline: + fiscal_missing_note = '
    Faltam: ' + esc(", ".join(fiscal_missing_inline)) + '.
    ' + fiscal_customer_url = f"/customers/{esc(linked_customer.get('id'))}" if linked_customer.get("id") else "/customers" + fiscal_association_card_html = f''' +
    +
    +
    +
    +

    Cliente fiscal

    +
    Ficha fiscal associada à oportunidade. Associar cliente fiscal
    +
    +
    {fiscal_status_badges}
    +
    +
    +
    {esc(linked_customer.get('name') or 'Cliente fiscal associado')}
    +
    NIF {esc(linked_customer.get('tax_id') or '—')} · {esc(linked_customer_email)}
    +
    {esc(linked_customer_address)}
    + {fiscal_missing_note} +
    + {jasmin_fiscal_sync_html} +
    + Ver ficha + Alterar/pesquisar +
    + + +
    +
    +
    Sugestões de identidade continuam disponíveis na secção de contexto quando houver candidatos por validar.
    +
    +
    + ''' + else: + fiscal_association_card_html = f''' +
    +
    +

    Associar cliente fiscal

    +
    Resolve o bloqueio fiscal desta oportunidade. Escolhe uma ficha existente ou deixa vazio para desassociar.
    +
    + + +
    + {jasmin_fiscal_sync_html} + +
    + Sugestões e identidade extraída +
    {email_identity_html}{fiscal_suggestions_html}
    +
    +
    +
    + ''' + + # "Bloqueios atuais" permanece como conceito de UI/teste, mas o layout agora separa operação e contexto. body = f''' ← Voltar a oportunidades @@ -871,26 +2516,49 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N {legacy_notice_html} {customer_mismatch_alert} {consistency_alert_html} - + +
    +
    +
    +
    Oportunidade
    +

    {esc(opportunity.get('title') or 'Oportunidade')}

    +
    {esc(customer_name)} · {esc(opportunity.get('product_interest') or 'Interesse por definir')}
    +
    +
    {opportunity_stage_badge(stage)}{opportunity_priority_chip(opportunity)}
    +
    +
    +
    Próxima ação{esc(primary_action)}
    +
    Cliente fiscal{esc(fiscal_state)}
    +
    Documento{esc(document_state)}
    +
    Valor{money_html(estimated_value)}
    +
    Tasks{esc(task_state)}
    +
    +
    + +
    -
    -
    Oportunidade

    {esc(opportunity.get('title') or 'Oportunidade')}

    {esc(customer_name)} · {esc(opportunity.get('product_interest') or 'Interesse por definir')}
    {opportunity_stage_badge(stage)}{opportunity_priority_chip(opportunity)}
    Próxima ação

    {esc(primary_action)}

    {esc(primary_note)}
    {primary_button}
    - + + - {fiscal_contact_html} +
    +
    Contexto e evidência
    + +
    {fiscal_contact_html}
    {fiscal_readiness_html}
    {shipment_readiness_html}
    @@ -898,15 +2566,17 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N

    Pipeline

    {stage_progress_html(stage)}
    - {operation_cockpit_html(opportunity_id, opportunity, operation_snapshot)} - -
    {opportunity_integrations_panel_html(opportunity_id)}
    + {operation_cockpit_html(opportunity_id, opportunity_for_cockpit, operation_snapshot)}
    {jasmin_documents_html(opportunity_id)}
    {opportunity_products_panel_html(opportunity_id)}
    -

    Tasks relacionadas

    Ações humanas já criadas para esta oportunidade.
    {task_rows}
    AçãoFilaEstadoCriada
    +
    {odoo_status_panel_html(opportunity_id)}
    + +
    {opportunity_integrations_panel_html(opportunity_id)}
    + +

    Tasks relacionadas

    Ações humanas já criadas para esta oportunidade.
    {task_rows}
    AçãoFilaEstadoData

    Mensagens Chatwoot

    Mensagens relevantes ligadas a esta oportunidade. A resposta continua no Chatwoot.
    {communication_rows}
    MensagemClassificaçãoEstadoRecebida
    @@ -914,38 +2584,89 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N
    Ver detalhes técnicos e edição avançada
    {technical_html}
    - -
    ''' return layout(str(opportunity.get("title") or "Oportunidade"), "Detalhe comercial com informação essencial", body, "opportunities") +@router.get("/opportunities/{opportunity_id}/partials/odoo-status", response_class=HTMLResponse) +async def opportunity_odoo_status_partial(opportunity_id: str): + if not is_uuid_text(opportunity_id): + return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) + return HTMLResponse(odoo_status_panel_html(opportunity_id)) + + @router.get("/opportunities/{opportunity_id}/partials/jasmin-documents", response_class=HTMLResponse) async def opportunity_jasmin_documents_partial(opportunity_id: str): + if not is_uuid_text(opportunity_id): + return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) return HTMLResponse(jasmin_documents_html(opportunity_id)) @router.get("/opportunities/{opportunity_id}/partials/products", response_class=HTMLResponse) async def opportunity_products_partial(opportunity_id: str): + if not is_uuid_text(opportunity_id): + return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) return HTMLResponse(opportunity_products_panel_html(opportunity_id)) + +@router.post("/commercial-documents/{document_id}/unlink-from-opportunity") +async def commercial_document_unlink_from_opportunity_action(document_id: str, request: Request): + form = await request.form() + opportunity_id = str(form.get("opportunity_id") or "").strip() + remove_lines = str(form.get("remove_imported_lines") or "1") == "1" + note = str(form.get("note") or "").strip() or "Documento removido manualmente desta oportunidade; pertence a outra compra/processo." + if not is_uuid_text(opportunity_id) or not is_uuid_text(document_id): + return PlainTextResponse("Identificador inválido.", status_code=422) + try: + result = unlink_commercial_document_from_opportunity( + opportunity_id, + document_id, + remove_imported_lines=remove_lines, + note=note, + actor="operator_ui_document_unlink", + ) + except Exception as exc: + if request.headers.get("hx-request"): + return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao desassociar documento: {exc}"), status_code=409) + return PlainTextResponse(f"Erro ao desassociar documento: {exc}", status_code=500) + notice = ( + "Documento desassociado desta oportunidade. " + f"Linhas importadas removidas: {result.get('imported_lines_deleted', 0)}." + ) + if request.headers.get("hx-request"): + return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice)) + return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303) + + +@router.post("/commercial-documents/{document_id}/role") +async def commercial_document_role_action(document_id: str, request: Request): + form = await request.form() + opportunity_id = str(form.get("opportunity_id") or "").strip() + role = str(form.get("role") or "current").strip().lower() + make_primary = str(form.get("make_primary") or "1") == "1" + if not is_uuid_text(opportunity_id) or not is_uuid_text(document_id): + return PlainTextResponse("Identificador inválido.", status_code=422) + try: + result = set_commercial_document_role_for_opportunity( + opportunity_id, + document_id, + role=role, + make_primary=make_primary, + actor="operator_ui_document_role", + ) + except Exception as exc: + if request.headers.get("hx-request"): + return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao atualizar papel do documento: {exc}"), status_code=409) + return PlainTextResponse(f"Erro ao atualizar papel do documento: {exc}", status_code=500) + label = {"current": "atual", "accepted": "aceite", "related": "relacionado", "historical": "histórico"}.get(result.get("role"), role) + notice = f"Documento marcado como {label}." + if request.headers.get("hx-request"): + return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice)) + return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303) + + @router.post("/commercial-documents/{document_id}/refresh") async def commercial_document_refresh(document_id: str, request: Request): form = await request.form() @@ -975,13 +2696,318 @@ async def commercial_document_pdf(document_id: str): return Response(content=data, media_type=content_type or "application/pdf", headers=headers) + +@router.post("/opportunities/{opportunity_id}/archive-spam") +async def opportunity_archive_spam_action(opportunity_id: str, request: Request): + if not is_uuid_text(opportunity_id): + return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) + form = await request.form() + reason = str(form.get("reason") or "spam/falso positivo").strip() + try: + from app.opportunity_service import archive_spam_opportunity_if_safe + + result = archive_spam_opportunity_if_safe( + opportunity_id, + reason=reason or "spam/falso positivo", + actor="operator_ui_archive_spam", + ) + except Exception as exc: + notice = quote(f"Não foi possível arquivar spam: {exc}") + return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303) + if not result.get("ok"): + msg = result.get("reason") or "não permitido" + if msg == "has_commercial_or_external_evidence": + msg = "A oportunidade tem documentos ou ligações externas; não foi arquivada automaticamente." + notice = quote(f"Arquivar spam recusado: {msg}") + return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303) + notice = quote("Oportunidade arquivada como spam/falso positivo e excluída do funil.") + return RedirectResponse(f"/opportunities?notice={notice}", status_code=303) + + +@router.post("/opportunities/{opportunity_id}/manual-correction") +async def opportunity_manual_correction_action(opportunity_id: str, request: Request): + if not is_uuid_text(opportunity_id): + return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) + form = await request.form() + unlink_odoo = str(form.get("unlink_odoo") or "") == "1" + unlink_jasmin = str(form.get("unlink_jasmin") or "") == "1" + remove_imported_lines = str(form.get("remove_imported_lines") or "") == "1" + stage = str(form.get("stage") or "INFO_SENT").strip().upper() + note = str(form.get("note") or "").strip() or "Correção manual: Odoo/Jasmin pertenciam a outro processo; classificado como informação enviada." + try: + result = apply_manual_external_correction( + opportunity_id, + unlink_odoo=unlink_odoo, + unlink_jasmin=unlink_jasmin, + remove_imported_lines=remove_imported_lines, + new_stage=stage, + note=note, + actor="operator_ui_manual_correction", + ) + except Exception as exc: + if is_htmx(request): + return PlainTextResponse(f"Erro na correção manual: {exc}", status_code=409) + return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote('Erro na correção manual: ' + str(exc))}", status_code=303) + notice = ( + "Correção aplicada: " + f"Odoo/Jasmin desligados; {result.get('imported_lines_deleted', 0)} linha(s) importada(s) removida(s); " + f"fase definida como {OPPORTUNITY_STAGE_LABELS.get(stage, stage)}." + ) + return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303) + + +@router.post("/opportunities/{opportunity_id}/odoo/unlink") +async def opportunity_odoo_unlink_action(opportunity_id: str, request: Request): + try: + result = apply_manual_external_correction( + opportunity_id, + unlink_odoo=True, + unlink_jasmin=False, + remove_imported_lines=True, + new_stage="INFO_SENT", + note="Correção manual: venda Odoo desassociada da oportunidade.", + actor="operator_ui_odoo_unlink", + ) + if request.headers.get("hx-request"): + return HTMLResponse(odoo_status_panel_html(opportunity_id, notice=f"Odoo desassociado. Linhas removidas: {result.get('imported_lines_deleted', 0)}")) + except Exception as exc: + if request.headers.get("hx-request"): + return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=f"Erro ao desassociar Odoo: {exc}"), status_code=409) + return PlainTextResponse(f"Erro ao desassociar Odoo: {exc}", status_code=500) + return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Odoo%20desassociado", status_code=303) + + +@router.post("/opportunities/{opportunity_id}/jasmin/unlink") +async def opportunity_jasmin_unlink_action(opportunity_id: str, request: Request): + try: + result = apply_manual_external_correction( + opportunity_id, + unlink_odoo=False, + unlink_jasmin=True, + remove_imported_lines=True, + new_stage="INFO_SENT", + note="Correção manual: documentos/candidatos Jasmin desassociados da oportunidade.", + actor="operator_ui_jasmin_unlink", + ) + if request.headers.get("hx-request"): + return HTMLResponse(jasmin_documents_html(opportunity_id, notice=f"Jasmin desassociado. Documentos removidos: {result.get('jasmin_documents_deleted', 0)} · linhas removidas: {result.get('imported_lines_deleted', 0)}")) + except Exception as exc: + if request.headers.get("hx-request"): + return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao desassociar Jasmin: {exc}"), status_code=409) + return PlainTextResponse(f"Erro ao desassociar Jasmin: {exc}", status_code=500) + return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Jasmin%20desassociado", status_code=303) + + +@router.post("/opportunities/{opportunity_id}/external-candidate/{item_id}/ignore") +async def opportunity_ignore_external_candidate_action(opportunity_id: str, item_id: str, request: Request): + source_system = "" + try: + with engine.begin() as conn: + source_system = str(conn.execute(text(""" + SELECT source_system FROM reconciliation_items WHERE id = CAST(:item_id AS UUID) + """), {"item_id": item_id}).scalar() or "") + count = ignore_external_candidate_for_opportunity(opportunity_id, item_id) + except Exception as exc: + if request.headers.get("hx-request"): + if source_system == "odoo": + return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=f"Erro ao ignorar candidato: {exc}"), status_code=409) + return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao ignorar candidato: {exc}"), status_code=409) + return PlainTextResponse(f"Erro ao ignorar candidato: {exc}", status_code=500) + notice = "Candidato ignorado." if count else "Candidato não encontrado ou já ignorado." + if request.headers.get("hx-request"): + if source_system == "odoo": + return HTMLResponse(odoo_status_panel_html(opportunity_id, notice=notice)) + return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice)) + return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303) + + +@router.post("/opportunities/{opportunity_id}/follow-up") +async def create_opportunity_follow_up_action(opportunity_id: str, request: Request): + if not is_uuid_text(opportunity_id): + return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) + form = await request.form() + follow_up_type = str(form.get("follow_up_type") or "generic").strip() + note = str(form.get("note") or "").strip() + try: + delay_days = int(str(form.get("delay_days") or "3")) + except Exception: + delay_days = 3 + try: + from app.followup_service import create_manual_follow_up_for_opportunity + + result = create_manual_follow_up_for_opportunity( + opportunity_id=opportunity_id, + follow_up_type=follow_up_type, + delay_days=delay_days, + note=note, + created_by="operator", + ) + notice = "Follow-up agendado." if result.get("ok") else "Não foi possível agendar follow-up." + except Exception as exc: + notice = f"Erro ao agendar follow-up: {exc}" + return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303) + + +@router.post("/opportunities/{opportunity_id}/commercial-terms") +async def update_opportunity_commercial_terms_action(opportunity_id: str, request: Request): + if not is_uuid_text(opportunity_id): + return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) + form = await request.form() + payment_terms = str(form.get("payment_terms") or "before_shipping").strip() + delivery_terms = str(form.get("delivery_terms") or "carrier").strip() + note = str(form.get("note") or "").strip() + if payment_terms not in PAYMENT_TERM_LABELS: + return PlainTextResponse("Condição de pagamento inválida.", status_code=422) + if delivery_terms not in DELIVERY_TERM_LABELS: + return PlainTextResponse("Condição de entrega inválida.", status_code=422) + payload = { + "payment_terms": payment_terms, + "delivery_terms": delivery_terms, + "commercial_terms_note": note, + "commercial_terms_updated_by": "operator", + } + try: + with engine.begin() as conn: + exists = conn.execute(text(""" + SELECT 1 FROM opportunities WHERE id = CAST(:opportunity_id AS UUID) LIMIT 1 + """), {"opportunity_id": opportunity_id}).scalar() + if not exists: + return PlainTextResponse("Oportunidade não encontrada.", status_code=404) + conn.execute(text(""" + UPDATE opportunities + SET metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:payload AS JSONB), + updated_at = now() + WHERE id = CAST(:opportunity_id AS UUID) + """), { + "opportunity_id": opportunity_id, + "payload": _json_payload(payload), + }) + except Exception as exc: + notice = quote(f"Não foi possível guardar condições comerciais: {exc}") + return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303) + notice = quote("Condições comerciais guardadas.") + return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303) + + +@router.post("/opportunities/{opportunity_id}/lifecycle") +async def update_opportunity_lifecycle_action(opportunity_id: str, request: Request): + if not is_uuid_text(opportunity_id): + return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) + form = await request.form() + state = str(form.get("state") or "active").strip().lower() + nurture_until = str(form.get("nurture_until") or "").strip() + reason = str(form.get("reason") or "").strip() + if state == "nurture" and not nurture_until: + return PlainTextResponse("Indica a data para retomar o contacto.", status_code=422) + try: + changed = set_opportunity_lifecycle( + opportunity_id, + state, + nurture_until=nurture_until or None, + reason=reason, + created_by="operator", + ) + except ValueError as exc: + return PlainTextResponse(str(exc), status_code=422) + except Exception as exc: + notice = quote(f"Não foi possível alterar o estado operacional: {exc}") + return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303) + if not changed: + return PlainTextResponse("Oportunidade não encontrada.", status_code=404) + try: + from app.followup_service import cancel_pending_followups_for_opportunity, create_follow_up_task + cancel_pending_followups_for_opportunity( + opportunity_id=opportunity_id, + reason=f"manual_lifecycle_change:{state}", + created_by="operator", + ) + if state == "recovery": + create_follow_up_task( + opportunity_id=opportunity_id, + action_code="RECOVER_OPPORTUNITY", + route="vendas", + action="Recuperar oportunidade sem resposta", + note=reason or "Rever canal, abordagem, timing e decidir entre nova tentativa, acompanhamento futuro ou perda.", + reason="MANUAL_RECOVERY", + delay_days=1, + created_by="operator", + idempotency_suffix=f"manual-recovery:{time.time_ns()}", + follow_up_family="generic", + follow_up_stage=99, + follow_up_max_stage=99, + cascade=False, + contact_purpose="recovery_review", + ) + elif state == "nurture": + create_follow_up_task( + opportunity_id=opportunity_id, + action_code="REVIEW_NURTURE", + route="vendas", + action="Rever oportunidade em acompanhamento futuro", + note=reason or "Retomar contacto na data acordada ou rever se o timing continua válido.", + reason="NURTURE_REVIEW", + delay_days=1, + created_by="operator", + idempotency_suffix=f"nurture:{nurture_until}:{time.time_ns()}", + follow_up_family="generic", + follow_up_stage=99, + follow_up_max_stage=99, + cascade=False, + contact_purpose="nurture_review", + due_at_override=_parse_opportunity_dt(nurture_until), + ) + except Exception: + pass + notice = quote(f"Estado operacional alterado para {lifecycle_label(state)}.") + return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303) + + +@router.post("/opportunities/{opportunity_id}/lost") +async def mark_opportunity_lost_action(opportunity_id: str, request: Request): + if not is_uuid_text(opportunity_id): + return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) + form = await request.form() + reason_code = str(form.get("reason_code") or "").strip().lower() + note = str(form.get("note") or "").strip() + try: + changed = mark_opportunity_lost( + opportunity_id, + reason_code=reason_code, + note=note, + created_by="operator", + ) + except ValueError as exc: + return PlainTextResponse(str(exc), status_code=422) + except Exception as exc: + notice = quote(f"Não foi possível marcar como perdida: {exc}") + return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303) + if not changed: + return PlainTextResponse("Oportunidade não encontrada.", status_code=404) + return RedirectResponse("/opportunities?status=open&scope=recovery", status_code=303) + + @router.post("/opportunities/{opportunity_id}/stage") async def update_opportunity_stage_action(opportunity_id: str, request: Request): + if not is_uuid_text(opportunity_id): + return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) form = await request.form() - stage = str(form.get("stage") or "").strip() + stage = str(form.get("stage") or "").strip().upper() + # Backwards-compatible alias used by older UI/tests. + # The canonical ClientFlow stage is NEW_LEAD. + stage_aliases = {"NEW": "NEW_LEAD"} + stage = stage_aliases.get(stage, stage) note = str(form.get("note") or "").strip() - if stage: + if not stage or stage not in OPPORTUNITY_STAGE_LABELS: + return PlainTextResponse("Fase de oportunidade inválida.", status_code=422) + if stage in {"LOST", "NO_INTEREST"}: + return PlainTextResponse("Usa a ação 'Fechar como perdida' e indica o motivo.", status_code=422) + try: set_opportunity_stage(opportunity_id, stage, note=note, created_by="operator") + except ValueError as exc: + return PlainTextResponse(f"Transição de fase inválida: {exc}", status_code=409) + except Exception as exc: + notice = quote(f"Não foi possível alterar fase: {exc}") + return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303) return RedirectResponse(f"/opportunities/{opportunity_id}", status_code=303) @@ -1092,6 +3118,21 @@ async def fiscal_suggestion_reject_action(suggestion_id: str, request: Request): return RedirectResponse(referer, status_code=303) + +@router.post("/opportunities/{opportunity_id}/jasmin/complete-fiscal") +async def opportunity_jasmin_complete_fiscal_action(opportunity_id: str, request: Request): + try: + from app.jasmin_fiscal_sync_service import apply_jasmin_fiscal_sync + result = apply_jasmin_fiscal_sync(opportunity_id, actor="operator_ui_jasmin_fiscal_sync") + filled = result.get("filled_fields") or [] + if filled: + notice = "Dados fiscais completados com Jasmin: " + ", ".join(str(x) for x in filled) + else: + notice = "Cliente fiscal associado/completado com dados Jasmin." + except Exception as exc: + notice = "Erro ao completar dados fiscais com Jasmin: " + str(exc) + return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303) + @router.post("/opportunities/{opportunity_id}/jasmin/sync-candidates") async def opportunity_jasmin_sync_candidates_action(opportunity_id: str, request: Request): try: @@ -1215,7 +3256,8 @@ async def opportunity_jasmin_create_quotation(opportunity_id: str, request: Requ print(f"ClientFlow Jasmin create quotation failed: {exc}", flush=True) if is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=str(exc)), status_code=409) - return PlainTextResponse(f"Erro ao criar pedido de orçamento Jasmin: {exc}", status_code=500) + notice = quote(f"Não foi possível criar orçamento Jasmin: {exc}") + return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303) if is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, notice="Pedido de orçamento enviado para a outbox Jasmin.")) return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Pedido%20de%20or%C3%A7amento%20enviado%20para%20a%20outbox%20Jasmin", status_code=303) @@ -1233,7 +3275,8 @@ async def opportunity_jasmin_convert_invoice(opportunity_id: str, request: Reque print(f"ClientFlow Jasmin convert invoice failed: {exc}", flush=True) if is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=str(exc)), status_code=409) - return PlainTextResponse(f"Erro ao criar pedido de fatura Jasmin: {exc}", status_code=500) + notice = quote(f"Não foi possível criar fatura Jasmin: {exc}") + return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303) if is_htmx(request): return HTMLResponse(jasmin_documents_html(opportunity_id, notice="Pedido de fatura enviado para a outbox Jasmin.")) return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Pedido%20de%20fatura%20enviado%20para%20a%20outbox%20Jasmin", status_code=303) @@ -1241,6 +3284,15 @@ async def opportunity_jasmin_convert_invoice(opportunity_id: str, request: Reque @router.post("/opportunities/{opportunity_id}/operations/{action_key}") async def opportunity_operation_action(opportunity_id: str, action_key: str, request: Request): + if not is_uuid_text(opportunity_id): + return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) + # Accept legacy/semantic action names used by older UI buttons and E2E audits. + action_aliases = { + "prepare_order": "odoo_sale_order", + "prepare_shipping": "packlink_shipment", + "send_followup": "tracking_sent", + } + action_key = action_aliases.get(str(action_key or ""), str(action_key or "")) form = await request.form() external_id = str(form.get("external_id") or "").strip() external_name = str(form.get("external_name") or form.get("external_ref") or form.get("title") or "").strip() @@ -1264,19 +3316,110 @@ async def opportunity_operation_action(opportunity_id: str, action_key: str, req else: register_operation_action(opportunity_id, action_key, external_id=external_id, external_name=external_name, external_url=external_url, note=note, created_by="operator") except OperationActionBlocked as exc: + # Ações operacionais incompatíveis com o estado da oportunidade são bloqueios reais, + # não sucesso silencioso. Devolve 409 para testes/API e HTMX; a UI mostra a razão. return PlainTextResponse(f"Ação bloqueada: {exc}", status_code=409) except Exception as exc: print(f"ClientFlow operation action failed: {exc}", flush=True) - return PlainTextResponse(f"Erro ao registar ação: {exc}", status_code=500) + if is_htmx(request): + return PlainTextResponse(f"Erro ao registar ação: {exc}", status_code=409) + notice = quote(f"Não foi possível registar ação: {exc}") + return RedirectResponse(f"/opportunities/{opportunity_id}?notice={notice}", status_code=303) return RedirectResponse(f"/opportunities/{opportunity_id}", status_code=303) +@router.post("/opportunities/{opportunity_id}/odoo/link-sale") +async def opportunity_odoo_link_sale_number_action(opportunity_id: str, request: Request): + if not is_uuid_text(opportunity_id): + return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) + form = await request.form() + sale_ref = str(form.get("sale_ref") or form.get("external_name") or form.get("external_ref") or "").strip() + if not sale_ref: + msg = "Indica o nº da venda Odoo, por exemplo S00308." + if request.headers.get("hx-request"): + return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=msg), status_code=422) + return PlainTextResponse(msg, status_code=422) + + sale_ref = sale_ref.upper() if sale_ref.lower().startswith("s") else sale_ref + external_id = sale_ref if sale_ref.isdigit() else "" + external_name = sale_ref + notice = f"Venda Odoo {sale_ref} registada na oportunidade." + + try: + from app.operation_service import register_operation_action + + register_operation_action( + opportunity_id, + "odoo_sale_order", + external_id=external_id, + external_name=external_name, + note=f"Venda Odoo {sale_ref} associada manualmente pelo operador.", + payload={"manual_odoo_sale_ref": sale_ref}, + created_by="operator_ui_odoo_sale_ref", + ) + + with engine.begin() as conn: + conn.execute(text(""" + UPDATE tasks + SET status = 'done', + note = COALESCE(note, '') || CAST(:note AS TEXT), + updated_at = NOW() + WHERE opportunity_id = CAST(:opportunity_id AS UUID) + AND action_code = 'PREPARE_ORDER' + AND status = 'pending' + """), { + "opportunity_id": opportunity_id, + "note": f"\n\nConcluída automaticamente: venda Odoo {sale_ref} associada manualmente.", + }) + + try: + sync_opportunity_odoo_status(opportunity_id) + notice = f"Venda Odoo {sale_ref} associada e estado WH/OUT sincronizado." + except Exception as sync_exc: + notice = f"Venda Odoo {sale_ref} associada. Sincronização Odoo falhou: {sync_exc}" + + if request.headers.get("hx-request"): + return HTMLResponse(odoo_status_panel_html(opportunity_id, notice=notice)) + return RedirectResponse(url=f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303) + except OperationActionBlocked as exc: + msg = f"Ação bloqueada: {exc}" + except Exception as exc: + msg = f"Erro ao associar venda Odoo: {exc}" + + if request.headers.get("hx-request"): + return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=msg), status_code=409) + return RedirectResponse(url=f"/opportunities/{opportunity_id}?notice={quote(msg)}", status_code=303) + + @router.post("/opportunities/{opportunity_id}/odoo/sync-status") async def opportunity_odoo_sync_status_action(opportunity_id: str, request: Request): try: - sync_opportunity_odoo_status(opportunity_id) - except Exception: - pass - return RedirectResponse(url=f"/opportunities/{opportunity_id}", status_code=303) + result = sync_opportunity_odoo_status(opportunity_id) + label = result.get("label") or result.get("physical_status") or "estado Odoo atualizado" + if request.headers.get("hx-request"): + return HTMLResponse(odoo_status_panel_html(opportunity_id, notice=f"Odoo sincronizado: {label}")) + except Exception as exc: + if request.headers.get("hx-request"): + return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=str(exc)), status_code=409) + return RedirectResponse(url=f"/opportunities/{opportunity_id}?notice=Erro%20ao%20sincronizar%20Odoo", status_code=303) + return RedirectResponse(url=f"/opportunities/{opportunity_id}?notice=Odoo%20sincronizado", status_code=303) + + +@router.post("/opportunities/{opportunity_id}/odoo/link-candidate/{item_id}") +async def opportunity_odoo_link_candidate_action(opportunity_id: str, item_id: str, request: Request): + try: + from app.reconciliation_service import link_reconciliation_to_opportunity + link_reconciliation_to_opportunity(item_id, opportunity_id, actor="operator_ui_odoo_panel") + try: + sync_opportunity_odoo_status(opportunity_id) + except Exception: + pass + if request.headers.get("hx-request"): + return HTMLResponse(odoo_status_panel_html(opportunity_id, notice="Venda Odoo associada à oportunidade.")) + except Exception as exc: + if request.headers.get("hx-request"): + return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=f"Erro ao associar venda Odoo: {exc}"), status_code=409) + return PlainTextResponse(f"Erro ao associar venda Odoo: {exc}", status_code=500) + return RedirectResponse(url=f"/opportunities/{opportunity_id}?notice=Venda%20Odoo%20associada", status_code=303) diff --git a/app/admin_ui/pages/orders.py b/app/admin_ui/pages/orders.py index f750b2b..b11db96 100644 --- a/app/admin_ui/pages/orders.py +++ b/app/admin_ui/pages/orders.py @@ -4,6 +4,7 @@ Moved from app.admin_dashboard in v4.7.2. The handlers still reuse legacy helpers to keep this refactor behavior-preserving. """ from fastapi import APIRouter +from urllib.parse import quote import app.admin_dashboard as legacy from app.admin_dashboard import * # noqa: F401,F403 @@ -117,9 +118,11 @@ async def order_detail_page(opportunity_id: str): if not material_rows: material_rows = f'Sem produtos definidos. Adicionar na oportunidade.' + return_to = f"/orders/{opportunity_id}" task_rows = "" for task in tasks: - task_rows += f'
  • {esc(action_label(task.get("action_code")))} · {status_badge(task.get("status"))}
  • ' + task_href = f"/tasks/{esc(task.get('id'))}?return_to={quote(return_to, safe='')}" + task_rows += f'
  • {esc(action_label(task.get("action_code")))} · {status_badge(task.get("status"))}
  • ' if not task_rows: task_rows = '
  • Sem tarefas associadas.
  • ' diff --git a/app/admin_ui/pages/outbox.py b/app/admin_ui/pages/outbox.py index a2e1376..0e474bc 100644 --- a/app/admin_ui/pages/outbox.py +++ b/app/admin_ui/pages/outbox.py @@ -5,7 +5,7 @@ HTMX table partial so filters and operator actions can refresh the outbox without replacing the full page. """ from fastapi import APIRouter, Request -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, PlainTextResponse import app.admin_dashboard as legacy from app.admin_dashboard import * # noqa: F401,F403 from app.admin_ui.guidance import outbox_operator_message @@ -142,6 +142,8 @@ async def outbox_page( @router.get("/outbox/{outbox_id}", response_class=HTMLResponse) async def outbox_detail(outbox_id: str): + if not is_uuid_text(outbox_id): + return PlainTextResponse("Identificador de outbox inválido.", status_code=422) item = get_outbox_item(outbox_id) if not item: @@ -194,6 +196,8 @@ async def outbox_detail(outbox_id: str): @router.post("/outbox/{outbox_id}/retry") async def outbox_retry(outbox_id: str, request: Request): + if not is_uuid_text(outbox_id): + return PlainTextResponse("Identificador de outbox inválido.", status_code=422) form = await request.form() opportunity_id = str(form.get("opportunity_id") or "").strip() set_outbox_status(outbox_id=outbox_id, status="pending") @@ -212,6 +216,8 @@ def _outbox_htmx_or_redirect(request: Request, status: str = "all", target_syste @router.post("/outbox/{outbox_id}/pending") async def outbox_pending(outbox_id: str, request: Request): + if not is_uuid_text(outbox_id): + return PlainTextResponse("Identificador de outbox inválido.", status_code=422) set_outbox_status(outbox_id=outbox_id, status="pending") from app.operator_audit_service import record_operator_action_best_effort record_operator_action_best_effort(action="outbox_reprocess_requested", entity_type="outbox", entity_id=outbox_id, actor="operator", after={"status": "pending"}) @@ -220,6 +226,8 @@ async def outbox_pending(outbox_id: str, request: Request): @router.post("/outbox/{outbox_id}/sent") async def outbox_sent(outbox_id: str, request: Request): + if not is_uuid_text(outbox_id): + return PlainTextResponse("Identificador de outbox inválido.", status_code=422) set_outbox_status(outbox_id=outbox_id, status="sent") from app.operator_audit_service import record_operator_action_best_effort record_operator_action_best_effort(action="outbox_marked_sent", entity_type="outbox", entity_id=outbox_id, actor="operator", after={"status": "sent"}) @@ -228,6 +236,8 @@ async def outbox_sent(outbox_id: str, request: Request): @router.post("/outbox/{outbox_id}/failed") async def outbox_failed(outbox_id: str, request: Request): + if not is_uuid_text(outbox_id): + return PlainTextResponse("Identificador de outbox inválido.", status_code=422) set_outbox_status(outbox_id=outbox_id, status="failed", error="Marcado manualmente como failed.") from app.operator_audit_service import record_operator_action_best_effort record_operator_action_best_effort(action="outbox_marked_failed", entity_type="outbox", entity_id=outbox_id, actor="operator", after={"status": "failed"}) @@ -236,6 +246,8 @@ async def outbox_failed(outbox_id: str, request: Request): @router.post("/outbox/{outbox_id}/ignored") async def outbox_ignored(outbox_id: str, request: Request): + if not is_uuid_text(outbox_id): + return PlainTextResponse("Identificador de outbox inválido.", status_code=422) set_outbox_status(outbox_id=outbox_id, status="ignored", error="Ignorado manualmente pelo operador.") from app.operator_audit_service import record_operator_action_best_effort record_operator_action_best_effort(action="outbox_ignored", entity_type="outbox", entity_id=outbox_id, actor="operator", after={"status": "ignored"}) diff --git a/app/admin_ui/pages/products.py b/app/admin_ui/pages/products.py index c576dd5..49888e6 100644 --- a/app/admin_ui/pages/products.py +++ b/app/admin_ui/pages/products.py @@ -4,12 +4,37 @@ Moved from app.admin_dashboard in v4.7.2. The handlers still reuse legacy helpers to keep this refactor behavior-preserving. """ from fastapi import APIRouter +from decimal import Decimal, InvalidOperation +from fastapi.responses import PlainTextResponse import app.admin_dashboard as legacy from app.admin_dashboard import * # noqa: F401,F403 router = APIRouter() +def _validate_product_form_payload(data: dict) -> str: + sku = str(data.get("sku") or "").strip() + name = str(data.get("name") or "").strip() + if not sku: + return "SKU é obrigatório." + if not name: + return "Nome do produto é obrigatório." + for key, label, allow_blank, min_value in [ + ("default_unit_price", "Preço base", True, Decimal("0")), + ("vat_rate", "IVA", True, Decimal("0")), + ]: + value = data.get(key) + if allow_blank and (value is None or str(value).strip() == ""): + continue + try: + decimal_value = Decimal(str(value).replace(",", ".").strip()) + except (InvalidOperation, ValueError): + return f"{label} inválido." + if decimal_value < min_value: + return f"{label} não pode ser negativo." + return "" + + @router.get("/products", response_class=HTMLResponse) @router.get("/produtos", response_class=HTMLResponse) async def products_page( @@ -186,6 +211,14 @@ async def product_new_page(): @router.post("/products") async def product_create(request: Request): data = dict(await request.form()) + validation_error = _validate_product_form_payload(data) + if validation_error: + body = f''' + ← Voltar a produtos +
    {esc(validation_error)}
    +
    {product_form_html(data, action="/products", submit_label="Criar produto")}
    + ''' + return HTMLResponse(layout("Novo produto", "Corrige os campos e tenta novamente", body, "products"), status_code=422) try: product_id = create_product(data) return RedirectResponse(f"/products/{product_id}", status_code=303) @@ -195,11 +228,13 @@ async def product_create(request: Request):
    Erro ao criar produto: {esc(exc)}
    {product_form_html(data, action="/products", submit_label="Criar produto")}
    ''' - return layout("Novo produto", "Corrige os campos e tenta novamente", body, "products") + return HTMLResponse(layout("Novo produto", "Corrige os campos e tenta novamente", body, "products"), status_code=422) @router.get("/products/{product_id}", response_class=HTMLResponse) async def product_detail_page(product_id: str): + if not is_uuid_text(product_id): + return PlainTextResponse("Identificador de produto inválido.", status_code=422) product = get_product(product_id) if not product: return layout("Produto não encontrado", "Catálogo", '
    Produto não encontrado.
    ', "products") @@ -242,7 +277,18 @@ async def product_detail_page(product_id: str): @router.post("/products/{product_id}/update") async def product_update(product_id: str, request: Request): + if not is_uuid_text(product_id): + return PlainTextResponse("Identificador de produto inválido.", status_code=422) data = dict(await request.form()) + validation_error = _validate_product_form_payload(data) + if validation_error: + product = get_product(product_id) or data + body = f''' + ← Voltar ao produto +
    {esc(validation_error)}
    +
    {product_form_html(product, action=f"/products/{product_id}/update", submit_label="Guardar alterações")}
    + ''' + return HTMLResponse(layout("Editar produto", "Corrige os campos e tenta novamente", body, "products"), status_code=422) try: update_product(product_id, data) return RedirectResponse(f"/products/{product_id}", status_code=303) @@ -253,18 +299,24 @@ async def product_update(product_id: str, request: Request):
    Erro ao guardar produto: {esc(exc)}
    {product_form_html(product, action=f"/products/{product_id}/update", submit_label="Guardar alterações")}
    ''' - return layout("Editar produto", "Corrige os campos e tenta novamente", body, "products") + return HTMLResponse(layout("Editar produto", "Corrige os campos e tenta novamente", body, "products"), status_code=422) @router.post("/products/{product_id}/toggle") async def product_toggle(product_id: str, request: Request): + if not is_uuid_text(product_id): + return PlainTextResponse("Identificador de produto inválido.", status_code=422) data = dict(await request.form()) - set_product_active(product_id, str(data.get("active") or "false").lower() == "true") + ok = set_product_active(product_id, str(data.get("active") or "false").lower() == "true") + if not ok: + return PlainTextResponse("Produto não encontrado.", status_code=404) return RedirectResponse(f"/products/{product_id}", status_code=303) @router.post("/opportunities/{opportunity_id}/items/add") async def opportunity_item_add(opportunity_id: str, request: Request): + if not is_uuid_text(opportunity_id): + return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422) data = dict(await request.form()) try: add_opportunity_item( @@ -280,7 +332,8 @@ async def opportunity_item_add(opportunity_id: str, request: Request): except Exception as exc: print(f"ClientFlow add opportunity item failed: {exc}", flush=True) if is_htmx(request): - return HTMLResponse(opportunity_products_panel_html(opportunity_id, error_notice=str(exc)), status_code=409) + return HTMLResponse(opportunity_products_panel_html(opportunity_id, error_notice=str(exc)), status_code=422) + return PlainTextResponse(f"Dados inválidos ao adicionar item: {exc}", status_code=422) if is_htmx(request): return HTMLResponse(opportunity_products_panel_html(opportunity_id, notice="Produto adicionado à oportunidade.")) return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Produto%20adicionado%20%C3%A0%20oportunidade", status_code=303) @@ -288,12 +341,15 @@ async def opportunity_item_add(opportunity_id: str, request: Request): @router.post("/opportunities/{opportunity_id}/items/{item_id}/delete") async def opportunity_item_delete(opportunity_id: str, item_id: str, request: Request): + if not is_uuid_text(opportunity_id) or not is_uuid_text(item_id): + return PlainTextResponse("Identificador inválido.", status_code=422) try: delete_opportunity_item(item_id) except Exception as exc: print(f"ClientFlow delete opportunity item failed: {exc}", flush=True) if is_htmx(request): - return HTMLResponse(opportunity_products_panel_html(opportunity_id, error_notice=str(exc)), status_code=409) + return HTMLResponse(opportunity_products_panel_html(opportunity_id, error_notice=str(exc)), status_code=422) + return PlainTextResponse(f"Dados inválidos ao adicionar item: {exc}", status_code=422) if is_htmx(request): return HTMLResponse(opportunity_products_panel_html(opportunity_id, notice="Produto removido.")) return RedirectResponse(f"/opportunities/{opportunity_id}", status_code=303) diff --git a/app/admin_ui/pages/reconciliation.py b/app/admin_ui/pages/reconciliation.py index c760e47..fb5abcb 100644 --- a/app/admin_ui/pages/reconciliation.py +++ b/app/admin_ui/pages/reconciliation.py @@ -52,7 +52,7 @@ router = APIRouter() TYPE_LABELS = { "jasmin_quotation": "Orçamento Jasmin", - "jasmin_proforma": "Pró-forma Jasmin", + "jasmin_proforma": "Orçamento Jasmin legado", "jasmin_invoice": "Fatura Jasmin", "odoo_sale_order": "Venda Odoo", "payment_proof": "Comprovativo", @@ -85,7 +85,7 @@ def _operation_label(value: str | None) -> str: labels = { "odoo_sale_order": "Venda Odoo", "jasmin_quotation": "Orçamento Jasmin", - "jasmin_proforma": "Pró-forma Jasmin", + "jasmin_proforma": "Orçamento Jasmin legado", "jasmin_invoice": "Fatura Jasmin", "document": "Documento", } @@ -443,8 +443,8 @@ async def reconciliation_page(status: Optional[str] = "open", external_type: Opt

    Registar pedido externo

    -
    -
    +
    +
    @@ -506,6 +506,12 @@ async def reconciliation_sync_odoo(request: Request): days = _safe_days(form.get("days"), 3) result = sync_odoo_reconciliation_candidates(limit=50, days=days) notice = f"Odoo últimos {result.get('days', days)} dias: analisadas {result.get('seen', 0)} vendas · candidatos criados/atualizados {result.get('created_or_updated', 0)}" + if result.get("already_linked"): + notice += f" · já ligadas {result.get('already_linked', 0)}" + if result.get("resolved_existing"): + notice += f" · candidatos obsoletos resolvidos {result.get('resolved_existing', 0)}" + if result.get("link_conflicts"): + notice += f" · conflitos de ligação {result.get('link_conflicts', 0)}" if result.get("skipped"): notice += " · " + str(result.get("skipped")) return RedirectResponse(f"/reconciliation?days={days}¬ice={esc(notice)}", status_code=303) diff --git a/app/admin_ui/pages/revenue_forecast.py b/app/admin_ui/pages/revenue_forecast.py new file mode 100644 index 0000000..8bc67a6 --- /dev/null +++ b/app/admin_ui/pages/revenue_forecast.py @@ -0,0 +1,281 @@ +"""Sales target and management forecast dashboard.""" +from __future__ import annotations + +from urllib.parse import urlencode + +from fastapi import APIRouter, Form +from fastapi.responses import HTMLResponse, RedirectResponse + +from app.admin_dashboard import esc, money_html, stage_label +from app.admin_ui.components import kpi_card +from app.admin_ui.layout import layout +from app.revenue_forecast_service import TARGET_METRICS, get_revenue_forecast, set_sales_target + +router = APIRouter() + + +def _pct(value: object, digits: int = 0) -> str: + try: + return f"{float(value or 0) * 100:.{digits}f}%" + except Exception: + return "0%" + + +def _number(value: object) -> float: + try: + return float(value or 0) + except Exception: + return 0.0 + + +def _status_alert(status: dict) -> str: + tone = str(status.get("tone") or "gray") + css = {"green": "success", "orange": "warning", "red": "danger", "gray": "secondary"}.get(tone, "secondary") + return f'
    {esc(status.get("label"))}
    {esc(status.get("message"))}
    ' + + +def _class_badge(value: str) -> str: + mapping = { + "realised": ("Realizado", "success"), + "committed": ("Comprometido", "primary"), + "probable": ("Provável", "warning"), + "realised_other_period": ("Já realizado", "secondary"), + } + label, css = mapping.get(str(value), (value or "—", "secondary")) + return f'{esc(label)}' + + +@router.post("/forecast/target") +@router.post("/finance/forecast/target") +@router.post("/financeiro/previsao/meta") +async def revenue_forecast_target_save( + month: str = Form(...), + metric: str = Form("invoiced"), + target_amount: str = Form("0"), +): + raw = str(target_amount or "0").strip().replace(" ", "") + if "," in raw: + raw = raw.replace(".", "").replace(",", ".") + try: + amount = float(raw) + except ValueError: + amount = 0.0 + set_sales_target(month=month, metric=metric, target_amount=amount, updated_by="operator") + return RedirectResponse(f"/forecast?{urlencode({'month': month, 'metric': metric})}", status_code=303) + + +@router.get("/finance/forecast") +@router.get("/financeiro/previsao") +async def legacy_revenue_forecast(month: str | None = None, metric: str = "invoiced"): + params = {"metric": metric} + if month: + params["month"] = month + return RedirectResponse(f"/forecast?{urlencode(params)}", status_code=302) + + +@router.get("/forecast", response_class=HTMLResponse) +async def revenue_forecast_page(month: str | None = None, metric: str = "invoiced"): + forecast = get_revenue_forecast(limit=1000, month=month, metric=metric) + summary = forecast["summary"] + management = forecast["management"] + period = forecast["period"] + target = forecast["target"] + month_data = management["month"] + next30 = management["next_30_days"] + recoverable = management.get("recoverable") or {} + target_amount = _number(management["target_amount"]) + + metric_options = "".join( + f'' + for key, label in TARGET_METRICS.items() + ) + month_value = str(period["month_start"])[:7] + + # Progress bar segments stop at the target; any surplus is shown separately. + if target_amount > 0: + realised_ratio = min(_number(month_data["realised"]) / target_amount, 1.0) + remaining = max(1.0 - realised_ratio, 0.0) + committed_ratio = min(_number(month_data["committed"]) / target_amount, remaining) + remaining = max(remaining - committed_ratio, 0.0) + probable_ratio = min(_number(month_data["probable"]) / target_amount, remaining) + missing_ratio = max(1.0 - realised_ratio - committed_ratio - probable_ratio, 0.0) + progress = f""" + +
    + Realizado {money_html(month_data['realised'])} + Comprometido {money_html(month_data['committed'])} + Provável {money_html(month_data['probable'])} + Falta {money_html(management['gap'])} +
    + """ + else: + progress = '
    Configura uma meta para visualizar o progresso e o desvio.
    ' + + diagnostics_html = "".join( + f""" +
    +
    {esc(d.get('severity'))}
    +
    {esc(d.get('label'))}
    {esc(d.get('detail'))}
    +
    + """ + for d in forecast.get("diagnostics", []) + ) or '
    Sem riscos relevantes identificados com os dados atuais.
    ' + + actions_rows = "" + for action in forecast.get("priority_actions", []): + impact = action.get("impact_amount") or 0 + flags = [] + if action.get("overdue_tasks"): + flags.append(f"{action['overdue_tasks']} task(s) vencida(s)") + if action.get("has_conflict"): + flags.append("conflito de identidade") + actions_rows += f""" + + {esc(action.get('action_group'))} + {esc(action.get('customer_name') or action.get('title') or 'Oportunidade')}
    {esc(action.get('title') or '')}
    + {esc(stage_label(action.get('stage')))} + {money_html(action.get('amount') or 0)} + {esc(action.get('recommended_action'))}
    {esc(', '.join(flags) or '—')}
    + {money_html(impact)} + + """ + if not actions_rows: + actions_rows = 'Sem ações prioritárias calculadas.' + + realised_rows = "" + for item in forecast.get("realised_items", [])[:80]: + realised_rows += f""" + + {esc(item.get('reference') or 'Realizado')} + {money_html(item.get('amount') or 0)} + {esc(str(item.get('realised_at') or '')[:10])} + {f'Abrir' if item.get('opportunity_id') else '—'} + + """ + if not realised_rows: + realised_rows = 'Sem valor realizado para esta métrica no mês selecionado.' + + opportunity_rows = "" + future_valued = [i for i in forecast["items"] if i.get("forecast_class") in {"committed", "probable"} and _number(i.get("amount")) > 0] + for item in future_valued[:80]: + flags = [] + if item.get("has_conflict"): + flags.append("conflito fiscal") + if item.get("is_stale"): + flags.append("inativa >30d") + if item.get("overdue_tasks"): + flags.append(f"{item['overdue_tasks']} task(s) vencida(s)") + sample = item.get("probability_sample") or {} + sample_hint = "" + if item.get("probability_source") == "historical_blended": + sample_hint = f" · {int(sample.get('won') or 0)}/{int(sample.get('resolved') or 0)} ganhas" + probability_cell = ( + '100%
    receita comprometida
    ' + if item.get("forecast_class") == "committed" + else f"{_pct(item.get('effective_probability'))}
    fase {_pct(item.get('probability'))} × atividade {esc(item.get('activity_factor'))}{esc(sample_hint)}
    " + ) + expected_value = item.get("amount") if item.get("forecast_class") == "committed" else item.get("weighted_amount") + opportunity_rows += f""" + + {esc(item.get('customer_name') or item.get('title') or 'Oportunidade')}
    {esc(item.get('title') or '')}
    + {esc(stage_label(item.get('stage')))}
    {_class_badge(item.get('forecast_class'))}
    + {money_html(item.get('amount') or 0)}
    {esc(item.get('value_source'))}
    + {probability_cell} + {money_html(expected_value or 0)} + {esc(str(item.get('expected_date') or '')[:10])} + {esc(', '.join(flags) or '—')} + + """ + if not opportunity_rows: + opportunity_rows = 'Sem oportunidades futuras valorizadas.' + + stage_rows = "".join( + f"{esc(stage_label(row.get('stage')))}{esc(row.get('count'))}{esc(row.get('valued'))}{money_html(row.get('gross') or 0)}{money_html(row.get('weighted') or 0)}" + for row in forecast.get("stage_summary", []) + ) + + zero_value_items = [i for i in forecast["items"] if i.get("forecast_class") in {"committed", "probable"} and _number(i.get("amount")) <= 0] + zero_rows = "".join( + f'{esc(item.get("customer_name") or item.get("title") or "Oportunidade")}
    {esc(item.get("title") or "")}
    {esc(stage_label(item.get("stage")))}{esc(str(item.get("updated_at") or "")[:10])}Valorizar' + for item in zero_value_items[:30] + ) or 'Todas as oportunidades futuras têm valor.' + + scenarios = management["scenarios"] + body = f""" + + +
    Dashboard de gestão comercial. Separa o que já conta para a meta, o que está comprometido e o que ainda depende de conversão. Não representa tesouraria nem substitui validação contabilística.
    + +
    +
    +
    +
    +
    +
    +
    +
    Período: {esc(period['month_start'])} a {esc(period['month_end'])} · critério: {esc(management['metric_label'])}
    +
    + + {_status_alert(management['status'])} + +
    + {kpi_card('Meta mensal', money_html(target_amount), '/forecast', management['metric_label'], 'bi-bullseye')} + {kpi_card('Realizado', money_html(month_data['realised']), '/forecast', f"{summary['realised_count']} registo(s) no mês", 'bi-check2-circle', 'cf-kpi-tone-green')} + {kpi_card('Comprometido', money_html(month_data['committed']), '/forecast', f"{month_data['committed_count']} oportunidade(s) até ao fim do mês", 'bi-lock')} + {kpi_card('Pipeline provável', money_html(month_data['probable']), '/forecast', f"{month_data['probable_count']} oportunidade(s) ponderadas", 'bi-graph-up-arrow')} + {kpi_card('Previsão total', money_html(month_data['forecast_total']), '/forecast', f"cumprimento {_pct(management['attainment'])}", 'bi-speedometer2')} + {kpi_card('Desvio', money_html(management['gap']), '/forecast', 'falta para suportar a meta' if management['gap'] else f"excedente {money_html(management['surplus'])}", 'bi-exclamation-triangle', 'cf-kpi-tone-red' if management['gap'] else 'cf-kpi-tone-green')} +
    + +
    +

    Progresso da meta

    Sem dupla contagem entre realizado, comprometido e provável.
    {_pct(management['attainment'])}
    + {progress} +
    + +
    +

    Até ao fim do mês

    {money_html(month_data['forecast_total'])}
    Realizado {money_html(month_data['realised'])} · futuro adicional {money_html(month_data['future_total'])}
    +

    Próximos 30 dias adicionais

    {money_html(next30['future_total'])}
    Até {esc(period['next_30_end'])}; não inclui o realizado do mês.
    +
    + +
    +
    +

    Capacidade de recuperação

    Pagamentos já existentes previstos após o fim do mês que podem ser acelerados. Não entram na previsão base.
    +
    {money_html(recoverable.get('weighted') or 0)}
    {esc(recoverable.get('count') or 0)} pagamento(s) · bruto {money_html(recoverable.get('gross') or 0)}
    +
    +
    +
    Previsão com aceleração{money_html(recoverable.get('accelerated_total') or month_data['forecast_total'])}
    +
    Cumprimento acelerado{_pct(recoverable.get('accelerated_attainment'))}
    +
    Desvio residual{money_html(recoverable.get('residual_gap') or 0)}
    +
    +
    + +
    +

    Diagnóstico do desvio

    {diagnostics_html}
    +

    Cenários até ao fim do mês

    Conservador{money_html(scenarios['conservative'])}
    Provável{money_html(scenarios['probable'])}
    Com aceleração{money_html(scenarios['optimistic'])}
    Potencial máximo conhecido{money_html(scenarios.get('maximum_known') or scenarios['optimistic'])}

    Novo pipeline necessário
    {money_html(management['new_pipeline_required'])}
    Conversão usada {_pct(management['new_pipeline_conversion'])} · aproximadamente {esc(management['new_opportunities_required'])} nova(s) oportunidade(s), quando existe valor médio suficiente.
    +
    + +

    Ações de maior impacto

    O que executar hoje para proteger ou recuperar a meta.
    {actions_rows}
    GrupoOportunidadeFaseValorAção recomendadaImpacto
    + +

    Realizado no mês

    Registos que já contam para a métrica selecionada; não voltam a ser somados no pipeline futuro.
    {realised_rows}
    ReferênciaValorData
    + +

    Oportunidades que suportam a previsão futura

    Realizado no mês é apresentado separadamente; esta tabela mostra apenas valor adicional comprometido ou provável.
    {opportunity_rows}
    OportunidadeFaseValorProbabilidadeValor esperadoDataAlertas
    + +
    +

    Funil por fase

    Quantidade, cobertura e valor.
    {stage_rows}
    FaseTotalCom valorBrutoPonderado
    +

    Oportunidades por valorizar

    {esc(summary['unvalued_opportunities'])} de {esc(summary['opportunities'])} sem valor · cobertura {_pct(summary['value_coverage'])} · qualidade {_pct(summary['quality_score'])}.
    {zero_rows}
    OportunidadeFaseAtualizada
    +
    + """ + return layout("Meta e desempenho comercial", "Acompanhar vendas e decidir quando mudar a abordagem", body, "forecast") diff --git a/app/admin_ui/pages/system.py b/app/admin_ui/pages/system.py index 946ea0c..f5eaed4 100644 --- a/app/admin_ui/pages/system.py +++ b/app/admin_ui/pages/system.py @@ -4,6 +4,8 @@ Moved from app.admin_dashboard in v4.7.2. The handlers still reuse legacy helpers to keep this refactor behavior-preserving. """ from fastapi import APIRouter +from sqlalchemy import text +from app.db import engine import app.admin_dashboard as legacy from app.admin_dashboard import * # noqa: F401,F403 @@ -203,7 +205,7 @@ async def system_health_page(): @router.get("/settings", response_class=HTMLResponse) @router.get("/configuracoes", response_class=HTMLResponse) async def settings_page(): - return RedirectResponse("/system", status_code=303) + return RedirectResponse("/settings/workflow", status_code=303) @router.get("/system/health", response_class=HTMLResponse) @@ -244,7 +246,7 @@ async def system_health_operational_page(): doc_rows = 'Sem documentos.' db_ok = bool((health.get("database") or {}).get("ok")) - critical_count = (0 if db_ok else 1) + m("outbox_processing_stale") + m("outbox_stale") + m("outbox_blocked_or_failed") + critical_count = (0 if db_ok else 1) + m("outbox_processing_stale") + m("outbox_stale") + m("outbox_blocked_or_failed") + m("chatwoot_incoming_pending") warning_count = m("ambiguous_opportunity_tasks") + m("open_opportunities_without_fiscal_customer") + m("active_incomplete_fiscal_customers") + m("products_missing_external_code") if critical_count: production_state = "Crítico" @@ -288,6 +290,10 @@ async def system_health_operational_page(): {kpi_card('Último webhook Chatwoot', esc('—' if not m('seconds_since_last_chatwoot_webhook') else str(m('seconds_since_last_chatwoot_webhook')) + 's'), '/events', f"eventos: {esc(m('chatwoot_events_total'))}", 'bi-chat-dots')} +
    + {kpi_card('Chatwoot inbound pendente', esc(m('chatwoot_incoming_pending')), '/events', f"inbound 24h: {esc(m('chatwoot_incoming_24h'))}", 'bi-inbox', 'cf-kpi-tone-red' if m('chatwoot_incoming_pending') else 'cf-kpi-tone-green')} +
    +

    Timers systemd

    Estado best-effort dos timers da outbox.
    {timer_rows}
    IntegraçãoUnidadeEstado

    Outbox por sistema

    {outbox_rows}
    SistemaEstados
    @@ -298,3 +304,127 @@ async def system_health_operational_page(): return layout("Saúde operacional", "Base de dados, integrações, timers e contadores", body, "system") + +@router.get("/settings/workflow", response_class=HTMLResponse) +@router.get("/configuracao/fluxo-operacional", response_class=HTMLResponse) +async def workflow_settings_page(): + from app.domain.opportunity_flow import load_company_profile + + profile = load_company_profile("blif") + stage_rows = "".join( + f"{esc(item.get('code'))}{esc(item.get('label'))}" + for item in profile.commercial_stages + if isinstance(item, dict) + ) or 'Sem fases configuradas.' + payment_rows = "".join(f"{esc(k)}{esc(v)}" for k, v in profile.payment_terms.items()) + delivery_rows = "".join(f"{esc(k)}{esc(v)}" for k, v in profile.delivery_terms.items()) + action_rows = "".join( + f"{esc(code)}{esc((cfg or {}).get('label') if isinstance(cfg, dict) else cfg)}{esc((cfg or {}).get('description') if isinstance(cfg, dict) else '')}" + for code, cfg in profile.actions.items() + ) + right_cards = profile.ui.get("opportunity_cards", {}).get("right", []) if isinstance(profile.ui, dict) else [] + left_cards = profile.ui.get("opportunity_cards", {}).get("left", []) if isinstance(profile.ui, dict) else [] + body = f''' + ← Voltar ao sistema +
    +
    +
    +

    Fluxo operacional

    +
    Configuração controlada do perfil ativo da empresa. As regras críticas continuam protegidas no backend.
    +
    +
    {esc(profile.name)}
    {esc(profile.version)}
    +
    +
    +
    +

    Defaults

    Pagamento
    {esc(profile.defaults.get('payment_terms') or '—')}
    Entrega
    {esc(profile.defaults.get('delivery_terms') or '—')}
    Follow-up
    {esc(profile.defaults.get('follow_up_delay_days') or '—')} dias
    +

    Cards da oportunidade

    Coluna operação
    {esc(' → '.join(map(str, right_cards)) or '—')}
    Coluna contexto
    {esc(' → '.join(map(str, left_cards)) or '—')}
    +

    Condições de pagamento

    {payment_rows}
    +

    Tipos de entrega

    {delivery_rows}
    +

    Fases comerciais

    {stage_rows}
    CódigoNome
    +

    Ações do motor

    {action_rows}
    CódigoNomeDescrição
    +
    + ''' + return layout("Fluxo operacional", "Perfil de empresa e defaults do workflow", body, "settings") + + +@router.get("/settings/workflow/audit", response_class=HTMLResponse) +@router.get("/admin/workflow/audit", response_class=HTMLResponse) +async def workflow_audit_page(): + """Lightweight coherence audit for workflow/operator UX regressions.""" + findings = [] + try: + with engine.begin() as conn: + rows = conn.execute(text(""" + SELECT + o.id::text, + o.title, + o.stage, + o.customer_name, + o.updated_at, + COUNT(*) FILTER (WHERE t.status = 'pending')::int AS pending_tasks, + COUNT(*) FILTER (WHERE t.status = 'pending' AND t.action_code = 'CONFIRM_PAYMENT')::int AS pending_confirm_payment, + COUNT(*) FILTER (WHERE t.status = 'pending' AND t.action_code = 'SEND_INVOICE')::int AS pending_send_invoice, + COUNT(*) FILTER (WHERE d.document_kind = 'invoice')::int AS invoices, + COUNT(*) FILTER (WHERE d.document_kind = 'quotation')::int AS quotes, + COUNT(*) FILTER (WHERE ol.system = 'clientflow' AND ol.external_type = 'payment' AND ol.status = 'confirmed')::int AS payments_confirmed, + COUNT(*) FILTER (WHERE lower(coalesce(t.note,'')) LIKE '%fatura por emitir%')::int AS stale_invoice_note, + COUNT(*) FILTER (WHERE lower(coalesce(t.note,'')) LIKE '%pró-forma%' OR lower(coalesce(t.action,'')) LIKE '%pró-forma%')::int AS visible_proforma_task + FROM opportunities o + LEFT JOIN tasks t ON t.opportunity_id = o.id + LEFT JOIN commercial_documents d ON d.opportunity_id = o.id + LEFT JOIN operation_links ol ON ol.opportunity_id = o.id + WHERE coalesce(o.status, 'open') <> 'closed' + GROUP BY o.id, o.title, o.stage, o.customer_name, o.updated_at + ORDER BY o.updated_at DESC NULLS LAST + LIMIT 300 + """)).mappings().all() + except Exception as exc: + rows = [] + findings.append({"severity": "alto", "code": "audit_query_failed", "title": "Auditoria indisponível", "detail": str(exc), "url": "/system"}) + + try: + from app.jasmin_fiscal_sync_service import audit_jasmin_fiscal_gaps + findings.extend(audit_jasmin_fiscal_gaps(limit=150)) + except Exception as exc: + findings.append({"severity": "baixo", "code": "jasmin_fiscal_audit_unavailable", "title": "Auditoria Jasmin fiscal indisponível", "detail": str(exc), "url": "/settings/workflow/audit"}) + + for row in rows: + url = f"/opportunities/{row.get('id')}" + title = row.get("title") or row.get("customer_name") or row.get("id") + if int(row.get("payments_confirmed") or 0) > 0 and int(row.get("pending_confirm_payment") or 0) > 0: + findings.append({"severity": "alto", "code": "payment_confirmed_but_confirm_task", "title": title, "detail": "Pagamento confirmado mas ainda existe task pendente de confirmar pagamento.", "url": url}) + if int(row.get("invoices") or 0) > 0 and int(row.get("stale_invoice_note") or 0) > 0: + findings.append({"severity": "médio", "code": "invoice_exists_but_task_says_to_issue", "title": title, "detail": "Fatura associada mas alguma task ainda diz 'fatura por emitir'.", "url": url}) + if int(row.get("visible_proforma_task") or 0) > 0: + findings.append({"severity": "baixo", "code": "legacy_proforma_word_visible", "title": title, "detail": "Texto histórico ainda contém 'pró-forma'; normalizar para orçamento para pagamento.", "url": url}) + if int(row.get("payments_confirmed") or 0) > 0 and int(row.get("invoices") or 0) == 0 and str(row.get("stage") or "").upper() not in {"PAYMENT_CONFIRMED", "WAITING_PAYMENT", "REVIEW"}: + findings.append({"severity": "médio", "code": "payment_confirmed_without_invoice", "title": title, "detail": "Pagamento confirmado sem fatura associada; verificar próxima ação.", "url": url}) + + severity_order = {"alto": 0, "médio": 1, "baixo": 2} + findings.sort(key=lambda f: (severity_order.get(str(f.get("severity")), 9), str(f.get("title") or ""))) + rows_html = "" + for f in findings[:200]: + sev = str(f.get("severity") or "baixo") + chip = "cf-chip-red" if sev == "alto" else ("cf-chip-orange" if sev == "médio" else "cf-chip-gray") + rows_html += f""" + + {esc(sev)} + {esc(f.get('code'))} + {esc(f.get('title') or 'Oportunidade')}
    {esc(f.get('detail') or '')}
    + + """ + if not rows_html: + rows_html = 'Sem incoerências encontradas nos primeiros processos analisados.' + body = f''' + ← Voltar ao fluxo operacional +
    +

    Auditor de coerência do fluxo

    +
    Deteta sinais de regressão entre pagamentos, faturas, tarefas antigas e textos legados. Esta primeira versão é read-only.
    +
    {len(findings)} achado(s)
    +
    +
    +

    Achados

    Prioriza alto/médio antes de operar novas ações financeiras.
    +
    {rows_html}
    SeveridadeCódigoProcesso
    +
    + ''' + return layout("Auditor de fluxo", "Coerência operacional", body, "settings") diff --git a/app/admin_ui/pages/tasks.py b/app/admin_ui/pages/tasks.py index 18665bd..c36863e 100644 --- a/app/admin_ui/pages/tasks.py +++ b/app/admin_ui/pages/tasks.py @@ -1,10 +1,14 @@ """Task list, detail and task action routes. -Moved from app.admin_dashboard in v4.7.2. The handlers still reuse +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 +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 ( @@ -12,17 +16,85 @@ from app.admin_ui.guidance import ( 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_task_display_html(value: str) -> str: - """Avoid false-positive technical error markers in email/task content. +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 - Some postmaster/Mail Delivery emails legitimately contain strings such as - "Exception:". The audit script treats those as runtime errors, so the UI - neutralizes the marker while preserving the meaning for the operator. - """ + +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", @@ -32,6 +104,12 @@ def _safe_task_display_html(value: str) -> str: "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) @@ -40,23 +118,1001 @@ def _safe_task_display_html(value: str) -> str: 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]) -> str: +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 = customer_display(task) + 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) @@ -75,18 +1131,18 @@ def render_tasks_list_partial(tasks: list[dict]) -> str: {task_priority_chip(task)}
    task
    - {esc(customer)} + {esc(customer)}
    {esc(subject or '—')}
    {source_line} {opp_line} {route_badge(task.get('route'))} - {status_badge(task.get('status'))}
    {sla_badge_html(task)}
    + {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('source_system') or '') == 'chatwoot' else ''}
    +
    Abrir{chatwoot_button(task.get('conversation_id'), 'Chatwoot') if str(task.get('conversation_id') or '').strip() else ''}
    ''' if not rows: @@ -105,7 +1161,8 @@ def render_tasks_list_partial(tasks: list[dict]) -> str: @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 HTMLResponse(render_tasks_list_partial(tasks)) + 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) @@ -118,6 +1175,7 @@ async def tasks_page( ): 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() @@ -128,6 +1186,8 @@ async def tasks_page( 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"), @@ -144,8 +1204,9 @@ async def tasks_page( 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 = customer_display(task) + 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) @@ -156,7 +1217,7 @@ async def tasks_page(
    {esc(action_label(action_code))}
    -

    {esc(customer)}

    +

    {esc(customer)}

    {task_priority_chip(task)}{status_badge(task.get('status'))}
    @@ -167,13 +1228,14 @@ async def tasks_page(
    {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('source_system') or '') == 'chatwoot' else ''} + Abrir + {chatwoot_button(task.get('conversation_id'), 'Chatwoot') if str(task.get('conversation_id') or '').strip() else ''}
    ''' @@ -184,8 +1246,9 @@ async def tasks_page( 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 = customer_display(task) + 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) @@ -204,18 +1267,18 @@ async def tasks_page( {task_priority_chip(task)}
    task
    - {esc(customer)} + {esc(customer)}
    {esc(subject or '—')}
    {source_line} {opp_line} {route_badge(task.get('route'))} - {status_badge(task.get('status'))}
    {sla_badge_html(task)}
    + {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('source_system') or '') == 'chatwoot' else ''}
    +
    Abrir{chatwoot_button(task.get('conversation_id'), 'Chatwoot') if str(task.get('conversation_id') or '').strip() else ''}
    ''' if not table_rows: @@ -255,6 +1318,7 @@ async def tasks_page( 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 @@ -270,18 +1334,460 @@ async def tasks_page(

    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)} + {render_tasks_list_partial(tasks, return_to=return_to)} ''' return layout("Tarefas", "Fila operacional com foco na próxima ação", body, "tasks") -def render_task_detail_partial(task_id: str, notice: str = "") -> str: + +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 '')}
    +
      {facts_html}
    +
    + ''' + 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 "") @@ -289,7 +1795,7 @@ def render_task_detail_partial(task_id: str, notice: str = "") -> str: 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 = str(task.get("linked_customer_name") or customer_display(task)) + 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) @@ -300,50 +1806,58 @@ def render_task_detail_partial(task_id: str, notice: str = "") -> str: 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 = f'Ver oportunidade' if opportunity_id else 'Sem oportunidade associada' - chatwoot_html = chatwoot_button(task.get('conversation_id'), 'Chatwoot') if str(task.get('source_system') or '') == 'chatwoot' else '' - fiscal_customer = { - "id": safe_customer_id, - "name": task.get("linked_customer_name"), - "email": task.get("linked_customer_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"), - } if safe_customer_id or task.get("linked_customer_name") else None + 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=task.get("customer_name") or customer, - contact_email=task.get("customer_email"), - contact_phone=task.get("customer_phone"), - conversation_id=task.get("conversation_id"), + 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 else "", + 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 {"SEND_QUOTE", "SEND_PROFORMA", "SEND_INVOICE"} 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'''
    -
    A atualizar…
    +
    {return_link_html}A atualizar…
    {notice_html}
    @@ -362,12 +1876,19 @@ def render_task_detail_partial(task_id: str, notice: str = "") -> str:

    Próxima ação

    {esc(next_action)}
    {route_badge(route_name)}{status_badge(status)}{task_priority_chip(task)}
    {fiscal_contact_html} {task_readiness_html} -

    Pedido do cliente

    {esc(str(request_text))}
    + {_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}
    @@ -376,17 +1897,25 @@ def render_task_detail_partial(task_id: str, notice: str = "") -> str: @router.get("/tasks/{task_id}/partials/detail", response_class=HTMLResponse) -async def task_detail_partial(task_id: str): - return HTMLResponse(render_task_detail_partial(task_id)) +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): +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 "") @@ -395,8 +1924,8 @@ async def task_detail_bootstrap_page(task_id: str): 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 = str(task.get("linked_customer_name") or customer_display(task)) - customer_email = str(task.get("customer_email") or "") + 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) @@ -413,8 +1942,7 @@ async def task_detail_bootstrap_page(task_id: str): request_text = _safe_task_display_html(str(request_text)) preparation = get_latest_task_preparation(task_id) - prep_vm = build_preparation_view_model(task, preparation) - suggested_reply = _safe_task_display_html(prep_vm.get("suggested_reply") or suggested_reply_for_task(task)) + 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 = ( @@ -429,26 +1957,21 @@ async def task_detail_bootstrap_page(task_id: str): href = f"{public_url}/app/accounts/{account_id}/conversations/{conversation_id}" chatwoot_link = f'Abrir Chatwoot ↗' - fiscal_customer = { - "id": safe_customer_id, - "name": task.get("linked_customer_name"), - "email": task.get("linked_customer_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"), - } if safe_customer_id or task.get("linked_customer_name") else None + 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=task.get("customer_name") or customer, - contact_email=customer_email, - contact_phone=customer_phone, + 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 else "", + 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 {"SEND_QUOTE", "SEND_PROFORMA", "SEND_INVOICE"} 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, @@ -456,12 +1979,14 @@ async def task_detail_bootstrap_page(task_id: str): blocked_text="Corrigir estes dados antes de emitir documento.", ) - missing_items = list(prep_vm.get("missing_fields") or []) - existing_missing_labels = {str(item.get("label") or "") for item in missing_items if isinstance(item, dict)} - 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}) + 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"))}' @@ -479,50 +2004,108 @@ async def task_detail_bootstrap_page(task_id: str): prep_type = str(prep_vm.get("prep_type") or "generic") assistant_buttons = "" if action_code == "SEND_PROFORMA": - assistant_buttons += f'
    ' + 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 = "" - if status == "pending": + 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", "IGNORE_SPAM", "REVIEW_MANUALLY", "NO_ACTION", + "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 or {}, ensure_ascii=False, indent=2, default=str))}
    " - for label, data in [ - ("Cliente", technical.get("customer")), - ("Faturação", technical.get("billing")), - ("Venda", technical.get("sale")), - ("Logística", technical.get("shipment")), - ] + 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""" - ← Voltar a tarefas + {return_link_html}
    @@ -546,7 +2129,7 @@ async def task_detail_bootstrap_page(task_id: str):
    Próxima ação
    -

    {esc(prep_vm.get('primary_action') or action_label(action_code))}

    +

    {esc(primary_action_text)}

    {status_badge(status)} {route_badge(route_name)} @@ -559,6 +2142,7 @@ async def task_detail_bootstrap_page(task_id: str): {chatwoot_link or ''}
    + {return_to_hidden}
    @@ -573,20 +2157,21 @@ async def task_detail_bootstrap_page(task_id: str):
    {missing_html}
    -
    -
    -

    Mensagem sugerida

    - -
    -
    {esc(suggested_reply)}
    -
    + {_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)}
    -

    Pedido do cliente

    +

    {esc(context_heading)}

    Assunto
    {esc(subject)}
    Mensagem
    -
    {esc(str(request_text))}
    + {context_html}
    @@ -602,7 +2187,7 @@ async def task_detail_bootstrap_page(task_id: str):

    Ligações

    - {f'Ver oportunidade' if opportunity_id else ''} + {_task_opportunity_navigation_html(task, opportunity_id)} {chatwoot_link or 'Chatwoot não configurado.'}
    @@ -614,38 +2199,24 @@ async def task_detail_bootstrap_page(task_id: str):
    -

    Concluir

    + +

    {esc(completion_heading)}

    {completion_html}
    + + {task_management_html}
    -
    - Ver detalhes técnicos -
    -
    -
    -

    Reclassificar

    -
    - - - -
    -
    -
    -

    Ignorar

    -
    - -
    -
    -
    -
    -
    {technical_blocks}
    -
    -
    + {technical_details_html}