feat(import): recognize group and fee-type columns and always ignore fee-status

This commit is contained in:
Moritz 2026-06-03 02:01:09 +02:00
parent 5c5fd56749
commit 95c7bf7a15
2 changed files with 250 additions and 11 deletions

View file

@ -47,10 +47,10 @@ defmodule Mv.Membership.Import.HeaderMapper do
"e-mail" "e-mail"
iex> HeaderMapper.build_maps(["Email", "First Name"], []) iex> HeaderMapper.build_maps(["Email", "First Name"], [])
{:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: []}} {:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}}
iex> HeaderMapper.build_maps(["Email", "CustomField"], [%{id: "cf1", name: "CustomField"}]) iex> HeaderMapper.build_maps(["Email", "CustomField"], [%{id: "cf1", name: "CustomField"}])
{:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: []}} {:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}}
""" """
@type column_map :: %{atom() => non_neg_integer()} @type column_map :: %{atom() => non_neg_integer()}
@ -60,6 +60,31 @@ defmodule Mv.Membership.Import.HeaderMapper do
# Required member fields # Required member fields
@required_member_fields [:email] @required_member_fields [:email]
# Fee-status header variants that must never be imported (computed/read-only field).
# Stored already-normalized; checked before member, custom, groups, and fee-type mapping.
# Maintain this list when new locale translations for fee-status are added.
@ignored_normalized [
"membershipfeestatus",
"mitgliedsbeitragsstatus",
"bezahlstatus"
]
# Normalized header variants for the groups column. The column is resolved to
# group associations during import; it is never a member or custom field.
@groups_column_normalized [
"groups",
"gruppen",
"gruppe"
]
# Normalized header variants for the membership fee-type column. The column is
# resolved to a MembershipFeeType during import; it is never a member or custom field.
@fee_type_column_normalized [
"membershipfeetype",
"feetype",
"beitragsart"
]
# Canonical member fields with their raw variants # Canonical member fields with their raw variants
# These will be normalized at runtime when building the lookup map # These will be normalized at runtime when building the lookup map
@member_field_variants_raw %{ @member_field_variants_raw %{
@ -239,30 +264,79 @@ defmodule Mv.Membership.Import.HeaderMapper do
## Returns ## Returns
- `{:ok, %{member: column_map, custom: custom_field_map, unknown: unknown_headers}}` on success - `{:ok, %{member: column_map, custom: custom_field_map, unknown: unknown_headers,
ignored: [non_neg_integer], groups_column_index: non_neg_integer | nil,
fee_type_column_index: non_neg_integer | nil}}` on success
- `{:error, reason}` on error (missing required field, duplicate headers) - `{:error, reason}` on error (missing required field, duplicate headers)
The `ignored` list holds the indices of fee-status columns (computed/read-only),
which are never mapped to member or custom fields.
## Examples ## Examples
iex> build_maps(["Email", "First Name"], []) iex> build_maps(["Email", "First Name"], [])
{:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: []}} {:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}}
iex> build_maps(["Email", "CustomField"], [%{id: "cf1", name: "CustomField"}]) iex> build_maps(["Email", "CustomField"], [%{id: "cf1", name: "CustomField"}])
{:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: []}} {:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}}
""" """
@spec build_maps([String.t()], [map()]) :: @spec build_maps([String.t()], [map()]) ::
{:ok, %{member: column_map(), custom: custom_field_map(), unknown: unknown_headers()}} {:ok,
%{
member: column_map(),
custom: custom_field_map(),
unknown: unknown_headers(),
ignored: [non_neg_integer()],
groups_column_index: non_neg_integer() | nil,
fee_type_column_index: non_neg_integer() | nil
}}
| {:error, String.t()} | {:error, String.t()}
def build_maps(headers, custom_fields) when is_list(headers) and is_list(custom_fields) do def build_maps(headers, custom_fields) when is_list(headers) and is_list(custom_fields) do
with {:ok, member_map, unknown_after_member} <- build_member_map(headers), ignored = ignored_indices(headers)
groups_column_index = first_matching_index(headers, @groups_column_normalized)
fee_type_column_index = first_matching_index(headers, @fee_type_column_normalized)
reserved =
[groups_column_index, fee_type_column_index | ignored]
|> Enum.reject(&is_nil/1)
|> MapSet.new()
with {:ok, member_map, unknown_after_member} <- build_member_map(headers, reserved),
{:ok, custom_map, unknown_after_custom} <- {:ok, custom_map, unknown_after_custom} <-
build_custom_field_map(headers, unknown_after_member, custom_fields, member_map) do build_custom_field_map(headers, unknown_after_member, custom_fields, member_map) do
unknown = Enum.map(unknown_after_custom, &Enum.at(headers, &1)) unknown = Enum.map(unknown_after_custom, &Enum.at(headers, &1))
{:ok, %{member: member_map, custom: custom_map, unknown: unknown}}
{:ok,
%{
member: member_map,
custom: custom_map,
unknown: unknown,
ignored: ignored,
groups_column_index: groups_column_index,
fee_type_column_index: fee_type_column_index
}}
end end
end end
# Returns the index of the first header whose normalized form is in `variants`,
# or nil if none match.
defp first_matching_index(headers, variants) do
headers
|> Enum.with_index()
|> Enum.find_value(fn {header, index} ->
if normalize_header(header) in variants, do: index
end)
end
# Returns the column indices whose normalized header is in the fee-status ignore list.
defp ignored_indices(headers) do
headers
|> Enum.with_index()
|> Enum.filter(fn {header, _index} -> normalize_header(header) in @ignored_normalized end)
|> Enum.map(fn {_header, index} -> index end)
end
# --- Private Functions --- # --- Private Functions ---
# Transliterates German umlauts and special characters # Transliterates German umlauts and special characters
@ -304,13 +378,14 @@ defmodule Mv.Membership.Import.HeaderMapper do
|> String.replace(" ", "") |> String.replace(" ", "")
end end
# Builds member field column map # Builds member field column map, skipping reserved (e.g. ignored) indices.
defp build_member_map(headers) do defp build_member_map(headers, reserved) do
result = result =
headers headers
|> Enum.with_index() |> Enum.with_index()
|> Enum.reduce_while({%{}, []}, fn {header, index}, {acc_map, acc_unknown} -> |> Enum.reduce_while({%{}, []}, fn {header, index}, {acc_map, acc_unknown} ->
normalized = normalize_header(header) normalized =
if MapSet.member?(reserved, index), do: "", else: normalize_header(header)
case process_member_header(header, index, normalized, acc_map, %{}) do case process_member_header(header, index, normalized, acc_map, %{}) do
{:error, reason} -> {:error, reason} ->

View file

@ -1,5 +1,6 @@
defmodule Mv.Membership.Import.HeaderMapperTest do defmodule Mv.Membership.Import.HeaderMapperTest do
use ExUnit.Case, async: true use ExUnit.Case, async: true
use ExUnitProperties
alias Mv.Membership.Import.HeaderMapper alias Mv.Membership.Import.HeaderMapper
@ -272,4 +273,167 @@ defmodule Mv.Membership.Import.HeaderMapperTest do
assert unknown == [] assert unknown == []
end end
end end
describe "build_maps/2 fee-status ignore list" do
test "places fee-status variants in ignored, not member or custom map" do
headers = ["email", "Bezahlstatus"]
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
assert result.member[:email] == 0
assert result.custom == %{}
assert result.ignored == [1]
refute Map.has_key?(result.member, :bezahlstatus)
end
test "ignores membership_fee_status snake-case variant" do
headers = ["email", "membership_fee_status"]
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
assert result.ignored == [1]
assert result.custom == %{}
end
test "ignores German Mitgliedsbeitragsstatus variant" do
headers = ["email", "Mitgliedsbeitragsstatus"]
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
assert result.ignored == [1]
end
test "fee-status takes priority over a same-named custom field" do
headers = ["email", "Bezahlstatus"]
custom_fields = [%{id: "cf1", name: "Bezahlstatus"}]
assert {:ok, result} = HeaderMapper.build_maps(headers, custom_fields)
assert result.ignored == [1]
assert result.custom == %{}
end
test "result carries groups_column_index and fee_type_column_index keys" do
assert {:ok, result} = HeaderMapper.build_maps(["email"], [])
assert Map.has_key?(result, :groups_column_index)
assert Map.has_key?(result, :fee_type_column_index)
end
end
describe "build_maps/2 groups column detection" do
test "detects German Gruppen variant and excludes it from member/custom maps" do
headers = ["email", "Gruppen"]
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
assert result.groups_column_index == 1
assert result.custom == %{}
assert result.unknown == []
refute Map.has_key?(result.member, :gruppen)
end
test "detects English Groups variant" do
headers = ["email", "Groups"]
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
assert result.groups_column_index == 1
end
test "detects singular Gruppe and lowercase groups variants" do
assert {:ok, %{groups_column_index: 1}} = HeaderMapper.build_maps(["email", "Gruppe"], [])
assert {:ok, %{groups_column_index: 1}} = HeaderMapper.build_maps(["email", "groups"], [])
end
test "groups column takes priority over a same-named custom field" do
headers = ["email", "Gruppen"]
custom_fields = [%{id: "cf1", name: "Gruppen"}]
assert {:ok, result} = HeaderMapper.build_maps(headers, custom_fields)
assert result.groups_column_index == 1
assert result.custom == %{}
end
test "groups_column_index is nil when no groups column present" do
assert {:ok, %{groups_column_index: nil}} = HeaderMapper.build_maps(["email"], [])
end
end
describe "build_maps/2 fee-type column detection" do
test "detects German Beitragsart variant and excludes it from member/custom maps" do
headers = ["email", "Beitragsart"]
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
assert result.fee_type_column_index == 1
assert result.custom == %{}
assert result.unknown == []
end
test "detects English fee type variants" do
assert {:ok, %{fee_type_column_index: 1}} =
HeaderMapper.build_maps(["email", "Fee Type"], [])
assert {:ok, %{fee_type_column_index: 1}} =
HeaderMapper.build_maps(["email", "fee type"], [])
assert {:ok, %{fee_type_column_index: 1}} =
HeaderMapper.build_maps(["email", "fee_type"], [])
assert {:ok, %{fee_type_column_index: 1}} =
HeaderMapper.build_maps(["email", "membership_fee_type"], [])
end
test "fee-type column takes priority over a same-named custom field" do
headers = ["email", "Beitragsart"]
custom_fields = [%{id: "cf1", name: "Beitragsart"}]
assert {:ok, result} = HeaderMapper.build_maps(headers, custom_fields)
assert result.fee_type_column_index == 1
assert result.custom == %{}
end
test "fee_type_column_index is nil when no fee-type column present" do
assert {:ok, %{fee_type_column_index: nil}} = HeaderMapper.build_maps(["email"], [])
end
test "detects groups and fee-type columns together" do
headers = ["email", "Gruppen", "Beitragsart"]
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
assert result.groups_column_index == 1
assert result.fee_type_column_index == 2
assert result.member[:email] == 0
assert result.custom == %{}
assert result.unknown == []
end
end
describe "build_maps/2 fee-status ignore property" do
property "every fee-status variant is ignored, never member or custom" do
check all(
variant <-
StreamData.member_of([
"Membership Fee Status",
"membership_fee_status",
"Mitgliedsbeitragsstatus",
"Bezahlstatus",
" Bezahlstatus ",
"BEZAHLSTATUS"
])
) do
custom_fields = [%{id: "cf1", name: variant}]
assert {:ok, result} = HeaderMapper.build_maps(["email", variant], custom_fields)
assert result.ignored == [1]
assert result.custom == %{}
refute Map.has_key?(result.member, :bezahlstatus)
end
end
end
end end