feat(overview): add point-in-time, suspended, OR-group and count-range filters
This commit is contained in:
parent
c547ca19af
commit
c0d1550401
23 changed files with 1116 additions and 117 deletions
|
|
@ -46,9 +46,16 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
|||
opts[:boolean_custom_fields]
|
||||
)
|
||||
|> apply_date_filters(opts[:date_filters])
|
||||
|> apply_stichtag_filter(opts[:stichtag])
|
||||
|> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields])
|
||||
|> apply_payment_filter(opts[:payment_filter], payment_period(opts))
|
||||
|> apply_sort(opts[:sort_field], opts[:sort_order], opts[:custom_fields] || [])
|
||||
|> apply_suspended_filter(opts[:suspended], payment_period(opts))
|
||||
|> apply_sort(
|
||||
opts[:sort_field],
|
||||
opts[:sort_order],
|
||||
opts[:custom_fields] || [],
|
||||
payment_period(opts)
|
||||
)
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -61,7 +68,12 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
|||
defp apply_search(query, _), do: query
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Group filters (AND across selected groups)
|
||||
# Group filters (:in OR across selected groups; :not_in AND-of-not-exists)
|
||||
#
|
||||
# Multiple "is" selections bundle into a single membership `exists` over the
|
||||
# union of group ids, so a member matching *any* selected group is kept (OR,
|
||||
# §1.29/§2.7). Each "is not" selection stays an independent not-exists, so a
|
||||
# member is excluded when it belongs to *any* of them.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
defp apply_group_filters(query, group_filters, _groups)
|
||||
|
|
@ -71,31 +83,32 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
|||
defp apply_group_filters(query, group_filters, groups) do
|
||||
valid_ids = valid_id_set(groups)
|
||||
|
||||
Enum.reduce(group_filters, query, fn {group_id_str, value}, q ->
|
||||
if MapSet.member?(valid_ids, group_id_str) do
|
||||
apply_one_group_filter(q, group_id_str, value)
|
||||
else
|
||||
q
|
||||
{in_filters, not_in_filters} =
|
||||
group_filters
|
||||
|> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end)
|
||||
|> Enum.split_with(fn {_, value} -> value == :in end)
|
||||
|
||||
query
|
||||
|> apply_group_in_filter(Enum.map(in_filters, fn {id_str, _} -> id_str end))
|
||||
|> apply_group_not_in_filters(Enum.map(not_in_filters, fn {id_str, _} -> id_str end))
|
||||
end
|
||||
|
||||
defp apply_group_in_filter(query, id_strs) do
|
||||
case cast_uuids(id_strs) do
|
||||
[] -> query
|
||||
uuids -> Ash.Query.filter(query, expr(exists(member_groups, group_id in ^uuids)))
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_group_not_in_filters(query, id_strs) do
|
||||
Enum.reduce(id_strs, query, fn id_str, q ->
|
||||
case Ecto.UUID.cast(id_str) do
|
||||
{:ok, uuid} -> Ash.Query.filter(q, expr(not exists(member_groups, group_id == ^uuid)))
|
||||
_ -> q
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp apply_one_group_filter(query, group_id_str, :in) do
|
||||
case Ecto.UUID.cast(group_id_str) do
|
||||
{:ok, uuid} -> Ash.Query.filter(query, expr(exists(member_groups, group_id == ^uuid)))
|
||||
_ -> query
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_one_group_filter(query, group_id_str, :not_in) do
|
||||
case Ecto.UUID.cast(group_id_str) do
|
||||
{:ok, uuid} -> Ash.Query.filter(query, expr(not exists(member_groups, group_id == ^uuid)))
|
||||
_ -> query
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_one_group_filter(query, _id, _value), do: query
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fee-type filters (:in OR; :not_in AND)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -209,8 +222,53 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
|||
)
|
||||
end
|
||||
|
||||
# Two-sided open-cycle range (§1.22/§3.14): keeps members whose in-period
|
||||
# unpaid count is within [min, max]. `max` nil means unbounded above, so
|
||||
# `{:unpaid_range, 1, nil}` is the default "has open" (>= 1). Both bounds push
|
||||
# to SQL through the same `unpaid_cycle_count` calculation.
|
||||
defp apply_payment_filter(query, {:unpaid_range, min, max}, %{from: from, to: to})
|
||||
when is_integer(min) and min > 0 do
|
||||
query
|
||||
|> Ash.Query.filter(unpaid_cycle_count(period_from: ^from, period_to: ^to) >= ^min)
|
||||
|> apply_unpaid_upper_bound(max, from, to)
|
||||
end
|
||||
|
||||
defp apply_payment_filter(query, _filter, _period), do: query
|
||||
|
||||
defp apply_unpaid_upper_bound(query, max, from, to) when is_integer(max) and max > 0 do
|
||||
Ash.Query.filter(
|
||||
query,
|
||||
expr(unpaid_cycle_count(period_from: ^from, period_to: ^to) <= ^max)
|
||||
)
|
||||
end
|
||||
|
||||
defp apply_unpaid_upper_bound(query, _max, _from, _to), do: query
|
||||
|
||||
# Suspended-status filter (§1.23): keeps members with at least one suspended
|
||||
# cycle whose cycle_end falls in the active period. Independent of the unpaid
|
||||
# count, which excludes suspended (§2.5). A correlated `exists` pushes to SQL;
|
||||
# the period bounds are composed conditionally so an unbounded (nil) side adds
|
||||
# no `cycle_end` comparison rather than comparing against a literal nil.
|
||||
defp apply_suspended_filter(query, true, %{from: from, to: to}) do
|
||||
Ash.Query.filter(
|
||||
query,
|
||||
expr(exists(membership_fee_cycles, ^suspended_period_predicate(from, to)))
|
||||
)
|
||||
end
|
||||
|
||||
defp apply_suspended_filter(query, _suspended, _period), do: query
|
||||
|
||||
defp suspended_period_predicate(nil, nil), do: expr(status == :suspended)
|
||||
|
||||
defp suspended_period_predicate(from, nil),
|
||||
do: expr(status == :suspended and cycle_end >= ^from)
|
||||
|
||||
defp suspended_period_predicate(nil, to),
|
||||
do: expr(status == :suspended and cycle_end <= ^to)
|
||||
|
||||
defp suspended_period_predicate(from, to),
|
||||
do: expr(status == :suspended and cycle_end >= ^from and cycle_end <= ^to)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Built-in date filters (join/exit)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -226,21 +284,41 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
|||
|
||||
defp apply_custom_date_filters(query, _filters, _custom_fields), do: query
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Point-in-time membership ("Stichtag"): a member counts as a member on the
|
||||
# reference date X iff it had joined by X and had not yet left as of X
|
||||
# (§1.24/§2.6). "Left as of X" means a non-nil exit_date at or before X, so an
|
||||
# exit_date strictly after X (or nil) still counts as a member on X.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
defp apply_stichtag_filter(query, %Date{} = x) do
|
||||
Ash.Query.filter(
|
||||
query,
|
||||
expr(join_date <= ^x and (is_nil(exit_date) or exit_date > ^x))
|
||||
)
|
||||
end
|
||||
|
||||
defp apply_stichtag_filter(query, _), do: query
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sort (always append the unique id tie-breaker for keyset stability)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
defp apply_sort(query, nil, _order, _custom_fields), do: Ash.Query.sort(query, id: :asc)
|
||||
defp apply_sort(query, _field, nil, _custom_fields), do: Ash.Query.sort(query, id: :asc)
|
||||
defp apply_sort(query, nil, _order, _custom_fields, _period),
|
||||
do: Ash.Query.sort(query, id: :asc)
|
||||
|
||||
defp apply_sort(query, _field, nil, _custom_fields, _period),
|
||||
do: Ash.Query.sort(query, id: :asc)
|
||||
|
||||
# Composite "Member" column: sort by last name then first name. The email that
|
||||
# shares the cell is display-only and does not affect the sort key (§1.21).
|
||||
defp apply_sort(query, field, order, _custom_fields) when field in [:name, "name"] do
|
||||
defp apply_sort(query, field, order, _custom_fields, _period) when field in [:name, "name"] do
|
||||
Ash.Query.sort(query, [{:last_name, order}, {:first_name, order}, {:id, :asc}])
|
||||
end
|
||||
|
||||
# Composite "Address" column: sort by city, then postal code, then street.
|
||||
defp apply_sort(query, field, order, _custom_fields) when field in [:address, "address"] do
|
||||
defp apply_sort(query, field, order, _custom_fields, _period)
|
||||
when field in [:address, "address"] do
|
||||
Ash.Query.sort(query, [
|
||||
{:city, order},
|
||||
{:postal_code, order},
|
||||
|
|
@ -249,7 +327,20 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
|||
])
|
||||
end
|
||||
|
||||
defp apply_sort(query, field, order, custom_fields) do
|
||||
# "Beiträge" (payment) column: sort DB-side by the period-scoped unpaid-cycle
|
||||
# count (§1.27). The count calculation takes the active period as arguments so
|
||||
# the sort key matches the displayed value; the id tie-breaker keeps keyset
|
||||
# pages stable across equal counts.
|
||||
defp apply_sort(query, field, order, _custom_fields, %{from: from, to: to})
|
||||
when field in [:payment, "payment"] do
|
||||
Ash.Query.sort(query, [
|
||||
{Ash.Sort.expr_sort(unpaid_cycle_count(period_from: ^from, period_to: ^to), :integer),
|
||||
order},
|
||||
{:id, :asc}
|
||||
])
|
||||
end
|
||||
|
||||
defp apply_sort(query, field, order, custom_fields, _period) do
|
||||
case sort_key(field) do
|
||||
nil -> apply_custom_field_sort(query, field, order, custom_fields)
|
||||
key -> Ash.Query.sort(query, [{key, order}, {:id, :asc}])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue