Избавление от undefined, всем необязательным полям присваиваются дефолтные значения #22

This commit is contained in:
2026-05-27 16:18:33 +03:00
parent ae6fe21ce6
commit b78df927dd
21 changed files with 2037 additions and 1088 deletions
+257 -127
View File
@@ -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.