bfocus 0.2.0__tar.gz → 0.2.1__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.1
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` |
@@ -201,6 +201,101 @@ bf.people.upsert("erp-1042", "app-77", access=True) # devolve o acesso
201
201
  de chamados e conversas. Um `upsert` com `access=True` devolve o acesso.
202
202
  - Como nos outros upserts, só o que você passa muda; `name` é obrigatório ao criar.
203
203
 
204
+ ### Campos personalizados da pessoa
205
+
206
+ `custom_fields` leva o que só existe no seu sistema (matrícula, centro de custo, filial). É a
207
+ **exceção** ao "só o que vier muda": a lista enviada **substitui a lista inteira** — campo que
208
+ ficar de fora é **removido**. Mande sempre a lista que o seu sistema tem hoje; omitir o argumento não mexe
209
+ em nada, como em qualquer outro campo.
210
+
211
+ A `visibility` é decidida no bFocus e **preservada entre sincronizações** — por isso ela não vai
212
+ no envio, só volta na resposta: o seu ERP não rebaixa nem promove a exposição de um dado sem
213
+ querer.
214
+
215
+ Vale no upsert de pessoa, no lote de pessoas e na listagem de pessoas do cliente.
216
+
217
+ ```python
218
+ p = bf.people.upsert(
219
+ "erp-1042",
220
+ "app-77",
221
+ custom_fields=[ # a lista INTEIRA do seu sistema
222
+ {"key": "matricula", "label": "Matrícula", "value": "4471"},
223
+ {"key": "filial", "label": "Filial", "value": "Centro"},
224
+ ],
225
+ )
226
+ for campo in p["custom_fields"]:
227
+ print(campo["key"], campo["value"], campo["visibility"]) # visibility vem do bFocus
228
+ ```
229
+
230
+ ### Apagar o e-mail ou o telefone da pessoa
231
+
232
+ Um contato gravado errado ficava preso para sempre: enquanto a ficha errada segurasse o
233
+ telefone, nenhum reenvio o soltava. `clear` apaga.
234
+
235
+ ```python
236
+ bf.people.upsert("erp-1042", "app-77", clear=["phone"]) # some o telefone
237
+ bf.people.upsert("erp-1042", "app-77", clear=["email", "phone"]) # some os dois
238
+ ```
239
+
240
+ Três regras que parecem contraintuitivas e são de propósito:
241
+
242
+ - **Apagar é explícito.** `phone=None`, `clear=[]` e não passar o argumento continuam
243
+ significando **"não mexe"** — a SDK não traduz `None` em `clear`. Fazer o `None` apagar
244
+ teria apagado, em silêncio e na primeira carga seguinte, o dado de todo sistema que manda
245
+ `None` para "não tenho esse valor".
246
+ - **Campo fora da lista é recusado, não ignorado**: hoje só `"email"` e `"phone"`; qualquer
247
+ outro devolve 422 `PERSON_CLEAR_FIELD_INVALID` (`ValidationError`).
248
+ - **Só se limpa a própria ficha.** Se você alcançou a pessoa por um identificador **extra**, a
249
+ API recusa com 409 `PERSON_CLEAR_NOT_OWN_RECORD` (`ConflictError`): apagar o contato de uma
250
+ ficha alcançada por apelido seria apagar dado de outro sistema. Para saber se o id que você
251
+ tem em mãos é o principal ou um extra, use `bf.people.identifiers.list(...)`.
252
+
253
+ Vale no `people.upsert` e no `people.batch` (`{"clear": ["phone"]}` no item).
254
+
255
+ ### Contato já usado: um 409 que você consegue resolver
256
+
257
+ `PERSON_EMAIL_TAKEN` e `PERSON_PHONE_TAKEN` (409) não são "tente de novo": o e-mail (ou o
258
+ telefone) já é de outra pessoa da conta. O erro diz **de quem**, em `err.data` (a API repete o mesmo
259
+ detalhe em `err.validation`, por compatibilidade):
260
+
261
+ | campo | o que é |
262
+ | --- | --- |
263
+ | `field` | `email` ou `phone` — qual contato está tomado |
264
+ | `owner_external_id` | o identificador da pessoa que já usa esse contato |
265
+ | `owner_name` | o nome dela |
266
+ | `owner_customer_external_id` | o cliente a que ela pertence |
267
+
268
+ **É o `owner_customer_external_id` que decide a ação**, e os dois casos pedem coisas opostas:
269
+
270
+ - **mesmo cliente que você enviou** → é quase sempre a MESMA pessoa em dois sistemas. Uma pessoa
271
+ tem **N identificadores**: registre o seu como **extra** dela. A partir daí o seu id encontra
272
+ essa pessoa.
273
+ - **outro cliente** → ninguém decide sozinho a quem a pessoa pertence. Não force: registre o caso
274
+ e leve para quem conhece o cadastro. Unificar dois clientes é decisão de gente, não de um
275
+ casamento por e-mail.
276
+
277
+ ```python
278
+ from bfocus import ConflictError
279
+
280
+ try:
281
+ bf.people.upsert("erp-1042", "app-77", name="Paula Reis", email="paula@padaria.example")
282
+ except ConflictError as err:
283
+ if err.code not in ("PERSON_EMAIL_TAKEN", "PERSON_PHONE_TAKEN"):
284
+ raise
285
+ dono = err.data
286
+ if dono.get("owner_customer_external_id") == "erp-1042":
287
+ # A mesma pessoa, com dois ids: o seu vira mais um identificador dela.
288
+ bf.people.identifiers.add(dono["owner_external_id"], "app-77", label="ERP")
289
+ else:
290
+ # Dono em OUTRO cliente: não decida sozinho — registre e leve para o cadastro.
291
+ avisar_cadastro(err.code, dono)
292
+ ```
293
+
294
+ `PERSON_CONTACT_OTHER_CUSTOMER` (409) é o mesmo assunto pelo outro lado, e é **recusa
295
+ definitiva**: a API não move mais uma pessoa de um cliente para outro só porque o e-mail (ou o
296
+ telefone) casou. Repetir a chamada não resolve — trate como caso para o cadastro, nunca como
297
+ falha temporária.
298
+
204
299
  ## Lotes
205
300
 
206
301
  `customers.batch` e `people.batch` criam/atualizam **até 500 itens por chamada** (`bfocus.BATCH_MAX`).
@@ -264,6 +359,23 @@ bf.people.identifiers.remove("app-77", "crm-p5")
264
359
 
265
360
  Num lote, um item enviado com um id extra volta com o principal em `merged_into`.
266
361
 
362
+ ### Ler os identificadores da pessoa (para reconciliar)
363
+
364
+ `bf.people.list(...)` mostra só o identificador **principal** de cada pessoa. Quando dois
365
+ cadastros seus eram a mesma pessoa, um dos ids virou **extra** — e some da listagem sem ter
366
+ sumido do cadastro. É isso que faz a sua conferência fechar "633 de 636" sem explicar os 3.
367
+
368
+ `people.identifiers.list` é a fonte de verdade dessa conferência, e é **leitura**: antes dela
369
+ era preciso ESCREVER (tentar um `add`) para descobrir o que tinha acontecido. Aceita no
370
+ caminho o id principal **ou qualquer um dos extras**.
371
+
372
+ ```python
373
+ ids = bf.people.identifiers.list("crm-p5") # o id extra que "sumiu" da listagem
374
+ print(ids["external_id"]) # "app-77" — o principal do cadastro
375
+ for i in ids["identifiers"]:
376
+ print(i["external_id"], i["label"], i["source"])
377
+ ```
378
+
267
379
  ## Sincronizar clientes e usuários do seu sistema
268
380
 
269
381
  **Ids com o prefixo do sistema, sem `:`** — a assinatura do widget recusa `:`. Use `-` como
@@ -476,6 +588,11 @@ Qualquer resposta fora de 2xx levanta `BfocusError` (ou uma subclasse):
476
588
  | `ServerError` | 5xx |
477
589
  | `NetworkError` | conexão/timeout — `status == 0`, `code == "NETWORK_ERROR"` |
478
590
 
591
+ Além de `code`, `status`, `message`, `request_id`, `validation`, `retry_after` e
592
+ `required_scope`, o erro tem **`data`**: o `data` do corpo, com o detalhe estruturado que alguns
593
+ erros trazem (`{}` quando não há). É por ele que um 409 de contato tomado diz de **quem** é o
594
+ contato — veja [Pessoas](#pessoas).
595
+
479
596
  **Decida pelo `code`** — ele é estável (`CUSTOMER_NOT_FOUND`, `INTEGRATION_SCOPE_MISSING`,
480
597
  `VALIDATION_ERROR`…). O `message` é texto para humanos e pode mudar. Ao falar com o suporte,
481
598
  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` |
