summaryrefslogtreecommitdiffstats
path: root/lib/cepis/faixa.ex
blob: ab85e8de9d44f2eea45d2e4042d4b4fe492946c4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
defmodule Cepis.Faixa do
  @moduledoc """
  The municipality a CEP that does **not** exist would have belonged to.

  It arrives on `Cepis.NotFoundError`, and nowhere else. **It is context for an
  error, not an address**: the lookup failed, the CEP is not in the DNE, and
  nothing here says otherwise. Using it as a fallback address is the one wrong
  use.

  It is worth having because a failing CEP is rarely random — it is almost
  always someone who knows the city and guessed the number, most often the
  `-000`, which 957 municipalities never received. Naming the municipality
  turns "does not exist" into "you are in Areal, but not at that number", which
  is the difference between a dead end and a form the user can fix.

  **Field names are the API's own, in Portuguese**, as everywhere else in this
  library: `localidade`, `uf`, `estado`, `regiao`, `ibge`, `cep_ini`,
  `cep_fim`, `ceps`.

  > #### The range is sparse {: .warning}
  >
  > `cep_ini` and `cep_fim` are the smallest and the largest CEP that
  > **actually exist** in that municipality — not the bounds the Correios
  > publish. The DNE's own start for Areal is `25845000`, which is exactly the
  > CEP that just failed; echoing it back would be useless. The numbers between
  > the two bounds are mostly not CEPs at all: Areal's 118 are scattered across
  > thousands of them. That is what `ceps` is for, and what `density/1`
  > measures. Never expand the pair into a list of valid CEPs.

  The API **omits** `faixa`, never sends it as null, when the CEP falls in a
  numeric band no municipality owns. So `nil` is an ordinary answer, not a
  failure, and every caller has to handle it.
  """

  defstruct localidade: "",
            uf: "",
            estado: "",
            regiao: "",
            ibge: "",
            cep_ini: "",
            cep_fim: "",
            ceps: 0

  @type t :: %__MODULE__{
          localidade: String.t(),
          uf: String.t(),
          estado: String.t(),
          regiao: String.t(),
          ibge: String.t(),
          cep_ini: String.t(),
          cep_fim: String.t(),
          ceps: non_neg_integer()
        }

  @doc """
  The share of the numbers between `cep_ini` and `cep_fim` that are real CEPs.

  About `0.024` for Areal — 118 CEPs over the 4899 numbers between `25845001`
  and `25849899`. It exists so the sparsity is measurable instead of being a
  footnote nobody reads before iterating over the interval:

      if Cepis.Faixa.density(faixa) < 0.5 do
        # guessing another number in the range is not going to work
        suggest_search_by_street(faixa.localidade, faixa.uf)
      end

  Returns `0.0` when the bounds are not two readable numbers.
  """
  @spec density(t()) :: float()
  def density(%__MODULE__{ceps: ceps, cep_ini: cep_ini, cep_fim: cep_fim}) do
    with {first, ""} <- Integer.parse(cep_ini),
         {last, ""} <- Integer.parse(cep_fim),
         true <- last >= first do
      ceps / (last - first + 1)
    else
      _ -> 0.0
    end
  end

  @doc false
  @spec from_map(term()) :: t() | nil
  def from_map(%{"localidade" => localidade} = data)
      when is_binary(localidade) and localidade != "" do
    %__MODULE__{
      localidade: localidade,
      uf: text(data, "uf"),
      estado: text(data, "estado"),
      regiao: text(data, "regiao"),
      ibge: text(data, "ibge"),
      cep_ini: text(data, "cep_ini"),
      cep_fim: text(data, "cep_fim"),
      ceps: count(data, "ceps")
    }
  end

  def from_map(_), do: nil

  defp text(data, key) do
    case Map.get(data, key) do
      value when is_binary(value) -> value
      value when is_number(value) -> to_string(value)
      _ -> ""
    end
  end

  defp count(data, key) do
    case Map.get(data, key) do
      value when is_integer(value) ->
        value

      value when is_float(value) ->
        trunc(value)

      value when is_binary(value) ->
        case Integer.parse(value) do
          {number, _} -> number
          :error -> 0
        end

      _ ->
        0
    end
  end
end