-module(core_event). -include("records.hrl"). -export([create/4, create_recurring/5, get_by_id/1, list_by_calendar/1, update/2, delete/1, materialize_occurrence/3]). -export([count_events/0, count_events_by_date/2]). -export([freeze/2, unfreeze/2]). -export([list_all/0]). -export([count_events_by_type/0, count_events_by_status/0, get_top_events_by_rating/1]). %% ───────────────────────────────────────────────────────────────── %% Значения по умолчанию для необязательных полей %% ───────────────────────────────────────────────────────────────── -define(DEFAULT_RECURRENCE_RULE, <<>>). -define(DEFAULT_MASTER_ID, <<>>). -define(DEFAULT_SPECIALIST_ID, <<>>). -define(DEFAULT_LOCATION, #location{address = <<>>, lat = 0.0, lon = 0.0}). -define(DEFAULT_TAGS, []). -define(DEFAULT_CAPACITY, 0). -define(DEFAULT_ONLINE_LINK, <<>>). -define(DEFAULT_REASON, <<>>). -define(DEFAULT_ATTACHMENTS, []). -define(DEFAULT_EDIT_HISTORY, []). %%%------------------------------------------------------------------- %%% @doc Создание одиночного события. %%% Все необязательные поля инициализируются значениями по умолчанию. %%% @end %%%------------------------------------------------------------------- -spec create(CalendarId :: binary(), Title :: binary(), StartTime :: calendar:datetime(), Duration :: non_neg_integer()) -> {ok, #event{}} | {error, term()}. create(CalendarId, Title, StartTime, Duration) -> Id = infra_utils:generate_id(16), Now = calendar:universal_time(), Event = #event{ id = Id, calendar_id = CalendarId, title = Title, description = <<"">>, event_type = single, start_time = StartTime, duration = Duration, recurrence_rule = ?DEFAULT_RECURRENCE_RULE, master_id = ?DEFAULT_MASTER_ID, is_instance = false, specialist_id = ?DEFAULT_SPECIALIST_ID, location = ?DEFAULT_LOCATION, tags = ?DEFAULT_TAGS, capacity = ?DEFAULT_CAPACITY, online_link = ?DEFAULT_ONLINE_LINK, status = active, reason = ?DEFAULT_REASON, rating_avg = 0.0, rating_count = 0, attachments = ?DEFAULT_ATTACHMENTS, edit_history = ?DEFAULT_EDIT_HISTORY, created_at = Now, updated_at = Now }, F = fun() -> mnesia:write(Event), {ok, Event} end, case mnesia:transaction(F) of {atomic, Result} -> Result; {aborted, Reason} -> {error, Reason} end. %%%------------------------------------------------------------------- %%% @doc Создание повторяющегося события (мастер-запись). %%% @end %%%------------------------------------------------------------------- -spec create_recurring(CalendarId :: binary(), Title :: binary(), StartTime :: calendar:datetime(), Duration :: non_neg_integer(), RRule :: map()) -> {ok, #event{}} | {error, term()}. create_recurring(CalendarId, Title, StartTime, Duration, RRule) -> Id = infra_utils:generate_id(16), Now = calendar:universal_time(), Event = #event{ id = Id, calendar_id = CalendarId, title = Title, description = <<"">>, event_type = recurring, start_time = StartTime, duration = Duration, recurrence_rule = jsx:encode(RRule), master_id = ?DEFAULT_MASTER_ID, is_instance = false, specialist_id = ?DEFAULT_SPECIALIST_ID, location = ?DEFAULT_LOCATION, tags = ?DEFAULT_TAGS, capacity = ?DEFAULT_CAPACITY, online_link = ?DEFAULT_ONLINE_LINK, status = active, reason = ?DEFAULT_REASON, rating_avg = 0.0, rating_count = 0, attachments = ?DEFAULT_ATTACHMENTS, edit_history = ?DEFAULT_EDIT_HISTORY, created_at = Now, updated_at = Now }, F = fun() -> mnesia:write(Event), {ok, Event} end, case mnesia:transaction(F) of {atomic, Result} -> Result; {aborted, Reason} -> {error, Reason} end. %%%------------------------------------------------------------------- %%% @doc Материализация вхождения повторяющегося события. %%% @end %%%------------------------------------------------------------------- -spec materialize_occurrence(MasterId :: binary(), OccurrenceStart :: calendar:datetime(), SpecialistId :: binary()) -> {ok, #event{}} | {error, term()}. materialize_occurrence(MasterId, OccurrenceStart, SpecialistId) -> case mnesia:dirty_read(event, MasterId) of [] -> {error, master_not_found}; [Master] when Master#event.event_type =:= recurring -> Existing = mnesia:dirty_match_object( #event{master_id = MasterId, start_time = OccurrenceStart, is_instance = true, _ = '_'} ), case Existing of [] -> InstanceId = infra_utils:generate_id(16), Now = calendar:universal_time(), Instance = #event{ id = InstanceId, calendar_id = Master#event.calendar_id, title = Master#event.title, description = Master#event.description, event_type = single, start_time = OccurrenceStart, duration = Master#event.duration, recurrence_rule = ?DEFAULT_RECURRENCE_RULE, master_id = MasterId, is_instance = true, specialist_id = SpecialistId, location = Master#event.location, tags = Master#event.tags, capacity = Master#event.capacity, online_link = Master#event.online_link, status = active, reason = ?DEFAULT_REASON, rating_avg = 0.0, rating_count = 0, attachments = ?DEFAULT_ATTACHMENTS, edit_history = ?DEFAULT_EDIT_HISTORY, created_at = Now, updated_at = Now }, F = fun() -> mnesia:write(Instance), {ok, Instance} end, case mnesia:transaction(F) of {atomic, Result} -> Result; {aborted, Reason} -> {error, Reason} end; [Instance] -> {ok, Instance} end; [_] -> {error, not_recurring} end. %%%------------------------------------------------------------------- %%% @doc Получить событие по идентификатору. %%% @end %%%------------------------------------------------------------------- -spec get_by_id(Id :: binary()) -> {ok, #event{}} | {error, not_found}. get_by_id(Id) -> case mnesia:dirty_read(event, Id) of [] -> {error, not_found}; [Event] -> {ok, Event} end. %%%------------------------------------------------------------------- %%% @doc Список активных событий календаря. %%% @end %%%------------------------------------------------------------------- -spec list_by_calendar(CalendarId :: binary()) -> {ok, [#event{}]}. list_by_calendar(CalendarId) -> Match = #event{calendar_id = CalendarId, status = active, is_instance = false, _ = '_'}, Events = mnesia:dirty_match_object(Match), {ok, Events}. %%%------------------------------------------------------------------- %%% @doc Обновить поля события. %%% `Updates` – список пар `[{atom(), term()}]`. %%% Поля, не указанные в обновлении, сохраняют прежние значения. %%% @end %%%------------------------------------------------------------------- -spec update(Id :: binary(), Updates :: [{atom(), term()}]) -> {ok, #event{}} | {error, not_found | term()}. update(Id, Updates) -> F = fun() -> case mnesia:read(event, Id) of [] -> {error, not_found}; [Event] -> UpdatedEvent = apply_updates(Event, Updates), mnesia:write(UpdatedEvent), {ok, UpdatedEvent} end end, case mnesia:transaction(F) of {atomic, Result} -> Result; {aborted, Reason} -> {error, Reason} end. %%%------------------------------------------------------------------- %%% @doc Мягкое удаление события (установка статуса `deleted`). %%% @end %%%------------------------------------------------------------------- -spec delete(Id :: binary()) -> {ok, #event{}} | {error, not_found | term()}. delete(Id) -> update(Id, [{status, deleted}]). %%%------------------------------------------------------------------- %%% @doc Количество записей в таблице `event`. %%% @end %%%------------------------------------------------------------------- -spec count_events() -> non_neg_integer(). count_events() -> mnesia:table_info(event, size). %%%------------------------------------------------------------------- %%% @doc Получить список всех активных мастер-событий. %%% @end %%%------------------------------------------------------------------- -spec list_all() -> [#event{}]. list_all() -> Match = #event{status = active, is_instance = false, _ = '_'}, mnesia:dirty_match_object(Match). %%%------------------------------------------------------------------- %%% @doc Подсчёт событий, созданных в заданном временном диапазоне. %%% Возвращает список кортежей `{{Year,Month,Day}, Count}`, отсортированный по дате. %%% @end %%%------------------------------------------------------------------- -spec count_events_by_date(From :: calendar:datetime(), To :: calendar:datetime()) -> [{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}]. count_events_by_date(From, To) -> All = mnesia:dirty_match_object(#event{_ = '_'}), Filtered = lists:filter(fun(E) -> E#event.created_at >= From andalso E#event.created_at =< To end, All), Counts = lists:foldl(fun(E, Acc) -> Day = date_part(E#event.created_at), 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). %%%------------------------------------------------------------------- %%% @doc Подсчёт событий по типам (single, recurring). %%% @end %%%------------------------------------------------------------------- -spec count_events_by_type() -> [{atom(), non_neg_integer()}]. count_events_by_type() -> Events = mnesia:dirty_match_object(#event{_ = '_'}), lists:foldl(fun(#event{event_type = Type}, Acc) -> case lists:keyfind(Type, 1, Acc) of false -> [{Type, 1} | Acc]; {Type, C} -> lists:keyreplace(Type, 1, Acc, {Type, C+1}) end end, [], Events). %%%------------------------------------------------------------------- %%% @doc Подсчёт событий по статусам (active, cancelled, completed). %%% @end %%%------------------------------------------------------------------- -spec count_events_by_status() -> [{atom(), non_neg_integer()}]. count_events_by_status() -> Events = mnesia:dirty_match_object(#event{_ = '_'}), lists:foldl(fun(#event{status = Status}, Acc) -> case lists:keyfind(Status, 1, Acc) of false -> [{Status, 1} | Acc]; {Status, C} -> lists:keyreplace(Status, 1, Acc, {Status, C+1}) end end, [], Events). %%%------------------------------------------------------------------- %%% @doc Возвращает топ-N событий по среднему рейтингу (по убыванию). %%% @end %%%------------------------------------------------------------------- -spec get_top_events_by_rating(N :: non_neg_integer()) -> [#event{}]. get_top_events_by_rating(N) -> Events = mnesia:dirty_match_object(#event{_ = '_'}), Sorted = lists:reverse(lists:sort( fun(A, B) -> A#event.rating_avg =< B#event.rating_avg end, Events)), lists:sublist(Sorted, N). %%%------------------------------------------------------------------- %%% @doc Выделить из datetime только дату `{Y,M,D}`. %%% @end %%%------------------------------------------------------------------- -spec date_part(calendar:datetime()) -> {pos_integer(), pos_integer(), pos_integer()}. date_part({{Y,M,D}, _}) -> {Y,M,D}. %%%------------------------------------------------------------------- %%% @doc Применить список обновлений к записи события. %%% @end %%%------------------------------------------------------------------- -spec apply_updates(#event{}, [{atom(), term()}]) -> #event{}. apply_updates(Event, Updates) -> Updated = lists:foldl(fun({Field, Value}, E) -> set_field(Field, Value, E) end, Event, Updates), Updated#event{updated_at = calendar:universal_time()}. %%%------------------------------------------------------------------- %%% @doc Заморозить событие с указанием причины. %%% @end %%%------------------------------------------------------------------- -spec freeze(Id :: binary(), Reason :: binary()) -> {ok, #event{}} | {error, not_found | term()}. freeze(Id, Reason) -> update(Id, [{status, frozen}, {reason, Reason}]). %%%------------------------------------------------------------------- %%% @doc Разморозить событие с указанием причины. %%% @end %%%------------------------------------------------------------------- -spec unfreeze(Id :: binary(), Reason :: binary()) -> {ok, #event{}} | {error, not_found | term()}. unfreeze(Id, Reason) -> update(Id, [{status, active}, {reason, Reason}]). %%%=================================================================== %%% ВНУТРЕННИЕ ФУНКЦИИ %%%=================================================================== %%%------------------------------------------------------------------- %%% @doc Установить одно поле записи события. %%% Неизвестные поля игнорируются. %%% @end %%%------------------------------------------------------------------- -spec set_field(atom(), term(), #event{}) -> #event{}. set_field(title, Value, E) -> E#event{title = Value}; set_field(description, Value, E) -> E#event{description = Value}; set_field(start_time, Value, E) -> E#event{start_time = Value}; set_field(duration, Value, E) -> E#event{duration = Value}; set_field(specialist_id, Value, E) -> E#event{specialist_id = Value}; set_field(location, Value, E) -> E#event{location = Value}; set_field(tags, Value, E) -> E#event{tags = Value}; set_field(capacity, Value, E) -> E#event{capacity = Value}; set_field(online_link, Value, E) -> E#event{online_link = Value}; set_field(status, Value, E) -> E#event{status = Value}; set_field(reason, Value, E) -> E#event{reason = Value}; set_field(rating_avg, Value, E) -> E#event{rating_avg = Value}; set_field(rating_count, Value, E) -> E#event{rating_count = Value}; set_field(attachments, Value, E) -> E#event{attachments = Value}; set_field(edit_history, Value, E) -> E#event{edit_history = Value}; set_field(recurrence_rule, Value, E) -> E#event{recurrence_rule = Value}; set_field(master_id, Value, E) -> E#event{master_id = Value}; set_field(is_instance, Value, E) -> E#event{is_instance = Value}; set_field(event_type, Value, E) -> E#event{event_type = Value}; set_field(calendar_id, Value, E) -> E#event{calendar_id = Value}; set_field(_, _, E) -> E.