Statistic Page closes #310 #417

Merged
moritz merged 16 commits from feature/statistics into main 2026-02-12 19:40:23 +01:00
2 changed files with 34 additions and 28 deletions
Showing only changes of commit a263cb4954 - Show all commits

View file

@ -87,7 +87,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
def generate_cycles_for_member(member_or_id, opts \\ []) def generate_cycles_for_member(member_or_id, opts \\ [])
def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do
case load_member(member_id) do case load_member(member_id, opts) do
{:ok, member} -> generate_cycles_for_member(member, opts) {:ok, member} -> generate_cycles_for_member(member, opts)
{:error, reason} -> {:error, reason} {:error, reason} -> {:error, reason}
end end
@ -97,25 +97,25 @@ defmodule Mv.MembershipFees.CycleGenerator do
today = Keyword.get(opts, :today, Date.utc_today()) today = Keyword.get(opts, :today, Date.utc_today())
skip_lock? = Keyword.get(opts, :skip_lock?, false) skip_lock? = Keyword.get(opts, :skip_lock?, false)
do_generate_cycles_with_lock(member, today, skip_lock?) do_generate_cycles_with_lock(member, today, skip_lock?, opts)
end end
# Generate cycles with lock handling # Generate cycles with lock handling
# Returns {:ok, cycles, notifications} - notifications are never sent here, # Returns {:ok, cycles, notifications} - notifications are never sent here,
# they should be returned to the caller (e.g., via after_action hook) # they should be returned to the caller (e.g., via after_action hook)
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?) do defp do_generate_cycles_with_lock(member, today, true = _skip_lock?, opts) do
# Lock already set by caller (e.g., regenerate_cycles_on_type_change) # Lock already set by caller (e.g., regenerate_cycles_on_type_change or seeds)
# Just generate cycles without additional locking # Just generate cycles without additional locking
do_generate_cycles(member, today) do_generate_cycles(member, today, opts)
end end
defp do_generate_cycles_with_lock(member, today, false) do defp do_generate_cycles_with_lock(member, today, false, opts) do
lock_key = :erlang.phash2(member.id) lock_key = :erlang.phash2(member.id)
Repo.transaction(fn -> Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key]) Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_generate_cycles(member, today) do case do_generate_cycles(member, today, opts) do
{:ok, cycles, notifications} -> {:ok, cycles, notifications} ->
# Return cycles and notifications - do NOT send notifications here # Return cycles and notifications - do NOT send notifications here
# They will be sent by the caller (e.g., via after_action hook) # They will be sent by the caller (e.g., via after_action hook)
@ -235,25 +235,33 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Private functions # Private functions
defp load_member(member_id) do # Use actor from opts when provided (e.g. seeds pass admin); otherwise system actor
system_actor = SystemActor.get_system_actor() defp get_actor(opts) do
opts = Helpers.ash_actor_opts(system_actor) case Keyword.get(opts, :actor) do
nil -> SystemActor.get_system_actor()
actor -> actor
end
end
defp load_member(member_id, opts) do
actor = get_actor(opts)
read_opts = Helpers.ash_actor_opts(actor)
query = query =
Member Member
|> Ash.Query.filter(id == ^member_id) |> Ash.Query.filter(id == ^member_id)
|> Ash.Query.load([:membership_fee_type, :membership_fee_cycles]) |> Ash.Query.load([:membership_fee_type, :membership_fee_cycles])
case Ash.read_one(query, opts) do case Ash.read_one(query, read_opts) do
{:ok, nil} -> {:error, :member_not_found} {:ok, nil} -> {:error, :member_not_found}
{:ok, member} -> {:ok, member} {:ok, member} -> {:ok, member}
{:error, reason} -> {:error, reason} {:error, reason} -> {:error, reason}
end end
end end
defp do_generate_cycles(member, today) do defp do_generate_cycles(member, today, opts) do
# Reload member with relationships to ensure fresh data # Reload member with relationships to ensure fresh data
case load_member(member.id) do case load_member(member.id, opts) do
{:ok, member} -> {:ok, member} ->
cond do cond do
is_nil(member.membership_fee_type_id) -> is_nil(member.membership_fee_type_id) ->
@ -263,7 +271,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
{:error, :no_join_date} {:error, :no_join_date}
true -> true ->
generate_missing_cycles(member, today) generate_missing_cycles(member, today, opts)
end end
{:error, reason} -> {:error, reason} ->
@ -271,7 +279,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
end end
end end
defp generate_missing_cycles(member, today) do defp generate_missing_cycles(member, today, opts) do
fee_type = member.membership_fee_type fee_type = member.membership_fee_type
interval = fee_type.interval interval = fee_type.interval
amount = fee_type.amount amount = fee_type.amount
@ -287,7 +295,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Only generate if start_date <= end_date # Only generate if start_date <= end_date
if start_date && Date.compare(start_date, end_date) != :gt do if start_date && Date.compare(start_date, end_date) != :gt do
cycle_starts = generate_cycle_starts(start_date, end_date, interval) cycle_starts = generate_cycle_starts(start_date, end_date, interval)
create_cycles(cycle_starts, member.id, fee_type.id, amount) create_cycles(cycle_starts, member.id, fee_type.id, amount, opts)
else else
{:ok, [], []} {:ok, [], []}
end end
@ -382,9 +390,9 @@ defmodule Mv.MembershipFees.CycleGenerator do
end end
end end
defp create_cycles(cycle_starts, member_id, fee_type_id, amount) do defp create_cycles(cycle_starts, member_id, fee_type_id, amount, opts) do
system_actor = SystemActor.get_system_actor() actor = get_actor(opts)
opts = Helpers.ash_actor_opts(system_actor) create_opts = Helpers.ash_actor_opts(actor)
# Always use return_notifications?: true to collect notifications # Always use return_notifications?: true to collect notifications
# Notifications will be returned to the caller, who is responsible for # Notifications will be returned to the caller, who is responsible for
@ -400,7 +408,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
} }
handle_cycle_creation_result( handle_cycle_creation_result(
Ash.create(MembershipFeeCycle, attrs, [return_notifications?: true] ++ opts), Ash.create(MembershipFeeCycle, attrs, [return_notifications?: true] ++ create_opts),
cycle_start cycle_start
) )
end) end)

