258 lines
8.2 KiB
Elixir
258 lines
8.2 KiB
Elixir
defmodule Mv.Membership.Import.ColumnResolver do
|
|
@moduledoc """
|
|
Read-only resolution of CSV import columns against the database.
|
|
|
|
Given the `HeaderMapper.build_maps/2` result, the raw numbered rows, and an
|
|
actor, `resolve/3` determines:
|
|
|
|
- which group names in the groups column already exist (`groups_found`) and
|
|
which would have to be created (`groups_to_create`);
|
|
- a small set of preview rows for the mapping preview UI.
|
|
|
|
No database writes happen here; the resolver only reads. Group creation and
|
|
member-group assignment happen during processing via `create_or_find_group/3`.
|
|
|
|
This module has no Phoenix or web dependencies.
|
|
"""
|
|
|
|
require Logger
|
|
|
|
alias Mv.Membership.Import.HeaderMapper
|
|
|
|
@preview_row_limit 3
|
|
|
|
@type numbered_row :: {pos_integer(), [String.t()]}
|
|
|
|
@type resolution :: %{
|
|
groups_found: [%{id: String.t(), name: String.t()}],
|
|
groups_to_create: [String.t()],
|
|
fee_type_map: %{String.t() => String.t()},
|
|
fee_type_warnings: [String.t()],
|
|
has_empty_fee_type_cells?: boolean(),
|
|
preview_rows: [[String.t()]]
|
|
}
|
|
|
|
@doc """
|
|
Resolves the group and fee-type columns of an import against the database and
|
|
extracts preview rows.
|
|
|
|
Returns a map with `:groups_found`, `:groups_to_create`, `:fee_type_map`,
|
|
`:fee_type_warnings`, `:has_empty_fee_type_cells?`, and `:preview_rows`.
|
|
"""
|
|
@spec resolve(map(), [numbered_row()], term()) :: resolution()
|
|
def resolve(header_maps, rows, actor) do
|
|
%{
|
|
groups_found: groups_found,
|
|
groups_to_create: groups_to_create
|
|
} = resolve_groups(header_maps, rows, actor)
|
|
|
|
%{
|
|
fee_type_map: fee_type_map,
|
|
fee_type_warnings: fee_type_warnings,
|
|
has_empty_fee_type_cells?: has_empty_fee_type_cells?
|
|
} = resolve_fee_types(header_maps, rows, actor)
|
|
|
|
%{
|
|
groups_found: groups_found,
|
|
groups_to_create: groups_to_create,
|
|
fee_type_map: fee_type_map,
|
|
fee_type_warnings: fee_type_warnings,
|
|
has_empty_fee_type_cells?: has_empty_fee_type_cells?,
|
|
preview_rows: preview_rows(rows)
|
|
}
|
|
end
|
|
|
|
defp resolve_groups(%{groups_column_index: nil}, _rows, _actor) do
|
|
%{groups_found: [], groups_to_create: []}
|
|
end
|
|
|
|
defp resolve_groups(%{groups_column_index: index}, rows, actor) do
|
|
existing_groups = list_groups(actor)
|
|
lookup = build_group_lookup(existing_groups)
|
|
|
|
names = unique_group_names(rows, index)
|
|
|
|
{found, to_create} =
|
|
Enum.reduce(names, {[], []}, fn name, {found, to_create} ->
|
|
case Map.get(lookup, normalize_name(name)) do
|
|
nil -> {found, [name | to_create]}
|
|
group -> {[%{id: group.id, name: group.name} | found], to_create}
|
|
end
|
|
end)
|
|
|
|
%{groups_found: Enum.reverse(found), groups_to_create: Enum.reverse(to_create)}
|
|
end
|
|
|
|
defp resolve_fee_types(%{fee_type_column_index: nil}, _rows, _actor) do
|
|
%{fee_type_map: %{}, fee_type_warnings: [], has_empty_fee_type_cells?: false}
|
|
end
|
|
|
|
defp resolve_fee_types(%{fee_type_column_index: index}, rows, actor) do
|
|
lookup = build_fee_type_lookup(actor)
|
|
|
|
cells = Enum.map(rows, fn {_line, values} -> Enum.at(values, index) end)
|
|
|
|
has_empty? = Enum.any?(cells, &blank?/1)
|
|
|
|
{fee_type_map, warnings} =
|
|
cells
|
|
|> Enum.reject(&blank?/1)
|
|
|> Enum.uniq_by(&normalize_fee_type_name/1)
|
|
|> Enum.reduce({%{}, []}, fn name, {map, warnings} ->
|
|
case Map.get(lookup, normalize_fee_type_name(name)) do
|
|
nil -> {map, [String.trim(name) | warnings]}
|
|
id -> {Map.put(map, normalize_fee_type_name(name), id), warnings}
|
|
end
|
|
end)
|
|
|
|
%{
|
|
fee_type_map: fee_type_map,
|
|
fee_type_warnings: Enum.reverse(warnings),
|
|
has_empty_fee_type_cells?: has_empty?
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Normalizes a fee-type name using the same rules as CSV header normalization
|
|
(trim, lowercase, transliterate, drop hyphens and whitespace).
|
|
"""
|
|
@spec normalize_fee_type_name(String.t() | nil) :: String.t()
|
|
def normalize_fee_type_name(name) when is_binary(name), do: HeaderMapper.normalize_header(name)
|
|
def normalize_fee_type_name(_), do: ""
|
|
|
|
defp build_fee_type_lookup(actor) do
|
|
actor
|
|
|> list_fee_types()
|
|
|> Enum.reduce(%{}, fn fee_type, acc ->
|
|
normalized = normalize_fee_type_name(fee_type.name)
|
|
|
|
if Map.has_key?(acc, normalized) do
|
|
Logger.warning(
|
|
"Multiple membership fee types normalize to #{inspect(normalized)}; using the first match for CSV import."
|
|
)
|
|
|
|
acc
|
|
else
|
|
Map.put(acc, normalized, fee_type.id)
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp list_fee_types(actor) do
|
|
Mv.MembershipFees.list_membership_fee_types!(actor: actor)
|
|
end
|
|
|
|
defp blank?(nil), do: true
|
|
defp blank?(value) when is_binary(value), do: String.trim(value) == ""
|
|
defp blank?(_), do: false
|
|
|
|
@doc """
|
|
Finds an existing group by name (case-insensitive) or creates it.
|
|
|
|
Looks first in the pre-fetched `groups` list, then in the database (to catch
|
|
groups created earlier in the same import), and only creates a new group when
|
|
none is found. This keeps group resolution idempotent across re-imports.
|
|
"""
|
|
@spec create_or_find_group(String.t(), [Mv.Membership.Group.t()], term()) ::
|
|
{:ok, Mv.Membership.Group.t()} | {:error, term()}
|
|
def create_or_find_group(name, groups, actor) when is_binary(name) do
|
|
trimmed = String.trim(name)
|
|
normalized = normalize_name(trimmed)
|
|
|
|
case find_group_in_list(groups, normalized) do
|
|
nil -> find_or_create_group(trimmed, normalized, actor)
|
|
group -> {:ok, group}
|
|
end
|
|
end
|
|
|
|
defp find_group_in_list(groups, normalized) do
|
|
Enum.find(groups, fn group -> normalize_name(group.name) == normalized end)
|
|
end
|
|
|
|
defp find_or_create_group(trimmed, normalized, actor) do
|
|
case fetch_group_by_normalized_name(normalized, actor) do
|
|
nil -> create_group(trimmed, normalized, actor)
|
|
group -> {:ok, group}
|
|
end
|
|
end
|
|
|
|
# Normalizes the Ash code-interface return to a two-shape result.
|
|
#
|
|
# On a create failure the group may have been created concurrently by another
|
|
# import session between our read and our write (the DB unique index is the
|
|
# final arbiter, and the name validation is fail-open). Re-fetch by normalized
|
|
# name and link to the existing group rather than failing the row.
|
|
defp create_group(name, normalized, actor) do
|
|
case Mv.Membership.create_group(%{name: name}, actor: actor) do
|
|
{:ok, %Mv.Membership.Group{} = group} ->
|
|
{:ok, group}
|
|
|
|
{:error, reason} ->
|
|
case fetch_group_by_normalized_name(normalized, actor) do
|
|
nil -> {:error, reason}
|
|
group -> {:ok, group}
|
|
end
|
|
end
|
|
end
|
|
|
|
# Fetches a single group by case-insensitive name using a name-filtered query
|
|
# rather than reading the whole groups table. `normalized` is the trimmed,
|
|
# lower-cased name; the DB comparison uses LOWER(name) consistent with the
|
|
# Group resource's case-insensitive uniqueness constraint.
|
|
defp fetch_group_by_normalized_name(normalized, actor) do
|
|
require Ash.Query
|
|
|
|
Mv.Membership.Group
|
|
|> Ash.Query.filter(fragment("LOWER(?) = ?", name, ^normalized))
|
|
|> Ash.read(actor: actor, domain: Mv.Membership)
|
|
|> case do
|
|
{:ok, [group | _]} -> group
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Splits a raw groups-cell value into trimmed, non-empty group names.
|
|
"""
|
|
@spec split_group_names(String.t() | nil) :: [String.t()]
|
|
def split_group_names(nil), do: []
|
|
|
|
def split_group_names(cell) when is_binary(cell) do
|
|
cell
|
|
|> String.split(",")
|
|
|> Enum.map(&String.trim/1)
|
|
|> Enum.reject(&(&1 == ""))
|
|
end
|
|
|
|
defp unique_group_names(rows, index) do
|
|
rows
|
|
|> Enum.flat_map(fn {_line, values} ->
|
|
values
|
|
|> Enum.at(index)
|
|
|> split_group_names()
|
|
end)
|
|
|> Enum.uniq_by(&normalize_name/1)
|
|
end
|
|
|
|
defp preview_rows(rows) do
|
|
rows
|
|
|> Enum.take(@preview_row_limit)
|
|
|> Enum.map(fn {_line, values} -> values end)
|
|
end
|
|
|
|
defp list_groups(actor) do
|
|
Mv.Membership.list_groups!(actor: actor)
|
|
end
|
|
|
|
defp build_group_lookup(groups) do
|
|
Enum.reduce(groups, %{}, fn group, acc ->
|
|
Map.put(acc, normalize_name(group.name), group)
|
|
end)
|
|
end
|
|
|
|
# Case-insensitive comparison consistent with the Group resource's
|
|
# case-insensitive name uniqueness.
|
|
defp normalize_name(name) when is_binary(name) do
|
|
name |> String.trim() |> String.downcase()
|
|
end
|
|
end
|