-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.