@@ -174,6 +174,101 @@ bf.people.upsert("erp-1042", "app-77", access=True) # devolve o acesso
174
174
  de chamados e conversas. Um `upsert` com `access=True` devolve o acesso.
175
175
  - Como nos outros upserts, só o que você passa muda; `name` é obrigatório ao criar.
176
176
 
177
+ ### Campos personalizados da pessoa
178
+
179
+ `custom_fields` leva o que só existe no seu sistema (matrícula, centro de custo, filial). É a
180
+ **exceção** ao "só o que vier muda": a lista enviada **substitui a lista inteira** — campo que
181
+ ficar de fora é **removido**. Mande sempre a lista que o seu sistema tem hoje; omitir o argumento não mexe
182
+ em nada, como em qualquer outro campo.
183
+
184
+ A `visibility` é decidida no bFocus e **preservada entre sincronizações** — por isso ela não vai
185
+ no envio, só volta na resposta: o seu ERP não rebaixa nem promove a exposição de um dado sem
186
+ querer.
187
+
188
+ Vale no upsert de pessoa, no lote de pessoas e na listagem de pessoas do cliente.
189
+
190
+ ```python
191
+ p = bf.people.upsert(
192
+ "erp-1042",
193
+ "app-77",
194
+ custom_fields=[ # a lista INTEIRA do seu sistema
195
+ {"key": "matricula", "label": "Matrícula", "value": "4471"},
196
+ {"key": "filial", "label": "Filial", "value": "Centro"},
197
+ ],
198
+ )
199
+ for campo in p["custom_fields"]:
200
+ print(campo["key"], campo["value"], campo["visibility"]) # visibility vem do bFocus
201
+ ```
202
+
203
+ ### Apagar o e-mail ou o telefone da pessoa
204
+
205
+ Um contato gravado errado ficava preso para sempre: enquanto a ficha errada segurasse o
206
+ telefone, nenhum reenvio o soltava. `clear` apaga.
207
+
208
+ ```python
209
+ bf.people.upsert("erp-1042", "app-77", clear=["phone"]) # some o telefone
210
+ bf.people.upsert("erp-1042", "app-77", clear=["email", "phone"]) # some os dois
211
+ ```
212
+
213
+ Três regras que parecem contraintuitivas e são de propósito:
214
+
215
+ - **Apagar é explícito.** `phone=None`, `clear=[]` e não passar o argumento continuam
216
+ significando **"não mexe"** — a SDK não traduz `None` em `clear`. Fazer o `None` apagar
217
+ teria apagado, em silêncio e na primeira carga seguinte, o dado de todo sistema que manda
218
+ `None` para "não tenho esse valor".
219
+ - **Campo fora da lista é recusado, não ignorado**: hoje só `"email"` e `"phone"`; qualquer
220
+ outro devolve 422 `PERSON_CLEAR_FIELD_INVALID` (`ValidationError`).
221
+ - **Só se limpa a própria ficha.** Se você alcançou a pessoa por um identificador **extra**, a
222
+ API recusa com 409 `PERSON_CLEAR_NOT_OWN_RECORD` (`ConflictError`): apagar o contato de uma
223
+ ficha alcançada por apelido seria apagar dado de outro sistema. Para saber se o id que você
224
+ tem em mãos é o principal ou um extra, use `bf.people.identifiers.list(...)`.
225
+
226
+ Vale no `people.upsert` e no `people.batch` (`{"clear": ["phone"]}` no item).
227
+
228
+ ### Contato já usado: um 409 que você consegue resolver
229
+
230
+ `PERSON_EMAIL_TAKEN` e `PERSON_PHONE_TAKEN` (409) não são "tente de novo": o e-mail (ou o
231
+ telefone) já é de outra pessoa da conta. O erro diz **de quem**, em `err.data` (a API repete o mesmo
232
+ detalhe em `err.validation`, por compatibilidade):
233
+
234
+ | campo | o que é |
235
+ | --- | --- |
236
+ | `field` | `email` ou `phone` — qual contato está tomado |
237
+ | `owner_external_id` | o identificador da pessoa que já usa esse contato |
238
+ | `owner_name` | o nome dela |
239
+ | `owner_customer_external_id` | o cliente a que ela pertence |
240
+
241
+ **É o `owner_customer_external_id` que decide a ação**, e os dois casos pedem coisas opostas:
242
+
243
+ - **mesmo cliente que você enviou** → é quase sempre a MESMA pessoa em dois sistemas. Uma pessoa
244
+ tem **N identificadores**: registre o seu como **extra** dela. A partir daí o seu id encontra
245
+ essa pessoa.
246
+ - **outro cliente** → ninguém decide sozinho a quem a pessoa pertence. Não force: registre o caso
247
+ e leve para quem conhece o cadastro. Unificar dois clientes é decisão de gente, não de um
248
+ casamento por e-mail.
249
+
250
+ ```python
251
+ from bfocus import ConflictError
252
+
253
+ try:
254
+ bf.people.upsert("erp-1042", "app-77", name="Paula Reis", email="paula@padaria.example")
255
+ except ConflictError as err:
256
+ if err.code not in ("PERSON_EMAIL_TAKEN", "PERSON_PHONE_TAKEN"):
257
+ raise
258
+ dono = err.data
259
+ if dono.get("owner_customer_external_id") == "erp-1042":
260
+ # A mesma pessoa, com dois ids: o seu vira mais um identificador dela.
261
+ bf.people.identifiers.add(dono["owner_external_id"], "app-77", label="ERP")
262
+ else:
263
+ # Dono em OUTRO cliente: não decida sozinho — registre e leve para o cadastro.
264
+ avisar_cadastro(err.code, dono)
265
+ ```
266
+
267
+ `PERSON_CONTACT_OTHER_CUSTOMER` (409) é o mesmo assunto pelo outro lado, e é **recusa
268
+ definitiva**: a API não move mais uma pessoa de um cliente para outro só porque o e-mail (ou o
269
+ telefone) casou. Repetir a chamada não resolve — trate como caso para o cadastro, nunca como
270
+ falha temporária.
271
+
177
272
  ## Lotes
