CRM Comercial GoGenetic¶
Provider atual e nomes legados¶
O adapter implementa o contrato HTTP HubSpot v3 para que o agente possa trabalhar com o CRM Comercial próprio da GoGenetic. A decisão histórica HubSpot → CRM próprio está em context/integrations/crm-comercial-gogenetic.md; .env.example aponta a base do CRM próprio. Os nomes HUBSPOT_*, HubSpotClient e hubspot_* no log continuam por compatibilidade técnica.
O destino efetivo é HUBSPOT_BASE_URL. O default de Settings, HubSpotHttpConfig e SAM continua https://api.hubapi.com. Portanto, o nome da variável não identifica o provider e o checkout, sozinho, não comprova qual endpoint está ativo em produção.
Localização e contratos¶
| Arquivo | Símbolos e responsabilidade |
|---|---|
src/core/integrations/hubspot/interface.py |
HubSpotClient com upsert de contato, criação e atualização de deal |
src/core/integrations/hubspot/client.py |
HubSpotHttpConfig, HubSpotHttpClient, normalização, HTTP e classificação de falhas |
src/core/integrations/hubspot/payloads.py |
build_contact_payload, build_deal_payload |
src/core/integrations/hubspot/stub.py |
HubSpotClientStub, IDs fictícios constantes |
src/core/integrations/factory.py |
build_hubspot_client() para aplicação e smoke |
src/core/application/confirm_action.py |
Upsert síncrono de contato na confirmação de Coleta |
src/core/application/crm_sync.py e src/crm_sync/app.py |
Escrita assíncrona dos negócios após commit da sessão |
class HubSpotClient(Protocol):
def upsert_contact(self, payload: dict[str, Any]) -> StubResponse: ...
def create_deal(self, payload: dict[str, Any]) -> StubResponse: ...
def update_deal(self, deal_id: str,
properties: dict[str, Any]) -> StubResponse: ...
check_health() e search_contacts() existem no client HTTP para smokes, mas não integram esse Protocol. mock_id representa o ID externo real no adapter HTTP. O stub retorna mock_contact_123 e mock_deal_456, sem rede.
Configuração e autenticação¶
HUBSPOT_ENABLED=false escolhe stub. A factory real exige token direto em HUBSPOT_ACCESS_TOKEN ou resolução de HUBSPOT_ACCESS_TOKEN_SSM_PARAM, mais HUBSPOT_PIPELINE_ID e HUBSPOT_DEALSTAGE_ID. O valor direto prevalece sobre SSM. Requests usam Authorization: Bearer <TOKEN>, Accept: application/json e Content-Type: application/json; GET /health é a exceção sem autenticação. Paths são unidos à base preservando eventual prefixo nela.
Configurações adicionais: HUBSPOT_TIMEOUT_SECONDS, HUBSPOT_RETRY_MAX, os cinco HUBSPOT_STAGE_*, HUBSPOT_OWNER_ID, HUBSPOT_OWNER_ROUTING_CONFIG. Este último apenas gera hubspot_owner_routing_config_present: o adapter não interpreta a configuração para distribuir owners. hubspot_owner_id é o owner do payload ou o default estático configurado.
Busca, criação e atualização de contato¶
O builder extrai primeiro e sobrenome de nome; sem nome usa firstname="Lead WhatsApp". telefone cadastral prevalece sobre o telefone da sessão. Envia endereço, cidade, estado, CEP, segmento, origem e documento quando conhecidos. O client remove propriedades nulas/vazias, converte telefone para + e dígitos e email para minúsculas sem espaços nas pontas.
O upsert pesquisa antes de criar. Email e telefone formam grupos independentes de filtro EQ, com limit=1. O client adota o primeiro resultado com id: não resolve divergência entre um contato encontrado por email e outro por telefone.
POST <CRM_BASE_URL>/crm/v3/objects/contacts/search
Authorization: Bearer <TOKEN>
{
"filterGroups": [
{"filters":[{"propertyName":"email","operator":"EQ","value":"teste@example.invalid"}]},
{"filters":[{"propertyName":"phone","operator":"EQ","value":"+5500000000000"}]}
],
"properties": ["email", "phone", "firstname", "lastname"],
"limit": 1
}
Resposta de busca {"results":[{"id":"contact-demo"}]} leva a PATCH /crm/v3/objects/contacts/contact-demo. {"results":[]} leva a POST /crm/v3/objects/contacts. O corpo de ambos tem a mesma estrutura:
{
"properties": {
"firstname": "Pessoa",
"lastname": "Exemplo",
"email": "teste@example.invalid",
"phone": "+5500000000000",
"address": "Rua de Exemplo, 100",
"city": "Cidade Exemplo",
"state": "PR",
"zip": "00000000",
"segmento": "PESQUISA",
"origem_lead": "Agente Digital WhatsApp",
"cpf_cnpj": "<CPF_FICTICIO>"
}
}
Exemplos são estruturais; os placeholders e documentos fictícios não são cadastro válido para envio. {"id":"contact-demo"} vira sucesso com esse ID; PATCH também aceita o ID conhecido como fallback. Falta de email e telefone retorna hubspot_missing_contact_identifier sem HTTP. Falha na busca bloqueia o upsert: 404 no endpoint de busca não significa “contato não existe”.
Se POST de contato receber 409, o adapter pesquisa novamente e atualiza o contato encontrado. Sem ID nessa segunda busca, retorna o conflito. Não existe header de idempotência; a proteção é busca prévia + reconsulta em conflito, sujeita à unicidade oferecida pelo servidor.
Negócio, associação e enriquecimento progressivo¶
O endpoint de negócios efetivamente usado é /crm/v3/objects/0-3, não /crm/v3/objects/deals. POST cria; PATCH em /0-3/{id} atualiza. O builder prepara propriedades; o client remove nulos/vazios e completa pipeline, estágio e owner com configuração.
{
"properties": {
"dealname": "Solicitação — Pessoa Exemplo",
"pipeline": "pipeline-demo",
"dealstage": "stage-humano-demo",
"hubspot_owner_id": "owner-demo",
"segmento": "PESQUISA",
"origem_lead": "Agente Digital WhatsApp",
"descricao_solicitacao": "Solicita informações técnicas.",
"historico_conversa": "Cliente aguarda atendimento especializado.",
"qualification_data_json": "objetivo=pesquisa; quantidade_amostras=3",
"telefone": "+5500000000000",
"email": "teste@example.invalid",
"cpf_cnpj": "<CPF_FICTICIO>"
},
"associations": [{
"to": {"id": "contact-demo"},
"types": [{"associationCategory":"HUBSPOT_DEFINED","associationTypeId":3}]
}]
}
servico_solicitado também é enviado quando identificado. qualification_data_json, apesar do nome, é uma string campo=valor; campo=valor, não JSON serializado. historico_conversa recebe o parágrafo do resumo operacional, não o histórico integral. A associação usa exatamente categoria HUBSPOT_DEFINED e tipo 3; valide a semântica no provider compatível ao migrar.
Novos dados geram uma nova projeção de contato/deal. Campos ainda desconhecidos são removidos, preservando no provider propriedades que o PATCH não envia; por isso esse mecanismo não implementa limpeza explícita de campos. Os IDs ficam duráveis em CRM_SYNC#<session_id>. A criação é adiada até o primeiro resultado de sessão confirmado. Veja o fluxo completo em sincronização CRM.
Sucesso, erros e retry¶
{"id":"deal-demo"} no POST vira StubResponse(success=True, mock_id="deal-demo", detail="hubspot_deal_create_success"). Resposta sem ID no POST é falha hubspot_missing_deal_id, error_kind="uncertain"; o servidor pode ter criado o objeto. PATCH aceita ID previamente conhecido como fallback.
| Ocorrência | Client HTTP | Worker |
|---|---|---|
| 400/401/403 | Sem retry local; falha permanente | BLOCKED |
| 404 na busca/PATCH | Falha; não converte em ausência nem cria substituto | BLOCKED, investigar endpoint/ID |
| 409 em contato | Nova busca e possível PATCH | Falha final tratada conforme retorno |
| 409 em negócio | Falha permanente; sem upsert de deal | BLOCKED |
| 429 em criação de negócio | POST não é repetido localmente | PENDING, tentativa posterior |
| Timeout/rede/5xx/JSON inválido em criação | Uma chamada POST; resultado incerto | UNCERTAIN, sem nova criação automática |
| Rede/429/5xx em busca/PATCH | No client comum, até retry_max retries |
Client do worker usa zero retries HTTP; agenda duravelmente |
Client comum: timeout default 10 s por request, retry default 2, espera exponencial min(2**attempt, 8) segundos; HTTP não transitório e resposta JSON inválida não repetem. POST de deal desativa retry mesmo quando configurado. Worker limita timeout a 1–10 s, fixa retry HTTP zero e aplica backoff de 1 a 60 minutos. Erros de contato/ERP síncronos na confirmação podem levar a HANDOFF_PENDING por api_failure, com efeitos externos parciais e sem rollback.
Logs: hubspot_missing_contact_identifier, hubspot_http_failed, hubspot_request_failed, hubspot_invalid_response, hubspot_object_synced. O corpo de erro pode fornecer correlationId/correlation_id ao smoke; o StubResponse normal não expõe esse campo. Não atribua ao CRM erros apenas pelo prefixo hubspot_; confirme a base configurada.
Segurança, testes e migração¶
O client HTTP não registra token nem corpo cadastral no erro. Stubs registram payloads por meio do logger compartilhado; seu scrubbing não cobre automaticamente todos os nomes de propriedades externos, portanto use dados sintéticos nos testes. A API própria deve implementar autenticação, propriedades customizadas, associação, IDs estáveis e classificação HTTP compatíveis.
tests/unit/integrations/test_hubspot_http_client.py cobre base customizada, normalização, busca/criação/PATCH, 409, classificação incerta, POST sem retry, 429 e logging. tests/unit/integrations/test_factory.py verifica configuração. tests/unit/application/test_crm_sync.py cobre concorrência e convergência. src/core/application/smokes/crm_smoke.py oferece health/busca e modo de escrita controlado; não execute modo write sem ambiente e dados acordados.
python -m pytest tests/unit/integrations/test_hubspot_http_client.py -q
Se o novo CRM preservar o contrato, altere URL, token, pipeline/stages/owner, e valide o provider com smokes autorizados. Se o contrato mudar, implemente outro HubSpotClient, ajuste tanto a factory quanto src/crm_sync/app.py e preserve tratamento de uncertain. Não renomeie HUBSPOT_* isoladamente: SAM, scripts, dados persistidos e consumidores precisam migrar juntos.