fix ticket stats

This commit is contained in:
2026-05-19 22:16:26 +03:00
parent d040256447
commit faee11ce29
4 changed files with 82 additions and 26 deletions

View File

@@ -196,7 +196,8 @@
last_seen :: calendar:datetime(), last_seen :: calendar:datetime(),
status :: open | in_progress | resolved | closed, status :: open | in_progress | resolved | closed,
assigned_to :: binary(), assigned_to :: binary(),
resolution_note :: binary() resolution_note :: binary(),
closed_at :: calendar:datetime() | undefined
}). }).
%% ------------------- Подписки ---------------------------------------- %% ------------------- Подписки ----------------------------------------

View File

@@ -30,12 +30,13 @@ update_ticket(Id, Updates) ->
delete_ticket(Id) -> delete_ticket(Id) ->
case get_by_id(Id) of case get_by_id(Id) of
{ok, _Ticket} -> % переменная не используется {ok, _Ticket} ->
mnesia:dirty_delete({ticket, Id}), mnesia:dirty_delete({ticket, Id}),
{ok, deleted}; {ok, deleted};
Error -> Error Error -> Error
end. end.
%% @doc Статистика по тикетам (используется в admin_handler_ticket_stats)
stats() -> stats() ->
Tickets = list_all(), Tickets = list_all(),
#{ #{
@@ -50,6 +51,8 @@ stats() ->
create_ticket(Data) -> create_ticket(Data) ->
Id = infra_utils:generate_id(9), Id = infra_utils:generate_id(9),
Now = calendar:universal_time(), Now = calendar:universal_time(),
Status0 = maps:get(<<"status">>, Data, open), %% <-- ИСПРАВЛЕНО: извлекаем сырое значение
Status = normalize_status(Status0), %% <-- ИСПРАВЛЕНО: приводим к атому
Ticket = #ticket{ Ticket = #ticket{
id = Id, id = Id,
reporter_id = maps:get(<<"reporter_id">>, Data, undefined), reporter_id = maps:get(<<"reporter_id">>, Data, undefined),
@@ -60,9 +63,10 @@ create_ticket(Data) ->
count = 1, count = 1,
first_seen = Now, first_seen = Now,
last_seen = Now, last_seen = Now,
status = maps:get(<<"status">>, Data, open), status = Status, %% <-- ИСПРАВЛЕНО: сохраняем атом
assigned_to = maps:get(<<"assigned_to">>, Data, undefined), assigned_to = maps:get(<<"assigned_to">>, Data, undefined),
resolution_note = maps:get(<<"resolution_note">>, Data, undefined) resolution_note = maps:get(<<"resolution_note">>, Data, undefined),
closed_at = undefined
}, },
mnesia:dirty_write(Ticket), mnesia:dirty_write(Ticket),
{ok, Ticket}. {ok, Ticket}.
@@ -70,32 +74,52 @@ create_ticket(Data) ->
list_by_user(UserId) -> list_by_user(UserId) ->
mnesia:dirty_match_object(#ticket{reporter_id = UserId, _ = '_'}). mnesia:dirty_match_object(#ticket{reporter_id = UserId, _ = '_'}).
%% ── функции подсчёта с нормализацией статуса ─────────────
%% @private Подсчитывает тикеты с заданным статусом (атом или бинарный)
count_by_status(Status, Tickets) -> count_by_status(Status, Tickets) ->
length([T || T <- Tickets, T#ticket.status =:= Status]). length([T || T <- Tickets, normalize_status(T#ticket.status) =:= Status]).
%% @doc Количество тикетов по статусу (атом или бинарный)
count_tickets_by_status(Status) -> count_tickets_by_status(Status) ->
Match = #ticket{status = Status, _ = '_'}, Tickets = list_all(),
length(mnesia:dirty_match_object(Match)). count_by_status(Status, Tickets).
%% @doc Количество тикетов, назначенных администратору, с заданным статусом
count_tickets_by_admin(AdminId, Status) -> count_tickets_by_admin(AdminId, Status) ->
Match = #ticket{assigned_to = AdminId, status = Status, _ = '_'}, Tickets = list_all(),
length(mnesia:dirty_match_object(Match)). length([T || T <- Tickets,
T#ticket.assigned_to =:= AdminId andalso
normalize_status(T#ticket.status) =:= Status]).
avg_resolution_time() -> avg_resolution_time() ->
Tickets = mnesia:dirty_match_object(#ticket{status = closed, _ = '_'}), % Загружаем все тикеты (или можно только закрытые, если их мало решите по нагрузке)
case Tickets of Tickets = mnesia:dirty_match_object(#ticket{_ = '_'}),
% Фильтруем закрытые с учётом нормализации статуса
ClosedTickets = [T || T <- Tickets,
normalize_status(T#ticket.status) =:= closed,
T#ticket.closed_at =/= undefined],
case ClosedTickets of
[] -> 0; [] -> 0;
_ -> _ ->
TotalSeconds = lists:sum([calendar:datetime_to_gregorian_seconds(T#ticket.last_seen) - TotalSeconds = lists:sum([
calendar:datetime_to_gregorian_seconds(T#ticket.first_seen) || T <- Tickets]), calendar:datetime_to_gregorian_seconds(T#ticket.closed_at) -
TotalSeconds / length(Tickets) / 3600.0 calendar:datetime_to_gregorian_seconds(T#ticket.first_seen)
|| T <- ClosedTickets]),
TotalSeconds / length(ClosedTickets) / 3600.0
end. end.
%% ── внутренние ───────────────────────────────────────── %% ── внутренние ─────────────────────────────────────────
apply_updates(Ticket, Updates) -> apply_updates(Ticket, Updates) ->
lists:foldl(fun({Key, Value}, Acc) -> lists:foldl(fun({Key, Value}, Acc) ->
case Key of case Key of
<<"status">> -> Acc#ticket{status = binary_to_atom(Value, utf8)}; <<"status">> ->
NewStatus = normalize_status(Value),
Acc1 = Acc#ticket{status = NewStatus},
case NewStatus of
closed -> Acc1#ticket{closed_at = calendar:universal_time()};
_ -> Acc1
end;
<<"assigned_to">> -> Acc#ticket{assigned_to = Value}; <<"assigned_to">> -> Acc#ticket{assigned_to = Value};
<<"resolution_note">> -> Acc#ticket{resolution_note = Value}; <<"resolution_note">> -> Acc#ticket{resolution_note = Value};
<<"error_message">> -> Acc#ticket{error_message = Value}; <<"error_message">> -> Acc#ticket{error_message = Value};
@@ -104,3 +128,11 @@ apply_updates(Ticket, Updates) ->
_ -> Acc _ -> Acc
end end
end, Ticket, maps:to_list(Updates)). end, Ticket, maps:to_list(Updates)).
%% @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.

View File

@@ -105,18 +105,24 @@ parse_ticket_filters(Req) ->
q => proplists:get_value(<<"q">>, Qs) q => proplists:get_value(<<"q">>, Qs)
}. }.
%% @private Дополнительная фильтрация (assigned_to, q). %% @private Дополнительная фильтрация (status, assigned_to, q).
-spec apply_ticket_filters([#ticket{}], map()) -> [#ticket{}]. -spec apply_ticket_filters([#ticket{}], map()) -> [#ticket{}].
apply_ticket_filters(Tickets, Filters) -> apply_ticket_filters(Tickets, Filters) ->
Status = maps:get(status, Filters, undefined),
Assigned = maps:get(assigned_to, Filters, undefined), Assigned = maps:get(assigned_to, Filters, undefined),
Q = maps:get(q, Filters, undefined), Q = maps:get(q, Filters, undefined),
F1 = case Assigned of F1 = case Status of
undefined -> Tickets; undefined -> Tickets;
_ -> [T || T <- Tickets, T#ticket.assigned_to =:= Assigned] _ -> [T || T <- Tickets,
normalize_status(T#ticket.status) =:= normalize_status(Status)]
end,
F2 = case Assigned of
undefined -> F1;
_ -> [T || T <- F1, T#ticket.assigned_to =:= Assigned]
end, end,
case Q of case Q of
undefined -> F1; undefined -> F2;
_ -> [T || T <- F1, _ -> [T || T <- F2,
string:str(binary_to_list(T#ticket.error_message), binary_to_list(Q)) > 0] string:str(binary_to_list(T#ticket.error_message), binary_to_list(Q)) > 0]
end. end.
@@ -148,3 +154,11 @@ pagination_headers(#{limit := Limit, offset := Offset}, Total) ->
<<"x-total-count">> => integer_to_binary(Total), <<"x-total-count">> => integer_to_binary(Total),
<<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">> <<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">>
}. }.
%% @private Нормализует статус: атом → атом, binary → атом.
-spec normalize_status(atom() | binary()) -> atom().
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.

View File

@@ -118,10 +118,10 @@ get_statistics(AdminId) ->
case admin_utils:is_admin(AdminId) of case admin_utils:is_admin(AdminId) of
true -> true ->
All = core_ticket:list_all(), All = core_ticket:list_all(),
Open = length([T || T <- All, T#ticket.status =:= open]), Open = count_by_status(All, open),
InProgress = length([T || T <- All, T#ticket.status =:= in_progress]), InProgress = count_by_status(All, in_progress),
Resolved = length([T || T <- All, T#ticket.status =:= resolved]), Resolved = count_by_status(All, resolved),
Closed = length([T || T <- All, T#ticket.status =:= closed]), Closed = count_by_status(All, closed),
TotalErrors = lists:sum([T#ticket.count || T <- All]), TotalErrors = lists:sum([T#ticket.count || T <- All]),
#{ #{
total_tickets => length(All), total_tickets => length(All),
@@ -134,6 +134,15 @@ get_statistics(AdminId) ->
false -> {error, access_denied} false -> {error, access_denied}
end. end.
count_by_status(Tickets, Status) ->
length([T || T <- Tickets, normalize_status(T#ticket.status) =:= Status]).
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.
%% ============ Вспомогательные функции ============ %% ============ Вспомогательные функции ============
notify_admins(_Ticket) -> notify_admins(_Ticket) ->