From faee11ce29a8119c1c467fdea9cdfaad8dda402f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=A1=D0=B0?= =?UTF-8?q?=D0=B1=D0=B8=D0=BB=D0=B8=D0=BD?= Date: Tue, 19 May 2026 22:16:26 +0300 Subject: [PATCH] fix ticket stats --- include/records.hrl | 3 +- src/core/core_ticket.erl | 62 +++++++++++++++----- src/handlers/admin/admin_handler_tickets.erl | 26 ++++++-- src/logic/logic_ticket.erl | 17 ++++-- 4 files changed, 82 insertions(+), 26 deletions(-) diff --git a/include/records.hrl b/include/records.hrl index d72bc7b..e2f1bd6 100644 --- a/include/records.hrl +++ b/include/records.hrl @@ -196,7 +196,8 @@ last_seen :: calendar:datetime(), status :: open | in_progress | resolved | closed, assigned_to :: binary(), - resolution_note :: binary() + resolution_note :: binary(), + closed_at :: calendar:datetime() | undefined }). %% ------------------- Подписки ---------------------------------------- diff --git a/src/core/core_ticket.erl b/src/core/core_ticket.erl index 08e4a96..7c827dc 100644 --- a/src/core/core_ticket.erl +++ b/src/core/core_ticket.erl @@ -30,12 +30,13 @@ update_ticket(Id, Updates) -> delete_ticket(Id) -> case get_by_id(Id) of - {ok, _Ticket} -> % переменная не используется + {ok, _Ticket} -> mnesia:dirty_delete({ticket, Id}), {ok, deleted}; Error -> Error end. +%% @doc Статистика по тикетам (используется в admin_handler_ticket_stats) stats() -> Tickets = list_all(), #{ @@ -50,6 +51,8 @@ stats() -> create_ticket(Data) -> Id = infra_utils:generate_id(9), Now = calendar:universal_time(), + Status0 = maps:get(<<"status">>, Data, open), %% <-- ИСПРАВЛЕНО: извлекаем сырое значение + Status = normalize_status(Status0), %% <-- ИСПРАВЛЕНО: приводим к атому Ticket = #ticket{ id = Id, reporter_id = maps:get(<<"reporter_id">>, Data, undefined), @@ -60,9 +63,10 @@ create_ticket(Data) -> count = 1, first_seen = Now, last_seen = Now, - status = maps:get(<<"status">>, Data, open), + status = Status, %% <-- ИСПРАВЛЕНО: сохраняем атом 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), {ok, Ticket}. @@ -70,32 +74,52 @@ create_ticket(Data) -> list_by_user(UserId) -> mnesia:dirty_match_object(#ticket{reporter_id = UserId, _ = '_'}). +%% ── функции подсчёта с нормализацией статуса ───────────── + +%% @private Подсчитывает тикеты с заданным статусом (атом или бинарный) 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) -> - Match = #ticket{status = Status, _ = '_'}, - length(mnesia:dirty_match_object(Match)). + Tickets = list_all(), + count_by_status(Status, Tickets). +%% @doc Количество тикетов, назначенных администратору, с заданным статусом count_tickets_by_admin(AdminId, Status) -> - Match = #ticket{assigned_to = AdminId, status = Status, _ = '_'}, - length(mnesia:dirty_match_object(Match)). + Tickets = list_all(), + length([T || T <- Tickets, + T#ticket.assigned_to =:= AdminId andalso + normalize_status(T#ticket.status) =:= Status]). 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; _ -> - TotalSeconds = lists:sum([calendar:datetime_to_gregorian_seconds(T#ticket.last_seen) - - calendar:datetime_to_gregorian_seconds(T#ticket.first_seen) || T <- Tickets]), - TotalSeconds / length(Tickets) / 3600.0 + TotalSeconds = lists:sum([ + calendar:datetime_to_gregorian_seconds(T#ticket.closed_at) - + calendar:datetime_to_gregorian_seconds(T#ticket.first_seen) + || T <- ClosedTickets]), + TotalSeconds / length(ClosedTickets) / 3600.0 end. %% ── внутренние ───────────────────────────────────────── apply_updates(Ticket, Updates) -> lists:foldl(fun({Key, Value}, Acc) -> 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}; <<"resolution_note">> -> Acc#ticket{resolution_note = Value}; <<"error_message">> -> Acc#ticket{error_message = Value}; @@ -103,4 +127,12 @@ apply_updates(Ticket, Updates) -> <<"context">> -> Acc#ticket{context = Value}; _ -> Acc end - end, Ticket, maps:to_list(Updates)). \ No newline at end of file + 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. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_tickets.erl b/src/handlers/admin/admin_handler_tickets.erl index 4e0e451..b8356fc 100644 --- a/src/handlers/admin/admin_handler_tickets.erl +++ b/src/handlers/admin/admin_handler_tickets.erl @@ -105,18 +105,24 @@ parse_ticket_filters(Req) -> q => proplists:get_value(<<"q">>, Qs) }. -%% @private Дополнительная фильтрация (assigned_to, q). +%% @private Дополнительная фильтрация (status, assigned_to, q). -spec apply_ticket_filters([#ticket{}], map()) -> [#ticket{}]. apply_ticket_filters(Tickets, Filters) -> + Status = maps:get(status, Filters, undefined), Assigned = maps:get(assigned_to, Filters, undefined), Q = maps:get(q, Filters, undefined), - F1 = case Assigned of + F1 = case Status of 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, case Q of - undefined -> F1; - _ -> [T || T <- F1, + undefined -> F2; + _ -> [T || T <- F2, string:str(binary_to_list(T#ticket.error_message), binary_to_list(Q)) > 0] end. @@ -147,4 +153,12 @@ pagination_headers(#{limit := Limit, offset := Offset}, Total) -> <<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])), <<"x-total-count">> => integer_to_binary(Total), <<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">> - }. \ No newline at end of file + }. + +%% @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. \ No newline at end of file diff --git a/src/logic/logic_ticket.erl b/src/logic/logic_ticket.erl index a94be85..f73fe44 100644 --- a/src/logic/logic_ticket.erl +++ b/src/logic/logic_ticket.erl @@ -118,10 +118,10 @@ get_statistics(AdminId) -> case admin_utils:is_admin(AdminId) of true -> All = core_ticket:list_all(), - Open = length([T || T <- All, T#ticket.status =:= open]), - InProgress = length([T || T <- All, T#ticket.status =:= in_progress]), - Resolved = length([T || T <- All, T#ticket.status =:= resolved]), - Closed = length([T || T <- All, T#ticket.status =:= closed]), + Open = count_by_status(All, open), + InProgress = count_by_status(All, in_progress), + Resolved = count_by_status(All, resolved), + Closed = count_by_status(All, closed), TotalErrors = lists:sum([T#ticket.count || T <- All]), #{ total_tickets => length(All), @@ -134,6 +134,15 @@ get_statistics(AdminId) -> false -> {error, access_denied} 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) ->