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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bfocus
3
- Version: 0.2.0
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 para ele.
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 para ele.
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("extra_emails/extra_phones precisam ser uma lista de strings.")
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 é transferida.
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
- ) -> Person:
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(Person, data)
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),
@@ -1,3 +1,3 @@
1
1
  """Versão da SDK. O ``scripts/release-sdks.sh`` bumpa esta linha e o ``pyproject.toml``."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.2"
@@ -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. Enviar a lista SUBSTITUI a lista inteira."""
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.0"
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