feat(import): recognize group and fee-type columns and always ignore fee-status
This commit is contained in:
parent
5c5fd56749
commit
95c7bf7a15
2 changed files with 250 additions and 11 deletions
|
|
@ -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} ->
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue