Избавление от undefined, всем необязательным полям присваиваются дефолтные значения #22
This commit is contained in:
+257
-127
@@ -1,138 +1,268 @@
|
||||
-module(core_ticket).
|
||||
-include("records.hrl").
|
||||
-export([list_all/0,
|
||||
get_by_id/1,
|
||||
update_ticket/2,
|
||||
delete_ticket/1,
|
||||
stats/0,
|
||||
create_ticket/1,
|
||||
list_by_user/1]).
|
||||
-export([count_tickets_by_status/1, count_tickets_by_admin/2]).
|
||||
-export([avg_resolution_time/0]).
|
||||
-export([create/4, get_by_id/1, list_all/0, update/2, update_status/2, delete/1,
|
||||
assign/2, resolve/3, close/2]).
|
||||
-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([update_ticket/2]).
|
||||
-export([delete_ticket/1]). % ← для admin_handler_ticket_by_id
|
||||
|
||||
%% ─────────────────────────────────────────────────────────────────
|
||||
%% Значения по умолчанию для необязательных полей
|
||||
%% ─────────────────────────────────────────────────────────────────
|
||||
-define(DEFAULT_CONTEXT, <<>>).
|
||||
-define(DEFAULT_ASSIGNED_TO, <<>>).
|
||||
-define(DEFAULT_RESOLUTION_NOTE, <<>>).
|
||||
-define(DEFAULT_CLOSED_AT, {{1970,1,1},{0,0,0}}).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Создание тикета из map, переданного обработчиком.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec create_ticket(Data :: map()) -> {ok, #ticket{}} | {error, term()}.
|
||||
create_ticket(Data) ->
|
||||
ReporterId = maps:get(<<"reporter_id">>, Data),
|
||||
ErrorHash = maps:get(<<"error_hash">>, Data, <<>>),
|
||||
ErrorMessage = maps:get(<<"error_message">>, Data),
|
||||
Stacktrace = maps:get(<<"stacktrace">>, Data, <<>>),
|
||||
Context = maps:get(<<"context">>, Data, ?DEFAULT_CONTEXT),
|
||||
Id = infra_utils:generate_id(16),
|
||||
Now = calendar:universal_time(),
|
||||
Ticket = #ticket{
|
||||
id = Id,
|
||||
reporter_id = ReporterId,
|
||||
error_hash = ErrorHash,
|
||||
error_message = ErrorMessage,
|
||||
stacktrace = Stacktrace,
|
||||
context = Context,
|
||||
count = 1,
|
||||
first_seen = Now,
|
||||
last_seen = Now,
|
||||
status = open,
|
||||
assigned_to = ?DEFAULT_ASSIGNED_TO,
|
||||
resolution_note = ?DEFAULT_RESOLUTION_NOTE,
|
||||
closed_at = ?DEFAULT_CLOSED_AT
|
||||
},
|
||||
F = fun() -> mnesia:write(Ticket), {ok, Ticket} end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Result} -> Result;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Создание тикета с явным указанием всех обязательных полей.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec create(ReporterId :: binary(), ErrorHash :: binary(),
|
||||
ErrorMessage :: binary(), Stacktrace :: binary()) ->
|
||||
{ok, #ticket{}} | {error, term()}.
|
||||
create(ReporterId, ErrorHash, ErrorMessage, Stacktrace) ->
|
||||
create_ticket(#{
|
||||
<<"reporter_id">> => ReporterId,
|
||||
<<"error_hash">> => ErrorHash,
|
||||
<<"error_message">> => ErrorMessage,
|
||||
<<"stacktrace">> => Stacktrace
|
||||
}).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Обновление тикета через map (для admin_handler_ticket_by_id).
|
||||
%%% Значение ключа `status` принудительно приводится к атому.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec update_ticket(Id :: binary(), Updates :: map()) ->
|
||||
{ok, #ticket{}} | {error, not_found | term()}.
|
||||
update_ticket(Id, Updates) ->
|
||||
TupleList = maps:fold(fun(Key, Value, Acc) ->
|
||||
AtomKey = case Key of
|
||||
<<"status">> -> status;
|
||||
<<"assigned_to">> -> assigned_to;
|
||||
<<"resolution_note">> -> resolution_note;
|
||||
<<"closed_at">> -> closed_at;
|
||||
_ -> Key
|
||||
end,
|
||||
AtomValue = case Key of
|
||||
<<"status">> ->
|
||||
try binary_to_existing_atom(Value, utf8)
|
||||
catch _:_ -> Value
|
||||
end;
|
||||
_ -> Value
|
||||
end,
|
||||
[{AtomKey, AtomValue} | Acc]
|
||||
end, [], Updates),
|
||||
update(Id, TupleList).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Получить тикет по идентификатору.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec get_by_id(Id :: binary()) -> {ok, #ticket{}} | {error, not_found}.
|
||||
get_by_id(Id) ->
|
||||
case mnesia:dirty_read(ticket, Id) of
|
||||
[] -> {error, not_found};
|
||||
[Ticket] -> {ok, Ticket}
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Список всех тикетов.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec list_all() -> [#ticket{}].
|
||||
list_all() ->
|
||||
mnesia:dirty_match_object(#ticket{_ = '_'}).
|
||||
|
||||
get_by_id(Id) ->
|
||||
case mnesia:dirty_read({ticket, Id}) of
|
||||
[Ticket] -> {ok, Ticket};
|
||||
[] -> {error, not_found}
|
||||
end.
|
||||
|
||||
update_ticket(Id, Updates) ->
|
||||
case get_by_id(Id) of
|
||||
{ok, Ticket} ->
|
||||
Updated = apply_updates(Ticket, Updates),
|
||||
mnesia:dirty_write(Updated),
|
||||
{ok, Updated};
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
delete_ticket(Id) ->
|
||||
case get_by_id(Id) of
|
||||
{ok, _Ticket} ->
|
||||
mnesia:dirty_delete({ticket, Id}),
|
||||
{ok, deleted};
|
||||
Error -> Error
|
||||
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)
|
||||
}.
|
||||
|
||||
%% ── новые функции ──────────────────────────────────────
|
||||
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),
|
||||
error_hash = maps:get(<<"error_hash">>, Data, <<"">>),
|
||||
error_message = maps:get(<<"error_message">>, Data),
|
||||
stacktrace = maps:get(<<"stacktrace">>, Data, <<"">>),
|
||||
context = maps:get(<<"context">>, Data, <<"">>),
|
||||
count = 1,
|
||||
first_seen = Now,
|
||||
last_seen = Now,
|
||||
status = Status, %% <-- ИСПРАВЛЕНО: сохраняем атом
|
||||
assigned_to = maps:get(<<"assigned_to">>, Data, undefined),
|
||||
resolution_note = maps:get(<<"resolution_note">>, Data, undefined),
|
||||
closed_at = undefined
|
||||
},
|
||||
mnesia:dirty_write(Ticket),
|
||||
{ok, Ticket}.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Список тикетов, созданных пользователем.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec list_by_user(UserId :: binary()) -> [#ticket{}].
|
||||
list_by_user(UserId) ->
|
||||
mnesia:dirty_match_object(#ticket{reporter_id = UserId, _ = '_'}).
|
||||
Match = #ticket{reporter_id = UserId, _ = '_'},
|
||||
mnesia:dirty_match_object(Match).
|
||||
|
||||
%% ── функции подсчёта с нормализацией статуса ─────────────
|
||||
|
||||
%% @private Подсчитывает тикеты с заданным статусом (атом или бинарный)
|
||||
count_by_status(Status, Tickets) ->
|
||||
length([T || T <- Tickets, normalize_status(T#ticket.status) =:= Status]).
|
||||
|
||||
%% @doc Количество тикетов по статусу (атом или бинарный)
|
||||
count_tickets_by_status(Status) ->
|
||||
Tickets = list_all(),
|
||||
count_by_status(Status, Tickets).
|
||||
|
||||
%% @doc Количество тикетов, назначенных администратору, с заданным статусом
|
||||
count_tickets_by_admin(AdminId, Status) ->
|
||||
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{_ = '_'}),
|
||||
% Фильтруем закрытые с учётом нормализации статуса
|
||||
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.closed_at) -
|
||||
calendar:datetime_to_gregorian_seconds(T#ticket.first_seen)
|
||||
|| T <- ClosedTickets]),
|
||||
TotalSeconds / length(ClosedTickets) / 3600.0
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Обновить поля тикета (список кортежей).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec update(Id :: binary(), Updates :: [{atom(), term()}]) ->
|
||||
{ok, #ticket{}} | {error, not_found | term()}.
|
||||
update(Id, Updates) ->
|
||||
F = fun() ->
|
||||
case mnesia:read(ticket, Id) of
|
||||
[] -> {error, not_found};
|
||||
[Ticket] ->
|
||||
UpdatedTicket = apply_updates(Ticket, Updates),
|
||||
mnesia:write(UpdatedTicket),
|
||||
{ok, UpdatedTicket}
|
||||
end
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Result} -> Result;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
%% ── внутренние ─────────────────────────────────────────
|
||||
apply_updates(Ticket, Updates) ->
|
||||
lists:foldl(fun({Key, Value}, Acc) ->
|
||||
case Key of
|
||||
<<"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};
|
||||
<<"stacktrace">> -> Acc#ticket{stacktrace = Value};
|
||||
<<"context">> -> Acc#ticket{context = Value};
|
||||
_ -> Acc
|
||||
end
|
||||
end, Ticket, maps:to_list(Updates)).
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Изменить статус тикета.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec update_status(Id :: binary(), NewStatus :: open | in_progress | resolved | closed) ->
|
||||
{ok, #ticket{}} | {error, not_found | term()}.
|
||||
update_status(Id, NewStatus) ->
|
||||
update(Id, [{status, NewStatus}]).
|
||||
|
||||
%% @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.
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Физическое удаление тикета.
|
||||
%%% Возвращает `{ok, deleted}` при успехе.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec delete(Id :: binary()) -> {ok, deleted} | {error, not_found}.
|
||||
delete(Id) ->
|
||||
case mnesia:dirty_read(ticket, Id) of
|
||||
[] -> {error, not_found};
|
||||
[_] -> mnesia:dirty_delete(ticket, Id), {ok, deleted}
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Удаление тикета (синоним delete/1, для обратной совместимости).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec delete_ticket(Id :: binary()) -> {ok, deleted} | {error, not_found}.
|
||||
delete_ticket(Id) ->
|
||||
delete(Id).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Назначить тикет на администратора.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec assign(Id :: binary(), AdminId :: binary()) ->
|
||||
{ok, #ticket{}} | {error, not_found | term()}.
|
||||
assign(Id, AdminId) ->
|
||||
update(Id, [{assigned_to, AdminId}, {status, in_progress}]).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Разрешить тикет с указанием примечания.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec resolve(Id :: binary(), AdminId :: binary(), Note :: binary()) ->
|
||||
{ok, #ticket{}} | {error, not_found | term()}.
|
||||
resolve(Id, AdminId, Note) ->
|
||||
update(Id, [{status, resolved}, {resolution_note, Note},
|
||||
{assigned_to, AdminId}]).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Закрыть тикет (устанавливает статус `closed` и `closed_at`).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec close(Id :: binary(), AdminId :: binary()) ->
|
||||
{ok, #ticket{}} | {error, not_found | term()}.
|
||||
close(Id, AdminId) ->
|
||||
update(Id, [{status, closed}, {closed_at, calendar:universal_time()},
|
||||
{assigned_to, AdminId}]).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Количество тикетов.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec count_tickets() -> non_neg_integer().
|
||||
count_tickets() ->
|
||||
mnesia:table_info(ticket, size).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Количество тикетов с заданным статусом.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-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)).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Среднее время разрешения тикетов (в часах).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-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.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Количество тикетов, назначенных на администратора.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-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));
|
||||
count_tickets_by_admin(AdminId, Status) ->
|
||||
Match = #ticket{assigned_to = AdminId, status = Status, _ = '_'},
|
||||
length(mnesia:dirty_match_object(Match)).
|
||||
|
||||
%%%===================================================================
|
||||
%%% ВНУТРЕННИЕ ФУНКЦИИ
|
||||
%%%===================================================================
|
||||
|
||||
apply_updates(Ticket, Updates) ->
|
||||
Updated = lists:foldl(fun({Field, Value}, T) -> set_field(Field, Value, T) end,
|
||||
Ticket, Updates),
|
||||
Updated#ticket{last_seen = calendar:universal_time()}.
|
||||
|
||||
set_field(status, Value, T) -> T#ticket{status = Value};
|
||||
set_field(assigned_to, Value, T) -> T#ticket{assigned_to = Value};
|
||||
set_field(resolution_note, Value, T) -> T#ticket{resolution_note = Value};
|
||||
set_field(closed_at, Value, T) -> T#ticket{closed_at = Value};
|
||||
set_field(error_message, Value, T) -> T#ticket{error_message = Value};
|
||||
set_field(stacktrace, Value, T) -> T#ticket{stacktrace = Value};
|
||||
set_field(context, Value, T) -> T#ticket{context = Value};
|
||||
set_field(count, Value, T) -> T#ticket{count = Value};
|
||||
set_field(_, _, T) -> T.
|
||||
Reference in New Issue
Block a user