Files
EventHubBack/src/core/core_ticket.erl
T

290 lines
12 KiB
Erlang

-module(core_ticket).
-include("records.hrl").
-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]).
-export([count_tickets_by_date/2]).
%% ─────────────────────────────────────────────────────────────────
%% Значения по умолчанию для необязательных полей
%% ─────────────────────────────────────────────────────────────────
-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{_ = '_'}).
%%%-------------------------------------------------------------------
%%% @doc Список тикетов, созданных пользователем.
%%% @end
%%%-------------------------------------------------------------------
-spec list_by_user(UserId :: binary()) -> [#ticket{}].
list_by_user(UserId) ->
Match = #ticket{reporter_id = UserId, _ = '_'},
mnesia:dirty_match_object(Match).
%%%-------------------------------------------------------------------
%%% @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.
%%%-------------------------------------------------------------------
%%% @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}]).
%%%-------------------------------------------------------------------
%%% @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)).
%%%-------------------------------------------------------------------
%%% @doc Подсчёт тикетов, созданных в заданном временном диапазоне.
%%% @end
%%%-------------------------------------------------------------------
-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).
date_part({{Y,M,D}, _}) -> {Y,M,D}.
%%%===================================================================
%%% ВНУТРЕННИЕ ФУНКЦИИ
%%%===================================================================
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.