178
273
 
179
274
  `customers.batch` e `people.batch` criam/atualizam **até 500 itens por chamada** (`bfocus.BATCH_MAX`).
@@ -237,6 +332,23 @@ bf.people.identifiers.remove("app-77", "crm-p5")
237
332
 
238
333
  Num lote, um item enviado com um id extra volta com o principal em `merged_into`.
239
334
 
335
+ ### Ler os identificadores da pessoa (para reconciliar)
336
+
337
+ `bf.people.list(...)` mostra só o identificador **principal** de cada pessoa. Quando dois
338
+ cadastros seus eram a mesma pessoa, um dos ids virou **extra** — e some da listagem sem ter
339
+ sumido do cadastro. É isso que faz a sua conferência fechar "633 de 636" sem explicar os 3.
340
+
341
+ `people.identifiers.list` é a fonte de verdade dessa conferência, e é **leitura**: antes dela
342
+ era preciso ESCREVER (tentar um `add`) para descobrir o que tinha acontecido. Aceita no
343
+ caminho o id principal **ou qualquer um dos extras**.
344
+
345
+ ```python
346
+ ids = bf.people.identifiers.list("crm-p5") # o id extra que "sumiu" da listagem
347
+ print(ids["external_id"]) # "app-77" — o principal do cadastro
348
+ for i in ids["identifiers"]:
349
+ print(i["external_id"], i["label"], i["source"])
350
+ ```
351
+
240
352
  ## Sincronizar clientes e usuários do seu sistema
