feat: upsert stats counters, daily buckets and ETS tops for admin metrics. Refs EventHub/EventHubBack#42 #43 #44 #45 #46
CI / test (push) Failing after 25m40s
CI / deploy-ift (push) Has been skipped
CI / e2e-ift (push) Has been skipped
CI / deploy-stage (push) Has been skipped
CI / e2e-stage (push) Has been skipped

This commit is contained in:
2026-07-17 23:10:00 +03:00
parent f2445db66c
commit dd754e28cc
20 changed files with 1885 additions and 408 deletions
+57 -41
View File
@@ -163,17 +163,19 @@ unfreeze(Id, Reason) ->
-spec count_calendars_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
count_calendars_by_date(From, To) ->
All = mnesia:dirty_match_object(#calendar{_ = '_'}),
Filtered = lists:filter(fun(C) -> C#calendar.created_at >= From andalso
C#calendar.created_at =< To end, All),
Counts = lists:foldl(fun(C, Acc) ->
Day = date_part(C#calendar.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, Cnt} -> lists:keyreplace(Day, 1, Acc, {Day, Cnt+1})
end
end, [], Filtered),
lists:sort(Counts).
core_stats:with_daily(calendars_created, From, To, fun() ->
All = mnesia:dirty_match_object(#calendar{_ = '_'}),
Filtered = lists:filter(fun(C) -> C#calendar.created_at >= From andalso
C#calendar.created_at =< To end, All),
Counts = lists:foldl(fun(C, Acc) ->
Day = date_part(C#calendar.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, Cnt} -> lists:keyreplace(Day, 1, Acc, {Day, Cnt+1})
end
end, [], Filtered),
lists:sort(Counts)
end).
date_part({{Y,M,D}, _}) -> {Y,M,D}.
@@ -183,13 +185,15 @@ date_part({{Y,M,D}, _}) -> {Y,M,D}.
%%%-------------------------------------------------------------------
-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).
core_stats:with_dim(calendar, type, fun() ->
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)
end).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт календарей по статусам (active, frozen, deleted).
@@ -197,13 +201,15 @@ count_calendars_by_type() ->
%%%-------------------------------------------------------------------
-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).
core_stats:with_dim(calendar, status, fun() ->
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)
end).
%%%-------------------------------------------------------------------
%%% @doc Топ-N календарей по количеству отзывов.
@@ -211,11 +217,15 @@ count_calendars_by_status() ->
%%%-------------------------------------------------------------------
-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).
case stats_tops:get_top_calendars_by_reviews(N) of
{ok, Calendars} -> Calendars;
{error, _} ->
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)
end.
%%%-------------------------------------------------------------------
%%% @doc Топ-N календарей по количеству позитивных отзывов (rating >= 4).
@@ -223,15 +233,17 @@ get_top_calendars_by_reviews(N) ->
%%%-------------------------------------------------------------------
-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).
case stats_tops:get_top_calendars_by_positive_reviews(N) of
{ok, Calendars} -> Calendars;
{error, _} -> top_by_review_filter(N, fun(R) -> R#review.rating >= 4 end)
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).
case stats_tops:get_top_calendars_by_negative_reviews(N) of
{ok, Calendars} -> Calendars;
{error, _} -> top_by_review_filter(N, fun(R) -> R#review.rating =< 2 end)
end.
%%%-------------------------------------------------------------------
%%% @doc Топ-N календарей по среднему рейтингу.
@@ -239,11 +251,15 @@ get_top_calendars_by_negative_reviews(N) ->
%%%-------------------------------------------------------------------
-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).
case stats_tops:get_top_calendars_by_rating(N) of
{ok, Calendars} -> Calendars;
{error, _} ->
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)
end.
%% @private Вспомогательная функция для фильтрации отзывов и подсчёта по календарям.
top_by_review_filter(N, FilterFun) ->
+40 -30
View File
@@ -240,17 +240,19 @@ list_all() ->
-spec count_events_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
count_events_by_date(From, To) ->
All = mnesia:dirty_match_object(#event{_ = '_'}),
Filtered = lists:filter(fun(E) -> E#event.created_at >= From andalso
E#event.created_at =< To end, All),
Counts = lists:foldl(fun(E, Acc) ->
Day = date_part(E#event.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts).
core_stats:with_daily(events_created, From, To, fun() ->
All = mnesia:dirty_match_object(#event{_ = '_'}),
Filtered = lists:filter(fun(E) -> E#event.created_at >= From andalso
E#event.created_at =< To end, All),
Counts = lists:foldl(fun(E, Acc) ->
Day = date_part(E#event.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts)
end).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт событий по типам (single, recurring).
@@ -258,13 +260,15 @@ count_events_by_date(From, To) ->
%%%-------------------------------------------------------------------
-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).
core_stats:with_dim(event, type, fun() ->
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)
end).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт событий по статусам (active, cancelled, completed).
@@ -272,13 +276,15 @@ count_events_by_type() ->
%%%-------------------------------------------------------------------
-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).
core_stats:with_dim(event, status, fun() ->
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)
end).
%%%-------------------------------------------------------------------
%%% @doc Возвращает топ-N событий по среднему рейтингу (по убыванию).
@@ -286,11 +292,15 @@ count_events_by_status() ->
%%%-------------------------------------------------------------------
-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).
case stats_tops:get_top_events_by_rating(N) of
{ok, Events} -> Events;
{error, _} ->
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)
end.
%%%-------------------------------------------------------------------
%%% @doc Выделить из datetime только дату `{Y,M,D}`.
+77 -54
View File
@@ -129,8 +129,10 @@ count_reports() ->
-spec count_reports_by_status(Status :: pending | reviewed | dismissed) ->
non_neg_integer().
count_reports_by_status(Status) ->
Match = #report{status = Status, _ = '_'},
length(mnesia:dirty_match_object(Match)).
core_stats:with_counter({report_by_status, Status}, fun() ->
Match = #report{status = Status, _ = '_'},
length(mnesia:dirty_match_object(Match))
end).
%%%-------------------------------------------------------------------
%%% @doc Количество жалоб, разрешённых конкретным администратором.
@@ -139,8 +141,10 @@ count_reports_by_status(Status) ->
-spec count_reports_resolved_by_admin(AdminId :: binary(), Status :: reviewed | dismissed) ->
non_neg_integer().
count_reports_resolved_by_admin(AdminId, Status) ->
Match = #report{resolved_by = AdminId, status = Status, _ = '_'},
length(mnesia:dirty_match_object(Match)).
Rows = index_read(report, AdminId, #report.resolved_by, fun() ->
mnesia:dirty_match_object(#report{resolved_by = AdminId, _ = '_'})
end),
length([R || R <- Rows, R#report.status =:= Status]).
%%%-------------------------------------------------------------------
%%% @doc Среднее время разрешения жалоб заданного статуса (в часах).
@@ -148,16 +152,22 @@ count_reports_resolved_by_admin(AdminId, Status) ->
%%%-------------------------------------------------------------------
-spec avg_resolution_time(Status :: reviewed | dismissed) -> float().
avg_resolution_time(Status) ->
Match = #report{status = Status, _ = '_'},
Reports = mnesia:dirty_match_object(Match),
case Reports of
[] -> 0.0;
_ ->
Total = lists:sum([calendar:datetime_to_gregorian_seconds(R#report.resolved_at) -
calendar:datetime_to_gregorian_seconds(R#report.created_at)
|| R <- Reports, R#report.resolved_at /= undefined]),
Total / length(Reports) / 3600.0
end.
core_stats:with_avg_hours(
{report_resolved_count, Status},
{report_resolved_seconds_sum, Status},
fun() ->
Match = #report{status = Status, _ = '_'},
Reports = mnesia:dirty_match_object(Match),
Resolved = [R || R <- Reports, R#report.resolved_at /= undefined],
case Resolved of
[] -> 0.0;
_ ->
Total = lists:sum([calendar:datetime_to_gregorian_seconds(R#report.resolved_at) -
calendar:datetime_to_gregorian_seconds(R#report.created_at)
|| R <- Resolved]),
Total / length(Resolved) / 3600.0
end
end).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт жалоб, созданных в заданном временном диапазоне.
@@ -166,17 +176,19 @@ avg_resolution_time(Status) ->
-spec count_reports_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
count_reports_by_date(From, To) ->
All = mnesia:dirty_match_object(#report{_ = '_'}),
Filtered = lists:filter(fun(R) -> R#report.created_at >= From andalso
R#report.created_at =< To end, All),
Counts = lists:foldl(fun(R, Acc) ->
Day = date_part(R#report.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts).
core_stats:with_daily(reports_created, From, To, fun() ->
All = mnesia:dirty_match_object(#report{_ = '_'}),
Filtered = lists:filter(fun(R) -> R#report.created_at >= From andalso
R#report.created_at =< To end, All),
Counts = lists:foldl(fun(R, Acc) ->
Day = date_part(R#report.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts)
end).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт жалоб по типам цели (event, calendar, review).
@@ -184,13 +196,15 @@ count_reports_by_date(From, To) ->
%%%-------------------------------------------------------------------
-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).
core_stats:with_dim(report, target_type, fun() ->
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)
end).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт жалоб по статусам.
@@ -198,13 +212,15 @@ count_reports_by_target_type() ->
%%%-------------------------------------------------------------------
-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).
core_stats:with_dim(report, status, fun() ->
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)
end).
%%%-------------------------------------------------------------------
%%% @doc Топ-N целей по количеству жалоб.
@@ -214,19 +230,26 @@ count_reports_by_status() ->
-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.
case stats_tops:get_top_targets_by_reports(N) of
{ok, List} -> List;
{error, _} ->
Reports = mnesia:dirty_match_object(#report{_ = '_'}),
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),
Sorted = lists:reverse(lists:sort(fun({_, C1}, {_, C2}) -> C1 =< C2 end, Dict)),
lists:sublist([{Type, Id, Count} || {{Type, Id}, Count} <- Sorted], N)
end.
date_part({{Y,M,D}, _}) -> {Y,M,D}.
date_part({{Y,M,D}, _}) -> {Y,M,D}.
%% @private Index read with fallback to full match (tests without index).
index_read(Table, Key, Pos, FallbackFun) ->
case catch mnesia:dirty_index_read(Table, Key, Pos) of
{'EXIT', _} -> FallbackFun();
Rows when is_list(Rows) -> Rows
end.
+43 -36
View File
@@ -193,17 +193,19 @@ list_all() ->
-spec count_reviews_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
count_reviews_by_date(From, To) ->
All = mnesia:dirty_match_object(#review{_ = '_'}),
Filtered = lists:filter(fun(R) -> R#review.created_at >= From andalso
R#review.created_at =< To end, All),
Counts = lists:foldl(fun(R, Acc) ->
Day = date_part(R#review.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts).
core_stats:with_daily(reviews_created, From, To, fun() ->
All = mnesia:dirty_match_object(#review{_ = '_'}),
Filtered = lists:filter(fun(R) -> R#review.created_at >= From andalso
R#review.created_at =< To end, All),
Counts = lists:foldl(fun(R, Acc) ->
Day = date_part(R#review.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts)
end).
date_part({{Y,M,D}, _}) -> {Y,M,D}.
@@ -213,13 +215,15 @@ date_part({{Y,M,D}, _}) -> {Y,M,D}.
%%%-------------------------------------------------------------------
-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).
core_stats:with_dim(review, target_type, fun() ->
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)
end).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт отзывов по статусам (visible, hidden, deleted).
@@ -227,13 +231,15 @@ count_reviews_by_target_type() ->
%%%-------------------------------------------------------------------
-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).
core_stats:with_dim(review, status, fun() ->
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)
end).
%%%-------------------------------------------------------------------
%%% @doc Топ-N целей по общему количеству отзывов.
@@ -242,25 +248,26 @@ count_reviews_by_status() ->
-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).
case stats_tops:get_top_targets_by_reviews(N) of
{ok, List} -> List;
{error, _} -> aggregate_targets_by(fun(_Review) -> true end, N)
end.
%%%-------------------------------------------------------------------
%%% @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).
case stats_tops:get_top_targets_by_positive_reviews(N) of
{ok, List} -> List;
{error, _} -> aggregate_targets_by(fun(#review{rating = R}) -> R >= 4 end, N)
end.
%%%-------------------------------------------------------------------
%%% @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).
case stats_tops:get_top_targets_by_negative_reviews(N) of
{ok, List} -> List;
{error, _} -> aggregate_targets_by(fun(#review{rating = R}) -> R =< 2 end, N)
end.
%% @private Вспомогательная функция агрегации целей с фильтром.
aggregate_targets_by(Pred, N) ->
+108
View File
@@ -0,0 +1,108 @@
%%%-------------------------------------------------------------------
%%% @doc Чтение upsert-агрегатов admin-статистики.
%%% Live-значения в ETS коллектора; Mnesia — persistence.
%%% Если collector не запущен — {error, not_running} (caller может fallback-скан).
%%% @end
%%%-------------------------------------------------------------------
-module(core_stats).
-include("records.hrl").
-export([
available/0,
get_counter/1,
get_dim/2,
get_daily/3,
rebuild/0,
with_counter/2,
with_dim/3,
with_daily/4,
with_avg_hours/3
]).
%%%===================================================================
%%% API
%%%===================================================================
-spec available() -> boolean().
available() ->
case whereis(stats_collector) of
undefined -> false;
_Pid -> stats_collector:is_ready()
end.
-spec get_counter(term()) -> {ok, integer()} | {error, not_running | not_ready}.
get_counter(Key) ->
call_collector({get_counter, Key}).
%% @doc Размерность: get_dim(user, status) → [{pending, N}, {active, M}, ...].
-spec get_dim(atom(), atom()) -> {ok, [{atom(), integer()}]} | {error, not_running | not_ready}.
get_dim(Entity, Field) ->
call_collector({get_dim, Entity, Field}).
%% @doc Дневной ряд Metric за [From, To] (по дате, включительно).
-spec get_daily(atom(), calendar:datetime(), calendar:datetime()) ->
{ok, [{calendar:date(), non_neg_integer()}]} | {error, not_running | not_ready}.
get_daily(Metric, From, To) ->
call_collector({get_daily, Metric, From, To}).
-spec rebuild() -> ok | {error, not_running}.
rebuild() ->
case whereis(stats_collector) of
undefined -> {error, not_running};
_Pid -> stats_collector:rebuild()
end.
%% @doc Счётчик или FallbackFun/0 при недоступном коллекторе.
-spec with_counter(term(), fun(() -> integer())) -> integer().
with_counter(Key, FallbackFun) ->
case get_counter(Key) of
{ok, V} -> V;
{error, _} -> FallbackFun()
end.
-spec with_dim(atom(), atom(), fun(() -> [{atom(), integer()}])) -> [{atom(), integer()}].
with_dim(Entity, Field, FallbackFun) ->
case get_dim(Entity, Field) of
{ok, List} -> List;
{error, _} -> FallbackFun()
end.
-spec with_daily(atom(), calendar:datetime(), calendar:datetime(),
fun(() -> [{calendar:date(), non_neg_integer()}])) ->
[{calendar:date(), non_neg_integer()}].
with_daily(Metric, From, To, FallbackFun) ->
case get_daily(Metric, From, To) of
{ok, List} -> List;
{error, _} -> FallbackFun()
end.
%% @doc Avg hours from count+seconds_sum counters, or FallbackFun/0.
-spec with_avg_hours(term(), term(), fun(() -> float())) -> float().
with_avg_hours(CountKey, SumKey, FallbackFun) ->
case {get_counter(CountKey), get_counter(SumKey)} of
{{ok, Count}, {ok, Sum}} when Count > 0 ->
(Sum / Count) / 3600.0;
{{ok, _}, {ok, _}} ->
0.0;
_ ->
FallbackFun()
end.
%%%===================================================================
%%% Internal
%%%===================================================================
call_collector(Msg) ->
case whereis(stats_collector) of
undefined ->
{error, not_running};
_Pid ->
try stats_collector:call(Msg) of
{error, not_ready} -> {error, not_ready};
Other -> Other
catch
exit:{noproc, _} -> {error, not_running};
exit:{timeout, _} -> {error, not_running}
end
end.
+35 -27
View File
@@ -310,17 +310,19 @@ count_subscriptions() ->
-spec count_subscriptions_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
count_subscriptions_by_date(From, To) ->
All = mnesia:dirty_match_object(#subscription{_ = '_'}),
Filtered = lists:filter(fun(S) -> S#subscription.created_at >= From andalso
S#subscription.created_at =< To end, All),
Counts = lists:foldl(fun(S, Acc) ->
Day = date_part(S#subscription.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts).
core_stats:with_daily(subscriptions_created, From, To, fun() ->
All = mnesia:dirty_match_object(#subscription{_ = '_'}),
Filtered = lists:filter(fun(S) -> S#subscription.created_at >= From andalso
S#subscription.created_at =< To end, All),
Counts = lists:foldl(fun(S, Acc) ->
Day = date_part(S#subscription.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts)
end).
date_part({{Y,M,D}, _}) -> {Y,M,D}.
@@ -330,13 +332,15 @@ date_part({{Y,M,D}, _}) -> {Y,M,D}.
%%%-------------------------------------------------------------------
-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).
core_stats:with_dim(subscription, plan, fun() ->
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)
end).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт подписок по статусам.
@@ -344,13 +348,15 @@ count_subscriptions_by_plan() ->
%%%-------------------------------------------------------------------
-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).
core_stats:with_dim(subscription, status, fun() ->
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)
end).
%%%-------------------------------------------------------------------
%%% @doc Количество пробных подписок (trial_used = false).
@@ -358,8 +364,10 @@ count_subscriptions_by_status() ->
%%%-------------------------------------------------------------------
-spec count_trial_subscriptions() -> non_neg_integer().
count_trial_subscriptions() ->
Match = #subscription{trial_used = false, _ = '_'},
length(mnesia:dirty_match_object(Match)).
core_stats:with_counter(trial_subscriptions, fun() ->
Match = #subscription{trial_used = false, _ = '_'},
length(mnesia:dirty_match_object(Match))
end).
%%%-------------------------------------------------------------------
%%% @doc Платные подписки (trial_used = true), истекающие в течение Days дней.
+48 -47
View File
@@ -218,8 +218,10 @@ count_tickets() ->
-spec count_tickets_by_status(Status :: open | in_progress | resolved | closed) ->
non_neg_integer().
count_tickets_by_status(Status) ->
Match = #ticket{status = Status, _ = '_'},
length(mnesia:dirty_match_object(Match)).
core_stats:with_counter({ticket_by_status, Status}, fun() ->
Match = #ticket{status = Status, _ = '_'},
length(mnesia:dirty_match_object(Match))
end).
%%%-------------------------------------------------------------------
%%% @doc Среднее время разрешения тикетов (в часах).
@@ -227,34 +229,29 @@ count_tickets_by_status(Status) ->
%%%-------------------------------------------------------------------
-spec avg_resolution_time() -> float().
avg_resolution_time() ->
Tickets = list_all(),
Closed = [T || T <- Tickets, T#ticket.closed_at /= ?DEFAULT_CLOSED_AT],
case Closed of
[] -> 0.0;
_ ->
Total = lists:sum([calendar:datetime_to_gregorian_seconds(T#ticket.closed_at) -
calendar:datetime_to_gregorian_seconds(T#ticket.first_seen)
|| T <- Closed]),
Total / length(Closed) / 3600.0
end.
core_stats:with_avg_hours(ticket_resolved_count, ticket_resolved_seconds_sum, fun() ->
Tickets = list_all(),
Closed = [T || T <- Tickets, T#ticket.closed_at /= ?DEFAULT_CLOSED_AT],
case Closed of
[] -> 0.0;
_ ->
Total = lists:sum([calendar:datetime_to_gregorian_seconds(T#ticket.closed_at) -
calendar:datetime_to_gregorian_seconds(T#ticket.first_seen)
|| T <- Closed]),
Total / length(Closed) / 3600.0
end
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)
total => count_tickets(),
open => count_tickets_by_status(open),
in_progress => count_tickets_by_status(in_progress),
resolved => count_tickets_by_status(resolved),
closed => count_tickets_by_status(closed)
}.
%% ── функции подсчёта с нормализацией статуса ─────────────
%% @private Подсчитывает тикеты с заданным статусом (атом или бинарный)
count_by_status(Status, Tickets) ->
length([T || T <- Tickets, normalize_status(T#ticket.status) =:= Status]).
%%%-------------------------------------------------------------------
%%% @doc Количество тикетов, назначенных на администратора.
%%% @end
@@ -262,11 +259,14 @@ count_by_status(Status, Tickets) ->
-spec count_tickets_by_admin(AdminId :: binary(), StatusOrAll :: atom()) ->
non_neg_integer().
count_tickets_by_admin(AdminId, all) ->
Match = #ticket{assigned_to = AdminId, _ = '_'},
length(mnesia:dirty_match_object(Match));
length(index_read(ticket, AdminId, #ticket.assigned_to, fun() ->
mnesia:dirty_match_object(#ticket{assigned_to = AdminId, _ = '_'})
end));
count_tickets_by_admin(AdminId, Status) ->
Match = #ticket{assigned_to = AdminId, status = Status, _ = '_'},
length(mnesia:dirty_match_object(Match)).
Rows = index_read(ticket, AdminId, #ticket.assigned_to, fun() ->
mnesia:dirty_match_object(#ticket{assigned_to = AdminId, _ = '_'})
end),
length([T || T <- Rows, T#ticket.status =:= Status]).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт тикетов, созданных в заданном временном диапазоне.
@@ -275,32 +275,33 @@ count_tickets_by_admin(AdminId, Status) ->
-spec count_tickets_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
count_tickets_by_date(From, To) ->
All = mnesia:dirty_match_object(#ticket{_ = '_'}),
Filtered = lists:filter(fun(T) -> T#ticket.first_seen >= From andalso
T#ticket.first_seen =< To end, All),
Counts = lists:foldl(fun(T, Acc) ->
Day = date_part(T#ticket.first_seen),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts).
core_stats:with_daily(tickets_created, From, To, fun() ->
All = mnesia:dirty_match_object(#ticket{_ = '_'}),
Filtered = lists:filter(fun(T) -> T#ticket.first_seen >= From andalso
T#ticket.first_seen =< To end, All),
Counts = lists:foldl(fun(T, Acc) ->
Day = date_part(T#ticket.first_seen),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts)
end).
date_part({{Y,M,D}, _}) -> {Y,M,D}.
%% @private Index read with fallback to full match (tests without index).
index_read(Table, Key, Pos, FallbackFun) ->
case catch mnesia:dirty_index_read(Table, Key, Pos) of
{'EXIT', _} -> FallbackFun();
Rows when is_list(Rows) -> Rows
end.
%%%===================================================================
%%% ВНУТРЕННИЕ ФУНКЦИИ
%%%===================================================================
%% @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),
+49 -41
View File
@@ -266,17 +266,19 @@ list_all() ->
-spec count_users_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
count_users_by_date(From, To) ->
All = mnesia:dirty_match_object(#user{_ = '_'}),
Filtered = lists:filter(fun(U) -> U#user.created_at >= From andalso
U#user.created_at =< To end, All),
Counts = lists:foldl(fun(U, Acc) ->
Day = date_part(U#user.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts).
core_stats:with_daily(users_created, From, To, fun() ->
All = mnesia:dirty_match_object(#user{_ = '_'}),
Filtered = lists:filter(fun(U) -> U#user.created_at >= From andalso
U#user.created_at =< To end, All),
Counts = lists:foldl(fun(U, Acc) ->
Day = date_part(U#user.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts)
end).
%%%-------------------------------------------------------------------
%%% @doc Количество неподтверждённых пользователей (status = pending).
@@ -284,8 +286,10 @@ count_users_by_date(From, To) ->
%%%-------------------------------------------------------------------
-spec count_pending_users() -> non_neg_integer().
count_pending_users() ->
Match = #user{status = pending, _ = '_'},
length(mnesia:dirty_match_object(Match)).
core_stats:with_counter({user_by_status, pending}, fun() ->
Match = #user{status = pending, _ = '_'},
length(mnesia:dirty_match_object(Match))
end).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт неподтверждённых пользователей (status=pending) по дням.
@@ -294,18 +298,20 @@ count_pending_users() ->
-spec count_pending_users_by_date(From :: calendar:datetime(), To :: calendar:datetime()) ->
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
count_pending_users_by_date(From, To) ->
All = mnesia:dirty_match_object(#user{_ = '_'}),
Pending = lists:filter(fun(U) -> U#user.status =:= pending end, All),
Filtered = lists:filter(fun(U) -> U#user.created_at >= From andalso
U#user.created_at =< To end, Pending),
Counts = lists:foldl(fun(U, Acc) ->
Day = date_part(U#user.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts).
core_stats:with_daily(pending_users_created, From, To, fun() ->
All = mnesia:dirty_match_object(#user{_ = '_'}),
Pending = lists:filter(fun(U) -> U#user.status =:= pending end, All),
Filtered = lists:filter(fun(U) -> U#user.created_at >= From andalso
U#user.created_at =< To end, Pending),
Counts = lists:foldl(fun(U, Acc) ->
Day = date_part(U#user.created_at),
case lists:keyfind(Day, 1, Acc) of
false -> [{Day, 1} | Acc];
{Day, C} -> lists:keyreplace(Day, 1, Acc, {Day, C+1})
end
end, [], Filtered),
lists:sort(Counts)
end).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт пользователей по ролям.
@@ -314,14 +320,15 @@ count_pending_users_by_date(From, To) ->
%%%-------------------------------------------------------------------
-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.
core_stats:with_dim(user, role, fun() ->
Users = mnesia:dirty_match_object(#user{_ = '_'}),
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)
end).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт пользователей по статусам.
@@ -330,14 +337,15 @@ count_users_by_role() ->
%%%-------------------------------------------------------------------
-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.
core_stats:with_dim(user, status, fun() ->
Users = mnesia:dirty_match_object(#user{_ = '_'}),
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)
end).
%%%-------------------------------------------------------------------
%%% @doc Выделить из datetime только дату `{Y,M,D}`.