bfocus 0.2.0 → 0.2.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6989b99e0788e360a77f8853de9a52f5e9628db20515a2ccbb9c57fd173e4c23
4
- data.tar.gz: 3e3a8766862577d33ddf9e8bb3959b380fbd995f43e97688bd7f8bf05d3a06c2
3
+ metadata.gz: 3daefe09e7aa1fed098c71ffdbc8f0b43c020519b72031355aac21a8b16011ed
4
+ data.tar.gz: a21b02db9aff17837cf93cc6063b16324e4593382543fa7c1c0eedd032d916a9
5
5
  SHA512:
6
- metadata.gz: 337c578cf5bde255220540a83297d298a89a08f97e31f72a274a25b7614c5c9b68b9cd782a95f39382ed3b858019d42a0dd16a4fc66483a281e6e43bce1e2b18
7
- data.tar.gz: 5291a2a3e6b1cd5e6fd6bd7bf9d5dfb2e3abc12aa14cccc70b9fc1d2400eba5980e56edc62e2ccfa988acff36c3afb6bff13f9180cbb2e1c27f610bdc2c45716
6
+ metadata.gz: 111b5a2790952c3ba34c11d7d7f8d101b620b3e44b1869730d6663daf97a2f28c27880febb48aca95e3112f41e9159ba5c2174986d7242ad4b242f9ab8ff15c0
7
+ data.tar.gz: 8a8ee123b356b52e8c8fbfeded349d168bdad83c39a010e3328b7635a0424e13f5413295e2c991e4a9a82fe3444c3200cc0fbbe471c82132468a548ac02c0e02
data/README.md CHANGED
@@ -154,6 +154,22 @@ client.people.identifiers.add("app-77", "crm-p5")
154
154
  client.people.identifiers.remove("app-77", "crm-p5")