241
353
 
242
354
  **Ids com o prefixo do sistema, sem `:`** — a assinatura do widget recusa `:`. Use `-` como
@@ -449,6 +561,11 @@ Qualquer resposta fora de 2xx levanta `BfocusError` (ou uma subclasse):
449
561
  | `ServerError` | 5xx |
450
562
  | `NetworkError` | conexão/timeout — `status == 0`, `code == "NETWORK_ERROR"` |
451
563
 
564
+ Além de `code`, `status`, `message`, `request_id`, `validation`, `retry_after` e
565
+ `required_scope`, o erro tem **`data`**: o `data` do corpo, com o detalhe estruturado que alguns
566
+ erros trazem (`{}` quando não há). É por ele que um 409 de contato tomado diz de **quem** é o
567
+ contato — veja [Pessoas](#pessoas).
568
+
452
569
  **Decida pelo `code`** — ele é estável (`CUSTOMER_NOT_FOUND`, `INTEGRATION_SCOPE_MISSING`,
453
570
  `VALIDATION_ERROR`…). O `message` é texto para humanos e pode mudar. Ao falar com o suporte,
454
571
  informe o `request_id`: ele vem do corpo da resposta, senão do header `X-Request-Id`, senão é o id
@@ -150,12 +150,12 @@ def _identifier_label(label: Any) -> Optional[Dict[str, Any]]:
150
150
  return None if label is UNSET else {"label": label}
151
151
 
152
152
 
153
- def _strings(value: Any) -> Any:
153
+ def _strings(value: Any, field: str = "extra_emails/extra_phones") -> Any:
154
154
  """Sequência de strings → lista (preserva UNSET/None). String solta é erro (viraria letras)."""
155
155
  if value is UNSET or value is None:
156
156
  return value
157
157
  if isinstance(value, (str, bytes)):
158
- raise TypeError("extra_emails/extra_phones precisam ser uma lista de strings.")
158
+ raise TypeError(f"{field} precisa ser uma lista de strings.")
159
159
  return list(value)
160
160
 
161
161
 
@@ -554,6 +554,26 @@ class Customers(_Resource):
554
554
  class PeopleIdentifiers(_Resource):
555
555
  """Identificadores extras de uma pessoa — ``client.people.identifiers``."""
556
556
 
557
+ def list(
558
+ self, person_external_id: str, *, timeout: Optional[float] = None
559
+ ) -> PersonIdentifiers:
560
+ """Todos os identificadores da pessoa: o principal + os extras.
561
+
562
+ ``GET /people/{person_external_id}/identifiers``. Escopo ``customers:read``. Aceita
563
+ no caminho o identificador **principal ou qualquer um dos extras** — ``external_id``
564
+ no retorno é sempre o principal.
565
+
566
+ É a fonte de verdade para **reconciliar**: :meth:`People.list` mostra só o
567
+ identificador principal de cada pessoa, então um id que virou extra (porque dois
568
+ cadastros seus eram a mesma pessoa) some de lá sem ter sumido do cadastro. Sem esta
569
+ leitura era preciso ESCREVER (um ``add``) para descobrir o que tinha acontecido.
570
+
571
+ Pessoa inexistente: ``NotFoundError`` com ``code == "PERSON_NOT_FOUND"``.
572
+ """
573
+ pid = path_segment(person_external_id, "person_external_id")
574
+ data, _ = self._t.request("GET", f"/people/{pid}/identifiers", timeout=timeout)
575
+ return cast(PersonIdentifiers, data)
576
+
557
577
  def add(
558
578
  self,
559
579
  person_external_id: str,
@@ -624,6 +644,8 @@ class People(_Resource):
624
644
  is_primary: MaybeUnset[Optional[bool]] = UNSET,
625
645
  extra_emails: MaybeUnset[Optional[Sequence[str]]] = UNSET,
626
646
  extra_phones: MaybeUnset[Optional[Sequence[str]]] = UNSET,
647
+ custom_fields: MaybeUnset[Optional[Sequence[CustomFieldInput]]] = UNSET,
648
+ clear: MaybeUnset[Optional[Sequence[str]]] = UNSET,
627
649
  idempotency_key: Optional[str] = None,
628
650
  timeout: Optional[float] = None,
629
651
  ) -> PersonUpsertResult:
@@ -640,6 +662,19 @@ class People(_Resource):
640
662
  acesso retirado por :meth:`delete`.
641
663
  extra_emails: E-mails adicionais (somam aos que já existem).
642
664
  extra_phones: Telefones adicionais (somam aos que já existem).
665
+ custom_fields: Campos personalizados da pessoa, ``{"key", "label", "value"}``.
666
+ Ao contrário de ``extra_emails``/``extra_phones``, a lista **substitui** a
667
+ lista inteira: mande o que o seu sistema tem HOJE, porque campo que ficou de
668
+ fora é REMOVIDO. Omitir o argumento não mexe em nada. A ``visibility`` é
669
+ decidida no bFocus e preservada entre sincronizações.
670
+ clear: Campos a **apagar** nesta pessoa — hoje ``["email"]``, ``["phone"]`` ou os
671
+ dois. Apagar é EXPLÍCITO: ``email=None`` (e a lista vazia, e omitir o
672
+ argumento) continua significando "não mexe", nunca "apague". Campo fora da
673
+ lista aceita é RECUSADO pela API (422 ``PERSON_CLEAR_FIELD_INVALID``), não
674
+ ignorado. E só se limpa a PRÓPRIA ficha: se você alcançou a pessoa por um
675
+ identificador EXTRA, a API recusa (409 ``PERSON_CLEAR_NOT_OWN_RECORD``) —
676
+ apagar contato de ficha alcançada por apelido seria apagar dado de outro
677
+ sistema.
643
678
  """
644
679
  cext = path_segment(customer_external_id, "customer_external_id")
645
680
  pid = path_segment(person_external_id, "person_external_id")
@@ -653,6 +688,8 @@ class People(_Resource):
653
688
  "is_primary": is_primary,
654
689
  "extra_emails": _strings(extra_emails),
655
690
  "extra_phones": _strings(extra_phones),
691
+ "custom_fields": _dicts(custom_fields),
692
+ "clear": _strings(clear, "clear"),
656
693
  }
657
694
  )
658
695
  data, _ = self._t.request(
@@ -712,9 +749,11 @@ class People(_Resource):
712
749
  customer = _required_id(op, index, item, "customer_external_id")
713
750
  _required_id(op, index, item, "external_id")
714
751
  person = compact({k: v for k, v in item.items() if k != "customer_external_id"})
715
- for field in ("extra_emails", "extra_phones"):
752
+ for field in ("extra_emails", "extra_phones", "clear"):
716
753
  if field in person:
717
- person[field] = _strings(person[field])
754
+ person[field] = _strings(person[field], field)
755
+ if "custom_fields" in person:
756
+ person["custom_fields"] = _dicts(person["custom_fields"])
718
757
  body_items.append({"customer_external_id": customer, "person": person})
719
758
  if not body_items:
720
759
  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.1"
@@ -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
@@ -124,7 +124,12 @@ class _CustomFieldInputRequired(TypedDict):
124
124
 
125
125
 
126
126
  class CustomFieldInput(_CustomFieldInputRequired, total=False):
127
- """Campo personalizado de cliente. Enviar a lista SUBSTITUI a lista inteira."""
127
+ """Campo personalizado de cliente **ou de pessoa**. Enviar a lista SUBSTITUI a lista inteira.
128
+
129
+ Em pessoa, ``visibility`` **não** é aceito aqui: quem vê o campo é decisão do bFocus e é
130
+ preservada entre sincronizações (o seu sistema não rebaixa nem promove a exposição de um
131
+ dado sem querer).
132
+ """
128
133
 
129
134
  label: str
130
135
  type: Literal[
@@ -188,6 +193,10 @@ class PersonBatchItem(_PersonBatchItemRequired, total=False):
188
193
  is_primary: Optional[bool]
189
194
  extra_emails: Optional[List[str]]
190
195
  extra_phones: Optional[List[str]]
196
+ custom_fields: Optional[List[CustomFieldInput]]
197
+ #: Campos a APAGAR nesta pessoa (hoje ``"email"`` e/ou ``"phone"``). Apagar é explícito:
198
+ #: ``None``, lista vazia ou chave ausente continuam significando "não mexe".
199
+ clear: Optional[List[str]]
191
200
 
192
201
 
193
202
  class AgentTurn(TypedDict):
@@ -375,6 +384,7 @@ class Person(TypedDict):
375
384
  access: bool
376
385
  is_primary: bool
377
386
  customer_external_id: str
387
+ custom_fields: List[CustomField]
378
388
 
379
389
 
380
390
  class PersonUpsertResult(Person):
@@ -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.1"
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"
@@ -1021,6 +1021,14 @@
1021
1021
  "access": true,
1022
1022
  "is_primary": true,
1023
1023
  "customer_external_id": "ERP 1042",
1024
+ "custom_fields": [
1025
+ {
1026
+ "key": "matricula",
1027
+ "label": "Matrícula",
1028
+ "value": "4471",
1029
+ "visibility": "interno"
1030
+ }
1031
+ ],
1024
1032
  "status": "created"
1025
1033
  },
1026
1034
  "message": "Executado com sucesso"
@@ -1038,6 +1046,14 @@
1038
1046
  "access": true,
1039
1047
  "is_primary": true,
1040
1048
  "customer_external_id": "ERP 1042",
1049
+ "custom_fields": [
1050
+ {
1051
+ "key": "matricula",
1052
+ "label": "Matrícula",
1053
+ "value": "4471",
1054
+ "visibility": "interno"
1055
+ }
1056
+ ],
1041
1057
  "status": "created"
1042
1058
  }
1043
1059
  }
@@ -1085,6 +1101,14 @@
1085
1101
  "access": false,
1086
1102
  "is_primary": true,
1087
1103
  "customer_external_id": "ERP 1042",
1104
+ "custom_fields": [
1105
+ {
1106
+ "key": "matricula",
1107
+ "label": "Matrícula",
1108
+ "value": "4471",
1109
+ "visibility": "interno"
1110
+ }
1111
+ ],
1088
1112
  "status": "updated"
1089
1113
  },
1090
1114
  "message": "Executado com sucesso"
@@ -1102,6 +1126,14 @@
1102
1126
  "access": false,
1103
1127
  "is_primary": true,
1104
1128
  "customer_external_id": "ERP 1042",
1129
+ "custom_fields": [
1130
+ {
1131
+ "key": "matricula",
1132
+ "label": "Matrícula",
1133
+ "value": "4471",
1134
+ "visibility": "interno"
1135
+ }
1136
+ ],
1105
1137
  "status": "updated"
1106
1138
  }
1107
1139
  }
@@ -1181,7 +1213,15 @@
1181
1213
  "role": "Financeiro",
1182
1214
  "access": true,
1183
1215
  "is_primary": true,
1184
- "customer_external_id": "ERP 1042"
1216
+ "customer_external_id": "ERP 1042",
1217
+ "custom_fields": [
1218
+ {
1219
+ "key": "matricula",
1220
+ "label": "Matrícula",
1221
+ "value": "4471",
1222
+ "visibility": "interno"
1223
+ }
1224
+ ]
1185
1225
  },
1186
1226
  {
1187
1227
  "external_id": null,
@@ -1191,7 +1231,8 @@
1191
1231
  "role": "Compras",
1192
1232
  "access": false,
1193
1233
  "is_primary": false,
1194
- "customer_external_id": "ERP 1042"
1234
+ "customer_external_id": "ERP 1042",
1235
+ "custom_fields": []
1195
1236
  }
1196
1237
  ],
1197
1238
  "message": "Executado com sucesso"
@@ -1209,7 +1250,15 @@
1209
1250
  "role": "Financeiro",
1210
1251
  "access": true,
1211
1252
  "is_primary": true,
1212
- "customer_external_id": "ERP 1042"
1253
+ "customer_external_id": "ERP 1042",
1254
+ "custom_fields": [
1255
+ {
1256
+ "key": "matricula",
1257
+ "label": "Matrícula",
1258
+ "value": "4471",
1259
+ "visibility": "interno"
1260
+ }
1261
+ ]
1213
1262
  },
1214
1263
  {
1215
1264
  "external_id": null,
@@ -1219,7 +1268,8 @@
1219
1268
  "role": "Compras",
1220
1269
  "access": false,
1221
1270
  "is_primary": false,
1222
- "customer_external_id": "ERP 1042"
1271
+ "customer_external_id": "ERP 1042",
1272
+ "custom_fields": []
1223
1273
  }
1224
1274
  ]
1225
1275
  }
@@ -1253,7 +1303,15 @@
1253
1303
  "role": "Financeiro",
1254
1304
  "access": false,
1255
1305
  "is_primary": true,
1256
- "customer_external_id": "ERP 1042"
1306
+ "customer_external_id": "ERP 1042",
1307
+ "custom_fields": [
1308
+ {
1309
+ "key": "matricula",
1310
+ "label": "Matrícula",
1311
+ "value": "4471",
1312
+ "visibility": "interno"
1313
+ }
1314
+ ]
1257
1315
  },
1258
1316
  "message": "Acesso retirado"
1259
1317
  }
@@ -1269,7 +1327,15 @@
1269
1327
  "role": "Financeiro",
1270
1328
  "access": false,
1271
1329
  "is_primary": true,
1272
- "customer_external_id": "ERP 1042"
1330
+ "customer_external_id": "ERP 1042",
1331
+ "custom_fields": [
1332
+ {
1333
+ "key": "matricula",
1334
+ "label": "Matrícula",
1335
+ "value": "4471",
1336
+ "visibility": "interno"
1337
+ }
1338
+ ]
1273
1339
  }
1274
1340
  }
1275
1341
  },
@@ -1824,6 +1890,54 @@
1824
1890
  }
1825
1891
  }
1826
1892
  },
1893
+ {
1894
+ "id": "people.identifiers.list/by_extra",
1895
+ "op": "people.identifiers.list",
1896
+ "args": {
1897
+ "person_external_id": "crm-p5"
1898
+ },
1899
+ "exchanges": [
1900
+ {
1901
+ "retry": false,
1902
+ "request": {
1903
+ "method": "GET",
1904
+ "path": "/api/v1/integration/people/crm-p5/identifiers",
1905
+ "query": {},
1906
+ "body": null
1907
+ },
1908
+ "response": {
1909
+ "status": 200,
1910
+ "headers": {},
1911
+ "body": {
1912
+ "code": 200,
1913
+ "data": {
1914
+ "external_id": "app-77",
1915
+ "identifiers": [
1916
+ {
1917
+ "external_id": "crm-p5",
1918
+ "label": null,
1919
+ "source": "api"
1920
+ }
1921
+ ]
1922
+ },
1923
+ "message": "Executado com sucesso"
1924
+ }
1925
+ }
1926
+ }
1927
+ ],
1928
+ "expect": {
1929
+ "result": {
1930
+ "external_id": "app-77",
1931
+ "identifiers": [
1932
+ {
1933
+ "external_id": "crm-p5",
1934
+ "label": null,
1935
+ "source": "api"
1936
+ }
1937
+ ]
1938
+ }
1939
+ }
1940
+ },
1827
1941
  {
1828
1942
  "id": "people.identifiers.remove/not_found",
1829
1943
  "op": "people.identifiers.remove",
@@ -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