bfocus 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bfocus-0.2.0 → bfocus-0.2.2}/PKG-INFO +124 -3
- {bfocus-0.2.0 → bfocus-0.2.2}/README.md +123 -2
- {bfocus-0.2.0 → bfocus-0.2.2}/bfocus/_resources.py +51 -9
- {bfocus-0.2.0 → bfocus-0.2.2}/bfocus/_transport.py +7 -0
- {bfocus-0.2.0 → bfocus-0.2.2}/bfocus/_version.py +1 -1
- {bfocus-0.2.0 → bfocus-0.2.2}/bfocus/errors.py +9 -0
- {bfocus-0.2.0 → bfocus-0.2.2}/bfocus/types.py +39 -2
- {bfocus-0.2.0 → bfocus-0.2.2}/pyproject.toml +1 -1
- {bfocus-0.2.0 → bfocus-0.2.2}/tests/fixtures/cases.json +202 -6
- {bfocus-0.2.0 → bfocus-0.2.2}/tests/test_conformance.py +1 -0
- {bfocus-0.2.0 → bfocus-0.2.2}/tests/test_unit.py +26 -0
- {bfocus-0.2.0 → bfocus-0.2.2}/.gitignore +0 -0
- {bfocus-0.2.0 → bfocus-0.2.2}/LICENSE +0 -0
- {bfocus-0.2.0 → bfocus-0.2.2}/bfocus/__init__.py +0 -0
- {bfocus-0.2.0 → bfocus-0.2.2}/bfocus/_client.py +0 -0
- {bfocus-0.2.0 → bfocus-0.2.2}/bfocus/py.typed +0 -0
- {bfocus-0.2.0 → bfocus-0.2.2}/bfocus/widget.py +0 -0
- {bfocus-0.2.0 → bfocus-0.2.2}/examples/quickstart.py +0 -0
- {bfocus-0.2.0 → bfocus-0.2.2}/tests/_support.py +0 -0
- {bfocus-0.2.0 → bfocus-0.2.2}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bfocus
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: SDK oficial em Python da API pública do bFocus: clientes, produtos, release notes, base de conhecimento e agentes de IA.
|
|
5
5
|
Project-URL: Homepage, https://bfocus.com.br
|
|
6
6
|
Project-URL: Repository, https://github.com/bernisoftware/bfocus-python
|
|
@@ -118,7 +118,7 @@ Construir o cliente não faz nenhuma chamada de rede.
|
|
|
118
118
|
| `bf.customers.interactions` | `list`, `list_all`, `create` |
|
|
119
119
|
| `bf.customers.identifiers` | `add`, `remove` |
|
|
120
120
|
| `bf.people` | `upsert`, `list`, `delete`, `batch` |
|
|
121
|
-
| `bf.people.identifiers` | `add`, `remove` |
|
|
121
|
+
| `bf.people.identifiers` | `list`, `add`, `remove` |
|
|
122
122
|
| `bf.products` | `list`, `get`, `upsert`, `archive` |
|
|
123
123
|
| `bf.release_notes` | `list`, `list_all`, `get`, `upsert`, `publish` |
|
|
124
124
|
| `bf.kb` | `search` |
|
|
@@ -196,11 +196,110 @@ bf.people.upsert("erp-1042", "app-77", access=True) # devolve o acesso
|
|
|
196
196
|
|
|
197
197
|
- **Nunca duplica**: se o e-mail (ou o telefone) já pertence a uma pessoa que chegou por e-mail
|
|
198
198
|
ou por outro sistema, ela é **adotada** e ganha o seu `external_id`.
|
|
199
|
-
- A mesma pessoa informada com **outro cliente** é transferida
|
|
199
|
+
- A mesma pessoa informada com **outro cliente** NÃO é transferida: ela é **ligada** também a
|
|
200
|
+
esse cliente e a resposta volta com `linked=True`. O cadastro é único e a mesma pessoa circula
|
|
201
|
+
por vários clientes e vários produtos.
|
|
202
|
+
- **O acesso é do vínculo.** `delete` (e `access=False`) tira o acesso dela NESTE cliente, não nos
|
|
203
|
+
outros: `unlinked=True` na resposta quer dizer que ela segue ativa em algum outro.
|
|
200
204
|
- `delete` **retira o acesso** (devolve a pessoa com `access=False`); ela continua no histórico
|
|
201
205
|
de chamados e conversas. Um `upsert` com `access=True` devolve o acesso.
|
|
202
206
|
- Como nos outros upserts, só o que você passa muda; `name` é obrigatório ao criar.
|
|
203
207
|
|
|
208
|
+
### Campos personalizados da pessoa
|
|
209
|
+
|
|
210
|
+
`custom_fields` leva o que só existe no seu sistema (matrícula, centro de custo, filial). É a
|
|
211
|
+
**exceção** ao "só o que vier muda": a lista enviada **substitui a lista inteira** — campo que
|
|
212
|
+
ficar de fora é **removido**. Mande sempre a lista que o seu sistema tem hoje; omitir o argumento não mexe
|
|
213
|
+
em nada, como em qualquer outro campo.
|
|
214
|
+
|
|
215
|
+
A `visibility` é decidida no bFocus e **preservada entre sincronizações** — por isso ela não vai
|
|
216
|
+
no envio, só volta na resposta: o seu ERP não rebaixa nem promove a exposição de um dado sem
|
|
217
|
+
querer.
|
|
218
|
+
|
|
219
|
+
Vale no upsert de pessoa, no lote de pessoas e na listagem de pessoas do cliente.
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
p = bf.people.upsert(
|
|
223
|
+
"erp-1042",
|
|
224
|
+
"app-77",
|
|
225
|
+
custom_fields=[ # a lista INTEIRA do seu sistema
|
|
226
|
+
{"key": "matricula", "label": "Matrícula", "value": "4471"},
|
|
227
|
+
{"key": "filial", "label": "Filial", "value": "Centro"},
|
|
228
|
+
],
|
|
229
|
+
)
|
|
230
|
+
for campo in p["custom_fields"]:
|
|
231
|
+
print(campo["key"], campo["value"], campo["visibility"]) # visibility vem do bFocus
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Apagar o e-mail ou o telefone da pessoa
|
|
235
|
+
|
|
236
|
+
Um contato gravado errado ficava preso para sempre: enquanto a ficha errada segurasse o
|
|
237
|
+
telefone, nenhum reenvio o soltava. `clear` apaga.
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
bf.people.upsert("erp-1042", "app-77", clear=["phone"]) # some o telefone
|
|
241
|
+
bf.people.upsert("erp-1042", "app-77", clear=["email", "phone"]) # some os dois
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Três regras que parecem contraintuitivas e são de propósito:
|
|
245
|
+
|
|
246
|
+
- **Apagar é explícito.** `phone=None`, `clear=[]` e não passar o argumento continuam
|
|
247
|
+
significando **"não mexe"** — a SDK não traduz `None` em `clear`. Fazer o `None` apagar
|
|
248
|
+
teria apagado, em silêncio e na primeira carga seguinte, o dado de todo sistema que manda
|
|
249
|
+
`None` para "não tenho esse valor".
|
|
250
|
+
- **Campo fora da lista é recusado, não ignorado**: hoje só `"email"` e `"phone"`; qualquer
|
|
251
|
+
outro devolve 422 `PERSON_CLEAR_FIELD_INVALID` (`ValidationError`).
|
|
252
|
+
- **Só se limpa a própria ficha.** Se você alcançou a pessoa por um identificador **extra**, a
|
|
253
|
+
API recusa com 409 `PERSON_CLEAR_NOT_OWN_RECORD` (`ConflictError`): apagar o contato de uma
|
|
254
|
+
ficha alcançada por apelido seria apagar dado de outro sistema. Para saber se o id que você
|
|
255
|
+
tem em mãos é o principal ou um extra, use `bf.people.identifiers.list(...)`.
|
|
256
|
+
|
|
257
|
+
Vale no `people.upsert` e no `people.batch` (`{"clear": ["phone"]}` no item).
|
|
258
|
+
|
|
259
|
+
### Contato já usado: um 409 que você consegue resolver
|
|
260
|
+
|
|
261
|
+
`PERSON_EMAIL_TAKEN` e `PERSON_PHONE_TAKEN` (409) não são "tente de novo": o e-mail (ou o
|
|
262
|
+
telefone) já é de outra pessoa da conta. O erro diz **de quem**, em `err.data` (a API repete o mesmo
|
|
263
|
+
detalhe em `err.validation`, por compatibilidade):
|
|
264
|
+
|
|
265
|
+
| campo | o que é |
|
|
266
|
+
| --- | --- |
|
|
267
|
+
| `field` | `email` ou `phone` — qual contato está tomado |
|
|
268
|
+
| `owner_external_id` | o identificador da pessoa que já usa esse contato |
|
|
269
|
+
| `owner_name` | o nome dela |
|
|
270
|
+
| `owner_customer_external_id` | o cliente a que ela pertence |
|
|
271
|
+
|
|
272
|
+
**É o `owner_customer_external_id` que decide a ação**, e os dois casos pedem coisas opostas:
|
|
273
|
+
|
|
274
|
+
- **mesmo cliente que você enviou** → é quase sempre a MESMA pessoa em dois sistemas. Uma pessoa
|
|
275
|
+
tem **N identificadores**: registre o seu como **extra** dela. A partir daí o seu id encontra
|
|
276
|
+
essa pessoa.
|
|
277
|
+
- **outro cliente** → ninguém decide sozinho a quem a pessoa pertence. Não force: registre o caso
|
|
278
|
+
e leve para quem conhece o cadastro. Unificar dois clientes é decisão de gente, não de um
|
|
279
|
+
casamento por e-mail.
|
|
280
|
+
|
|
281
|
+
```python
|
|
282
|
+
from bfocus import ConflictError
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
bf.people.upsert("erp-1042", "app-77", name="Paula Reis", email="paula@padaria.example")
|
|
286
|
+
except ConflictError as err:
|
|
287
|
+
if err.code not in ("PERSON_EMAIL_TAKEN", "PERSON_PHONE_TAKEN"):
|
|
288
|
+
raise
|
|
289
|
+
dono = err.data
|
|
290
|
+
if dono.get("owner_customer_external_id") == "erp-1042":
|
|
291
|
+
# A mesma pessoa, com dois ids: o seu vira mais um identificador dela.
|
|
292
|
+
bf.people.identifiers.add(dono["owner_external_id"], "app-77", label="ERP")
|
|
293
|
+
else:
|
|
294
|
+
# Dono em OUTRO cliente: não decida sozinho — registre e leve para o cadastro.
|
|
295
|
+
avisar_cadastro(err.code, dono)
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
`PERSON_CONTACT_OTHER_CUSTOMER` (409) é o mesmo assunto pelo outro lado, e é **recusa
|
|
299
|
+
definitiva**: a API não move mais uma pessoa de um cliente para outro só porque o e-mail (ou o
|
|
300
|
+
telefone) casou. Repetir a chamada não resolve — trate como caso para o cadastro, nunca como
|
|
301
|
+
falha temporária.
|
|
302
|
+
|
|
204
303
|
## Lotes
|
|
205
304
|
|
|
206
305
|
`customers.batch` e `people.batch` criam/atualizam **até 500 itens por chamada** (`bfocus.BATCH_MAX`).
|
|
@@ -264,6 +363,23 @@ bf.people.identifiers.remove("app-77", "crm-p5")
|
|
|
264
363
|
|
|
265
364
|
Num lote, um item enviado com um id extra volta com o principal em `merged_into`.
|
|
266
365
|
|
|
366
|
+
### Ler os identificadores da pessoa (para reconciliar)
|
|
367
|
+
|
|
368
|
+
`bf.people.list(...)` mostra só o identificador **principal** de cada pessoa. Quando dois
|
|
369
|
+
cadastros seus eram a mesma pessoa, um dos ids virou **extra** — e some da listagem sem ter
|
|
370
|
+
sumido do cadastro. É isso que faz a sua conferência fechar "633 de 636" sem explicar os 3.
|
|
371
|
+
|
|
372
|
+
`people.identifiers.list` é a fonte de verdade dessa conferência, e é **leitura**: antes dela
|
|
373
|
+
era preciso ESCREVER (tentar um `add`) para descobrir o que tinha acontecido. Aceita no
|
|
374
|
+
caminho o id principal **ou qualquer um dos extras**.
|
|
375
|
+
|
|
376
|
+
```python
|
|
377
|
+
ids = bf.people.identifiers.list("crm-p5") # o id extra que "sumiu" da listagem
|
|
378
|
+
print(ids["external_id"]) # "app-77" — o principal do cadastro
|
|
379
|
+
for i in ids["identifiers"]:
|
|
380
|
+
print(i["external_id"], i["label"], i["source"])
|
|
381
|
+
```
|
|
382
|
+
|
|
267
383
|
## Sincronizar clientes e usuários do seu sistema
|
|
268
384
|
|
|
269
385
|
**Ids com o prefixo do sistema, sem `:`** — a assinatura do widget recusa `:`. Use `-` como
|
|
@@ -476,6 +592,11 @@ Qualquer resposta fora de 2xx levanta `BfocusError` (ou uma subclasse):
|
|
|
476
592
|
| `ServerError` | 5xx |
|
|
477
593
|
| `NetworkError` | conexão/timeout — `status == 0`, `code == "NETWORK_ERROR"` |
|
|
478
594
|
|
|
595
|
+
Além de `code`, `status`, `message`, `request_id`, `validation`, `retry_after` e
|
|
596
|
+
`required_scope`, o erro tem **`data`**: o `data` do corpo, com o detalhe estruturado que alguns
|
|
597
|
+
erros trazem (`{}` quando não há). É por ele que um 409 de contato tomado diz de **quem** é o
|
|
598
|
+
contato — veja [Pessoas](#pessoas).
|
|
599
|
+
|
|
479
600
|
**Decida pelo `code`** — ele é estável (`CUSTOMER_NOT_FOUND`, `INTEGRATION_SCOPE_MISSING`,
|
|
480
601
|
`VALIDATION_ERROR`…). O `message` é texto para humanos e pode mudar. Ao falar com o suporte,
|
|
481
602
|
informe o `request_id`: ele vem do corpo da resposta, senão do header `X-Request-Id`, senão é o id
|
|
@@ -91,7 +91,7 @@ Construir o cliente não faz nenhuma chamada de rede.
|
|
|
91
91
|
| `bf.customers.interactions` | `list`, `list_all`, `create` |
|
|
92
92
|
| `bf.customers.identifiers` | `add`, `remove` |
|
|
93
93
|
| `bf.people` | `upsert`, `list`, `delete`, `batch` |
|
|
94
|
-
| `bf.people.identifiers` | `add`, `remove` |
|
|
94
|
+
| `bf.people.identifiers` | `list`, `add`, `remove` |
|
|
95
95
|
| `bf.products` | `list`, `get`, `upsert`, `archive` |
|
|
96
96
|
| `bf.release_notes` | `list`, `list_all`, `get`, `upsert`, `publish` |
|
|
97
97
|
| `bf.kb` | `search` |
|
|
@@ -169,11 +169,110 @@ bf.people.upsert("erp-1042", "app-77", access=True) # devolve o acesso
|
|
|
169
169
|
|
|
170
170
|
- **Nunca duplica**: se o e-mail (ou o telefone) já pertence a uma pessoa que chegou por e-mail
|
|
171
171
|
ou por outro sistema, ela é **adotada** e ganha o seu `external_id`.
|
|
172
|
-
- A mesma pessoa informada com **outro cliente** é transferida
|
|
172
|
+
- A mesma pessoa informada com **outro cliente** NÃO é transferida: ela é **ligada** também a
|
|
173
|
+
esse cliente e a resposta volta com `linked=True`. O cadastro é único e a mesma pessoa circula
|
|
174
|
+
por vários clientes e vários produtos.
|
|
175
|
+
- **O acesso é do vínculo.** `delete` (e `access=False`) tira o acesso dela NESTE cliente, não nos
|
|
176
|
+
outros: `unlinked=True` na resposta quer dizer que ela segue ativa em algum outro.
|
|
173
177
|
- `delete` **retira o acesso** (devolve a pessoa com `access=False`); ela continua no histórico
|
|
174
178
|
de chamados e conversas. Um `upsert` com `access=True` devolve o acesso.
|
|
175
179
|
- Como nos outros upserts, só o que você passa muda; `name` é obrigatório ao criar.
|
|
176
180
|
|
|
181
|
+
### Campos personalizados da pessoa
|
|
182
|
+
|
|
183
|
+
`custom_fields` leva o que só existe no seu sistema (matrícula, centro de custo, filial). É a
|
|
184
|
+
**exceção** ao "só o que vier muda": a lista enviada **substitui a lista inteira** — campo que
|
|
185
|
+
ficar de fora é **removido**. Mande sempre a lista que o seu sistema tem hoje; omitir o argumento não mexe
|
|
186
|
+
em nada, como em qualquer outro campo.
|
|
187
|
+
|
|
188
|
+
A `visibility` é decidida no bFocus e **preservada entre sincronizações** — por isso ela não vai
|
|
189
|
+
no envio, só volta na resposta: o seu ERP não rebaixa nem promove a exposição de um dado sem
|
|
190
|
+
querer.
|
|
191
|
+
|
|
192
|
+
Vale no upsert de pessoa, no lote de pessoas e na listagem de pessoas do cliente.
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
p = bf.people.upsert(
|
|
196
|
+
"erp-1042",
|
|
197
|
+
"app-77",
|
|
198
|
+
custom_fields=[ # a lista INTEIRA do seu sistema
|
|
199
|
+
{"key": "matricula", "label": "Matrícula", "value": "4471"},
|
|
200
|
+
{"key": "filial", "label": "Filial", "value": "Centro"},
|
|
201
|
+
],
|
|
202
|
+
)
|
|
203
|
+
for campo in p["custom_fields"]:
|
|
204
|
+
print(campo["key"], campo["value"], campo["visibility"]) # visibility vem do bFocus
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Apagar o e-mail ou o telefone da pessoa
|
|
208
|
+
|
|
209
|
+
Um contato gravado errado ficava preso para sempre: enquanto a ficha errada segurasse o
|
|
210
|
+
telefone, nenhum reenvio o soltava. `clear` apaga.
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
bf.people.upsert("erp-1042", "app-77", clear=["phone"]) # some o telefone
|
|
214
|
+
bf.people.upsert("erp-1042", "app-77", clear=["email", "phone"]) # some os dois
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Três regras que parecem contraintuitivas e são de propósito:
|
|
218
|
+
|
|
219
|
+
- **Apagar é explícito.** `phone=None`, `clear=[]` e não passar o argumento continuam
|
|
220
|
+
significando **"não mexe"** — a SDK não traduz `None` em `clear`. Fazer o `None` apagar
|
|
221
|
+
teria apagado, em silêncio e na primeira carga seguinte, o dado de todo sistema que manda
|
|
222
|
+
`None` para "não tenho esse valor".
|
|
223
|
+
- **Campo fora da lista é recusado, não ignorado**: hoje só `"email"` e `"phone"`; qualquer
|
|
224
|
+
outro devolve 422 `PERSON_CLEAR_FIELD_INVALID` (`ValidationError`).
|
|
225
|
+
- **Só se limpa a própria ficha.** Se você alcançou a pessoa por um identificador **extra**, a
|
|
226
|
+
API recusa com 409 `PERSON_CLEAR_NOT_OWN_RECORD` (`ConflictError`): apagar o contato de uma
|
|
227
|
+
ficha alcançada por apelido seria apagar dado de outro sistema. Para saber se o id que você
|
|
228
|
+
tem em mãos é o principal ou um extra, use `bf.people.identifiers.list(...)`.
|
|
229
|
+
|
|
230
|
+
Vale no `people.upsert` e no `people.batch` (`{"clear": ["phone"]}` no item).
|
|
231
|
+
|
|
232
|
+
### Contato já usado: um 409 que você consegue resolver
|
|
233
|
+
|
|
234
|
+
`PERSON_EMAIL_TAKEN` e `PERSON_PHONE_TAKEN` (409) não são "tente de novo": o e-mail (ou o
|
|
235
|
+
telefone) já é de outra pessoa da conta. O erro diz **de quem**, em `err.data` (a API repete o mesmo
|
|
236
|
+
detalhe em `err.validation`, por compatibilidade):
|
|
237
|
+
|
|
238
|
+
| campo | o que é |
|
|
239
|
+
| --- | --- |
|
|
240
|
+
| `field` | `email` ou `phone` — qual contato está tomado |
|
|
241
|
+
| `owner_external_id` | o identificador da pessoa que já usa esse contato |
|
|
242
|
+
| `owner_name` | o nome dela |
|
|
243
|
+
| `owner_customer_external_id` | o cliente a que ela pertence |
|
|
244
|
+
|
|
245
|
+
**É o `owner_customer_external_id` que decide a ação**, e os dois casos pedem coisas opostas:
|
|
246
|
+
|
|
247
|
+
- **mesmo cliente que você enviou** → é quase sempre a MESMA pessoa em dois sistemas. Uma pessoa
|
|
248
|
+
tem **N identificadores**: registre o seu como **extra** dela. A partir daí o seu id encontra
|
|
249
|
+
essa pessoa.
|
|
250
|
+
- **outro cliente** → ninguém decide sozinho a quem a pessoa pertence. Não force: registre o caso
|
|
251
|
+
e leve para quem conhece o cadastro. Unificar dois clientes é decisão de gente, não de um
|
|
252
|
+
casamento por e-mail.
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
from bfocus import ConflictError
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
bf.people.upsert("erp-1042", "app-77", name="Paula Reis", email="paula@padaria.example")
|
|
259
|
+
except ConflictError as err:
|
|
260
|
+
if err.code not in ("PERSON_EMAIL_TAKEN", "PERSON_PHONE_TAKEN"):
|
|
261
|
+
raise
|
|
262
|
+
dono = err.data
|
|
263
|
+
if dono.get("owner_customer_external_id") == "erp-1042":
|
|
264
|
+
# A mesma pessoa, com dois ids: o seu vira mais um identificador dela.
|
|
265
|
+
bf.people.identifiers.add(dono["owner_external_id"], "app-77", label="ERP")
|
|
266
|
+
else:
|
|
267
|
+
# Dono em OUTRO cliente: não decida sozinho — registre e leve para o cadastro.
|
|
268
|
+
avisar_cadastro(err.code, dono)
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`PERSON_CONTACT_OTHER_CUSTOMER` (409) é o mesmo assunto pelo outro lado, e é **recusa
|
|
272
|
+
definitiva**: a API não move mais uma pessoa de um cliente para outro só porque o e-mail (ou o
|
|
273
|
+
telefone) casou. Repetir a chamada não resolve — trate como caso para o cadastro, nunca como
|
|
274
|
+
falha temporária.
|
|
275
|
+
|
|
177
276
|
## Lotes
|
|
178
277
|
|
|
179
278
|
`customers.batch` e `people.batch` criam/atualizam **até 500 itens por chamada** (`bfocus.BATCH_MAX`).
|
|
@@ -237,6 +336,23 @@ bf.people.identifiers.remove("app-77", "crm-p5")
|
|
|
237
336
|
|
|
238
337
|
Num lote, um item enviado com um id extra volta com o principal em `merged_into`.
|
|
239
338
|
|
|
339
|
+
### Ler os identificadores da pessoa (para reconciliar)
|
|
340
|
+
|
|
341
|
+
`bf.people.list(...)` mostra só o identificador **principal** de cada pessoa. Quando dois
|
|
342
|
+
cadastros seus eram a mesma pessoa, um dos ids virou **extra** — e some da listagem sem ter
|
|
343
|
+
sumido do cadastro. É isso que faz a sua conferência fechar "633 de 636" sem explicar os 3.
|
|
344
|
+
|
|
345
|
+
`people.identifiers.list` é a fonte de verdade dessa conferência, e é **leitura**: antes dela
|
|
346
|
+
era preciso ESCREVER (tentar um `add`) para descobrir o que tinha acontecido. Aceita no
|
|
347
|
+
caminho o id principal **ou qualquer um dos extras**.
|
|
348
|
+
|
|
349
|
+
```python
|
|
350
|
+
ids = bf.people.identifiers.list("crm-p5") # o id extra que "sumiu" da listagem
|
|
351
|
+
print(ids["external_id"]) # "app-77" — o principal do cadastro
|
|
352
|
+
for i in ids["identifiers"]:
|
|
353
|
+
print(i["external_id"], i["label"], i["source"])
|
|
354
|
+
```
|
|
355
|
+
|
|
240
356
|
## Sincronizar clientes e usuários do seu sistema
|
|
241
357
|
|
|
242
358
|
**Ids com o prefixo do sistema, sem `:`** — a assinatura do widget recusa `:`. Use `-` como
|
|
@@ -449,6 +565,11 @@ Qualquer resposta fora de 2xx levanta `BfocusError` (ou uma subclasse):
|
|
|
449
565
|
| `ServerError` | 5xx |
|
|
450
566
|
| `NetworkError` | conexão/timeout — `status == 0`, `code == "NETWORK_ERROR"` |
|
|
451
567
|
|
|
568
|
+
Além de `code`, `status`, `message`, `request_id`, `validation`, `retry_after` e
|
|
569
|
+
`required_scope`, o erro tem **`data`**: o `data` do corpo, com o detalhe estruturado que alguns
|
|
570
|
+
erros trazem (`{}` quando não há). É por ele que um 409 de contato tomado diz de **quem** é o
|
|
571
|
+
contato — veja [Pessoas](#pessoas).
|
|
572
|
+
|
|
452
573
|
**Decida pelo `code`** — ele é estável (`CUSTOMER_NOT_FOUND`, `INTEGRATION_SCOPE_MISSING`,
|
|
453
574
|
`VALIDATION_ERROR`…). O `message` é texto para humanos e pode mudar. Ao falar com o suporte,
|
|
454
575
|
informe o `request_id`: ele vem do corpo da resposta, senão do header `X-Request-Id`, senão é o id
|
|
@@ -52,6 +52,7 @@ from .types import (
|
|
|
52
52
|
Person,
|
|
53
53
|
PersonBatchItem,
|
|
54
54
|
PersonIdentifiers,
|
|
55
|
+
PersonRevokeResult,
|
|
55
56
|
PersonUpsertResult,
|
|
56
57
|
Product,
|
|
57
58
|
ProductRef,
|
|
@@ -150,12 +151,12 @@ def _identifier_label(label: Any) -> Optional[Dict[str, Any]]:
|
|
|
150
151
|
return None if label is UNSET else {"label": label}
|
|
151
152
|
|
|
152
153
|
|
|
153
|
-
def _strings(value: Any) -> Any:
|
|
154
|
+
def _strings(value: Any, field: str = "extra_emails/extra_phones") -> Any:
|
|
154
155
|
"""Sequência de strings → lista (preserva UNSET/None). String solta é erro (viraria letras)."""
|
|
155
156
|
if value is UNSET or value is None:
|
|
156
157
|
return value
|
|
157
158
|
if isinstance(value, (str, bytes)):
|
|
158
|
-
raise TypeError("
|
|
159
|
+
raise TypeError(f"{field} precisa ser uma lista de strings.")
|
|
159
160
|
return list(value)
|
|
160
161
|
|
|
161
162
|
|
|
@@ -554,6 +555,26 @@ class Customers(_Resource):
|
|
|
554
555
|
class PeopleIdentifiers(_Resource):
|
|
555
556
|
"""Identificadores extras de uma pessoa — ``client.people.identifiers``."""
|
|
556
557
|
|
|
558
|
+
def list(
|
|
559
|
+
self, person_external_id: str, *, timeout: Optional[float] = None
|
|
560
|
+
) -> PersonIdentifiers:
|
|
561
|
+
"""Todos os identificadores da pessoa: o principal + os extras.
|
|
562
|
+
|
|
563
|
+
``GET /people/{person_external_id}/identifiers``. Escopo ``customers:read``. Aceita
|
|
564
|
+
no caminho o identificador **principal ou qualquer um dos extras** — ``external_id``
|
|
565
|
+
no retorno é sempre o principal.
|
|
566
|
+
|
|
567
|
+
É a fonte de verdade para **reconciliar**: :meth:`People.list` mostra só o
|
|
568
|
+
identificador principal de cada pessoa, então um id que virou extra (porque dois
|
|
569
|
+
cadastros seus eram a mesma pessoa) some de lá sem ter sumido do cadastro. Sem esta
|
|
570
|
+
leitura era preciso ESCREVER (um ``add``) para descobrir o que tinha acontecido.
|
|
571
|
+
|
|
572
|
+
Pessoa inexistente: ``NotFoundError`` com ``code == "PERSON_NOT_FOUND"``.
|
|
573
|
+
"""
|
|
574
|
+
pid = path_segment(person_external_id, "person_external_id")
|
|
575
|
+
data, _ = self._t.request("GET", f"/people/{pid}/identifiers", timeout=timeout)
|
|
576
|
+
return cast(PersonIdentifiers, data)
|
|
577
|
+
|
|
557
578
|
def add(
|
|
558
579
|
self,
|
|
559
580
|
person_external_id: str,
|
|
@@ -624,6 +645,8 @@ class People(_Resource):
|
|
|
624
645
|
is_primary: MaybeUnset[Optional[bool]] = UNSET,
|
|
625
646
|
extra_emails: MaybeUnset[Optional[Sequence[str]]] = UNSET,
|
|
626
647
|
extra_phones: MaybeUnset[Optional[Sequence[str]]] = UNSET,
|
|
648
|
+
custom_fields: MaybeUnset[Optional[Sequence[CustomFieldInput]]] = UNSET,
|
|
649
|
+
clear: MaybeUnset[Optional[Sequence[str]]] = UNSET,
|
|
627
650
|
idempotency_key: Optional[str] = None,
|
|
628
651
|
timeout: Optional[float] = None,
|
|
629
652
|
) -> PersonUpsertResult:
|
|
@@ -632,7 +655,8 @@ class People(_Resource):
|
|
|
632
655
|
``PUT /customers/{customer_external_id}/people/{person_external_id}``. O ``status``
|
|
633
656
|
do retorno diz ``"created"``, ``"updated"`` ou ``"unchanged"``. Se o e-mail (ou o
|
|
634
657
|
telefone) já pertence a uma pessoa que chegou por outro caminho, ela é **adotada**
|
|
635
|
-
(nunca duplicada); a mesma pessoa informada com outro cliente é
|
|
658
|
+
(nunca duplicada); a mesma pessoa informada com outro cliente é LIGADA a ele também
|
|
659
|
+
(cadastro único em N clientes) e ``linked`` volta ``True``.
|
|
636
660
|
|
|
637
661
|
Args:
|
|
638
662
|
name: Obrigatório ao criar.
|
|
@@ -640,6 +664,19 @@ class People(_Resource):
|
|
|
640
664
|
acesso retirado por :meth:`delete`.
|
|
641
665
|
extra_emails: E-mails adicionais (somam aos que já existem).
|
|
642
666
|
extra_phones: Telefones adicionais (somam aos que já existem).
|
|
667
|
+
custom_fields: Campos personalizados da pessoa, ``{"key", "label", "value"}``.
|
|
668
|
+
Ao contrário de ``extra_emails``/``extra_phones``, a lista **substitui** a
|
|
669
|
+
lista inteira: mande o que o seu sistema tem HOJE, porque campo que ficou de
|
|
670
|
+
fora é REMOVIDO. Omitir o argumento não mexe em nada. A ``visibility`` é
|
|
671
|
+
decidida no bFocus e preservada entre sincronizações.
|
|
672
|
+
clear: Campos a **apagar** nesta pessoa — hoje ``["email"]``, ``["phone"]`` ou os
|
|
673
|
+
dois. Apagar é EXPLÍCITO: ``email=None`` (e a lista vazia, e omitir o
|
|
674
|
+
argumento) continua significando "não mexe", nunca "apague". Campo fora da
|
|
675
|
+
lista aceita é RECUSADO pela API (422 ``PERSON_CLEAR_FIELD_INVALID``), não
|
|
676
|
+
ignorado. E só se limpa a PRÓPRIA ficha: se você alcançou a pessoa por um
|
|
677
|
+
identificador EXTRA, a API recusa (409 ``PERSON_CLEAR_NOT_OWN_RECORD``) —
|
|
678
|
+
apagar contato de ficha alcançada por apelido seria apagar dado de outro
|
|
679
|
+
sistema.
|
|
643
680
|
"""
|
|
644
681
|
cext = path_segment(customer_external_id, "customer_external_id")
|
|
645
682
|
pid = path_segment(person_external_id, "person_external_id")
|
|
@@ -653,6 +690,8 @@ class People(_Resource):
|
|
|
653
690
|
"is_primary": is_primary,
|
|
654
691
|
"extra_emails": _strings(extra_emails),
|
|
655
692
|
"extra_phones": _strings(extra_phones),
|
|
693
|
+
"custom_fields": _dicts(custom_fields),
|
|
694
|
+
"clear": _strings(clear, "clear"),
|
|
656
695
|
}
|
|
657
696
|
)
|
|
658
697
|
data, _ = self._t.request(
|
|
@@ -674,12 +713,13 @@ class People(_Resource):
|
|
|
674
713
|
*,
|
|
675
714
|
idempotency_key: Optional[str] = None,
|
|
676
715
|
timeout: Optional[float] = None,
|
|
677
|
-
) ->
|
|
678
|
-
"""Retira o acesso da pessoa (devolve a pessoa com ``access=False``).
|
|
716
|
+
) -> PersonRevokeResult:
|
|
717
|
+
"""Retira o acesso da pessoa NESTE cliente (devolve a pessoa com ``access=False``).
|
|
679
718
|
|
|
680
719
|
``DELETE /customers/{customer_external_id}/people/{person_external_id}``. A pessoa
|
|
681
720
|
continua no histórico (chamados, conversas); :meth:`upsert` com ``access=True``
|
|
682
|
-
devolve o acesso.
|
|
721
|
+
devolve o acesso. O acesso é DO VÍNCULO: se ela também é de outros clientes, continua
|
|
722
|
+
ativa neles e a resposta volta com ``unlinked=True``.
|
|
683
723
|
"""
|
|
684
724
|
cext = path_segment(customer_external_id, "customer_external_id")
|
|
685
725
|
pid = path_segment(person_external_id, "person_external_id")
|
|
@@ -687,7 +727,7 @@ class People(_Resource):
|
|
|
687
727
|
"DELETE", f"/customers/{cext}/people/{pid}",
|
|
688
728
|
idempotency_key=idempotency_key, timeout=timeout,
|
|
689
729
|
)
|
|
690
|
-
return cast(
|
|
730
|
+
return cast(PersonRevokeResult, data)
|
|
691
731
|
|
|
692
732
|
def batch(
|
|
693
733
|
self,
|
|
@@ -712,9 +752,11 @@ class People(_Resource):
|
|
|
712
752
|
customer = _required_id(op, index, item, "customer_external_id")
|
|
713
753
|
_required_id(op, index, item, "external_id")
|
|
714
754
|
person = compact({k: v for k, v in item.items() if k != "customer_external_id"})
|
|
715
|
-
for field in ("extra_emails", "extra_phones"):
|
|
755
|
+
for field in ("extra_emails", "extra_phones", "clear"):
|
|
716
756
|
if field in person:
|
|
717
|
-
person[field] = _strings(person[field])
|
|
757
|
+
person[field] = _strings(person[field], field)
|
|
758
|
+
if "custom_fields" in person:
|
|
759
|
+
person["custom_fields"] = _dicts(person["custom_fields"])
|
|
718
760
|
body_items.append({"customer_external_id": customer, "person": person})
|
|
719
761
|
if not body_items:
|
|
720
762
|
return _empty_batch()
|
|
@@ -142,6 +142,7 @@ def build_error(
|
|
|
142
142
|
code: Optional[str] = None
|
|
143
143
|
human: Optional[str] = None
|
|
144
144
|
validation: Dict[str, Any] = {}
|
|
145
|
+
data: Dict[str, Any] = {}
|
|
145
146
|
request_id: Optional[str] = None
|
|
146
147
|
|
|
147
148
|
if isinstance(payload, dict):
|
|
@@ -154,6 +155,11 @@ def build_error(
|
|
|
154
155
|
human = msg
|
|
155
156
|
if isinstance(payload.get("validation"), dict):
|
|
156
157
|
validation = payload["validation"]
|
|
158
|
+
# `data` é o detalhe estruturado do erro (de quem é o contato já usado, o dono de um
|
|
159
|
+
# identificador…). A API também o repete em `validation`, mas quem lê o erro precisa
|
|
160
|
+
# alcançá-lo sem depender dessa duplicação.
|
|
161
|
+
if isinstance(payload.get("data"), dict):
|
|
162
|
+
data = payload["data"]
|
|
157
163
|
rid = payload.get("request_id")
|
|
158
164
|
if isinstance(rid, str) and rid:
|
|
159
165
|
request_id = rid
|
|
@@ -184,6 +190,7 @@ def build_error(
|
|
|
184
190
|
status,
|
|
185
191
|
request_id=request_id,
|
|
186
192
|
validation=validation,
|
|
193
|
+
data=data,
|
|
187
194
|
retry_after=retry_after,
|
|
188
195
|
required_scope=required_scope,
|
|
189
196
|
body=payload if payload is not None else (text or None),
|
|
@@ -38,6 +38,13 @@ class BfocusError(Exception):
|
|
|
38
38
|
``X-Request-Id`` que a SDK enviou (a API ecoa o do cliente) — sempre preenchido;
|
|
39
39
|
informe-o ao suporte.
|
|
40
40
|
validation: Mapa campo → motivo (erros de validação); ``{}`` quando não há.
|
|
41
|
+
data: O ``data`` do corpo do erro — o detalhe estruturado que alguns erros trazem;
|
|
42
|
+
``{}`` quando não há. É onde vem, por exemplo, de quem é o contato já usado num
|
|
43
|
+
409 ``PERSON_EMAIL_TAKEN``/``PERSON_PHONE_TAKEN`` (``field``,
|
|
44
|
+
``owner_external_id``, ``owner_name``, ``owner_customer_external_id``) e o
|
|
45
|
+
``owner`` de um ``IDENTIFIER_IN_USE``. A API repete esse detalhe em
|
|
46
|
+
:attr:`validation`, por compatibilidade com as SDKs que ainda não expunham
|
|
47
|
+
``data``.
|
|
41
48
|
retry_after: Segundos sugeridos pelo header ``Retry-After`` (só em 429).
|
|
42
49
|
required_scope: Escopo que faltou na chave (header ``X-Required-Scope``, só em 403).
|
|
43
50
|
body: Corpo da resposta já decodificado (dict), ou o texto cru quando não é JSON.
|
|
@@ -54,6 +61,7 @@ class BfocusError(Exception):
|
|
|
54
61
|
retry_after: Optional[float] = None,
|
|
55
62
|
required_scope: Optional[str] = None,
|
|
56
63
|
body: Any = None,
|
|
64
|
+
data: Optional[Dict[str, Any]] = None,
|
|
57
65
|
) -> None:
|
|
58
66
|
super().__init__(message)
|
|
59
67
|
self.code = code
|
|
@@ -61,6 +69,7 @@ class BfocusError(Exception):
|
|
|
61
69
|
self.status = status
|
|
62
70
|
self.request_id = request_id
|
|
63
71
|
self.validation: Dict[str, Any] = dict(validation or {})
|
|
72
|
+
self.data: Dict[str, Any] = dict(data or {})
|
|
64
73
|
self.retry_after = retry_after
|
|
65
74
|
self.required_scope = required_scope
|
|
66
75
|
self.body = body
|
|
@@ -36,6 +36,7 @@ __all__ = [
|
|
|
36
36
|
"AgentPreview",
|
|
37
37
|
"Deleted",
|
|
38
38
|
"Person",
|
|
39
|
+
"PersonRevokeResult",
|
|
39
40
|
"PersonUpsertResult",
|
|
40
41
|
"Identifier",
|
|
41
42
|
"CustomerWithIdentifiers",
|
|
@@ -124,7 +125,12 @@ class _CustomFieldInputRequired(TypedDict):
|
|
|
124
125
|
|
|
125
126
|
|
|
126
127
|
class CustomFieldInput(_CustomFieldInputRequired, total=False):
|
|
127
|
-
"""Campo personalizado de cliente
|
|
128
|
+
"""Campo personalizado de cliente **ou de pessoa**. Enviar a lista SUBSTITUI a lista inteira.
|
|
129
|
+
|
|
130
|
+
Em pessoa, ``visibility`` **não** é aceito aqui: quem vê o campo é decisão do bFocus e é
|
|
131
|
+
preservada entre sincronizações (o seu sistema não rebaixa nem promove a exposição de um
|
|
132
|
+
dado sem querer).
|
|
133
|
+
"""
|
|
128
134
|
|
|
129
135
|
label: str
|
|
130
136
|
type: Literal[
|
|
@@ -188,6 +194,10 @@ class PersonBatchItem(_PersonBatchItemRequired, total=False):
|
|
|
188
194
|
is_primary: Optional[bool]
|
|
189
195
|
extra_emails: Optional[List[str]]
|
|
190
196
|
extra_phones: Optional[List[str]]
|
|
197
|
+
custom_fields: Optional[List[CustomFieldInput]]
|
|
198
|
+
#: Campos a APAGAR nesta pessoa (hoje ``"email"`` e/ou ``"phone"``). Apagar é explícito:
|
|
199
|
+
#: ``None``, lista vazia ou chave ausente continuam significando "não mexe".
|
|
200
|
+
clear: Optional[List[str]]
|
|
191
201
|
|
|
192
202
|
|
|
193
203
|
class AgentTurn(TypedDict):
|
|
@@ -219,6 +229,12 @@ class Customer(TypedDict):
|
|
|
219
229
|
notes: Optional[str]
|
|
220
230
|
custom_fields: List[CustomField]
|
|
221
231
|
is_active: bool
|
|
232
|
+
#: Logotipo do cliente, como a equipe subiu no bFocus (``None`` = sem logotipo).
|
|
233
|
+
logo_url: Optional[str]
|
|
234
|
+
#: E-mails adicionais do cliente (o principal é ``email``).
|
|
235
|
+
extra_emails: List[str]
|
|
236
|
+
#: Telefones adicionais do cliente (o principal é ``phone``).
|
|
237
|
+
extra_phones: List[str]
|
|
222
238
|
created_at: Optional[str]
|
|
223
239
|
updated_at: Optional[str]
|
|
224
240
|
|
|
@@ -375,12 +391,31 @@ class Person(TypedDict):
|
|
|
375
391
|
access: bool
|
|
376
392
|
is_primary: bool
|
|
377
393
|
customer_external_id: str
|
|
394
|
+
custom_fields: List[CustomField]
|
|
395
|
+
#: Identificadores EXTRAS desta pessoa: os outros ids pelos quais ela também é encontrada.
|
|
396
|
+
#: É por aqui que você descobre que o id do SEU sistema virou apelido de outra ficha.
|
|
397
|
+
identifiers: List["Identifier"]
|
|
378
398
|
|
|
379
399
|
|
|
380
400
|
class PersonUpsertResult(Person):
|
|
381
|
-
"""Retorno de ``people.upsert``: a pessoa + ``status``."""
|
|
401
|
+
"""Retorno de ``people.upsert``: a pessoa + ``status`` + ``linked``."""
|
|
382
402
|
|
|
383
403
|
status: Literal["created", "updated", "unchanged"]
|
|
404
|
+
#: ``True`` = a pessoa JÁ EXISTIA em outro cliente e este envio a ligou também a este.
|
|
405
|
+
#: O cadastro é único e ela circula pelos dois; nada foi transferido nem duplicado.
|
|
406
|
+
linked: bool
|
|
407
|
+
#: Preenchido quando o id que você enviou é um APELIDO: este é o principal do cadastro.
|
|
408
|
+
merged_into: Optional[str]
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class PersonRevokeResult(Person):
|
|
412
|
+
"""Retorno de ``people.delete``: a pessoa + se ela apenas SAIU deste cliente.
|
|
413
|
+
|
|
414
|
+
``unlinked=True`` = ela continua com acesso, porque também é de outros clientes; o acesso é
|
|
415
|
+
do vínculo. ``False`` = era só deste cliente e foi desligada, como sempre.
|
|
416
|
+
"""
|
|
417
|
+
|
|
418
|
+
unlinked: bool
|
|
384
419
|
|
|
385
420
|
|
|
386
421
|
class Identifier(TypedDict):
|
|
@@ -414,6 +449,8 @@ class BatchItemResult(TypedDict):
|
|
|
414
449
|
status: Literal["created", "updated", "unchanged", "error"]
|
|
415
450
|
external_id: Optional[str]
|
|
416
451
|
merged_into: Optional[str]
|
|
452
|
+
#: A pessoa já existia em outro cliente e este item a ligou também a este (cadastro único).
|
|
453
|
+
linked: bool
|
|
417
454
|
error: Optional[str]
|
|
418
455
|
code: Optional[int]
|
|
419
456
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "bfocus"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "SDK oficial em Python da API pública do bFocus: clientes, produtos, release notes, base de conhecimento e agentes de IA."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -69,6 +69,9 @@
|
|
|
69
69
|
}
|
|
70
70
|
],
|
|
71
71
|
"is_active": true,
|
|
72
|
+
"logo_url": null,
|
|
73
|
+
"extra_emails": [],
|
|
74
|
+
"extra_phones": [],
|
|
72
75
|
"created_at": "2026-09-13T12:00:00+00:00",
|
|
73
76
|
"updated_at": "2026-09-13T12:00:00+00:00"
|
|
74
77
|
},
|
|
@@ -97,6 +100,9 @@
|
|
|
97
100
|
}
|
|
98
101
|
],
|
|
99
102
|
"is_active": true,
|
|
103
|
+
"logo_url": null,
|
|
104
|
+
"extra_emails": [],
|
|
105
|
+
"extra_phones": [],
|
|
100
106
|
"created_at": "2026-09-13T12:00:00+00:00",
|
|
101
107
|
"updated_at": "2026-09-13T12:00:00+00:00"
|
|
102
108
|
}
|
|
@@ -144,6 +150,9 @@
|
|
|
144
150
|
}
|
|
145
151
|
],
|
|
146
152
|
"is_active": true,
|
|
153
|
+
"logo_url": null,
|
|
154
|
+
"extra_emails": [],
|
|
155
|
+
"extra_phones": [],
|
|
147
156
|
"created_at": "2026-09-13T12:00:00+00:00",
|
|
148
157
|
"updated_at": "2026-09-13T12:00:00+00:00"
|
|
149
158
|
},
|
|
@@ -172,6 +181,9 @@
|
|
|
172
181
|
}
|
|
173
182
|
],
|
|
174
183
|
"is_active": true,
|
|
184
|
+
"logo_url": null,
|
|
185
|
+
"extra_emails": [],
|
|
186
|
+
"extra_phones": [],
|
|
175
187
|
"created_at": "2026-09-13T12:00:00+00:00",
|
|
176
188
|
"updated_at": "2026-09-13T12:00:00+00:00"
|
|
177
189
|
}
|
|
@@ -216,6 +228,9 @@
|
|
|
216
228
|
}
|
|
217
229
|
],
|
|
218
230
|
"is_active": true,
|
|
231
|
+
"logo_url": null,
|
|
232
|
+
"extra_emails": [],
|
|
233
|
+
"extra_phones": [],
|
|
219
234
|
"created_at": "2026-09-13T12:00:00+00:00",
|
|
220
235
|
"updated_at": "2026-09-13T12:00:00+00:00"
|
|
221
236
|
},
|
|
@@ -244,6 +259,9 @@
|
|
|
244
259
|
}
|
|
245
260
|
],
|
|
246
261
|
"is_active": true,
|
|
262
|
+
"logo_url": null,
|
|
263
|
+
"extra_emails": [],
|
|
264
|
+
"extra_phones": [],
|
|
247
265
|
"created_at": "2026-09-13T12:00:00+00:00",
|
|
248
266
|
"updated_at": "2026-09-13T12:00:00+00:00"
|
|
249
267
|
}
|
|
@@ -327,6 +345,9 @@
|
|
|
327
345
|
"notes": null,
|
|
328
346
|
"custom_fields": [],
|
|
329
347
|
"is_active": true,
|
|
348
|
+
"logo_url": null,
|
|
349
|
+
"extra_emails": [],
|
|
350
|
+
"extra_phones": [],
|
|
330
351
|
"created_at": "2026-09-13T12:00:00+00:00",
|
|
331
352
|
"updated_at": "2026-09-13T12:00:00+00:00"
|
|
332
353
|
}
|
|
@@ -356,6 +377,9 @@
|
|
|
356
377
|
"notes": null,
|
|
357
378
|
"custom_fields": [],
|
|
358
379
|
"is_active": true,
|
|
380
|
+
"logo_url": null,
|
|
381
|
+
"extra_emails": [],
|
|
382
|
+
"extra_phones": [],
|
|
359
383
|
"created_at": "2026-09-13T12:00:00+00:00",
|
|
360
384
|
"updated_at": "2026-09-13T12:00:00+00:00"
|
|
361
385
|
}
|
|
@@ -410,6 +434,9 @@
|
|
|
410
434
|
}
|
|
411
435
|
],
|
|
412
436
|
"is_active": true,
|
|
437
|
+
"logo_url": null,
|
|
438
|
+
"extra_emails": [],
|
|
439
|
+
"extra_phones": [],
|
|
413
440
|
"created_at": "2026-09-13T12:00:00+00:00",
|
|
414
441
|
"updated_at": "2026-09-13T12:00:00+00:00"
|
|
415
442
|
}
|
|
@@ -452,6 +479,9 @@
|
|
|
452
479
|
"notes": null,
|
|
453
480
|
"custom_fields": [],
|
|
454
481
|
"is_active": true,
|
|
482
|
+
"logo_url": null,
|
|
483
|
+
"extra_emails": [],
|
|
484
|
+
"extra_phones": [],
|
|
455
485
|
"created_at": "2026-09-13T12:00:00+00:00",
|
|
456
486
|
"updated_at": "2026-09-13T12:00:00+00:00"
|
|
457
487
|
}
|
|
@@ -488,6 +518,9 @@
|
|
|
488
518
|
}
|
|
489
519
|
],
|
|
490
520
|
"is_active": true,
|
|
521
|
+
"logo_url": null,
|
|
522
|
+
"extra_emails": [],
|
|
523
|
+
"extra_phones": [],
|
|
491
524
|
"created_at": "2026-09-13T12:00:00+00:00",
|
|
492
525
|
"updated_at": "2026-09-13T12:00:00+00:00"
|
|
493
526
|
},
|
|
@@ -502,6 +535,9 @@
|
|
|
502
535
|
"notes": null,
|
|
503
536
|
"custom_fields": [],
|
|
504
537
|
"is_active": true,
|
|
538
|
+
"logo_url": null,
|
|
539
|
+
"extra_emails": [],
|
|
540
|
+
"extra_phones": [],
|
|
505
541
|
"created_at": "2026-09-13T12:00:00+00:00",
|
|
506
542
|
"updated_at": "2026-09-13T12:00:00+00:00"
|
|
507
543
|
}
|
|
@@ -1021,6 +1057,17 @@
|
|
|
1021
1057
|
"access": true,
|
|
1022
1058
|
"is_primary": true,
|
|
1023
1059
|
"customer_external_id": "ERP 1042",
|
|
1060
|
+
"custom_fields": [
|
|
1061
|
+
{
|
|
1062
|
+
"key": "matricula",
|
|
1063
|
+
"label": "Matrícula",
|
|
1064
|
+
"value": "4471",
|
|
1065
|
+
"visibility": "interno"
|
|
1066
|
+
}
|
|
1067
|
+
],
|
|
1068
|
+
"identifiers": [],
|
|
1069
|
+
"linked": false,
|
|
1070
|
+
"merged_into": null,
|
|
1024
1071
|
"status": "created"
|
|
1025
1072
|
},
|
|
1026
1073
|
"message": "Executado com sucesso"
|
|
@@ -1038,6 +1085,17 @@
|
|
|
1038
1085
|
"access": true,
|
|
1039
1086
|
"is_primary": true,
|
|
1040
1087
|
"customer_external_id": "ERP 1042",
|
|
1088
|
+
"custom_fields": [
|
|
1089
|
+
{
|
|
1090
|
+
"key": "matricula",
|
|
1091
|
+
"label": "Matrícula",
|
|
1092
|
+
"value": "4471",
|
|
1093
|
+
"visibility": "interno"
|
|
1094
|
+
}
|
|
1095
|
+
],
|
|
1096
|
+
"identifiers": [],
|
|
1097
|
+
"linked": false,
|
|
1098
|
+
"merged_into": null,
|
|
1041
1099
|
"status": "created"
|
|
1042
1100
|
}
|
|
1043
1101
|
}
|
|
@@ -1085,6 +1143,17 @@
|
|
|
1085
1143
|
"access": false,
|
|
1086
1144
|
"is_primary": true,
|
|
1087
1145
|
"customer_external_id": "ERP 1042",
|
|
1146
|
+
"custom_fields": [
|
|
1147
|
+
{
|
|
1148
|
+
"key": "matricula",
|
|
1149
|
+
"label": "Matrícula",
|
|
1150
|
+
"value": "4471",
|
|
1151
|
+
"visibility": "interno"
|
|
1152
|
+
}
|
|
1153
|
+
],
|
|
1154
|
+
"identifiers": [],
|
|
1155
|
+
"linked": false,
|
|
1156
|
+
"merged_into": null,
|
|
1088
1157
|
"status": "updated"
|
|
1089
1158
|
},
|
|
1090
1159
|
"message": "Executado com sucesso"
|
|
@@ -1102,6 +1171,17 @@
|
|
|
1102
1171
|
"access": false,
|
|
1103
1172
|
"is_primary": true,
|
|
1104
1173
|
"customer_external_id": "ERP 1042",
|
|
1174
|
+
"custom_fields": [
|
|
1175
|
+
{
|
|
1176
|
+
"key": "matricula",
|
|
1177
|
+
"label": "Matrícula",
|
|
1178
|
+
"value": "4471",
|
|
1179
|
+
"visibility": "interno"
|
|
1180
|
+
}
|
|
1181
|
+
],
|
|
1182
|
+
"identifiers": [],
|
|
1183
|
+
"linked": false,
|
|
1184
|
+
"merged_into": null,
|
|
1105
1185
|
"status": "updated"
|
|
1106
1186
|
}
|
|
1107
1187
|
}
|
|
@@ -1181,7 +1261,16 @@
|
|
|
1181
1261
|
"role": "Financeiro",
|
|
1182
1262
|
"access": true,
|
|
1183
1263
|
"is_primary": true,
|
|
1184
|
-
"customer_external_id": "ERP 1042"
|
|
1264
|
+
"customer_external_id": "ERP 1042",
|
|
1265
|
+
"custom_fields": [
|
|
1266
|
+
{
|
|
1267
|
+
"key": "matricula",
|
|
1268
|
+
"label": "Matrícula",
|
|
1269
|
+
"value": "4471",
|
|
1270
|
+
"visibility": "interno"
|
|
1271
|
+
}
|
|
1272
|
+
],
|
|
1273
|
+
"identifiers": []
|
|
1185
1274
|
},
|
|
1186
1275
|
{
|
|
1187
1276
|
"external_id": null,
|
|
@@ -1191,7 +1280,9 @@
|
|
|
1191
1280
|
"role": "Compras",
|
|
1192
1281
|
"access": false,
|
|
1193
1282
|
"is_primary": false,
|
|
1194
|
-
"customer_external_id": "ERP 1042"
|
|
1283
|
+
"customer_external_id": "ERP 1042",
|
|
1284
|
+
"custom_fields": [],
|
|
1285
|
+
"identifiers": []
|
|
1195
1286
|
}
|
|
1196
1287
|
],
|
|
1197
1288
|
"message": "Executado com sucesso"
|
|
@@ -1209,7 +1300,16 @@
|
|
|
1209
1300
|
"role": "Financeiro",
|
|
1210
1301
|
"access": true,
|
|
1211
1302
|
"is_primary": true,
|
|
1212
|
-
"customer_external_id": "ERP 1042"
|
|
1303
|
+
"customer_external_id": "ERP 1042",
|
|
1304
|
+
"custom_fields": [
|
|
1305
|
+
{
|
|
1306
|
+
"key": "matricula",
|
|
1307
|
+
"label": "Matrícula",
|
|
1308
|
+
"value": "4471",
|
|
1309
|
+
"visibility": "interno"
|
|
1310
|
+
}
|
|
1311
|
+
],
|
|
1312
|
+
"identifiers": []
|
|
1213
1313
|
},
|
|
1214
1314
|
{
|
|
1215
1315
|
"external_id": null,
|
|
@@ -1219,7 +1319,9 @@
|
|
|
1219
1319
|
"role": "Compras",
|
|
1220
1320
|
"access": false,
|
|
1221
1321
|
"is_primary": false,
|
|
1222
|
-
"customer_external_id": "ERP 1042"
|
|
1322
|
+
"customer_external_id": "ERP 1042",
|
|
1323
|
+
"custom_fields": [],
|
|
1324
|
+
"identifiers": []
|
|
1223
1325
|
}
|
|
1224
1326
|
]
|
|
1225
1327
|
}
|
|
@@ -1253,7 +1355,17 @@
|
|
|
1253
1355
|
"role": "Financeiro",
|
|
1254
1356
|
"access": false,
|
|
1255
1357
|
"is_primary": true,
|
|
1256
|
-
"customer_external_id": "ERP 1042"
|
|
1358
|
+
"customer_external_id": "ERP 1042",
|
|
1359
|
+
"custom_fields": [
|
|
1360
|
+
{
|
|
1361
|
+
"key": "matricula",
|
|
1362
|
+
"label": "Matrícula",
|
|
1363
|
+
"value": "4471",
|
|
1364
|
+
"visibility": "interno"
|
|
1365
|
+
}
|
|
1366
|
+
],
|
|
1367
|
+
"identifiers": [],
|
|
1368
|
+
"unlinked": false
|
|
1257
1369
|
},
|
|
1258
1370
|
"message": "Acesso retirado"
|
|
1259
1371
|
}
|
|
@@ -1269,7 +1381,17 @@
|
|
|
1269
1381
|
"role": "Financeiro",
|
|
1270
1382
|
"access": false,
|
|
1271
1383
|
"is_primary": true,
|
|
1272
|
-
"customer_external_id": "ERP 1042"
|
|
1384
|
+
"customer_external_id": "ERP 1042",
|
|
1385
|
+
"custom_fields": [
|
|
1386
|
+
{
|
|
1387
|
+
"key": "matricula",
|
|
1388
|
+
"label": "Matrícula",
|
|
1389
|
+
"value": "4471",
|
|
1390
|
+
"visibility": "interno"
|
|
1391
|
+
}
|
|
1392
|
+
],
|
|
1393
|
+
"identifiers": [],
|
|
1394
|
+
"unlinked": false
|
|
1273
1395
|
}
|
|
1274
1396
|
}
|
|
1275
1397
|
},
|
|
@@ -1361,6 +1483,7 @@
|
|
|
1361
1483
|
"status": "updated",
|
|
1362
1484
|
"external_id": "ERP 1042",
|
|
1363
1485
|
"merged_into": null,
|
|
1486
|
+
"linked": false,
|
|
1364
1487
|
"error": null,
|
|
1365
1488
|
"code": null
|
|
1366
1489
|
},
|
|
@@ -1369,6 +1492,7 @@
|
|
|
1369
1492
|
"status": "error",
|
|
1370
1493
|
"external_id": "ERP 1044",
|
|
1371
1494
|
"merged_into": null,
|
|
1495
|
+
"linked": false,
|
|
1372
1496
|
"error": "NAME_REQUIRED",
|
|
1373
1497
|
"code": 422
|
|
1374
1498
|
}
|
|
@@ -1393,6 +1517,7 @@
|
|
|
1393
1517
|
"status": "updated",
|
|
1394
1518
|
"external_id": "ERP 1042",
|
|
1395
1519
|
"merged_into": null,
|
|
1520
|
+
"linked": false,
|
|
1396
1521
|
"error": null,
|
|
1397
1522
|
"code": null
|
|
1398
1523
|
},
|
|
@@ -1401,6 +1526,7 @@
|
|
|
1401
1526
|
"status": "error",
|
|
1402
1527
|
"external_id": "ERP 1044",
|
|
1403
1528
|
"merged_into": null,
|
|
1529
|
+
"linked": false,
|
|
1404
1530
|
"error": "NAME_REQUIRED",
|
|
1405
1531
|
"code": 422
|
|
1406
1532
|
}
|
|
@@ -1492,6 +1618,7 @@
|
|
|
1492
1618
|
"status": "created",
|
|
1493
1619
|
"external_id": "app-77",
|
|
1494
1620
|
"merged_into": null,
|
|
1621
|
+
"linked": false,
|
|
1495
1622
|
"error": null,
|
|
1496
1623
|
"code": null
|
|
1497
1624
|
},
|
|
@@ -1500,6 +1627,7 @@
|
|
|
1500
1627
|
"status": "error",
|
|
1501
1628
|
"external_id": "app-78",
|
|
1502
1629
|
"merged_into": null,
|
|
1630
|
+
"linked": false,
|
|
1503
1631
|
"error": "CUSTOMER_NOT_FOUND",
|
|
1504
1632
|
"code": 404
|
|
1505
1633
|
}
|
|
@@ -1524,6 +1652,7 @@
|
|
|
1524
1652
|
"status": "created",
|
|
1525
1653
|
"external_id": "app-77",
|
|
1526
1654
|
"merged_into": null,
|
|
1655
|
+
"linked": false,
|
|
1527
1656
|
"error": null,
|
|
1528
1657
|
"code": null
|
|
1529
1658
|
},
|
|
@@ -1532,6 +1661,7 @@
|
|
|
1532
1661
|
"status": "error",
|
|
1533
1662
|
"external_id": "app-78",
|
|
1534
1663
|
"merged_into": null,
|
|
1664
|
+
"linked": false,
|
|
1535
1665
|
"error": "CUSTOMER_NOT_FOUND",
|
|
1536
1666
|
"code": 404
|
|
1537
1667
|
}
|
|
@@ -1607,6 +1737,9 @@
|
|
|
1607
1737
|
}
|
|
1608
1738
|
],
|
|
1609
1739
|
"is_active": true,
|
|
1740
|
+
"logo_url": null,
|
|
1741
|
+
"extra_emails": [],
|
|
1742
|
+
"extra_phones": [],
|
|
1610
1743
|
"created_at": "2026-09-13T12:00:00+00:00",
|
|
1611
1744
|
"updated_at": "2026-09-13T12:00:00+00:00",
|
|
1612
1745
|
"identifiers": [
|
|
@@ -1642,6 +1775,9 @@
|
|
|
1642
1775
|
}
|
|
1643
1776
|
],
|
|
1644
1777
|
"is_active": true,
|
|
1778
|
+
"logo_url": null,
|
|
1779
|
+
"extra_emails": [],
|
|
1780
|
+
"extra_phones": [],
|
|
1645
1781
|
"created_at": "2026-09-13T12:00:00+00:00",
|
|
1646
1782
|
"updated_at": "2026-09-13T12:00:00+00:00",
|
|
1647
1783
|
"identifiers": [
|
|
@@ -1740,6 +1876,9 @@
|
|
|
1740
1876
|
}
|
|
1741
1877
|
],
|
|
1742
1878
|
"is_active": true,
|
|
1879
|
+
"logo_url": null,
|
|
1880
|
+
"extra_emails": [],
|
|
1881
|
+
"extra_phones": [],
|
|
1743
1882
|
"created_at": "2026-09-13T12:00:00+00:00",
|
|
1744
1883
|
"updated_at": "2026-09-13T12:00:00+00:00",
|
|
1745
1884
|
"identifiers": []
|
|
@@ -1769,6 +1908,9 @@
|
|
|
1769
1908
|
}
|
|
1770
1909
|
],
|
|
1771
1910
|
"is_active": true,
|
|
1911
|
+
"logo_url": null,
|
|
1912
|
+
"extra_emails": [],
|
|
1913
|
+
"extra_phones": [],
|
|
1772
1914
|
"created_at": "2026-09-13T12:00:00+00:00",
|
|
1773
1915
|
"updated_at": "2026-09-13T12:00:00+00:00",
|
|
1774
1916
|
"identifiers": []
|
|
@@ -1824,6 +1966,54 @@
|
|
|
1824
1966
|
}
|
|
1825
1967
|
}
|
|
1826
1968
|
},
|
|
1969
|
+
{
|
|
1970
|
+
"id": "people.identifiers.list/by_extra",
|
|
1971
|
+
"op": "people.identifiers.list",
|
|
1972
|
+
"args": {
|
|
1973
|
+
"person_external_id": "crm-p5"
|
|
1974
|
+
},
|
|
1975
|
+
"exchanges": [
|
|
1976
|
+
{
|
|
1977
|
+
"retry": false,
|
|
1978
|
+
"request": {
|
|
1979
|
+
"method": "GET",
|
|
1980
|
+
"path": "/api/v1/integration/people/crm-p5/identifiers",
|
|
1981
|
+
"query": {},
|
|
1982
|
+
"body": null
|
|
1983
|
+
},
|
|
1984
|
+
"response": {
|
|
1985
|
+
"status": 200,
|
|
1986
|
+
"headers": {},
|
|
1987
|
+
"body": {
|
|
1988
|
+
"code": 200,
|
|
1989
|
+
"data": {
|
|
1990
|
+
"external_id": "app-77",
|
|
1991
|
+
"identifiers": [
|
|
1992
|
+
{
|
|
1993
|
+
"external_id": "crm-p5",
|
|
1994
|
+
"label": null,
|
|
1995
|
+
"source": "api"
|
|
1996
|
+
}
|
|
1997
|
+
]
|
|
1998
|
+
},
|
|
1999
|
+
"message": "Executado com sucesso"
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
],
|
|
2004
|
+
"expect": {
|
|
2005
|
+
"result": {
|
|
2006
|
+
"external_id": "app-77",
|
|
2007
|
+
"identifiers": [
|
|
2008
|
+
{
|
|
2009
|
+
"external_id": "crm-p5",
|
|
2010
|
+
"label": null,
|
|
2011
|
+
"source": "api"
|
|
2012
|
+
}
|
|
2013
|
+
]
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
},
|
|
1827
2017
|
{
|
|
1828
2018
|
"id": "people.identifiers.remove/not_found",
|
|
1829
2019
|
"op": "people.identifiers.remove",
|
|
@@ -3482,6 +3672,9 @@
|
|
|
3482
3672
|
}
|
|
3483
3673
|
],
|
|
3484
3674
|
"is_active": true,
|
|
3675
|
+
"logo_url": null,
|
|
3676
|
+
"extra_emails": [],
|
|
3677
|
+
"extra_phones": [],
|
|
3485
3678
|
"created_at": "2026-09-13T12:00:00+00:00",
|
|
3486
3679
|
"updated_at": "2026-09-13T12:00:00+00:00"
|
|
3487
3680
|
},
|
|
@@ -3510,6 +3703,9 @@
|
|
|
3510
3703
|
}
|
|
3511
3704
|
],
|
|
3512
3705
|
"is_active": true,
|
|
3706
|
+
"logo_url": null,
|
|
3707
|
+
"extra_emails": [],
|
|
3708
|
+
"extra_phones": [],
|
|
3513
3709
|
"created_at": "2026-09-13T12:00:00+00:00",
|
|
3514
3710
|
"updated_at": "2026-09-13T12:00:00+00:00"
|
|
3515
3711
|
}
|
|
@@ -62,6 +62,7 @@ OPS: Dict[str, Callable[[Bfocus], Callable[..., Any]]] = {
|
|
|
62
62
|
"people.list": lambda bf: bf.people.list,
|
|
63
63
|
"people.delete": lambda bf: bf.people.delete,
|
|
64
64
|
"people.batch": lambda bf: bf.people.batch,
|
|
65
|
+
"people.identifiers.list": lambda bf: bf.people.identifiers.list,
|
|
65
66
|
"people.identifiers.add": lambda bf: bf.people.identifiers.add,
|
|
66
67
|
"people.identifiers.remove": lambda bf: bf.people.identifiers.remove,
|
|
67
68
|
"products.list": lambda bf: bf.products.list,
|
|
@@ -15,7 +15,9 @@ from bfocus import (
|
|
|
15
15
|
UNSET,
|
|
16
16
|
Bfocus,
|
|
17
17
|
BfocusError,
|
|
18
|
+
ConflictError,
|
|
18
19
|
NetworkError,
|
|
20
|
+
NotFoundError,
|
|
19
21
|
Page,
|
|
20
22
|
RateLimitError,
|
|
21
23
|
ServerError,
|
|
@@ -436,6 +438,30 @@ class ErrorShapeTest(_ServerCase):
|
|
|
436
438
|
self.assertIn("req-9", str(err))
|
|
437
439
|
self.assertIsNone(err.retry_after)
|
|
438
440
|
|
|
441
|
+
def test_data_traz_o_dono_do_contato_tomado(self) -> None:
|
|
442
|
+
"""409 acionável: `data` diz de QUEM é o e-mail, e a API repete em `validation`."""
|
|
443
|
+
dono = {"field": "email", "owner_external_id": "app-12", "owner_name": "Paula Reis",
|
|
444
|
+
"owner_customer_external_id": "erp-1042"}
|
|
445
|
+
bf = self.client([{
|
|
446
|
+
"status": 409, "headers": {},
|
|
447
|
+
"body": {"code": 409, "data": dono, "message": "PERSON_EMAIL_TAKEN",
|
|
448
|
+
"error": "PERSON_EMAIL_TAKEN", "validation": dono, "request_id": "req-9"},
|
|
449
|
+
}])
|
|
450
|
+
with self.assertRaises(ConflictError) as ctx:
|
|
451
|
+
bf.people.upsert("erp-1042", "app-77", email="paula@padaria.example")
|
|
452
|
+
err = ctx.exception
|
|
453
|
+
self.assertEqual(err.code, "PERSON_EMAIL_TAKEN")
|
|
454
|
+
self.assertEqual(err.data, dono)
|
|
455
|
+
self.assertEqual(err.data["owner_customer_external_id"], "erp-1042")
|
|
456
|
+
self.assertEqual(err.validation, dono)
|
|
457
|
+
|
|
458
|
+
def test_data_vazio_quando_o_erro_nao_traz_detalhe(self) -> None:
|
|
459
|
+
bf = self.client([{"status": 404, "headers": {},
|
|
460
|
+
"body": {"code": 404, "data": None, "error": "CUSTOMER_NOT_FOUND"}}])
|
|
461
|
+
with self.assertRaises(NotFoundError) as ctx:
|
|
462
|
+
bf.customers.get("erp-1042")
|
|
463
|
+
self.assertEqual(ctx.exception.data, {})
|
|
464
|
+
|
|
439
465
|
def test_2xx_sem_envelope_e_invalid_response(self) -> None:
|
|
440
466
|
bodies = [
|
|
441
467
|
{"status": 200, "headers": {}, "body": "<html>proxy</html>"},
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|