155
155
  ```
156
156
 
157
+ ### Ler os identificadores da pessoa (para reconciliar)
158
+
159
+ `client.people.list(...)` mostra só o identificador **principal** de cada pessoa. Quando dois
160
+ cadastros seus eram a mesma pessoa, um dos ids virou **extra** — e some da listagem sem ter sumido
161
+ do cadastro. É isso que faz a sua conferência fechar "633 de 636" sem explicar os 3.
162
+
163
+ `people.identifiers.list` é a fonte de verdade dessa conferência, e é **leitura**: antes dela era
164
+ preciso ESCREVER (tentar um `add`) para descobrir o que tinha acontecido. Aceita no caminho o id
165
+ principal **ou qualquer um dos extras**.
166
+
167
+ ```ruby
168
+ ids = client.people.identifiers.list("crm-p5") # o id extra que "sumiu" da listagem
169
+ ids["external_id"] # => "app-77" — o principal do cadastro
170
+ ids["identifiers"].each { |i| puts "#{i['external_id']} #{i['label']} #{i['source']}" }
171
+ ```
172
+
157
173
  ## Pessoas
158
174
 
159
175
  As pessoas (usuários do seu sistema) de cada cliente, em `client.people`. O `external_id` da pessoa
@@ -177,7 +193,105 @@ client.people.upsert("erp-1042", "app-77", access: true) # devolve o acesso
177
193
  - A mesma pessoa enviada com **outro cliente** é **transferida** para ele.
178
194
  - Como no resto da SDK, só o que você passa muda; `nil` limpa (`phone: nil`).
179
195
  - Campos: `name`, `email`, `phone`, `role`, `access` (pode usar o atendimento), `is_primary`
180
- (contato principal), `extra_emails`, `extra_phones`.
196
+ (contato principal), `extra_emails`, `extra_phones`, `custom_fields`, `clear`.
197
+ - Erros comuns (`code`): `CUSTOMER_NOT_FOUND`, `NAME_REQUIRED` (ao criar), `PERSON_EMAIL_TAKEN`,
198
+ `PERSON_PHONE_TAKEN`, `PERSON_CONTACT_OTHER_CUSTOMER`, `PERSON_EMAIL_STAFF`,
199
+ `PERSON_CLEAR_FIELD_INVALID`, `PERSON_CLEAR_NOT_OWN_RECORD`.
200
+
201
+ ### Campos personalizados da pessoa
202
+
203
+ `custom_fields` leva o que só existe no seu sistema (matrícula, centro de custo, filial). É a
204
+ **exceção** ao "só o que vier muda": a lista enviada **substitui a lista inteira** — campo que
205
+ ficar de fora é **removido**. Mande sempre a lista que o seu sistema tem hoje; omitir o argumento não mexe
206
+ em nada, como em qualquer outro campo.
207
+
208
+ A `visibility` é decidida no bFocus e **preservada entre sincronizações** — por isso ela não vai
209
+ no envio, só volta na resposta: o seu ERP não rebaixa nem promove a exposição de um dado sem
210
+ querer.
211
+
212
+ Vale no upsert de pessoa, no lote de pessoas e na listagem de pessoas do cliente.
213
+
214
+ ```ruby
215
+ pessoa = client.people.upsert(
216
+ "erp-1042", "app-77",
217
+ custom_fields: [ # a lista INTEIRA do seu sistema
218
+ { key: "matricula", label: "Matrícula", value: "4471" },
219
+ { key: "filial", label: "Filial", value: "Centro" }
220
+ ]
221
+ )
222
+ pessoa["custom_fields"].each do |campo|
223
+ puts "#{campo['key']} #{campo['value']} #{campo['visibility']}" # visibility vem do bFocus
224
+ end
225
+ ```
226
+
227
+ ### Apagar o e-mail ou o telefone da pessoa
228
+
229
+ Um contato gravado errado ficava preso para sempre: enquanto a ficha errada segurasse o telefone,
230
+ nenhum reenvio o soltava. `clear` apaga.
231
+
232
+ ```ruby
233
+ client.people.upsert("erp-1042", "app-77", clear: ["phone"]) # some o telefone
234
+ client.people.upsert("erp-1042", "app-77", clear: %w[email phone]) # somem os dois
235
+ ```
236
+
237
+ Três regras que parecem contraintuitivas e são de propósito:
238
+
239
+ - **Apagar é explícito.** `phone: nil`, `clear: []` e não passar o argumento continuam significando
240
+ **"não mexe"** — a SDK não traduz `nil` em `clear`. Fazer o `nil` apagar teria apagado, em
241
+ silêncio e na primeira carga seguinte, o dado de todo sistema que manda `nil` para "não tenho
242
+ esse valor".
243
+ - **Campo fora da lista é recusado, não ignorado**: hoje só `"email"` e `"phone"`; qualquer outro
244
+ devolve 422 `PERSON_CLEAR_FIELD_INVALID` (`Bfocus::ValidationError`).
245
+ - **Só se limpa a própria ficha.** Se você alcançou a pessoa por um identificador **extra**, a API
246
+ recusa com 409 `PERSON_CLEAR_NOT_OWN_RECORD` (`Bfocus::ConflictError`): apagar o contato de uma
247
+ ficha alcançada por apelido seria apagar dado de outro sistema. Para saber se o id que você tem em
248
+ mãos é o principal ou um extra, use `client.people.identifiers.list(...)`.
249
+
250
+ Vale no `people.upsert` e no `people.batch` (`"clear" => ["phone"]` no item).
251
+
252
+ ### Contato já usado: um 409 que você consegue resolver
253
+
254
+ `PERSON_EMAIL_TAKEN` e `PERSON_PHONE_TAKEN` (409) não são "tente de novo": o e-mail (ou o
255
+ telefone) já é de outra pessoa da conta. O erro diz **de quem**, em `error.data` (a API repete o mesmo
256
+ detalhe em `error.validation`, por compatibilidade):
257
+
258
+ | campo | o que é |
259
+ | --- | --- |
260
+ | `field` | `email` ou `phone` — qual contato está tomado |
261
+ | `owner_external_id` | o identificador da pessoa que já usa esse contato |
262
+ | `owner_name` | o nome dela |
263
+ | `owner_customer_external_id` | o cliente a que ela pertence |
264
+
265
+ **É o `owner_customer_external_id` que decide a ação**, e os dois casos pedem coisas opostas:
266
+
267
+ - **mesmo cliente que você enviou** → é quase sempre a MESMA pessoa em dois sistemas. Uma pessoa
268
+ tem **N identificadores**: registre o seu como **extra** dela. A partir daí o seu id encontra
269
+ essa pessoa.
270
+ - **outro cliente** → ninguém decide sozinho a quem a pessoa pertence. Não force: registre o caso
271
+ e leve para quem conhece o cadastro. Unificar dois clientes é decisão de gente, não de um
272
+ casamento por e-mail.
273
+
274
+ ```ruby
275
+ begin
276
+ client.people.upsert("erp-1042", "app-77", name: "Paula Reis", email: "paula@padaria.example")
277
+ rescue Bfocus::ConflictError => e
278
+ raise unless %w[PERSON_EMAIL_TAKEN PERSON_PHONE_TAKEN].include?(e.code)
279
+
280
+ dono = e.data
281
+ if dono["owner_customer_external_id"] == "erp-1042"
282
+ # A mesma pessoa, com dois ids: o seu vira mais um identificador dela.
283
+ client.people.identifiers.add(dono["owner_external_id"], "app-77", label: "ERP")
284
+ else
285
+ # Dono em OUTRO cliente: não decida sozinho — registre e leve para o cadastro.
286
+ avisar_cadastro(e.code, dono)
287
+ end
288
+ end
289
+ ```
290
+
291
+ `PERSON_CONTACT_OTHER_CUSTOMER` (409) é o mesmo assunto pelo outro lado, e é **recusa
292
+ definitiva**: a API não move mais uma pessoa de um cliente para outro só porque o e-mail (ou o
293
+ telefone) casou. Repetir a chamada não resolve — trate como caso para o cadastro, nunca como
294
+ falha temporária.
181
295
 
182
296
  ## Lotes — `customers.batch` e `people.batch`
183
297
 
@@ -410,10 +524,10 @@ Todos são `Hash` com chaves string (campos novos podem aparecer a qualquer mome
410
524
  | Contato (`customers.contacts.*`) | `id`, `external_id`, `name`, `role`, `email`, `phone`, `notes`, `is_primary`, `created_at`, `updated_at` |
411
525
  | Produto vinculado (`customers.products.*`) | `id`, `slug`, `name`, `is_active` |
412
526
  | Interação (`customers.interactions.*`) | `id`, `content`, `is_internal`, `author_kind`, `author_name`, `created_at` |
413
- | Pessoa (`people.list`, `delete`) | `external_id` (pode ser `nil`), `name`, `email`, `phone`, `role`, `access`, `is_primary`, `customer_external_id` |
527
+ | Pessoa (`people.list`, `delete`) | `external_id` (pode ser `nil`), `name`, `email`, `phone`, `role`, `access`, `is_primary`, `customer_external_id`, `custom_fields` (lista de `{key, label, value, visibility}`) |
414
528
  | Pessoa gravada (`people.upsert`) | a pessoa + `status` (`created`/`updated`/`unchanged`) |
415
529
  | Cliente com identificadores (`customers.identifiers.add`, `remove`) | o cliente + `identifiers` (lista de `{external_id, label, source}`) |
416
- | Identificadores da pessoa (`people.identifiers.add`, `remove`) | `external_id`, `identifiers` (lista de `{external_id, label, source}`) |
530
+ | Identificadores da pessoa (`people.identifiers.list`, `add`, `remove`) | `external_id`, `identifiers` (lista de `{external_id, label, source}`) |
417
531
  | Lote (`customers.batch`, `people.batch`) | `results` (lista de `{index, status, external_id, merged_into, error, code}`; `status` ∈ `created`/`updated`/`unchanged`/`error`), `summary` (`{created, updated, unchanged, error}`) |
418
532
  | Produto (`products.*`) | `id`, `slug`, `name`, `description`, `color`, `icon`, `is_active`, `sort_order`, `current_version`, `ai_level`, `created_at`, `updated_at` |
419
533
  | Release note (`release_notes.*`) | `id`, `product`, `version`, `title`, `description_html`, `audience`, `is_published`, `require_ack_internal`, `require_ack_external`, `published_at`, `created_at`, `updated_at` |
@@ -440,7 +554,10 @@ Qualquer resposta fora de 2xx levanta `Bfocus::Error` (ou uma subclasse):
440
554
  | `Bfocus::ServerError` | 5xx |
441
555
  | `Bfocus::NetworkError` | conexão/timeout — `status == 0`, `code == "NETWORK_ERROR"` |
442
556
 
443
- Todas têm `code`, `status`, `request_id`, `validation`, `retry_after`, `required_scope` e `body`.
557
+ Todas têm `code`, `status`, `request_id`, `validation`, `data`, `retry_after`, `required_scope` e
558
+ `body`. O `data` é o `data` do corpo: o detalhe estruturado que alguns erros trazem (`{}` quando
559
+ não há) — é por ele que um 409 de contato tomado diz de **quem** é o contato (veja
560
+ [Pessoas](#pessoas)).
444
561
  **Decida pelo `code`** — ele é estável (`CUSTOMER_NOT_FOUND`, `INTEGRATION_SCOPE_MISSING`,
445
562
  `MODULE_NOT_CONTRACTED`, `VALIDATION_ERROR`…). O `message` é texto para humanos e pode mudar. Ao
446
563
  falar com o suporte, informe o `request_id`: ele vem do corpo da resposta, senão do header
data/lib/bfocus/errors.rb CHANGED
@@ -21,6 +21,12 @@ module Bfocus
21
21
  attr_reader :request_id
22
22
  # @return [Hash{String=>String}] motivos por campo (erros de validação); `{}` quando não há.
23
23
  attr_reader :validation
24
+ # @return [Hash] o `data` do corpo do erro: o detalhe estruturado que alguns erros trazem
25
+ # (`{}` quando não há). É onde vem, por exemplo, de quem é o contato já usado num 409
26
+ # `PERSON_EMAIL_TAKEN`/`PERSON_PHONE_TAKEN` (`field`, `owner_external_id`, `owner_name`,
27
+ # `owner_customer_external_id`) e o `owner` de um `IDENTIFIER_IN_USE`. A API repete esse
28
+ # detalhe em {#validation}, por compatibilidade com as SDKs que ainda não expunham `data`.
29
+ attr_reader :data
24
30
  # @return [Integer, Float, nil] segundos do header `Retry-After` (só em 429).
25
31
  attr_reader :retry_after
26
32
  # @return [String, nil] escopo que faltou na chave (header `X-Required-Scope`, só em 403).
@@ -29,11 +35,12 @@ module Bfocus
29
35
  attr_reader :body
30
36
 
31
37
  def initialize(message = nil, code: nil, status: 0, request_id: nil, validation: nil,
32
- retry_after: nil, required_scope: nil, body: nil)
38
+ retry_after: nil, required_scope: nil, body: nil, data: nil)
33
39
  @code = code
34
40
  @status = status
35
41
  @request_id = request_id
36
42
  @validation = validation.is_a?(Hash) ? validation.dup : {}
43
+ @data = data.is_a?(Hash) ? data.dup : {}
37
44
  @retry_after = retry_after
38
45
  @required_scope = required_scope
39
46
  @body = body
@@ -8,6 +8,21 @@ module Bfocus
8
8
  # Retorno: `{"external_id", "identifiers" => [{"external_id", "label", "source"}, …]}`.
9
9
  # Id que já pertence a outro cadastro: `ConflictError` com `code == "IDENTIFIER_IN_USE"`.
10
10
  class PersonIdentifiers < Base
11
+ # Todos os identificadores da pessoa: o principal (`"external_id"` do retorno) e os extras.
12
+ # `GET /people/{person_external_id}/identifiers` (escopo `customers:read`). Aceita no
13
+ # caminho o principal OU qualquer um dos extras.
14
+ #
15
+ # É a fonte de verdade para RECONCILIAR: {People#list} mostra só o identificador principal,
16
+ # então um id que virou extra some de lá sem ter sumido do cadastro — e, sem esta leitura,
17
+ # era preciso ESCREVER (tentar um {#add}) para descobrir o que tinha acontecido.
18
+ #
19
+ # @return [Hash]
20
+ # @raise [Bfocus::NotFoundError] `PERSON_NOT_FOUND`.
21
+ def list(person_external_id, timeout: nil)
22
+ call("GET", "/people/#{segment(person_external_id, 'person_external_id')}/identifiers",
23
+ timeout: timeout)
24
+ end
25
+
11
26
  # Liga `extra_id` à pessoa (idempotente). `PUT /people/{person_external_id}/identifiers/{extra_id}`
12
27
  #
13
28
  # @param label [String, nil] rótulo livre. Não informado = sem corpo.
@@ -35,7 +50,8 @@ module Bfocus
35
50
  #
36
51
  # Pessoa: `"external_id"` (pode ser `nil` para quem chegou por e-mail/widget sem id),
37
52
  # `"name"`, `"email"`, `"phone"`, `"role"`, `"access"`, `"is_primary"`,
38
- # `"customer_external_id"`. O `upsert` devolve também `"status"`
53
+ # `"customer_external_id"` e `"custom_fields"` (lista de `{"key", "label", "value",
54
+ # "visibility"}`). O `upsert` devolve também `"status"`
39
55
  # (`"created"`/`"updated"`/`"unchanged"`).
40
56
  #
41
57
  # O `external_id` da pessoa é o mesmo `user_external_id` assinado no widget — por isso não
@@ -60,15 +76,28 @@ module Bfocus
60
76
  # @param is_primary [Boolean] contato principal do cliente.
61
77
  # @param extra_emails [Array<String>] e-mails adicionais.
62
78
  # @param extra_phones [Array<String>] telefones adicionais.
79
+ # @param custom_fields [Array<Hash>] campos personalizados (`{"key", "label", "value"}`).
80
+ # Ao contrário de `extra_emails`/`extra_phones`, a lista SUBSTITUI a lista inteira: mande
81
+ # o que o seu sistema tem hoje, porque campo que ficar de fora é REMOVIDO. Não passar o
82
+ # argumento não mexe em nada. A visibilidade é decidida no bFocus e preservada entre
83
+ # sincronizações.
84
+ # @param clear [Array<String>] campos a APAGAR nesta pessoa: `["email"]`, `["phone"]` ou os
85
+ # dois. Apagar é EXPLÍCITO: `phone: nil`, `clear: []` e não passar o argumento continuam
86
+ # significando "não mexe" — a SDK não traduz `nil` em `clear`. Campo fora da lista aceita
87
+ # é RECUSADO pela API (422 `PERSON_CLEAR_FIELD_INVALID`), não ignorado; e só se limpa a
88
+ # PRÓPRIA ficha: alcançando a pessoa por um identificador EXTRA, a API recusa (409
89
+ # `PERSON_CLEAR_NOT_OWN_RECORD`) — apagar contato de ficha alcançada por apelido seria
90
+ # apagar dado de outro sistema.
63
91
  # @return [Hash] a pessoa + `"status"`.
64
92
  def upsert(customer_external_id, person_external_id, name: UNSET, email: UNSET, phone: UNSET,
65
93
  role: UNSET, access: UNSET, is_primary: UNSET, extra_emails: UNSET, extra_phones: UNSET,
66
- idempotency_key: nil, timeout: nil)
94
+ custom_fields: UNSET, clear: UNSET, idempotency_key: nil, timeout: nil)
67
95
  cid = segment(customer_external_id, "customer_external_id")