View file

@ -379,10 +379,9 @@ Enum.each(member_attrs_list, fn member_attrs ->
# Generate cycles if member has a fee type # Generate cycles if member has a fee type
if final_member.membership_fee_type_id do if final_member.membership_fee_type_id do
# Load member with cycles to check if they already exist # Load member with cycles to check if they already exist (actor required for auth)
member_with_cycles = member_with_cycles =
final_member Ash.load!(final_member, :membership_fee_cycles, actor: admin_user_with_role)
|> Ash.load!(:membership_fee_cycles)
# Only generate if no cycles exist yet (to avoid duplicates on re-run) # Only generate if no cycles exist yet (to avoid duplicates on re-run)
cycles = cycles =
@ -427,7 +426,7 @@ Enum.each(member_attrs_list, fn member_attrs ->
if cycle.status != status do if cycle.status != status do
cycle cycle
|> Ash.Changeset.for_update(:update, %{status: status}) |> Ash.Changeset.for_update(:update, %{status: status})
|> Ash.update!(actor: admin_user_with_role) |> Ash.update!(actor: admin_user_with_role, domain: Mv.MembershipFees)
end end
end) end)
end end
@ -542,10 +541,9 @@ Enum.with_index(linked_members)
# Generate cycles for linked members # Generate cycles for linked members
if final_member.membership_fee_type_id do if final_member.membership_fee_type_id do
# Load member with cycles to check if they already exist # Load member with cycles to check if they already exist (actor required for auth)
member_with_cycles = member_with_cycles =
final_member Ash.load!(final_member, :membership_fee_cycles, actor: admin_user_with_role)
|> Ash.load!(:membership_fee_cycles)
# Only generate if no cycles exist yet (to avoid duplicates on re-run) # Only generate if no cycles exist yet (to avoid duplicates on re-run)
cycles = cycles =
@ -575,7 +573,7 @@ Enum.with_index(linked_members)
if cycle.status != status do if cycle.status != status do
cycle cycle
|> Ash.Changeset.for_update(:update, %{status: status}) |> Ash.Changeset.for_update(:update, %{status: status})
|> Ash.update!() |> Ash.update!(actor: admin_user_with_role, domain: Mv.MembershipFees)
end end
end) end)
end end