using System.IO;
using System.Text.Json;
using System.Transactions;
using Server.WatchBody.Core.Dal.Ref.Repository;
using Server.WatchBody.Core.Dal.Src.Domain;
using Server.WatchBody.Core.Dal.Src.Repository;
using Server.WatchBody.Web.Dto;
using CoreProgram = Server.WatchBody.Core.Dal.Src.Domain.Program;
namespace Server.WatchBody.Web.Services;
///
/// POST /api/v1/sync (spec 03-sync-post). Пакет атомарен («все или
/// ничего»): записи применяются внутри TransactionScope — Kit.Helpers
/// открывает соединение на каждый вызов, но Npgsql подключает их к
/// ambient-транзакции (Enlist по умолчанию). Все ЧТЕНИЯ выполняются до
/// открытия scope (select-функции Kit открывают собственную транзакцию —
/// вложенная запрещена); внутри scope — только записи. Идемпотентность —
/// по client_guid (PK uuid); athlete_id всех записей — из middleware
/// (🛡 изоляция).
///
public class SyncService(
IWorkoutRepository workouts,
IWorkoutSetRepository sets,
IExerciseRepository exercises,
IExerciseGroupRepository groups,
IProgramRepository programs,
IProgramVersionRepository versions,
IAthleteProfileRepository profiles,
IDailyLogRepository dailyLogs,
IBreakRepository breaks,
Core.Metrics.MetricsEngine metricsEngine,
Core.Programs.ProgressionRules progressionRules,
Core.Programs.BreakEngine breakEngine,
Microsoft.Extensions.Logging.ILogger logger)
{
private enum CoreWorkoutState { New, Exists }
public List Process(int athleteId, IReadOnlyList items)
{
// пре-проход: чтения и проверки (до транзакции)
var groupIds = groups.Select().ToDictionary(g => g.Code, g => g.Id);
var workoutStates = new Dictionary();
var existingWorkouts = new Dictionary();
var program = programs.Select(athleteId).FirstOrDefault();
var latestVersion = program == null ? 0
: versions.Select(new ProgramVersionFilter { AthleteId = athleteId, ProgramId = program.Id })
.Select(v => v.Version).DefaultIfEmpty(0).Max();
foreach (var item in items)
{
switch (item.Entity)
{
case "workout":
var existing = workouts.Get(athleteId, item.ClientGuid);
workoutStates[item.ClientGuid] = existing == null
? CoreWorkoutState.New
: CoreWorkoutState.Exists;
if (existing != null) existingWorkouts[item.ClientGuid] = existing;
break;
case "exercise":
if (!groupIds.ContainsKey(item.Data.GetProperty("group").GetString()!))
throw new SyncException(
$"неизвестная группа '{item.Data.GetProperty("group").GetString()}'");
break;
case "program_version":
var basedOn = SyncItemParser.IntOrNull(item.Data, "based_on_version");
if (basedOn != null && basedOn != latestVersion)
throw new SyncException("program_version_conflict", 409, "program_version_conflict");
break;
}
}
// транзакция: только записи (scalar-функции); в отдельном методе —
// чтобы scope (и ambient-транзакция) завершились до пересчёта метрик
ApplyAll(athleteId, items, groupIds, existingWorkouts, program?.Id);
// метрики — производные данные: пересчёт после фиксации пакета;
// сбой расчёта логируется, sync остаётся принятым (m04, 🛡4)
var today = DateOnly.FromDateTime(DateTime.UtcNow);
foreach (var workoutId in items
.Where(i => i.Entity == "workout")
.Select(i => i.ClientGuid).Distinct())
{
try
{
metricsEngine.ComputeForWorkout(athleteId, workoutId);
}
catch (Exception ex)
{
logger.LogWarning(ex,
"metrics: расчёт тренировки {WorkoutId} не удался", workoutId);
}
// правила прогрессии/перерывов (m05): тоже после коммита,
// сбой не влияет на ответ sync (module_reqs 🛡5)
try
{
breakEngine.CloseOnReturn(athleteId, workoutId, today);
}
catch (Exception ex)
{
logger.LogWarning(ex,
"rules: возврат после break не удался ({WorkoutId})", workoutId);
}
try
{
progressionRules.EvaluateAfterWorkout(athleteId, workoutId);
}
catch (Exception ex)
{
logger.LogWarning(ex,
"rules: микроправка не удалась ({WorkoutId})", workoutId);
}
}
try
{
breakEngine.DetectBreak(athleteId, today);
}
catch (Exception ex)
{
logger.LogWarning(ex, "rules: детект break не удался");
}
try
{
progressionRules.CheckDeload(athleteId, today);
}
catch (Exception ex)
{
logger.LogWarning(ex, "rules: делоад не удался");
}
return items.Select(i => i.ClientGuid).ToList();
}
private void ApplyAll(
int athleteId, IReadOnlyList items, Dictionary groupIds,
Dictionary existingWorkouts, int? programId)
{
using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
foreach (var item in items)
{
Apply(athleteId, item, groupIds, existingWorkouts, programId);
}
scope.Complete();
}
private void Apply(
int athleteId, SyncItem item, Dictionary groupIds,
Dictionary existingWorkouts, int? programId)
{
var d = item.Data;
switch (item.Entity)
{
case "workout":
ApplyWorkout(athleteId, item.ClientGuid, d, existingWorkouts.GetValueOrDefault(item.ClientGuid));
break;
case "workout_set":
sets.Insert(new WorkoutSet
{
Id = item.ClientGuid,
WorkoutId = d.GetProperty("workout_id").GetGuid(),
AthleteId = athleteId,
ExerciseId = SyncItemParser.StringOrNull(d, "exercise_id") is { } exId ? Guid.Parse(exId) : null,
Seq = d.GetProperty("seq").GetInt32(),
WeightKg = d.GetProperty("weight_kg").GetDecimal(),
Reps = d.GetProperty("reps").GetInt32(),
DoneAt = SyncItemParser.UtcDateOrNull(d, "done_at")!.Value,
Source = d.GetProperty("source").GetString()!,
InPlan = SyncItemParser.BoolOrNull(d, "in_plan") ?? false,
Rpe = SyncItemParser.IntOrNull(d, "rpe"),
KneePain = SyncItemParser.IntOrNull(d, "knee_pain"),
});
break;
case "exercise":
// З-41(а): is_active=false — логическое удаление (Ф-4.2,
// src.exercise_delete; идемпотентно: 0 строк — не ошибка);
// единственный серверный путь установки флага в v1 — им
// пользуется фильтр ограничений m05
if (SyncItemParser.BoolOrNull(d, "is_active") == false)
{
exercises.Delete(athleteId, item.ClientGuid);
}
else
{
exercises.Insert(new Exercise
{
Id = item.ClientGuid,
AthleteId = athleteId,
Name = d.GetProperty("name").GetString()!,
GroupId = groupIds[d.GetProperty("group").GetString()!],
IsBase = SyncItemParser.BoolOrNull(d, "is_base") ?? true,
IsActive = true,
ReplacementIds = [],
});
}
break;
case "program_version":
versions.Insert(athleteId, new ProgramVersion
{
Id = item.ClientGuid,
ProgramId = programId ?? CreateProgram(athleteId),
Version = 0, // номер считает БД (max+1)
BasedOnVersion = SyncItemParser.IntOrNull(d, "based_on_version"),
Status = "draft", // выставит БД
Author = SyncItemParser.StringOrNull(d, "author") ?? "user",
Body = d.GetRawText(),
Rationale = SyncItemParser.StringOrNull(d, "rationale") ?? string.Empty,
});
break;
case "profile":
profiles.Upsert(new AthleteProfile
{
AthleteId = athleteId,
Sex = SyncItemParser.StringOrNull(d, "sex"),
BirthYear = SyncItemParser.IntOrNull(d, "birth_year"),
WeightKg = SyncItemParser.DecimalOrNull(d, "weight_kg"),
HrMax = SyncItemParser.IntOrNull(d, "hr_max"),
});
break;
case "daily_log":
dailyLogs.Upsert(athleteId, new DailyLog
{
Id = item.ClientGuid,
AthleteId = athleteId,
Date = SyncItemParser.DayOrNull(d, "date")!.Value,
BpSys = SyncItemParser.IntOrNull(d, "bp_sys"),
BpDias = SyncItemParser.IntOrNull(d, "bp_dias"),
WeightKg = SyncItemParser.DecimalOrNull(d, "weight_kg"),
HrResting = SyncItemParser.IntOrNull(d, "hr_resting"),
Wellbeing = SyncItemParser.IntOrNull(d, "wellbeing"),
});
break;
case "break":
breaks.Insert(athleteId, new Break
{
Id = item.ClientGuid,
AthleteId = athleteId,
Kind = SyncItemParser.StringOrNull(d, "kind"),
Source = SyncItemParser.StringOrNull(d, "source") ?? "manual",
StartedOn = SyncItemParser.DayOrNull(d, "started_on")!.Value,
PlannedReturn = SyncItemParser.DayOrNull(d, "planned_return"),
Note = SyncItemParser.StringOrNull(d, "note") ?? string.Empty,
});
break;
default:
throw new SyncException($"неизвестная entity '{item.Entity}'");
}
}
private void ApplyWorkout(int athleteId, Guid workoutId, JsonElement d, Workout? existingRow)
{
workouts.Insert(athleteId, new Workout
{
Id = workoutId,
AthleteId = athleteId,
ProgramVersionId = SyncItemParser.StringOrNull(d, "program_version_id") is { } pv
? Guid.Parse(pv)
: null,
StartedAt = SyncItemParser.UtcDateOrNull(d, "started_at")!.Value,
Status = "active",
SessionKind = SyncItemParser.StringOrNull(d, "session_kind") ?? "normal",
Sets = [],
Commands = [],
});
var endedAt = SyncItemParser.UtcDateOrNull(d, "ended_at");
var status = SyncItemParser.StringOrNull(d, "status");
var avgHr = SyncItemParser.IntOrNull(d, "avg_hr");
if (endedAt == null && status == null && avgHr == null) return;
// дозакрытие: update по всем полям; факты .fit существуют только у
// уже существовавшей строки — их сохраняем (чтение сделано до scope)
var existing = existingRow;
Guid? programVersionId = SyncItemParser.StringOrNull(d, "program_version_id") is { } pvText
? Guid.Parse(pvText) : null;
var current = new Workout
{
Id = workoutId,
AthleteId = athleteId,
ProgramVersionId = programVersionId,
StartedAt = SyncItemParser.UtcDateOrNull(d, "started_at")!.Value,
Status = status ?? "active",
SessionKind = SyncItemParser.StringOrNull(d, "session_kind") ?? "normal",
EndedAt = endedAt,
AvgHr = avgHr,
FactsJson = existing?.FactsJson,
FitFileId = existing?.FitFileId,
HrRecoveryS = existing?.HrRecoveryS,
Sets = [],
Commands = [],
};
workouts.Update(athleteId, current);
}
private int CreateProgram(int athleteId) =>
// программа создаётся первой версией из sync
programs.Insert(new CoreProgram { AthleteId = athleteId, Title = "Программа" });
}