Add Statistics module for member and cycle aggregates
- first_join_year, active/inactive counts, joins/exits by year - cycle_totals_by_year, open_amount_total - Unit tests for Statistics
This commit is contained in:
parent
82e908a7e4
commit
fd10fe5cf6
2 changed files with 370 additions and 0 deletions
180
lib/mv/statistics.ex
Normal file
180
lib/mv/statistics.ex
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
defmodule Mv.Statistics do
|
||||
@moduledoc """
|
||||
Aggregated statistics for members and membership fee cycles.
|
||||
|
||||
Used by the statistics LiveView to display counts and sums. All functions
|
||||
accept an `opts` keyword list and pass `:actor` (and `:domain` where needed)
|
||||
to Ash reads so that policies are enforced.
|
||||
"""
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
|
||||
@doc """
|
||||
Returns the earliest year in which any member has a join_date.
|
||||
|
||||
Used to determine the start of the "relevant" year range for statistics
|
||||
(from first membership to current year). Returns `nil` if no member has
|
||||
a join_date.
|
||||
"""
|
||||
@spec first_join_year(keyword()) :: non_neg_integer() | nil
|
||||
def first_join_year(opts) do
|
||||
query =
|
||||
Member
|
||||
|> Ash.Query.filter(expr(not is_nil(join_date)))
|
||||
|> Ash.Query.sort(join_date: :asc)
|
||||
|> Ash.Query.limit(1)
|
||||
|
||||
case Ash.read_one(query, opts) do
|
||||
{:ok, nil} -> nil
|
||||
{:ok, member} -> member.join_date.year
|
||||
{:error, _} -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the count of active members (exit_date is nil).
|
||||
"""
|
||||
@spec active_member_count(keyword()) :: non_neg_integer()
|
||||
def active_member_count(opts) do
|
||||
query =
|
||||
Member
|
||||
|> Ash.Query.filter(expr(is_nil(exit_date)))
|
||||
|
||||
case Ash.count(query, opts) do
|
||||
{:ok, count} -> count
|
||||
{:error, _} -> 0
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the count of inactive members (exit_date is not nil).
|
||||
"""
|
||||
@spec inactive_member_count(keyword()) :: non_neg_integer()
|
||||
def inactive_member_count(opts) do
|
||||
query =
|
||||
Member
|
||||
|> Ash.Query.filter(expr(not is_nil(exit_date)))
|
||||
|
||||
case Ash.count(query, opts) do
|
||||
{:ok, count} -> count
|
||||
{:error, _} -> 0
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the count of members who joined in the given year (join_date in that year).
|
||||
"""
|
||||
@spec joins_by_year(integer(), keyword()) :: non_neg_integer()
|
||||
def joins_by_year(year, opts) do
|
||||
first_day = Date.new!(year, 1, 1)
|
||||
last_day = Date.new!(year, 12, 31)
|
||||
|
||||
query =
|
||||
Member
|
||||
|> Ash.Query.filter(expr(join_date >= ^first_day and join_date <= ^last_day))
|
||||
|
||||
case Ash.count(query, opts) do
|
||||
{:ok, count} -> count
|
||||
{:error, _} -> 0
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the count of members who exited in the given year (exit_date in that year).
|
||||
"""
|
||||
@spec exits_by_year(integer(), keyword()) :: non_neg_integer()
|
||||
def exits_by_year(year, opts) do
|
||||
first_day = Date.new!(year, 1, 1)
|
||||
last_day = Date.new!(year, 12, 31)
|
||||
|
||||
query =
|
||||
Member
|
||||
|> Ash.Query.filter(expr(exit_date >= ^first_day and exit_date <= ^last_day))
|
||||
|
||||
case Ash.count(query, opts) do
|
||||
{:ok, count} -> count
|
||||
{:error, _} -> 0
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns totals for membership fee cycles whose cycle_start falls in the given year.
|
||||
|
||||
Returns a map with keys: `:total`, `:paid`, `:unpaid`, `:suspended` (each a Decimal sum).
|
||||
"""
|
||||
@spec cycle_totals_by_year(integer(), keyword()) :: %{
|
||||
total: Decimal.t(),
|
||||
paid: Decimal.t(),
|
||||
unpaid: Decimal.t(),
|
||||
suspended: Decimal.t()
|
||||
}
|
||||
def cycle_totals_by_year(year, opts) do
|
||||
first_day = Date.new!(year, 1, 1)
|
||||
last_day = Date.new!(year, 12, 31)
|
||||
|
||||
query =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(expr(cycle_start >= ^first_day and cycle_start <= ^last_day))
|
||||
|
||||
opts_with_domain = Keyword.put(opts, :domain, MembershipFees)
|
||||
|
||||
case Ash.read(query, opts_with_domain) do
|
||||
{:ok, cycles} -> cycle_totals_from_cycles(cycles)
|
||||
{:error, _} -> zero_cycle_totals()
|
||||
end
|
||||
end
|
||||
|
||||
defp cycle_totals_from_cycles(cycles) do
|
||||
by_status = Enum.group_by(cycles, & &1.status)
|
||||
sum = fn status -> sum_amounts(by_status[status] || []) end
|
||||
|
||||
total =
|
||||
[:paid, :unpaid, :suspended]
|
||||
|> Enum.map(&sum.(&1))
|
||||
|> Enum.reduce(Decimal.new(0), &Decimal.add/2)
|
||||
|
||||
%{
|
||||
total: total,
|
||||
paid: sum.(:paid),
|
||||
unpaid: sum.(:unpaid),
|
||||
suspended: sum.(:suspended)
|
||||
}
|
||||
end
|
||||
|
||||
defp sum_amounts(cycles),
|
||||
do: Enum.reduce(cycles, Decimal.new(0), fn c, acc -> Decimal.add(acc, c.amount) end)
|
||||
|
||||
defp zero_cycle_totals do
|
||||
%{
|
||||
total: Decimal.new(0),
|
||||
paid: Decimal.new(0),
|
||||
unpaid: Decimal.new(0),
|
||||
suspended: Decimal.new(0)
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the sum of amount for all cycles with status :unpaid.
|
||||
"""
|
||||
@spec open_amount_total(keyword()) :: Decimal.t()
|
||||
def open_amount_total(opts) do
|
||||
query =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(expr(status == :unpaid))
|
||||
|
||||
opts_with_domain = Keyword.put(opts, :domain, MembershipFees)
|
||||
|
||||
case Ash.read(query, opts_with_domain) do
|
||||
{:ok, cycles} ->
|
||||
Enum.reduce(cycles, Decimal.new(0), fn c, acc -> Decimal.add(acc, c.amount) end)
|
||||
|
||||
{:error, _} ->
|
||||
Decimal.new(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue