Widen dedup match to 4-char grid prefix so an upload with a longer grid (e.g. FN42aa25) finds the existing FN42 contact. Within a match, classify as a refinement when the upload strictly extends the existing grid or fills a missing mode; as a contradiction (still skipped) when grids or modes genuinely disagree. Refinements flow through a new preview bucket, update the existing row in place, and re-enqueue enrichment when the position changed.
171 lines
5.5 KiB
Elixir
171 lines
5.5 KiB
Elixir
defmodule Microwaveprop.Radio.ImportMatcher do
|
|
@moduledoc """
|
|
Pure classification for CSV/ADIF upload rows against existing Contact records.
|
|
|
|
`prefix_key/5` builds a sortable key from `{station, 4-char grid prefix, band}`
|
|
so callers can bulk-lookup candidates from the database. `classify_against_existing/2`
|
|
compares a single upload row against a single candidate contact and decides
|
|
whether the upload is a plain duplicate, a grid/mode refinement of the existing
|
|
record, or an irreconcilable contradiction that must be skipped.
|
|
"""
|
|
|
|
alias Microwaveprop.Radio.Contact
|
|
|
|
@allowed_modes ~w(CW SSB FM FT8 FT4 Q65)
|
|
|
|
@type prefix_key ::
|
|
{{String.t(), String.t()}, {String.t(), String.t()}, integer()} | :invalid
|
|
|
|
@type change_map :: %{optional(:grid1) => String.t(), optional(:grid2) => String.t(), optional(:mode) => String.t()}
|
|
@type classification :: :duplicate | {:refinement, change_map()} | :contradiction
|
|
|
|
@spec prefix_key(String.t() | nil, String.t() | nil, String.t() | nil, String.t() | nil, term()) ::
|
|
prefix_key()
|
|
def prefix_key(station1, grid1, station2, grid2, band) do
|
|
with {:ok, s1, g1_prefix} <- build_side(station1, grid1),
|
|
{:ok, s2, g2_prefix} <- build_side(station2, grid2),
|
|
{:ok, band_int} <- normalize_band(band) do
|
|
[a, b] = Enum.sort([{s1, g1_prefix}, {s2, g2_prefix}])
|
|
{a, b, band_int}
|
|
else
|
|
_ -> :invalid
|
|
end
|
|
end
|
|
|
|
defp build_side(station, grid) do
|
|
with station_up when is_binary(station_up) <- upcase_trim(station),
|
|
grid_up when is_binary(grid_up) <- upcase_trim(grid),
|
|
true <- byte_size(grid_up) >= 4 do
|
|
{:ok, station_up, binary_part(grid_up, 0, 4)}
|
|
else
|
|
_ -> :error
|
|
end
|
|
end
|
|
|
|
defp upcase_trim(nil), do: nil
|
|
|
|
defp upcase_trim(value) when is_binary(value) do
|
|
trimmed = String.trim(value)
|
|
if trimmed == "", do: nil, else: String.upcase(trimmed)
|
|
end
|
|
|
|
defp upcase_trim(_), do: nil
|
|
|
|
defp normalize_band(%Decimal{} = d) do
|
|
{:ok, Decimal.to_integer(d)}
|
|
rescue
|
|
_ -> :error
|
|
end
|
|
|
|
defp normalize_band(value) when is_integer(value), do: {:ok, value}
|
|
|
|
defp normalize_band(value) when is_binary(value) do
|
|
case value |> String.trim() |> Integer.parse() do
|
|
{int, ""} -> {:ok, int}
|
|
_ -> :error
|
|
end
|
|
end
|
|
|
|
defp normalize_band(_), do: :error
|
|
|
|
@spec classify_against_existing(map(), Contact.t()) :: classification()
|
|
def classify_against_existing(upload_attrs, %Contact{} = existing) do
|
|
up_s1 = upcase_trim(upload_attrs["station1"])
|
|
up_s2 = upcase_trim(upload_attrs["station2"])
|
|
ex_s1 = upcase_trim(existing.station1)
|
|
ex_s2 = upcase_trim(existing.station2)
|
|
|
|
case resolve_direction(ex_s1, ex_s2, up_s1, up_s2) do
|
|
:ambiguous -> :duplicate
|
|
{up_grid1_key, up_grid2_key} -> compare_sides(existing, upload_attrs, up_grid1_key, up_grid2_key)
|
|
end
|
|
end
|
|
|
|
# Returns which upload keys ("grid1"/"grid2") correspond to existing's side1/side2.
|
|
# If direction matches as-is: {"grid1", "grid2"}. If swapped: {"grid2", "grid1"}.
|
|
# `:ambiguous` when callsigns don't pair cleanly (e.g. same callsign both sides).
|
|
defp resolve_direction(ex_s1, ex_s2, up_s1, up_s2) do
|
|
if pairable_callsigns?(ex_s1, ex_s2, up_s1, up_s2) do
|
|
match_direction(ex_s1, ex_s2, up_s1, up_s2)
|
|
else
|
|
:ambiguous
|
|
end
|
|
end
|
|
|
|
defp pairable_callsigns?(ex_s1, ex_s2, up_s1, up_s2) do
|
|
not is_nil(ex_s1) and not is_nil(ex_s2) and not is_nil(up_s1) and not is_nil(up_s2) and
|
|
ex_s1 != ex_s2 and up_s1 != up_s2
|
|
end
|
|
|
|
defp match_direction(ex_s1, ex_s2, up_s1, up_s2) do
|
|
cond do
|
|
ex_s1 == up_s1 and ex_s2 == up_s2 -> {"grid1", "grid2"}
|
|
ex_s1 == up_s2 and ex_s2 == up_s1 -> {"grid2", "grid1"}
|
|
true -> :ambiguous
|
|
end
|
|
end
|
|
|
|
defp compare_sides(existing, upload_attrs, up_grid1_key, up_grid2_key) do
|
|
up_g1 = upcase_trim(upload_attrs[up_grid1_key])
|
|
up_g2 = upcase_trim(upload_attrs[up_grid2_key])
|
|
ex_g1 = upcase_trim(existing.grid1)
|
|
ex_g2 = upcase_trim(existing.grid2)
|
|
|
|
grid1_result = compare_grid(ex_g1, up_g1)
|
|
grid2_result = compare_grid(ex_g2, up_g2)
|
|
mode_result = compare_mode(existing.mode, upload_attrs["mode"])
|
|
|
|
combine_results(grid1_result, grid2_result, mode_result)
|
|
end
|
|
|
|
# Each compare_* returns :same | :contradiction | {:refine, new_value}
|
|
defp compare_grid(nil, _up), do: :same
|
|
defp compare_grid(_ex, nil), do: :same
|
|
defp compare_grid(ex, up) when ex == up, do: :same
|
|
|
|
defp compare_grid(ex, up) do
|
|
cond do
|
|
byte_size(up) > byte_size(ex) and String.starts_with?(up, ex) -> {:refine, up}
|
|
byte_size(up) < byte_size(ex) and String.starts_with?(ex, up) -> :same
|
|
true -> :contradiction
|
|
end
|
|
end
|
|
|
|
defp compare_mode(_ex, nil), do: :same
|
|
|
|
defp compare_mode(nil, up) do
|
|
normalized = upcase_trim(up)
|
|
|
|
cond do
|
|
is_nil(normalized) -> :same
|
|
normalized in @allowed_modes -> {:refine, normalized}
|
|
true -> :same
|
|
end
|
|
end
|
|
|
|
defp compare_mode(ex, up) do
|
|
up_norm = upcase_trim(up)
|
|
|
|
cond do
|
|
is_nil(up_norm) -> :same
|
|
String.upcase(ex) == up_norm -> :same
|
|
true -> :contradiction
|
|
end
|
|
end
|
|
|
|
defp combine_results(grid1_result, grid2_result, mode_result) do
|
|
results = [{:grid1, grid1_result}, {:grid2, grid2_result}, {:mode, mode_result}]
|
|
|
|
if Enum.any?(results, fn {_k, r} -> r == :contradiction end) do
|
|
:contradiction
|
|
else
|
|
changes =
|
|
Enum.reduce(results, %{}, fn
|
|
{key, {:refine, value}}, acc -> Map.put(acc, key, value)
|
|
_, acc -> acc
|
|
end)
|
|
|
|
if changes == %{}, do: :duplicate, else: {:refinement, changes}
|
|
end
|
|
end
|
|
end
|