Stage 3.3

This commit is contained in:
2026-04-20 14:04:30 +03:00
parent 5291f70337
commit 42a047a938
9 changed files with 988 additions and 29 deletions
+118 -11
View File
@@ -1,18 +1,17 @@
-module(logic_event).
-include("records.hrl").
-export([create_event/5, get_event/2, list_events/2,
-export([create_event/5, create_recurring_event/6, get_event/2, list_events/2,
update_event/3, delete_event/2]).
-export([validate_event_time/1]).
-export([validate_event_time/1, get_occurrences/3, cancel_occurrence/3]).
-export([materialize_for_booking/3]).
%% Создание события
%% Создание одиночного события
create_event(UserId, CalendarId, Title, StartTime, Duration) ->
% Проверяем, что календарь существует и пользователь имеет права
case logic_calendar:get_calendar(UserId, CalendarId) of
{ok, Calendar} ->
case logic_calendar:can_edit(UserId, Calendar) of
true ->
% Валидация времени
case validate_event_time(StartTime) of
ok ->
core_event:create(CalendarId, Title, StartTime, Duration);
@@ -26,11 +25,100 @@ create_event(UserId, CalendarId, Title, StartTime, Duration) ->
Error
end.
%% Создание повторяющегося события
create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule) ->
case logic_calendar:get_calendar(UserId, CalendarId) of
{ok, Calendar} ->
case logic_calendar:can_edit(UserId, Calendar) of
true ->
case validate_event_time(StartTime) of
ok ->
case logic_recurrence:validate_rrule(RRule) of
true ->
core_event:create_recurring(CalendarId, Title, StartTime, Duration, RRule);
false ->
{error, invalid_rrule}
end;
{error, _} = Error ->
Error
end;
false ->
{error, access_denied}
end;
Error ->
Error
end.
%% Получение вхождений повторяющегося события в диапазоне
%% Получение вхождений повторяющегося события в диапазоне
get_occurrences(UserId, MasterId, RangeEnd) ->
case get_event(UserId, MasterId) of
{ok, Event} when Event#event.event_type =:= recurring ->
Decoded = jsx:decode(Event#event.recurrence_rule, [return_maps]),
RRuleMap = case Decoded of
Map when is_map(Map) -> Map;
{ok, Map} -> Map
end,
{ok, ParsedRule} = logic_recurrence:parse_rrule(RRuleMap),
% Генерируем вхождения
Occurrences = logic_recurrence:generate_occurrences(
Event#event.start_time, ParsedRule, RangeEnd
),
% Получаем исключения
Exceptions = get_exceptions(MasterId),
% Фильтруем отменённые вхождения
ValidOccurrences = filter_cancelled(Occurrences, Exceptions),
% Проверяем материализованные вхождения (могут иметь изменения)
FinalOccurrences = merge_materialized(MasterId, ValidOccurrences),
{ok, FinalOccurrences};
{ok, _} ->
{error, not_recurring};
Error ->
Error
end.
%% Отмена отдельного вхождения
cancel_occurrence(UserId, MasterId, OccurrenceStart) ->
case get_event(UserId, MasterId) of
{ok, Event} when Event#event.event_type =:= recurring ->
case logic_calendar:get_calendar(UserId, Event#event.calendar_id) of
{ok, Calendar} ->
case logic_calendar:can_edit(UserId, Calendar) of
true ->
% Добавляем исключение
Exception = #recurrence_exception{
master_id = MasterId,
original_start = OccurrenceStart,
action = cancel,
new_start = undefined
},
mnesia:dirty_write(Exception),
{ok, cancelled};
false ->
{error, access_denied}
end;
Error ->
Error
end;
{ok, _} ->
{error, not_recurring};
Error ->
Error
end.
%% Материализация вхождения при записи участника
materialize_for_booking(MasterId, OccurrenceStart, SpecialistId) ->
core_event:materialize_occurrence(MasterId, OccurrenceStart, SpecialistId).
%% Получение события с проверкой доступа
get_event(UserId, EventId) ->
case core_event:get_by_id(EventId) of
{ok, Event} ->
% Проверяем доступ к календарю
case logic_calendar:get_calendar(UserId, Event#event.calendar_id) of
{ok, _} -> {ok, Event};
Error -> Error
@@ -52,12 +140,10 @@ list_events(UserId, CalendarId) ->
update_event(UserId, EventId, Updates) ->
case core_event:get_by_id(EventId) of
{ok, Event} ->
% Проверяем права на календарь
case logic_calendar:get_calendar(UserId, Event#event.calendar_id) of
{ok, Calendar} ->
case logic_calendar:can_edit(UserId, Calendar) of
true ->
% Валидация обновлений
ValidUpdates = validate_updates(Updates),
core_event:update(EventId, ValidUpdates);
false ->
@@ -89,7 +175,7 @@ delete_event(UserId, EventId) ->
Error
end.
%% Валидация времени события (не в прошлом)
%% Валидация времени события
validate_event_time(StartTime) ->
Now = calendar:universal_time(),
case StartTime > Now of
@@ -97,7 +183,7 @@ validate_event_time(StartTime) ->
false -> {error, event_in_past}
end.
%% Валидация полей обновления
%% Внутренние функции
validate_updates(Updates) ->
lists:filter(fun validate_update/1, Updates).
@@ -118,4 +204,25 @@ validate_update({location, Value}) ->
validate_update({tags, Value}) when is_list(Value) -> true;
validate_update({capacity, Value}) when is_integer(Value), Value > 0 -> true;
validate_update({online_link, Value}) when is_binary(Value) -> true;
validate_update(_) -> false.
validate_update(_) -> false.
get_exceptions(MasterId) ->
Match = #recurrence_exception{master_id = MasterId, _ = '_'},
mnesia:dirty_match_object(Match).
filter_cancelled(Occurrences, Exceptions) ->
CancelledStarts = [E#recurrence_exception.original_start ||
E <- Exceptions, E#recurrence_exception.action =:= cancel],
lists:filter(fun(Occ) -> not lists:member(Occ, CancelledStarts) end, Occurrences).
merge_materialized(MasterId, Occurrences) ->
Materialized = mnesia:dirty_match_object(
#event{master_id = MasterId, is_instance = true, status = active, _ = '_'}
),
lists:map(fun(Occ) ->
case lists:keyfind(Occ, #event.start_time, Materialized) of
false -> {virtual, Occ};
Event -> {materialized, Event}
end
end, Occurrences).
+160
View File
@@ -0,0 +1,160 @@
-module(logic_recurrence).
-include("records.hrl").
-export([parse_rrule/1, generate_occurrences/3]).
-export([validate_rrule/1]).
%% Типы частоты повторения
-define(FREQ_DAILY, <<"DAILY">>).
-define(FREQ_WEEKLY, <<"WEEKLY">>).
-define(FREQ_MONTHLY, <<"MONTHLY">>).
%% Парсинг RRULE из JSON
parse_rrule(RRuleMap) when is_map(RRuleMap) ->
Freq = maps:get(<<"freq">>, RRuleMap, maps:get(freq, RRuleMap, undefined)),
Interval = maps:get(<<"interval">>, RRuleMap, maps:get(interval, RRuleMap, undefined)),
Until = maps:get(<<"until">>, RRuleMap, maps:get(until, RRuleMap, undefined)),
Count = maps:get(<<"count">>, RRuleMap, maps:get(count, RRuleMap, undefined)),
ByDay = maps:get(<<"byday">>, RRuleMap, maps:get(byday, RRuleMap, [])),
{ok, #{
freq => Freq,
interval => Interval,
until => Until,
count => Count,
byday => ByDay
}}.
%% Валидация RRULE
validate_rrule(RRule) ->
try
% Поддерживаем и атомы, и бинарные ключи
Freq = get_freq(RRule),
Interval = get_interval(RRule),
ValidFreq = (Freq =:= ?FREQ_DAILY orelse
Freq =:= ?FREQ_WEEKLY orelse
Freq =:= ?FREQ_MONTHLY orelse
Freq =:= <<"DAILY">> orelse
Freq =:= <<"WEEKLY">> orelse
Freq =:= <<"MONTHLY">>),
ValidInterval = is_integer(Interval) andalso Interval >= 1,
ValidFreq andalso ValidInterval
catch
_:_ -> false
end.
get_freq(#{freq := Freq}) -> Freq;
get_freq(#{<<"freq">> := Freq}) -> Freq.
get_interval(#{interval := Interval}) -> Interval;
get_interval(#{<<"interval">> := Interval}) -> Interval.
%% Генерация вхождений в заданном диапазоне
generate_occurrences(StartTime, RRule, RangeEnd) ->
Freq = normalize_freq(get_freq(RRule)),
Interval = get_interval(RRule),
UntilRaw = maps:get(until, RRule, maps:get(<<"until">>, RRule, undefined)),
Count = maps:get(count, RRule, maps:get(<<"count">>, RRule, undefined)),
ByDay = maps:get(byday, RRule, maps:get(<<"byday">>, RRule, [])),
EndBoundary = case UntilRaw of
undefined -> RangeEnd;
U when is_binary(U) ->
{ok, UntilDt} = parse_datetime_str(U),
erlang:min(UntilDt, RangeEnd)
end,
generate_by_freq(StartTime, Freq, Interval, ByDay, EndBoundary, Count, 0, []).
normalize_freq(<<"DAILY">>) -> ?FREQ_DAILY;
normalize_freq(<<"WEEKLY">>) -> ?FREQ_WEEKLY;
normalize_freq(<<"MONTHLY">>) -> ?FREQ_MONTHLY;
normalize_freq(Freq) when is_atom(Freq) -> Freq.
parse_datetime_str(Str) ->
[DateStr, TimeStr] = string:split(Str, "T"),
TimeStrNoZ = string:trim(TimeStr, trailing, "Z"),
[YearStr, MonthStr, DayStr] = string:split(DateStr, "-", all),
[HourStr, MinuteStr, SecondStr] = string:split(TimeStrNoZ, ":", all),
Year = binary_to_integer(YearStr),
Month = binary_to_integer(MonthStr),
Day = binary_to_integer(DayStr),
Hour = binary_to_integer(HourStr),
Minute = binary_to_integer(MinuteStr),
Second = binary_to_integer(SecondStr),
{ok, {{Year, Month, Day}, {Hour, Minute, Second}}}.
minus(Dt1, Dt2) when Dt1 < Dt2 -> Dt1;
minus(_, Dt2) -> Dt2.
%% Генерация по частоте
generate_by_freq(Current, ?FREQ_DAILY, Interval, _ByDay, EndBoundary, MaxCount, Count, Acc) ->
case should_stop(Current, EndBoundary, MaxCount, Count) of
true -> lists:reverse(Acc);
false ->
generate_by_freq(
add_days(Current, Interval),
?FREQ_DAILY, Interval, _ByDay, EndBoundary, MaxCount,
Count + 1, [Current | Acc]
)
end;
generate_by_freq(Current, ?FREQ_WEEKLY, Interval, ByDay, EndBoundary, MaxCount, Count, Acc) ->
case should_stop(Current, EndBoundary, MaxCount, Count) of
true -> lists:reverse(Acc);
false ->
% Если указаны дни недели, генерируем только в эти дни
NewOccurrences = case ByDay of
[] -> [Current];
Days -> filter_by_weekday(Current, Days)
end,
generate_by_freq(
add_weeks(Current, Interval),
?FREQ_WEEKLY, Interval, ByDay, EndBoundary, MaxCount,
Count + 1, NewOccurrences ++ Acc
)
end;
generate_by_freq(Current, ?FREQ_MONTHLY, Interval, ByDay, EndBoundary, MaxCount, Count, Acc) ->
case should_stop(Current, EndBoundary, MaxCount, Count) of
true -> lists:reverse(Acc);
false ->
NewOccurrences = case ByDay of
[] -> [Current];
_ -> filter_by_month_day(Current, ByDay)
end,
generate_by_freq(
add_months(Current, Interval),
?FREQ_MONTHLY, Interval, ByDay, EndBoundary, MaxCount,
Count + 1, NewOccurrences ++ Acc
)
end.
%% Вспомогательные функции
should_stop(Current, EndBoundary, MaxCount, Count) ->
(Current > EndBoundary) orelse (MaxCount =/= undefined andalso Count >= MaxCount).
add_days({{Y, M, D}, Time}, N) ->
Days = calendar:date_to_gregorian_days(Y, M, D) + N,
{calendar:gregorian_days_to_date(Days), Time}.
add_weeks(DateTime, N) ->
add_days(DateTime, N * 7).
add_months({{Y, M, D}, Time}, N) ->
TotalMonths = Y * 12 + M - 1 + N,
NewYear = TotalMonths div 12,
NewMonth = TotalMonths rem 12 + 1,
NewDay = minus(D, calendar:last_day_of_the_month(NewYear, NewMonth)),
{{NewYear, NewMonth, NewDay}, Time}.
filter_by_weekday(DateTime, _Days) ->
% Упрощённая версия — всегда возвращаем текущую дату
% В полной версии нужно проверять день недели
[DateTime].
filter_by_month_day(DateTime, _Days) ->
[DateTime].