Расширена статистика для разделов #22

This commit is contained in:
2026-05-28 13:49:08 +03:00
parent 2dac5e5102
commit 1986fd4694
20 changed files with 1189 additions and 71 deletions
+89
View File
@@ -4,6 +4,9 @@
-export([count_calendars/0, list_all/0]).
-export([freeze/2, unfreeze/2]).
-export([count_calendars_by_date/2]).
-export([count_calendars_by_type/0, count_calendars_by_status/0,
get_top_calendars_by_reviews/1, get_top_calendars_by_positive_reviews/1,
get_top_calendars_by_negative_reviews/1, get_top_calendars_by_rating/1]).
%% ─────────────────────────────────────────────────────────────────
%% Значения по умолчанию для необязательных полей
@@ -174,6 +177,92 @@ count_calendars_by_date(From, To) ->
date_part({{Y,M,D}, _}) -> {Y,M,D}.
%%%-------------------------------------------------------------------
%%% @doc Подсчёт календарей по типам (personal, commercial).
%%% @end
%%%-------------------------------------------------------------------
-spec count_calendars_by_type() -> [{atom(), non_neg_integer()}].
count_calendars_by_type() ->
Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}),
lists:foldl(fun(#calendar{type = Type}, Acc) ->
case lists:keyfind(Type, 1, Acc) of
false -> [{Type, 1} | Acc];
{Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1})
end
end, [], Calendars).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт календарей по статусам (active, frozen, deleted).
%%% @end
%%%-------------------------------------------------------------------
-spec count_calendars_by_status() -> [{atom(), non_neg_integer()}].
count_calendars_by_status() ->
Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}),
lists:foldl(fun(#calendar{status = Status}, Acc) ->
case lists:keyfind(Status, 1, Acc) of
false -> [{Status, 1} | Acc];
{Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1})
end
end, [], Calendars).
%%%-------------------------------------------------------------------
%%% @doc Топ-N календарей по количеству отзывов.
%%% @end
%%%-------------------------------------------------------------------
-spec get_top_calendars_by_reviews(N :: non_neg_integer()) -> [#calendar{}].
get_top_calendars_by_reviews(N) ->
Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}),
Sorted = lists:reverse(lists:sort(
fun(A, B) -> A#calendar.rating_count =< B#calendar.rating_count end,
Calendars)),
lists:sublist(Sorted, N).
%%%-------------------------------------------------------------------
%%% @doc Топ-N календарей по количеству позитивных отзывов (rating >= 4).
%%% @end
%%%-------------------------------------------------------------------
-spec get_top_calendars_by_positive_reviews(N :: non_neg_integer()) -> [#calendar{}].
get_top_calendars_by_positive_reviews(N) ->
top_by_review_filter(N, fun(R) -> R#review.rating >= 4 end).
%%%-------------------------------------------------------------------
%%% @doc Топ-N календарей по количеству негативных отзывов (rating <= 2).
%%% @end
%%%-------------------------------------------------------------------
-spec get_top_calendars_by_negative_reviews(N :: non_neg_integer()) -> [#calendar{}].
get_top_calendars_by_negative_reviews(N) ->
top_by_review_filter(N, fun(R) -> R#review.rating =< 2 end).
%%%-------------------------------------------------------------------
%%% @doc Топ-N календарей по среднему рейтингу.
%%% @end
%%%-------------------------------------------------------------------
-spec get_top_calendars_by_rating(N :: non_neg_integer()) -> [#calendar{}].
get_top_calendars_by_rating(N) ->
Calendars = mnesia:dirty_match_object(#calendar{_ = '_'}),
Sorted = lists:reverse(lists:sort(
fun(A, B) -> A#calendar.rating_avg =< B#calendar.rating_avg end,
Calendars)),
lists:sublist(Sorted, N).
%% @private Вспомогательная функция для фильтрации отзывов и подсчёта по календарям.
top_by_review_filter(N, FilterFun) ->
Reviews = mnesia:dirty_match_object(#review{target_type = calendar, _ = '_'}),
Filtered = lists:filter(FilterFun, Reviews),
% Считаем количество отзывов для каждого календаря
Counts = lists:foldl(fun(#review{target_id = CalId}, Acc) ->
case lists:keyfind(CalId, 1, Acc) of
false -> [{CalId, 1} | Acc];
{CalId, C} -> lists:keyreplace(CalId, 1, Acc, {CalId, C+1})
end
end, [], Filtered),
% Сортируем по убыванию количества
SortedIds = [Id || {Id, _} <- lists:reverse(lists:sort(fun({_,C1},{_,C2}) -> C1 =< C2 end, Counts))],
TopIds = lists:sublist(SortedIds, N),
% Получаем объекты календарей
[Cal || Id <- TopIds,
{ok, Cal} <- [core_calendar:get_by_id(Id)]].
%%%===================================================================
%%% ВНУТРЕННИЕ ФУНКЦИИ
%%%===================================================================
+41
View File
@@ -5,6 +5,7 @@
-export([count_events/0, count_events_by_date/2]).
-export([freeze/2, unfreeze/2]).
-export([list_all/0]).
-export([count_events_by_type/0, count_events_by_status/0, get_top_events_by_rating/1]).
%% ─────────────────────────────────────────────────────────────────
%% Значения по умолчанию для необязательных полей
@@ -251,6 +252,46 @@ count_events_by_date(From, To) ->
end, [], Filtered),
lists:sort(Counts).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт событий по типам (single, recurring).
%%% @end
%%%-------------------------------------------------------------------
-spec count_events_by_type() -> [{atom(), non_neg_integer()}].
count_events_by_type() ->
Events = mnesia:dirty_match_object(#event{_ = '_'}),
lists:foldl(fun(#event{event_type = Type}, Acc) ->
case lists:keyfind(Type, 1, Acc) of
false -> [{Type, 1} | Acc];
{Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1})
end
end, [], Events).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт событий по статусам (active, cancelled, completed).
%%% @end
%%%-------------------------------------------------------------------
-spec count_events_by_status() -> [{atom(), non_neg_integer()}].
count_events_by_status() ->
Events = mnesia:dirty_match_object(#event{_ = '_'}),
lists:foldl(fun(#event{status = Status}, Acc) ->
case lists:keyfind(Status, 1, Acc) of
false -> [{Status, 1} | Acc];
{Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1})
end
end, [], Events).
%%%-------------------------------------------------------------------
%%% @doc Возвращает топ-N событий по среднему рейтингу (по убыванию).
%%% @end
%%%-------------------------------------------------------------------
-spec get_top_events_by_rating(N :: non_neg_integer()) -> [#event{}].
get_top_events_by_rating(N) ->
Events = mnesia:dirty_match_object(#event{_ = '_'}),
Sorted = lists:reverse(lists:sort(
fun(A, B) -> A#event.rating_avg =< B#event.rating_avg end,
Events)),
lists:sublist(Sorted, N).
%%%-------------------------------------------------------------------
%%% @doc Выделить из datetime только дату `{Y,M,D}`.
%%% @end
+52 -1
View File
@@ -3,7 +3,7 @@
-export([create/4, get_by_id/1, list_all/0, list_by_reporter/1, update_status/3, count_reports/0]).
-export([list_by_target/2, get_count_by_target/2]).
-export([count_reports_by_status/1, count_reports_resolved_by_admin/2, avg_resolution_time/1]).
-export([count_reports_by_date/2]).
-export([count_reports_by_date/2, get_top_targets_by_reports/1, count_reports_by_status/0, count_reports_by_target_type/0]).
%% ─────────────────────────────────────────────────────────────────
%% Значения по умолчанию для необязательных полей
@@ -178,4 +178,55 @@ count_reports_by_date(From, To) ->
end, [], Filtered),
lists:sort(Counts).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт жалоб по типам цели (event, calendar, review).
%%% @end
%%%-------------------------------------------------------------------
-spec count_reports_by_target_type() -> [{atom(), non_neg_integer()}].
count_reports_by_target_type() ->
Reports = mnesia:dirty_match_object(#report{_ = '_'}),
lists:foldl(fun(#report{target_type = Type}, Acc) ->
case lists:keyfind(Type, 1, Acc) of
false -> [{Type, 1} | Acc];
{Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1})
end
end, [], Reports).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт жалоб по статусам.
%%% @end
%%%-------------------------------------------------------------------
-spec count_reports_by_status() -> [{atom(), non_neg_integer()}].
count_reports_by_status() ->
Reports = mnesia:dirty_match_object(#report{_ = '_'}),
lists:foldl(fun(#report{status = Status}, Acc) ->
case lists:keyfind(Status, 1, Acc) of
false -> [{Status, 1} | Acc];
{Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1})
end
end, [], Reports).
%%%-------------------------------------------------------------------
%%% @doc Топ-N целей по количеству жалоб.
%%% Возвращает список [{TargetType, TargetId, Count}], отсортированный по убыванию Count.
%%% @end
%%%-------------------------------------------------------------------
-spec get_top_targets_by_reports(N :: non_neg_integer()) ->
[{TargetType :: atom(), TargetId :: binary(), Count :: non_neg_integer()}].
get_top_targets_by_reports(N) ->
Reports = mnesia:dirty_match_object(#report{_ = '_'}),
% Группируем по {TargetType, TargetId}
Dict = lists:foldl(fun(#report{target_type = Type, target_id = Id}, Acc) ->
Key = {Type, Id},
case lists:keyfind(Key, 1, Acc) of
false -> [{Key, 1} | Acc];
{Key, C} -> lists:keyreplace(Key, 1, Acc, {Key, C+1})
end
end, [], Reports),
% Сортируем по убыванию Count
Sorted = lists:reverse(lists:sort(fun({_, C1}, {_, C2}) -> C1 =< C2 end, Dict)),
% Преобразуем в тройки и берём первые N
Top = lists:sublist([{Type, Id, Count} || {{Type, Id}, Count} <- Sorted], N),
Top.
date_part({{Y,M,D}, _}) -> {Y,M,D}.
+72
View File
@@ -5,6 +5,9 @@
-export([get_average_rating/2, has_user_reviewed/3]).
-export([count_reviews/0, list_all/0]).
-export([count_reviews_by_date/2]).
-export([count_reviews_by_target_type/0, count_reviews_by_status/0,
get_top_targets_by_reviews/1, get_top_targets_by_positive_reviews/1,
get_top_targets_by_negative_reviews/1]).
%% ─────────────────────────────────────────────────────────────────
%% Значения по умолчанию для необязательных полей
@@ -204,6 +207,75 @@ count_reviews_by_date(From, To) ->
date_part({{Y,M,D}, _}) -> {Y,M,D}.
%%%-------------------------------------------------------------------
%%% @doc Подсчёт отзывов по типам цели (event, calendar).
%%% @end
%%%-------------------------------------------------------------------
-spec count_reviews_by_target_type() -> [{atom(), non_neg_integer()}].
count_reviews_by_target_type() ->
Reviews = mnesia:dirty_match_object(#review{_ = '_'}),
lists:foldl(fun(#review{target_type = Type}, Acc) ->
case lists:keyfind(Type, 1, Acc) of
false -> [{Type, 1} | Acc];
{Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1})
end
end, [], Reviews).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт отзывов по статусам (visible, hidden, deleted).
%%% @end
%%%-------------------------------------------------------------------
-spec count_reviews_by_status() -> [{atom(), non_neg_integer()}].
count_reviews_by_status() ->
Reviews = mnesia:dirty_match_object(#review{_ = '_'}),
lists:foldl(fun(#review{status = Status}, Acc) ->
case lists:keyfind(Status, 1, Acc) of
false -> [{Status, 1} | Acc];
{Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1})
end
end, [], Reviews).
%%%-------------------------------------------------------------------
%%% @doc Топ-N целей по общему количеству отзывов.
%%% @end
%%%-------------------------------------------------------------------
-spec get_top_targets_by_reviews(N :: non_neg_integer()) ->
[{TargetType :: atom(), TargetId :: binary(), Count :: non_neg_integer()}].
get_top_targets_by_reviews(N) ->
aggregate_targets_by(fun(_Review) -> true end, N).
%%%-------------------------------------------------------------------
%%% @doc Топ-N целей по количеству позитивных отзывов (rating >= 4).
%%% @end
%%%-------------------------------------------------------------------
-spec get_top_targets_by_positive_reviews(N :: non_neg_integer()) ->
[{TargetType :: atom(), TargetId :: binary(), Count :: non_neg_integer()}].
get_top_targets_by_positive_reviews(N) ->
aggregate_targets_by(fun(#review{rating = R}) -> R >= 4 end, N).
%%%-------------------------------------------------------------------
%%% @doc Топ-N целей по количеству негативных отзывов (rating <= 2).
%%% @end
%%%-------------------------------------------------------------------
-spec get_top_targets_by_negative_reviews(N :: non_neg_integer()) ->
[{TargetType :: atom(), TargetId :: binary(), Count :: non_neg_integer()}].
get_top_targets_by_negative_reviews(N) ->
aggregate_targets_by(fun(#review{rating = R}) -> R =< 2 end, N).
%% @private Вспомогательная функция агрегации целей с фильтром.
aggregate_targets_by(Pred, N) ->
Reviews = mnesia:dirty_match_object(#review{_ = '_'}),
Filtered = lists:filter(Pred, Reviews),
Dict = lists:foldl(fun(#review{target_type = Type, target_id = Id}, Acc) ->
Key = {Type, Id},
case lists:keyfind(Key, 1, Acc) of
false -> [{Key, 1} | Acc];
{Key, C} -> lists:keyreplace(Key, 1, Acc, {Key, C+1})
end
end, [], Filtered),
Sorted = lists:reverse(lists:sort(fun({_, C1}, {_, C2}) -> C1 =< C2 end, Dict)),
lists:sublist([{Type, Id, Cnt} || {{Type, Id}, Cnt} <- Sorted], N).
%%%===================================================================
%%% ВНУТРЕННИЕ ФУНКЦИИ
%%%===================================================================
+58 -4
View File
@@ -3,8 +3,10 @@
-export([create/3, get_by_id/1, get_active_by_user/1, list_by_user/1, list_all/0]).
-export([update_status/2, check_expired/0]).
-export([list_subscriptions/0, create_subscription/1, update_subscription/2, delete_subscription/1]).
-export([count_subscription/0]).
-export([count_subscriptions/0]).
-export([count_subscriptions_by_date/2]).
-export([count_subscriptions_by_plan/0, count_subscriptions_by_status/0,
count_trial_subscriptions/0, get_ending_paid_subscriptions/1]).
-define(TRIAL_DAYS, 30).
@@ -291,8 +293,8 @@ parse_iso_datetime(Other) -> Other.
%%% @doc Количество подписок.
%%% @end
%%%-------------------------------------------------------------------
-spec count_subscription() -> non_neg_integer().
count_subscription() ->
-spec count_subscriptions() -> non_neg_integer().
count_subscriptions() ->
mnesia:table_info(subscription, size).
%%%-------------------------------------------------------------------
@@ -314,4 +316,56 @@ count_subscriptions_by_date(From, To) ->
end, [], Filtered),
lists:sort(Counts).
date_part({{Y,M,D}, _}) -> {Y,M,D}.
date_part({{Y,M,D}, _}) -> {Y,M,D}.
%%%-------------------------------------------------------------------
%%% @doc Подсчёт подписок по планам.
%%% @end
%%%-------------------------------------------------------------------
-spec count_subscriptions_by_plan() -> [{atom(), non_neg_integer()}].
count_subscriptions_by_plan() ->
Subs = mnesia:dirty_match_object(#subscription{_ = '_'}),
lists:foldl(fun(#subscription{plan = Plan}, Acc) ->
case lists:keyfind(Plan, 1, Acc) of
false -> [{Plan, 1} | Acc];
{Plan, C} -> lists:keyreplace(Plan, 1, Acc, {Plan, C+1})
end
end, [], Subs).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт подписок по статусам.
%%% @end
%%%-------------------------------------------------------------------
-spec count_subscriptions_by_status() -> [{atom(), non_neg_integer()}].
count_subscriptions_by_status() ->
Subs = mnesia:dirty_match_object(#subscription{_ = '_'}),
lists:foldl(fun(#subscription{status = Status}, Acc) ->
case lists:keyfind(Status, 1, Acc) of
false -> [{Status, 1} | Acc];
{Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1})
end
end, [], Subs).
%%%-------------------------------------------------------------------
%%% @doc Количество пробных подписок (trial_used = false).
%%% @end
%%%-------------------------------------------------------------------
-spec count_trial_subscriptions() -> non_neg_integer().
count_trial_subscriptions() ->
Match = #subscription{trial_used = false, _ = '_'},
length(mnesia:dirty_match_object(Match)).
%%%-------------------------------------------------------------------
%%% @doc Платные подписки (trial_used = true), истекающие в течение Days дней.
%%% @end
%%%-------------------------------------------------------------------
-spec get_ending_paid_subscriptions(Days :: non_neg_integer()) -> [#subscription{}].
get_ending_paid_subscriptions(Days) ->
NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
ThresholdSec = NowSec + Days * 86400,
Match = #subscription{status = active, trial_used = true, _ = '_'},
ActivePaid = mnesia:dirty_match_object(Match),
lists:filter(fun(#subscription{expires_at = Exp}) ->
ExpSec = calendar:datetime_to_gregorian_seconds(Exp),
ExpSec >= NowSec andalso ExpSec =< ThresholdSec
end, ActivePaid).
+26 -1
View File
@@ -5,7 +5,7 @@
-export([count_tickets/0]).
-export([create_ticket/1]).
-export([list_by_user/1]).
-export([count_tickets_by_status/1, avg_resolution_time/0, count_tickets_by_admin/2]).
-export([count_tickets_by_status/1, avg_resolution_time/0, count_tickets_by_admin/2, stats/0]).
-export([update_ticket/2]).
-export([delete_ticket/1]).
-export([count_tickets_by_date/2]).
@@ -236,6 +236,23 @@ avg_resolution_time() ->
Total / length(Closed) / 3600.0
end.
%% @doc Статистика по тикетам (используется в admin_handler_ticket_stats)
stats() ->
Tickets = list_all(),
#{
total => length(Tickets),
open => count_by_status(open, Tickets),
in_progress => count_by_status(in_progress, Tickets),
resolved => count_by_status(resolved, Tickets),
closed => count_by_status(closed, Tickets)
}.
%% ── функции подсчёта с нормализацией статуса ─────────────
%% @private Подсчитывает тикеты с заданным статусом (атом или бинарный)
count_by_status(Status, Tickets) ->
length([T || T <- Tickets, normalize_status(T#ticket.status) =:= Status]).
%%%-------------------------------------------------------------------
%%% @doc Количество тикетов, назначенных на администратора.
%%% @end
@@ -274,6 +291,14 @@ date_part({{Y,M,D}, _}) -> {Y,M,D}.
%%% ВНУТРЕННИЕ ФУНКЦИИ
%%%===================================================================
%% @private Преобразует бинарный статус в атом, если нужно.
%% Атомы возвращает без изменений.
normalize_status(Status) when is_atom(Status) -> Status;
normalize_status(Status) when is_binary(Status) ->
try binary_to_existing_atom(Status, utf8)
catch error:badarg -> Status
end.
apply_updates(Ticket, Updates) ->
Updated = lists:foldl(fun({Field, Value}, T) -> set_field(Field, Value, T) end,
Ticket, Updates),
+33
View File
@@ -6,6 +6,7 @@
-export([list_users/0]).
-export([block/2, unblock/2]).
-export([count_users/0, count_users_by_date/2, count_pending_users/0, count_pending_users_by_date/2, list_all/0]).
-export([count_users_by_role/0, count_users_by_status/0]).
-export([create_bot/2, delete_bot/1]).
%% ─────────────────────────────────────────────────────────────────
@@ -306,6 +307,38 @@ count_pending_users_by_date(From, To) ->
end, [], Filtered),
lists:sort(Counts).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт пользователей по ролям.
%%% Возвращает список [{Role :: atom(), Count :: non_neg_integer()}].
%%% @end
%%%-------------------------------------------------------------------
-spec count_users_by_role() -> [{atom(), non_neg_integer()}].
count_users_by_role() ->
Users = mnesia:dirty_match_object(#user{_ = '_'}),
Dict = lists:foldl(fun(#user{role = Role}, Acc) ->
case lists:keyfind(Role, 1, Acc) of
false -> [{Role, 1} | Acc];
{Role, C} -> lists:keyreplace(Role, 1, Acc, {Role, C+1})
end
end, [], Users),
Dict.
%%%-------------------------------------------------------------------
%%% @doc Подсчёт пользователей по статусам.
%%% Возвращает список [{Status :: atom(), Count :: non_neg_integer()}].
%%% @end
%%%-------------------------------------------------------------------
-spec count_users_by_status() -> [{atom(), non_neg_integer()}].
count_users_by_status() ->
Users = mnesia:dirty_match_object(#user{_ = '_'}),
Dict = lists:foldl(fun(#user{status = Status}, Acc) ->
case lists:keyfind(Status, 1, Acc) of
false -> [{Status, 1} | Acc];
{Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1})
end
end, [], Users),
Dict.
%%%-------------------------------------------------------------------
%%% @doc Выделить из datetime только дату `{Y,M,D}`.
%%% @end