68
96
  pid = segment(person_external_id, "person_external_id")
69
97
  person = compact(
70
98
  "name" => name, "email" => email, "phone" => phone, "role" => role, "access" => access,
71
- "is_primary" => is_primary, "extra_emails" => extra_emails, "extra_phones" => extra_phones
99
+ "is_primary" => is_primary, "extra_emails" => extra_emails, "extra_phones" => extra_phones,
100
+ "custom_fields" => custom_fields, "clear" => clear
72
101
  )
73
102
  call("PUT", "/customers/#{cid}/people/#{pid}",
74
103
  body: { "person" => person }, idempotency_key: idempotency_key, timeout: timeout)
@@ -211,6 +211,7 @@ module Bfocus
211
211
  human = nil
212
212
  request_id = nil
213
213
  validation = {}
214
+ data = {}
214
215
 
215
216
  if payload.is_a?(Hash)
216
217
  err = payload["error"]
@@ -222,6 +223,10 @@ module Bfocus
222
223
  end
223
224
  human = msg if msg.is_a?(String) && !msg.empty? && msg != code
224
225
  validation = payload["validation"] if payload["validation"].is_a?(Hash)
226
+ # `data` é o detalhe estruturado do erro (de quem é o contato já usado, o dono de um
227
+ # identificador…). A API também o repete em `validation`, mas quem lê o erro precisa
228
+ # alcançá-lo sem depender dessa duplicação.
229
+ data = payload["data"] if payload["data"].is_a?(Hash)
225
230
  rid = payload["request_id"]
226
231
  request_id = rid if rid.is_a?(String) && !rid.empty?
227
232
  elsif !text.strip.empty?
@@ -247,6 +252,7 @@ module Bfocus
247
252
  status: status,
248
253
  request_id: request_id,
249
254
  validation: validation,
255
+ data: data,
250
256
  retry_after: retry_after,
251
257
  required_scope: required_scope,
252
258
  body: payload.nil? ? present(text) : payload
@@ -3,5 +3,5 @@
3
3
  module Bfocus
4
4
  # Versão da gem. O `scripts/release-sdks.sh` do monorepo bumpa esta linha por regex e o
5
5
  # `bfocus.gemspec` lê daqui — mantenha o formato exato `VERSION = "X.Y.Z"`.
6
- VERSION = "0.2.0"
6
+ VERSION = "0.2.1"
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bfocus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Berni Software
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-19 00:00:00.000000000 Z
11
+ date: 2026-09-20 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Clientes, produtos, release notes, base de conhecimento e agentes de
14
14
  IA do bFocus. Zero dependências de runtime, novas tentativas e idempotência automáticas.