feat: add membership fee status to columns and dropdown
This commit is contained in:
parent
36e57b24be
commit
e1266944b1
7 changed files with 725 additions and 514 deletions
170
lib/mv/membership/import/import_runner.ex
Normal file
170
lib/mv/membership/import/import_runner.ex
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
defmodule Mv.Membership.Import.ImportRunner do
|
||||
@moduledoc """
|
||||
Orchestrates CSV member import: file reading, progress tracking, chunk processing,
|
||||
and error formatting. Used by `MvWeb.ImportExportLive` to keep LiveView thin.
|
||||
|
||||
This module does not depend on Phoenix or LiveView. It provides pure functions for
|
||||
progress/merge and side-effectful helpers (read_file_entry, process_chunk) that
|
||||
are called from the LiveView or from tasks started by it.
|
||||
"""
|
||||
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Membership.Import.MemberCSV
|
||||
|
||||
@default_max_errors 50
|
||||
|
||||
@doc """
|
||||
Reads file content from a Phoenix LiveView upload entry (path).
|
||||
|
||||
Used as the callback for `consume_uploaded_entries/3`. Returns `{:ok, content}` or
|
||||
`{:error, reason}` with a user-friendly string.
|
||||
"""
|
||||
@spec read_file_entry(map(), map()) :: {:ok, String.t()} | {:error, String.t()}
|
||||
def read_file_entry(%{path: path}, _entry) do
|
||||
case File.read(path) do
|
||||
{:ok, content} ->
|
||||
{:ok, content}
|
||||
|
||||
{:error, reason} when is_atom(reason) ->
|
||||
{:error, :file.format_error(reason)}
|
||||
|
||||
{:error, %File.Error{reason: reason}} ->
|
||||
{:error, :file.format_error(reason)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, Exception.message(reason)}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Normalizes the result of `consume_uploaded_entries/3` into `{:ok, content}` or `{:error, reason}`.
|
||||
|
||||
Handles both the standard `[{:ok, content}]` and test helpers that may return `[content]`.
|
||||
"""
|
||||
@spec parse_consume_result(list()) :: {:ok, String.t()} | {:error, String.t()}
|
||||
def parse_consume_result(raw) do
|
||||
case raw do
|
||||
[{:ok, content}] when is_binary(content) -> {:ok, content}
|
||||
[content] when is_binary(content) -> {:ok, content}
|
||||
[{:error, reason}] -> {:error, gettext("Failed to read file: %{reason}", reason: reason)}
|
||||
[] -> {:error, gettext("No file was uploaded")}
|
||||
_other -> {:error, gettext("Failed to read uploaded file: unexpected format")}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Builds the initial progress map from a prepared import_state.
|
||||
"""
|
||||
@spec initial_progress(map(), keyword()) :: map()
|
||||
def initial_progress(import_state, opts \\ []) do
|
||||
_max_errors = Keyword.get(opts, :max_errors, @default_max_errors)
|
||||
total = length(import_state.chunks)
|
||||
|
||||
%{
|
||||
inserted: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
warnings: import_state.warnings || [],
|
||||
status: :running,
|
||||
current_chunk: 0,
|
||||
total_chunks: total,
|
||||
errors_truncated?: false
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Merges a chunk result into the current progress and returns updated progress.
|
||||
|
||||
Caps errors at `max_errors` (default 50). Sets `status` to `:done` when all chunks
|
||||
have been processed.
|
||||
"""
|
||||
@spec merge_progress(map(), map(), non_neg_integer(), keyword()) :: map()
|
||||
def merge_progress(progress, chunk_result, current_chunk_idx, opts \\ []) do
|
||||
max_errors = Keyword.get(opts, :max_errors, @default_max_errors)
|
||||
|
||||
all_errors = progress.errors ++ chunk_result.errors
|
||||
new_errors = Enum.take(all_errors, max_errors)
|
||||
errors_truncated? = length(all_errors) > max_errors
|
||||
new_warnings = progress.warnings ++ Map.get(chunk_result, :warnings, [])
|
||||
|
||||
chunks_processed = current_chunk_idx + 1
|
||||
new_status = if chunks_processed >= progress.total_chunks, do: :done, else: :running
|
||||
|
||||
%{
|
||||
inserted: progress.inserted + chunk_result.inserted,
|
||||
failed: progress.failed + chunk_result.failed,
|
||||
errors: new_errors,
|
||||
warnings: new_warnings,
|
||||
status: new_status,
|
||||
current_chunk: chunks_processed,
|
||||
total_chunks: progress.total_chunks,
|
||||
errors_truncated?: errors_truncated? || Map.get(chunk_result, :errors_truncated?, false)
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the next action after processing a chunk: send the next chunk index or done.
|
||||
"""
|
||||
@spec next_chunk_action(non_neg_integer(), non_neg_integer()) ::
|
||||
{:send_chunk, non_neg_integer()} | :done
|
||||
def next_chunk_action(current_idx, total_chunks) do
|
||||
next_idx = current_idx + 1
|
||||
if next_idx < total_chunks, do: {:send_chunk, next_idx}, else: :done
|
||||
end
|
||||
|
||||
@doc """
|
||||
Processes one chunk (validate + create members), then sends `{:chunk_done, idx, result}`
|
||||
or `{:chunk_error, idx, reason}` to `live_view_pid`.
|
||||
|
||||
Options: `:custom_field_lookup`, `:existing_error_count`, `:max_errors`, `:actor`.
|
||||
"""
|
||||
@spec process_chunk(
|
||||
list(),
|
||||
map(),
|
||||
map(),
|
||||
keyword(),
|
||||
pid(),
|
||||
non_neg_integer()
|
||||
) :: :ok
|
||||
def process_chunk(chunk, column_map, custom_field_map, opts, live_view_pid, idx) do
|
||||
result =
|
||||
try do
|
||||
MemberCSV.process_chunk(chunk, column_map, custom_field_map, opts)
|
||||
rescue
|
||||
e -> {:error, Exception.message(e)}
|
||||
catch
|
||||
:exit, reason -> {:error, inspect(reason)}
|
||||
:throw, reason -> {:error, inspect(reason)}
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, chunk_result} -> send(live_view_pid, {:chunk_done, idx, chunk_result})
|
||||
{:error, reason} -> send(live_view_pid, {:chunk_error, idx, reason})
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a user-facing error message for chunk failures (invalid index, missing state,
|
||||
or processing failure).
|
||||
"""
|
||||
@spec format_chunk_error(
|
||||
:invalid_index | :missing_state | :processing_failed,
|
||||
non_neg_integer(),
|
||||
any()
|
||||
) ::
|
||||
String.t()
|
||||
def format_chunk_error(:invalid_index, idx, _reason) do
|
||||
gettext("Invalid chunk index: %{idx}", idx: idx)
|
||||
end
|
||||
|
||||
def format_chunk_error(:missing_state, idx, _reason) do
|
||||
gettext("Import state is missing. Cannot process chunk %{idx}.", idx: idx)
|
||||
end
|
||||
|
||||
def format_chunk_error(:processing_failed, idx, reason) do
|
||||
gettext("Failed to process chunk %{idx}: %{reason}", idx: idx, reason: inspect(reason))
|
||||
end
|
||||
end
|
||||
|
|
@ -16,8 +16,9 @@ defmodule Mv.Membership.MemberExport do
|
|||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||
|
||||
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
||||
["membership_fee_status", "payment_status"]
|
||||
@computed_export_fields ["membership_fee_status", "payment_status"]
|
||||
["membership_fee_status"]
|
||||
@computed_export_fields ["membership_fee_status"]
|
||||
@computed_insert_after "membership_fee_start_date"
|
||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||
@domain_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
|
|
@ -75,9 +76,16 @@ defmodule Mv.Membership.MemberExport do
|
|||
if f in parsed.selectable_member_fields do
|
||||
%{kind: :member_field, key: f}
|
||||
else
|
||||
%{kind: :computed, key: String.to_existing_atom(f)}
|
||||
# only allow known computed export fields to avoid crashing on unknown atoms
|
||||
if f in @computed_export_fields do
|
||||
%{kind: :computed, key: String.to_existing_atom(f)}
|
||||
else
|
||||
# ignore unknown non-selectable fields defensively
|
||||
nil
|
||||
end
|
||||
end
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
custom_specs =
|
||||
parsed.custom_field_ids
|
||||
|
|
@ -96,7 +104,8 @@ defmodule Mv.Membership.MemberExport do
|
|||
|
||||
need_cycles =
|
||||
parsed.show_current_cycle or parsed.cycle_status_filter != nil or
|
||||
parsed.computed_fields != []
|
||||
parsed.computed_fields != [] or
|
||||
"membership_fee_status" in parsed.member_fields
|
||||
|
||||
query =
|
||||
Member
|
||||
|
|
@ -143,6 +152,9 @@ defmodule Mv.Membership.MemberExport do
|
|||
members
|
||||
end
|
||||
|
||||
# Calculate membership_fee_status for computed fields
|
||||
members = add_computed_fields(members, parsed.computed_fields, parsed.show_current_cycle)
|
||||
|
||||
{:ok, members}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
|
|
@ -241,6 +253,19 @@ defmodule Mv.Membership.MemberExport do
|
|||
|
||||
defp apply_cycle_status_filter(members, _status, _show_current), do: members
|
||||
|
||||
defp add_computed_fields(members, computed_fields, show_current_cycle) do
|
||||
computed_fields = computed_fields || []
|
||||
|
||||
if "membership_fee_status" in computed_fields do
|
||||
Enum.map(members, fn member ->
|
||||
status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle)
|
||||
Map.put(member, :membership_fee_status, status) # <= Atom rein
|
||||
end)
|
||||
else
|
||||
members
|
||||
end
|
||||
end
|
||||
|
||||
# Called by controller to build parsed map from raw params (kept here so controller stays thin)
|
||||
@doc """
|
||||
Parses and validates export params (from JSON payload).
|
||||
|
|
@ -251,12 +276,31 @@ defmodule Mv.Membership.MemberExport do
|
|||
"""
|
||||
@spec parse_params(map()) :: map()
|
||||
def parse_params(params) do
|
||||
member_fields = filter_allowed_member_fields(extract_list(params, "member_fields"))
|
||||
{selectable_member_fields, computed_fields} = split_member_fields(member_fields)
|
||||
# DB fields come from "member_fields"
|
||||
raw_member_fields = extract_list(params, "member_fields")
|
||||
member_fields = filter_allowed_member_fields(raw_member_fields)
|
||||
|
||||
# computed fields can come from "computed_fields" (new payload) OR legacy inclusion in member_fields
|
||||
computed_fields =
|
||||
(extract_list(params, "computed_fields") ++ member_fields)
|
||||
|> normalize_computed_fields()
|
||||
|> Enum.filter(&(&1 in @computed_export_fields))
|
||||
|> Enum.uniq()
|
||||
|
||||
# selectable DB fields: only real domain member fields, ordered like the table
|
||||
selectable_member_fields =
|
||||
member_fields
|
||||
|> Enum.filter(&(&1 in @domain_member_field_strings))
|
||||
|> order_member_fields_like_table()
|
||||
|
||||
# final member_fields list (used for column specs order): table order + computed inserted
|
||||
ordered_member_fields =
|
||||
selectable_member_fields
|
||||
|> insert_computed_fields_like_table(computed_fields)
|
||||
|
||||
%{
|
||||
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
|
||||
member_fields: member_fields,
|
||||
member_fields: ordered_member_fields,
|
||||
selectable_member_fields: selectable_member_fields,
|
||||
computed_fields: computed_fields,
|
||||
custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")),
|
||||
|
|
@ -269,12 +313,6 @@ defmodule Mv.Membership.MemberExport do
|
|||
}
|
||||
end
|
||||
|
||||
defp split_member_fields(member_fields) do
|
||||
selectable = Enum.filter(member_fields, fn f -> f in @domain_member_field_strings end)
|
||||
computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end)
|
||||
{selectable, computed}
|
||||
end
|
||||
|
||||
defp extract_boolean(params, key) do
|
||||
case Map.get(params, key) do
|
||||
true -> true
|
||||
|
|
@ -341,4 +379,41 @@ defmodule Mv.Membership.MemberExport do
|
|||
end)
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
defp order_member_fields_like_table(fields) when is_list(fields) do
|
||||
table_order = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
table_order |> Enum.filter(&(&1 in fields))
|
||||
end
|
||||
|
||||
defp insert_computed_fields_like_table(db_fields_ordered, computed_fields) do
|
||||
# Insert membership_fee_status right after membership_fee_start_date (if both selected),
|
||||
# otherwise append at the end of DB fields.
|
||||
computed_fields = computed_fields || []
|
||||
|
||||
db_with_insert =
|
||||
Enum.flat_map(db_fields_ordered, fn f ->
|
||||
if f == @computed_insert_after and "membership_fee_status" in computed_fields do
|
||||
[f, "membership_fee_status"]
|
||||
else
|
||||
[f]
|
||||
end
|
||||
end)
|
||||
|
||||
remaining =
|
||||
computed_fields
|
||||
|> Enum.reject(&(&1 in db_with_insert))
|
||||
|
||||
db_with_insert ++ remaining
|
||||
end
|
||||
|
||||
defp normalize_computed_fields(fields) when is_list(fields) do
|
||||
fields
|
||||
|> Enum.filter(&is_binary/1)
|
||||
|> Enum.map(fn
|
||||
"payment_status" -> "membership_fee_status"
|
||||
other -> other
|
||||
end)
|
||||
end
|
||||
|
||||
defp normalize_computed_fields(_), do: []
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue